@powersync/service-module-mongodb-storage 0.23.1 → 0.24.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 (99) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/LICENSE +201 -67
  3. package/dist/storage/MongoBucketStorage.d.ts +1 -0
  4. package/dist/storage/MongoBucketStorage.js +3 -0
  5. package/dist/storage/MongoBucketStorage.js.map +1 -1
  6. package/dist/storage/implementation/MongoBucketBatch.d.ts +60 -39
  7. package/dist/storage/implementation/MongoBucketBatch.js +123 -296
  8. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  9. package/dist/storage/implementation/MongoOpIdAllocator.d.ts +6 -1
  10. package/dist/storage/implementation/MongoOpIdAllocator.js +12 -5
  11. package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -1
  12. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +7 -0
  13. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +28 -2
  14. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
  15. package/dist/storage/implementation/MongoReplicationOptions.d.ts +27 -0
  16. package/dist/storage/implementation/MongoReplicationOptions.js +26 -0
  17. package/dist/storage/implementation/MongoReplicationOptions.js.map +1 -0
  18. package/dist/storage/implementation/MongoSyncBucketStorage.js +2 -3
  19. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  20. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +15 -2
  21. package/dist/storage/implementation/MongoSyncRulesLock.js +36 -14
  22. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  23. package/dist/storage/implementation/common/BatchOperationBuffer.d.ts +79 -0
  24. package/dist/storage/implementation/common/BatchOperationBuffer.js +286 -0
  25. package/dist/storage/implementation/common/BatchOperationBuffer.js.map +1 -0
  26. package/dist/storage/implementation/common/PersistedBatch.d.ts +10 -103
  27. package/dist/storage/implementation/common/PersistedBatch.js +1 -291
  28. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  29. package/dist/storage/implementation/common/PreparedPublication.d.ts +43 -0
  30. package/dist/storage/implementation/common/PreparedPublication.js +2 -0
  31. package/dist/storage/implementation/common/PreparedPublication.js.map +1 -0
  32. package/dist/storage/implementation/common/SourceRecordStore.d.ts +0 -2
  33. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +17 -13
  34. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +259 -18
  35. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  36. package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +21 -15
  37. package/dist/storage/implementation/v1/PersistedBatchV1.js +58 -24
  38. package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
  39. package/dist/storage/implementation/v1/SourceRecordStoreV1.d.ts +0 -2
  40. package/dist/storage/implementation/v1/SourceRecordStoreV1.js +0 -3
  41. package/dist/storage/implementation/v1/SourceRecordStoreV1.js.map +1 -1
  42. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +25 -14
  43. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +350 -23
  44. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  45. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +1 -0
  46. package/dist/storage/implementation/v3/MongoCompactorV3.js +10 -4
  47. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  48. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +39 -28
  49. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  50. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +46 -24
  51. package/dist/storage/implementation/v3/PersistedBatchV3.js +159 -83
  52. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  53. package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +12 -0
  54. package/dist/storage/implementation/v3/SourceRecordStoreV3.js +41 -13
  55. package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -1
  56. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +4 -0
  57. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +4 -0
  58. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  59. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +27 -2
  60. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +61 -5
  61. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  62. package/package.json +9 -10
  63. package/src/storage/MongoBucketStorage.ts +4 -0
  64. package/src/storage/implementation/MongoBucketBatch.ts +185 -392
  65. package/src/storage/implementation/MongoOpIdAllocator.ts +12 -5
  66. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +40 -1
  67. package/src/storage/implementation/MongoReplicationOptions.ts +28 -0
  68. package/src/storage/implementation/MongoSyncBucketStorage.ts +2 -3
  69. package/src/storage/implementation/MongoSyncRulesLock.ts +39 -17
  70. package/src/storage/implementation/common/BatchOperationBuffer.ts +353 -0
  71. package/src/storage/implementation/common/PersistedBatch.ts +12 -390
  72. package/src/storage/implementation/common/PreparedPublication.ts +49 -0
  73. package/src/storage/implementation/common/SourceRecordStore.ts +0 -2
  74. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +339 -27
  75. package/src/storage/implementation/v1/PersistedBatchV1.ts +74 -32
  76. package/src/storage/implementation/v1/SourceRecordStoreV1.ts +0 -5
  77. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +430 -32
  78. package/src/storage/implementation/v3/MongoCompactorV3.ts +13 -4
  79. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +43 -29
  80. package/src/storage/implementation/v3/PersistedBatchV3.ts +209 -108
  81. package/src/storage/implementation/v3/SourceRecordStoreV3.ts +45 -15
  82. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +9 -0
  83. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +79 -5
  84. package/test/src/checkpoint_cleanup.test.ts +75 -0
  85. package/test/src/concurrent_writers.test.ts +127 -53
  86. package/test/src/helpers/PublicationCommandMonitor.ts +119 -0
  87. package/test/src/helpers/concurrentWriter.mjs +2 -2
  88. package/test/src/invalid_sync_config.test.ts +9 -0
  89. package/test/src/object_storage_lifecycle.test.ts +72 -2
  90. package/test/src/prepared_publication.test.ts +388 -0
  91. package/test/src/replication_preparation.test.ts +829 -0
  92. package/test/src/replication_publication_baseline.test.ts +203 -0
  93. package/test/src/replication_publication_batching.test.ts +212 -0
  94. package/test/src/replication_uploads.test.ts +393 -0
  95. package/test/src/source_table_config.test.ts +4 -4
  96. package/test/src/storage_object_storage_inline_threshold.test.ts +1 -1
  97. package/test/src/storage_s3_compaction_lifecycle.test.ts +93 -1
  98. package/test/src/storage_sync.test.ts +83 -2
  99. package/tsconfig.tsbuildinfo +1 -1
