Files
synapse/tests/replication/test_member_info.py
T
Kegan DougalandClaude Fable 5.1 61da72e7f5 A3: Add a stream for MSC4428 member_info changes with no event anchor
Changes to a member's `member_info` entry that no event in the room anchors
(erasure, and MSC4430 link verification) were only delivered when the member
next appeared in a response. Give them a stream so that syncing clients are
woken and told.

* `StreamToken.member_info_key`: a trailing `MultiWriterStreamToken`
  component (`StreamKeyType.MEMBER_INFO`); tokens without it still parse.
* `msc4428_member_info_changes` (delta 95/01) behind a multi-writer id
  generator with no fixed writer list, as for cache invalidations: any
  worker may write (erasure runs wherever deactivation is handled).
  `MemberInfoChangesStream` replicates rows; workers wake room members.
* Retention: rows older than 30 days are pruned hourly on the background
  worker and the prune point recorded; a since token older than that is
  sent the full map per room instead of deltas.
* Producer: `MemberInfoHandler.on_user_erased`, from `deactivate_account`,
  one row per room the user has any membership in.
* Consumers: sync v2 reads the changes for all joined rooms once per sync
  and includes a room with an empty timeline when it has any; sliding sync
  includes such rooms when choosing what to send and resends the full map
  for rooms it last sent at an older position.

The stream exists always, like every stream, but nothing writes to it unless
`msc4428_enabled` is set.

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

82 lines
3.1 KiB
Python

#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2026 Element Creations Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
from twisted.internet.testing import MemoryReactor
from synapse.api.constants import MemberInfo
from synapse.rest import admin
from synapse.rest.client import login, room, sync
from synapse.server import HomeServer
from synapse.types import JsonDict
from synapse.util.clock import Clock
from tests.replication._base import BaseMultiWorkerStreamTestCase
from tests.server import make_request
class MemberInfoWorkerTestCase(BaseMultiWorkerStreamTestCase):
"""MSC4428: a `/sync` served by a worker sees an erasure recorded on the
main process, with nothing in the room to anchor it."""
servlets = [
admin.register_servlets,
login.register_servlets,
room.register_servlets,
sync.register_servlets,
]
def default_config(self) -> JsonDict:
config = super().default_config()
config["experimental_features"] = {"msc4428_enabled": True}
return config
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.store = hs.get_datastores().main
self.alice = self.register_user("alice", "pass")
self.alice_tok = self.login("alice", "pass")
self.bob = self.register_user("bob", "pass")
self.bob_tok = self.login("bob", "pass")
self.room_id = self.helper.create_room_as(self.alice, tok=self.alice_tok)
self.helper.join(self.room_id, self.bob, tok=self.bob_tok)
def test_erasure_reaches_sync_on_worker(self) -> None:
worker_hs = self.make_worker_hs("synapse.app.generic_worker")
site = self._hs_to_site[worker_hs]
channel = make_request(
self.reactor, site, "GET", "/sync", access_token=self.alice_tok
)
self.assertEqual(channel.code, 200, channel.json_body)
self.assertNotIn(
MemberInfo.FIELD, channel.json_body["rooms"]["join"][self.room_id]
)
since = channel.json_body["next_batch"]
# Erase Bob on the main process, without an event in the room.
self.get_success(self.store.mark_user_erased(self.bob))
self.get_success(self.hs.get_member_info_handler().on_user_erased(self.bob))
self.replicate()
channel = make_request(
self.reactor,
site,
"GET",
f"/sync?since={since}",
access_token=self.alice_tok,
)
self.assertEqual(channel.code, 200, channel.json_body)
room = channel.json_body["rooms"]["join"][self.room_id]
self.assertEqual(room["timeline"]["events"], [])
self.assertEqual(room[MemberInfo.FIELD], {self.bob: {MemberInfo.USER_ID: None}})