@powersync/service-module-mongodb-storage 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +5 -1
  3. package/dist/storage/MongoBucketStorage.js +30 -6
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +23 -10
  6. package/dist/storage/implementation/MongoBucketBatch.js +132 -88
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoCompactor.d.ts +2 -0
  9. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  10. package/dist/storage/implementation/MongoIdSequence.d.ts +10 -1
  11. package/dist/storage/implementation/MongoIdSequence.js +14 -1
  12. package/dist/storage/implementation/MongoIdSequence.js.map +1 -1
  13. package/dist/storage/implementation/MongoOpIdAllocator.d.ts +27 -0
  14. package/dist/storage/implementation/MongoOpIdAllocator.js +97 -0
  15. package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -0
  16. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +5 -0
  17. package/dist/storage/implementation/MongoSyncBucketStorage.js +17 -0
  18. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  19. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +26 -2
  20. package/dist/storage/implementation/MongoSyncRulesLock.js +38 -1
  21. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  22. package/dist/storage/implementation/db.js +1 -0
  23. package/dist/storage/implementation/db.js.map +1 -1
  24. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +5 -0
  25. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +136 -133
  26. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  27. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +1 -1
  28. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  29. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +5 -1
  30. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +131 -148
  31. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  32. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +4 -3
  33. package/dist/storage/implementation/v3/MongoCompactorV3.js +14 -10
  34. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  35. package/dist/storage/implementation/v3/compact-utils.d.ts +3 -2
  36. package/dist/storage/implementation/v3/compact-utils.js +12 -5
  37. package/dist/storage/implementation/v3/compact-utils.js.map +1 -1
  38. package/dist/storage/implementation/v3/models.d.ts +1 -1
  39. package/dist/utils/test-utils.d.ts +2 -1
  40. package/dist/utils/test-utils.js +4 -3
  41. package/dist/utils/test-utils.js.map +1 -1
  42. package/package.json +7 -7
  43. package/src/storage/MongoBucketStorage.ts +35 -7
  44. package/src/storage/implementation/MongoBucketBatch.ts +201 -110
  45. package/src/storage/implementation/MongoCompactor.ts +2 -0
  46. package/src/storage/implementation/MongoIdSequence.ts +21 -1
  47. package/src/storage/implementation/MongoOpIdAllocator.ts +105 -0
  48. package/src/storage/implementation/MongoSyncBucketStorage.ts +19 -0
  49. package/src/storage/implementation/MongoSyncRulesLock.ts +56 -3
  50. package/src/storage/implementation/db.ts +1 -0
  51. package/src/storage/implementation/models.ts +1 -0
  52. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +150 -152
  53. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +1 -1
  54. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +167 -194
  55. package/src/storage/implementation/v3/MongoCompactorV3.ts +19 -22
  56. package/src/storage/implementation/v3/compact-utils.ts +16 -7
  57. package/src/storage/implementation/v3/models.ts +1 -1
  58. package/src/utils/test-utils.ts +5 -3
  59. package/test/src/__snapshots__/storage.test.ts.snap +102 -2
  60. package/test/src/__snapshots__/storage_compacting.test.ts.snap +2 -2
  61. package/test/src/__snapshots__/storage_sync.test.ts.snap +12 -12
  62. package/test/src/cleanup-stopped-sync-configs.test.ts +19 -13
  63. package/test/src/compact-utils.test.ts +33 -8
  64. package/test/src/concurrent_writers.test.ts +599 -0
  65. package/test/src/helpers/concurrentWriter.mjs +29 -0
  66. package/test/src/helpers/s3TestFactory.ts +12 -12
  67. package/test/src/object_storage_usage.test.ts +2 -1
  68. package/test/src/parameter_compacting_v1.test.ts +2 -2
  69. package/test/src/parameter_compacting_v3.test.ts +2 -2
  70. package/test/src/parameter_compaction_fence.test.ts +3 -3
  71. package/test/src/storage.test.ts +32 -33
  72. package/test/src/storage_compacting.test.ts +147 -21
  73. package/test/src/storage_object_storage_inline_threshold.test.ts +3 -3
  74. package/test/src/storage_s3_checksums.test.ts +4 -4
  75. package/test/src/storage_s3_compaction_lifecycle.test.ts +8 -8
  76. package/test/src/storage_s3_reading.test.ts +4 -4
  77. package/test/src/storage_s3_writing.test.ts +3 -3
  78. package/test/src/storage_sync.test.ts +52 -46
  79. package/test/src/storeCurrentData.test.ts +5 -5
  80. package/tsconfig.tsbuildinfo +1 -1
