Skip to content

Commit 27577d4

Browse files
authored
mongodb: improving idle change stream resume (#4254)
This PR: - revert the previous 5 minute context timeout [change](https://github.com/PeerDB-io/peerdb/pull/4166/changes) (this was [causing an issue](#4213) where change stream that takes a long time to resume can stuck and retry loop). - setting `client.Watch` with batchSize = 0, allowing change stream to return immediately, and the "catch-up" can be blocked on `changestream.Next` instead of `client.Watch` - improve handle of idle stream by: - always checkpoint in-memory when context timed out with records accumulated - always persist offset to catalog when context timed out without records accumulated, so that if pipe is paused or an error occurred, we can resume from a recent offset - add unit test with a mock change stream to avoid having to slow down CI; this will also make it easier to extend unit tests for other mongodb cdc behavior Fixes: DBI-691
1 parent f090ccc commit 27577d4

3 files changed

Lines changed: 267 additions & 44 deletions

File tree

flow/connectors/mongo/cdc.go

Lines changed: 53 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,23 @@ type ChangeEvent struct {
3636
ClusterTime bson.Timestamp `bson:"clusterTime"`
3737
}
3838

39+
// ChangeStream is defined as an interface, allowing tests inject mock change stream.
40+
type ChangeStream interface {
41+
Next(ctx context.Context) bool
42+
ResumeToken() bson.Raw
43+
Err() error
44+
Close(ctx context.Context) error
45+
Current() bson.Raw
46+
}
47+
48+
type changeStreamWrapper struct {
49+
*mongo.ChangeStream
50+
}
51+
52+
func (w *changeStreamWrapper) Current() bson.Raw {
53+
return w.ChangeStream.Current
54+
}
55+
3956
func (c *MongoConnector) GetTableSchema(
4057
ctx context.Context,
4158
_ map[string]string,
@@ -87,8 +104,7 @@ func (c *MongoConnector) SetupReplication(ctx context.Context, input *protos.Set
87104
if err != nil {
88105
return model.SetupReplicationResult{}, fmt.Errorf("failed to create changestream pipeline: %w", err)
89106
}
90-
91-
changeStream, err := createChangeStream(c.client, ctx, pipeline, changeStreamOpts)
107+
changeStream, err := c.createChangeStream(ctx, pipeline, changeStreamOpts)
92108
if err != nil {
93109
return model.SetupReplicationResult{}, fmt.Errorf("failed to start change stream for storing initial resume token: %w", err)
94110
}
@@ -107,7 +123,7 @@ func (c *MongoConnector) SetupReplication(ctx context.Context, input *protos.Set
107123
}
108124
}
109125
}
110-
err = c.SetLastOffset(ctx, input.FlowJobName, model.CdcCheckpoint{
126+
err = c.metadataStore.SetLastOffset(ctx, input.FlowJobName, model.CdcCheckpoint{
111127
Text: base64.StdEncoding.EncodeToString(resumeToken),
112128
})
113129
if err != nil {
@@ -137,7 +153,12 @@ func (c *MongoConnector) PullRecords(
137153

138154
changeStreamOpts := options.ChangeStream().
139155
SetComment("PeerDB changeStream for mirror " + req.FlowJobName).
140-
SetFullDocument(options.UpdateLookup)
156+
SetFullDocument(options.UpdateLookup).
157+
// batchSize=0 only affects the initial aggregate response, so Watch returns
158+
// immediately without blocking on the initial cursor establishment. Subsequent
159+
// getMore calls fall back to the server default (up to 16 MiB per batch).
160+
// https://www.mongodb.com/docs/manual/reference/method/cursor.batchSize/
161+
SetBatchSize(0)
141162

142163
var resumeToken bson.Raw
143164
var err error
@@ -157,7 +178,7 @@ func (c *MongoConnector) PullRecords(
157178
return err
158179
}
159180

160-
changeStream, err := createChangeStream(c.client, ctx, pipeline, changeStreamOpts)
181+
changeStream, err := c.createChangeStream(ctx, pipeline, changeStreamOpts)
161182
if err != nil {
162183
if isResumeTokenNotFoundError(err) && resumeToken != nil {
163184
timestamp, err := decodeTimestampFromResumeToken(resumeToken)
@@ -166,7 +187,7 @@ func (c *MongoConnector) PullRecords(
166187
}
167188
changeStreamOpts.SetStartAtOperationTime(&timestamp)
168189
changeStreamOpts.SetResumeAfter(nil)
169-
changeStream, err = createChangeStream(c.client, ctx, pipeline, changeStreamOpts)
190+
changeStream, err = c.createChangeStream(ctx, pipeline, changeStreamOpts)
170191
if err != nil {
171192
return fmt.Errorf("failed to recreate change stream: %w", err)
172193
}
@@ -207,12 +228,23 @@ func (c *MongoConnector) PullRecords(
207228
otelManager.Metrics.AllFetchedBytesCounter.Add(ctx, read)
208229
}()
209230

210-
checkpoint := func() {
211-
if resumeToken := changeStream.ResumeToken(); resumeToken != nil {
212-
resumeTokenText := base64.StdEncoding.EncodeToString(resumeToken)
213-
req.RecordStream.UpdateLatestCheckpointText(resumeTokenText)
214-
} else {
231+
checkpoint := func() string {
232+
rt := changeStream.ResumeToken()
233+
if rt == nil {
215234
c.logger.Warn("change stream does not currently contain a resume token")
235+
return ""
236+
}
237+
text := base64.StdEncoding.EncodeToString(rt)
238+
req.RecordStream.UpdateLatestCheckpointText(text)
239+
return text
240+
}
241+
checkpointToCatalog := func() {
242+
text := checkpoint()
243+
if text == "" {
244+
return
245+
}
246+
if err := c.metadataStore.SetLastOffset(ctx, req.FlowJobName, model.CdcCheckpoint{Text: text}); err != nil {
247+
c.logger.Error("failed to persist resume token", slog.String("resumeToken", text), slog.Any("error", err))
216248
}
217249
}
218250

@@ -300,7 +332,7 @@ func (c *MongoConnector) PullRecords(
300332
changeStreamOpts.SetStartAtOperationTime(nil)
301333
}
302334

303-
changeStream, err = createChangeStream(c.client, ctx, pipeline, changeStreamOpts)
335+
changeStream, err = c.createChangeStream(ctx, pipeline, changeStreamOpts)
304336
if err != nil {
305337
return err
306338
}
@@ -317,11 +349,14 @@ func (c *MongoConnector) PullRecords(
317349

318350
if errors.Is(err, context.DeadlineExceeded) {
319351
if recordCount > 0 {
352+
// advance offset to the PostBatchResumeToken since the last change event's resume token may be quite old
353+
checkpoint()
320354
break
321355
}
322-
// update with PostBatchResumeToken on empty batch
323-
// ref: https://github.com/mongodb/specifications/blob/master/source/change-streams/change-streams.md
324-
checkpoint()
356+
// when no events arrived in this batch, still advance offset to the PostBatchResumeToken.
357+
// it's safe to persist to catalog since no records were handed off to the sync workflow,
358+
// so there's no in-flight data we could skip past by advancing the offset.
359+
checkpointToCatalog()
325360
// DeadlineExceeded errors are deemed not recoverable/resumable, so we have to create a new change stream instance
326361
if err := recreateChangeStream(false); err != nil {
327362
return fmt.Errorf("failed to recreate change stream: %w", err)
@@ -342,12 +377,13 @@ func (c *MongoConnector) PullRecords(
342377
return fmt.Errorf("change stream error: %w", err)
343378
}
344379

345-
changeEventSize := int64(len(changeStream.Current))
380+
current := changeStream.Current()
381+
changeEventSize := int64(len(current))
346382
deltaBytesProcessed.Add(changeEventSize)
347383
cumulativeBytesProcessed.Add(changeEventSize)
348384

349385
var changeEvent ChangeEvent
350-
if err := bson.Unmarshal(changeStream.Current, &changeEvent); err != nil {
386+
if err := bson.Unmarshal(current, &changeEvent); err != nil {
351387
return fmt.Errorf("failed to decode change stream document: %w", err)
352388
}
353389

@@ -465,20 +501,6 @@ func createPipeline(tableNameMapping map[string]model.NameAndExclude) (mongo.Pip
465501
return pipeline, nil
466502
}
467503

468-
// createChangeStream calls client.Watch with a 5 minute context deadline
469-
// so the driver sends it as maxTimeMS over the wire. Otherwise, server-side
470-
// default maxTimeMS is used which can sometimes be too short.
471-
func createChangeStream(
472-
client *mongo.Client,
473-
parent context.Context,
474-
pipeline mongo.Pipeline,
475-
opts *options.ChangeStreamOptionsBuilder,
476-
) (*mongo.ChangeStream, error) {
477-
watchCtx, cancel := context.WithTimeout(parent, 5*time.Minute)
478-
defer cancel()
479-
return client.Watch(watchCtx, pipeline, opts)
480-
}
481-
482504
// This can happen if the resumeToken we are attempting to `ResumeAfter` refers to a table that has been
483505
// filtered out of the change stream pipeline (for example, if a user pauses and edits a mirror). If
484506
// this happens, we decode the resumeToken and extract its operation time, and start a new changeStream

flow/connectors/mongo/cdc_test.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package connmongo
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"encoding/binary"
7+
"encoding/hex"
8+
"testing"
9+
"time"
10+
11+
"github.com/stretchr/testify/require"
12+
"go.mongodb.org/mongo-driver/v2/bson"
13+
"go.mongodb.org/mongo-driver/v2/mongo"
14+
"go.mongodb.org/mongo-driver/v2/mongo/options"
15+
16+
"github.com/PeerDB-io/peerdb/flow/generated/protos"
17+
"github.com/PeerDB-io/peerdb/flow/internal"
18+
"github.com/PeerDB-io/peerdb/flow/model"
19+
"github.com/PeerDB-io/peerdb/flow/otel_metrics"
20+
"github.com/PeerDB-io/peerdb/flow/shared"
21+
)
22+
23+
type iterationType int
24+
25+
const (
26+
// idle returns false on Next() with context.DeadlineExceeded
27+
idle iterationType = iota
28+
// insert returns true on Next() with a generated insert event
29+
insert
30+
)
31+
32+
//nolint:govet // it's a test, no need for fieldalignment
33+
type mockChangeStream struct {
34+
err error
35+
resumeToken bson.Raw
36+
current bson.Raw
37+
38+
idx int
39+
iterations []iterationType
40+
emittedTimes []time.Time
41+
42+
t *testing.T
43+
}
44+
45+
func newMockChangeStream(t *testing.T, iter ...iterationType) *mockChangeStream {
46+
t.Helper()
47+
return &mockChangeStream{t: t, iterations: iter}
48+
}
49+
50+
func (cs *mockChangeStream) Next(context.Context) bool {
51+
if cs.idx >= len(cs.iterations) {
52+
cs.t.Fatalf("mockChangeStream: Next past end of mocked iterations (%d iterations)", len(cs.iterations))
53+
}
54+
ts := time.Now()
55+
cs.emittedTimes = append(cs.emittedTimes, ts)
56+
cs.resumeToken = toResumeToken(ts)
57+
58+
label := cs.iterations[cs.idx]
59+
cs.idx++
60+
61+
switch label {
62+
case insert:
63+
cs.current = newInsertChangeEvent(bson.NewObjectID(), ts)
64+
cs.err = nil
65+
return true
66+
case idle:
67+
cs.err = context.DeadlineExceeded
68+
return false
69+
default:
70+
cs.t.Fatalf("mockChangeStream: unknown label %d", label)
71+
return false
72+
}
73+
}
74+
75+
func (cs *mockChangeStream) ResumeToken() bson.Raw { return cs.resumeToken }
76+
func (cs *mockChangeStream) Err() error { return cs.err }
77+
func (cs *mockChangeStream) Current() bson.Raw { return cs.current }
78+
func (cs *mockChangeStream) Close(context.Context) error { return nil }
79+
80+
var _ ChangeStream = (*mockChangeStream)(nil)
81+
82+
type mockMetadataStore struct{ persisted []model.CdcCheckpoint }
83+
84+
func (ms *mockMetadataStore) GetLastOffset(context.Context, string) (model.CdcCheckpoint, error) {
85+
if n := len(ms.persisted); n > 0 {
86+
return ms.persisted[n-1], nil
87+
}
88+
return model.CdcCheckpoint{}, nil
89+
}
90+
91+
func (ms *mockMetadataStore) SetLastOffset(_ context.Context, _ string, off model.CdcCheckpoint) error {
92+
ms.persisted = append(ms.persisted, off)
93+
return nil
94+
}
95+
96+
func drainMongoCDCRecordsAsync(t *testing.T, stream *model.CDCStream[model.RecordItems]) {
97+
t.Helper()
98+
go func() {
99+
for range stream.GetRecords() {
100+
}
101+
}()
102+
}
103+
104+
func newInsertChangeEvent(id bson.ObjectID, ts time.Time) bson.Raw {
105+
event, _ := bson.Marshal(bson.D{
106+
{Key: "ns", Value: bson.D{
107+
{Key: "db", Value: "db"},
108+
{Key: "coll", Value: "coll"},
109+
}},
110+
{Key: "operationType", Value: "insert"},
111+
{Key: "documentKey", Value: bson.D{{Key: "_id", Value: id}}},
112+
{Key: "fullDocument", Value: bson.D{
113+
{Key: "_id", Value: id},
114+
{Key: "val", Value: "test"},
115+
}},
116+
{Key: "clusterTime", Value: toBsonTs(ts)},
117+
})
118+
return event
119+
}
120+
121+
func TestChangeStreamIdleConnectionAdvancesOffset(t *testing.T) {
122+
ctx := t.Context()
123+
124+
mockCS := newMockChangeStream(t, idle, idle, insert, idle)
125+
mockStore := &mockMetadataStore{}
126+
connector := &MongoConnector{
127+
logger: internal.LoggerFromCtx(t.Context()),
128+
createChangeStream: func(
129+
context.Context, mongo.Pipeline, ...options.Lister[options.ChangeStreamOptions],
130+
) (ChangeStream, error) {
131+
return mockCS, nil
132+
},
133+
metadataStore: mockStore,
134+
}
135+
136+
otelManager, err := otel_metrics.NewOtelManager(ctx, "test", false)
137+
require.NoError(t, err)
138+
139+
req := &model.PullRecordsRequest[model.RecordItems]{
140+
FlowJobName: "test_mongo_idle",
141+
RecordStream: model.NewCDCStream[model.RecordItems](100),
142+
TableNameMapping: map[string]model.NameAndExclude{"db.coll": {Name: "db_coll"}},
143+
TableNameSchemaMapping: map[string]*protos.TableSchema{},
144+
MaxBatchSize: 10000,
145+
IdleTimeout: time.Minute,
146+
Env: map[string]string{"PEERDB_MONGODB_DIRECT_BSON_CONVERTER": "true"},
147+
}
148+
drainMongoCDCRecordsAsync(t, req.RecordStream)
149+
150+
require.NoError(t, connector.PullRecords(ctx, shared.CatalogPool{}, otelManager, req))
151+
require.Len(t, mockStore.persisted, 2)
152+
require.Equal(t, b64(toResumeToken(mockCS.emittedTimes[0])), mockStore.persisted[0].Text)
153+
require.Equal(t, b64(toResumeToken(mockCS.emittedTimes[1])), mockStore.persisted[1].Text)
154+
require.Equal(t, b64(toResumeToken(mockCS.emittedTimes[3])), req.RecordStream.GetLastCheckpoint().Text)
155+
}
156+
157+
func b64(raw bson.Raw) string {
158+
return base64.StdEncoding.EncodeToString(raw)
159+
}
160+
161+
func toBsonTs(ts time.Time) bson.Timestamp {
162+
return bson.Timestamp{T: uint32(ts.Unix()), I: uint32(ts.Nanosecond())}
163+
}
164+
165+
func toResumeToken(ts time.Time) bson.Raw {
166+
t := toBsonTs(ts)
167+
keyString := make([]byte, 9)
168+
keyString[0] = byte(kTimestamp)
169+
binary.BigEndian.PutUint64(keyString[1:], uint64(t.T)<<32|uint64(t.I))
170+
raw, _ := bson.Marshal(bson.D{{Key: "_data", Value: hex.EncodeToString(keyString)}})
171+
return raw
172+
}
173+
174+
func TestResumeTokenHelpersRoundTrip(t *testing.T) {
175+
ts := time.Now().UTC()
176+
rt := toResumeToken(ts)
177+
bsonTs, err := decodeTimestampFromResumeToken(rt)
178+
require.NoError(t, err)
179+
require.Equal(t, toBsonTs(ts), bsonTs)
180+
}

0 commit comments

Comments
 (0)