Commit Graph
230 Commits
Author SHA1 Message Date
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
Erik JohnstonandClaude Opus 5.5 ce84ed1e66 Give the Rust cache a secondary index, touch, and lazy auth IDs
Preparation for making the Rust event cache the one `EventsWorkerStore`
reads through.

- `Cache::new_indexed` keeps the keys grouped by a value-derived index key,
  maintained on every insert and removal path, and `invalidate_index` drops
  one group. A room purge invalidates the event cache by room, and
  retention runs that for every room with a policy, so it must not walk
  the whole cache.
- `Cache::touch` marks an entry as read without cloning it,
  `get_or_count_miss` and `get_uncounted` cover the metrics cases of
  Python's `update_metrics`, and `count_miss` records a miss found
  elsewhere.
- `CachedEvent` works out its auth event IDs on first use. An event filled
  from Python is usually never read by state resolution, so it should not
  pay to parse them. `auth_event_ids` is fallible now, and its callers in
  state resolution propagate the error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpBQxvqZTUvHCMm3u6CR7J
2026-10-15 12:14:53 +00:00
Erik JohnstonandClaude Opus 5.5 6cdfc77936 Work out a sync response's rooms in Rust
Phase 4 of the sync-in-Rust plan. `SyncHandler.room_changes` ports
`_get_room_changes_for_incremental_sync` and
`_get_room_changes_for_initial_sync`: which rooms the response reports, and
which the user joined or left since the last sync. It fetches the timelines
of the joined rooms itself, as `get_room_events_stream_for_rooms` does.
`SyncHandler.generate_room_entries` then computes every joined and archived
room's entry, ten at a time. A room it cannot compute, or fails on, comes
back in the result for Python to compute. A panic in a room is that room's
error, so it cannot fail the rooms that have recorded their lazy-loaded
members.

The `state_after` rollback now reads the state deltas with no upper bound,
after the current state, so that it covers every change the current state
may hold.

These replace the per-room `generate_room_entry` and `RoomSyncInput`. The
store gains `event_position`, for the leave events of archived rooms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 09:45:45 +00:00
Erik JohnstonandClaude Opus 5.5 61ea8da02b Compute one room's sync entry in Rust
Phase 3 of the sync-in-Rust plan: `SyncHandler.generate_room_entry` ports
`_generate_room_entry` for a joined or archived room, reading through the
sync store. It covers the timeline (gap check, filtering, visibility and the
backward pagination loop), the state delta on the full, incremental and
`state_after` paths with the lazy-loaded members cache, the room summary,
sticky events and unread counts. Partial state rooms raise
`RustSyncFallback`. Bundled aggregations stay with Python.

A room records the lazy-loaded members it sends only once its entry is
computed. A room that fails part way records none, so Python can compute it
from the same cache. Python reads and writes that cache through
`ll_member_was_sent` and `ll_member_mark_sent`.

The visibility checks get a batch loader over the store, and the store gains
`events_have_relations` for relation filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194eNQP5weBtVMg8NKtc6VU
2026-10-14 23:14:41 +00:00
Erik JohnstonandClaude Fable 5.1 f7c9f55d8f Add the Rust sync store
The queries of the per-room sync loop, on the native Postgres driver, each
a port of the store method it is named after. A `SyncReader` is made per
request and borrows a pooled connection for one group of statements at a
time, so that a sync worker with hundreds of requests in flight does not
pin a connection per room; a group that needs one snapshot runs in a
`REPEATABLE READ READ ONLY` transaction. State groups are loaded whole,
with the recursive CTE sent as one simple query per batch, and cached as
full maps.

The caches that copy a `@cached` function (rooms for user, room summary,
partial state rooms, erased users, retention policies, event to state
group, unread counts) live on the Rust store and are attached to the
Python cache each names by the new `RustWorkerStore`, which also takes
over building the Rust store. Unread counts are invalidated by room as
well as by room and user, as the Python tree cache is.

`tests/storage/test_sync_store.py` runs every query and the Python
method against the same database, with multi-writer tokens, redactions
of every kind, and the summary rotation of the unread counts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194eNQP5weBtVMg8NKtc6VU
2026-10-14 15:11:51 +00:00
Erik JohnstonandClaude Fable 5.1 74f5fbf306 Let a Rust cache mirror a Python cache's invalidations
The sync store's caches are copies of `@cached` functions of the
datastore, and those are invalidated by several paths: a write on the
process, the caches stream, and the current state batch. Rather than tap
each path, a `DeferredCache` now takes mirrors, invalidated with its own
keys whenever it is, and `mirror_rust_cache` attaches a Rust cache as one,
passing the key as the tuple of arguments.

