Files
synapse/tests/storage/test_state_deltas.py
T
Paul Chobert 8c11b13f63 Fix state events missing from MSC4222 state_after when the since token falls inside a persist batch (#20171)
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))
2026-09-16 11:07:25 +02:00

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,
)