Skip to content

Commit 6100f6e

Browse files
Backfill from nearby points past pagination token (#19611)
The juicy details and explanation are in the diff itself. Split out from #18873 in order to fix paginating from [MSC3871](matrix-org/matrix-spec-proposals#3871) gap tokens actually backfilling history. To be clear, this is a good change to make outside of the [MSC3871](matrix-org/matrix-spec-proposals#3871) use case. For example (as the new Complement test shows), fixes a problem where if you try to paginate `/messages` from tokens returned by `/context`, we could fail to backfill anything new and hide away history. Also fixes matrix-org/complement#853
1 parent 697ef33 commit 6100f6e

5 files changed

Lines changed: 476 additions & 145 deletions

File tree

changelog.d/19611.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix Synapse not backfilling new history when attempting to use a pagination token near a backward extremity.
Lines changed: 394 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,394 @@
1+
// This file is licensed under the Affero General Public License (AGPL) version 3.
2+
//
3+
// Copyright (C) 2026 Element Creations Ltd
4+
//
5+
// This program is free software: you can redistribute it and/or modify
6+
// it under the terms of the GNU Affero General Public License as
7+
// published by the Free Software Foundation, either version 3 of the
8+
// License, or (at your option) any later version.
9+
//
10+
// See the GNU Affero General Public License for more details:
11+
// <https://www.gnu.org/licenses/agpl-3.0.html>.
12+
13+
package synapse_tests
14+
15+
import (
16+
"encoding/json"
17+
"fmt"
18+
"net/url"
19+
"slices"
20+
"strings"
21+
"testing"
22+
23+
"github.com/matrix-org/complement"
24+
"github.com/matrix-org/complement/b"
25+
"github.com/matrix-org/complement/client"
26+
"github.com/matrix-org/complement/helpers"
27+
"github.com/matrix-org/gomatrixserverlib/spec"
28+
"github.com/tidwall/gjson"
29+
)
30+
31+
func TestMessagesOverFederation(t *testing.T) {
32+
deployment := complement.Deploy(t, 2)
33+
defer deployment.Destroy(t)
34+
35+
alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{
36+
LocalpartSuffix: "alice",
37+
})
38+
bob := deployment.Register(t, "hs2", helpers.RegistrationOpts{
39+
LocalpartSuffix: "bob",
40+
})
41+
42+
// The typical convention to find backfill points is from the backward extremities in
43+
// the DAG. Backward extremities are the oldest events we know of in the room but we
44+
// only know of them because some other event referenced them by prev_event and aren't
45+
// known to the homeserver yet (meaning we don't know their depth specifically). So we
46+
// can only do approximate depth comparisons (use the depth of the known events
47+
// they're connected to). And we don't know if those backward extremities point to a
48+
// long chain/fork of history that could stretch back far enough to be visible.
49+
//
50+
// This means a naive homeserver implementation that looks for backward extremities <=
51+
// depth of the `/messages?dir=b&from=xxx` token may overlook a backfill point that could
52+
// reveal more history in the window the user is currently paginating in.
53+
//
54+
// This could be a near miss as this test is specifically stressing or a more deep miss
55+
// as the backward extremity could reveal an entire fork of history that stretches
56+
// back far enough to be visible.
57+
//
58+
// In Synapse, we consider "nearby" as anything within range of the `limit` specified
59+
// in `/messages?dir=b&from=xxx&limit=xxx`.
60+
//
61+
// This test lives in our in-repo Complement tests for Synapse because the Matrix spec
62+
// doesn't have any rules for how a homeserver should backfill. Practically speaking,
63+
// homeservers that don't do anything for this problem will just hide messages from
64+
// clients. This underscores the fact why it's necessary for homeservers to indicate that
65+
// there is a gap (using MSC3871) at the very least.
66+
//
67+
// --------------------------------------------------
68+
//
69+
// Even with MSC3871 gaps, the tested behavior here is necessary as the gap prev/next
70+
// tokens point before/after the event (remember: tokens are positions between
71+
// events), so if you use `/messages?dir=b&from=<gap prev_pagination_token>`, we can't
72+
// rely on naive depth comparison. MSC3871 Complement tests will also exercise this.
73+
// Example:
74+
//
75+
// t0 t1 t2 t3 t4
76+
// [A] <--- [B] <--- [C] <--- [bob join 4]
77+
//
78+
// When Bob calls `/messages?dir=b&backfill=false`, he sees a gap (`{ event_id: "bob
79+
// join 4", prev_pagination_token: "t3", next_pagination_token: "t4" }`) and tries to
80+
// fill it in with `/messages?dir=b&from=t3&limit=10&backfill=true`. To find backfill
81+
// points, Synapse will compare `t3` with the backward extremity at an approximate
82+
// depth of 4. Which is why we take `t3`, add the `limit=10` and then do the
83+
// comparison (find any backfill points with an approximate depth <= 13).
84+
t.Run("Backfill from nearby backward extremities past token", func(t *testing.T) {
85+
// Alice creates the room
86+
roomID := alice.MustCreateRoom(t, map[string]interface{}{
87+
// The `public_chat` preset includes `history_visibility: "shared"` ("Previous
88+
// events are always accessible to newly joined members. All events in the
89+
// room are accessible, even those sent when the member was not a part of the
90+
// room."), which is what we want to test.
91+
"preset": "public_chat",
92+
})
93+
94+
// Keep track of the order
95+
eventIDs := make([]string, 0)
96+
// Map from event_id to event info
97+
eventMap := make(map[string]EventInfo)
98+
99+
// Send some message history into the room
100+
numberOfMessagesToSend := 3
101+
messageDrafts := make([]MessageDraft, 0, numberOfMessagesToSend)
102+
for i := 0; i < numberOfMessagesToSend; i++ {
103+
messageDrafts = append(
104+
messageDrafts,
105+
MessageDraft{alice, fmt.Sprintf("message history %d", i+1)},
106+
)
107+
}
108+
sendAndTrackMessages(t, roomID, messageDrafts, &eventIDs, &eventMap)
109+
110+
// Bob joins the room
111+
bob.MustJoinRoom(t, roomID, []spec.ServerName{
112+
deployment.GetFullyQualifiedHomeserverName(t, "hs1"),
113+
})
114+
bobJoinEventID := getStateID(t, bob, roomID, "m.room.member", bob.UserID)
115+
116+
// Make it easy to cross-reference the events being talked about in the logs
117+
for eventIndex, eventID := range eventIDs {
118+
t.Logf("Message %d -> event_id=%s", eventIndex, eventID)
119+
}
120+
121+
// Use a `/context` request to get a pagination token just before Bob's join event
122+
// (remember: tokens are positions between events)
123+
//
124+
// Usually a client would just use `/messages?dir=b` to start getting history
125+
// after joining but this is valid as well. To illustrate a more real example of
126+
// this, someone can use `/timestamp_to_event` to jump back in history and
127+
// `/context` to start paginating history.
128+
contextRes := bob.MustDo(
129+
t,
130+
"GET",
131+
[]string{"_matrix", "client", "v3", "rooms", roomID, "context", bobJoinEventID},
132+
client.WithContentType("application/json"),
133+
client.WithQueries(url.Values{
134+
"limit": []string{"0"},
135+
}),
136+
)
137+
contextResResBody := client.ParseJSON(t, contextRes)
138+
// > `start`: A token that can be used to paginate backwards with.
139+
// > - https://spec.matrix.org/v1.17/client-server-api/#get_matrixclientv3roomsroomidcontexteventid
140+
paginationToken := client.GetJSONFieldStr(t, contextResResBody, "start")
141+
142+
// Paginate backwards from the join event
143+
messagesRes := bob.MustDo(
144+
t,
145+
"GET",
146+
[]string{"_matrix", "client", "v3", "rooms", roomID, "messages"},
147+
client.WithContentType("application/json"),
148+
client.WithQueries(url.Values{
149+
"dir": []string{"b"},
150+
"limit": []string{"100"},
151+
"from": []string{paginationToken},
152+
}),
153+
)
154+
messagesResBody := client.ParseJSON(t, messagesRes)
155+
156+
// Since `dir=b`, these will be in reverse chronological order
157+
actualEventIDsFromRequest := extractEventIDsFromMessagesResponse(t, messagesResBody)
158+
159+
// Put them in chronological order to match the expected list
160+
chronologicalActualEventIds := slices.Clone(actualEventIDsFromRequest)
161+
slices.Reverse(chronologicalActualEventIds)
162+
163+
// Assert timeline order
164+
assertEventsInOrder(t, chronologicalActualEventIds, eventIDs)
165+
})
166+
167+
// TODO: Backfill test to make sure we backfill from forks when viewing history (see
168+
// docstring above).
169+
//
170+
// 1. Alice (hs1, engineered homeserver) creates a room with events A, B
171+
// 1. Bob (hs2) joins the room
172+
// 1. Bob leaves the room
173+
// 1. Alice creates a fork from A with some history (1, 2, 3) and connects it back with a new event C
174+
// 1. Bob joins back
175+
// 1. Bob paginates `/messages?dir=b&from=<token-after-b>`
176+
// 1. Ensure Bob sees events: B, 2, 1, A
177+
//
178+
// 1 <--- 2 <----- 3
179+
// / \
180+
// A <------- B ▲ <--- C <-- D
181+
// |
182+
// Paginate backwards from this point
183+
// t.Run("Backfill from nearby backward extremities past token (fork)", func(t *testing.T) {
184+
}
185+
186+
// These utilities match what we're using in the Complement repo (see
187+
// `matrix-org/complement` -> `tests/csapi/room_messages_test.go`)
188+
189+
type MessageDraft struct {
190+
Sender *client.CSAPI
191+
Message string
192+
}
193+
194+
type EventInfo struct {
195+
MessageDraft MessageDraft
196+
EventID string
197+
}
198+
199+
func sendMessageDrafts(
200+
t *testing.T,
201+
roomID string,
202+
messageDrafts []MessageDraft,
203+
) []string {
204+
t.Helper()
205+
206+
eventIDs := make([]string, len(messageDrafts))
207+
for messageDraftIndex, messageDraft := range messageDrafts {
208+
eventID := messageDraft.Sender.SendEventSynced(t, roomID, b.Event{
209+
Type: "m.room.message",
210+
Content: map[string]interface{}{
211+
"msgtype": "m.text",
212+
"body": messageDraft.Message,
213+
},
214+
})
215+
eventIDs[messageDraftIndex] = eventID
216+
}
217+
218+
return eventIDs
219+
}
220+
221+
// sendAndTrackMessages sends the given message drafts to the room, keeping track of the
222+
// new events in the list of `eventIDs` and `eventMap`. Returns the list of new event
223+
// IDs that were sent.
224+
func sendAndTrackMessages(
225+
t *testing.T,
226+
roomID string,
227+
messageDrafts []MessageDraft,
228+
eventIDs *[]string,
229+
eventMap *map[string]EventInfo,
230+
) []string {
231+
t.Helper()
232+
233+
newEventIDs := sendMessageDrafts(t, roomID, messageDrafts)
234+
235+
*eventIDs = append(*eventIDs, newEventIDs...)
236+
for i, eventID := range newEventIDs {
237+
(*eventMap)[eventID] = EventInfo{
238+
MessageDraft: messageDrafts[i],
239+
EventID: eventID,
240+
}
241+
}
242+
243+
return newEventIDs
244+
}
245+
246+
// extractEventIDsFromMessagesResponse extracts the event IDs from the given
247+
// `/messages` response body.
248+
func extractEventIDsFromMessagesResponse(
249+
t *testing.T,
250+
messagesResBody json.RawMessage,
251+
) []string {
252+
t.Helper()
253+
254+
wantKey := "chunk"
255+
keyRes := gjson.GetBytes(messagesResBody, wantKey)
256+
if !keyRes.Exists() {
257+
t.Fatalf("extractEventIDsFromMessagesResponse: missing key '%s'", wantKey)
258+
}
259+
if !keyRes.IsArray() {
260+
t.Fatalf(
261+
"extractEventIDsFromMessagesResponse: key '%s' is not an array (was %s)",
262+
wantKey,
263+
keyRes.Type,
264+
)
265+
}
266+
267+
var eventIDs []string
268+
actualEvents := keyRes.Array()
269+
for _, event := range actualEvents {
270+
eventIDs = append(eventIDs, event.Get("event_id").Str)
271+
}
272+
273+
return eventIDs
274+
}
275+
276+
func filterEventIDs(t *testing.T, actualEventIDs []string, expectedEventIDs []string) []string {
277+
t.Helper()
278+
279+
relevantActualEventIDs := make([]string, 0, len(expectedEventIDs))
280+
for _, eventID := range actualEventIDs {
281+
if slices.Contains(expectedEventIDs, eventID) {
282+
relevantActualEventIDs = append(relevantActualEventIDs, eventID)
283+
}
284+
}
285+
286+
return relevantActualEventIDs
287+
}
288+
289+
// assertEventsInOrder asserts all `actualEventIDs` are present and in order according
290+
// to `expectedEventIDs`. Other unrelated events can be in between.
291+
func assertEventsInOrder(t *testing.T, actualEventIDs []string, expectedEventIDs []string) {
292+
t.Helper()
293+
294+
relevantActualEventIDs := filterEventIDs(t, actualEventIDs, expectedEventIDs)
295+
296+
if len(relevantActualEventIDs) != len(expectedEventIDs) {
297+
t.Fatalf(
298+
"expected %d events in timeline (got %d relevant events filtered down from %d events)\n%s",
299+
len(expectedEventIDs),
300+
len(relevantActualEventIDs),
301+
len(actualEventIDs),
302+
generateEventOrderDiffString(relevantActualEventIDs, expectedEventIDs),
303+
)
304+
}
305+
306+
for i, eventID := range relevantActualEventIDs {
307+
if eventID != expectedEventIDs[i] {
308+
t.Fatalf(
309+
"expected event ID %s (got %s) at index %d\n%s",
310+
expectedEventIDs[i],
311+
eventID,
312+
i,
313+
generateEventOrderDiffString(relevantActualEventIDs, expectedEventIDs),
314+
)
315+
}
316+
}
317+
}
318+
319+
func generateEventOrderDiffString(actualEventIDs []string, expectedEventIDs []string) string {
320+
expectedLines := make([]string, len(expectedEventIDs))
321+
for i, expectedEventID := range expectedEventIDs {
322+
isExpectedInActual := slices.Contains(actualEventIDs, expectedEventID)
323+
isMissingIndicatorString := " "
324+
if !isExpectedInActual {
325+
isMissingIndicatorString = "?"
326+
}
327+
328+
expectedLines[i] = fmt.Sprintf("%2d: %s %s", i, isMissingIndicatorString, expectedEventID)
329+
}
330+
expectedDiffString := strings.Join(expectedLines, "\n")
331+
332+
actualLines := make([]string, len(actualEventIDs))
333+
for actualEventIndex, actualEventID := range actualEventIDs {
334+
isActualInExpected := slices.Contains(expectedEventIDs, actualEventID)
335+
isActualInExpectedIndicatorString := " "
336+
if isActualInExpected {
337+
isActualInExpectedIndicatorString = "+"
338+
}
339+
340+
expectedIndex := slices.Index(expectedEventIDs, actualEventID)
341+
expectedIndexString := ""
342+
if actualEventIndex != expectedIndex {
343+
expectedDirectionString := "⬆️"
344+
if expectedIndex > actualEventIndex {
345+
expectedDirectionString = "⬇️"
346+
}
347+
348+
expectedIndexString = fmt.Sprintf(
349+
" (expected index %d %s)",
350+
expectedIndex,
351+
expectedDirectionString,
352+
)
353+
}
354+
355+
actualLines[actualEventIndex] = fmt.Sprintf("%2d: %s %s%s",
356+
actualEventIndex, isActualInExpectedIndicatorString, actualEventID, expectedIndexString,
357+
)
358+
}
359+
actualDiffString := strings.Join(actualLines, "\n")
360+
361+
return fmt.Sprintf(
362+
"Actual events ('+' = found expected items):\n%s\nExpected events ('?' = missing expected items):\n%s",
363+
actualDiffString,
364+
expectedDiffString,
365+
)
366+
}
367+
368+
func getStateID(
369+
t *testing.T,
370+
c *client.CSAPI,
371+
roomID string,
372+
stateType string,
373+
stateKey string,
374+
) string {
375+
t.Helper()
376+
377+
stateRes := c.MustDo(t, "GET", []string{"_matrix", "client", "v3", "rooms", roomID, "state"})
378+
stateResBody := client.ParseJSON(t, stateRes)
379+
eventJSON := gjson.ParseBytes(stateResBody)
380+
if !eventJSON.IsArray() {
381+
t.Fatalf("expected array of state events but found %s", eventJSON.Type)
382+
}
383+
384+
events := eventJSON.Array()
385+
386+
for _, event := range events {
387+
if event.Get("type").Str == stateType && event.Get("state_key").Str == stateKey {
388+
return event.Get("event_id").Str
389+
}
390+
}
391+
392+
t.Fatalf("Unable to find state event for (%s, %s). Room state: %s", stateType, stateKey, events)
393+
return ""
394+
}

0 commit comments

Comments
 (0)