Commit Graph
249 Commits
Author SHA1 Message Date
Erik JohnstonandClaude Opus 5.5 1452faf192 Recalculate a room's current state in Rust at rust_event_persistence: full
`_update_current_state`, the queue's `_UpdateCurrentStateTask`, becomes
one call to `Persister.update_current_state`:

- `calculate_current_state` reads the room's forward extremities (its
  state DAG extremities in an MSC4242 room) and takes the state of
  their one state group, or resolves several, after checking the groups
  still exist and putting off their deletion, as `resolve_state_groups`
  does.
- The delta from the current state and the sliding sync table changes
  are computed as for a chunk, with no events.
- If anything changed, one stream ID is taken from the events stream,
  as `get_next` does, and `_update_current_state_txn` runs in Rust.
  Python applies the effects, which finishes the ID.

A room on state resolution v1 whose extremities need resolving is handed
back, and Python recalculates its state as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXMkcZVkcZhi21augBff4a
2026-10-18 10:35:53 +00:00
Erik JohnstonandClaude Opus 5.5 fd8c19ed2c Run what follows each chunk's write in Rust at rust_event_persistence: full
`_after_persisting_events` moves into the batch step, after the write
and before Python applies the chunk and finishes its stream orderings,
as it ran inside `get_next_mult` in Python:

- The persisted events, by type and origin, and the persisted position
  go to the Rust metrics that `flush_rust_persist_metrics` reports.
- With MSC4293, a join, or a leave by someone else, ends the redaction
  of the target's events. A ban or kick with `redact_events` set, by a
  sender who may redact (`_can_sender_redact`, read from the current
  state), starts it. Python then drops the room's events from its event
  cache, through a new `event_cache_room_invalidations` effect, even if
  a later event of the chunk fails, as Python drops them right after
  each start.
- The forward extremities prefill moves into the effects.

The MSC4293 flag goes with each batch, as Python reads it per chunk and
tests change it at runtime.

If what follows the write fails, the chunk is still written, so Python
applies its effects and finishes its orderings as a failure, as
`get_next_mult` does when its block raises, then raises the error and
releases the batch's state groups as a failed persist does.

Where Python would raise on odd power levels or memberships, Rust is
more lenient: it reads a level that is a string of an integer as that
number (Python compares two strings, or raises), a null default as 0
and a `users` or `events` that is not an object as missing, and skips a
member event without a membership. Numbers, floats and bools compare as
in Python.

The chunk path, for chunks Rust hands back, keeps the Python block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXMkcZVkcZhi21augBff4a
2026-10-17 23:41:30 +00:00
Erik JohnstonandClaude Opus 5.5 ea11b6bb2d Persist each queued batch in Rust at rust_event_persistence: full
At `full`, `_persist_event_batch` hands the batch to
`Persister.persist_batch` and steps through it with `batch_next`:

- The first step drops the events already persisted under the same
  transaction ID (`get_already_persisted_events`).
- Each step prepares the next chunk of up to 100 events, marks the
  batch's state groups as being persisted
  (`persisting_state_group_references`, two short native
  transactions), takes the chunk's stream orderings and writes it.
- Python applies what the chunk asks for, runs
  `_after_persisting_events` and finishes the orderings before the next
  step, which first releases the state groups. So each chunk is
  prepared against current caches.
- If Python cannot apply a chunk, `batch_abort` releases the state
  groups as a failed persist does.

A chunk Rust cannot prepare (a state resolution fallback, or an error)
is handed back with the rest of the batch to the chunk-by-chunk path,
which prepares it again and falls back or raises as at lower levels.

Python drives the steps, rather than Rust calling back into Python, so
every step has a `_blocking` twin for tests and nothing waits on the
reactor from Rust. That costs the same hops as a callback: one in and
one out per chunk.

The prepare inputs are now built from `Event`s that share their data
with the Python events, so a chunk's input can be built without the
GIL once the batch has been deduplicated.

The persister's Deferreds already follow the logcontext rules, so they
are awaited directly. Wrapping them in `make_deferred_yieldable` again,
as the `prepare_chunk`, `persist_events_txn` and `persist_chunk` call
sites did, resumed the caller in the sentinel context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXMkcZVkcZhi21augBff4a
2026-10-17 23:16:13 +00:00
Erik JohnstonandClaude Opus 5.5 68cc9e1b64 Take the event stream orderings in Rust at rust_event_persistence: full
At `full`, `Persister.persist_chunk` takes the chunk's stream orderings
from the events or backfill stream tracker, which Python's
`_stream_id_gen` and `_backfill_id_gen` share. It runs `nextval` in
autocommit after prepare, as late as `get_next_mult` does, and sets each
event's stream ordering, received time and instance before the write
transaction. Backfilled events take theirs negated.

The orderings come back unfinished in `PersistEffects.stream_ids`.
`_persist_events_and_state_updates` skips `get_next_mult` when Rust takes
them. Its post-transaction block (metrics, MSC4293
`room_ban_redactions`, the extremities prefill) moves to
`_after_persisting_events`, which both paths call. At `full` it runs
after the effects are applied and before the orderings are finished, as
it ran inside `get_next_mult`. So a ban that redacts a user's events is
recorded before other workers see it.

`finish_rust_persist_stream_ids` finishes the orderings as
`get_next_mult` does on exit: it tells replication, and on success moves
`stream_positions` on. If the transaction fails, Rust finishes them. If
the apply or the follow-ups raise, Python finishes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXMkcZVkcZhi21augBff4a
2026-10-17 22:11:41 +00:00
Erik JohnstonandClaude Opus 5.5 7698ca7e78 Record profile updates in the Rust persist transaction
The seventh commit of phase 2, which completes it. The Rust transaction
now covers the whole of `_persist_events_txn`, so at the `transaction`
level every chunk Rust prepared is written in Rust, including on a
server with `include_profile_updates_in_sync`.