A `tree=True` cache is invalidated by a prefix of its key as well, so the
Rust cache gains `invalidate_matching`, which drops every entry whose key
satisfies a test, and a key type can decode a prefix into one. A key of
two strings, such as room and user, does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194eNQP5weBtVMg8NKtc6VU
2026-10-14 15:11:35 +00:00
Erik JohnstonandClaude Fable 5.1 02ea713e95 Load events with their redactions in Rust
The state res store built events from rows itself, unredacted. Sync sends
events to clients, so it needs the whole of `_get_events_from_db`: the
`redactions` and `room_ban_redactions` tables, the recheck of redactions
whose authority was not verified at persist time, and `get_events_as_list`
on top, with its redact behaviours and the previous content of state
events.

`storage/events.rs` holds that port, over a `Querier` so it runs inside
the state res transaction as well as on a borrowed sync connection. The
event cache entry becomes a `CachedEvent`: the event as persisted and its
redacted form, as Python's `EventCacheEntry`. `StateResEvent` is an alias
of it, and the state res store loads through the shared code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194eNQP5weBtVMg8NKtc6VU
2026-10-14 15:11:35 +00:00
Erik JohnstonandClaude Fable 5.1 e5d754f874 Port the room summary heroes to Rust
`extract_heroes_from_room_summary`, with `tests/sync/test_rust_summary.py`
comparing it with the Python on random summaries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194eNQP5weBtVMg8NKtc6VU
2026-10-14 13:35:25 +00:00
Erik JohnstonandClaude Fable 5.1 a67156e52b Port the lazy-loaded members cache to Rust
`LazyLoadedMembersCache` as it is on develop after #20289: an LRU of the
members sent to a device, each stamped with the room position of the
response that carried it, and `start_request` forgetting the entries
recorded after the request's `since`. The per-device caches expire
after thirty minutes without use, as the Python `ExpiringCache` does.

The Rust sync handler holds them, not the store: they are per device and
no data change invalidates them. `SyncHandler.start_request` is its
first entry point.

`tests/sync/test_rust_ll_members.py` mirrors the Python cache's tests
and runs random operation sequences, with multi-writer positions,
through both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194eNQP5weBtVMg8NKtc6VU
2026-10-14 13:35:25 +00:00
Erik JohnstonandClaude Fable 5.1 90e4d6492a Port the client visibility checks to Rust
The per-event decision of `filter_and_transform_events_for_client`:
`_check_client_allowed_to_see_event` and the membership, history
visibility, send-to-client and effective visibility checks under it, the
call invite rule, and the membership annotation for MSC4115. The inputs
are the event, the user's membership and the history visibility at it as
events, and flags; loading those is the caller's job, so the module can
serve the sync handler and later ports alike. The erased-sender case
prunes through the existing Rust redaction.

The `allowed` closure in the Python is now a module-level function,
`_check_event_for_client`, with the same body, so that
`tests/sync/test_rust_visibility.py` can compare the two exactly on
random events, state and flags, sticky and outlier events included.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194eNQP5weBtVMg8NKtc6VU
2026-10-14 13:35:25 +00:00
Erik JohnstonandClaude Fable 5.1 d017f728f8 Port the sync state delta arithmetic to Rust
`_calculate_state`, the `use_state_after` roll-back over state deltas of
the full-state path, and the delta loop of the incremental path. The
database reads that produce their inputs stay with the store.

The two Python loops were inline in the handler; they are now functions,
`_roll_back_state_over_deltas` and `_apply_current_state_deltas`, so that
`tests/sync/test_rust_state_delta.py` can compare them with the Rust on
random state maps and deltas. Behaviour is unchanged.

One deliberate difference: when the start and end of the timeline
disagree on a key that is in neither the timeline nor the previous state,
`_calculate_state` sends whichever event its set iteration visits last.
The Rust sends the one from the end of the timeline, the state the client
should end up with. The test allows the Python either.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194eNQP5weBtVMg8NKtc6VU
2026-10-14 13:35:25 +00:00
Erik JohnstonandClaude Fable 5.1 83b8d5a406 Port the sync filters to Rust
`Filter` and `FilterCollection` from `synapse.api.filtering`, built from
the filter JSON the Python one was built from, once per request. The
checks are pure functions of the filter and the event, over events,
presence updates and the JSON of ephemeral events and account data. The
one part that needs the database, the `related_by_*` relation checks, is
a trait the store will supply; `needs_relation_check` says when.

The config flags the Python reads (MSC3773, MSC3874, profile updates in
sync) are passed in, so the port has no reach into the config.

