Files
synapse/tests/replication/test_sharded_sticky_events.py
Olivier 'reivilibreandEric Eastwood 7773f05596 Use sharded tokens in MSC4354: Sticky Events sync and sliding sync, preventing events appearing in multiple sync responses. (#20186)
Fixes: #19661
Part of: MSC4354

First introduced some helpers for querying a sharded stream, which were moved to
https://github.com/element-hq/synapse/pull/20108.

- SQL builder for 'give me rows between two sharded tokens'
- a function to return the 'new' from_token after a limited read


Then make the sticky events portions of both oldschool and sliding sync use
sharded tokens.

This prevents a 'torn sync' problem where, because the events and sticky
events streams are both sharded with the same writers:

1. persisting a sticky event leads to an event row and a `sticky_events`
row being created for event `e` (on writer `W`).
2. Suppose another writer is still persisting, so the position of the
stream for writer W advances, but the global
['linear'](https://element-hq.github.io/synapse/latest/development/synapse_architecture/streams.html?highlight=%E2%80%9Clinear%E2%80%9D#current-stream-id)
(I have been calling this 'baseline') position does not advance yet.
3. sync is sharding-aware for the regular events stream, so `e` appears
as a real-time timeline event, thus coming down sync
4. since sticky events were not sharding-aware, `e` is not detected as a
sticky event in this sync response. The sticky event position doesn't
advance in the sync token, either.
5. (assume the position advances because other writers have finished
writing and so the 'linear' position advances for the sticky events
stream)
6. the client then syncs again, using the new sync token. This time, `e`
appears in the sticky event section.

If we make sticky events sharding-aware in sync, then `e` would appear
for consideration in both the timeline section **and** the sticky event
section in the first sync.
It then gets deduplicated out of the sticky section since it is
appearing in the timeline section in the same sync.

---------

Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
Co-authored-by: Eric Eastwood <erice@element.io>
2026-10-05 12:40:55 +01:00

283 lines
11 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>.
import logging
import sqlite3
from contextlib import contextmanager
from typing import Iterator
from unittest.mock import AsyncMock
from twisted.internet.testing import MemoryReactor
import synapse.rest.admin
from synapse.api.constants import EventTypes
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 synapse.util.duration import Duration
from tests.replication._base import BaseMultiWorkerStreamTestCase
from tests.utils import USE_POSTGRES_FOR_TESTS
logger = logging.getLogger(__name__)
class ShardedStickyEventsTestCase(BaseMultiWorkerStreamTestCase):
"""
Tests for sharded Sticky Events.
"""
if not USE_POSTGRES_FOR_TESTS and sqlite3.sqlite_version_info < (3, 40, 0):
# We need the JSON functionality in SQLite
skip = f"SQLite version is too old to support sticky events: {sqlite3.sqlite_version_info} (See https://github.com/element-hq/synapse/issues/19428)"
servlets = [
synapse.rest.admin.register_servlets_for_client_rest_resource,
login.register_servlets,
room.register_servlets,
sync.register_servlets,
]
def default_config(self) -> JsonDict:
conf = super().default_config()
conf["experimental_features"] = {
# Sliding Sync
"msc3575_enabled": True,
# Sticky Events
"msc4354_enabled": True,
}
conf["stream_writers"] = {"events": ["worker1", "worker2"]}
conf["instance_map"] = {
"main": {"host": "testserv", "port": 8765},
"worker1": {"host": "testserv", "port": 1001},
"worker2": {"host": "testserv", "port": 1002},
}
return conf
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.store = hs.get_datastores().main
# FIXME: This can be removed once we bump `SCHEMA_COMPAT_VERSION`, see
# `SlidingSyncBase`.
self.store.have_finished_sliding_sync_background_jobs = AsyncMock( # type: ignore[method-assign]
return_value=True
)
self.user_id = self.register_user("user", "pass")
self.tok = self.login("user", "pass")
def _setup_workers_and_rooms(self) -> None:
"""
Start the two event persisters and create a room on each of them.
"""
self.make_worker_hs("synapse.app.generic_worker", {"worker_name": "worker1"})
self.worker_hs2 = self.make_worker_hs(
"synapse.app.generic_worker", {"worker_name": "worker2"}
)
# Create rooms until we have at least one on each of the workers.
# (We can't choose the room IDs directly, so we rely on the sharding
# hash to route the generated rooms to different workers.)
room_generation_results = self._generate_rooms_on_worker(self.user_id, self.tok)
self.room_id1 = room_generation_results["worker1"]
self.room_id2 = room_generation_results["worker2"]
# Verify that the expected worker is responsible for each room.
self.assertEqual(
self.hs.config.worker.events_shard_config.get_instance(self.room_id1),
"worker1",
)
self.assertEqual(
self.hs.config.worker.events_shard_config.get_instance(self.room_id2),
"worker2",
)
def _send_sticky_event(self, room_id: str, body: str) -> str:
return self.helper.send_sticky_event(
room_id,
EventTypes.Message,
duration=Duration(minutes=5),
content={"body": body, "msgtype": "m.text"},
tok=self.tok,
)["event_id"]
@contextmanager
def _stalled_sticky_writer(self) -> Iterator[None]:
"""
Manipulate worker2's sticky events `MultiWriterIdGenerator` to mimic
it getting stuck part way through persisting a sticky event.
Whilst the context manager is entered, worker2's sticky events stream position
will not advance.
This stops the 'all writers have persisted up to' position from advancing as well.
"""
worker_store2 = self.worker_hs2.get_datastores().main
stalled_write = worker_store2._sticky_events_id_gen.get_next()
self.get_success(stalled_write.__aenter__())
try:
yield
finally:
self.get_success(stalled_write.__aexit__(None, None, None))
def test_torn_oldschool_sync(self) -> None:
"""
Tests that a sticky event must not be presented in two sync responses
as a 'torn' sync response. This test is for oldschool sync.
Specifically, a sticky event must not arrive in the timeline of
the first sync response and then in the sticky events section in
a subsequent sync response.
(This is a specific failure mode that occurred when the reader of the
sticky events stream was not shard-aware.)
"""
self._setup_workers_and_rooms()
# Get our initial sync out of the way
channel = self.make_request("GET", "/sync", access_token=self.tok)
self.assertEqual(channel.code, 200, channel.result)
since = channel.json_body["next_batch"]
with self._stalled_sticky_writer():
sticky_event_id = self._send_sticky_event(self.room_id1, "sticky message")
# First response of the torn syncs: the event comes down the timeline, because the
# events stream token covers worker1's write despite worker2 lagging.
channel = self.make_request(
"GET", f"/sync?since={since}", access_token=self.tok
)
self.assertEqual(channel.code, 200, channel.result)
room = channel.json_body["rooms"]["join"][self.room_id1]
self.assertIn(
sticky_event_id,
[event["event_id"] for event in room["timeline"]["events"]],
room,
)
# Having been sent it in the timeline, we shouldn't also be sent it in
# the sticky section of the same response.
self.assertNotIn("msc4354_sticky", room, room)
since = channel.json_body["next_batch"]
# worker2 catches up, so an unsharded sticky events position is now free to
# advance past worker1's write.
self.replicate()
# Second response of the torn syncs: the sticky event shouldn't come down
# in the sticky event section.
channel = self.make_request(
"GET", f"/sync?since={since}", access_token=self.tok
)
self.assertEqual(channel.code, 200, channel.result)
room = channel.json_body.get("rooms", {}).get("join", {}).get(self.room_id1, {})
self.assertNotIn(
"msc4354_sticky",
room,
f"sticky event {sticky_event_id} was sent down the timeline in the "
f"previous sync, so we should not expect to see a sticky section in this sync: "
f"{room}",
)
def test_torn_sliding_sync(self) -> None:
"""
Tests that a sticky event must not be presented in two sync responses
as a 'torn' sync response. This test is for sliding sync.
Specifically, a sticky event must not arrive in the timeline of
the first sync response and then in the sticky events section in
a subsequent sync response.
(This is a specific failure mode that occurred when the reader of the
sticky events stream was not shard-aware.)
"""
self._setup_workers_and_rooms()
sync_endpoint = "/_matrix/client/unstable/org.matrix.simplified_msc3575/sync"
sync_body: JsonDict = {
"lists": {
"main": {
"ranges": [[0, 99]],
"required_state": [],
"timeline_limit": 10,
}
},
"extensions": {"org.matrix.msc4354.sticky_events": {"enabled": True}},
}
# Initial sync
channel = self.make_request(
"POST", sync_endpoint, sync_body, access_token=self.tok
)
self.assertEqual(channel.code, 200, channel.result)
pos = channel.json_body["pos"]
# There are no sticky events yet
self.assertNotIn(
"org.matrix.msc4354.sticky_events",
channel.json_body["extensions"],
channel.json_body["extensions"],
)
with self._stalled_sticky_writer():
sticky_event_id = self._send_sticky_event(self.room_id1, "sticky message")
# First response of the torn syncs: the event comes down the room timeline.
channel = self.make_request(
"POST", f"{sync_endpoint}?pos={pos}", sync_body, access_token=self.tok
)
self.assertEqual(channel.code, 200, channel.result)
pos = channel.json_body["pos"]
room = channel.json_body["rooms"][self.room_id1]
self.assertIn(
sticky_event_id,
[event["event_id"] for event in room["timeline"]],
room,
)
# ... and so should have been deduplicated out of the sticky section of
# the same response, leaving an empty room entry behind.
sticky_extension = channel.json_body["extensions"][
"org.matrix.msc4354.sticky_events"
]
next_batch = sticky_extension["next_batch"]
self.assertEqual(
sticky_extension,
{
"rooms": {self.room_id1: {"events": []}},
"next_batch": next_batch,
},
sticky_extension,
)
# The token should carry the per-writer positions, as worker2 is stalled.
self.assertTrue(next_batch.startswith("sticky_m"), next_batch)
sync_body["extensions"]["org.matrix.msc4354.sticky_events"]["since"] = (
next_batch
)
# worker2 catches up, so an unsharded sticky events position is now free to
# advance past worker1's write.
self.replicate()
# Second response of the torn syncs: the sticky event shouldn't come down
# in the sticky event section.
channel = self.make_request(
"POST", f"{sync_endpoint}?pos={pos}", sync_body, access_token=self.tok
)
self.assertEqual(channel.code, 200, channel.result)
# There is nothing left to send, so the extension should be omitted entirely.
self.assertNotIn(
"org.matrix.msc4354.sticky_events",
channel.json_body["extensions"],
f"sticky event {sticky_event_id} was sent down the timeline in the "
f"previous sync, so no sticky events extension should be sent now: "
f"{channel.json_body['extensions']}",
)