- `txn/profile.rs` ports `_record_profile_updates_for_state_delta_txn`
  and the helpers it calls: the joined room updates of local members
  who joined, and the left room updates, both ways, of members who left
  or were state reset out of the room, with the deletes of the rows they
  replace. It runs at the end of `update_current_state`, as in Python.
- Its rows take IDs from the profile updates stream inside the
  transaction, through a third `TxnStream` over the tracker of
  `_profile_updates_id_gen`. `PersistEffects.profile_stream_ids` hands
  them to the applier, which finishes them as `get_next_mult_txn` does,
  as it does the sticky events stream's. The two share
  `_finish_txn_stream_ids`.
- The controller no longer falls back to Python on a server that
  records profile updates.
- The Python helpers take the members in user ID order where they took
  a set's order, so both sides take the stream IDs in the same order.
- `Persister.persist_events_txn` resolves with the effects alone. The
  events left and the redacted copies were only for the Python steps
  after Rust's in the twin test, which are gone.

The twin test compares the profile updates rows each twin's persists
wrote, and the rows of each twin's own users from outside them, which a
leave deletes. Those are compared without `inserted_ts`, as each twin
writes them at a different time. New scenarios, with members of each
twin's own, cover:

- joins, a change of display name, and leaves
- several members in one chunk
- a member state reset out of the room
- a remote member state reset out of it, which writes nothing, as only
  local members have membership snapshots
- a leave while still sharing another room with some members, and with
  all of them
- a leave deleting the rows about earlier profile changes
- the last local member leaving

Making the Rust shared room query match nothing, dropping the reverse
left room rows, or skipping the delete of the leaver's rows about the
others, fails the scenarios that cover it. The
prereqs test now checks that a server recording profile updates writes
the rows in Rust and finishes the stream IDs.

These suites pass at `SYNAPSE_TEST_RUST_PERSIST=transaction` on the
native driver: `tests.handlers` (including the profile and sync tests
that record profile updates), `tests.federation`, `tests.replication`,
`tests.rest.client.sliding_sync` (including the profiles extension),
the phase 2 storage suites, and the `tests.rest.client` group but for
the two `test_rooms` tests that count database transactions, which fail
the same way at `prepare`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXMkcZVkcZhi21augBff4a
2026-10-17 21:14:35 +00:00
Erik JohnstonandClaude Opus 5.5 023028fe05 Write chunks in Rust at rust_event_persistence: transaction
The sixth commit of phase 2. At the `transaction` level, a chunk Rust
prepared is written by the Rust transaction in place of
`_persist_events_txn`.

- `Persister.persist_events_txn`, with a `_blocking` twin for tests,
  runs the transaction over the chunk and resolves with its effects.
  The test-only entry it replaces shared its body.
- `_persist_events_and_state_updates` takes a `RustTransaction` (the
  persister and the prepared chunk). With one, it calls Rust inside the
  same stream ordering allocation, then applies the effects, so the
  event stream IDs are finished after them, as before. The call and
  the apply run together under `delay_cancellation`, as
  `runInteraction` runs its callbacks: a transaction that commits must
  have its effects applied, or the cache and sticky events stream IDs it
  took are never finished.
- The controller passes one when the level is `transaction` or later
  and Rust prepared the chunk. A chunk Python prepared, after a state
  resolution fallback, is written in Python. So is every chunk on a
  server with `include_profile_updates_in_sync`, as the Rust
  transaction leaves the profile updates to Python.
- `_add_to_cache` moves to Rust: the transaction picks the events
  neither rejected nor redacted, in
  `PersistEffects.event_cache_prefills`, and the applier puts the events
  Python holds into the event cache. Of a duplicated event, it puts the
  copy that was written, the earliest non-outlier, as `_add_to_cache`
  reads the deduplicated chunk.
- The applier is now `apply_rust_persist_effects(store, ...)` in
  `synapse/storage/databases/main/rust_persist.py`, as the event
  persister may be a worker, whose main store is a `GenericWorkerStore`.
  A mixin on both stores cannot be linearised into `DataStore`. If a
  step raises, it still finishes the cache and sticky events stream IDs
  it has not finished, as `call_on_exception` does in Python.

The twin test compares the events each side puts in the event cache,
and checks which copy of a duplicated event goes in. New prereqs tests
check:

- that Rust writes the chunk, with Python's transaction made to fail,
  and the applier fills the event cache
- that a lowered level, or a server that records profile updates,
  writes in Python a chunk Rust prepared
- that the transaction takes the GIL at no persist site
- that cancelling the persist mid-transaction waits for it and applies
  the effects
- that a worker event persister writes in Rust to the main database

The Rust transaction does not yet record the `sql_txn_*` metrics, the
log context's transaction counts or the `db.persist_events` span, as
the native driver's write path records none of them.

These suites pass at `SYNAPSE_TEST_RUST_PERSIST=transaction` on the
native driver: `tests.handlers`, `tests.federation`, `tests.replication`
(sharded event persisters included), `tests.rest.client.sliding_sync`,
the storage suites in the phase 2 plan, and `test_sync`,
`test_relations`, `test_redactions`, `test_sticky_events`,
`test_profile`, `test_retention` and `test_rooms` in
`tests.rest.client`, but for two `test_rooms` tests that count database
transactions. Those fail the same way at `prepare`: Rust's prepare step
takes one more transaction per room creation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXMkcZVkcZhi21augBff4a
2026-10-17 19:41:34 +00:00
Erik JohnstonandClaude Opus 5.5 c4af646bdf Read event contexts that came from another worker in Rust
A context sent to the event persister over replication holds its state
maps as `immutabledict`s, which Rust's persist context conversion
refused, as it took only a `dict`. So on a deployment with workers,
preparing a chunk in Rust failed for every event another worker sent,
and fell back to Python with an error logged. It now takes any mapping.