@@ -6,14 +6,15 @@ import * as timers from 'node:timers/promises';
6
6
  import { mongoTableId } from '../../utils/util.js';
7
7
  import { MAX_ROW_SIZE } from './MongoBucketBatchShared.js';
8
8
  import { OpIdRangeExhausted } from './MongoIdSequence.js';
9
+ import { REPLICATION_WRITE_OPTIONS } from './MongoReplicationOptions.js';
9
10
  import { MongoSyncRulesLock } from './MongoSyncRulesLock.js';
10
11
  import { OperationBatch, RecordOperation } from './OperationBatch.js';
11
12
  import { createObjectStorageUsageWriterId } from './v3/object-storage/ObjectStorageUsage.js';
12
13
  export class MongoBucketBatch extends BaseObserver {
13
14
  options;
14
15
  logger;
15
- client;
16
16
  db;
17
+ /** Shared by this lease's writers; only use inside a serialized public operation. */
17
18
  session;
18
19
  sync_rules;
19
20
  replicationStreamId;
@@ -61,11 +62,12 @@ export class MongoBucketBatch extends BaseObserver {
61
62
  ? options.replicationLock.signal
62
63
  : AbortSignal.any([options.signal, options.replicationLock.signal])
63
64
  };
64
- this.client = options.db.client;
65
- this.db = options.db;
65
+ // A MongoDB session can only be used with its originating client. Writers
66
+ // created through another factory must still use this lease's database.
67
+ this.db = options.replicationLock.db;
66
68
  this.replicationStreamId = options.replicationStreamId;
67
69
  this.resumeFromLsn = options.resumeFromLsn;
68
- this.session = this.client.startSession();
70
+ this.session = options.replicationLock.session;
69
71
  this.replicationStreamName = options.replicationStreamName;
70
72
  this.sync_rules = options.parsedSyncConfig.hydratedSyncConfig;
71
73
  this.storeCurrentData = options.storeCurrentData;
@@ -82,9 +84,40 @@ export class MongoBucketBatch extends BaseObserver {
82
84
  sync_rules_id: this.replicationStreamId
83
85
  });
84
86
  }