`tests/sync/test_rust_filter.py` compares the two on random filters and
random events, with and without the MSCs enabled.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194eNQP5weBtVMg8NKtc6VU
2026-10-14 13:35:25 +00:00
Erik JohnstonandClaude Fable 5.1 4cc37d280a Port RoomStreamToken and the pagination bounds to Rust
The first of the pure parts of `/sync` that the Rust sync handler needs:
`RoomStreamToken` with its three string forms and its vector clock
arithmetic (`is_before_or_eq`, `copy_and_advance`, `bound_stream_token`),
`persisted_after`, and the pagination helpers of `stream.py` that turn
tokens into bounds and filter the rows that come back by writer. The SQL
is built in the Postgres row-comparison form only, as the handler reads
through the native Postgres driver.

Tokens cross the boundary as the Python class, converted both ways.

The multi-writer filtering is where a bug would be silent, so
`tests/sync/test_rust_stream_token.py` checks every operation against the
Python on random tokens with instance maps. `tests/sync/fixtures.py`
holds what the differential tests that follow share.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194eNQP5weBtVMg8NKtc6VU
2026-10-14 13:35:25 +00:00
Erik JohnstonandClaude Fable 5.1 1abc57e826 Add the Rust sync handler skeleton behind experimental_features.rust_sync
`rust_sync` takes `off`, `shadow` or `on`, as `rust_state_res` does, and
`RustHandlers.sync` is built when the main database uses the native Rust
driver. The Python `SyncHandler` reads the option and takes the Rust
handler, warning and keeping the Python path when the driver is not the
Rust one. Nothing is served by Rust yet: the per-room entry points come
with the ports that follow.

Trial gets the same plumbing the state resolution has: `SYNAPSE_TEST_
RUST_SYNC` sets the option on every test homeserver, and the Rust handler
is wrapped so that each entry point's `_blocking` twin answers under the
fake reactor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194eNQP5weBtVMg8NKtc6VU
2026-10-14 13:35:25 +00:00
Erik JohnstonandClaude Fable 5.1 5b910ba13c Let a Rust deferred abort its tokio task when cancelled
Cancelling a deferred from `create_deferred` failed it at once and left
the tokio task running to completion, its result thrown away. That is
the right behaviour for a cache load, which other callers may be
waiting on, but wrong for work only the cancelled caller wanted: a
`/sync` request's per-room loop, say, which clients abandon constantly
and which holds a pooled connection while it runs.

`create_deferred_with` takes an `OnCancel`. With `Abort`, the deferred's
canceller aborts the task, so its future is dropped where it stands and
whatever it holds is released by `Drop`. The default stays as it was.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194eNQP5weBtVMg8NKtc6VU
2026-10-14 13:24:40 +00:00
Erik JohnstonandClaude Fable 5.1 2d836b040d Move the state res caches onto the Rust store
The event, chain position, chain link and state map caches lived on the
Rust `StateResolutionHandler`, each a `SizedLru` of its own. The Rust
sync handler will read the event and state group caches too, so they
move to the Rust `Store`, which the main datastore builds and every Rust
handler borrows. They are now `Cache`s in the runtime's registry, so
age-based expiry, cache factors and metrics come with that rather than
being re-implemented, and `SizedLru` goes.

`EventsWorkerStore` now empties them through the store (`RustStore.
invalidate_event` and `clear_event_cache`) rather than through the state
res handler, so they are kept in step with the Python event cache on
every database, and the conflict cache is the one cache the handler
still owns.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194eNQP5weBtVMg8NKtc6VU
2026-10-14 13:24:40 +00:00
Erik JohnstonandClaude Fable 5.1 e1df32f1ea Weigh Rust cache entries, and let callers fill a cache directly
The Rust `Cache` bounded itself by entry count, through `lru`'s capacity.
The state resolution caches need a bound on the sum of what their values
weigh: a state map counts for its entries, a chain's links for each link,
as Python's `size_callback` caches do. So the bound is now a weight, with
a `Weigher` chosen at construction and the default weighing every value
as one, and the cache enforces it itself over an unbounded `lru`.

Those caches are also filled by readers that load a batch of keys in one
statement rather than through `get_or_load`, so `Cache::insert` is now
public, with `prefill` semantics: it leaves any in-flight load for the
key to answer its waiters and cache what it found.

A cache key that is a single string or integer now decodes from Python,
as the keys of the caches moving here are event IDs and group IDs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194eNQP5weBtVMg8NKtc6VU
2026-10-14 12:52:37 +00:00
Erik JohnstonandClaude Fable 5.1 4d002f82eb Move is_feature_enabled onto a cache that lives in Rust
The first Rust database function to get a cache, and the first Python
`@cached` function to be replaced by one. The Rust `/versions` handler
calls `Store::is_feature_enabled_for_user` on every authenticated request,
and until now each call ran a query. The Python `is_feature_enabled` had a
cache of the same rows under another name, which every writer would have
had to invalidate alongside. Now there is one cache, keyed on
`(user_id, feature)`, and both sides read through it.