Two worker tests with the native driver showed it, and then showed a
test harness problem. `make_worker_hs` points a worker's Python pool at
the master's database once the worker is built, but the worker's Rust
handlers keep the native pool they were built with, on the worker's own
database. It now points that pool at the master's database too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXMkcZVkcZhi21augBff4a
2026-10-17 17:46:41 +00:00
Erik JohnstonandClaude Opus 5.5 aecc892ea3 Persist sticky events and sliding sync bump stamps in Rust
The fifth commit of phase 2. The Rust transaction now runs the rest of
`_persist_events_txn`, but for the event cache prefill (`_add_to_cache`)
and the profile updates, which stay in Python.

- `txn/sticky.rs`: `insert_sticky_events_txn` for the chunk's new
  events, and `un_soft_fail_sticky_events_txn` for the events
  `compute_sticky_events_to_un_soft_fail` found in prepare. A
  de-outliered sticky event becomes sticky inside `update_outliers`, as
  in Python, so the chunk's redactions come after it. As in Python, an
  event with a zero sticky duration is not tried there.
- `txn/sliding_sync.rs`: the latest stream ordering and bump stamp of
  the room's `sliding_sync_joined_rooms` row.
- `txn/streams.rs`: what was `CacheStream`, now `TxnStream` for any
  stream the transaction takes IDs from, several at a time. The sticky
  events stream is the second. Its IDs come back unfinished in
  `PersistEffects.sticky_stream_ids`. Python finishes them once the
  caches are invalidated, notifies replication and, as the stream has
  writers, updates `stream_positions`, as `get_next_mult_txn`'s
  callbacks do. A failed attempt's IDs are finished as for the cache
  stream.

`persist_events_txn` returns the events left. The test entry no longer
returns the de-outliered events.

Python's `un_soft_fail_sticky_events_txn` gave the events their new
stream IDs in a set's order. It now keeps their order in the stream, by
`event_stream_ordering`, with any event without a row last, by event ID.
Rust does the same, which lets the twin test compare several events
lifted at once.

The new scenarios found a Python bug, which Rust keeps. A de-outliered
sticky event's `sticky_events` row has the stream ordering of the chunk
that de-outliered it, but its `events` row keeps the one it was first
persisted with. `delete_sticky_events_txn` looks for the latter, so a
redaction of the event, in that chunk or any later one, leaves it
sticky until it expires.

The twin harness:

- records the stream IDs the applier finishes and the notifiers it
  calls, rather than modelling them from the effects
- merges each persist's finished IDs per stream into one call, and
  compares its notifiers by whether they were called. Python queues
  them per allocation of IDs, and the effects do each once.
- names a background process by what it runs: an ID generator's
  `stream_positions` update by its stream, any other by its description

New twin scenarios:

- sticky events in one chunk, one of them expired
- a sticky outlier, de-outliered; de-outliered and redacted in one
  chunk; de-outliered with a new sticky event in one chunk
- soft-failed sticky events lifted when their sender joins, one and
  three at once
- a rejected sticky event, a backfilled one, and one redacted in its
  own chunk

A prereqs test checks that applying the effects finishes the sticky
events stream IDs after the event cache invalidations, then notifies,
and updates `stream_positions`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXMkcZVkcZhi21augBff4a
2026-10-17 16:51:28 +00:00
Erik JohnstonandClaude Opus 5.5 d4e5888439 Update the current state in the Rust persist transaction
The fourth commit of phase 2. The Rust transaction now also runs
`_update_current_state_txn`, apart from the profile updates at its end.

- `txn/current_state.rs`: the delta stream rows, read before the current
  state changes, then `current_state_events` and the state columns of
  `sliding_sync_joined_rooms`. When the server leaves the room, the room
  version is recorded and the room's current state goes instead.
  `local_current_membership` and the membership snapshots follow either
  way. The effects move the current state delta and membership stream
  change caches on, and invalidate the state caches. The `cs_cache_fake`
  rows name 50 members each. Remote members who share no room with the
  server any more have their device lists unsubscribed.
- `txn/caches.rs`: `_send_invalidation_to_replication`, the cache stream
  row without the local invalidation.

The profile updates take IDs from the `profile_updates` stream, which
has writers and so a `stream_positions` row. They move to
`_record_profile_updates_for_state_delta_txn` in Python, which the twin
test runs after Rust, and are left for a later commit. The twin test
cannot compare their rows, as the twins share the profile tables. The
profile suites cover the moved code.

Rust differs from Python in these places:

- Rust writes the members of the `cs_cache_fake` rows, and the device
  list rows of the remote users who left, in sorted order. Python's
  follow a set's.
- A create event whose `room_version` is not a string, null included,
  or whose `creator` is neither a string nor null, fails the persist
  when the server leaves. Python stores a number cast to text or a
  NULL, and fails on an object. Event auth requires a string
  `room_version`, so only old or corrupt data can differ.

The room version upsert has a Postgres test on temporary tables. The
twin test cannot see it, as the `rooms` row always holds the version
already.

The twin harness now applies Rust's effects with
`_apply_rust_persist_effects`, as the facade will, so the steps after
Rust's see the caches Rust invalidated. Some of what Python writes from
a set, it compares in any order:

- sets in effects, as sorted tuples
- a run of `cs_cache_fake` rows, as its members and its row sizes
- a run of single-key cache stream rows of one cache, as its keys
- an event's `auth_events`, where the stored JSON matches its content
  hash, which covers their order. The hand-built events are rehashed.

It compares `device_lists_remote_extremeties` on each twin's own remote
users.

New twin scenarios:

- the sliding sync state columns (encryption, name, tombstone and a
  leave)
- the server leaving the room, with one or two remote members still
  joined
