Make MSC4102 "prefer unthreaded receipt" durable at insert time#19838
Make MSC4102 "prefer unthreaded receipt" durable at insert time#19838erikjohnston wants to merge 3 commits into
Conversation
73621b4 to
a7434ba
Compare
There was a problem hiding this comment.
Pull request overview
This PR makes MSC4102’s “prefer unthreaded read receipt” behavior durable by enforcing the preference at receipt insert time, preventing conflicting threaded receipts from being persisted (and later resurfacing across split /sync windows).
Changes:
- Update receipt insertion logic to drop threaded receipts when an unthreaded receipt for the same user/room/type already supersedes it.
- Add a regression test covering the threaded-vs-unthreaded insert-time behavior.
- Add a Towncrier bugfix newsfragment describing the user-visible fix.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
synapse/storage/databases/main/receipts.py |
Enforce MSC4102 preference at insert time by dropping semantically-redundant threaded receipts. |
tests/storage/test_receipts.py |
Add regression coverage to ensure conflicting threaded receipts are dropped when an unthreaded receipt exists. |
changelog.d/19838.bugfix |
Add release note for the MSC4102 /sync receipt ordering bugfix. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Previously the "unthreaded receipt always wins over a clashing threaded one" behaviour was only applied at read time, in `ReceiptInRoom.merge_to_content`, which dedupes the pair within a single /sync response. When the two receipts end up at different stream positions and are served in separate /sync responses (e.g. when they arrive over federation as separate EDUs), the threaded receipt could be served on its own and incorrectly win, which is what caused the `TestThreadReceiptsInSyncMSC4102` Complement flake. Drop a threaded receipt at insert time if an unthreaded receipt for the same user already exists at the same or a later event, so it never gets persisted, streamed or federated. This is safe for notification counts, since the unthreaded receipt already acts as a floor across all threads. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
a7434ba to
8750a01
Compare
| ) | ||
| self.assertEqual(res, {self.room_id1: event1_2_id, self.room_id2: event2_1_id}) | ||
|
|
||
| def test_threaded_receipt_dropped_when_unthreaded_exists(self) -> None: |
There was a problem hiding this comment.
Seems like the TestThreadReceiptsInSyncMSC4102 Complement test should also be updated to better separate read receipts across EDU's so it can consistently stress the failure case. Or could be an additional new test that does that (engineered homeserver)
| # Doing this at insert time, as well as when serving receipts, | ||
| # makes the "prefer unthreaded" behaviour durable: otherwise the | ||
| # threaded receipt could be served on its own in a later /sync | ||
| # response (e.g. when the unthreaded and threaded receipts | ||
| # arrive in separate federation EDUs and so end up at different | ||
| # stream positions), causing the client to incorrectly see it | ||
| # win. |
There was a problem hiding this comment.
Can we drop the /sync behavior in favor the insert solution we have here?
Perhaps instead an assert at the /sync level.
There was a problem hiding this comment.
Mmm, possibly. We wouldn't want to do it immediately to handle existing receipts in the DB.
| @@ -0,0 +1 @@ | |||
| Fix a bug where a threaded read receipt could incorrectly win over a clashing unthreaded one (MSC4102) when the two were served in separate `/sync` responses, e.g. when received over federation as separate EDUs. | |||
There was a problem hiding this comment.
How did you reproduce the TestThreadReceiptsInSyncMSC4102 failure locally? Or did the LLM just spot what was wrong based on the test flake output from CI?
I struggled: #19171
There was a problem hiding this comment.
This was a case of throwing LLM at the logs of a flakey run. What it spit out made sense and seemed to match what was happening, and the suggested fix (this) made sense in the context of what we were doing when reading as well.
| @@ -0,0 +1 @@ | |||
| Fix a bug where a threaded read receipt could incorrectly win over a clashing unthreaded one (MSC4102) when the two were served in separate `/sync` responses, e.g. when received over federation as separate EDUs. | |||
There was a problem hiding this comment.
Per my points in matrix-org/complement#881, I think this may be missing the mark on the behavior of MSC4102
There was a problem hiding this comment.
How so? We want to make sure that when combining receipts for the same event ID we want the unthreaded to win? MSC4102 does only talk about EDUs, but I think the idea is still that you want unthreaded to win everywhere?
There was a problem hiding this comment.
but I think the idea is still that you want unthreaded to win everywhere?
I don't think there is any spec for this.
We want to record all of the read receipts and only deduplicate if there are conflicting read receipts when creating m.receipt EDU's. We seem to already be doing this.
And to better clarify and exaggerate why this is fine, it's okay to send and receive a threaded read receipt for the same event two hours after the unthreaded read receipt. See MSC3771 which explains,
This MSC proposes allowing the same receipt type to exist multiple times in a room per user:
- Once for the unthreaded timeline.
- Once for the main timeline in the room.
- Once per threaded timeline.
And the only restriction is that "this still does not allow a caller to move their receipts backwards in a room"
If this breakdown is to be believed, the bug appears to be in the following behavior especially "The unthreaded receipt was never surfaced" part. Why is /sync advancing past its persisted position?
- Alice sends an unthreaded receipt for
event B, then a threaded receipt for the sameevent B. Both are federated to hs2 as two separatem.receiptEDUs and persisted there at receipts stream positions 2 (unthreaded) and 3
(threaded).- Bob's initial sync advanced its receipt token to 2 but emitted no receipt; his next (incremental) sync window was
(2,3], containing only the threaded receipt.- The unthreaded receipt was never surfaced, so the threaded one "won" → MSC4102 violation → timeout.
Fix #19171
Part of #18537
Problem
TestThreadReceiptsInSyncMSC4102is a persistent Complement flake (workers + federation). Example failing run.MSC4102 requires that an unthreaded read receipt always wins over a clashing threaded one (same user, same event). Today that's enforced only at read time, in
ReceiptInRoom.merge_to_content, which dedupes a clashing pair withina single
/syncresponse.That breaks down when the two receipts are persisted at different stream positions and get served in separate
/syncresponses. Tracing the failing run:event B, then a threaded receipt for the sameevent B. Both are federated to hs2 as two separatem.receiptEDUs and persisted there at receipts stream positions 2 (unthreaded) and 3(threaded).
(2,3], containing only the threaded receipt.Fix
Make "unthreaded wins" durable at insert time. In
_insert_linearized_receipt_txn, drop a threaded receipt if an unthreaded receipt for the same(room, type, user)already exists for the same event. It is then neverpersisted, streamed, or federated, so no sync-window split can resurface it — and on hs1 the clashing threaded receipt is never sent over federation in the first place.
Key design points:
ReceiptInRoom.merge_to_content(which clashes purely on(user_id, event_id), never on ordering). This keeps the two layers consistent and order-independent.event_stream_ordering, it also covers a remote unthreaded receipt whose event we hadn't seen when it arrived (NULLevent_stream_ordering, which is never backfilled outside the one-timebackground update). (Thanks to the Copilot review for catching this case.)
This is safe for notification counts:
EventPushSummary.is_unreadalready treats the unthreaded receipt as a floor across all threads, so a threaded receipt at the same event is redundant for counts.