The main datastore builds the Rust `Store` in its `__init__`, over its own
`db_pool`, and `hs.get_rust_store()` hands it to the Rust handlers: the
datastore owns the store, the handlers borrow it. The store's
`is_feature_enabled_for_user` reaches Python as a callable cache object,
bound on the datastore under that name. Calling it answers a hit with an
already-fired Deferred, without leaving the reactor thread, and only a
miss goes through tokio. Being a `RustCache`, it is also what
`_invalidate_cache_and_stream`, replication and `register_cache` take, so
`set_features_for_user` invalidates it with the one call it already made,
and the name is spelled once in Rust, where the cache is built.

`is_feature_enabled` keeps its global-config short cut in Python and
delegates the per-user lookup.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-14 12:42:31 +00:00
Erik JohnstonandClaude Fable 5.1 f049bbf911 Expose Rust caches to Synapse's invalidation, metrics and expiry
Invalidation stays in Python. Synapse invalidates caches by *name*: it
resolves `getattr(store, cache_name)` and calls `.invalidate(key)` on
whatever it finds. So `RustCache`, a `#[pyclass]` exposing that protocol,
bound onto a datastore under the right attribute name, inherits the whole
existing path (the replication stream, `txn.call_after`, `invalidate_all`)
with no changes to the machinery. Keys arrive as strings from other workers,
because the stream's column is `keys text[]`, and each key type decodes them
back via `PyCacheKey`. A key it cannot decode matches nothing, as it would
for a Python cache, rather than failing the batch of invalidations it
arrived in.

Every cache belongs to one homeserver's `RustRuntime`, whose `CacheRegistry`
builds it and is what expires it, so nothing here is process-wide. Tests
run several homeservers in one process, and one homeserver's expiry tick
must not reach another's caches. Entries expire by age like Python's, off
the same 30 second looping call and the same `expire_caches` /
`cache_entry_ttl` options, but per cache rather than via a global linked
list, since a cache's LRU order is already its age order. Memory-pressure
eviction is not implemented. That one does need a global coldest-first
order, which a k-way merge over the per-cache LRU tails can give without a
global list.

Rust keeps the hit, miss and eviction counters. `register_rust_cache`
registers a cache with the usual `CacheMetric` and copies them onto it at
each scrape, and wires `set_cache_factor` up so that `SYNAPSE_CACHE_FACTOR_*`
and `caches.per_cache_factors` reach a Rust cache the same way.

There are still no caches. The next commit adds the first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-14 12:41:47 +00:00
Erik JohnstonandClaude Fable 5.1 4b1ec177a0 Add a cache for database functions that live in Rust
Moving database functions into Rust means moving their caches too: almost
every hot DB function in Synapse is wrapped in `@cached`, and porting one
without its cache trades a Python call for a database round trip.

`Cache<K, V>` is a bounded LRU that Rust code can read without the GIL.
Concurrent misses for one key share a single load, with a guard against the
case that makes that dangerous: a key invalidated while its load is in flight
must not be populated with what the load returns, or the cache holds a stale
entry that nothing will ever evict. Failures are never cached, and a load
that is dropped or panics hands the key to the next caller rather than
wedging it.

Every eviction path returns the removed values for the caller to drop with
the lock released. Nothing needs that yet, since every value so far is plain
data, but the stated direction is to back Python's `LruCache` with this
container, and a `Py<PyAny>` value cannot be dropped under a lock that tokio
readers take without the GIL.