- remote members and their device lists
- 60 members changing at once
- a state key, and a local member's invite with its snapshot, removed
  by state resolution

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXMkcZVkcZhi21augBff4a
2026-10-17 15:35:31 +00:00
Erik JohnstonandClaude Opus 5.5 41c7808711 Store the event metadata in the Rust persist transaction
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
2026-10-17 14:28:54 +00:00
Erik JohnstonandClaude Opus 5.5 b5216c757b Store the events themselves in the Rust persist transaction
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
2026-10-17 13:29:57 +00:00
Erik JohnstonandClaude Opus 5.5 e4e22f63e5 Start the Rust persist transaction, with a twin-room test
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
2026-10-17 11:42:25 +00:00
Erik JohnstonandClaude Opus 5.5 940c43b8fd Prepare chunks in Rust at the shadow and prepare levels
`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
2026-10-17 10:45:55 +00:00
Erik JohnstonandClaude Opus 5.5 94c9194327 Calculate the sticky events to un-soft-fail in Rust
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
2026-10-17 10:30:33 +00:00
Erik JohnstonandClaude Opus 5.5 d087705a72 Calculate the sliding sync table changes in Rust
Port `_calculate_sliding_sync_table_changes` and
`_get_sliding_sync_insert_values_from_state_map` to
`rust/src/persist/sliding_sync.rs`. The reads go through a
`SlidingSyncReads` trait over the persist reader. State events that are
not in the chunk come from the Rust event loader, redacted and without
rejected events, as `get_events` returns them.

The membership snapshots to insert are sorted by user ID. Python lists
them in the order of the delta.

The stripped state helper only runs inside the write transaction, for
out-of-band memberships, so it moves to phase 2.

`SlidingSyncTestCase` 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
2026-10-16 18:07:53 +00:00
Erik JohnstonandClaude Opus 5.5 7f6b56d1f8 Calculate the chain cover index in Rust
Port `calculate_chain_cover_index_for_events` and the class methods it
calls from `PersistEventsStore` to `rust/src/persist/chain_cover.rs`,
with `_LinkMap` and `sorted_topologically`. As in Python, the step runs
in a write transaction of its own. It allocates chain IDs from the
`event_auth_chain_id` sequence and puts off events whose auth events are
missing, but does not write the index.

The reads and writes go through a `ChainCoverStore` trait, which
`WriteTxn` implements and the unit tests implement in memory. The step
takes a `ChainCoverEvent` view that reads the auth events up front but
fails only when they are used. An MSC4242 event can't give its auth
events before auth has run, and Python reads them only for events not
yet in the index.

The result matches Python's except in two orders Python leaves to
chance: which new chain gets which ID, and the order of an event's
links. The redundant-link reduction stays quadratic, as in Python.

`PersistReaderForTest` gains the step, and `ChainCoverTestCase` compares
it with Python on the same database. New chains are compared by the
lowest event ID on them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHKYPSc37kWqGACn9aKxKL
2026-10-16 17:28:33 +00:00
Erik JohnstonandClaude Opus 5.5 e86fce4dea Calculate the state delta in Rust
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
2026-10-16 16:36:34 +00:00
Erik JohnstonandClaude Opus 5.5 a737c2c97f Calculate the forward extremities in Rust
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
2026-10-16 15:51:22 +00:00
Erik JohnstonandClaude Opus 5.5 1afdeb20df Add the reads persisting events needs, in Rust
`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
2026-10-16 14:08:18 +00:00
Erik JohnstonandClaude Opus 5.5 419c47e91d Add a write transaction helper for the native pool
`run_write_interaction` is the Rust `runInteraction` for writes: a
`REPEATABLE READ` transaction, committed at the end, and run again from the
start on a serialization failure, a deadlock, a cancelled statement or a
lost connection, up to five attempts as in Python. Constraint violations come
back as their own error, so an expected conflict can be told apart.

Once a statement fails, Postgres has aborted the transaction, and a COMMIT
would roll back without an error. So if the closure catches a statement
error and returns success, the commit is refused with an error.

`WriteTxn::insert_many` and `upsert_many` write a batch in one statement
whatever its size, passing each column as an array to `UNNEST`.
`execute_batch` pipelines a per-row statement for the inserts that need a
sub-select per row.

The tests run against a real server when `SYNAPSE_TEST_POSTGRES_DSN` is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHKYPSc37kWqGACn9aKxKL
2026-10-16 13:06:06 +00:00
Erik JohnstonandClaude Opus 5.5 a05a064643 Add the Rust event persister and what it builds on
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
2026-10-16 13:06:06 +00:00
Erik JohnstonandClaude Opus 5.5 3bb1fe675b Move the native pool wrapper out of the federation queues
The event persister will hold the Python `RustConnectionPool` the same way.
The GIL site it reads the pool under is now a parameter, so each user is
counted under its own site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHKYPSc37kWqGACn9aKxKL
2026-10-16 13:06:06 +00:00
Erik JohnstonandClaude Opus 5.5 21d19056d0 Move the multi-writer stream ID bookkeeping to Rust
`MultiWriterIdGenerator` tracks in memory which IDs this instance has
allocated and not finished, and each writer's position, and derives the
current token from them. That state now lives in a Rust `StreamIdTracker`,
so that Rust code can allocate and finish stream IDs without the GIL. The
generator keeps what touches the database or the reactor: loading the start
positions, the sequence, the `stream_positions` table and notifying
replication.

The logic is a line-by-line port. Two changes in form: the in-flight fetches
are a counted map (they were a sorted multiset), and the position gauge reads
the tracker when scraped instead of being set on each change. The issue 19795
debug line is logged after the lock is released, since logging can take the
GIL.

