Skip to content

Catch up MSC4354 Sticky Events to remote homeservers that have missed them. - #20165

Merged
reivilibre merged 43 commits into
developfrom
rei/sticky_events_fed_backlog
Oct 5, 2026
Merged

reivilibre merged 43 commits into
developfrom
rei/sticky_events_fed_backlog

Conversation

@reivilibre

@reivilibre reivilibre commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Part of: MSC4354 (Sticky Events)

Our locally-sent Sticky Events need to be reliably (within bounds of possibility) sent to remote homeservers.

This PR adds the mechanism to make this happen.

Notable omissions:

  • catching up historical sticky events to homeservers that only just join the room

Review Guidance

The following commit is refactoring and hopefully stands alone, with no behavioural changes. I suggest considering it on its own.

  • Pull out user_is_local_like_pattern

After that, I recommend looking at the database schema (delta introducing a table) to understand the data model
for tracking backlogged sticky events. That's more or less the central part of this PR.

It may be helpful to remember that Sticky Events are events that are temporarily 'promoted' such that we want to ensure that all users and all homeservers receive them,
without letting them fall down 'gaps' of unretrieved history. Essentially, it's a temporary delivery reliability increase.

There are then roughly three logical pieces to review:

  • how, when sending federation transactions, we retrieve events from the sticky event backlog and record that
    we have succeeded in sending them
  • how we become aware of a new backlog of sticky events. (Bear in mind that, unless the remote homeserver goes down, the intention
    is for sticky events to not have any special processing)
    The docstring on mark_backlogged_sticky_events_after_catchup_transaction contains a diagrammatical explanation of this
    mechanism and how it works.
  • how we round-robin between sending out sticky event backlog transactions and 'main queue' (normal) transactions containing
    real-time PDUs and EDUs

Or if you prefer, it may be easiest to just start looking at the storage changes
to get an idea of the storage contract exposed by the database layer, since
the federation transmission logic itself is (I think) fairly understandable,
as long as you trust that contract.

Happy to pair, explain, add comments, anything; just let me know!


  1. Replace _TransactionQueueManager with prepare/complete transaction methods
    Split out at Refactor the federation transmission code to delineate transaction preparation and completion. #20166

  2. Pull out user_is_local_like_pattern

  3. unwrap utility

  4. Add destination_room_sticky_events_backlog table

  5. Introduce a StickyEventStreamPosition NewType

  6. Add store methods

  7. Wire sticky event backlog transactions into the per-destination queue

  8. Add mark of backlog after catch-up transaction

  9. Add tests

@reivilibre reivilibre changed the title Catch up [MSC4354 Sticky Events](https://github.com/matrix-org/matrix-spec-proposals/pull/4354) to remote homeservers that have missed them. Catch up MSC4354 Sticky Events to remote homeservers that have missed them. Aug 28, 2026
@reivilibre
reivilibre force-pushed the rei/sticky_events_fed_backlog branch 2 times, most recently from 19f9df7 to ff65c5e Compare August 28, 2026 17:38
@github-actions

github-actions Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Schema Diff

Please check that this looks as expected!

common (sqlite)

Unchanged

common (postgres)

Unchanged

main (sqlite)

--- /tmp/schema_diff_vc7yy24k/before/main/full_schemas/9999/full.sql.sqlite	2026-10-02 10:39:20.322790882 +0000
+++ /tmp/schema_diff_vc7yy24k/after/main/full_schemas/9999/full.sql.sqlite	2026-10-02 10:39:09.954713358 +0000
@@ -1194,20 +1194,45 @@
 
   -- The full user ID of the local user that should receive the profile update.
   user_id TEXT NOT NULL,
 
   -- Unix timestamp (milliseconds). Used to determine when to prune rows (to prevent the table
   -- from growing indefinitely).
   inserted_ts BIGINT NOT NULL,
 
   PRIMARY KEY (user_id, stream_id)
 );
