Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
aaa229f
Add MSC4354 experimental feature flag
reivilibre Dec 22, 2025
a25a5b5
Expose MSC4354 enablement on /versions
reivilibre Dec 22, 2025
7abcf6d
Add constants for sticky events
reivilibre Dec 22, 2025
c2d4cf4
Add sticky_events table
reivilibre Dec 22, 2025
ec5f2f8
Add sticky events store and stream
reivilibre Dec 22, 2025
003fe1f
EventBase: add the concept of sticky_duration
reivilibre Dec 22, 2025
c7075d6
EventBuilder: allow building events with sticky event fields
reivilibre Dec 22, 2025
0a4f7e6
store method: insert_sticky_events_txn
reivilibre Dec 22, 2025
53e4968
When persisting currently-sticky events, add to sticky event stream
reivilibre Dec 22, 2025
137c457
Allow clients to send sticky events
reivilibre Dec 22, 2025
90e9539
Add test helper for sending sticky events
reivilibre Dec 22, 2025
9a0b28e
Expose the sticky event TTL to clients
reivilibre Dec 22, 2025
b41ef41
Add a test for sticky TTL calculation and exposure to clients
reivilibre Jan 5, 2026
fb67e9a
Newsfile
reivilibre Jan 9, 2026
383d97c
Use Duration
reivilibre Jan 22, 2026
47306d2
Tweak comment on SQL column
reivilibre Jan 22, 2026
beead8a
Update synapse/config/workers.py
reivilibre Jan 22, 2026
1353ec3
Comments on constants
reivilibre Jan 26, 2026
a424310
FIELD_NAME -> EVENT_FIELD_NAME
reivilibre Jan 26, 2026
2522c38
Docstring explain stream writers for events and sticky_events
reivilibre Jan 26, 2026
450d5ee
Make explanation not double negative
reivilibre Jan 26, 2026
34d23ef
Comment why 1 hour
reivilibre Jan 26, 2026
f1ec22b
Stagger start of cleanup looping calls
reivilibre Jan 26, 2026
d28f17a
Specify bounds
reivilibre Jan 26, 2026
33f953e
Introduce StickyEventUpdate dataclass
reivilibre Jan 26, 2026
5f8f302
Remove stale log
reivilibre Jan 26, 2026
015819e
Docstring on delete_expired_sticky_events
reivilibre Jan 26, 2026
3c51231
Use StickyEventField
reivilibre Jan 26, 2026
11378ac
Remove un-soft-failing related handling
reivilibre Jan 26, 2026
db9504f
Add tracking issue
reivilibre Jan 26, 2026
ae5f1c5
Don't denormalise `soft_failed` as it could become mutable
reivilibre Jan 27, 2026
6527373
Add tests for the sticky events store methods
reivilibre Jan 27, 2026
d80d2c4
Describe what types of events do and don't live in sticky_events table
reivilibre Jan 27, 2026
e3a8f19
Note that events persisted before enablement won't be considered sticky
reivilibre Jan 27, 2026
f42af6a
Update synapse/storage/schema/main/delta/93/01_sticky_events.sql
reivilibre Jan 29, 2026
3be0e87
tests: just get the stream IDs between insertions
reivilibre Feb 3, 2026
efc08d2
tests: Read raw table for checking expired sticky events are deleted
reivilibre Feb 3, 2026
5a5efc5
Comment why we min(origin_server_ts, now_ms)
reivilibre Feb 3, 2026
d83656a
Explain skip rationale
reivilibre Feb 3, 2026
cf16293
Expand comment on staggering
reivilibre Feb 3, 2026
b0693b5
bool subclass of int clarification
reivilibre Feb 3, 2026
c0c78dc
Add a test for sticky events not being sticky when feature disabled
reivilibre Feb 3, 2026
2b822dd
Apparently a `?` in a SQL comment causes the migration to fail :D
reivilibre Feb 3, 2026
601b1f3
Suggested doc tweaks
reivilibre Feb 4, 2026
02d245f
Prevent starting up with feature when SQLite is too old
reivilibre Feb 5, 2026
737d482
Skip the test on old SQLite
reivilibre Feb 5, 2026
a535477
Add test for de-outliering events
reivilibre Feb 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/19365.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Support sending and receiving [MSC4354 Sticky Event](https://github.com/matrix-org/matrix-spec-proposals/pull/4354) metadata.
2 changes: 2 additions & 0 deletions docker/complement/conf/workers-shared-extra.yaml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ experimental_features:
msc4155_enabled: true
# Thread Subscriptions
msc4306_enabled: true
# Sticky Events
msc4354_enabled: true

server_notices:
system_mxid_localpart: _server
Expand Down
43 changes: 42 additions & 1 deletion synapse/api/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
"""Contains constants from the specification."""

import enum
from typing import Final
from typing import Final, TypedDict

from synapse.util.duration import Duration

# the max size of a (canonical-json-encoded) event
MAX_PDU_SIZE = 65536
Expand Down Expand Up @@ -292,6 +294,8 @@ class EventUnsignedContentFields:
# Requesting user's membership, per MSC4115
MEMBERSHIP: Final = "membership"

STICKY_TTL: Final = "msc4354_sticky_duration_ttl_ms"


class MTextFields:
"""Fields found inside m.text content blocks."""
Expand Down Expand Up @@ -377,3 +381,40 @@ class Direction(enum.Enum):
class ProfileFields:
DISPLAYNAME: Final = "displayname"
AVATAR_URL: Final = "avatar_url"


class StickyEventField(TypedDict):
"""
Dict content of the `sticky` part of an event.
"""

duration_ms: int


class StickyEvent:
QUERY_PARAM_NAME: Final = "org.matrix.msc4354.sticky_duration_ms"
"""
Query parameter used by clients for setting the sticky duration of an event they are sending.

Applies to:
- /rooms/.../send/...
- /rooms/.../state/...
"""

EVENT_FIELD_NAME: Final = "msc4354_sticky"
"""
Name of the field in the top-level event dict that contains the sticky event dict.
"""

MAX_DURATION: Duration = Duration(hours=1)
"""
Maximum stickiness duration as specified in MSC4354.
Ensures that data in the /sync response can go down and not grow unbounded.
"""

MAX_EVENTS_IN_SYNC: Final = 100
"""
Maximum number of sticky events to include in /sync.

This is the default specified in the MSC. Chosen arbitrarily.
"""
2 changes: 2 additions & 0 deletions synapse/app/generic_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
from synapse.storage.databases.main.sliding_sync import SlidingSyncStore
from synapse.storage.databases.main.state import StateGroupWorkerStore
from synapse.storage.databases.main.stats import StatsStore
from synapse.storage.databases.main.sticky_events import StickyEventsWorkerStore
from synapse.storage.databases.main.stream import StreamWorkerStore
from synapse.storage.databases.main.tags import TagsWorkerStore
from synapse.storage.databases.main.task_scheduler import TaskSchedulerWorkerStore
Expand Down Expand Up @@ -137,6 +138,7 @@ class GenericWorkerStore(
RoomWorkerStore,
DirectoryWorkerStore,
ThreadSubscriptionsWorkerStore,
StickyEventsWorkerStore,
PushRulesWorkerStore,
ApplicationServiceTransactionWorkerStore,
ApplicationServiceWorkerStore,
Expand Down
6 changes: 6 additions & 0 deletions synapse/config/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -597,5 +597,11 @@ def read_config(
# (and MSC4308: Thread Subscriptions extension to Sliding Sync)
self.msc4306_enabled: bool = experimental.get("msc4306_enabled", False)

# MSC4354: Sticky Events
# Tracked in: https://github.com/element-hq/synapse/issues/19409
# Note that sticky events persisted before this feature is enabled will not be
# considered sticky by the local homeserver.
self.msc4354_enabled: bool = experimental.get("msc4354_enabled", False)

# MSC4380: Invite blocking
self.msc4380_enabled: bool = experimental.get("msc4380_enabled", False)
4 changes: 3 additions & 1 deletion synapse/config/workers.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ class WriterLocations:
"""Specifies the instances that write various streams.

Attributes:
events: The instances that write to the event and backfill streams.
events: The instances that write to the event, backfill and `sticky_events` streams.
(`sticky_events` is written to during event persistence so must be handled by the
same stream writers.)
typing: The instances that write to the typing stream. Currently
can only be a single instance.
to_device: The instances that write to the to_device stream. Currently
Expand Down
30 changes: 29 additions & 1 deletion synapse/events/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,20 @@
import attr
from unpaddedbase64 import encode_base64

from synapse.api.constants import EventContentFields, EventTypes, RelationTypes
from synapse.api.constants import (
EventContentFields,
EventTypes,
RelationTypes,
StickyEvent,
)
from synapse.api.room_versions import EventFormatVersions, RoomVersion, RoomVersions
from synapse.synapse_rust.events import EventInternalMetadata
from synapse.types import (
JsonDict,
StrCollection,
)
from synapse.util.caches import intern_dict
from synapse.util.duration import Duration
from synapse.util.frozenutils import freeze

if TYPE_CHECKING:
Expand Down Expand Up @@ -318,6 +324,28 @@ def freeze(self) -> None:
# this will be a no-op if the event dict is already frozen.
self._dict = freeze(self._dict)

def sticky_duration(self) -> Duration | None:
"""
Returns the effective sticky duration of this event, or None
if the event does not have a sticky duration.
(Sticky Events are a MSC4354 feature.)

Clamps the sticky duration to the maximum allowed duration.
"""
sticky_obj = self.get_dict().get(StickyEvent.EVENT_FIELD_NAME, None)
if type(sticky_obj) is not dict:
return None
sticky_duration_ms = sticky_obj.get("duration_ms", None)
# MSC: Clamp to 0 and MAX_DURATION (1 hour)
# We use `type(...) is int` to avoid accepting bools as `isinstance(True, int)`
# (bool is a subclass of int)
if type(sticky_duration_ms) is int and sticky_duration_ms >= 0:
return min(
Duration(milliseconds=sticky_duration_ms),
StickyEvent.MAX_DURATION,
)
return None

def __str__(self) -> str:
return self.__repr__()

Expand Down
10 changes: 9 additions & 1 deletion synapse/events/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
import attr
from signedjson.types import SigningKey

from synapse.api.constants import MAX_DEPTH, EventTypes
from synapse.api.constants import MAX_DEPTH, EventTypes, StickyEvent, StickyEventField
from synapse.api.room_versions import (
KNOWN_EVENT_FORMAT_VERSIONS,
EventFormatVersions,
Expand Down Expand Up @@ -89,6 +89,10 @@ class EventBuilder:

content: JsonDict = attr.Factory(dict)
unsigned: JsonDict = attr.Factory(dict)
sticky: StickyEventField | None = None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
sticky: StickyEventField | None = None
sticky_content: StickyEventContent | None = None

🤷 Not necessarily better but it's a bit strange to onboard to these names and what they are.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't entirely prefer 'content' but maybe attributes, properties, or something like that? Any thoughts on those two?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They don't stand out to me as better.

The problem with "content" is we already have event content.

The problem with the others is we're talking about "content" for the sticky attribute/property/key.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

meh, I'm not sure but maybe EventSticky? At least it feels a little like event.sticky and doesn't confuse you into thinking it's the sticky event itself.

My thought behind attributes/properties what naming it after what the class is, rather than where it sits within the event. 'These are the properties for sticky events'. Hm.

But I really don't like sticky event 'content' given the conflict with event content.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this is the only thing I would like to see if you had any further thoughts on before the PR lands? @MadLittleMods

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably fine as-is. We don't have any great options ⏩

"""
Fields for MSC4354: Sticky Events
"""

# These only exist on a subset of events, so they raise AttributeError if
# someone tries to get them when they don't exist.
Expand Down Expand Up @@ -269,6 +273,9 @@ async def build(
if self._origin_server_ts is not None:
event_dict["origin_server_ts"] = self._origin_server_ts

if self.sticky is not None:
event_dict[StickyEvent.EVENT_FIELD_NAME] = self.sticky

return create_local_event_from_event_dict(
clock=self._clock,
hostname=self._hostname,
Expand Down Expand Up @@ -318,6 +325,7 @@ def for_room_version(
unsigned=key_values.get("unsigned", {}),
redacts=key_values.get("redacts", None),
origin_server_ts=key_values.get("origin_server_ts", None),
sticky=key_values.get(StickyEvent.EVENT_FIELD_NAME, None),
)


Expand Down
13 changes: 10 additions & 3 deletions synapse/handlers/delayed_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

from twisted.internet.interfaces import IDelayedCall

from synapse.api.constants import EventTypes
from synapse.api.constants import EventTypes, StickyEvent, StickyEventField
from synapse.api.errors import ShadowBanError, SynapseError
from synapse.api.ratelimiting import Ratelimiter
from synapse.config.workers import MAIN_PROCESS_INSTANCE_NAME
Expand Down Expand Up @@ -333,6 +333,7 @@ async def add(
origin_server_ts: int | None,
content: JsonDict,
delay: int,
sticky_duration_ms: int | None,
) -> str:
"""
Creates a new delayed event and schedules its delivery.
Expand All @@ -346,7 +347,9 @@ async def add(
If None, the timestamp will be the actual time when the event is sent.
content: The content of the event to be sent.
delay: How long (in milliseconds) to wait before automatically sending the event.

sticky_duration_ms: If an MSC4354 sticky event: the sticky duration (in milliseconds).
The event will be attempted to be reliably delivered to clients and remote servers
during its sticky period.
Returns: The ID of the added delayed event.

Raises:
Expand Down Expand Up @@ -382,6 +385,7 @@ async def add(
origin_server_ts=origin_server_ts,
content=content,
delay=delay,
sticky_duration_ms=sticky_duration_ms,
)

if self._repl_client is not None:
Expand Down Expand Up @@ -570,7 +574,10 @@ async def _send_event(

if event.state_key is not None:
event_dict["state_key"] = event.state_key

if event.sticky_duration_ms is not None:
event_dict[StickyEvent.EVENT_FIELD_NAME] = StickyEventField(
duration_ms=event.sticky_duration_ms
)
(
sent_event,
_,
Expand Down
1 change: 1 addition & 0 deletions synapse/notifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,7 @@ def on_new_event(
StreamKeyType.TYPING,
StreamKeyType.UN_PARTIAL_STATED_ROOMS,
StreamKeyType.THREAD_SUBSCRIPTIONS,
StreamKeyType.STICKY_EVENTS,
],
new_token: int,
users: Collection[str | UserID] | None = None,
Expand Down
11 changes: 10 additions & 1 deletion synapse/replication/tcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@
UnPartialStatedEventStream,
UnPartialStatedRoomStream,
)
from synapse.replication.tcp.streams._base import ThreadSubscriptionsStream
from synapse.replication.tcp.streams._base import (
StickyEventsStream,
ThreadSubscriptionsStream,
)
from synapse.replication.tcp.streams.events import (
EventsStream,
EventsStreamEventRow,
Expand Down Expand Up @@ -262,6 +265,12 @@ async def on_rdata(
token,
users=[row.user_id for row in rows],
)
elif stream_name == StickyEventsStream.NAME:
self.notifier.on_new_event(
StreamKeyType.STICKY_EVENTS,
token,
rooms=[row.room_id for row in rows],
)

await self._presence_handler.process_replication_rows(
stream_name, instance_name, token, rows
Expand Down
7 changes: 7 additions & 0 deletions synapse/replication/tcp/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
)
from synapse.replication.tcp.streams._base import (
DeviceListsStream,
StickyEventsStream,
ThreadSubscriptionsStream,
)
from synapse.util.background_queue import BackgroundQueue
Expand Down Expand Up @@ -216,6 +217,12 @@ def __init__(self, hs: "HomeServer"):

continue

if isinstance(stream, StickyEventsStream):
if hs.get_instance_name() in hs.config.worker.writers.events:
self._streams_to_replicate.append(stream)

continue

if isinstance(stream, DeviceListsStream):
if hs.get_instance_name() in hs.config.worker.writers.device_lists:
self._streams_to_replicate.append(stream)
Expand Down
3 changes: 3 additions & 0 deletions synapse/replication/tcp/streams/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
PushersStream,
PushRulesStream,
ReceiptsStream,
StickyEventsStream,
Stream,
ThreadSubscriptionsStream,
ToDeviceStream,
Expand Down Expand Up @@ -68,6 +69,7 @@
ToDeviceStream,
FederationStream,
AccountDataStream,
StickyEventsStream,
ThreadSubscriptionsStream,
UnPartialStatedRoomStream,
UnPartialStatedEventStream,
Expand All @@ -90,6 +92,7 @@
"ToDeviceStream",
"FederationStream",
"AccountDataStream",
"StickyEventsStream",
"ThreadSubscriptionsStream",
"UnPartialStatedRoomStream",
"UnPartialStatedEventStream",
Expand Down
45 changes: 45 additions & 0 deletions synapse/replication/tcp/streams/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -763,3 +763,48 @@ async def _update_function(
return [], to_token, False

return rows, rows[-1][0], len(updates) == limit


@attr.s(slots=True, auto_attribs=True)
class StickyEventsStreamRow:
"""Stream to inform workers about changes to sticky events."""
Comment on lines +769 to +770
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For my own reference, dev notes on creating a new stream: docs/development/synapse_architecture/streams.md#cheatsheet-for-creating-a-new-stream


room_id: str

event_id: str
"""The sticky event ID"""


class StickyEventsStream(_StreamFromIdGen):
"""A sticky event was changed."""

NAME = "sticky_events"
ROW_TYPE = StickyEventsStreamRow

def __init__(self, hs: "HomeServer"):
self.store = hs.get_datastores().main
super().__init__(
hs.get_instance_name(),
self._update_function,
self.store._sticky_events_id_gen,
)

async def _update_function(
self, instance_name: str, from_token: int, to_token: int, limit: int
) -> StreamUpdateResult:
updates = await self.store.get_updated_sticky_events(
from_id=from_token, to_id=to_token, limit=limit
)
rows = [
(
update.stream_id,
# These are the args to `StickyEventsStreamRow`
(update.room_id, update.event_id),
)
for update in updates
]

if not rows:
return [], to_token, False

return rows, rows[-1][0], len(updates) == limit
Loading
Loading