mirror of
https://github.com/element-hq/synapse.git
synced 2026-09-26 00:14:04 +00:00
This PR fixes the issue described as comment here: https://github.com/element-hq/synapse/issues/18793#issuecomment-3502202379 In Element Call, this shows up as ghost participants: someone who left the call keeps being displayed until a later state change refreshes the room. The bug is not specific to Element Call: any state event can be affected, RTC membership just changes often enough to make it visible. ## What happens Alice has a client syncing against a homeserver where events are persisted by one worker (the event persister) and `/sync` is served by another (the sync worker). Her client is parked in a long-poll: `GET /sync?since=s99&timeout=30000`. Bob joins a call at the same moment Carol sends a message. Carol's message reaches the persister first; Bob's `m.call.member` arrives while that write is still in flight, so the per-room persist queue groups them into one transaction: ``` events (each gets its own stream ordering): stream_ordering 100: m.room.message Carol stream_ordering 101: m.call.member Bob (state) current_state_delta_stream (how state_after finds state changes): stream_id 100 ────► (m.call.member, @bob) -> $bob_join_call ▲ └─ stamped with the batch MINIMUM (100), not the event's own 101 (see `_update_current_state_txn`) ``` The transaction commits: both events and the delta row are now in the database, atomically. The persister then announces the new events over replication, one RDATA token per stream ordering — rows are only merged into one token when they share a position, and 100 and 101 don't. So the sync worker's events-stream position steps 99 → 100 → 101, and on reaching 100 it pokes the notifier. Alice's long-poll wakes at exactly that moment. Her response is built at the worker's *current* position — `end = 100` — with RDATA 101 still in the queue: ``` Sync A (since=99, end=100): timeline: events 99 < ordering ≤ 100 → [Carol's message] state_after: deltas 99 < stream_id ≤ 100 → [$bob_join_call] ← delivered EARLY next_batch: s100 ← mid-batch token ``` No race on the client's side is needed: the server *hands out* the mid-batch token as `next_batch`. Alice's client re-polls with it, as every sync client does. The worker has meanwhile processed RDATA 101: ``` Sync B (since=100, end=101): timeline: events 100 < ordering ≤ 101 → [Bob's m.call.member @101] ✓ state_after: deltas 100 < stream_id ≤ 101 → [] row is stamped 100 ✗ ``` A state event in the timeline with an empty `state_after`. An MSC4222 client trusts `state_after` over timeline state events, so Alice's copy of Bob's call membership never updates from this response. On a single process this cannot happen: the batch's stream IDs are released as a whole, so the position visible to `/sync` jumps 99 → 101 and `s100` is never handed out. Only a process that learns its position from replication — any sync worker — ticks through the middle of a batch. ### Pull Request Checklist <!-- Please read https://element-hq.github.io/synapse/latest/development/contributing_guide.html before submitting your pull request --> * [x] Pull request is based on the develop branch * [x] Pull request includes a [changelog file](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#changelog). The entry should: - Be a short description of your change which makes sense to users. "Fixed a bug that prevented receiving messages from other servers." instead of "Moved X method from `EventStore` to `EventWorkerStore`.". - Use markdown where necessary, mostly for `code blocks`. - End with either a period (.) or an exclamation mark (!). - Start with a capital letter. - Feel free to credit yourself, by adding a sentence "Contributed by @github_username." or "Contributed by [Your Name]." to the end of the entry. * [x] [Code style](https://element-hq.github.io/synapse/latest/code_style.html) is correct (run the [linters](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#run-the-linters))
338 lines
14 KiB
Python
338 lines
14 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 random
|
|
from typing import Any
|
|
|
|
from immutabledict import immutabledict
|
|
|
|
from twisted.test.proto_helpers import MemoryReactor
|
|
|
|
import synapse.rest.admin
|
|
from synapse.api.constants import EventTypes
|
|
from synapse.events import EventBase
|
|
from synapse.rest.client import login, room
|
|
from synapse.server import HomeServer
|
|
from synapse.types import RoomStreamToken
|
|
from synapse.util.clock import Clock
|
|
|
|
from tests import unittest
|
|
from tests.test_utils.event_injection import (
|
|
create_event,
|
|
persist_message_and_state_event_in_one_batch,
|
|
)
|
|
|
|
|
|
class StateDeltasByEventPositionTestCase(unittest.HomeserverTestCase):
|
|
"""Tests for `get_current_state_deltas_for_room_by_event_position`, which
|
|
bounds each delta on the position of its state event rather than on the
|
|
delta row's own `stream_id` (rows are stamped with the minimum stream
|
|
ordering of their persist batch, so the two can differ)."""
|
|
|
|
servlets = [
|
|
synapse.rest.admin.register_servlets,
|
|
room.register_servlets,
|
|
login.register_servlets,
|
|
]
|
|
|
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
|
self.store = hs.get_datastores().main
|
|
|
|
self.alice = self.register_user("alice", "password")
|
|
self.alice_tok = self.login("alice", "password")
|
|
self.room_id = self.helper.create_room_as(self.alice, tok=self.alice_tok)
|
|
|
|
def _persist_batch(self) -> tuple[EventBase, EventBase]:
|
|
return self.get_success(
|
|
persist_message_and_state_event_in_one_batch(
|
|
self.hs, self.room_id, self.alice
|
|
)
|
|
)
|
|
|
|
def _batch_positions(self) -> tuple[str, int, int]:
|
|
"""Persist a batch and return (state event id, batch minimum position,
|
|
state event position), sanity-checking the batch shape."""
|
|
message, state_event = self._persist_batch()
|
|
message_pos = message.internal_metadata.stream_ordering
|
|
state_pos = state_event.internal_metadata.stream_ordering
|
|
assert message_pos is not None and state_pos is not None
|
|
self.assertLess(message_pos, state_pos)
|
|
return state_event.event_id, message_pos, state_pos
|
|
|
|
def test_delta_row_is_stamped_at_batch_minimum(self) -> None:
|
|
"""Documents the precondition: the `current_state_delta_stream` row of
|
|
a state event persisted in a batch is stamped with the batch's
|
|
*minimum* stream ordering (see `_update_current_state_txn`), not the
|
|
state event's own, so a window bounded on the stamp and a window
|
|
bounded on the event disagree about which side of a mid-batch token
|
|
the delta falls on."""
|
|
state_event_id, message_pos, _state_pos = self._batch_positions()
|
|
|
|
rows = self.get_success(
|
|
self.store.db_pool.simple_select_list(
|
|
table="current_state_delta_stream",
|
|
keyvalues={"room_id": self.room_id, "type": "m.call.member"},
|
|
retcols=("stream_id", "event_id"),
|
|
desc="test_delta_row_is_stamped_at_batch_minimum",
|
|
)
|
|
)
|
|
self.assertEqual(rows, [(message_pos, state_event_id)])
|
|
|
|
def test_mid_batch_delta_is_in_window(self) -> None:
|
|
"""A window whose lower bound splits a persist batch contains the
|
|
batch's state event delta, which the stamp-bounded query misses."""
|
|
state_event_id, message_pos, state_pos = self._batch_positions()
|
|
|
|
from_token = RoomStreamToken(stream=message_pos)
|
|
to_token = RoomStreamToken(stream=state_pos)
|
|
|
|
# The stamp-bounded query misses the delta: the row is stamped at the
|
|
# batch minimum, below the window.
|
|
deltas = self.get_success(
|
|
self.store.get_current_state_deltas_for_room(
|
|
self.room_id, from_token=from_token, to_token=to_token
|
|
)
|
|
)
|
|
self.assertEqual([d.event_id for d in deltas], [])
|
|
|
|
# The by-event-position query recovers it.
|
|
deltas = self.get_success(
|
|
self.store.get_current_state_deltas_for_room_by_event_position(
|
|
self.room_id, from_token=from_token, to_token=to_token
|
|
)
|
|
)
|
|
self.assertEqual([d.event_id for d in deltas], [state_event_id])
|
|
|
|
def test_delta_is_not_reported_before_its_event(self) -> None:
|
|
"""A window ending at the batch minimum must not contain the state
|
|
event's delta: its effective position is the event's own, beyond the
|
|
window. (The stamp-bounded query reports it here, one window early.)"""
|
|
state_event_id, message_pos, _state_pos = self._batch_positions()
|
|
|
|
to_token = RoomStreamToken(stream=message_pos)
|
|
|
|
deltas = self.get_success(
|
|
self.store.get_current_state_deltas_for_room(
|
|
self.room_id, from_token=None, to_token=to_token
|
|
)
|
|
)
|
|
self.assertIn(state_event_id, [d.event_id for d in deltas])
|
|
|
|
deltas = self.get_success(
|
|
self.store.get_current_state_deltas_for_room_by_event_position(
|
|
self.room_id, from_token=None, to_token=to_token
|
|
)
|
|
)
|
|
self.assertNotIn(state_event_id, [d.event_id for d in deltas])
|
|
|
|
def test_no_lower_bound(self) -> None:
|
|
"""With no lower bound the event-driven query is unnecessary and the
|
|
query returns everything up to the upper bound, batch rows included."""
|
|
state_event_id, _message_pos, state_pos = self._batch_positions()
|
|
|
|
deltas = self.get_success(
|
|
self.store.get_current_state_deltas_for_room_by_event_position(
|
|
self.room_id,
|
|
from_token=None,
|
|
to_token=RoomStreamToken(stream=state_pos),
|
|
)
|
|
)
|
|
# The room's creation state plus the batched state event.
|
|
self.assertIn(state_event_id, [d.event_id for d in deltas])
|
|
|
|
def test_no_upper_bound(self) -> None:
|
|
"""With no upper bound a mid-batch lower bound still recovers the
|
|
batch's state event delta."""
|
|
state_event_id, message_pos, _state_pos = self._batch_positions()
|
|
|
|
deltas = self.get_success(
|
|
self.store.get_current_state_deltas_for_room_by_event_position(
|
|
self.room_id,
|
|
from_token=RoomStreamToken(stream=message_pos),
|
|
to_token=None,
|
|
)
|
|
)
|
|
self.assertEqual([d.event_id for d in deltas], [state_event_id])
|
|
|
|
def test_rows_without_an_event_keep_their_stamp(self) -> None:
|
|
"""Rows with no event -- the clearance of the room's state when the
|
|
last local user leaves -- have no event position to bound on and are
|
|
returned at their own stamp, exactly as the stamp-bounded query
|
|
returns them."""
|
|
before = self.store.get_room_max_token()
|
|
self.helper.leave(self.room_id, self.alice, tok=self.alice_tok)
|
|
after = self.store.get_room_max_token()
|
|
|
|
deltas = self.get_success(
|
|
self.store.get_current_state_deltas_for_room_by_event_position(
|
|
self.room_id, from_token=before, to_token=after
|
|
)
|
|
)
|
|
|
|
self.assertTrue(deltas)
|
|
self.assertEqual({d.event_id for d in deltas}, {None})
|
|
self.assertIn(
|
|
(EventTypes.Create, ""), {(d.event_type, d.state_key) for d in deltas}
|
|
)
|
|
self.assertCountEqual(
|
|
deltas,
|
|
self.get_success(
|
|
self.store.get_current_state_deltas_for_room(
|
|
self.room_id, from_token=before, to_token=after
|
|
)
|
|
),
|
|
)
|
|
|
|
def _persist_mixed_batch(self, kinds: list[bool], rng: random.Random) -> None:
|
|
"""Persist one batch of events, a state event for each True in
|
|
`kinds` (over a handful of reused state keys) and a message for each
|
|
False, all forked off the same forward extremities."""
|
|
persistence = self.hs.get_storage_controllers().persistence
|
|
assert persistence is not None
|
|
prev_event_ids = self.get_success(
|
|
self.store.get_prev_events_for_room(self.room_id)
|
|
)
|
|
events = []
|
|
for i, is_state in enumerate(kinds):
|
|
kwargs: dict[str, Any]
|
|
if is_state:
|
|
kwargs = {
|
|
"type": "m.call.member",
|
|
"state_key": f"k{rng.randrange(4)}",
|
|
"content": {"memberships": [{"device_id": f"d{rng.random()}"}]},
|
|
}
|
|
else:
|
|
kwargs = {
|
|
"type": "m.room.message",
|
|
"content": {"msgtype": "m.text", "body": f"msg {i}"},
|
|
}
|
|
events.append(
|
|
self.get_success(
|
|
create_event(
|
|
self.hs,
|
|
room_id=self.room_id,
|
|
sender=self.alice,
|
|
prev_event_ids=prev_event_ids,
|
|
**kwargs,
|
|
)
|
|
)
|
|
)
|
|
self.get_success(persistence.persist_events(events))
|
|
|
|
def test_matches_brute_force_over_every_window(self) -> None:
|
|
"""Cross-checks the query against a brute-force oracle.
|
|
|
|
Persists a mix of batches (lone messages, lone state events, batches of
|
|
two to four events with state events at varying positions, state keys
|
|
reused across batches), then for every pair of positions in the room's
|
|
stream -- and for tokens whose position for the room's writer is ahead
|
|
of their minimum, which is what a multi-writer token looks like --
|
|
checks that the deltas returned are exactly the rows whose effective
|
|
position (the maximum of the row's stamp and its event's stream
|
|
ordering) lies in the window, in stamp order.
|
|
"""
|
|
rng = random.Random(4222)
|
|
for _ in range(20):
|
|
size = rng.choice([1, 1, 2, 3, 4])
|
|
kinds = [rng.random() < 0.6 for _ in range(size)]
|
|
if size == 1:
|
|
kinds = [rng.random() < 0.5]
|
|
self._persist_mixed_batch(kinds, rng)
|
|
|
|
# The oracle: every row's effective position, straight from the tables.
|
|
rows = self.get_success(
|
|
self.store.db_pool.simple_select_list(
|
|
table="current_state_delta_stream",
|
|
keyvalues={"room_id": self.room_id},
|
|
retcols=("stream_id", "event_id"),
|
|
desc="oracle_rows",
|
|
)
|
|
)
|
|
event_positions = dict(
|
|
self.get_success(
|
|
self.store.db_pool.simple_select_list(
|
|
table="events",
|
|
keyvalues={"room_id": self.room_id},
|
|
retcols=("event_id", "stream_ordering"),
|
|
desc="oracle_events",
|
|
)
|
|
)
|
|
)
|
|
effective = {
|
|
(stream_id, event_id): max(
|
|
stream_id, event_positions.get(event_id, stream_id)
|
|
)
|
|
for stream_id, event_id in rows
|
|
}
|
|
self.assertGreater(len(rows), 10)
|
|
self.assertTrue(
|
|
any(effective[key] > key[0] for key in effective),
|
|
"test setup: expected some rows stamped before their event",
|
|
)
|
|
|
|
lowest = min(event_positions.values()) - 1
|
|
highest = max(event_positions.values())
|
|
positions: list[int | None] = [None, *range(lowest, highest + 1)]
|
|
|
|
def check(
|
|
from_token: RoomStreamToken | None,
|
|
to_token: RoomStreamToken | None,
|
|
from_pos: int | None,
|
|
to_pos: int | None,
|
|
) -> None:
|
|
expected = {
|
|
key
|
|
for key, pos in effective.items()
|
|
if (from_pos is None or from_pos < pos)
|
|
and (to_pos is None or pos <= to_pos)
|
|
}
|
|
deltas = self.get_success(
|
|
self.store.get_current_state_deltas_for_room_by_event_position(
|
|
self.room_id, from_token=from_token, to_token=to_token
|
|
)
|
|
)
|
|
self.assertEqual(
|
|
{(d.stream_id, d.event_id) for d in deltas},
|
|
expected,
|
|
f"window ({from_token}, {to_token}]",
|
|
)
|
|
stamps = [d.stream_id for d in deltas]
|
|
self.assertEqual(stamps, sorted(stamps), "deltas not in stamp order")
|
|
|
|
for from_pos in positions:
|
|
for to_pos in positions:
|
|
if from_pos is not None and to_pos is not None and to_pos <= from_pos:
|
|
continue
|
|
check(
|
|
RoomStreamToken(stream=from_pos) if from_pos is not None else None,
|
|
RoomStreamToken(stream=to_pos) if to_pos is not None else None,
|
|
from_pos,
|
|
to_pos,
|
|
)
|
|
# The same window as a multi-writer token: the room's writer
|
|
# ("master") sits at the position, the token's minimum behind.
|
|
if from_pos is not None and to_pos is not None and from_pos % 3 == 0:
|
|
check(
|
|
RoomStreamToken(
|
|
stream=from_pos - 2,
|
|
instance_map=immutabledict({"master": from_pos}),
|
|
),
|
|
RoomStreamToken(
|
|
stream=to_pos - 2,
|
|
instance_map=immutabledict({"master": to_pos}),
|
|
),
|
|
from_pos,
|
|
to_pos,
|
|
)
|