+CREATE TABLE destination_rooms_sticky_events_backlog (
+    -- Server name of the remote homeserver.
+    destination TEXT NOT NULL,
+
+    -- Room ID in which sticky events have been missed.
+    room_id TEXT NOT NULL
+        -- Only track this information for rooms we know about;
+        -- if we delete a room locally then also delete the tracking info.
+        REFERENCES rooms(room_id) ON DELETE CASCADE,
+
+    -- Position in the sticky events stream, corresponding to the
+    -- `sticky_events.stream_id` of the first sticky event that
+    -- has yet to be sent.
+    --
+    -- Because sticky events must be sent in order (as per MSC4354),
+    -- all subsequent sticky events for the same room with higher
+    -- `stream_id`s are also unsent.
+    --
+    -- Not a foreign key because we must still support expiration of sticky events.
+    sticky_events_stream_position INTEGER NOT NULL,
+
+    -- It's enough to track one position per (destination, room_id) pair.
+    PRIMARY KEY (destination, room_id)
+);
+CREATE INDEX destination_rooms_sticky_events_backlog_room_id ON destination_rooms_sticky_events_backlog (room_id);
 CREATE UNIQUE INDEX receipts_graph_unique_index ON receipts_graph (room_id, receipt_type, user_id) WHERE thread_id IS NULL;
 CREATE UNIQUE INDEX receipts_linearized_unique_index ON receipts_linearized (room_id, receipt_type, user_id) WHERE thread_id IS NULL;
 CREATE INDEX device_lists_changes_in_room_by_room_idx ON device_lists_changes_in_room (room_id, stream_id) ;
 CREATE INDEX events_jump_to_date_idx ON events (room_id, origin_server_ts) WHERE NOT outlier;
 CREATE INDEX room_membership_user_room_idx ON room_memberships (user_id, room_id) ;
 CREATE UNIQUE INDEX profiles_full_user_id_key ON profiles (full_user_id) ;
 CREATE UNIQUE INDEX full_users_unique_idx ON user_filters (full_user_id, filter_id) ;
 CREATE INDEX event_push_actions_stream_highlight_index ON event_push_actions (highlight, stream_ordering) WHERE highlight=0;
 CREATE INDEX current_state_events_stream_ordering_idx ON current_state_events (event_stream_ordering) ;
 CREATE INDEX local_current_membership_stream_ordering_idx ON local_current_membership (event_stream_ordering) ;

main (postgres)

--- /tmp/schema_diff_vc7yy24k/before/main/full_schemas/9999/full.sql.postgres	2026-10-02 10:39:20.791793642 +0000
+++ /tmp/schema_diff_vc7yy24k/after/main/full_schemas/9999/full.sql.postgres	2026-10-02 10:39:10.462717334 +0000
@@ -203,20 +203,25 @@
 CREATE TABLE destination_rooms (
     destination text NOT NULL,
     room_id text NOT NULL,
     stream_ordering bigint NOT NULL
 );
 COMMENT ON TABLE destination_rooms IS 'Information about transmission of PDUs in a given room to a given remote homeserver.';
 COMMENT ON COLUMN destination_rooms.destination IS 'server name of remote homeserver in question';
 COMMENT ON COLUMN destination_rooms.room_id IS 'room ID in question';
 COMMENT ON COLUMN destination_rooms.stream_ordering IS '`stream_ordering` of the most recent PDU in this room that needs to be sent (by us) to this homeserver.
 This can only be pointing to our own PDU because we are only responsible for sending our own PDUs.';
+CREATE TABLE destination_rooms_sticky_events_backlog (
+    destination text NOT NULL,
+    room_id text NOT NULL,
+    sticky_events_stream_position integer NOT NULL
+);
 CREATE TABLE destinations (
     destination text NOT NULL,
     retry_last_ts bigint,
     retry_interval bigint,
     failure_ts bigint,
     last_successful_stream_ordering bigint
 );
 COMMENT ON TABLE destinations IS 'Information about remote homeservers and the health of our connection to them.';
 COMMENT ON COLUMN destinations.destination IS 'server name of remote homeserver in question';
 COMMENT ON COLUMN destinations.retry_last_ts IS 'The last time we tried and failed to reach the remote server, in ms.
