Skip to content

Make TestThreadReceiptsInSyncMSC4102 deterministic#881

Open
erikjohnston wants to merge 1 commit into
mainfrom
erikj/fix_receipts_fed
Open

Make TestThreadReceiptsInSyncMSC4102 deterministic#881
erikjohnston wants to merge 1 commit into
mainfrom
erikj/fix_receipts_fed

Conversation

@erikjohnston

Copy link
Copy Markdown
Member

Companion to element-hq/synapse#19838

The test only caught the MSC4102 "prefer unthreaded receipt" bug when the clashing unthreaded and threaded receipts happened to be served in the same /sync response (where read-time dedup hides the threaded one), so it flaked.

Force the receipts into separate sync windows by waiting for each user to observe the unthreaded receipt before sending the clashing threaded one, then assert the threaded receipt never wins, both locally and over federation.

The test only caught the MSC4102 "prefer unthreaded receipt" bug when
the clashing unthreaded and threaded receipts happened to be served in
the same /sync response (where read-time dedup hides the threaded one),
so it flaked.

Force the receipts into separate sync windows by waiting for each user to
observe the unthreaded receipt before sending the clashing threaded one,
then assert the threaded receipt never wins, both locally and over
federation.
Comment on lines +326 to +332
// Servers should always prefer the unthreaded receipt when there is a clash of
// receipts for the same event, and that preference must be *durable*: it must
// hold even when the unthreaded and threaded receipts are served in separate
// `/sync` responses. This happens when the two land at different stream
// positions, e.g. when they arrive over federation as separate EDUs. A
// non-durable server that only dedupes at read-time will serve the threaded
// receipt on its own in a later `/sync`, letting it incorrectly win.

@MadLittleMods MadLittleMods Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It's unclear to me that this is true for all cases.

As far as I can tell, for example, we could also tackle this problem at read-time. In Synapse, get_linearized_receipts_for_room(...)/get_linearized_receipts_for_rooms(...), could de-duplicate if a previous unthreaded receipt existed. Even in Synapse, we store things as (room_id, receipt_type, user_id, thread_id) after all which accommodates holding onto both

Which also makes me question whether our approach in element-hq/synapse#19838 is the best (not sure)

(also plays into some of the comments which make some assumptions as well)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Is your point that actually you could correctly implement this at read time by checking for other receipts when pulling from the DB? Yes I suppose you could?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yes, in which case this comment assumes too much

Comment on lines +421 to +425
// We can't use a `syncHasThreadedReadReceipt` check for this: MustSyncUntil
// drops a check once it passes, whereas we need to keep scanning every
// response for the forbidden receipt right up until the marker arrives. So
// we accumulate a flag by hand and use the marker as the (positive) stop
// condition.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We can still use syncHasThreadedReadReceipt but composed into our own check

// In the unlikely event that you want all the checkers to pass *explicitly* in a single /sync
// response (e.g to assert some form of atomic update which updates multiple parts of the /sync
// response at once) then make your own checker function which does this.

// response for the forbidden receipt right up until the marker arrives. So
// we accumulate a flag by hand and use the marker as the (positive) stop
// condition.
marker := client.SyncTimelineHasEventID(roomID, markerEventID)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
marker := client.SyncTimelineHasEventID(roomID, markerEventID)
syncTimelineHasMarkerEventID := client.SyncTimelineHasEventID(roomID, markerEventID)

Although, I don't think it really matters to store and re-use this.

// which the threaded receipt must have been delivered if it was going to be. We
// assert it never is, both for the local user and for a remote user over
// federation.
func TestThreadReceiptsInSyncMSC4102(t *testing.T) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

MSC4102 only mentions that the de-duplication should happen when assembling EDU's and there is a unthreaded and threaded receipt in the same response.

It doesn't say anything about an unthreaded read receipt winning out over new threaded read receipts

Comment on lines -357 to -358
// now send an unthreaded RR for event B and a threaded RR for event B and ensure we see the unthreaded RR
// down /sync. Non-compliant servers will typically send the last one only.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think this test needs to stay trying to send an unthreaded and threaded read receipt in the same /send federation transaction.

But we can't control how a homeserver sends EDU's so I don't think this test can be made deterministic. We need to update it to accommodate the MSC4102 behavior or them being sent across two transactions.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think this test needs to stay trying to send an unthreaded and threaded read receipt in the same /send federation transaction.

Why?

But we can't control how a homeserver sends EDU's so I don't think this test can be made deterministic. We need to update it to accommodate the MSC4102 behavior or them being sent across two transactions.

Sorry, what are you actually suggesting we do?

@MadLittleMods MadLittleMods Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is the whole point of the MSC4102 and we want the test to stress this behavior,

When a server is combining receipts into an EDU, if there are multiple receipts for the same (user, event, receipt type), always choose the receipt which is unthreaded (has no thread_id) when aggregating into an EDU.

This change will apply to all m.receipt EDUs, which includes both CSAPI and Federation endpoints.

-- MSC4102

But we can't control how a homeserver emits EDU's so the best we can do is send them quickly in succession and see what it does. Combining into one EDU is fine (per MSC4102) or separating is fine (depends on how slow the server is to process things and when the request comes in).

We need to update the test to accommodate both expectations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants