Files
synapse/tests/replication
Kegan DougalandClaude Fable 5.1 6c5fccd908 C1: Persist MSC4430 member key membership keyed by user where the server needs a user
In an MSC4430 member key room the membership state keys and senders are
per-room Ed25519 keys, not user IDs. The room's own tables keep those keys
(`current_state_events`, `room_memberships`, the state and auth chains), but
several tables and caches are keyed by *user* and are what everything
downstream of persistence consults to answer "which rooms is this user in":
`local_current_membership`, the sliding sync membership snapshots,
`event_txn_id_device_id` (a foreign key to `devices`), `get_rooms_for_user`
and its relatives, and the membership stream cache that gates incremental
sync. This change fills those with the user behind each key we hold, on the
main process and on workers, and leaves rooms without member keys on exactly
the path they took before. The member key room version is not registered, so
none of it is reachable.

The local identity map. Storage cannot import the handler that owns identity
resolution for the rest of the server, so the storage half lives on the member
keys store: `MemberKeysWorkerStore.get_local_identity_map_txn(txn, room_id,
identities)` and its async counterpart `get_local_identity_map`. Both turn
room member identities into local user IDs: our users' IDs map to themselves,
member keys we hold *in that room* map to their user, and everything else
(other servers' users, other members' keys, and our own keys for other rooms)
is dropped. Member keys are only looked up when `msc4430_enabled` is on; off,
a member-key-shaped state key is as malformed as it always was and is dropped
as `is_mine_id` dropped it. The lookup is one batched query, and none at all
when no identity is a member key, so rooms without member keys cost nothing.
The map is built once per persisted batch at the top of `_persist_events_txn`
from every sender, every membership state key and every membership change in
the state delta, and threaded to `_persist_transaction_ids_txn`,
`_store_room_members_txn` and `_update_current_state_txn`; the only identities
not known until inside the transaction are the room's members when we are
leaving it, which `_update_current_state_txn` adds with a second lookup. The
partial-state resync path (`update_current_state`) builds its own map for its
delta. The async form serves `_calculate_sliding_sync_table_changes` and the
persistence controller, from the cached per-key lookups; keys never change
once created, so a cached answer can only be missing, never wrong.

What is keyed by user now. `_update_current_state_txn` writes
`local_current_membership` rows for the mapped users; the sliding sync
snapshot rows carry the mapped user (and the member key as `sender`, which is
what the room says); `_store_room_members_txn` does the same for out-of-band
memberships (remote invites and their rejections); `_persist_transaction_ids_txn`
stores the mapped sender, and `get_already_persisted_events` (the
persistence-time deduplication of a transaction ID) looks it up the same way,
so two racing sends of one transaction ID in a member key room still
collapse to one event; a sender the map does not know is stored as before,
so the foreign key makes a key that fails to map fail loudly rather than
silently drop the transaction ID; the persisted-events metric counts events
from our keys as local.

Caches and workers. Membership cache invalidations are keyed by the event's
state key, in `_invalidate_caches_for_event` (every event, and the events
stream for workers) and in `_invalidate_state_caches` (current state changes,
and the caches stream). In a member key room that key is the member key, so
the user-keyed caches `_invalidate_caches_for_event` touches
(`get_rooms_for_user`, `_get_rooms_for_local_user_where_membership_is_inner`,
`get_sliding_sync_rooms_for_user_from_membership_snapshots`,
`get_invited_rooms_for_local_user`, `did_forget`,
`get_forgotten_rooms_for_user` and the membership stream cache) are now also
invalidated for the user behind the key: once per membership event at the
event's own position, next to the per-event invalidation, and once from
`_update_current_state_txn` at the batch's position for members with no
event in the batch (a state reset, or everyone in the room when we leave it).
Workers get these as a row of their own on the caches stream
(`MEMBER_KEY_USERS_CACHE_NAME`, keys `[room_id, stream_ordering, *user_ids]`,
handled by `_invalidate_user_caches_for_member_keys`): the events stream rows
name the key and a worker cannot map it, and the current-state rows would
record the change at the caches stream's own position, which the membership
stream cache ignores when it is below the worker's starting event position
and misplaces when it is above. Only users whose state key is a key get these
rows; a user whose state key is their user ID has already had exactly these
invalidations, so rooms without member keys see no extra rows.
`_invalidate_state_caches` itself no longer parses member keys as user IDs: a
key names no host, so a membership change under one drops the
`is_host_joined` / `is_host_invited` entries for the whole room, which needs
those two caches to be tree caches keyed by room first. That is the one
change not gated on member keys: the caches change shape for every room (an
entry can be dropped by its room prefix), the alternative being to flush them
for every room on every change in a member key room. The replication client's
notifier wake-up on workers parsed the membership state key as a user ID,
which for a key raised and aborted the whole batch of replication rows
(nothing in that batch, in any room, woke a sync on that worker); it now
wakes the user behind one of our keys, and nobody for anyone else's.

Reads. `get_rooms_for_user` and `get_rooms_for_users` union
`local_current_membership` for local users when the flag is on; it is keyed
by user ID in every room and agrees with `current_state_events` in rooms
without member keys, so the union adds only the member key rooms. With the
flag off the legacy query runs byte for byte.
`get_membership_changes_for_user` also matches the user's member keys
(`room_memberships.user_id` is the state key) when the flag is on, scoped to
each event's room: keys are public strings, so a membership event in room B
naming the user's room A key is not a membership change of theirs. In the
persistence controller, extremity pruning groups senders by origin to drop
an old extremity sooner when the same origin sends again; in a member key
room a key is its own origin (the stricter reading), and the "never drop our
own events" walk uses the map rather than `is_mine_id`.
`_is_server_still_joined` maps the delta's member keys to users and passes
user IDs to `is_local_host_in_room_ignoring_users`, which queries
`local_current_membership`; before this the server would not have noticed
its last member leaving a member key room.

Remote member keys. Those reach `handle_potentially_left_users_txn` as
opaque identities (nothing in the map), where "do we still share a room with
them" is answered from `current_state_events` by state key and the
unsubscribe is a no-op delete plus one cache invalidation.

Run against the unchanged code, the tests show how an unadapted site fails:
thirteen of the fourteen persisting tests end in `MemberKeyHasNoDomainError`
(500) from `_invalidate_state_caches`, the first site on every path to parse
a member key as a user ID, which hides the ones behind it
(`_update_current_state_txn`, `_calculate_sliding_sync_table_changes`,
`_persist_transaction_ids_txn`, `_prune_extremities`,
`_is_server_still_joined`, the replication client, all adapted here). The one
silent failure was the out-of-band path in `_store_room_members_txn`:
`is_mine_id` answered False for the invitee's key and the invite was simply
never recorded for the user.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 09:22:39 +02:00
..
2026-06-02 11:05:38 +01:00