Files
synapse/tests/storage/test_sticky_events.py
Olivier 'reivilibre 93368c7eb8 Prevent MSC4354 Sticky Events being sticky when they were redacted in the same persistence batch. (#20322)
Part of: MSC4354
Part of: #19641
Follows: #20232 (which applies already-persisted redactions to Sticky
Events being persisted)


In some cases we batch events together during persistence.

If one of the events in the batch is a redaction of another event, we
should apply it, which provides the same benefit as #20232 (namely: we
avoid potentially dodgy event content being persisted & the event
naturally loses its stickiness
so is not written to `sticky_events`)

---------

Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
2026-10-09 12:30:58 +01:00

1396 lines
54 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 sqlite3
from http import HTTPStatus
from immutabledict import immutabledict
from parameterized import parameterized
from twisted.internet.testing import MemoryReactor
from synapse.api.constants import (
EventContentFields,
EventTypes,
Membership,
StickyEvent,
StickyEventField,
)
from synapse.api.room_versions import RoomVersions
from synapse.replication.tcp.streams._base import StickyEventStreamPosition
from synapse.rest import admin
from synapse.rest.client import login, register, room, sync
from synapse.server import HomeServer
from synapse.types import JsonDict, MultiWriterStreamToken, RoomID, create_requester
from synapse.util.clock import Clock
from synapse.util.duration import Duration
from tests import unittest
from tests.test_utils.event_injection import (
create_event,
inject_event,
inject_member_event,
)
from tests.utils import USE_POSTGRES_FOR_TESTS
class StickyEventsTestCase(unittest.HomeserverTestCase):
"""
Tests for the storage functions related to MSC4354: 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 = [
room.register_servlets,
sync.register_servlets,
login.register_servlets,
register.register_servlets,
admin.register_servlets,
]
def default_config(self) -> JsonDict:
config = super().default_config()
config["experimental_features"] = {
"msc3575_enabled": True,
"msc4354_enabled": True,
}
return config
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.store = self.hs.get_datastores().main
# Register an account and create a room
self.user_id = self.register_user("user", "pass")
self.token = self.login(self.user_id, "pass")
self.room_id = self.helper.create_room_as(self.user_id, tok=self.token)
def test_get_updated_sticky_events(self) -> None:
"""Test getting updated sticky events between stream IDs."""
# Get the starting stream_id
start_id = self.store.get_sticky_events_stream_token().stream
event_id_1 = self.helper.send_sticky_event(
self.room_id,
EventTypes.Message,
duration=Duration(minutes=1),
content={"body": "message 1", "msgtype": "m.text"},
tok=self.token,
)["event_id"]
mid_id = self.store.get_sticky_events_stream_token().stream
event_id_2 = self.helper.send_sticky_event(
self.room_id,
EventTypes.Message,
duration=Duration(minutes=1),
content={"body": "message 2", "msgtype": "m.text"},
tok=self.token,
)["event_id"]
end_id = self.store.get_sticky_events_stream_token().stream
# Get all updates
updates = self.get_success(
self.store.get_updated_sticky_events(
from_id=start_id, to_id=end_id, limit=10
)
)
self.assertEqual(len(updates), 2)
self.assertEqual(updates[0].event_id, event_id_1)
self.assertEqual(updates[0].soft_failed, False)
self.assertEqual(updates[1].event_id, event_id_2)
self.assertEqual(updates[1].soft_failed, False)
# Get only the second update
updates = self.get_success(
self.store.get_updated_sticky_events(from_id=mid_id, to_id=end_id, limit=10)
)
self.assertEqual(len(updates), 1)
self.assertEqual(updates[0].event_id, event_id_2)
self.assertEqual(updates[0].soft_failed, False)
def test_delete_expired_sticky_events(self) -> None:
"""Test deletion of expired sticky events."""
# Insert an expired event by advancing time past its duration
self.helper.send_sticky_event(
self.room_id,
EventTypes.Message,
duration=Duration(milliseconds=1),
content={"body": "expired message", "msgtype": "m.text"},
tok=self.token,
)
self.reactor.advance(0.002)
# Insert a non-expired event
event_id_2 = self.helper.send_sticky_event(
self.room_id,
EventTypes.Message,
duration=Duration(minutes=1),
content={"body": "non-expired message", "msgtype": "m.text"},
tok=self.token,
)["event_id"]
end_id = self.store.get_sticky_events_stream_token().stream
# Delete expired events
self.get_success(self.store._delete_expired_sticky_events())
# Check that only the non-expired event remains
sticky_events = self.get_success(
self.store.db_pool.simple_select_list(
table="sticky_events", keyvalues=None, retcols=("stream_id", "event_id")
)
)
self.assertEqual(
sticky_events,
[
(end_id, event_id_2),
],
)
def test_redaction_removes_sticky_event(self) -> None:
"""
Tests that redacting a sticky event causes it to be removed from the `sticky_events` table.
"""
def _sticky_event_ids_from_table() -> list[str]:
"""Return all event IDs in the `sticky_events` table, in sticky stream order."""
rows = self.get_success(
self.store.db_pool.simple_select_list(
table="sticky_events",
keyvalues=None,
retcols=("stream_id", "event_id"),
)
)
return [event_id for _, event_id in sorted(rows)]
redacted_event_id = self.helper.send_sticky_event(
self.room_id,
EventTypes.Message,
duration=Duration(minutes=1),
content={"body": "to be redacted", "msgtype": "m.text"},
tok=self.token,
)["event_id"]
kept_event_id = self.helper.send_sticky_event(
self.room_id,
EventTypes.Message,
duration=Duration(minutes=1),
content={"body": "to be left alone", "msgtype": "m.text"},
tok=self.token,
)["event_id"]
# Before redaction, both sticky events are in the table.
self.assertEqual(
set(_sticky_event_ids_from_table()), {redacted_event_id, kept_event_id}
)
# Redact
channel = self.make_request(
"PUT",
f"/_matrix/client/v3/rooms/{self.room_id}/redact/{redacted_event_id}/txn1",
{},
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.OK, channel.result)
# After redaction, only the unredacted sticky event is left in the table.
self.assertEqual(set(_sticky_event_ids_from_table()), {kept_event_id})
def test_get_updated_sticky_events_with_limit(self) -> None:
"""Test that the limit parameter works correctly."""
# Get the starting stream_id
start_id = self.store.get_sticky_events_stream_token().stream
event_id_1 = self.helper.send_sticky_event(
self.room_id,
EventTypes.Message,
duration=Duration(minutes=1),
content={"body": "message 1", "msgtype": "m.text"},
tok=self.token,
)["event_id"]
self.helper.send_sticky_event(
self.room_id,
EventTypes.Message,
duration=Duration(minutes=1),
content={"body": "message 2", "msgtype": "m.text"},
tok=self.token,
)
# Get only the first update
updates = self.get_success(
self.store.get_updated_sticky_events(
from_id=start_id, to_id=start_id + 2, limit=1
)
)
self.assertEqual(len(updates), 1)
self.assertEqual(updates[0].event_id, event_id_1)
def test_get_sticky_events_in_rooms_token_does_not_go_backwards(self) -> None:
"""
Tests `get_sticky_events_in_rooms` to make sure that the token does not get rewound, for instance if a
different, lagging, sync worker handles a request.
"""
instance_name = self.hs.get_instance_name()
self.helper.send_sticky_event(
self.room_id,
EventTypes.Message,
duration=Duration(minutes=1),
content={"body": "message 1", "msgtype": "m.text"},
tok=self.token,
)
first_id = self.store.get_sticky_events_stream_token().stream
event_id_2 = self.helper.send_sticky_event(
self.room_id,
EventTypes.Message,
duration=Duration(minutes=1),
content={"body": "message 2", "msgtype": "m.text"},
tok=self.token,
)["event_id"]
second_id = self.store.get_sticky_events_stream_token().stream
from_token = MultiWriterStreamToken(
stream=first_id, instance_map=immutabledict({"someworker": 42})
)
# `to_token` has 2 components that are behind compared to `from_token`:
# the baseline position and the position of the advanced worker 'someworker'
to_token = MultiWriterStreamToken(
stream=first_id - 1,
instance_map=immutabledict({instance_name: second_id, "someworker": 36}),
)
new_token, sticky_by_room = self.get_success(
self.store.get_sticky_events_in_rooms(
[self.room_id],
from_token=from_token,
to_token=to_token,
now=self.clock.time_msec(),
limit=None,
)
)
self.assertEqual(sticky_by_room, {self.room_id: [event_id_2]})
self.assertEqual(
new_token,
MultiWriterStreamToken(
# The baseline position is not wound back
stream=first_id,
instance_map=immutabledict(
{
instance_name: second_id,
# The position of this advanced worker is not wound back either
"someworker": 42,
}
),
),
)
def test_outlier_events_not_in_table(self) -> None:
"""
Tests the behaviour of outliered and then de-outliered events in the
sticky_events table: they should only be added once they are de-outliered.
"""
persist_controller = self.hs.get_storage_controllers().persistence
assert persist_controller is not None
user1_id = self.register_user("user1", "pass")
user2_id = self.register_user("user2", "pass")
user2_tok = self.login(user2_id, "pass")
start_id = self.store.get_sticky_events_stream_token().stream
room_id = self.helper.create_room_as(
user2_id, tok=user2_tok, room_version=RoomVersions.V10.identifier
)
# Create a membership event
event_dict = {
"type": EventTypes.Member,
"state_key": user1_id,
"sender": user1_id,
"room_id": room_id,
"content": {EventContentFields.MEMBERSHIP: Membership.JOIN},
StickyEvent.EVENT_FIELD_NAME: StickyEventField(
duration_ms=Duration(hours=1).as_millis()
),
}
# Create the event twice: once as an outlier, once as a non-outlier.
# It's not at all obvious, but event creation before is deterministic
# (provided we don't change the forward extremities of the room!),
# so these two events are actually the same event with the same event ID.
(
event_outlier,
unpersisted_context_outlier,
) = self.get_success(
self.hs.get_event_creation_handler().create_event(
requester=create_requester(user1_id),
event_dict=event_dict,
outlier=True,
)
)
(
event_non_outlier,
unpersisted_context_non_outlier,
) = self.get_success(
self.hs.get_event_creation_handler().create_event(
requester=create_requester(user1_id),
event_dict=event_dict,
outlier=False,
)
)
# Safety check that we're testing what we think we are
self.assertEqual(event_outlier.event_id, event_non_outlier.event_id)
# Now persist the event as an outlier first of all
# FIXME: Should we use an `EventContext.for_outlier(...)` here?
# Doesn't seem to matter for this test.
context_outlier = self.get_success(
unpersisted_context_outlier.persist(event_outlier)
)
self.get_success(
persist_controller.persist_event(
event_outlier,
context_outlier,
)
)
# Since the event is outliered, it won't show up in the sticky_events table...
sticky_events = self.get_success(
self.store.db_pool.simple_select_list(
table="sticky_events", keyvalues=None, retcols=("stream_id", "event_id")
)
)
self.assertEqual(len(sticky_events), 0)
# Now persist the event properly so that it gets de-outliered.
context_non_outlier = self.get_success(
unpersisted_context_non_outlier.persist(event_non_outlier)
)
self.get_success(
persist_controller.persist_event(
event_non_outlier,
context_non_outlier,
)
)
end_id = self.store.get_sticky_events_stream_token().stream
# Check the event made it into the sticky_events table
updates = self.get_success(
self.store.get_updated_sticky_events(
from_id=start_id, to_id=end_id, limit=10
)
)
self.assertEqual(len(updates), 1)
self.assertEqual(updates[0].event_id, event_non_outlier.event_id)
def test_redacted_before_persisted_not_tracked(self) -> None:
"""
Tests that a sticky event which was redacted before we persisted it
(i.e. we learned of its redaction first, as can happen over federation)
is not sent down to clients over sync and is not added to the `sticky_events` table.
"""
user2_id = self.register_user("user2", "pass")
user2_tok = self.login(user2_id, "pass")
self.helper.join(self.room_id, user2_id, tok=user2_tok)
persist_controller = self.hs.get_storage_controllers().persistence
assert persist_controller is not None
# Create the sticky event, but do not persist it yet
sticky_event, sticky_event_context = self.get_success(
create_event(
self.hs,
room_id=self.room_id,
sender=user2_id,
type=EventTypes.Message,
content={"body": "sticky", "msgtype": "m.text"},
# Corresponds to StickyEvent.EVENT_FIELD_NAME
msc4354_sticky=StickyEventField(
duration_ms=Duration(minutes=1).as_millis()
),
)
)
# Create the redaction of the sticky event and persist it first,
# as if it had arrived over federation before the sticky event.
redaction_event, redaction_event_context = self.get_success(
create_event(
self.hs,
room_id=self.room_id,
sender=user2_id,
type=EventTypes.Redaction,
content={
"reason": "nothing here but us trees",
"redacts": sticky_event.event_id,
},
)
)
self.get_success(
persist_controller.persist_event(redaction_event, redaction_event_context)
)
# Now the sticky event arrives over federation and is persisted *after* the redaction
self.get_success(
persist_controller.persist_event(sticky_event, sticky_event_context)
)
# The event should have been persisted in its redacted form.
event = self.get_success(self.store.get_event(sticky_event.event_id))
self.assertEqual(event.event_id, sticky_event.event_id)
self.assertEqual(event.internal_metadata.redacted_by, redaction_event.event_id)
self.assertEqual(event.content, {})
self.assertIsNone(event.sticky_duration())
# Since it is redacted, it must not have been added to the sticky_events
# table...
sticky_events = self.get_success(
self.store.db_pool.simple_select_list(
table="sticky_events", keyvalues=None, retcols=("event_id",)
)
)
self.assertEqual(sticky_events, [])
# ...nor shown to clients down sync.
self.assertEqual(self._get_visible_sticky_event_ids(), set())
@parameterized.expand([(True,), (False,)])
def test_redacted_in_same_batch_not_tracked(
self, flip_order_in_batch: bool
) -> None:
"""
Tests that a sticky event which is persisted in the same batch as a redaction of it
is not sent down to clients over sync and is not added to the `sticky_events` table.
"""
user2_id = self.register_user("user2", "pass")
user2_tok = self.login(user2_id, "pass")
self.helper.join(self.room_id, user2_id, tok=user2_tok)
persist_controller = self.hs.get_storage_controllers().persistence
assert persist_controller is not None
sticky_event, sticky_event_context = self.get_success(
create_event(
self.hs,
room_id=self.room_id,
sender=user2_id,
type=EventTypes.Message,
content={"body": "sticky", "msgtype": "m.text"},
# Corresponds to StickyEvent.EVENT_FIELD_NAME
msc4354_sticky=StickyEventField(
duration_ms=Duration(minutes=1).as_millis()
),
)
)
redaction_event, redaction_event_context = self.get_success(
create_event(
self.hs,
room_id=self.room_id,
sender=user2_id,
type=EventTypes.Redaction,
content={
"reason": "nothing here but us trees",
"redacts": sticky_event.event_id,
},
)
)
# Persist both events in a single batch.
# Test both orders of events to make sure it doesn't affect the outcome.
if flip_order_in_batch:
batch = [
(redaction_event, redaction_event_context),
(sticky_event, sticky_event_context),
]
else:
batch = [
(sticky_event, sticky_event_context),
(redaction_event, redaction_event_context),
]
self.get_success(persist_controller.persist_events(batch))
# The event should have been persisted in its redacted form.
event = self.get_success(self.store.get_event(sticky_event.event_id))
self.assertEqual(event.internal_metadata.redacted_by, redaction_event.event_id)
self.assertEqual(event.content, {})
self.assertIsNone(event.sticky_duration())
# Since it is redacted, it must not have been added to the sticky_events
# table...
sticky_events = self.get_success(
self.store.db_pool.simple_select_list(
table="sticky_events", keyvalues=None, retcols=("event_id",)
)
)
self.assertEqual(sticky_events, [])
# ...nor shown to clients down sync.
self.assertEqual(self._get_visible_sticky_event_ids(), set())
def test_soft_failed_events_are_tracked(self) -> None:
"""
Tests that sticky events marked as soft_failed ARE inserted
into the sticky_events table, as their soft-failed status can be re-evaluated later,
as per MSC4354.
"""
user_id = self.register_user("testuser", "pass")
token = self.login(user_id, "pass")
room_id = self.helper.create_room_as(user_id, tok=token)
start_id = self.store.get_sticky_events_stream_token().stream
# Create and persist a sticky event that is soft-failed
soft_failed_sticky_event = self.get_success(
inject_event(
self.hs,
room_id=room_id,
sender=user_id,
type=EventTypes.Message,
content={"body": "spam checker spammy message", "msgtype": "m.text"},
internal_metadata={"soft_failed": True},
# Corresponds to StickyEvent.EVENT_FIELD_NAME
msc4354_sticky=StickyEventField(
duration_ms=Duration(minutes=1).as_millis()
),
)
)
end_id = self.store.get_sticky_events_stream_token().stream
updates = self.get_success(
self.store.get_updated_sticky_events(
from_id=start_id, to_id=end_id, limit=10
)
)
self.assertEqual(len(updates), 1)
self.assertEqual(updates[0].event_id, soft_failed_sticky_event.event_id)
def test_policy_server_spammy_events_are_not_tracked(self) -> None:
"""
Tests that sticky events marked as policy_server_spammy are NOT inserted
into the sticky_events table, as they are exempt from the soft-failed
re-evaluation logic.
"""
user_id = self.register_user("testuser", "pass")
token = self.login(user_id, "pass")
room_id = self.helper.create_room_as(user_id, tok=token)
start_id = self.store.get_sticky_events_stream_token().stream
# Create and persist a sticky event that is marked policy_server_spammy
# N.B. policy_server_spammy events are always soft-failed too
_spammy_sticky_event = self.get_success(
inject_event(
self.hs,
room_id=room_id,
sender=user_id,
type=EventTypes.Message,
content={"body": "spam checker spammy message", "msgtype": "m.text"},
internal_metadata={"soft_failed": True, "policy_server_spammy": True},
# Corresponds to StickyEvent.EVENT_FIELD_NAME
msc4354_sticky=StickyEventField(
duration_ms=Duration(minutes=1).as_millis()
),
)
)
# Also insert a valid sticky event as a canary for the test setup
valid_sticky_event = self.get_success(
inject_event(
self.hs,
room_id=room_id,
sender=user_id,
type=EventTypes.Message,
content={"body": "normal sticky", "msgtype": "m.text"},
# Corresponds to StickyEvent.EVENT_FIELD_NAME
msc4354_sticky=StickyEventField(
duration_ms=Duration(minutes=1).as_millis()
),
)
)
end_id = self.store.get_sticky_events_stream_token().stream
# Verify only the regular event was inserted
updates = self.get_success(
self.store.get_updated_sticky_events(
from_id=start_id, to_id=end_id, limit=10
)
)
self.assertEqual(len(updates), 1)
self.assertEqual(updates[0].event_id, valid_sticky_event.event_id)
def test_spam_checker_spammy_events_are_not_tracked(self) -> None:
"""
Tests that sticky events marked as spam_checker_spammy are NOT inserted
into the sticky_events table, as they are exempt from the soft-failed
re-evaluation logic.
"""
user_id = self.register_user("testuser", "pass")
token = self.login(user_id, "pass")
room_id = self.helper.create_room_as(user_id, tok=token)
start_id = self.store.get_sticky_events_stream_token().stream
# Create and persist a sticky event that is marked spam_checker_spammy
# N.B. spam_checker_spammy events are always soft-failed too
_spammy_sticky_event = self.get_success(
inject_event(
self.hs,
room_id=room_id,
sender=user_id,
type=EventTypes.Message,
content={"body": "spam checker spammy message", "msgtype": "m.text"},
internal_metadata={"soft_failed": True, "spam_checker_spammy": True},
# Corresponds to StickyEvent.EVENT_FIELD_NAME
msc4354_sticky=StickyEventField(
duration_ms=Duration(minutes=1).as_millis()
),
)
)
# Also insert a valid sticky event as a canary for the test setup
valid_sticky_event = self.get_success(
inject_event(
self.hs,
room_id=room_id,
sender=user_id,
type=EventTypes.Message,
content={"body": "normal sticky", "msgtype": "m.text"},
# Corresponds to StickyEvent.EVENT_FIELD_NAME
msc4354_sticky=StickyEventField(
duration_ms=Duration(minutes=1).as_millis()
),
)
)
end_id = self.store.get_sticky_events_stream_token().stream
# Verify only the valid sticky event was inserted
updates = self.get_success(
self.store.get_updated_sticky_events(
from_id=start_id, to_id=end_id, limit=10
)
)
self.assertEqual(len(updates), 1)
self.assertEqual(updates[0].event_id, valid_sticky_event.event_id)
def _get_visible_sticky_event_ids(self) -> set[str]:
"""
Returns the IDs of the sticky events visible to clients in sync.
"""
sync_body: JsonDict = {
"lists": {
"main": {
"ranges": [[0, 0]],
"required_state": [],
# We don't want any timeline events, just sticky events
"timeline_limit": 0,
}
},
"extensions": {
"org.matrix.msc4354.sticky_events": {
"enabled": True,
}
},
}
channel = self.make_request(
"POST",
"/_matrix/client/unstable/org.matrix.simplified_msc3575/sync",
sync_body,
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
sticky_events = channel.json_body["extensions"].get(
"org.matrix.msc4354.sticky_events"
)
if sticky_events is None:
return set()
events_in_room = (
sticky_events.get("rooms", {}).get(self.room_id, {}).get("events", [])
)
return {event["event_id"] for event in events_in_room}
def test_soft_failure_cleared_when_state_changes(self) -> None:
"""
Tests that a soft-failed sticky event stops being soft-failed once a change to
the room's current state means that it passes auth after all.
"""
user2_id = self.register_user("user2", "pass")
user2_tok = self.login(user2_id, "pass")
self.helper.join(self.room_id, user2_id, tok=user2_tok)
# Devoice user2, so that their sticky event will (realistically) fail auth
# against the room's current state.
self.helper.send_state(
self.room_id,
EventTypes.PowerLevels,
body={"users": {user2_id: -1}, "events_default": 0},
tok=self.token,
)
# Inject a soft-failed sticky event. This is cheating a bit for brevity.
# In the real world, we'd need to craft a soft-failed sticky event to arrive over federation.
# The Complement test will do this: https://github.com/matrix-org/complement/pull/806/files#diff-6c9d6d169485d0848c6b20dd9b43f6fe669a8a710e42f953d08fa25a99cc8f4cR509
event_id = self.get_success(
inject_event(
self.hs,
room_id=self.room_id,
sender=user2_id,
type=EventTypes.Message,
content={"body": "sticky", "msgtype": "m.text"},
internal_metadata={"soft_failed": True},
# Corresponds to StickyEvent.EVENT_FIELD_NAME
msc4354_sticky=StickyEventField(
duration_ms=Duration(minutes=1).as_millis()
),
)
).event_id
# Whilst it is soft-failed, the event isn't shown to clients.
self.assertEqual(self._get_visible_sticky_event_ids(), set())
# Change the room's power levels to voice user2 back.
# This triggers the soft-fail re-evaluation and also allows the soft-failed sticky
# event to pass state-dependent auth checks against the current state, becoming
# un-soft-failed
self.helper.send_state(
self.room_id,
EventTypes.PowerLevels,
body={"users": {user2_id: 0}, "events_default": 0},
tok=self.token,
)
# The event has been re-evaluated and is now shown to clients...
self.assertEqual(self._get_visible_sticky_event_ids(), {event_id})
# ...and the soft-failure flag has been cleared.
event = self.get_success(self.store.get_event(event_id))
self.assertFalse(event.internal_metadata.is_soft_failed())
def test_soft_failure_cleared_when_sender_membership_changes(self) -> None:
"""
Tests that soft-failure status of a sticky event is reconsidered when
the sender's membership changes.
"""
user2_id = self.register_user("user2", "pass")
user2_tok = self.login(user2_id, "pass")
# Inject a soft-failed sticky event from user2
event_id = self.get_success(
inject_event(
self.hs,
room_id=self.room_id,
sender=user2_id,
type=EventTypes.Message,
content={"body": "sticky", "msgtype": "m.text"},
internal_metadata={"soft_failed": True},
# Corresponds to StickyEvent.EVENT_FIELD_NAME
msc4354_sticky=StickyEventField(
duration_ms=Duration(minutes=1).as_millis()
),
)
).event_id
# Whilst it is soft-failed, the event isn't shown to clients.
self.assertEqual(self._get_visible_sticky_event_ids(), set())
# Check that an irrelevant user's membership changing doesn't affect the event
user3_id = self.register_user("user3", "pass")
user3_tok = self.login(user3_id, "pass")
self.helper.join(self.room_id, user3_id, tok=user3_tok)
self.assertEqual(self._get_visible_sticky_event_ids(), set())
# The sender joins, so the event now passes auth and is un-soft-failed.
self.helper.join(self.room_id, user2_id, tok=user2_tok)
self.assertEqual(self._get_visible_sticky_event_ids(), {event_id})
# ...and the soft-failure has been cleared from the event itself.
event = self.get_success(self.store.get_event(event_id))
self.assertFalse(event.internal_metadata.is_soft_failed())
class StickyEventsFederationBacklogTestCase(unittest.HomeserverTestCase):
"""
Storage-level tests for the federation sticky event backlog mechanism.
This mechanism is used to catch a destination up on sticky events that were skipped over by a
federation catch-up transaction.
"""
if not USE_POSTGRES_FOR_TESTS and sqlite3.sqlite_version_info < (3, 40, 0):
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 = [
room.register_servlets,
login.register_servlets,
register.register_servlets,
admin.register_servlets,
]
def default_config(self) -> JsonDict:
config = super().default_config()
config["experimental_features"] = {"msc4354_enabled": True}
return config
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.store = self.hs.get_datastores().main
# Register an account and create a room
self.user_id = self.register_user("user", "pass")
self.token = self.login(self.user_id, "pass")
def _send_sticky(self, room_id: str, body: str) -> str:
"""
Send a sticky event.
"""
return self.helper.send_sticky_event(
room_id,
EventTypes.Message,
duration=Duration(minutes=1),
content={"body": body, "msgtype": "m.text"},
tok=self.token,
)["event_id"]
def _stream_ordering_for(self, event_id: str) -> int:
"""
Get the `stream_ordering` for the given event.
"""
event = self.get_success(self.hs.get_datastores().main.get_event(event_id))
stream_ordering = event.internal_metadata.stream_ordering
assert stream_ordering is not None
return stream_ordering
def _sticky_stream_id_for(self, event_id: str) -> int:
"""
Get the `sticky_events` `stream_id` for the given event.
"""
return self.get_success(
self.hs.get_datastores().main.db_pool.simple_select_one_onecol(
table="sticky_events",
keyvalues={"event_id": event_id},
retcol="stream_id",
desc="test:get_sticky_stream_id",
)
)
def _sticky_backlog_rows(self) -> set[tuple[str, str, int]]:
"""
All rows of the `destination_rooms_sticky_events_backlog` table,
as a set: row order is arbitrary, as the query has no ORDER BY.
"""
rows = self.get_success(
self.hs.get_datastores().main.db_pool.simple_select_list(
table="destination_rooms_sticky_events_backlog",
keyvalues=None,
retcols=("destination", "room_id", "sticky_events_stream_position"),
)
)
return set(rows)
def test_mark_backlogged_after_catchup_records_earliest_unsent_per_room(
self,
) -> None:
"""
Tests that a catch-up transaction that advances over a gap records,
per room and per destination, one row for every earliest sticky event
left unsent in the gap.
See the docstring on `mark_backlogged_sticky_events_after_catchup_transaction`
for a diagrammatical description.
"""
room1 = self.helper.create_room_as(self.user_id, tok=self.token)
room2 = self.helper.create_room_as(self.user_id, tok=self.token)
# The event immediately before the gap.
# Suppose that this is where the destination had
# successfully been caught up to.
before_gap_event_id = self.helper.send(room1, "before the gap", tok=self.token)[
"event_id"
]
# Send 2 sticky events into room1
room1_sticky1_event_id = self._send_sticky(room1, "sticky 1")
_room1_sticky2_event_id = self._send_sticky(room1, "sticky 2")
# Send a sticky event into room2
room2_sticky3_event_id = self._send_sticky(room2, "sticky 3")
# Send a couple of events for the the catch-up transaction to advance us to.
(room1_after_gap_event_id,) = self.helper.send_messages(
room1, 1, tok=self.token
)
(room2_after_gap_event_id,) = self.helper.send_messages(
room2, 1, tok=self.token
)
# We store destination rooms entries for those:
# this is how the outstanding events to be sent are tracked.
# It's also a necessary prerequisite for the backlog marking calculation.
self.get_success(
self.store.store_destination_rooms_entries(
{"host2"}, room1, self._stream_ordering_for(room1_after_gap_event_id)
)
)
self.get_success(
self.store.store_destination_rooms_entries(
{"host2"}, room2, self._stream_ordering_for(room2_after_gap_event_id)
)
)
# Now suppose we sent the first catch-up transaction (for room1, since the forward extremity
# of room1 is the oldest catch-up forward extremity in our database).
# This creates a gap of unsent sticky events that need to be caught up.
self.get_success(
self.store.mark_backlogged_sticky_events_after_catchup_transaction(
"host2",
old_last_successfully_sent_stream_ordering=self._stream_ordering_for(
before_gap_event_id
),
new_last_successfully_sent_stream_ordering=self._stream_ordering_for(
room1_after_gap_event_id
),
event_stream_orderings_sent_in_transaction={
self._stream_ordering_for(room1_after_gap_event_id)
},
)
)
self.assertEqual(
self._sticky_backlog_rows(),
{
# In room1: we need to catch up from the first sticky event
("host2", room1, self._sticky_stream_id_for(room1_sticky1_event_id)),
# In room2: we need to catch up from the first sticky event in that room
("host2", room2, self._sticky_stream_id_for(room2_sticky3_event_id)),
},
)
def test_mark_backlogged_after_catchup_ignores_events_outside_the_gap(self) -> None:
"""
Tests that when marking a backlog, we ignore:
- sticky events outside the gap
- non-sticky events inside the gap
"""
room_id = self.helper.create_room_as(self.user_id, tok=self.token)
# Send a sticky event. Suppose we had already delivered this one to the destination.
event1_id_sticky = self._send_sticky(room_id, "already sent")
# Send 2 regular events that we suppose we had _not_ delivered to the destination yet.
(_event2_id_nonsticky, _event3_id_nonsticky) = self.helper.send_messages(
room_id, 2, tok=self.token
)
# Send a sticky event. This is the one we'll treat as the forward extremity
event4_id_sticky = self._send_sticky(room_id, "not yet due to be sent")
# Note down that we have events up to `event4_sticky` that need to be
# sent out.
self.get_success(
self.store.store_destination_rooms_entries(
{"host2"}, room_id, self._stream_ordering_for(event4_id_sticky)
)
)
# Suppose we sent a catch-up transaction with `event4_sticky`,
self.get_success(
self.store.mark_backlogged_sticky_events_after_catchup_transaction(
"host2",
old_last_successfully_sent_stream_ordering=self._stream_ordering_for(
event1_id_sticky
),
new_last_successfully_sent_stream_ordering=self._stream_ordering_for(
event4_id_sticky
),
# Really we 'should' put `event4_sticky` here to match reality
# However we're interested in testing the range being correct without
# the set difference operation covering up any mistakes.
event_stream_orderings_sent_in_transaction=set(),
)
)
# There should be no backlog of unsent sticky events tracked,
# because there were none in the gap.
self.assertEqual(self._sticky_backlog_rows(), set())
def test_mark_backlogged_after_catchup_keeps_earliest_position(self) -> None:
"""
Tests that repeated catch-up transactions do not advance the backlog position
(as that would lose sticky events in the gap).
"""
room_id = self.helper.create_room_as(self.user_id, tok=self.token)
# First of all, suppose we had already sent out an event
event1_start_id = self.helper.send(room_id, "gap start", tok=self.token)[
"event_id"
]
# then send a sticky event that we will lose in the gap
event2_sticky_id = self._send_sticky(room_id, "early sticky")
# Send a 'middle' event that we will send out in a catch-up transaction
event3_middle_id = self.helper.send(room_id, "middle", tok=self.token)[
"event_id"
]
self.get_success(
self.store.store_destination_rooms_entries(
{"host2"}, room_id, self._stream_ordering_for(event3_middle_id)
)
)
# We get a catch-up transaction sent out with `middle` in it.
# This creates a gap of unsent sticky events.
self.get_success(
self.store.mark_backlogged_sticky_events_after_catchup_transaction(
"host2",
old_last_successfully_sent_stream_ordering=self._stream_ordering_for(
event1_start_id
),
new_last_successfully_sent_stream_ordering=self._stream_ordering_for(
event3_middle_id
),
event_stream_orderings_sent_in_transaction={
self._stream_ordering_for(event3_middle_id)
},
)
)
self.assertEqual(
self._sticky_backlog_rows(),
{("host2", room_id, self._sticky_stream_id_for(event2_sticky_id))},
)
# Now send another sticky event and 'lose' it in a gap again.
# Send a final event that we will send out in a catch-up transaction
# (in order to create a gap for this event to sit in)
event5_end_id = self.helper.send(room_id, "gap end", tok=self.token)["event_id"]
self.get_success(
self.store.store_destination_rooms_entries(
{"host2"}, room_id, self._stream_ordering_for(event5_end_id)
)
)
self.get_success(
self.store.mark_backlogged_sticky_events_after_catchup_transaction(
"host2",
old_last_successfully_sent_stream_ordering=self._stream_ordering_for(
event3_middle_id
),
new_last_successfully_sent_stream_ordering=self._stream_ordering_for(
event5_end_id
),
event_stream_orderings_sent_in_transaction={
self._stream_ordering_for(event5_end_id)
},
)
)
# We should find that the backlog still starts at `event2_sticky`,
# because it's the earliest sticky event that needs catching up.
self.assertEqual(
self._sticky_backlog_rows(),
{("host2", room_id, self._sticky_stream_id_for(event2_sticky_id))},
)
def test_get_backlogged_sticky_events_returns_none_when_no_backlog(self) -> None:
"""
Tests that `get_backlogged_sticky_events` returns `None` when
there is nothing to catch up on (empty backlog table).
"""
# Make a room with a sticky event that we intend to send to
# the destination
room_id = self.helper.create_room_as(self.user_id, tok=self.token)
event_id = self._send_sticky(room_id, "sticky")
# This notes our intention to send the event to the destination
# But it's not a sticky event backlog yet, just part of the regular
# event flow
self.get_success(
self.store.store_destination_rooms_entries(
{"host2"}, room_id, self._stream_ordering_for(event_id)
)
)
# So there should be no backlog
self.assertIsNone(
self.get_success(
self.store.get_backlogged_sticky_events_for_destination("host2")
)
)
def test_get_backlogged_sticky_events_returns_local_events_in_stream_order(
self,
) -> None:
"""
Tests that `get_backlogged_sticky_events` returns sticky events in stream order.
"""
room_id = self.helper.create_room_as(self.user_id, tok=self.token)
_sticky_1_event_id = self._send_sticky(room_id, "sticky 1")
sticky_2_event_id = self._send_sticky(room_id, "sticky 2")
sticky_3_event_id = self._send_sticky(room_id, "sticky 3")
sticky_4_event_id = self._send_sticky(room_id, "sticky 4")
# Pretend a catch-up transaction left a gap of unsent sticky events,
# starting from sticky_2 onwards.
self.get_success(
self.store.db_pool.simple_insert(
table="destination_rooms_sticky_events_backlog",
values={
"destination": "host2",
"room_id": room_id,
"sticky_events_stream_position": self._sticky_stream_id_for(
sticky_2_event_id
),
},
desc="test:insert_backlog",
)
)
result = self.get_success(
self.store.get_backlogged_sticky_events_for_destination("host2")
)
self.assertEqual(
result,
(
RoomID.from_string(room_id),
self._sticky_stream_id_for(sticky_4_event_id),
# We see sticky 2 events up to and including 4, in that order
[sticky_2_event_id, sticky_3_event_id, sticky_4_event_id],
),
)
def test_get_backlogged_sticky_events_respects_limit(self) -> None:
"""
Tests that `get_backlogged_sticky_events` respects the limit.
"""
room_id = self.helper.create_room_as(self.user_id, tok=self.token)
sticky_1_event_id = self._send_sticky(room_id, "sticky 1")
sticky_2_event_id = self._send_sticky(room_id, "sticky 2")
_sticky_3_event_id = self._send_sticky(room_id, "sticky 3")
# Pretend a catch-up transaction left a gap of unsent sticky events,
# starting from sticky_1 onwards.
self.get_success(
self.store.db_pool.simple_insert(
table="destination_rooms_sticky_events_backlog",
values={
"destination": "host2",
"room_id": room_id,
"sticky_events_stream_position": self._sticky_stream_id_for(
sticky_1_event_id
),
},
desc="test:insert_backlog",
)
)
self.assertEqual(
self.get_success(
self.store.get_backlogged_sticky_events_for_destination(
"host2", limit=2
)
),
(
RoomID.from_string(room_id),
# We get the sticky event stream ID of sticky_2 as that's the last one we received
# in this window
self._sticky_stream_id_for(sticky_2_event_id),
# We limit to 2 so we don't see sticky_3 here
[sticky_1_event_id, sticky_2_event_id],
),
)
def test_get_backlogged_sticky_events_excludes_remote_senders(self) -> None:
"""
Tests that we only consider our own (locally-sent) sticky events as
eligible for backlog catch-up.
> As with regular events, servers are only responsible for sending sticky events originating from their own server.
> — https://github.com/matrix-org/matrix-spec-proposals/blob/kegan/persist-edu/proposals/4354-sticky-events.md?plain=1#L195C1-L195C114
"""
room_id = self.helper.create_room_as(self.user_id, tok=self.token)
self.get_success(
inject_member_event(self.hs, room_id, "@remote:host3", Membership.JOIN)
)
remote_sticky_event_id = self.get_success(
inject_event(
self.hs,
room_id=room_id,
sender="@remote:host3",
type=EventTypes.Message,
content={"body": "remote sticky", "msgtype": "m.text"},
# Corresponds to StickyEvent.EVENT_FIELD_NAME
msc4354_sticky=StickyEventField(
duration_ms=Duration(minutes=1).as_millis()
),
)
).event_id
local_sticky_event_id = self._send_sticky(room_id, "local sticky")
# Sanity check our test: the remote event _is_ in the sticky events table.
self.assertEqual(
set(
self.get_success(
self.store.db_pool.simple_select_onecol(
table="sticky_events",
keyvalues={"room_id": room_id},
retcol="event_id",
desc="test:all_sticky_event_ids",
)
)
),
{remote_sticky_event_id, local_sticky_event_id},
)
assert self._sticky_stream_id_for(
remote_sticky_event_id
) < self._sticky_stream_id_for(local_sticky_event_id)
self.get_success(
self.store.db_pool.simple_insert(
table="destination_rooms_sticky_events_backlog",
values={
"destination": "host2",
"room_id": room_id,
"sticky_events_stream_position": self._sticky_stream_id_for(
remote_sticky_event_id
),
},
desc="test:insert_backlog",
)
)
self.assertEqual(
self.get_success(
self.store.get_backlogged_sticky_events_for_destination("host2")
),
(
RoomID.from_string(room_id),
self._sticky_stream_id_for(local_sticky_event_id),
[local_sticky_event_id],
),
)
def test_get_backlogged_sticky_events_cleans_up_stale_backlog(self) -> None:
"""
Tests that backlog rows are removed on-demand when it turns out there are
no unexpired sticky events remaining.
The backlog row is essentially just a 'hint' that there might be sticky events
left to send, but not a guarantee (due to expiry).
"""
room_id = self.helper.create_room_as(self.user_id, tok=self.token)
# A sticky event that expires almost immediately.
short_lived_event_id = self.helper.send_sticky_event(
room_id,
EventTypes.Message,
duration=Duration(milliseconds=1),
content={"body": "short lived", "msgtype": "m.text"},
tok=self.token,
)["event_id"]
self.get_success(
self.store.db_pool.simple_insert(
table="destination_rooms_sticky_events_backlog",
values={
"destination": "host2",
"room_id": room_id,
"sticky_events_stream_position": self._sticky_stream_id_for(
short_lived_event_id
),
},
desc="test:insert_backlog",
)
)
# Advance the reactor and trigger the deletion of expired sticky events
self.reactor.advance(0.002)
self.get_success(self.store._delete_expired_sticky_events())
self.assertIsNone(
self.get_success(
self.store.get_backlogged_sticky_events_for_destination("host2")
)
)
# Also note that the `destination_rooms_sticky_events_backlog` has been cleared
# so that we don't keep reconsidering this room that no longer has any
# unexpired sticky events to be sent.
self.assertEqual(self._sticky_backlog_rows(), set())
def test_mark_backlogged_sticky_events_sent_advances_position(self) -> None:
"""
Marking a batch as sent moves the recorded position to just *after* the
highest sent position, so the next batch starts with the first unsent event.
"""
room_id = self.helper.create_room_as(self.user_id, tok=self.token)
sticky_1_event_id = self._send_sticky(room_id, "sticky 1")
sticky_2_event_id = self._send_sticky(room_id, "sticky 2")
sticky_3_event_id = self._send_sticky(room_id, "sticky 3")
self.get_success(
self.store.db_pool.simple_insert(
table="destination_rooms_sticky_events_backlog",
values={
"destination": "host2",
"room_id": room_id,
"sticky_events_stream_position": self._sticky_stream_id_for(
sticky_1_event_id
),
},
desc="test:insert_backlog",
)
)
self.get_success(
self.store.mark_backlogged_sticky_events_sent(
"host2",
RoomID.from_string(room_id),
StickyEventStreamPosition(
self._sticky_stream_id_for(sticky_2_event_id)
),
)
)
# The stored position is an *inclusive lower bound on what is left*, hence
# exactly one past the highest event we sent.
self.assertEqual(
self._sticky_backlog_rows(),
{("host2", room_id, self._sticky_stream_id_for(sticky_2_event_id) + 1)},
)
# And the next batch is just the remaining event.
self.assertEqual(
self.get_success(
self.store.get_backlogged_sticky_events_for_destination("host2")
),
(
RoomID.from_string(room_id),
self._sticky_stream_id_for(sticky_3_event_id),
[sticky_3_event_id],
),
)