87
+ async resolveTables(options) {
88
+ return this.withWriterLock(() => this.resolveTablesInternal(options));
89
+ }
90
+ async commit(lsn, options) {
91
+ return this.withWriterLock(() => this.commitInternal(lsn, options));
92
+ }
93
+ async keepalive(lsn) {
94
+ return this.commit(lsn, { createEmptyCheckpoints: true });
95
+ }
96
+ async setResumeLsn(lsn) {
97
+ return this.withWriterLock(() => this.setResumeLsnInternal(lsn));
98
+ }
99
+ async getSourceTableStatus(table) {
100
+ return this.withWriterLock(() => this.getSourceTableStatusInternal(table));
101
+ }
102
+ async markAllSnapshotDone(no_checkpoint_before_lsn) {
103
+ return this.withWriterLock(() => this.markAllSnapshotDoneInternal(no_checkpoint_before_lsn));
104
+ }
105
+ async markSnapshotDone(no_checkpoint_before_lsn, options) {
106
+ return this.withWriterLock(() => this.markSnapshotDoneInternal(no_checkpoint_before_lsn, options));
107
+ }
108
+ async markTableSnapshotRequired(table) {
109
+ return this.withWriterLock(() => this.markTableSnapshotRequiredInternal(table));
110
+ }
111
+ async markTableSnapshotDone(tables, no_checkpoint_before_lsn) {
112
+ return this.withWriterLock(() => this.markTableSnapshotDoneInternal(tables, no_checkpoint_before_lsn));
113
+ }
85
114
  /** Perform any version-specific setup required before writing custom checkpoints. */
86
115
  async prepareCustomWriteCheckpoints() { }