Entries are stamped from the Rust clock (#20294). The parts of the cache
that depend on the time take it as an argument, so the tests can use
explicit timestamps and never pin the process-wide virtual clock, which
`cargo test` runs them in parallel against.

Nothing uses the cache yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 12:41:47 +00:00
Erik JohnstonandClaude Fable 5.1 2c379b5cb5 Resolve state groups in Rust, remembering each group's converted state
With the database work cached, converting the state sets from Python
dicts was the largest piece of a warm resolution: 5,010 entries pulled
out of tuples and interned, under the GIL, before the future starts,
and 2,505 entries built into a dict on the way back. Every group's
state was converted again on every resolution it took part in.

The handler gains resolve_state_groups, which takes the state groups
by ID with their state, as StateResolutionHandler.resolve_state_groups
receives them, and keeps each group's converted StateMap in a cache
keyed on the group ID. State groups are immutable and their IDs are
never reused, so an entry can only go stale by its group being
deleted, which makes it dead weight rather than wrong. The cache is
cleared on a room purge with the others. A group given with fewer
entries than its cached state is a filtered one: it is converted for
that call and does not replace the full map.

The result is returned in the shape of a _StateCacheEntry, chosen as
_make_state_cache_entry does: the input group the resolved state
equals, else the input group it differs least from and the difference,
else the state itself. So the common cases cross the boundary as a
group ID or a small delta rather than the whole state.

StateResolutionHandler.resolve_state_groups uses it in "on" mode, with
the usual fallback to Python for rooms without a chain cover index.
Shadow mode keeps comparing through resolve_events_with_store. The
state sets are now shared maps inside the Rust, so a cached map and a
converted one are handled alike.

On the benchmark room (2,000 members, 1,000 conflicted events) a warm
resolution through the state groups takes 9 to 11 ms end to end once
the groups' state is cached, from 15 to 19 through the flat entry
point; Python warm is 37 to 44.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-13 17:34:55 +00:00
Erik JohnstonandClaude Fable 5.1 368465b323 Cache the chain cover index on the Rust state resolution handler
With the event cache warm, the chain cover queries were what was left of
the database work in a resolution: the position of every event in the
state sets, and the links out of every chain they are on. Both can be
cached.

A chain position never changes once assigned, so it is cached before
the transaction commits, and dropped with the event in case the event is
purged and received again. The links out of a chain only grow, at the
end: an event appended at sequence number n adds links out of (chain, n)
and changes nothing below. So links read while the chain was known to
reach n are complete for every walk that starts at or below n, and the
cache keeps that bound with them. A walk that needs a chain beyond its
bound refetches it. Both caches go on a room purge, which deletes the
room's chains and links.

The chain cover walk now gathers links first (from the cache, following
the links a backwards walk would follow so reached chains are gathered
too, and from the database for the rest) and then walks them once. The
Python walks each batch of its recursive query as it arrives, but a batch
is closed under reachability, so walking the union comes to the same
thing. Fetching only what a sequence-aware walk reaches, rather than the
full reachability closure, leaves the auth difference and the v2.1
conflicted subgraph unchanged: any forwards path that ends inside a
backwards-reachable range runs through chains the backwards walks
reached, below their bounds. The trial comparison against the Python
runs every case through a handler's caches too.

The three store-side caches are bundled as StateResCaches, so the store
and the invalidation hooks treat them as one. The integer-keyed maps in
the chain cover code and the interner use FxHash, and the reverse
position map is one flat map, which together are worth 2 ms on the warm
path of the benchmark. The stats gained the cache hit counts and
timings for the auth chain difference, event loading and the
connection.

On the benchmark room (2,000 members, 1,000 conflicted events) a warm
resolution runs no chain queries and takes 14 to 20 ms end to end, from
27 to 32 with the event cache alone; Python warm is 38 to 41.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-13 16:52:05 +00:00
Erik JohnstonandClaude Fable 5.1 eeb74db0aa Cache events on the Rust state resolution handler
Loading and parsing events was most of a Rust resolution's time: against
a warm Python event cache, Rust was level with Python. The handler now
keeps the events it loads, keyed by event ID, so a resolution in a room
it has seen reads them from memory.

The conflict cache's LRU becomes a generic SizedLru shared by both
caches. The Postgres store takes the event cache, reads it before the
database and inserts what it loads. The stats report event cache hits
and the time spent converting state to and from Python.

Invalidation follows the Python event cache rather than having its own
sources: EventsWorkerStore._invalidate_local_get_event_cache and the
room-wide variant drop the same events from the Rust cache. Those two
methods are where every event cache invalidation ends up, including a
partial-state event's rejection status changing, which is the one
change state resolution can observe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-13 16:04:19 +00:00
Erik JohnstonandClaude Fable 5.1 b5988fdc64 Simplify the state res types
- `StateResEvent` wraps a plain `Event`. An event handed in from Python is
  a shallow copy sharing the Python object's data (its internal metadata
  included), so the `EventSource::{Python, Rust}` enum and the separate
  constructors go.
- `CpuStats` folds into `ResolveStats`, and the store's five atomic
  counters become one `Mutex<DbStats>`.
- `prepare` takes the state already split by `separate`, as the handler
  needs; the three-line `prepare` wrapper that called `separate` itself is
  gone and `prepare_separated` takes its name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-13 15:32:24 +00:00