@@ -1,8 +1,8 @@
1
1
  import crypto from 'crypto';
2
2
 
3
3
  import { mongo } from '@powersync/lib-service-mongodb';
4
- import { ErrorCode, Logger, ServiceError } from '@powersync/lib-services-framework';
5
- import { storage } from '@powersync/service-core';
4
+ import { ErrorCode, Logger, ReplicationAbortedError, ServiceError } from '@powersync/lib-services-framework';
5
+ import { storage, utils } from '@powersync/service-core';
6
6
  import { VersionedPowerSyncMongo } from './db.js';
7
7
 
8
8
  const LOCK_DURATION_MS = 60 * 1000;
@@ -12,6 +12,12 @@ const LOCK_DURATION_MS = 60 * 1000;
12
12
  * processes that replication stream at a time.
13
13
  */
14
14
  export class MongoSyncRulesLock implements storage.ReplicationLock {
15
+ /** FIFO admission shared by this lease's writers; database fencing still enforces ownership. */
16
+ readonly writerMutex = new utils.Mutex();
17
+
18
+ private readonly abort = new AbortController();
19
+ readonly signal = this.abort.signal;
20
+
15
21
  private readonly refreshInterval: NodeJS.Timeout;
16
22
 
17
23
  /**
@@ -67,13 +73,14 @@ export class MongoSyncRulesLock implements storage.ReplicationLock {
67
73
  constructor(
68
74
  private db: VersionedPowerSyncMongo,
69
75
  public sync_rules_id: number,
70
- private lock_id: string,
76
+ public readonly lock_id: string,
71
77
  private logger: Logger
72
78
  ) {
73
79
  this.refreshInterval = setInterval(async () => {
74
80
  try {
75
81
  await this.refresh();
76
82
  } catch (e) {
83
+ this.abort.abort(e);
77
84
  this.logger.error('Failed to refresh lock', e);
78
85
  clearInterval(this.refreshInterval);
79
86
  }
@@ -81,6 +88,7 @@ export class MongoSyncRulesLock implements storage.ReplicationLock {
81
88
  }
82
89
 
83
90
  async release(): Promise<void> {
91
+ this.abort.abort(new Error('Replication lock released'));
84
92
  clearInterval(this.refreshInterval);
85
93
  const result = await this.db.sync_rules.updateOne(
86
94
  {
@@ -97,6 +105,51 @@ export class MongoSyncRulesLock implements storage.ReplicationLock {
97
105
  }
98
106
  }
99
107
 
108
+ throwIfAborted(): void {
109
+ this.signal.throwIfAborted();
110
+ }
111
+
112
+ static ownerFilter(streamId: number, lock: MongoSyncRulesLock) {
113
+ lock.throwIfAborted();
114
+ return { _id: streamId, 'lock.id': lock.lock_id };
115
+ }
116
+
117
+ static heartbeatUpdate() {
118
+ // Always change the existing heartbeat, even for writes in the same
119
+ // millisecond. A no-op update is not sufficient for a transactional fence.
120
+ return {
121
+ last_keepalive_ts: {
122
+ $max: ['$$NOW', { $add: [{ $ifNull: ['$last_keepalive_ts', new Date(0)] }, 1] }]
123
+ }
124
+ };
125
+ }
126
+
127
+ static assertOwned<T>(document: T | null): T {
128
+ if (document == null) {
129
+ throw new ReplicationAbortedError('Replication writer no longer owns the stream');
130
+ }
131
+ return document;
132
+ }
133
+
134
+ /**
135
+ * A write, not just an ownership read: takeover conflicts with every transaction
136
+ * that publishes under this owner. Every writer must hold a stream lease.
137
+ */
138
+ static async fence(
139
+ db: VersionedPowerSyncMongo,
140
+ streamId: number,
141
+ lock: MongoSyncRulesLock,
142
+ session: mongo.ClientSession,
143
+ projection: mongo.Document = { last_persisted_op: 1, last_checkpoint: 1, keepalive_op: 1 }
144
+ ) {
145
+ const doc = await db.sync_rules.findOneAndUpdate(
146
+ this.ownerFilter(streamId, lock),
147
+ [{ $set: this.heartbeatUpdate() }],
148
+ { session, returnDocument: 'after', projection }
149
+ );
150
+ return this.assertOwned(doc);
151
+ }
152
+
100
153
  private async refresh(): Promise<void> {
101
154
  const result = await this.db.sync_rules.findOneAndUpdate(
102
155
  {
@@ -81,6 +81,7 @@ export class PowerSyncMongo {
81
81
  this.bucket_data = db.collection('bucket_data');
82
82
  this.bucket_parameters = db.collection('bucket_parameters');
83
83
  this.op_id_sequence = db.collection('op_id_sequence');
84
+ // Uses legacy terminology instead of "sync config" for backwards-compatibility
84
85
  this.sync_rules = db.collection('sync_rules');
85
86
  this.source_tables = db.collection('source_tables');
86
87
  this.custom_write_checkpoints = db.collection('custom_write_checkpoints');
@@ -308,6 +308,7 @@ export interface CustomWriteCheckpointDocument {
308
308
  _id: bson.ObjectId;
309
309
  user_id: string;
310
310
  checkpoint: bigint;
311
+ // Uses the legacy field name "sync_rules_id" for backwards-compatibility. See AGENTS.md.
311
312
  sync_rules_id: number;
312
313
  /**
313
314
  * Unlike managed write checkpoints, custom write checkpoints are flushed together with
@@ -6,8 +6,11 @@ import * as bson from 'bson';
6
6
  import { mongoTableId } from '../../../utils/util.js';
7
7
  import { calculateCheckpointState } from '../CheckpointState.js';
8
8
  import { MongoBucketBatch, MongoBucketBatchOptions } from '../MongoBucketBatch.js';
9
+ import { MongoSyncRulesLock } from '../MongoSyncRulesLock.js';
10
+ import { MongoWriteBatch } from '../MongoWriteBatch.js';
9
11
  import { PersistedBatch } from '../common/PersistedBatch.js';
10
12
  import { SourceRecordStore } from '../common/SourceRecordStore.js';
13
+ import { SyncRuleDocumentBase } from '../models.js';
11
14
  import { PersistedBatchV1 } from './PersistedBatchV1.js';
12
15
  import { SourceRecordStoreV1 } from './SourceRecordStoreV1.js';
13
16
  import { VersionedPowerSyncMongoV1 } from './VersionedPowerSyncMongoV1.js';
@@ -34,6 +37,27 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
34
37
  this.store = new SourceRecordStoreV1(this.db, this.replicationStreamId);
35
38
  }
36
39
 
40
+ protected override persistedOpHead(stream: SyncRuleDocumentBase): InternalOpId {
41
+ const legacy = stream as SyncRuleDocumentV1;
42
+ const checkpoint = legacy.last_checkpoint ?? 0n;
43
+ const keepalive = legacy.keepalive_op == null ? 0n : BigInt(legacy.keepalive_op);
44
+ return checkpoint > keepalive ? checkpoint : keepalive;
45
+ }
46
+
47
+ protected override onReplicationTransactionFlush(writes: MongoWriteBatch, lastOp: InternalOpId): void {
48
+ // Keep flushed operations recoverable before a checkpoint can be published.
49
+ // keepalive_op remains a decimal string for compatibility with legacy readers.
50
+ writes.updateOne(this.db.sync_rules, { _id: this.replicationStreamId }, [
51
+ {
52
+ $set: {
53
+ keepalive_op: {
54
+ $toString: { $max: [{ $toLong: '$keepalive_op' }, '$last_checkpoint', lastOp] }
55
+ }
56
+ }
57
+ }
58
+ ]);
59
+ }
60
+
37
61
  protected override recordPersistedOp(lastOp: InternalOpId): void {
38
62
  this.persisted_op = lastOp;
39
63
  }
@@ -101,7 +125,8 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
101
125
  }));
102
126
 
103
127
  let result: storage.ResolveTablesResult | null = null;
104
- await this.db.client.withSession(async (session) => {
128
+ const session = this.session;
129
+ await this.withTransaction(async () => {
105
130
  const col = this.db.sourceTablesV1(this.replicationStreamId);
106
131
 
107
132
  // Find records that overlap by name or relation id.
@@ -219,129 +244,136 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
219
244
  async commit(lsn: string, options?: storage.BucketBatchCommitOptions): Promise<storage.CheckpointResult> {
220
245
  const { createEmptyCheckpoints } = { ...storage.DEFAULT_BUCKET_BATCH_COMMIT_OPTIONS, ...options };
221
246
 
222
- await this.flush(options);
223
-
224
247
  using _ = this.tracer.span('storage', 'commit');
225
248
 
226
- const now = new Date();
249
+ const updateCheckpoint = async (persistedOp = this.persisted_op) => {
250
+ const now = new Date();
227
251
 
228
- await this.db.write_checkpoints.updateMany(
229
- {
230
- processed_at_lsn: null,
231
- 'lsns.1': { $lte: lsn }
232
- },
233
- {
234
- $set: {
235
- processed_at_lsn: lsn
236
- }
237
- },
238
- {
239
- session: this.session
240
- }
241
- );
252
+ const can_checkpoint = {
253
+ $and: [
254
+ { $eq: ['$snapshot_done', true] },
255
+ {
256
+ $or: [{ $eq: ['$last_checkpoint_lsn', null] }, { $lte: ['$last_checkpoint_lsn', { $literal: lsn }] }]
257
+ },
258
+ {
259
+ $or: [{ $eq: ['$no_checkpoint_before', null] }, { $lte: ['$no_checkpoint_before', { $literal: lsn }] }]
260
+ }
261
+ ]
262
+ };
242
263
 
243
- const can_checkpoint = {
244
- $and: [
245
- { $eq: ['$snapshot_done', true] },
246
- {
247
- $or: [{ $eq: ['$last_checkpoint_lsn', null] }, { $lte: ['$last_checkpoint_lsn', { $literal: lsn }] }]
248
- },
249
- {
250
- $or: [{ $eq: ['$no_checkpoint_before', null] }, { $lte: ['$no_checkpoint_before', { $literal: lsn }] }]
251
- }
252
- ]
253
- };
264
+ const new_keepalive_op = {
265
+ $cond: [
266
+ can_checkpoint,
267
+ { $literal: null },
268
+ {
269
+ $toString: {
270
+ $max: [{ $toLong: '$keepalive_op' }, { $literal: persistedOp }, 0n]
271
+ }
272
+ }
273
+ ]
274
+ };
275
+
276
+ const new_last_checkpoint = {
277
+ $cond: [
278
+ can_checkpoint,
279
+ {
280
+ $max: ['$last_checkpoint', { $literal: persistedOp }, { $toLong: '$keepalive_op' }, 0n]
281
+ },
282
+ '$last_checkpoint'
283
+ ]
284
+ };
254
285
 
255
- const new_keepalive_op = {
256
- $cond: [
257
- can_checkpoint,
258
- { $literal: null },
286
+ const preUpdateDocument = (await this.db.sync_rules.findOneAndUpdate(
287
+ MongoSyncRulesLock.ownerFilter(this.replicationStreamId, this.options.replicationLock),
288
+ [
289
+ {
290
+ $set: {
291
+ _can_checkpoint: can_checkpoint,
292
+ _not_empty: createEmptyCheckpoints
293
+ ? true
294
+ : {
295
+ $or: [
296
+ { $literal: createEmptyCheckpoints },
297
+ { $ne: ['$keepalive_op', new_keepalive_op] },
298
+ { $ne: ['$last_checkpoint', new_last_checkpoint] }
299
+ ]
300
+ }
301
+ }
302
+ },
303
+ {
304
+ $set: {
305
+ ...MongoSyncRulesLock.heartbeatUpdate(),
306
+ last_checkpoint_lsn: {
307
+ $cond: [{ $and: ['$_can_checkpoint', '$_not_empty'] }, { $literal: lsn }, '$last_checkpoint_lsn']
308
+ },
309
+ last_checkpoint_ts: {
310
+ $cond: [{ $and: ['$_can_checkpoint', '$_not_empty'] }, { $literal: now }, '$last_checkpoint_ts']
311
+ },
312
+
313
+ last_fatal_error: { $literal: null },
314
+ last_fatal_error_ts: { $literal: null },
315
+ keepalive_op: new_keepalive_op,
316
+ last_checkpoint: new_last_checkpoint,
317
+ snapshot_lsn: {
318
+ $cond: [{ $and: ['$_can_checkpoint', '$_not_empty'] }, { $literal: null }, '$snapshot_lsn']
319
+ }
320
+ }
321
+ },
322
+ {
323
+ $unset: ['_can_checkpoint', '_not_empty']
324
+ }
325
+ ],
259
326
  {
260
- $toString: {
261
- $max: [{ $toLong: '$keepalive_op' }, { $literal: this.persisted_op }, 0n]
327
+ session: this.session,
328
+ returnDocument: 'before',
329
+ projection: {
330
+ snapshot_done: 1,
331
+ last_checkpoint_lsn: 1,
332
+ no_checkpoint_before: 1,
333
+ keepalive_op: 1,
334
+ last_checkpoint: 1
262
335
  }
263
336
  }
264
- ]
265
- };
337
+ )) as SyncRuleDocumentV1;
266
338
 
267
- const new_last_checkpoint = {
268
- $cond: [
269
- can_checkpoint,
270
- {
271
- $max: ['$last_checkpoint', { $literal: this.persisted_op }, { $toLong: '$keepalive_op' }, 0n]
272
- },
273
- '$last_checkpoint'
274
- ]
339
+ return MongoSyncRulesLock.assertOwned(preUpdateDocument);
275
340
  };
276
341
 
277
- const preUpdateDocument = (await this.db.sync_rules.findOneAndUpdate(
278
- { _id: this.replicationStreamId },
279
- [
342
+ const finishCheckpoint = async (preUpdateDocument: SyncRuleDocumentV1, lastOp?: InternalOpId) => {
343
+ await this.db.write_checkpoints.updateMany(
280
344
  {
281
- $set: {
282
- _can_checkpoint: can_checkpoint,
283
- _not_empty: createEmptyCheckpoints
284
- ? true
285
- : {
286
- $or: [
287
- { $literal: createEmptyCheckpoints },
288
- { $ne: ['$keepalive_op', new_keepalive_op] },
289
- { $ne: ['$last_checkpoint', new_last_checkpoint] }
290
- ]
291
- }
292
- }
345
+ processed_at_lsn: null,
346
+ 'lsns.1': { $lte: lsn }
293
347
  },
294
348
  {
295
349
  $set: {
296
- last_checkpoint_lsn: {
297
- $cond: [{ $and: ['$_can_checkpoint', '$_not_empty'] }, { $literal: lsn }, '$last_checkpoint_lsn']
298
- },
299
- last_checkpoint_ts: {
300
- $cond: [{ $and: ['$_can_checkpoint', '$_not_empty'] }, { $literal: now }, '$last_checkpoint_ts']
301
- },
302
- last_keepalive_ts: { $literal: now },
303
- last_fatal_error: { $literal: null },
304
- last_fatal_error_ts: { $literal: null },
305
- keepalive_op: new_keepalive_op,
306
- last_checkpoint: new_last_checkpoint,
307
- snapshot_lsn: {
308
- $cond: [{ $and: ['$_can_checkpoint', '$_not_empty'] }, { $literal: null }, '$snapshot_lsn']
309
- }
350
+ processed_at_lsn: lsn
310
351
  }
311
352
  },
312
353
  {
313
- $unset: ['_can_checkpoint', '_not_empty']
354
+ session: this.session
314
355
  }
315
- ],
316
- {
317
- session: this.session,
318
- returnDocument: 'before',
319
- projection: {
320
- snapshot_done: 1,
321
- last_checkpoint_lsn: 1,
322
- no_checkpoint_before: 1,
323
- keepalive_op: 1,
324
- last_checkpoint: 1
325
- }
326
- }
327
- )) as SyncRuleDocumentV1;
328
-
329
- if (preUpdateDocument == null) {
330
- throw new ReplicationAssertionError(
331
- 'Failed to update checkpoint - no matching sync_rules document for _id: ' + this.replicationStreamId
332
356
  );
333
- }
334
357
 
335
- const checkpointState = calculateCheckpointState({
336
- lsn,
337
- snapshotDone: preUpdateDocument.snapshot_done === true,
338
- lastCheckpointLsn: preUpdateDocument.last_checkpoint_lsn,
339
- noCheckpointBefore: preUpdateDocument.no_checkpoint_before,
340
- keepaliveOp: preUpdateDocument.keepalive_op == null ? null : BigInt(preUpdateDocument.keepalive_op),
341
- lastCheckpoint: preUpdateDocument.last_checkpoint,
342
- persistedOp: this.persisted_op,
343
- createEmptyCheckpoints
344
- });
358
+ const checkpointState = calculateCheckpointState({
359
+ lsn,
360
+ snapshotDone: preUpdateDocument.snapshot_done === true,
361
+ lastCheckpointLsn: preUpdateDocument.last_checkpoint_lsn,
362
+ noCheckpointBefore: preUpdateDocument.no_checkpoint_before,
363
+ keepaliveOp: preUpdateDocument.keepalive_op == null ? null : BigInt(preUpdateDocument.keepalive_op),
364
+ lastCheckpoint: preUpdateDocument.last_checkpoint,
365
+ persistedOp: lastOp ?? this.persistedOpHead(preUpdateDocument),
366
+ createEmptyCheckpoints
367
+ });
368
+ return { checkpointState, preUpdateDocument };
369
+ };
370
+
371
+ const { checkpointState, preUpdateDocument } = await this.flushAndCommit(
372
+ async (_stream, lastOp) => finishCheckpoint(await updateCheckpoint(lastOp), lastOp),
373
+ // Without a flush, the checkpoint update itself fences the transaction.
374
+ () => this.withFencedTransaction(updateCheckpoint, finishCheckpoint),
375
+ options
376
+ );
345
377
  if (checkpointState.checkpointBlocked) {
346
378
  if (Date.now() - this.lastWaitingLogThrottled > 5_000) {
347
379
  this.logger.info(
@@ -379,41 +411,20 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
379
411
 
380
412
  async setResumeLsn(lsn: string): Promise<void> {
381
413
  using _ = this.tracer.span('storage', 'set_resume_lsn');
382
- await this.db.sync_rules.updateOne(
383
- {
384
- _id: this.replicationStreamId
385
- },
386
- {
387
- $set: {
388
- snapshot_lsn: lsn
389
- }
390
- },
391
- {
392
- session: this.session,
393
- // Losing occasional resume LSN is fine. That may mean reprocessing
394
- // some source changes in some edge cases, which is not an issue since
395
- // changes are processed in an idempotent way.
396
- writeConcern: { w: 1 }
397
- }
398
- );
414
+ // Losing occasional resume LSN would only reprocess source changes.
415
+ // Keep the lease check atomic, but do not wait for majority replication of this resume hint.
416
+ await this.updateStreamMetadata({ snapshot_lsn: { $literal: lsn } }, {}, { w: 1 });
399
417
  }
400
418
 
401
419
  async markAllSnapshotDone(no_checkpoint_before_lsn: string): Promise<void> {
402
- await this.db.sync_rules.updateOne(
403
- {
404
- _id: this.replicationStreamId
405
- },
406
- {
407
- $set: {
408
- snapshot_done: true,
409
- last_keepalive_ts: new Date()
410
- },
411
- $max: {
412
- no_checkpoint_before: no_checkpoint_before_lsn
413
- }
414
- },
415
- { session: this.session }
416
- );
420
+ await this.updateStreamMetadata(this.snapshotDoneUpdate(no_checkpoint_before_lsn));
421
+ }
422
+
423
+ private snapshotDoneUpdate(no_checkpoint_before_lsn: string) {
424
+ return {
425
+ snapshot_done: true,
426
+ no_checkpoint_before: { $max: ['$no_checkpoint_before', { $literal: no_checkpoint_before_lsn }] }
427
+ };
417
428
  }
418
429
 
419
430
  async markSnapshotDone(no_checkpoint_before_lsn: string, options?: { throwOnConflict?: boolean }): Promise<void> {
@@ -443,22 +454,12 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
443
454
  }
444
455
  }
445
456
 
446
- await this.markAllSnapshotDone(no_checkpoint_before_lsn);
457
+ await this.updateStreamMetadataInTransaction(this.snapshotDoneUpdate(no_checkpoint_before_lsn));
447
458
  });
448
459
  }
449
460
 
450
461
  async markTableSnapshotRequired(_table: storage.SourceTable): Promise<void> {
451
- await this.db.sync_rules.updateOne(
452
- {
453
- _id: this.replicationStreamId
454
- },
455
- {
456
- $set: {
457
- snapshot_done: false
458
- }
459
- },
460
- { session: this.session }
461
- );
462
+ await this.updateStreamMetadata({ snapshot_done: false });
462
463
  }
463
464
 
464
465
  async markTableSnapshotDone(
@@ -490,9 +491,6 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
490
491
  _id: this.replicationStreamId
491
492
  },
492
493
  {
493
- $set: {
494
- last_keepalive_ts: new Date()
495
- },
496
494
  $max: {
497
495
  no_checkpoint_before: no_checkpoint_before_lsn
498
496
  }
@@ -516,7 +514,7 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
516
514
  const session = this.session;
517
515
  let activated = false;
518
516
  let needsFutureActivationCheck = true;
519
- await session.withTransaction(async () => {
517
+ await this.withTransaction(async () => {
520
518
  // Reset on transaction retries.
521
519
  activated = false;
522
520
  needsFutureActivationCheck = true;
@@ -554,7 +552,7 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
554
552
  } else if (doc?.state != storage.SyncRuleState.PROCESSING) {
555
553
  needsFutureActivationCheck = false;
556
554
  }
557
- });
555
+ }, session);
558
556
  if (activated) {
559
557
  this.logger.info(`Activated new replication stream at ${lsn}`);
560
558
  await this.db.notifyCheckpoint();
@@ -90,7 +90,7 @@ export class MongoSyncBucketStorageV1 extends MongoSyncBucketStorage {
90
90
  if (keepaliveOp == null && lastCheckpoint == null) {
91
91
  return null;
92
92
  }
93
- return (keepaliveOp ?? 0n) > (lastCheckpoint ?? 0n) ? keepaliveOp : lastCheckpoint;
93
+ return [keepaliveOp ?? 0n, lastCheckpoint ?? 0n].reduce((a, b) => (a > b ? a : b));
94
94
  }
95
95
 
96
96
  protected async createWriterImpl(options: storage.CreateWriterOptions): Promise<storage.BucketStorageBatch> {