One behaviour change, a fix: on restart, the loader sorted the rows past
`stream_positions` by their stored ID before negating them. On the backfill
stream that visited our own rows furthest first, so our position ended at
the nearest of them. The rows are now negated first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHKYPSc37kWqGACn9aKxKL
2026-10-16 13:06:06 +00:00
Erik JohnstonandClaude Opus 5.5 18985a084c Move the GIL accounting out of the federation module
Event persistence will take the GIL from tokio too, and should be counted the
same way. Its sites are named with a `persist_` prefix and reported by
`persist_gil_stats`; `federation_gil_stats` reports the rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHKYPSc37kWqGACn9aKxKL
2026-10-16 09:58:38 +00:00
Erik JohnstonandClaude Opus 5.5 a78b02b55c Load events in Rust for Python too, on the native driver
With the main database on the native Postgres driver, every event load
goes through the Rust `EventLoader`. `EventsWorkerStore` sends the events
its own lookups miss to `RustStore.load_events`, and `_get_events_from_db`
to `reload_events`. Loads from Python, sync and state resolution then
coalesce in the shared event cache, and an invalidation during a load
stops it caching a stale entry. Python's fetch threads and in-flight map
stay only for other databases.

- `EventLoader::load` goes through `Cache::get_or_load_many`. The extra
  redaction events it reads are returned but no longer cached.
- `PoolQuerier` checks its connection out before claiming any loads, so
  readers that hold connections while they wait cannot starve a load.
- An unusable event raises `InvalidEventError` or
  `DatabaseCorruptionError`, as from Python, which some callers catch.
- Cancelling a Python caller leaves the load running for the others, and
  `evt_db_fetch_count` counts only what a caller read itself.
- `RustStore` holds the `DatabasePool` weakly and reads its pool per call.

Tests: trial makes the loads block, as it does Python's transactions
(`_BlockingRustCalls`, renamed from `_BlockingRustSyncHandler`). The
cancellation and outage tests get Rust variants. Two tests stop depending
on how far requests advance the clock or on which `DatabasePool` object
a worker store keeps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpBQxvqZTUvHCMm3u6CR7J
2026-10-16 09:34:35 +00:00
Erik JohnstonandClaude Opus 5.5 4678f930d2 Coalesce batched loads in the Rust cache
`Cache::get_or_load_many` looks up several keys at once and loads the
misses with one call. Each key coalesces with loads already in flight,
single or batched, and a key invalidated mid-load is answered but not
cached. Waiters now get `None` for a key a batch load did not find, and
a single load waiting on such a key loads it itself.

`Cache::abandon_loads` stops every in-flight load from caching, for an
invalidation that cannot name the keys it affects. A room purge uses it
on the event cache, as the room of an event being loaded isn't known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpBQxvqZTUvHCMm3u6CR7J
2026-10-16 09:34:30 +00:00
Erik JohnstonandClaude Opus 5.5 50bc702e76 Fix review findings in Rust catch-up and the sticky backlog
- The server filter read each restricted event's state group before it
  checked for partial state, and through a cache that a prefill never
  reached. After an event got its full state, a retry could use the old
  partial group. It now checks first, then reads those groups from the
  database. A prefill of a Python cache now invalidates its Rust mirrors.
- Catch-up ended in two steps, so a PDU that send_pdu skipped in between
  was lost until the next catch-up. One lock now covers both.
- The sticky backlog query reads a fresh snapshot per statement, so
  events could expire between its cleanup and its retry. It now cleans
  up again, a few times, before giving up.
- Tests for each, plus catch-up's batch limit, a failed catch-up send,
  module notification, a filtered-out sticky batch, the hand-off to
  Python, the backlog cleanup boundary and paging outstanding
  destinations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-16 09:27:35 +00:00
Erik JohnstonandClaude Opus 5.5 2d76b50a9c Send the sticky event backlog in Rust on the native driver
`NativeHooks::sticky_backlog` loads a backlogged batch, cleaning up rooms
whose sticky events expired, reads the events through the shared event
cache and filters them for the destination. Its completion records the
batch natively. Where the filter would wait for full state, the batch is
left to Python, as before.

`FederationStore` also gains `get_catch_up_outstanding_destinations` for
phase 6. Each query has a differential test against Python's.

The native queue tests now run with MSC4354 on, so the loops look at the
sticky event backlog, and pin that it takes no GIL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 17:03:57 +00:00
Erik JohnstonandClaude Opus 5.5 5c6f35b0e5 Catch up in Rust on the native driver
`NativeHooks::catch_up` runs `_catch_up_transmission_loop` natively. It
reads the catch-up events and the room's forward extremities, loads
events through the shared event cache, filters the extremities with the
Rust `filter_events_for_server`, sends each room's PDUs through the
queue's sink, records the sticky event gap and moves the catch-up
position on. A destination catching up no longer takes the GIL, unless a
module wants to hear about delivered events.

`FederationStore` gains the catch-up queries, with differential tests
against Python's. The `catch_up` hook is given its queue, and a PDU can
now hold an event loaded in Rust.

Where Python would wait for a room's full state to filter, Rust sends
the original PDU, as it does for a room with partial state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 16:50:51 +00:00
Erik JohnstonandClaude Opus 5.5 0ef9dc7a6a Port filter_events_for_server's loading to Rust
`visibility::filter_events_for_server` reads the erased senders, the
partial state rooms, the history visibility and the target server's
memberships at each event through a `SyncReader`, and leaves the
per-event decision to `event_visible_to_server`. That now borrows its
arguments.

Python waits for the full state of an event whose memberships it needs.
Rust cannot, and returns `NeedsFullState` for the caller to handle.

The differential tests compare it with Python's on random rooms, an
outlier and a room with partial state, for every combination of flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 16:36:07 +00:00
Erik JohnstonandClaude Opus 5.5 ec6d048d11 Fix review findings in the native federation store
- Keep an unchanged device stream's position. The tokens are read after
  the change check, so moving to them could skip a message that arrived
  in between.
- Decode cross-signing keys as leniently as Python does, including
  URL-safe base64.
- Mark the transaction open before awaiting BEGIN, so a dropped task
  still rolls back.
- Tell modules about delivered events before the database writes, as
  Python does.
- Update the stream change cache before its listeners, and log a
  listener that fails.