Erik JohnstonandClaude Fable 5.1 f69cd49cc6 Use identifier::UserID in the auth rules
`user_id_from_string` reimplemented the sigil and colon check that
`UserID::try_from` already does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-13 15:32:24 +00:00
Erik JohnstonandClaude Fable 5.1 d5d897f359 Build internal metadata from Python through the JSON constructor
`EventInternalMetadataData::from_python_pair` and `from_json_pair` carried
the same table of known keys and their types. `from_python_pair` now
depythonizes the value and goes through `from_json_pair`, which reports an
unknown key and a wrong type separately. `from_json_str` then behaves as
`new` does: it skips unknown keys silently and warns only on a wrong type,
rather than warning on every key an old row carries that Synapse no longer
reads.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-13 15:32:24 +00:00
Erik JohnstonandClaude Opus 5.5 bf17370b6a Make the Rust state resolution synchronous under trial
The Rust resolution fires its deferred from the tokio threads, and the
fake reactor only waits for those inside a request. Background work that
resolves state, such as processing a PDU after /send has returned, had
not finished when tests checked its effects, so tests failed with
SYNAPSE_TEST_RUST_STATE_RES set.

The handler gains resolve_blocking, and test homeservers use it when
SYNAPSE_TEST_RUST_STATE_RES is set, as make_fake_db_pool already does
for database transactions. tests/state/test_rust_handler.py still covers
the deferred.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-13 11:50:49 +00:00
Erik JohnstonandClaude Opus 5.5 eec1906e75 Answer conflict-free Rust state resolutions synchronously
When the state sets agree there is nothing to read or resolve. The
handler now separates the state under the GIL and, without conflicts,
returns an already-fired deferred instead of checking out a connection
and hopping through the tokio runtime.

This also keeps the common path synchronous, as the Python one is:
test_failed_partial_join_is_clean checks the partial-state flag right
after the join fails, and the resync that clears it resolves zero state
groups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-13 11:37:42 +00:00
Erik JohnstonandClaude Opus 5.5 553bfa4558 Report the time spent building events in Rust state res stats
Loading an event means parsing its JSON and recomputing its event ID,
which the Python event cache usually saves. parse_time shows how much
that costs, to decide whether the Rust side needs an event cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-13 11:23:30 +00:00
Erik JohnstonandClaude Opus 5.5 22bc7cdb38 Return the state res connection before running the algorithm
v2::resolve is now prepare, which does all the reads, and
PendingResolution::run, which does no I/O. The handler commits and
returns its pooled connection between the two, so a large resolution
does not hold one of the pool's connections while it sorts and checks
auth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-13 11:13:29 +00:00
Erik JohnstonandClaude Opus 5.5 c427fc3238 Add a Rust StateResolutionHandler
RustHandlers gains state_resolution when the main database uses the
native driver. Its resolve() converts the state sets and in-flight
events under the GIL, then resolves on the tokio runtime in one
read-only transaction and returns a deferred of the state and a
StateResStats with the database and CPU figures. A room without a chain
cover index fails with NoChainCoverIndexError so the caller can use
Python instead.

The pool is read from RustConnectionPool._pool on each call, as close()
and start() replace it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-13 10:49:24 +00:00
Erik JohnstonandClaude Opus 5.5 391000cffe Port state resolution v2 and v2.1 to Rust
A port of synapse/state/v2.py over the Rust state res store. The async
layer separates the conflicted state, computes the auth chain difference
(handling unpersisted events and the v2.1 conflicted subgraph) and then
loads every event the algorithm can read: the conflicted set, the base
state, their auth events, and the power levels chains the mainline sort
walks. The Python fetches those lazily inside the sorts.

The core (power sort, iterative auth checks, mainline sort) does no I/O.
It runs on the tokio workers and yields every 100 iterations, as the
Python does, rather than on spawn_blocking, which the crate avoids
because blocking tasks outlive the runtime's shutdown.

Resolutions of conflicted sets are cached under the same key as the
Python's _conflict_cache_key, with the same size bound.

The graphs in tests/state/test_v2.py are ported as cargo tests over the
in-memory store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-13 10:49:24 +00:00
Erik JohnstonandClaude Opus 5.5 0bbe571c20 Add a Rust state res store on the native Postgres driver
Everything state resolution reads from the database, in Rust, inside one
REPEATABLE READ READ ONLY transaction on a connection from the native
driver's pool:

- An event loader that builds Events from event_json rows the way
  EventsWorkerStore does, including its room version fallbacks.
- A port of the chain cover auth chain difference, with the v2.1
  conflicted subgraph and the fixup for unindexed events. Rooms without
  a chain cover index are reported as NoChainCoverIndex, as the Rust
  side has no fallback.
- An in-memory store for the algorithm's unit tests.

