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
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
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>
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
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
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
- `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
`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
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
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
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
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
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
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
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
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
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
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
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
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
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
`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
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
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
`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
`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
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
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
Every `execute` did a fresh `prepare` + `query_raw` — two server round-trips
per query — and threw the `Statement` away; even BEGIN/COMMIT/ROLLBACK went
through a prepare. psycopg2 pays neither cost, which showed up as timing
margins under load (e.g. the worker-lock contention stress test timing out
under a parallel test run).
The pooled item is now a `PooledClient`: the `Client` plus a per-connection
statement cache keyed by SQL (`prepare_cached`). The cache lives with the
pooled connection — named prepared statements are per-session server state, so
they survive checkouts and a repeated query on any later checkout skips the
prepare round-trip. `PooledClient` derefs to `Client`, so existing call sites
are unchanged. The map is cleared at a size cap so one-off SQL (e.g.
execute_values' literal-spliced statements) can't grow it without bound; the
hot set re-warms in one round.
Concurrent DDL can invalidate a cached plan (SQLSTATE 0A000, "cached plan must
not change result type"). `execute` handles it: outside a transaction the
statement is re-prepared fresh and retried once; inside one (already aborted)
the stale cache entry is shed without I/O and the error propagates, so the
caller's next transaction gets a valid plan.
Transaction control (BEGIN/COMMIT/ROLLBACK) now uses `batch_execute` — the
simple-query protocol, one round-trip, nothing to prepare.
Verified: rust tests 158/158; storage/lock/worker-lock suites pass on the Rust
backend, including the worker-lock contention test under -j4 that previously
exceeded its wall-clock budget.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3G4M92AmwSSZCbmtMJU3d
Two unit tests still asserted that `json` is rejected by the value mapping,
which became stale when json/jsonb decode and encode were added:
`accepts_lists_match_supported_types` now checks json/jsonb are accepted by
both `ToSql` and the Python decoder (while remaining outside the shared scalar
list), and `from_sql_rejects_unsupported_column_type` uses `uuid` as its
genuinely-unsupported type.
Full `cargo test` is green again (157 passed); caught by a full-suite run after
filtered runs had missed it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3G4M92AmwSSZCbmtMJU3d
Synapse binds some binary values (e.g. slices of event data) as a `memoryview`
rather than `bytes`/`bytearray`. The shim's `from_py` handled the latter two but
not `memoryview`, so those parameters raised `TypeError: unsupported parameter
type for postgres: memoryview` — e.g. persisting an event over federation
(seen via `test_third_party_rules.test_on_new_event`).
Accept a `memoryview` as a BYTEA parameter, copying its bytes out with
`tobytes()` (the buffer protocol isn't available under the limited ABI the crate
builds against).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3G4M92AmwSSZCbmtMJU3d
A custom profile field value is a JSON document passed as text and cast
with `?::jsonb` in the query; reading one back binds a `jsonpath` text
parameter. `json`'s wire format is the raw text, and `jsonb` / `jsonpath`
share a one-byte version header followed by the text, so accept a `Text`
value for all three.
The matching query-side change (pass the value as a str instead of
`psycopg2.extras.Json`) is driver-independent and lives in its own PR.
Reading a `json`/`jsonb` column failed with "error deserializing column" — the
value mapping had no decoder for them. Synapse reads genuine `jsonb` values, most
visibly for custom profile fields (`get_profile_field` selects
`JSONB_EXTRACT_PATH(fields, ?)`), so those requests returned HTTP 500 on the Rust
backend.
Decode `json`/`jsonb` into the matching Python object (dict/list/scalar), as
psycopg2 does by default: `json` is the raw JSON text, `jsonb` a one-byte version
header (currently 1) then the text; parse with serde_json and convert via
pythonize. `PythonPgFromSql::accepts` now also accepts `json`/`jsonb`; the
Rust-native `DbValue` decoder stays scalar-only (nothing reads json through it).
Fixes the profile custom-field 500s (e.g. test_non_string,
test_can_lookup_own_profile) on the Rust backend.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3G4M92AmwSSZCbmtMJU3d
The value mapping bound arrays as parameters (a `list` → `Array`) but couldn't
decode an array *column* back out — the decode side assumed "Synapse only binds
arrays as parameters". That assumption was wrong: the caches replication stream
reads `keys` (a `text[]`) via `get_all_updated_caches`, so decoding failed with
"error deserializing column", the stream read errored, and a worker never
received bulk cache invalidations (the `wait_for_stream_position` in the test
hung and never fired).
Decode an array column into a Python `list`, each element decoded by the array's
element type (via `array_from_sql`); `PythonPgFromSql::accepts` now also accepts
arrays of a supported scalar element type, mirroring `PgValue`'s `ToSql`. The
Rust-native `DbValue` decoder stays scalar-only — nothing reads array columns
through it. Adds `fallible-iterator` (the version postgres-protocol already
uses) to iterate the decoded array's elements.
Fixes tests.storage.databases.main.test_cache.CacheInvalidationOverReplication's
test_bulk_invalidation_replicates on the Rust backend.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3G4M92AmwSSZCbmtMJU3d
Two value-mapping gaps surfaced by the storage suite on the Rust backend:
- `bytearray` parameters. Synapse deliberately passes binary data as
`bytearray` (the psycopg2 engine disables the `bytes` adapter to catch
accidental text-as-bytes bugs), but the shim only accepted `bytes`, so
binary background-update parameters raised `TypeError`. Accept `bytearray`
as a BYTEA parameter too.
- `tid` columns. The receipts-dedup background update selects a row's `ctid`
and then compares against it (`WHERE ctid != ?`). psycopg2 renders `tid` as
a `(block,offset)` string and accepts that string back; the shim had no
`tid` support at all. Decode `tid` to that same string (three big-endian
u16s: the block number's halves, then the offset — matching the server's
`tidsend`) and encode the string back to the wire form when it is bound as a
parameter.
Both directions are covered for the Python (`PythonPgFromSql`) and the
Rust-native (`DbValueFromSql`) decoders and the `ToSql` encoder, and `tid` is
added to the shared `accepts_column_type` set.
Fixes tests.storage.test_user_filters.test_bg_migration and
tests.storage.databases.main.test_receipts's linearized-unique-index background
update on the Rust backend.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3G4M92AmwSSZCbmtMJU3d
Add SYNAPSE_TEST_RUST_DRIVER to run the Postgres test suite against the native
Rust driver (sets use_rust_driver on the test database config, off by default).
Teach the harness's make_fake_db_pool to build a RustConnectionPool for the Rust
engine, with its worker thread pool swapped for the threadless test pool so
queries run deterministically on the reactor during `pump` (the shim's block_on
waits there for the tokio workers). Test-database create/drop is admin work, so
it now goes directly over psycopg2 (always available in tests) rather than the
engine module's `connect`.
Running the suite this way surfaces a few real psycopg2-compatibility gaps:
- `fixup_config_defaults` (was `fixup_default_host`) now also fills libpq's
PGUSER / PGPASSWORD environment defaults, not just the host — tokio-postgres
reads none of them from the environment, but Synapse's test setup relies on
them (as psycopg2 does).
- `build_dsn` skips `None`-valued args, matching psycopg2's handling of unset
kwargs.
With these, a homeserver boots on the Rust backend under the test harness and
starts running real transactions and background updates. The remaining psycopg2
type-coercion / behaviour differences are addressed in the commits that follow;
psycopg2 and sqlite runs are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3G4M92AmwSSZCbmtMJU3d
`DatabasePool`'s `inner_func` calls `conn.reconnect()` when a connection is
found closed, or to recycle one that has exceeded the per-connection
transaction limit (`txn_limit`). The Rust DBAPI2 adapter had no `reconnect`, so
those paths (off by default, but real) would have raised.
Add `reconnect` to the adapter: it returns the current connection to the pool
(or discards it if unusable) and checks out a fresh one. The adapter now holds
the pool it was checked out of, with an `owns_pool` flag distinguishing a shared
pool (RustConnectionPool — reconnect from it, don't close it) from a bootstrap
pool-of-one (`rust_dbapi.connect` — closed together with the connection).
Tested: reconnect swaps in a working connection; runWithConnection and the
bootstrap/adapter paths are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3G4M92AmwSSZCbmtMJU3d
A running homeserver exercises three shim capabilities beyond what the unit
tests reached; add them so it boots on the Rust backend end to end:
- `prepare_database` reads `db_conn.autocommit`; add a readable `autocommit`
getter to the shim `Connection` (and the adapter), mirroring psycopg2's
property.
- schema preparation runs multi-statement scripts via
`engine.execute_script_file` -> `cursor.executescript`; route
`LoggingTransaction.executescript` to the shim's multi-statement primitive
for the Rust backend (as it already does for sqlite), and expose
`executescript` on the DBAPI2 adapter cursor.
- store loading binds Python lists as Postgres arrays for `column = ANY($1)` /
`!= ALL($1)` (Synapse's `make_in_list_sql_clause`); add a `PgValue::Array`
variant that classifies a `list` and encodes it as an array of the element
column type, and accept array column types in `ToSql`.
With these, a homeserver boots on the Rust backend: it prepares the full schema
(176 tables), listens, `/health` returns OK, and DB-backed client endpoints
respond correctly.
Tested: array `to_sql` / classification and array-column `accepts` (Rust unit
tests); and, against a live Postgres, `= ANY($1)` list binding, adapter
`executescript`, and the `autocommit` getter. sqlite and psycopg2 homeserver
boots remain green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3G4M92AmwSSZCbmtMJU3d
Move the equivalent of the engine's `on_new_connection` into the pool's
`ConnectionManager::create`, so every pooled connection is configured once when
it is opened rather than on each checkout. A new `SessionConfig` (carried by the
manager) drives it:
- the default isolation level is set to REPEATABLE READ, matching the engine's
`default_isolation_level`, so a plain BEGIN behaves as it did under psycopg2;
- `synchronous_commit` is turned off when configured;
- `statement_timeout` is set when configured.
`bytea_output` is deliberately not set: tokio-postgres uses the binary protocol
for prepared statements, so that text-format GUC is irrelevant to how we decode
bytea.
`create_pool` keeps its signature (default session); a new
`create_pool_with_session` and two new keyword-only `ConnectionPool(...)`
arguments (`synchronous_commit`, `statement_timeout_ms`) expose the settings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3G4M92AmwSSZCbmtMJU3d
The database engine's `is_connection_closed` and `in_transaction` checks (used
by the transaction driver before reusing a pooled connection) need to read
these off the raw connection, as they do from psycopg2's `conn.closed` /
`conn.status`. Add the two accessors to the shim: `is_closed()` is true once the
connection is closed/returned/discarded or the socket is torn down, and
`in_transaction()` reports the tracked transaction flag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3G4M92AmwSSZCbmtMJU3d
`RustConnectionPool` runs synchronous Synapse transaction functions off the
reactor thread against the native Rust connection pool. Each
`run_with_connection(func, *args)` call runs, on a dedicated Twisted thread
pool, `func(conn, *args)` with a `Connection` checked out of the Rust pool, and
returns a Deferred that fires on the reactor with the result (or an errback if
it raised). It builds on `defer_to_threadpool`, so log contexts are preserved
across the hop, and returns the connection to the pool afterwards (clean →
reused, mid-transaction/broken → discarded, per the shim's disposal rules).
The Rust pool sizes 1:1 with the thread count, and `PyConnectionPool.close()`
(new) closes all idle connections so the owner can drop the server connections
deterministically. The bridge is lifecycle-owner-managed (start/close) and
takes no clock, keeping it trivially testable; slotting it into
`DatabasePool.make_pool` — which also needs engine-level support for the shim
connection (in_transaction, is_connection_closed, autocommit/isolation,
reconnect) — is left to a follow-up.
Tested against a live Postgres over the real reactor (Synapse's in-memory test
reactor mocks the DB thread pool out, so a plain Twisted trial TestCase is used):
result/return-value, arg forwarding, exception-to-errback, connection reuse
across calls, and concurrent checkouts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3G4M92AmwSSZCbmtMJU3d
The Python-facing `Connection`/`Cursor` shim now always wraps a connection
checked out of the `deadpool` pool. `ConnInner` holds a `PooledConnection` and
disposes of it correctly when the last reference (the `Connection` and all its
cursors) goes away, via a `Drop` impl plus `release`/`discard` helpers:
- a clean connection is returned to the pool for reuse;
- a connection is *discarded* (detached with `Object::take`, which also
shrinks the pool) whenever reuse would be unsafe — a failed COMMIT/ROLLBACK,
a poisoned mutex, or being dropped with a transaction still open (which
can't be rolled back synchronously from `Drop`, so the socket close makes
the server do it).
A plain query error still does *not* throw the connection away — it stays
open+aborted for the driver to `rollback()`, exactly as psycopg2 behaves.
The pool is the only way to obtain a connection: the standalone `connect(dsn)`
free function is replaced by a Python-facing `ConnectionPool` class (Rust
`PyConnectionPool`, exposed as `postgres.ConnectionPool`). Build it once from a
DSN, then check connections out with `pool.connect()`. Checkout failures map
onto the DBAPI2 hierarchy — a backend connect error reuses the query-error
mapping (and its `pgcode`), while a timeout / closed pool becomes
`OperationalError` — so `connect()` behaves like psycopg2's for callers.
Adds live-Postgres tests (gated on SYNAPSE_TEST_POSTGRES_DSN) asserting which
connections end up back in the pool, and drives the Python test suite through a
pool in `setUp`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3G4M92AmwSSZCbmtMJU3d
Introduce the native Rust connection pool that both the Rust-native
query path and the Python-facing Connection/Cursor shim will draw from,
so we run a single pool rather than two that could exhaust the server's
connection limit between them.
Uses the generic deadpool::managed pool with our own ConnectionManager
(rather than deadpool-postgres) so connection creation reuses the
existing connect/default-host logic and connection-task spawning. The
pooled item is a plain tokio_postgres::Client, used with the standard
async query functions; recycle() drops connections the driver reports
closed. Live tests are gated on SYNAPSE_TEST_POSTGRES_DSN.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implement DBAPI2's `executemany`, which Synapse uses for batched writes.
The statement is prepared once and run for each parameter set inside the
connection's (lazily opened) transaction, so a failure part-way aborts
the whole batch. The per-set executions are pipelined — their futures
are driven concurrently so tokio_postgres streams the batch onto the
connection in one round-trip rather than one per statement.
Afterwards `rowcount` reports the total rows affected across all
executions (as psycopg2 does) via a new fetchless "command complete"
cursor state; there is no result set to fetch or describe. An empty
parameter sequence is a no-op that leaves rowcount at -1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The column names for a result set were already carried through the
cursor state machine but kept write-only, awaiting a reader. Add the
PEP-249 `Cursor.description` accessor on top of that plumbing: it
returns one 7-tuple per column (only the name populated, which is all
Synapse reads), or `None` when there is no row-returning result set —
before any query, after an error, or for a column-less statement such
as a bare INSERT, matching psycopg2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Synapse's schema setup (`prepare_database.py`) runs `;`-separated SQL
scripts via the engine's `executescript`. `Cursor.execute` can't serve
those: it `prepare`s the query, and Postgres rejects multiple commands in
a prepared statement.
Add `Cursor.executescript`, which runs the whole script on the simple-query
protocol (`batch_execute`), which does allow multiple statements. It takes
no parameters and produces no fetchable rows.
The script runs inside the connection's *current* transaction (opened
lazily like `execute`) and is left open for the caller to commit. It
deliberately does NOT reproduce the commit-any-pending-transaction-first
behaviour of `sqlite3.executescript` (which psycopg2's engine mirrors with a
leading `COMMIT`). That forced commit actually undercuts the atomicity
`prepare_database` sets out to get — it opens a transaction so "upgrades are
either applied completely, or not at all", but the first script's implicit
commit ends it. Running the script within the ongoing transaction instead
lets successive scripts accumulate and be committed once, which is both
simpler and more correct. The only engine-level piece left to layer on top
is the auto-increment placeholder substitution.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Previously every `tokio_postgres` error became a bare `RuntimeError`. Synapse's
transaction driver, though, branches on the *type* of a database error and on
its `pgcode`: `new_transaction` retries `OperationalError`, retries deadlocks it
recognises via `is_deadlock` (which reads `pgcode`) on a `DatabaseError`, and
`simple_upsert` retries `IntegrityError`. With everything collapsed to
`RuntimeError` none of that fired.
Add just the distinctions Synapse acts on, rather than psycopg2's full PEP-249
hierarchy: `Error` -> `DatabaseError` -> {`OperationalError`, `IntegrityError`},
exposed on the `postgres` submodule, each instance tagged with `pgcode` (the
SQLSTATE string, or `None`). A small classifier maps the SQLSTATE class:
constraint violations (`23`) to `IntegrityError`, connection/resource classes
(`08`/`53`/`57`/`58`) to `OperationalError`, everything else (incl. `40*`
deadlocks, which retry via `pgcode`) to `DatabaseError`. Codeless errors are
split with `is_closed()`: a lost connection is operational, any other (a bad
parameter, a failed connect) is a plain `DatabaseError` so it isn't retried.
Errors surfacing while a result stream is drained (the usual case for an
`INSERT` constraint violation) now route through the same mapping, so they carry
the right class and `pgcode` too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tokio_postgres has a different default host than libpq, which is what
Synapse used via psycopg2 (and what psql uses). libpq's default host is
configurable at build time, so when the DSN omits a host ask libpq what
its default would be, through the `crate::libpq` wrapper that loads the
system libpq at runtime, and use that.