- Check the metrics through a registry scrape, with a fresh event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 16:09:38 +00:00
Erik JohnstonandClaude Opus 5.5 93287194f8 Do the Rust transmission loop's database work natively
On the native Postgres driver, the Rust queues' `NativeHooks` read the
device EDUs and record finished transactions through `FederationStore`,
on tokio. A transaction then takes no trip to the reactor, unless it
finishes a sticky event batch or a module wants to hear about delivered
events. Catch-up and the sticky event backlog stay Python. SQLite and
the Python Postgres driver keep the Python hooks.

Python skips the device queries for destinations with nothing new by
asking two stream change caches. Rust keeps a mirror of each, per
destination, fed by a new `StreamChangeCache.add_listener`, so it hears
of every change the caches do. The current stream positions live in
Python's ID generators, and are read under the GIL (without a reactor
trip) only when a destination has changed.

The loops now count their transactions, EDUs and requests in Rust.
`synapse.metrics.pre_collect_hooks` adds the counts to the Python
metrics when they are collected. This removes a GIL crossing on every
failed send, and from `_rust_complete`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 15:43:17 +00:00
Erik JohnstonandClaude Opus 5.5 1ffaf26696 Add a native federation store for the transmission loop
`FederationStore` ports the store methods the federation transmission
loop calls around each transaction to the native Postgres driver:
`get_new_device_msgs_for_remote`, `delete_device_msgs_for_remote`,
`get_device_updates_by_remote` (with the cross-signing key updates and
cross-signatures), `mark_as_sent_devices_by_remote` and
`set_destination_last_successful_stream_ordering`. The stream change
cache checks those start with are left to the caller.

None of the writes has a Python cache to invalidate. The cross-signing
reads skip Python's caches.

`tests/storage/test_federation_store.py` runs each against the Python
method on one database, through a `FederationStoreForTest` pyclass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 15:42:50 +00:00
Erik JohnstonandClaude Opus 5.5 9709ba66fb Fix review findings in the Rust federation queues
- Stop catch-up before the next room once the loop is shut down. The Rust
  loop cannot cancel the Python catch-up coroutine, and after a shutdown
  the transaction manager returns without sending, so the room would be
  recorded as sent.
- Record the request metrics when the Python fallback for reading a
  transaction response fails.
- Give each transmission loop its own LoopActivity, so a loop that ends
  cannot clear the parked flag of the next.
- Count the time spent reading a hook's result as GIL time of its site.
- Correct the AwaitableObserver::finished doc.
- Let test_send_receipts_with_backoff_sender accept either order of its
  first two sends, which run at once with the queues in Rust.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 14:54:44 +00:00
Erik JohnstonandClaude Opus 5.5 9e90814d9b Add Rust per-destination federation queues
`DestinationQueue` holds what waits to go to one destination: PDUs,
EDUs, keyed EDUs, presence and receipts, with Python's merge rules and
limits. The first thing queued starts its transmission loop as a tokio
task, which builds and sends transactions until there is nothing left,
as `_transaction_transmission_loop` does.

Catch-up, the sticky event backlog and the loop's database work stay
Python for now, behind `QueueHooks`. `RustDestinationQueue` is the
pyclass the Python facade wraps, and calls those hooks on it.

`run_python_awaitable_observed` converts a Python result on the reactor
and tells an observer when the awaitable starts and finishes. The loop
uses it so tests can tell a loop waiting on the reactor from one with
work to do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 14:54:44 +00:00
Erik JohnstonandClaude Opus 5.5 b8d8d5e015 Fix review findings in the Rust transaction sender
- Only build the Rust sender from a real `RustFederationClient`. A test
  that mocks the federation client no longer fails with a `TypeError`.
- Log the `TX` and response lines under Python's logger names, so log
  configs that name those loggers still apply.
- Move the PDUs and EDUs into the request body, so a transaction holds
  one copy of them while it is retried, besides the canonical JSON.
- Drop the unused `time_now` from `Event::to_pdu_value`.
- Set the compare mode per `TransactionManager` in the tests that use
  it, not on the class.
- Test that the tracing headers reach the request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 14:54:44 +00:00
Erik JohnstonandClaude Opus 5.5 4a69275712 Add a Rust transaction sender
`TransactionSender` builds `/send` transactions and sends them through
the Rust federation client, with the options `send_transaction` uses.
It reads the response and returns the ids of the PDUs the remote
accepted. `Event::to_pdu_value` builds the PDU JSON from the Rust event
without the GIL.

`RustTransactionManager` is the Python facade. It takes the events and
EDU dicts, and returns a Deferred. A response body that serde_json
refuses goes to Python's decoder, as `JsonParser` would.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 14:54:23 +00:00
Erik JohnstonandClaude Opus 5.5 a379722f2c Fix review findings in the Rust federation client
- Create the sleeper's `Notified` under the map lock, so a wake that lands
  just before the sleep starts is not lost. Count sleeps per destination
  and forget the destination in a drop guard, which also runs on cancel.
- Map an SRV `.` target to `ConnectError`, as Python's resolver does.
- Call a custom parser's `finish` when its last write fails, and when the
  request is cancelled mid-read.
- Check the path is ASCII after the backoff check, as Python does.
- `put_json` takes `rewrite_age_ts`, which `send_transaction` sets. Any
  other `json_data_callback` sends through the Python client.
- Count attempts in the no-retry test, and test the request counters,
  the proxy-worker fallback, shutdown and the server-up wake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 14:54:23 +00:00
Erik JohnstonandClaude Opus 5.5 f82f4b4511 Add a Rust federation request layer
Port `MatrixFederationHttpClient._send_request` and `_handle_response`
to Rust, over the phase 1 agent: destination validation and the
whitelist, the retry limiter, `X-Matrix` signing per attempt, the long
and short retry schedules with jitter, `wake_destination`, timeouts on
the virtual clock, the trailing slash retry, and the mapping of each
failure to the exception Python raises.

The JSON parsers run on tokio without the GIL. Python's decoder is the
fallback for the JSON serde_json refuses, such as lone surrogates. A
custom `ByteParser` is fed under the GIL in 64 KiB chunks. The
transaction manager's `json_data_callback` becomes a `rewrite_age_ts`
option, so a retry needs no call into Python.