Test-only pyfunctions expose the loader and the auth chain difference
so trial can compare them with the Python store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-13 10:49:24 +00:00
Erik JohnstonandClaude Opus 5.5 34fdef7a9d Use the Rust auth rules from Python
check_state_dependent_auth_rules and auth_types_for_event now call the
Rust implementation, so every caller (federation, sticky events, state
res v1 and v2) uses it. The Python helpers that only they used are gone.
The ed25519 check for third-party invites stays in Python, as a helper
the Rust rule calls.

auth_types_for_event returns its pairs in the order the Python added
them. Event creation lists the auth events in the set's iteration
order, so a Rust HashSet's random order changed event IDs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-13 10:49:24 +00:00
Erik JohnstonandClaude Opus 5.5 afae38f4ee Port the state-dependent auth rules to Rust
A port of check_state_dependent_auth_rules and auth_types_for_event,
with the helpers they use: power level lookups with Python's int()
coercion, the membership rules, _check_power_levels, check_redaction and
the MSC3757 and MSC4289 rules.

Failures come in two kinds. Rejected maps to AuthError, which state
resolution skips. Invalid covers what the Python raises as something
else (SynapseError(400) from power level validation, ValueError from
int(), KeyError from a missing field), which state resolution must not
swallow.

Third-party invites do the structural checks in Rust and call back into
Python for the ed25519 signature check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-13 09:54:14 +00:00
Erik JohnstonandClaude Opus 5.5 2143381b6a Add Event::from_parts and internal metadata from JSON
Rust code can now build an Event without Python: from the parsed JSON,
or from the JSON text stored in event_json. The Python constructor calls
from_parts, so event ID and room ID derivation lives in one place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-13 09:52:19 +00:00
Erik JohnstonandClaude Opus 5.5 d3aa37e502 Expose Rust-side accessors on Event
The pymethods stay as thin wrappers. Rust callers (the auth rules and
state resolution) need the event's type, state key, content, sender and
room version without going through Python.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-13 09:47:26 +00:00
Erik JohnstonandClaude Fable 5.1 4636443dd2 Ask for the isolation level on BEGIN rather than setting the session default
The Rust pool set REPEATABLE READ as the session's default transaction
isolation, so autocommit statements ran at REPEATABLE READ too. psycopg2
applies its isolation level only to the transactions it opens, and
autocommit statements keep the server's default of READ COMMITTED.
Synapse's worker read/write lock release runs in autocommit mode and
relies on that: Complement's TestMSC3757OwnedState failed on the Rust
driver with a foreign-key violation while releasing a lock, and passed
on psycopg2.

Mirror psycopg2: the connection opens its implicit transactions with
`BEGIN ISOLATION LEVEL ...` (REPEATABLE READ unless the engine's
`set_isolation_level` says otherwise), and the pool no longer touches the
session default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-12 21:07:12 +00:00
Erik JohnstonandClaude Fable 5.1 5c17f8fcd2 Resolve libpq environment defaults in one place
The `PG*` fallbacks for the DSN lived in `mod.rs` and the `PGSSL*` ones
in `tls.rs`, each with its own env helper. Put both behind one
`resolve_connection_defaults`, which the pool calls once, so the two
cannot drift apart and `tls::build` stays a pure function of its params.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-12 18:46:12 +00:00
Erik JohnstonandClaude Fable 5.1 63558ec96e Borrow query parameters in execute instead of cloning them
`execute` cloned every parameter list to keep a copy for the stale-plan
retry. `query_raw` takes borrowed values, so pass a slice and iterate it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-12 18:45:17 +00:00
Erik JohnstonandClaude Fable 5.1 d04ec15bfc Enforce txn_limit in the Rust pool rather than per thread
`DatabasePool` enforces `txn_limit` by counting transactions per worker
thread and reconnecting the thread's connection past the limit. The
Rust pool does not pin connections to threads, so that count recycled
whichever connection the thread happened to check out.

