The third commit of phase 2. The Rust transaction now also runs
`_update_metadata_tables_txn`, apart from the event cache prefill at its
end (`_add_to_cache`), which stays in Python:
- `txn/metadata.rs`: push actions move from the staging table, and the
chunk's staging rows go. A redaction removes the push actions and the
relation of the event it redacts, and moves or deletes its thread.
Then search (with the plain text topic from `m.topic`), redactions,
retention, relations and threads, labels, expiry, and room members.
An out-of-band membership of a local user also updates
`local_current_membership` and the user's sliding sync snapshot, with
the stripped state of an invite or knock.
- `txn/edges.rs`: `event_edges` with the backward extremities, and the
MSC4242 state DAG edges.
- `txn/caches.rs`: `_invalidate_cache_and_stream`. The rows go into
`cache_invalidation_stream_by_instance` inside the transaction, with
IDs from the cache stream's tracker. The committed attempt hands its
IDs to Python in `PersistEffects.cache_stream_ids`. Python finishes
them after invalidating the caches, as `get_next_txn` does. A failed
attempt's IDs are finished when its steps fail, or for a failed
commit, by the next attempt or when the persist ends. A fetch of an ID
that is dropped before the sequence answers still ends, so a cancelled
persist cannot hold the stream's position back.
Where Python and Rust differ, the input is malformed:
- Python stores labels from any value it can iterate, and non-string
labels. Rust stores only string labels from a list.
- An integer lifetime or expiry beyond `u64` is a float to serde, so
Rust ignores it where Python's insert fails. Within `u64` but beyond
`bigint`, both fail.
The persister reads `enable_search` and `enable_ephemeral_messages`
from the server config. `persist_events_txn_for_test` takes
`inhibit_local_membership_updates`.
New twin scenarios: each kind of relation and its redaction, which
moves the thread back and then deletes it, a relation redacted in its
own chunk, two thread replies in one chunk, search, retention (also two
policies in one chunk), labels and expiry, member rows, push actions and
their redaction, an out-of-band invite and its rejection, a knock, and a
backfilled invite. The out-of-band ones are skipped in state DAG rooms,
as a hand-built event has no state DAG context. A redaction before its
target is now stored with a recheck still needed.
The test harness compared `event_edges` by its `room_id` column, which
is no longer filled in, so it compared no rows. It now compares by
`event_id`. It also names the cache behind each invalidation, compares
the cache stream rows' times, and finishes the cache stream IDs Rust
hands back. Fifteen mutants of the new steps each fail the comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXMkcZVkcZhi21augBff4a
The second commit of phase 2. The Rust transaction now runs the steps of
`_persist_events_txn` from `_update_room_depths_txn` up to
`_store_rejected_events_txn`:
- `txn/events.rs`: the room's depth and its place in the events stream
cache, de-outliering, redactions that already apply (the event is
stored pruned) and the earlier events the chunk's redactions confirm
(they stop being sticky, and a passed recheck is stored), the
`event_json`, `events` and `state_events` rows, transaction IDs, state
groups and partial state, the chain cover index and `event_auth`, and
rejections.
- `txn/edges.rs`: the forward and state DAG extremities, and the
backward extremities and timeline gaps of a de-outliered event.
`_handle_mult_prev_events` will use the latter for new events.
- `txn/json.rs`: JSON as `json_encoder` writes it, with non-ASCII and
DEL escaped. The event and its internal metadata keep the key order
`get_dict_for_persistence()` and `get_dict()` give, so the `event_json`
text is Python's byte for byte. Only number text from federation can
differ (`1.50` against Python's `1.5`).
An event with partial state in a room that is no longer partial stated
fails with `PartialStateConflictError`, as in Python.
`Persister.persist_events_txn_for_test` takes the chunk Rust prepared,
which `PreparedChunk` now shares through an `Arc`. It returns which
events were stored redacted and which were de-outliered, so the twin
test's Python half can carry on from there. Making a de-outliered event
sticky stays in Python until the sticky commit, as it needs the sticky
stream's ID generator. Python makes those sticky before deleting
redacted events' sticky rows, so the sticky commit has to insert them
inside `update_outliers`.
New twin scenarios: transaction IDs, URLs and non-ASCII text, a
redaction recheck (alone and in a chunk with its target), a partial
state conflict, a redacted sticky event, and a redaction of a later
redaction, which needs censoring again, and an event de-outliered while
its prev event is an outlier (a backward extremity and a timeline gap).
The test checks that room B's transaction takes the chunk prepared for
it. The fork scenario now advances
the clock between its topics, as state resolution otherwise breaks the
tie by event ID, which differs between the rooms. Each ID generator's
stream is ranked separately. Nineteen mutants of the new steps each
fail the comparison. `PersistenceJsonTestCase` compares the `event_json`
text byte for byte.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXMkcZVkcZhi21augBff4a
The first commit of phase 2 of the event persistence plan
(`transaction` level). `rust/src/persist/txn/` will hold the port of
`_persist_events_txn`. This commit adds its driver: the share lock on
the room's `rooms` row, the cache updates Python queues for every event
up front, and dropping duplicate events. It returns the events left for
the steps still in Python.
`tests/storage/test_rust_persist_txn.py` builds the same DAG in two
rooms. Room A persists in Python. Room B prepares in Rust, runs the
Rust part of the transaction in its own committed transaction through
`Persister.persist_events_txn_for_test`, and then runs the Python steps
after it. The test compares the rows of every table the transaction
writes, after normalising room and event IDs, stream positions, state
groups and chain IDs, and compares the cache updates both rooms asked
for. Each later commit moves more steps from the Python half to the
Rust half, in transaction order, until the facade runs the whole
transaction. Mutating the driver (dropping the redaction
invalidation, the relation, the state key, or the outlier rule for
duplicates) fails the comparison.
Two open items from phase 1:
- Reads before the transaction now retry. Python runs each read in a
`runInteraction` that retries a lost connection or a cancelled
statement. `Persister.prepare_chunk` now prepares the whole chunk
again on such an error, up to five attempts. That is safe because its
only write, bumping the state groups it uses, can be repeated. The
reader and the state resolution store keep the pool's error as the
cause, so `is_retryable_error` can classify it. A `WriteError` does
not count, as its write interaction has already retried, and
`WriteError::from_pg` shares the classification.
- The Rust persister needs the state tables in the main database, as
it reads and writes both in one transaction. `Persister::for_homeserver`
now checks for that, and the persister is off when they are apart.
Also fixed in the dispatch: at `off`, `_prepare_chunk` ran Rust when a
persister existed. That only happens when a test changes the level at
runtime. The twin test does exactly that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXMkcZVkcZhi21augBff4a
`Persister.prepare_chunk` runs the steps of the previous commits for a
chunk: the extremities and state delta, then the sliding sync table
changes and sticky events, then the chain cover index. The chain cover
step comes last as the only one that writes, so a failure in an earlier
step leaves nothing for Python to find. Sliding sync and sticky events
now come before the stream orderings are allocated, as nothing there
reads them. Each step reads the clock when it needs the time.
`_persist_event_batch` dispatches on `rust_event_persistence`:
- `shadow`: Rust runs first with its chain cover write rolled back, then
Python. The results are compared and Python's are used. The comparison
ignores orders Python leaves to chance and the IDs of new chains. Both
rejecting the chunk with the same error is a match.
- `prepare` and above: Rust's results are used. A `SynapseError` is
raised as Python would raise it. Python computes the chunk when Rust
cannot resolve state in the room, or on any other Rust error.
Trial runs strict by default (`SYNAPSE_TEST_RUST_PERSIST_STRICT`), so a
mismatch or Rust error fails the test. Rust's counts and state res stats
are recorded by Python once it uses the result.
At `prepare`, the `_calculate_state_and_extrem` and
`calculate_chain_cover_index_for_events` timings give way to
`rust_prepare_chunk`. Python's state resolution and state group caches
are not filled, so creating a room costs one more transaction
(`RoomsCreateTestCase` counts it) and the first local event after a
fork resolves state again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXMkcZVkcZhi21augBff4a
Port `compute_sticky_events_to_un_soft_fail` and
`_get_soft_failed_sticky_events_to_recheck` to
`rust/src/persist/sticky.rs`. The reads go through a `StickyReads`
trait over the persist reader. Events to recheck and auth events not in
the chunk come from the Rust event loader, redacted and without
rejected events, as `get_events` returns them.
An auth failure keeps an event soft failed. A malformed event fails the
step, as Python raises it as something other than `AuthError`.
`persist_error_to_py` now raises an `AuthFailure` as the auth rules
raise it.
`StickyTestCase` compares the two over the same database, chunk and
delta.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHKYPSc37kWqGACn9aKxKL
Port `_get_new_state_after_events`, `_calculate_state_delta`,
`_is_server_still_joined` and the two steps that call them with the
extremity calculations, `_calculate_new_forward_extremities_and_state_delta`
and `_process_state_dag_forward_extremities_and_state_delta`, to
`rust/src/persist/state_delta.rs`. Reads go through a `StateDeltaReads`
trait on top of `ExtremityReads`, and state resolution through a
`StateResolver`, so the unit tests can fake both.
`NativeStateResolver` checks and bumps the input state groups as
`check_state_groups_and_bump_deletion` does, then resolves with the Rust
state resolution on the native pool, through `resolve_on_pool`, which is
split out of `StateResolutionHandler::start_converted`. The persister
shares the handler's conflict cache. There is no `_state_cache` of whole
resolutions. Rooms on state resolution v1 or without a chain cover index
fail with `StateResFallback`, for the caller to run the Python step.
`NewEvent` gains the fields the state delta reads from an event and its
context. `PersistReaderForTest` gains the five calculations, and
`test_persist_reader.py` compares each with the Python method on events
built by the event creation handler, including a resolved fork and an
MSC4242 room.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHKYPSc37kWqGACn9aKxKL
Port `_calculate_new_extremities`, `_calculate_new_state_dag_extremities`
and `_prune_extremities` from the persist events controller to
`rust/src/persist/extremities.rs`. They read through an `ExtremityReads`
trait, which the `PersistReader` implements and the unit tests implement
over an in-memory DAG. Metrics go to a `PersistCounts` the caller owns,
so shadow mode can drop them.
Missing prev state events fail with `MissingPrevStateEvents`, which
`persist_error_to_py` turns into the same 500 `SynapseError` as Python.
`PersistReaderForTest` gains the three calculations, and
`test_persist_reader.py` compares each with the Python method on the
same database and the same new events.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHKYPSc37kWqGACn9aKxKL
`PersistReader` wraps a `SyncReader` and adds the reads the persist
controller makes before its write transaction: forward and state DAG
extremities, the prev event walks of `_get_events_which_are_prevs` and
`_get_prevs_before_rejected`, `have_seen_events`, memberships by event ID,
`is_local_host_in_room_ignoring_users`, the room version, the sliding sync
joined room check, `get_last_event_pos_in_room` and the soft failed sticky
events. None of them is cached. Each runs once per chunk.
The chain cover reads are free functions over a `Querier`, not reader
methods. The chain cover step reads, allocates chain IDs and writes in one
transaction, so they must run on that transaction's connection. To allow
that, `WriteTxn` now implements `Querier`. A `WriteError` that passes
through `anyhow` on the way out comes back as itself, so a conflict is still
retried.
The persister builds its own `SyncStore` over the shared caches.
`tests/storage/test_persist_reader.py` runs each read against the Python
method or statement it ports, on the native driver.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHKYPSc37kWqGACn9aKxKL
Phase 0 of the event persistence plan, apart from the write transaction
helper:
- `experimental_features.rust_event_persistence`, one ordered level
(off, shadow, prepare, transaction, full). Nothing uses the levels yet
beyond building the persister.
- `RustHandlers.persister`: built on the events writer when the level is not
off and the main database uses the native driver. It holds the pool, the
shared caches and the events, backfill and cache stream trackers.
- `PersistEffects`, the Python cache updates a Rust transaction will ask for
in place of `txn.call_after`, applied in one call by
`DataStore._apply_rust_persist_effects`. A test persists batches through
Python and checks that applying the matching effects updates the same
caches. The chunk's stream IDs come back unfinished and are finished last,
as Python's `get_next_mult` does after the transaction's callbacks, so
the current token never passes an event the stream change caches don't
know about.
- `PersistContext`, read from an `EventContext` once per event.
- `Event::to_persistence_value` and `EventInternalMetadata::to_persistence_value`:
the `event_json` columns built without the GIL.
- Rust-side persistence counters, added to the Python metrics by a pre-collect
hook. A homeserver can now register more than one such hook. The persisted
position is reported only when it moved, so it can't pull back the gauge
the Python path sets.
- `SYNAPSE_TEST_RUST_PERSIST` for trial and `RUST_PERSIST` for Complement.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHKYPSc37kWqGACn9aKxKL