The retry timings are read through a Rust mirror of
`get_destination_retry_timings`. Writes go to a Python callable, which
invalidates the cache across workers and reports a server coming back.

Every GIL acquisition in the federation code goes through `with_gil`,
which counts calls and hold time per call site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 14:54:15 +00:00
Erik JohnstonandClaude Opus 5.5 897cf044ce Bound federation handshakes, and finish abandoned .well-known lookups
A shared .well-known lookup only ran while a caller awaited it. If every
caller gave up, the next request resumed it with its fetch timeout long
expired, and cached a failure for an hour. Run it as its own task.

The TLS handshake and a proxy's CONNECT answer had no timeout. hyper
finishes some connections in a background task that no request timeout
reaches, so a peer that stalled after accepting leaked the task and its
socket. Give each 30 seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 14:54:15 +00:00
Erik JohnstonandClaude Opus 5.5 6a2ac9be0e Add a Rust federation agent
The Rust replacement for `MatrixFederationAgent` and its connection pool,
as a library: nothing in Synapse sends through it yet. The request layer
(signing, retries, backoff) comes next and will be its first caller.

`Agent::request` takes a `matrix-federation://` request, applies
`.well-known` delegation to the URI and the `Host` header, and sends it
through hyper-util's pooled client (5 idle connections per host, 120s
idle timeout, HTTP/1.1, no automatic retries), as Python's pool does.

The pool calls our connector, derived from matrix-hyper-federation-
client's `MatrixConnector` (Apache-2.0). It resolves the server name
(SRV), tries each server in order and returns the first error if all
fail. For each server it resolves addresses through the IP range policy,
connects TCP with Twisted's staggered attempts and 30s timeout, or
tunnels through `https_proxy` with `CONNECT` unless `no_proxy` exempts
the host, then does TLS verified against the delegated server name,
never the SRV target. The `.well-known` fetch shares the pool and, as in
Python, blocks IP literals by the blocklist alone.

Failures are a typed `TransportError` (DNS, connect, TLS, and so on), so
the request layer can tell a DNS failure from the rest. The agent
implements `FederationTransport`.

`RustFederationAgentForTest` exposes a blocking `request` to Python. The
trial tests drive it against a real TLS server on a thread, signed by
the test CA, covering the delegation, SRV, SNI, IP literal, blocklist,
certificate verification and connection reuse cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 14:54:15 +00:00
Erik JohnstonandClaude Opus 5.5 7f34eefa26 Port federation server name resolution and policies to Rust
The parts of the outbound federation agent that decide where to connect
and what to trust, each a port of the Python it replaces, with Rust unit
tests. Nothing uses them yet.

- `resolve`: `_matrix-fed._tcp`, then `_matrix._tcp`, then port 8448, as
  `MatrixHostnameEndpoint._resolve_server`. SRV records are ordered as
  `_sort_server_list` (RFC 2782), cached while every record is live, and
  the stale answer is used when a refetch fails. A lone `.` target means
  the service is unavailable. SRV lookups go through hickory, and A/AAAA
  through the system resolver, as Twisted uses getaddrinfo. Parts derive
  from matrix-hyper-federation-client (Apache-2.0).
- `well_known`: `WellKnownResolver`, with Python's cache periods, jitter,
  grace period, had-valid memory, size cap and retries, behind a
  `WellKnownFetcher` trait so the state machine is tested without HTTP.
  Two additions: each fetch attempt has a timeout (`client_timeout`), and
  concurrent lookups for one server name share a fetch. An `m.server`
  that is not a valid URI authority now counts as an invalid response.
- `policy`: the IP range lists, refusing a name if any of its addresses
  is blocked (the DNS rebinding defence), and the `https_proxy`
  settings with Python's `no_proxy` matching.
- `tls`: `FederationPolicyForHTTPS` on rustls: platform roots or the
  custom CA list, verification off (still sending SNI), the per-host
  verification whitelist. rustls has no TLS 1.0 or 1.1, so a minimum
  below 1.2 means 1.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 14:54:15 +00:00
Erik JohnstonandClaude Opus 5.5 db4febd65a Forget abandoned virtual-clock sleeps, drop TransportError::IpBlocked
A dropped Clock::sleep left its waker in VIRTUAL_SLEEPERS until the
virtual time passed its deadline, which a later test case's reactor may
never do. Prune closed wakers when sleeping and when the time moves.

Nothing constructs IpBlocked: a blocked address is a failed DNS lookup,
as in Python.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 14:54:15 +00:00
Erik JohnstonandClaude Opus 5.5 9d4d050a0b Pass the outbound federation config to Rust
Extend the Rust view of the homeserver config with what the federation
request layer and agent need:

- `config.federation`: timeouts, retry counts and delays, destination
  backoff, the domain whitelist and the metrics domains.
- `config.tls`: certificate verification, the per-host verification
  whitelist, the minimum TLS version, and the custom CA list. Python now
  keeps the PEM text of that list too, so Rust never reads the files.
- `config.server`: the federation IP range block and allow lists (taken
  from `netaddr.IPSet`s), the user agent suffix and the proxy config.

The verification whitelist holds Python regexes made by
`glob_to_regex`. They translate to Rust's syntax with two changes: `\Z`
becomes `\z` and `re.IGNORECASE` becomes `(?i)`.

The changelog entry needs renaming to the PR number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 14:54:15 +00:00
Erik JohnstonandClaude Opus 5.5 489c09175d Sign federation requests in Rust
Add `rust/src/federation`, starting with the `X-Matrix` request
signature and the transport seam the request layer will send through.

The signing code comes from matrix-hyper-federation-client (Apache-2.0),
changed to use Synapse's canonical JSON serializer, Python's header
layout and quoting, and `destination_is` for identity servers. It keeps
that crate's signature test vectors. The body is serialized to canonical
JSON once, then both signed and sent in that form.