@@ -1433,20 +1438,22 @@
 ALTER TABLE ONLY current_state_events
     ADD CONSTRAINT current_state_events_room_id_type_state_key_key UNIQUE (room_id, type, state_key);
 ALTER TABLE ONLY dehydrated_devices
     ADD CONSTRAINT dehydrated_devices_pkey PRIMARY KEY (user_id);
 ALTER TABLE ONLY delayed_events
     ADD CONSTRAINT delayed_events_pkey PRIMARY KEY (user_localpart, delay_id);
 ALTER TABLE ONLY delayed_events_stream_pos
     ADD CONSTRAINT delayed_events_stream_pos_lock_key UNIQUE (lock);
 ALTER TABLE ONLY destination_rooms
     ADD CONSTRAINT destination_rooms_pkey PRIMARY KEY (destination, room_id);
+ALTER TABLE ONLY destination_rooms_sticky_events_backlog
+    ADD CONSTRAINT destination_rooms_sticky_events_backlog_pkey PRIMARY KEY (destination, room_id);
 ALTER TABLE ONLY destinations
     ADD CONSTRAINT destinations_pkey PRIMARY KEY (destination);
 ALTER TABLE ONLY device_lists_changes_converted_stream_position
     ADD CONSTRAINT device_lists_changes_converted_stream_position_lock_key UNIQUE (lock);
 ALTER TABLE ONLY device_lists_changes_in_room_max_pruned_stream_id
     ADD CONSTRAINT device_lists_changes_in_room_max_pruned_stream_id_lock_key UNIQUE (lock);
 ALTER TABLE ONLY device_lists_remote_pending
     ADD CONSTRAINT device_lists_remote_pending_pkey PRIMARY KEY (stream_id);
 ALTER TABLE ONLY devices
     ADD CONSTRAINT device_uniqueness UNIQUE (user_id, device_id);
@@ -1644,20 +1651,21 @@
 CREATE INDEX current_state_delta_stream_room_idx ON current_state_delta_stream USING btree (room_id, stream_id);
 CREATE INDEX current_state_events_member_index ON current_state_events USING btree (state_key) WHERE (type = 'm.room.member'::text);
 CREATE INDEX current_state_events_members_room_index ON current_state_events USING btree (room_id, membership) WHERE (type = 'm.room.member'::text);
 CREATE INDEX current_state_events_stream_ordering_idx ON current_state_events USING btree (event_stream_ordering);
 CREATE UNIQUE INDEX delayed_events_idx ON delayed_events USING btree (delay_id);
 CREATE INDEX delayed_events_is_processed ON delayed_events USING btree (is_processed);
 CREATE INDEX delayed_events_room_state_event_idx ON delayed_events USING btree (room_id, event_type, state_key) WHERE (state_key IS NOT NULL);
 CREATE INDEX delayed_events_send_ts ON delayed_events USING btree (send_ts);
 CREATE INDEX deleted_pushers_stream_id ON deleted_pushers USING btree (stream_id);
 CREATE INDEX destination_rooms_room_id ON destination_rooms USING btree (room_id);
+CREATE INDEX destination_rooms_sticky_events_backlog_room_id ON destination_rooms_sticky_events_backlog USING btree (room_id);
 CREATE INDEX device_auth_providers_devices ON device_auth_providers USING btree (user_id, device_id);
 CREATE INDEX device_auth_providers_sessions ON device_auth_providers USING btree (auth_provider_id, auth_provider_session_id);
 CREATE INDEX device_federation_inbox_received_ts_index ON device_federation_inbox USING btree (received_ts);
 CREATE INDEX device_federation_inbox_sender_id ON device_federation_inbox USING btree (origin, message_id);
 CREATE INDEX device_federation_outbox_destination_id ON device_federation_outbox USING btree (destination, stream_id);
 CREATE INDEX device_federation_outbox_id ON device_federation_outbox USING btree (stream_id);
 CREATE INDEX device_inbox_stream_id_user_id ON device_inbox USING btree (stream_id, user_id);
 CREATE INDEX device_inbox_user_stream_id ON device_inbox USING btree (user_id, device_id, stream_id);
 CREATE INDEX device_lists_changes_in_room_by_room_idx ON device_lists_changes_in_room USING btree (room_id, stream_id);
 CREATE INDEX device_lists_changes_in_room_inserted_ts_idx ON device_lists_changes_in_room USING btree (inserted_ts) WHERE (inserted_ts IS NOT NULL);
