mirror of
https://github.com/element-hq/synapse.git
synced 2026-11-04 19:57:45 +00:00
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>