The homeserver's signing key crosses to Rust as its algorithm, version
and seed, taken from `hs.signing_key`. Rust never reads the key file.

`FederationTransport` is what the request layer will send through.
`PyTransport` implements it with a Python callable, which is how Rust
level tests will inject responses in place of `FakeTransport`.

A trial test checks the Rust header is byte-identical to the one
`MatrixFederationHttpClient.build_auth_headers` builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 14:54:15 +00:00
Erik JohnstonandClaude Opus 5.5 aa2ffd95aa Let Rust code sleep on the virtual test clock
Tokio's timers run on real time, but trial drives a virtual reactor clock
that only moves when a test advances it. A Rust task that sleeps with
`tokio::time::sleep` under trial waits out real time while the test
believes none has passed.

`Clock::sleep` sleeps on the virtual clock while a test has pinned it:
`set_virtual_time_msec` wakes the sleepers that are due. Otherwise it is
`tokio::time::sleep`. Code that sleeps takes a `Sleeper`, so that its
Rust unit tests can use tokio's pausable clock instead, since the
virtual time is process-global and an earlier test may have pinned it.

The federation sender port needs this for its backoff and well-known
retry sleeps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 14:54:15 +00:00
Erik JohnstonandClaude Opus 5.5 abbd332043 Bundle a sync timeline's aggregations in Rust
The new `sync/relations.rs` ports the relations handler's
`get_bundled_aggregations`: thread summaries with participation and
ignored users' replies subtracted, references, and the latest edit. The
Rust sync handler bundles them for limited and newly joined timelines,
loads the redactions of the bundled events, and the encoder serializes
them. Python no longer bundles the rooms Rust computes, and the room
entries hand Python the bundles only when something reads the timeline.

A thread whose latest reply is from an ignored user falls back to
Python for that room: finding the latest reply that is not takes the
relations pagination, which is not ported.

The relations queries read the database each time. Python's relations
caches are not mirrored.

Shadow mode now compares the Rust bundles with Python's, in the encoded
rooms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 12:45:03 +00:00
Erik JohnstonandClaude Opus 5.5 99c830d9ab Encode the rooms of a sync response in Rust
The rooms the Rust sync handler computes now reach the servlet as
`RustJoinedSyncResult` and `RustArchivedSyncResult`. They subclass the
Python entries, and their fields become Python objects only when read.
The servlet encodes them in one call to the new Rust `RoomsEncoder`,
which ports `encode_room` over the existing serializer. If that call
fails, Python encodes them.

The Rust handler loads each room's redactions as it computes the room.
The serializer reads redactions, bundles and module additions through a
`SerializeLookups` trait, so that it takes Rust events as well as Python
maps. Bundled aggregations are still computed in Python.

Shadow mode now also compares the encoded JSON of each room.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 12:45:03 +00:00
Erik JohnstonandClaude Opus 5.5 47367a14a2 Make the Rust event cache EventsWorkerStore's event cache
`EventsWorkerStore._get_event_cache` is now the Rust `EventCache` that the
sync handler and state resolution read, so each event is held once.

The cache keeps Rust `CachedEvent` values. Python gets them through
`RustEventCache`, a `RustCache` subclass with the `AsyncLruCache` names the
rest of Synapse uses, and each hit wraps the cached event in a new `Event`
object. The wrapper shares the cached event's internal metadata, unsigned
data and signatures, so a change Python makes, such as adding
`prev_content` or flipping `outlier`, lands in the one copy the Rust
readers see. Python's `_event_ref` weak map is checked before the cache,
so while anything holds an event, a lookup returns that same object, as
before. A hit there marks the cache entry as read, and puts the event back
if it was evicted.

- The cache is sized from `caches.event_cache_size` and named
  `*getEvent*`, so its metrics and `per_cache_factors` keep their names.
  The Rust loader now counts its misses too.
- Invalidating an event drops its chain position as well, and a room
  purge drops the room's events through the room index, plus the chain
  cover and state group caches, as `RustStore.invalidate_event` and
  `clear_event_cache` did. Those two calls are gone.
- `EventsWorkerStore` and `RustWorkerStore` run their `__init__` bodies
  in a different order in `DataStore` and `GenericWorkerStore`, as they
  sit differently in each MRO. So whichever runs first builds the
  `RustStore`. A datastore without `RustWorkerStore`, such as the port
  script's, gets a standalone `RustEventCache`.
- The external cache stubs (`get_external`, `set_external`,
  `_invalidate_async_get_event_cache` and its room variant) are dropped.
  They were documented no-ops.
- The sync shadow diff still ignores `prev_content` on state events.
  Python keeps the objects it holds after the cache evicts them, so a Rust
  reader can still load a second copy without it. Trial's cache of one
  event makes that routine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpBQxvqZTUvHCMm3u6CR7J
2026-10-15 12:14:54 +00:00
Erik JohnstonandClaude Opus 5.5 76a8355da2 Evict Rust cache entries on memory pressure
With `cache_autotuning` configured, `_expire_old_entries` walks Python's
global cache list from the coldest entry and evicts while jemalloc reports
too much memory allocated. Rust caches were not on that list, so they were
never evicted for memory, which matters once the event cache lives in Rust.

Each Rust cache's LRU order is already a sorted run, so the registry
merges them without a global list: `CacheRegistry::evict_coldest` keeps
evicting from the cache whose coldest entry is coldest, up to a bound and
a count. The Python walk calls it before evicting each Python entry, with
that entry's last access as the bound, so both sides go coldest first
together. It reports the coldest Rust entry left, so Python only calls
again once its walk passes that time. Memory is re-read after each batch
of 100 Rust evictions, as after 100 Python ones, and `min_cache_ttl` holds
for Rust entries too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpBQxvqZTUvHCMm3u6CR7J
2026-10-15 12:14:54 +00:00