@@ -1850,20 +1858,22 @@
 CREATE TRIGGER check_event_stream_ordering BEFORE INSERT OR UPDATE ON room_memberships FOR EACH ROW EXECUTE FUNCTION check_event_stream_ordering();
 CREATE TRIGGER check_partial_state_events BEFORE INSERT OR UPDATE ON partial_state_events FOR EACH ROW EXECUTE FUNCTION check_partial_state_events();
 CREATE TRIGGER delete_read_write_lock_parent_trigger AFTER DELETE ON worker_read_write_locks FOR EACH ROW EXECUTE FUNCTION delete_read_write_lock_parent();
 CREATE TRIGGER upsert_read_write_lock_parent_trigger BEFORE INSERT ON worker_read_write_locks FOR EACH ROW EXECUTE FUNCTION upsert_read_write_lock_parent();
 ALTER TABLE ONLY access_tokens
     ADD CONSTRAINT access_tokens_refresh_token_id_fkey FOREIGN KEY (refresh_token_id) REFERENCES refresh_tokens(id) ON DELETE CASCADE;
 ALTER TABLE ONLY destination_rooms
     ADD CONSTRAINT destination_rooms_destination_fkey FOREIGN KEY (destination) REFERENCES destinations(destination);
 ALTER TABLE ONLY destination_rooms
     ADD CONSTRAINT destination_rooms_room_id_fkey FOREIGN KEY (room_id) REFERENCES rooms(room_id);
+ALTER TABLE ONLY destination_rooms_sticky_events_backlog
+    ADD CONSTRAINT destination_rooms_sticky_events_backlog_room_id_fkey FOREIGN KEY (room_id) REFERENCES rooms(room_id) ON DELETE CASCADE;
 ALTER TABLE ONLY event_edges
     ADD CONSTRAINT event_edges_event_id_fkey FOREIGN KEY (event_id) REFERENCES events(event_id);
 ALTER TABLE ONLY event_failed_pull_attempts
     ADD CONSTRAINT event_failed_pull_attempts_room_id_fkey FOREIGN KEY (room_id) REFERENCES rooms(room_id);
 ALTER TABLE ONLY event_forward_extremities
     ADD CONSTRAINT event_forward_extremities_event_id FOREIGN KEY (event_id) REFERENCES events(event_id) DEFERRABLE INITIALLY DEFERRED;
 ALTER TABLE ONLY current_state_events
     ADD CONSTRAINT event_stream_ordering_fkey FOREIGN KEY (event_stream_ordering) REFERENCES events(stream_ordering) NOT VALID;
 ALTER TABLE ONLY local_current_membership
     ADD CONSTRAINT event_stream_ordering_fkey FOREIGN KEY (event_stream_ordering) REFERENCES events(stream_ordering) NOT VALID;

state (sqlite)

Unchanged

state (postgres)

Unchanged