87
116
  async flush(options) {
117
+ return this.withWriterLock(() => this.flushInternal(options));
118
+ }
119
+ /** Internal helpers require the public operation to hold the writer mutex. */
120
+ async flushInternal(options) {
88
121
  let result = null;
89
122
  // One flush may be split over multiple transactions.
90
123
  // Each flushInner() is one transaction.
@@ -103,162 +136,21 @@ export class MongoBucketBatch extends BaseObserver {
103
136
  }
104
137
  let result;
105
138
  while (this.batch != null || this.write_checkpoint_batch.length > 0) {
106
- await this.flushInner(options, async (stream, lastOp) => {
107
- result = await checkpoint(stream, lastOp);
139
+ await this.flushInner(options, async (stream, lastOp, writes) => {
140
+ result = await checkpoint(stream, lastOp, writes);
108
141
  }, projection);
109
142
  }
110
143
  return result;
111
144
  }
112
- async flushInner(options, checkpoint, projection) {
113
- const batch = this.batch;
114
- let last_op = null;
115
- let resumeBatch = null;
116
- let clearedError = false;
117
- using _ = this.tracer.span('storage', 'flush');
118
- await this.hooks?.beforeBatchFlush?.(this);
119
- if (this.write_checkpoint_batch.length > 0) {
120
- // Collection/index creation cannot run inside the replication transaction.
121
- await this.prepareCustomWriteCheckpoints();
122
- }
123
- await this.withReplicationTransaction(`Flushing ${batch?.length ?? 0} ops`, async (session, opSeq) => {
124
- clearedError = false;
125
- if (batch != null) {
126
- const result = await this.replicateBatch(session, batch, opSeq, options);
127
- resumeBatch = result.resumeBatch;
128
- clearedError ||= result.clearedError;
129
- }
130
- if (this.write_checkpoint_batch.length > 0) {
131
- this.logger.info(`Writing ${this.write_checkpoint_batch.length} custom write checkpoints`);
132
- await this.batchCreateCustomWriteCheckpoints(session, opSeq.next());
133
- }
134
- last_op = opSeq.last();
135
- }, async (stream, lastOp) => {
136
- if (checkpoint != null && resumeBatch == null) {
137
- // The checkpoint must also persist the head, including when visibility is blocked.
138
- await checkpoint(stream, lastOp);
139
- return true;
140
- }
141
- return false;
142
- }, projection);
143
- // Keep checkpoints available if the transaction retries after writing them.
144
- this.write_checkpoint_batch = [];
145
- if (clearedError) {
146
- this.clearedError = true;
147
- }
148
- // null if done, set if we need another flush
149
- this.batch = resumeBatch;
150
- if (last_op == null) {
151
- throw new ReplicationAssertionError('Unexpected last_op == null');
152
- }
153
- this.recordPersistedOp(last_op);
154
- this.last_flushed_op = last_op;
155
- await this.hooks?.afterBatchFlush?.(this);
156
- return { flushed_op: last_op };
157
- }
158
- async replicateBatch(session, batch, op_seq, options) {
159
- let sizes = undefined;
160
- using _ = this.tracer.span('storage', 'replicate_batch');
161
- // Only look up current_data sizes if the batch stores current_data and at least one
162
- // table in it does too (per-table can disable it, e.g. Postgres REPLICA IDENTITY FULL).
163
- const anyTableStoresCurrentData = this.storeCurrentData && batch.batch.some((r) => r.record.sourceTable.storeCurrentData);
164
- if (anyTableStoresCurrentData && !this.skipExistingRows) {
165
- // We skip this step if no tables store current_data, since the sizes will
166
- // always be small in that case.
167
- // With skipExistingRows, we don't load the full documents into memory,
168
- // so we can also skip the size lookup step.
169
- // Find sizes of current_data documents, to assist in intelligent batching without
170
- // exceeding memory limits.
171
- //
172
- // A previous attempt tried to do batching by the results of the current_data query
173
- // (automatically limited to 48MB(?) per batch by MongoDB). The issue is that it changes
174
- // the order of processing, which then becomes really tricky to manage.
175
- // This now takes 2+ queries, but doesn't have any issues with order of operations.
176
- // Within this branch this.storeCurrentData is true, so the per-table flag is the
177
- // effective value - only look up sizes for tables that actually store current_data.
178
- const sizeLookups = batch.batch
179
- .filter((r) => r.record.sourceTable.storeCurrentData)
180
- .map((r) => ({
181
- sourceTableId: mongoTableId(r.record.sourceTable.id),
182
- replicaId: r.beforeId
183
- }));
184
- sizes = await this.sourceRecordStore.loadSizes(session, sizeLookups);
185
- }
186
- // If set, we need to start a new transaction with this batch.
187
- let resumeBatch = null;
188
- let transactionSize = 0;
189
- let didFlush = false;
190
- // Now batch according to the sizes
191
- // This is a single batch if storeCurrentData == false
192
- for await (let b of batch.batched(sizes)) {
193
- if (resumeBatch) {
194
- for (let op of b) {
195
- resumeBatch.push(op);
196
- }
197
- continue;
198
- }
199
- using lookupSpan = this.tracer.span('storage', 'lookup');
200
- const lookups = b.map((r) => ({
201
- sourceTableId: mongoTableId(r.record.sourceTable.id),
202
- replicaId: r.beforeId
203
- }));
204
- let sourceRecordLookup = await this.sourceRecordStore.loadDocuments(session, lookups, this.skipExistingRows);
205
- lookupSpan.end();
206
- let persistedBatch = this.createPersistedBatch(transactionSize);
207
- // The current code structure makes it tricky to cleanly split this span from the one
208
- // where fluhsing. So we manually end and re-create this span whenever we flush.
209
- let evalSpan = this.tracer.span('evaluate');
210
- for (let op of b) {
211
- if (resumeBatch) {
212
- resumeBatch.push(op);
213
- continue;
214
- }
215
- const sourceRecord = sourceRecordLookup.get(op.internalBeforeKey) ?? null;
216
- if (sourceRecord != null) {
217
- // If it will be used again later, it will be set again using nextData below
218
- sourceRecordLookup.delete(op.internalBeforeKey);
219
- }
220
- const nextData = this.saveOperation(persistedBatch, op, sourceRecord, op_seq);
221
- if (nextData != null) {
222
- // Update our current_data and size cache
223
- sourceRecordLookup.set(op.internalAfterKey, nextData);
224
- sizes?.set(op.internalAfterKey, nextData.data?.length() ?? 0);
225
- }
226
- if (persistedBatch.shouldFlushTransaction()) {
227
- evalSpan.end();
228
- // Transaction is getting big.
229
- // Flush, and resume in a new transaction.
230
- using persistSpan = this.tracer.span('storage', 'persist_flush');
231
- const { flushedAny } = await persistedBatch.flush(this.session, options);
232
- didFlush ||= flushedAny;
233
- persistedBatch = null;
234
- // Computing our current progress is a little tricky here, since
235
- // we're stopping in the middle of a batch.
236
- // We create a new batch, and push any remaining operations to it.
237
- resumeBatch = new OperationBatch();
238
- persistSpan.end();
239
- evalSpan = this.tracer.span('evaluate');
240
- }
241
- }
242
- evalSpan.end();
243
- if (persistedBatch) {
244
- transactionSize = persistedBatch.currentSize;
245
- using _ = this.tracer.span('storage', 'persist_flush');
246
- const { flushedAny } = await persistedBatch.flush(this.session, options);
247
- didFlush ||= flushedAny;
248
- }
249
- }
250
- const clearedError = didFlush && !this.clearedError;
251
- if (clearedError) {
252
- // No need to clear an error more than once per batch, since an error would always result in restarting the batch.
253
- using _ = this.tracer.span('storage', 'clear_error');
254
- await this.clearError(this.session);
255
- }
256
- return {
257
- resumeBatch: resumeBatch?.hasData() ? resumeBatch : null,
258
- clearedError
259
- };
260
- }
261
- saveOperation(batch, operation, sourceRecord, opSeq) {
145
+ /**
146
+ * Evaluate one source operation and append its storage writes to batch.
147
+ *
148
+ * Diagnostics run during evaluation. V1 calls markRecordUnavailable immediately;
149
+ * v3/v4 supply a collector and deliver repair notifications only after a
150
+ * confirmed publication. A failed row attempt may have registered a repair,
151
+ * so its caller must discard that row's collected notification on failure.
152
+ */
153
+ saveOperation(batch, operation, sourceRecord, opSeq, onRecordUnavailable) {
262
154
  const record = operation.record;
263
155
  const beforeId = operation.beforeId;
264
156
  const afterId = operation.afterId;
@@ -292,18 +184,6 @@ export class MongoBucketBatch extends BaseObserver {
292
184
  // Not an error if we re-apply a transaction
293
185
  existing_buckets = [];
294
186
  existing_lookups = [];
295
- if (!isCompleteRow(storeCurrentData, after)) {
296
- if (this.markRecordUnavailable != null) {
297
- // This will trigger a "resnapshot" of the record.
298
- // This is not relevant if storeCurrentData is false, since we'll get the full row
299
- // directly in the replication stream.
300
- this.markRecordUnavailable(record);
301
- }
302
- else {
303
- // Log to help with debugging if there was a consistency issue
304
- this.logger.warn(`Cannot find previous record for update on ${record.sourceTable.qualifiedName}: ${beforeId} / ${record.before?.id}`);
305
- }
306
- }
307
187
  }
308
188
  else {
309
189
  existing_buckets = result.buckets;
@@ -313,6 +193,23 @@ export class MongoBucketBatch extends BaseObserver {
313
193
  after = storage.mergeToast(after, data);
314
194
  }
315
195
  }
196
+ if (!isCompleteRow(storeCurrentData, after)) {
197
+ if (onRecordUnavailable != null) {
198
+ // This will trigger a "resnapshot" of the record.
199
+ // This is not relevant if storeCurrentData is false, since we'll get the full row
200
+ // directly in the replication stream.
201
+ // Check after merging, including existing incomplete rows/tombstones.
202
+ // A flush can commit before a process dies or notification delivery
203
+ // fails. Its source position has not advanced, so WAL replay must
204
+ // request repair again rather than treating mere record existence as
205
+ // proof that the missing TOAST values have been recovered.
206
+ onRecordUnavailable(record);
207
+ }
208
+ else if (result == null) {
209
+ // Log to help with debugging if there was a consistency issue
210
+ this.logger.warn(`Cannot find previous record for update on ${record.sourceTable.qualifiedName}: ${beforeId} / ${record.before?.id}`);
211
+ }
212
+ }
316
213
  }
317
214
  else if (record.tag == SaveOperationTag.DELETE) {
318
215
  const result = sourceRecord;
@@ -323,7 +220,7 @@ export class MongoBucketBatch extends BaseObserver {
323
220
  // Log to help with debugging if there was a consistency issue.
324
221
  // Gate on the batch-level flag: FULL tables (per-record flag false) still get a
325
222
  // current_data entry, so a missing record on DELETE is meaningful for them too.
326
- if (this.storeCurrentData && this.markRecordUnavailable == null) {
223
+ if (this.storeCurrentData && onRecordUnavailable == null) {
327
224
  this.logger.warn(`Cannot find previous record for delete on ${record.sourceTable.qualifiedName}: ${beforeId} / ${record.before?.id}`);
328
225
  }
329
226
  }
@@ -483,21 +380,44 @@ export class MongoBucketBatch extends BaseObserver {
483
380
  async withTransaction(cb, session = this.session) {
484
381
  return this.withFencedTransaction(() => this.fence(session), cb, session);
485
382
  }
486
- /** The first operation must modify the stream document conditional on lease ownership. */
487
- async withFencedTransaction(acquireFence, cb, session = this.session) {
488
- return this.withWriterLock(() => session.withTransaction(async () => {
489
- const stream = await acquireFence();
383
+ /**
384
+ * Internal transaction helper: the public writer operation already holds the
385
+ * mutex. The first operation must modify the stream document conditional on
386
+ * lease ownership. This database fence is required on every retry even though
387
+ * local writers are serialized; another process may have taken over the lease.
388
+ */
389
+ async withFencedTransaction(acquireFence, cb, session = this.session, description) {
390
+ this.options.replicationLock.throwIfAborted();
391
+ let attempts = 0;
392
+ // Includes driver commit retries and the delays between transaction attempts.
393
+ using _transactionSpan = this.tracer.span('storage', 'transaction');
394
+ const result = await session.withTransaction(async () => {
395
+ attempts++;
396
+ // One callback invocation per transaction attempt. Commit happens after
397
+ // this span; command monitoring is needed to measure the full wire lifetime.
398
+ using attemptSpan = this.tracer.span('storage', 'transaction_body');
490
399
  try {
400
+ const stream = await acquireFence();
491
401
  const result = await cb(stream);
492
402
  this.options.replicationLock.throwIfAborted();
493
403
  return result;
494
404
  }
495
405
  catch (e) {
406
+ attemptSpan.end();
496
407
  if (e instanceof OpIdRangeExhausted) {
497
408
  throw e;
498
409
  }
499
410
  if (e instanceof mongo.MongoError && e.hasErrorLabel('TransientTransactionError')) {
500
- // Likely write conflict caused by concurrent writes to this replication stream.
411
+ // Log every tenth attempt, including failures of the fence, without
412
+ // logging a stack or bulk result for every driver retry.
413
+ if ((attempts + 1) % 10 === 0) {
414
+ this.logger.info(`${description ?? 'Replication transaction'} - try ${attempts + 1}`, {
415
+ error: e.message,
416
+ code: e.code,
417
+ codeName: e instanceof mongo.MongoServerError ? e.codeName : undefined,
418
+ errorLabels: e.errorLabels
419
+ });
420
+ }
501
421
  }
502
422
  else {
503
423
  this.logger.warn('Transaction error', e);
@@ -507,19 +427,28 @@ export class MongoBucketBatch extends BaseObserver {
507
427
  await timers.setTimeout(delay);
508
428
  throw e;
509
429
  }
510
- }, { maxCommitTimeMS: 10000, writeConcern: { w: 'majority' } }));
430
+ }, { maxCommitTimeMS: 10000, ...REPLICATION_WRITE_OPTIONS });
431
+ return result;
511
432
  }
433
+ /**
434
+ * Public database operations acquire the lease's mutex once, then call only
435
+ * internal helpers until they finish. Those helpers must never call a public
436
+ * database operation or reacquire this mutex. All session use, including reads,
437
+ * belongs inside this boundary so it cannot join another writer's transaction.
438
+ * Transaction helpers still fence database ownership on every attempt.
439
+ */
512
440
  async withWriterLock(callback) {
513
441
  using lockSpan = this.tracer.span('storage', 'internal_lock');
442
+ this.options.replicationLock.throwIfAborted();
514
443
  return this.options.replicationLock.writerMutex.exclusiveLock(async () => {
515
444
  lockSpan.end();
516
445
  this.options.replicationLock.throwIfAborted();
517
446
  return callback();
518
447
  });
519
448
  }
520
- /** Single-document updates enforce ownership atomically, without a separate transaction. */
521
- async updateStreamMetadata(set, filter = {}, writeConcern = { w: 'majority' }) {
522
- await this.withWriterLock(() => this.writeStreamMetadata(set, filter, writeConcern));
449
+ /** Internal single-document update; the public operation already holds the mutex. */
450
+ async updateStreamMetadata(set, filter = {}, writeConcern = REPLICATION_WRITE_OPTIONS.writeConcern) {
451
+ await this.writeStreamMetadata(set, filter, writeConcern);
523
452
  }
524
453
  /** Caller must already be inside a fenced transaction holding the writer mutex. */
525
454
  async updateStreamMetadataInTransaction(set, filter = {}) {
@@ -528,70 +457,9 @@ export class MongoBucketBatch extends BaseObserver {
528
457
  async writeStreamMetadata(set, filter, writeConcern) {
529
458
  const result = await this.db.sync_rules.updateOne({ ...filter, ...MongoSyncRulesLock.ownerFilter(this.replicationStreamId, this.options.replicationLock) }, [{ $set: { ...set, ...MongoSyncRulesLock.heartbeatUpdate() } }], { session: this.session, ...(writeConcern == null ? {} : { writeConcern }) });
530
459
  if (result.matchedCount === 0) {
531
- throw new ReplicationAbortedError('Replication writer no longer owns the stream');
532
- }
533
- }
534
- async withReplicationTransaction(description, callback, finish, projection) {
535
- let flushTry = 0;
536
- const lastTry = Date.now() + 90000;
537
- const allocator = this.options.opIdAllocator;
538
- let lastOp = 0n;
539
- // Refill outside the publication transaction, before evaluating any rows.
540
- // Exhaustion remains a fallback for large batches or a newer stream head.
541
- this.options.replicationLock.throwIfAborted();
542
- await allocator.ensureCapacity();
543
- for (;;) {
544
- try {
545
- await this.withFencedTransaction(() => this.fence(this.session, projection), async (stream) => {
546
- flushTry += 1;
547
- if (flushTry % 10 == 0) {
548
- this.logger.info(`${description} - try ${flushTry}`);
549
- }
550
- if (flushTry > 20 && Date.now() > lastTry) {
551
- throw new ServiceError(ErrorCode.PSYNC_S1402, 'Max transaction tries exceeded');
552
- }
553
- // The fence already holds this stream document for the transaction.
554
- // A different writer may have used higher IDs since our last flush.
555
- const opSeq = allocator.sequence(this.persistedOpHead(stream));
556
- await callback(this.session, opSeq);
557
- lastOp = opSeq.last();
558
- if (!(await finish?.(stream, lastOp))) {
559
- const writes = this.db.createWriteBatch(this.session, { ordered: false });
560
- // Allow subclasses to persist additional flush-time state in the same transaction.
561
- this.onReplicationTransactionFlush(writes, lastOp);
562
- await writes.execute();
563
- }
564
- // Notifications and cleanup must wait until the transaction commits.
565
- });
566
- allocator.committed(lastOp);
567
- return;
568
- }
569
- catch (error) {
570
- if (!(error instanceof OpIdRangeExhausted)) {
571
- throw error;
572
- }
573
- // withTransaction has aborted every write. Reserve outside that transaction,
574
- // then replay evaluation from the same committed stream head. Existing
575
- // ranges can be reused on retry because no operation from this attempt committed.
576
- this.options.replicationLock.throwIfAborted();
577
- await allocator.reserve();
578
- }
460
+ throw new ReplicationAbortedError('Replication writer lost its lease or metadata target changed');
579
461
  }
580
462
  }
581
- /**
582
- * Called after a replication transaction has successfully committed, with the last persisted op id.
583
- *
584
- * v1 storage tracks this in memory to fold into the next checkpoint. v3 storage does not need it:
585
- * the stream-level `last_persisted_op` is already advanced durably by
586
- * {@link onReplicationTransactionFlush} or the combined checkpoint update within the same
587
- * transaction, and empty commits read it from the document.
588
- *
589
- * Keep calls to this adjacent to {@link withReplicationTransaction} usage - both must observe
590
- * every path that persists ops.
591
- */
592
- recordPersistedOp(_lastOp) {
593
- // No-op by default.
594
- }
595
463
  async [Symbol.asyncDispose]() {
596
464
  if (this.batch?.hasData() || this.write_checkpoint_batch.length > 0) {
597
465
  // We don't error here, since:
@@ -599,7 +467,7 @@ export class MongoBucketBatch extends BaseObserver {
599
467
  // 2. SuppressedError is messy to deal with.
600
468
  this.logger.warn('Disposing writer with unflushed changes');
601
469
  }
602
- await this.session.endSession();
470
+ // The lease owns the shared session and ends it after all writers stop.
603
471
  super.clearListeners();
604
472
  }
605
473
  async dispose() {
@@ -648,8 +516,10 @@ export class MongoBucketBatch extends BaseObserver {
648
516
  * Drop is equivalent to TRUNCATE, plus removing our record of the table.
649
517
  */
650
518
  async drop(sourceTables) {
651
- await this.truncate(sourceTables);
652
- const result = await this.flush();
519
+ return this.withWriterLock(() => this.dropInternal(sourceTables));
520
+ }
521
+ async dropInternal(sourceTables) {
522
+ await this.truncateInternal(sourceTables);
653
523
  await this.withTransaction(async () => {
654
524
  for (let table of sourceTables) {
655
525
  await this.db
@@ -658,16 +528,18 @@ export class MongoBucketBatch extends BaseObserver {
658
528
  }
659
529
  });
660
530
  await this.cleanupDroppedSourceTables(sourceTables);
661
- return result;
531
+ return null;
662
532
  }
663
533
  async truncate(sourceTables) {
664
- await this.flush();
534
+ return this.withWriterLock(() => this.truncateInternal(sourceTables));
535
+ }
536
+ async truncateInternal(sourceTables) {
537
+ await this.flushInternal();
665
538
  let last_op = null;
666
539
  for (let table of sourceTables) {
667
- last_op = await this.truncateSingle(table);
540
+ last_op = await this.truncateSingleInternal(table);
668
541
  }
669
542
  if (last_op) {
670
- this.recordPersistedOp(last_op);
671
543
  return {
672
544
  flushed_op: last_op
673
545
  };
@@ -677,45 +549,12 @@ export class MongoBucketBatch extends BaseObserver {
677
549
  }
678
550
  }
679
551
  async truncateSingle(sourceTable) {
680
- let last_op = null;
681
- // To avoid too large transactions, we limit the amount of data we delete per transaction.
682
- // Since we don't use the record data here, we don't have explicit size limits per batch.
683
- const BATCH_LIMIT = 2000;
684
- let lastBatchCount = BATCH_LIMIT;
685
- while (lastBatchCount == BATCH_LIMIT) {
686
- await this.withReplicationTransaction(`Truncate ${sourceTable.qualifiedName}`, async (session, opSeq) => {
687
- using evalSpan = this.tracer.span('evaluate');
688
- const sourceTableId = mongoTableId(sourceTable.id);
689
- const batch = await this.sourceRecordStore.loadTruncateBatch(session, sourceTableId, BATCH_LIMIT);
690
- const persistedBatch = this.createPersistedBatch(0);
691
- for (let value of batch) {
692
- persistedBatch.saveBucketData({
693
- op_seq: opSeq,
694
- before_buckets: value.buckets,
695
- evaluated: [],
696
- table: sourceTable,
697
- sourceKey: value.replicaId
698
- });
699
- persistedBatch.saveParameterData({
700
- op_seq: opSeq,
701
- existing_lookups: value.lookups,
702
- evaluated: [],
703
- sourceTable: sourceTable,
704
- sourceKey: value.replicaId
705
- });
706
- // Since this is not from streaming replication, we can do a hard delete
707
- persistedBatch.hardDeleteCurrentData(sourceTableId, value.replicaId);
708
- }
709
- evalSpan.end();
710
- using _ = this.tracer.span('storage', 'persist_flush');
711
- await persistedBatch.flush(session);
712
- lastBatchCount = batch.length;
713
- last_op = opSeq.last();
714
- });
715
- }
716
- return last_op;
552
+ return this.withWriterLock(() => this.truncateSingleInternal(sourceTable));
717
553
  }
718
554
  async updateTableProgress(table, progress) {
555
+ return this.withWriterLock(() => this.updateTableProgressInternal(table, progress));
556
+ }
557
+ async updateTableProgressInternal(table, progress) {
719
558
  const copy = table.clone();
720
559
  const snapshotStatus = {
721
560
  totalEstimatedCount: progress.totalEstimatedCount ?? copy.snapshotStatus?.totalEstimatedCount ?? 0,
@@ -736,18 +575,6 @@ export class MongoBucketBatch extends BaseObserver {
736
575
  });
737
576
  return copy;
738
577
  }
739
- async clearError(session) {
740
- await this.db.sync_rules.updateOne({
741
- _id: this.replicationStreamId
742
- }, {
743
- $set: {
744
- last_fatal_error: null,
745
- last_fatal_error_ts: null
746
- }
747
- }, {
748
- session
749
- });
750
- }
751
578
  /**
752
579
  * Gets relevant {@link HydratedEventDescriptor}s for the given {@link SourceTable}
753
580
  */