Skip to content

Commit 52fb6e9

Browse files
Support sending and receiving MSC4354 Sticky Event metadata. (#19365)
Part of: MSC4354 whose experimental feature tracking issue is #19409 Follows: #19340 (a necessary bugfix for `/event/` to set this metadata) Partially supersedes: #18968 This PR implements the first batch of work to support MSC4354 Sticky Events. Sticky events are events that have been configured with a finite 'stickiness' duration, capped to 1 hour per current MSC draft. Whilst an event is sticky, we provide stronger delivery guarantees for the event, both to our clients and to remote homeservers, essentially making it reliable delivery as long as we have a functional connection to the client/server and until the stickiness expires. This PR merely supports creating sticky events and receiving the sticky TTL metadata in clients. It is not suitable for trialling sticky events since none of the other semantics are implemented. Contains a temporary SQLite workaround due to a bug in our supported version enforcement: #19452 --------- Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org> Co-authored-by: Eric Eastwood <erice@element.io>
1 parent 1841ded commit 52fb6e9

File tree

30 files changed

+1147
-15
lines changed

30 files changed

+1147
-15
lines changed

changelog.d/19365.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Support sending and receiving [MSC4354 Sticky Event](https://github.com/matrix-org/matrix-spec-proposals/pull/4354) metadata.

docker/complement/conf/workers-shared-extra.yaml.j2

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ experimental_features:
139139
msc4155_enabled: true
140140
# Thread Subscriptions
141141
msc4306_enabled: true
142+
# Sticky Events
143+
msc4354_enabled: true
142144

143145
server_notices:
144146
system_mxid_localpart: _server

synapse/api/constants.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424
"""Contains constants from the specification."""
2525

2626
import enum
27-
from typing import Final
27+
from typing import Final, TypedDict
28+
29+
from synapse.util.duration import Duration
2830

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

297+
STICKY_TTL: Final = "msc4354_sticky_duration_ttl_ms"
298+
295299

296300
class MTextFields:
297301
"""Fields found inside m.text content blocks."""
@@ -377,3 +381,40 @@ class Direction(enum.Enum):
377381
class ProfileFields:
378382
DISPLAYNAME: Final = "displayname"
379383
AVATAR_URL: Final = "avatar_url"
384+
385+
386+
class StickyEventField(TypedDict):
387+
"""
388+
Dict content of the `sticky` part of an event.
389+
"""
390+
391+
duration_ms: int
392+
393+
394+
class StickyEvent:
395+
QUERY_PARAM_NAME: Final = "org.matrix.msc4354.sticky_duration_ms"
396+
"""
397+
Query parameter used by clients for setting the sticky duration of an event they are sending.
398+
399+
Applies to:
400+
- /rooms/.../send/...
401+
- /rooms/.../state/...
402+
"""
403+
404+
EVENT_FIELD_NAME: Final = "msc4354_sticky"
405+
"""
406+
Name of the field in the top-level event dict that contains the sticky event dict.
407+
"""
408+
409+
MAX_DURATION: Duration = Duration(hours=1)
410+
"""
411+
Maximum stickiness duration as specified in MSC4354.
412+
Ensures that data in the /sync response can go down and not grow unbounded.
413+
"""
414+
415+
MAX_EVENTS_IN_SYNC: Final = 100
416+
"""
417+
Maximum number of sticky events to include in /sync.
418+
419+
This is the default specified in the MSC. Chosen arbitrarily.
420+
"""

synapse/app/generic_worker.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
from synapse.storage.databases.main.sliding_sync import SlidingSyncStore
103103
from synapse.storage.databases.main.state import StateGroupWorkerStore
104104
from synapse.storage.databases.main.stats import StatsStore
105+
from synapse.storage.databases.main.sticky_events import StickyEventsWorkerStore
105106
from synapse.storage.databases.main.stream import StreamWorkerStore
106107
from synapse.storage.databases.main.tags import TagsWorkerStore
107108
from synapse.storage.databases.main.task_scheduler import TaskSchedulerWorkerStore
@@ -137,6 +138,7 @@ class GenericWorkerStore(
137138
RoomWorkerStore,
138139
DirectoryWorkerStore,
139140
ThreadSubscriptionsWorkerStore,
141+
StickyEventsWorkerStore,
140142
PushRulesWorkerStore,
141143
ApplicationServiceTransactionWorkerStore,
142144
ApplicationServiceWorkerStore,

synapse/config/experimental.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,5 +580,11 @@ def read_config(
580580
# (and MSC4308: Thread Subscriptions extension to Sliding Sync)
581581
self.msc4306_enabled: bool = experimental.get("msc4306_enabled", False)
582582

583+
# MSC4354: Sticky Events
584+
# Tracked in: https://github.com/element-hq/synapse/issues/19409
585+
# Note that sticky events persisted before this feature is enabled will not be
586+
# considered sticky by the local homeserver.
587+
self.msc4354_enabled: bool = experimental.get("msc4354_enabled", False)
588+
583589
# MSC4380: Invite blocking
584590
self.msc4380_enabled: bool = experimental.get("msc4380_enabled", False)

synapse/config/workers.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,9 @@ class WriterLocations:
127127
"""Specifies the instances that write various streams.
128128
129129
Attributes:
130-
events: The instances that write to the event and backfill streams.
130+
events: The instances that write to the event, backfill and `sticky_events` streams.
131+
(`sticky_events` is written to during event persistence so must be handled by the
132+
same stream writers.)
131133
typing: The instances that write to the typing stream. Currently
132134
can only be a single instance.
133135
to_device: The instances that write to the to_device stream. Currently

synapse/events/__init__.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,20 @@
3636
import attr
3737
from unpaddedbase64 import encode_base64
3838

39-
from synapse.api.constants import EventContentFields, EventTypes, RelationTypes
39+
from synapse.api.constants import (
40+
EventContentFields,
41+
EventTypes,
42+
RelationTypes,
43+
StickyEvent,
44+
)
4045
from synapse.api.room_versions import EventFormatVersions, RoomVersion, RoomVersions
4146
from synapse.synapse_rust.events import EventInternalMetadata
4247
from synapse.types import (
4348
JsonDict,
4449
StrCollection,
4550
)
4651
from synapse.util.caches import intern_dict
52+
from synapse.util.duration import Duration
4753
from synapse.util.frozenutils import freeze
4854

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

327+
def sticky_duration(self) -> Duration | None:
328+
"""
329+
Returns the effective sticky duration of this event, or None
330+
if the event does not have a sticky duration.
331+
(Sticky Events are a MSC4354 feature.)
332+
333+
Clamps the sticky duration to the maximum allowed duration.
334+
"""
335+
sticky_obj = self.get_dict().get(StickyEvent.EVENT_FIELD_NAME, None)
336+
if type(sticky_obj) is not dict:
337+
return None
338+
sticky_duration_ms = sticky_obj.get("duration_ms", None)
339+
# MSC: Clamp to 0 and MAX_DURATION (1 hour)
340+
# We use `type(...) is int` to avoid accepting bools as `isinstance(True, int)`
341+
# (bool is a subclass of int)
342+
if type(sticky_duration_ms) is int and sticky_duration_ms >= 0:
343+
return min(
344+
Duration(milliseconds=sticky_duration_ms),
345+
StickyEvent.MAX_DURATION,
346+
)
347+
return None
348+
321349
def __str__(self) -> str:
322350
return self.__repr__()
323351

synapse/events/builder.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
import attr
2525
from signedjson.types import SigningKey
2626

27-
from synapse.api.constants import MAX_DEPTH, EventTypes
27+
from synapse.api.constants import MAX_DEPTH, EventTypes, StickyEvent, StickyEventField
2828
from synapse.api.room_versions import (
2929
KNOWN_EVENT_FORMAT_VERSIONS,
3030
EventFormatVersions,
@@ -89,6 +89,10 @@ class EventBuilder:
8989

9090
content: JsonDict = attr.Factory(dict)
9191
unsigned: JsonDict = attr.Factory(dict)
92+
sticky: StickyEventField | None = None
93+
"""
94+
Fields for MSC4354: Sticky Events
95+
"""
9296

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

276+
if self.sticky is not None:
277+
event_dict[StickyEvent.EVENT_FIELD_NAME] = self.sticky
278+
272279
return create_local_event_from_event_dict(
273280
clock=self._clock,
274281
hostname=self._hostname,
@@ -318,6 +325,7 @@ def for_room_version(
318325
unsigned=key_values.get("unsigned", {}),
319326
redacts=key_values.get("redacts", None),
320327
origin_server_ts=key_values.get("origin_server_ts", None),
328+
sticky=key_values.get(StickyEvent.EVENT_FIELD_NAME, None),
321329
)
322330

323331

synapse/handlers/delayed_events.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from twisted.internet.interfaces import IDelayedCall
1919

20-
from synapse.api.constants import EventTypes
20+
from synapse.api.constants import EventTypes, StickyEvent, StickyEventField
2121
from synapse.api.errors import ShadowBanError, SynapseError
2222
from synapse.api.ratelimiting import Ratelimiter
2323
from synapse.config.workers import MAIN_PROCESS_INSTANCE_NAME
@@ -333,6 +333,7 @@ async def add(
333333
origin_server_ts: int | None,
334334
content: JsonDict,
335335
delay: int,
336+
sticky_duration_ms: int | None,
336337
) -> str:
337338
"""
338339
Creates a new delayed event and schedules its delivery.
@@ -346,7 +347,9 @@ async def add(
346347
If None, the timestamp will be the actual time when the event is sent.
347348
content: The content of the event to be sent.
348349
delay: How long (in milliseconds) to wait before automatically sending the event.
349-
350+
sticky_duration_ms: If an MSC4354 sticky event: the sticky duration (in milliseconds).
351+
The event will be attempted to be reliably delivered to clients and remote servers
352+
during its sticky period.
350353
Returns: The ID of the added delayed event.
351354
352355
Raises:
@@ -382,6 +385,7 @@ async def add(
382385
origin_server_ts=origin_server_ts,
383386
content=content,
384387
delay=delay,
388+
sticky_duration_ms=sticky_duration_ms,
385389
)
386390

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

571575
if event.state_key is not None:
572576
event_dict["state_key"] = event.state_key
573-
577+
if event.sticky_duration_ms is not None:
578+
event_dict[StickyEvent.EVENT_FIELD_NAME] = StickyEventField(
579+
duration_ms=event.sticky_duration_ms
580+
)
574581
(
575582
sent_event,
576583
_,

synapse/notifier.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,7 @@ def on_new_event(
526526
StreamKeyType.TYPING,
527527
StreamKeyType.UN_PARTIAL_STATED_ROOMS,
528528
StreamKeyType.THREAD_SUBSCRIPTIONS,
529+
StreamKeyType.STICKY_EVENTS,
529530
],
530531
new_token: int,
531532
users: Collection[str | UserID] | None = None,

0 commit comments

Comments
 (0)