Give the native pool a `max_uses` instead: deadpool's recycle hook
retires a connection once it has served that many checkouts, which is
what `txn_limit` means to bound. `DatabasePool` skips its own counting
when the pool reports that it handles the limit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-12 18:43:26 +00:00
Erik JohnstonandClaude Fable 5.1 8c66441a95 Export connection-pool metrics for the Rust backend
Pool exhaustion was invisible until checkouts timed out. Expose
deadpool's occupancy from the Rust pool and publish it as gauges per
pool (max connections, open connections, idle connections, waiting
checkouts), plus a histogram of the time a checkout waits for a
connection.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-12 18:41:08 +00:00
Erik JohnstonandClaude Fable 5.1 dceabefad5 Apply idle_in_transaction_session_timeout on the Rust backend
develop gained an `idle_in_transaction_session_timeout` database option,
applied by the psycopg2 engine's `on_new_connection`. The Rust backend
does its session setup in the pool instead, so carry the setting through
`PoolConfig` and set it alongside `statement_timeout` when a connection
is opened.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-12 18:38:24 +00:00
Erik JohnstonandClaude Fable 5.1 2688ede37c Collapse the Rust pool constructors into a PoolConfig
`create_pool`, `create_pool_with_session` and the eleven-argument
`ConnectionPool.__new__` grew one parameter at a time. One `PoolConfig`
with a `Default` replaces the wrapper, so a caller sets only the fields
it cares about and the next pool option is a field rather than a new
positional argument.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-12 18:37:47 +00:00
Erik JohnstonandClaude Fable 5.1 2c6d53886f Decode numeric columns to Decimal in the Rust value mapping
`numeric` results had no decoding arm, so `SUM(bigint)`, `AVG(...)` and
the `::NUMERIC[]` cast in the room statistics query all failed with
"failed to decode column". Decode the wire format (base-10000 digits
with a weight and display scale) to its decimal text and hand that to
`decimal.Decimal`, which is what psycopg2 returns for these columns.
Arrays of numeric decode element-wise through the existing array path.

Binding a `Decimal` parameter is still unsupported; Synapse never does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
2026-10-12 18:35:42 +00:00
Erik JohnstonandClaude Opus 4.8 f2b41a6005 Add a connection-checkout timeout to the native Rust Postgres pool
A checkout from the Rust connection pool (`ConnectionPool.connect`) previously
blocked indefinitely while waiting for a free slot or establishing a new
connection, so an unreachable or overloaded server could hang the worker
threads handling requests forever.

Bound the whole checkout with a configurable timeout: deadpool's `wait` and
`create` timeouts (enabled via its `rt_tokio_1` runtime feature, driven on our
own tokio runtime). Hitting it raises an OperationalError, which Synapse
already treats as a retryable operational error.

Configurable via the `pool_checkout_timeout` database option (milliseconds;
default 30s, `0` disables), read by the Rust engine and plumbed to both the
connection pool and the bootstrap (pool-of-one) connection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3G4M92AmwSSZCbmtMJU3d
2026-10-12 18:31:24 +00:00
Erik JohnstonandClaude Opus 4.8 c8d43aebc7 Add TLS support to the native Rust Postgres backend
The Rust backend connected with `NoTls`, so it couldn't talk to a Postgres that
requires (or, as libpq defaults, prefers) TLS — a blocker for most managed /
networked databases. Honour the same libpq keys psycopg2 already forwards, so an
existing `database.args` keeps working: `sslmode`, `sslrootcert`, `sslcert`,
`sslkey`, `sslpassword`.

Implemented on rustls (already in the tree via reqwest; pinned to the `ring`
provider since `aws-lc-rs` needs a C toolchain we don't have). `tokio-postgres`
only knows `sslmode` disable/prefer/require and does no verification itself, so a
new `postgres/tls.rs` drives both the handshake mode and the verifier:

  disable      -> no TLS
  allow/prefer -> encrypt opportunistically, no verification  (accept-any)
  require      -> encrypt, no verification (libpq's `require` does NOT verify)
  verify-ca    -> verify the chain, not the hostname          (WebPKI, name bypassed)
  verify-full  -> verify chain + hostname                     (WebPKI)

`sslrootcert` (or the system trust store) supplies the roots; `sslcert`/`sslkey`
add a client certificate for mutual TLS. The cert-path keys and the `verify-*`
modes are stripped from the DSN and passed to the pool as explicit params
(`rust_dbapi.split_ssl_params`), since `tokio-postgres` can't parse them.

Behaviour change: the default (no `sslmode`) is now libpq's `prefer` — attempt
TLS, fall back to plaintext — where it was previously no TLS. This matches
psycopg2; plaintext servers still connect via the fallback.

Caveats vs libpq/openssl: rustls is stricter (requires SANs, rejects a
non-CA self-signed root), and an encrypted client key (`sslpassword`) isn't
supported and is rejected with a clear error.

Validated end-to-end against an SSL Postgres: require / verify-ca / verify-full
connect and use TLS, verify-full with only the system roots correctly rejects an
unknown CA, and hostname is checked (verify-full) / ignored (verify-ca). Unit
tests cover the sslmode->verifier mapping; the Rust-backend storage suites still
pass with the new default (prefer falls back against the plaintext test server).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3G4M92AmwSSZCbmtMJU3d
2026-10-12 18:31:24 +00:00