@reivilibre
reivilibre force-pushed the rei/sticky_events_fed_backlog branch from ff65c5e to 79d0686 Compare September 1, 2026 10:40
reivilibre added a commit that referenced this pull request Sep 15, 2026
…eparation and completion. (#20166)

A key refactoring for, and split out of,
#20165

Would be easier to land first to isolate the diff.

Should be a standalone change with no behavioural change.

Motivation is that #20165 will round-robin between 'main queue'
transactions and 'sticky event' transactions.
To keep the data flow clear, I wanted to insert a typed struct (well,
`attrs` dataclass) as an interface between the 'preparation' of a
transaction and its 'completion'.
Doing this whilst keeping the asynchronous context manager style did not
lead to a readable result in my opinion.
(I would also say the async context manager is a touch 'magic' /
obscures control flow, but I suspect this is largely down to opinion.)

Replace _TransactionQueueManager with prepare/complete transaction
methods

---------

Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
@reivilibre
reivilibre force-pushed the rei/sticky_events_fed_backlog branch 3 times, most recently from 978f82b to a6b625f Compare September 15, 2026 16:53
@reivilibre
reivilibre force-pushed the rei/sticky_events_fed_backlog branch from a6b625f to 31e987f Compare September 15, 2026 16:55
reivilibre and others added 2 commits September 15, 2026 17:18
Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
@reivilibre
reivilibre force-pushed the rei/sticky_events_fed_backlog branch from 31e987f to 8d3fa18 Compare September 15, 2026 17:18
@reivilibre
reivilibre marked this pull request as ready for review September 16, 2026 09:24
@reivilibre
reivilibre requested a review from a team as a code owner September 16, 2026 09:24
@reivilibre
reivilibre requested review from MadLittleMods and removed request for a team September 16, 2026 09:24
HarisDotParis pushed a commit to HarisDotParis/synapse that referenced this pull request Sep 16, 2026
…eparation and completion. (element-hq#20166)

A key refactoring for, and split out of,
element-hq#20165

Would be easier to land first to isolate the diff.

Should be a standalone change with no behavioural change.

Motivation is that element-hq#20165 will round-robin between 'main queue'
transactions and 'sticky event' transactions.
To keep the data flow clear, I wanted to insert a typed struct (well,
`attrs` dataclass) as an interface between the 'preparation' of a
transaction and its 'completion'.
Doing this whilst keeping the asynchronous context manager style did not
lead to a readable result in my opinion.
(I would also say the async context manager is a touch 'magic' /
obscures control flow, but I suspect this is largely down to opinion.)

Replace _TransactionQueueManager with prepare/complete transaction
methods

---------

Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
Comment thread synapse/storage/database.py
Comment thread synapse/storage/database.py
Comment thread synapse/federation/sender/per_destination_queue.py Outdated
Comment thread synapse/federation/sender/per_destination_queue.py Outdated
Comment thread synapse/federation/sender/per_destination_queue.py Outdated
Comment thread tests/federation/test_federation_catch_up.py
Comment thread tests/federation/test_federation_catch_up.py Outdated
Comment thread tests/federation/test_federation_catch_up.py Outdated
Comment thread tests/storage/test_sticky_events.py Outdated
Comment thread tests/storage/test_sticky_events.py Outdated
Comment thread changelog.d/20165.feature
Comment thread synapse/federation/sender/per_destination_queue.py Outdated
Comment thread synapse/federation/sender/per_destination_queue.py Outdated
Comment thread synapse/federation/sender/per_destination_queue.py Outdated
Comment thread synapse/federation/sender/per_destination_queue.py
Comment thread tests/federation/test_federation_catch_up.py Outdated
Comment thread synapse/federation/sender/per_destination_queue.py Outdated
Comment thread synapse/storage/databases/main/sticky_events.py Outdated
Comment thread synapse/storage/databases/main/sticky_events.py Outdated
Comment thread synapse/storage/databases/main/sticky_events.py
Comment thread synapse/storage/databases/main/sticky_events.py
Comment thread synapse/storage/databases/main/sticky_events.py
Comment thread changelog.d/20165.feature
Comment thread synapse/federation/sender/per_destination_queue.py Outdated
Comment thread synapse/storage/databases/main/sticky_events.py
@reivilibre
reivilibre merged commit 9f0168a into develop Oct 5, 2026
48 checks passed
@reivilibre
reivilibre deleted the rei/sticky_events_fed_backlog branch October 5, 2026 09:52
reivilibre added a commit that referenced this pull request Oct 15, 2026
…are newly joining the room. (#20337)

Part of: MSC4354 (Experimental Feature tracking issue #19409)

Follows: #20165 (Sticky Event federation backlog + catch-up
transactions)


The MSC says that we need to tell a newly-joining server about all of
our sticky events (yes, even the ones before the server joined the
room).
This PR introduces the mechanism, building on the 'Sticky Event backlog'
mechanism introduced in #20165, to make that happen.

Most of the logic is taken from the original Sticky Events PR #18968,
but the Sticky Event backlog system did not exist back then.
(The benefit of having the persistent Sticky Event backlog is that we
don't need to rely on the federation senders not crashing to make sure
we get around to sending the Sticky Events out.)

- Add NEW_SERVER_JOINED replication command. (This is a cross-worker
'wake-up call' to check for a sticky event backlog)
We send it when persisting a state change that causes a new server to be
in the room.
We handle it by waking up federation senders for the destination,
including checking the sticky event backlog tables.

- When a server newly joins a room, we insert a sticky event backlog row
for that server, if there are any Sticky Events needing to be sent.

---------

Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants