@@ -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+
3956func (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
0 commit comments