@powersync/service-module-mongodb-storage 0.23.2 → 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 +36 -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
@@ -39,6 +39,7 @@ import { MAX_ROW_SIZE } from './MongoBucketBatchShared.js';
39
39
  import { MongoIdSequence, OpIdRangeExhausted } from './MongoIdSequence.js';
40
40
  import { MongoOpIdAllocator } from './MongoOpIdAllocator.js';
41
41
  import { MongoParsedSyncConfigSet } from './MongoParsedSyncConfigSet.js';
42
+ import { REPLICATION_WRITE_OPTIONS } from './MongoReplicationOptions.js';
42
43
  import { MongoSyncRulesLock } from './MongoSyncRulesLock.js';
43
44
  import { MongoWriteBatch } from './MongoWriteBatch.js';
44
45
  import { OperationBatch, RecordOperation } from './OperationBatch.js';
@@ -48,7 +49,6 @@ import { createObjectStorageUsageWriterId } from './v3/object-storage/ObjectStor
48
49
  export interface MongoBucketBatchOptions {
49
50
  replicationLock: MongoSyncRulesLock;
50
51
  opIdAllocator: MongoOpIdAllocator;
51
- db: VersionedPowerSyncMongo;
52
52
  /**
53
53
  * The parsed sync config set for this batch.
54
54
  *
@@ -94,8 +94,8 @@ export abstract class MongoBucketBatch
94
94
  protected readonly options: MongoBucketBatchOptions;
95
95
  protected logger: Logger;
96
96
 
97
- private readonly client: mongo.MongoClient;
98
97
  readonly db: VersionedPowerSyncMongo;
98
+ /** Shared by this lease's writers; only use inside a serialized public operation. */
99
99
  public readonly session: mongo.ClientSession;
100
100
  protected readonly sync_rules: HydratedSyncConfig;
101
101
 
@@ -111,16 +111,16 @@ export abstract class MongoBucketBatch
111
111
  * via SourceTable.storeCurrentData. The effective per-record value is the conjunction of
112
112
  * the two.
113
113
  */
114
- private readonly storeCurrentData: boolean;
114
+ protected readonly storeCurrentData: boolean;
115
115
  public readonly skipExistingRows: boolean;
116
116
  protected readonly mapping: BucketDefinitionMapping;
117
117
  protected readonly objectStorageUsageWriterId = createObjectStorageUsageWriterId();
118
118
 
119
- private batch: OperationBatch | null = null;
119
+ protected batch: OperationBatch | null = null;
120
120
  protected write_checkpoint_batch: storage.CustomWriteCheckpointOptions[] = [];
121
- private markRecordUnavailable: BucketStorageMarkRecordUnavailable | undefined;
122
- private hooks: storage.StorageHooks | undefined;
123
- private clearedError = false;
121
+ protected readonly markRecordUnavailable: BucketStorageMarkRecordUnavailable | undefined;
122
+ protected hooks: storage.StorageHooks | undefined;
123
+ protected clearedError = false;
124
124
 
125
125
  protected tracer: PerformanceTracer<'storage' | 'evaluate'>;
126
126
 
@@ -151,11 +151,12 @@ export abstract class MongoBucketBatch
151
151
  ? options.replicationLock.signal
152
152
  : AbortSignal.any([options.signal, options.replicationLock.signal])
153
153
  };
154
- this.client = options.db.client;
155
- this.db = options.db;
154
+ // A MongoDB session can only be used with its originating client. Writers
155
+ // created through another factory must still use this lease's database.
156
+ this.db = options.replicationLock.db;
156
157
  this.replicationStreamId = options.replicationStreamId;
157
158
  this.resumeFromLsn = options.resumeFromLsn;
158
- this.session = this.client.startSession();
159
+ this.session = options.replicationLock.session;
159
160
  this.replicationStreamName = options.replicationStreamName;
160
161
  this.sync_rules = options.parsedSyncConfig.hydratedSyncConfig;
161
162
  this.storeCurrentData = options.storeCurrentData;
@@ -175,7 +176,11 @@ export abstract class MongoBucketBatch
175
176
  });
176
177
  }
177
178
 
178
- abstract resolveTables(options: storage.ResolveTablesOptions): Promise<storage.ResolveTablesResult>;
179
+ async resolveTables(options: storage.ResolveTablesOptions): Promise<storage.ResolveTablesResult> {
180
+ return this.withWriterLock(() => this.resolveTablesInternal(options));
181
+ }
182
+
183
+ protected abstract resolveTablesInternal(options: storage.ResolveTablesOptions): Promise<storage.ResolveTablesResult>;
179
184
 
180
185
  protected abstract createPersistedBatch(writtenSize: number): PersistedBatch;
181
186
 
@@ -183,31 +188,73 @@ export abstract class MongoBucketBatch
183
188
 
184
189
  protected abstract cleanupDroppedSourceTables(sourceTables: storage.SourceTable[]): Promise<void>;
185
190
 
186
- abstract commit(lsn: string, options?: storage.BucketBatchCommitOptions): Promise<storage.CheckpointResult>;
191
+ async commit(lsn: string, options?: storage.BucketBatchCommitOptions): Promise<storage.CheckpointResult> {
192
+ return this.withWriterLock(() => this.commitInternal(lsn, options));
193
+ }
194
+
195
+ protected abstract commitInternal(
196
+ lsn: string,
197
+ options?: storage.BucketBatchCommitOptions
198
+ ): Promise<storage.CheckpointResult>;
199
+
200
+ async keepalive(lsn: string): Promise<storage.CheckpointResult> {
201
+ return this.commit(lsn, { createEmptyCheckpoints: true });
202
+ }
203
+
204
+ async setResumeLsn(lsn: string): Promise<void> {
205
+ return this.withWriterLock(() => this.setResumeLsnInternal(lsn));
206
+ }
207
+
208
+ protected abstract setResumeLsnInternal(lsn: string): Promise<void>;
187
209
 
188
- abstract keepalive(lsn: string): Promise<storage.CheckpointResult>;
210
+ async getSourceTableStatus(table: storage.SourceTable): Promise<storage.SourceTable | null> {
211
+ return this.withWriterLock(() => this.getSourceTableStatusInternal(table));
212
+ }
189
213
 
190
- abstract setResumeLsn(lsn: string): Promise<void>;
214
+ protected abstract getSourceTableStatusInternal(table: storage.SourceTable): Promise<storage.SourceTable | null>;
191
215
 
192
- abstract getSourceTableStatus(table: storage.SourceTable): Promise<storage.SourceTable | null>;
216
+ async markAllSnapshotDone(no_checkpoint_before_lsn: string): Promise<void> {
217
+ return this.withWriterLock(() => this.markAllSnapshotDoneInternal(no_checkpoint_before_lsn));
218
+ }
193
219
 
194
- abstract markAllSnapshotDone(no_checkpoint_before_lsn: string): Promise<void>;
220
+ protected abstract markAllSnapshotDoneInternal(no_checkpoint_before_lsn: string): Promise<void>;
195
221
 
196
- abstract markSnapshotDone(no_checkpoint_before_lsn: string, options?: { throwOnConflict?: boolean }): Promise<void>;
222
+ async markSnapshotDone(no_checkpoint_before_lsn: string, options?: { throwOnConflict?: boolean }): Promise<void> {
223
+ return this.withWriterLock(() => this.markSnapshotDoneInternal(no_checkpoint_before_lsn, options));
224
+ }
197
225
 
198
- abstract markTableSnapshotRequired(table: storage.SourceTable): Promise<void>;
226
+ protected abstract markSnapshotDoneInternal(
227
+ no_checkpoint_before_lsn: string,
228
+ options?: { throwOnConflict?: boolean }
229
+ ): Promise<void>;
199
230
 
200
- abstract markTableSnapshotDone(
231
+ async markTableSnapshotRequired(table: storage.SourceTable): Promise<void> {
232
+ return this.withWriterLock(() => this.markTableSnapshotRequiredInternal(table));
233
+ }
234
+
235
+ protected abstract markTableSnapshotRequiredInternal(table: storage.SourceTable): Promise<void>;
236
+
237
+ async markTableSnapshotDone(
201
238
  tables: storage.SourceTable[],
202
239
  no_checkpoint_before_lsn?: string
203
- ): Promise<storage.SourceTable[]>;
240
+ ): Promise<storage.SourceTable[]> {
241
+ return this.withWriterLock(() => this.markTableSnapshotDoneInternal(tables, no_checkpoint_before_lsn));
242
+ }
204
243
 
205
- protected abstract batchCreateCustomWriteCheckpoints(session: mongo.ClientSession, opId: InternalOpId): Promise<void>;
244
+ protected abstract markTableSnapshotDoneInternal(
245
+ tables: storage.SourceTable[],
246
+ no_checkpoint_before_lsn?: string
247
+ ): Promise<storage.SourceTable[]>;
206
248
 
207
249
  /** Perform any version-specific setup required before writing custom checkpoints. */
208
250
  protected async prepareCustomWriteCheckpoints(): Promise<void> {}
209
251
 
210
252
  async flush(options?: storage.BatchBucketFlushOptions): Promise<storage.FlushedResult | null> {
253
+ return this.withWriterLock(() => this.flushInternal(options));
254
+ }
255
+
256
+ /** Internal helpers require the public operation to hold the writer mutex. */
257
+ protected async flushInternal(options?: storage.BatchBucketFlushOptions): Promise<storage.FlushedResult | null> {
211
258
  let result: storage.FlushedResult | null = null;
212
259
  // One flush may be split over multiple transactions.
213
260
  // Each flushInner() is one transaction.
@@ -222,7 +269,7 @@ export abstract class MongoBucketBatch
222
269
 
223
270
  /** Publish the checkpoint in the final flush transaction, or use the empty-commit path. */
224
271
  protected async flushAndCommit<T>(
225
- checkpoint: (stream: SyncRuleDocumentBase, lastOp: InternalOpId) => Promise<T>,
272
+ checkpoint: (stream: SyncRuleDocumentBase, lastOp: InternalOpId, writes?: MongoWriteBatch) => Promise<T>,
226
273
  commitWithoutFlush: () => Promise<T>,
227
274
  options?: storage.BatchBucketFlushOptions,
228
275
  projection?: mongo.Document
@@ -234,8 +281,8 @@ export abstract class MongoBucketBatch
234
281
  while (this.batch != null || this.write_checkpoint_batch.length > 0) {
235
282
  await this.flushInner(
236
283
  options,
237
- async (stream, lastOp) => {
238
- result = await checkpoint(stream, lastOp);
284
+ async (stream, lastOp, writes) => {
285
+ result = await checkpoint(stream, lastOp, writes);
239
286
  },
240
287
  projection
241
288
  );
@@ -243,200 +290,26 @@ export abstract class MongoBucketBatch
243
290
  return result;
244
291
  }
245
292
 
246
- private async flushInner(
293
+ protected abstract flushInner(
247
294
  options?: storage.BatchBucketFlushOptions,
248
- checkpoint?: (stream: SyncRuleDocumentBase, lastOp: InternalOpId) => Promise<void>,
295
+ checkpoint?: (stream: SyncRuleDocumentBase, lastOp: InternalOpId, writes?: MongoWriteBatch) => Promise<void>,
249
296
  projection?: mongo.Document
250
- ): Promise<storage.FlushedResult | null> {
251
- const batch = this.batch;
252
- let last_op: InternalOpId | null = null;
253
- let resumeBatch: OperationBatch | null = null;
254
- let clearedError = false;
255
-
256
- using _ = this.tracer.span('storage', 'flush');
257
-
258
- await this.hooks?.beforeBatchFlush?.(this);
259
- if (this.write_checkpoint_batch.length > 0) {
260
- // Collection/index creation cannot run inside the replication transaction.
261
- await this.prepareCustomWriteCheckpoints();
262
- }
263
-
264
- await this.withReplicationTransaction(
265
- `Flushing ${batch?.length ?? 0} ops`,
266
- async (session, opSeq) => {
267
- clearedError = false;
268
- if (batch != null) {
269
- const result = await this.replicateBatch(session, batch, opSeq, options);
270
- resumeBatch = result.resumeBatch;
271
- clearedError ||= result.clearedError;
272
- }
273
-
274
- if (this.write_checkpoint_batch.length > 0) {
275
- this.logger.info(`Writing ${this.write_checkpoint_batch.length} custom write checkpoints`);
276
- await this.batchCreateCustomWriteCheckpoints(session, opSeq.next());
277
- }
278
-
279
- last_op = opSeq.last();
280
- },
281
- async (stream, lastOp) => {
282
- if (checkpoint != null && resumeBatch == null) {
283
- // The checkpoint must also persist the head, including when visibility is blocked.
284
- await checkpoint(stream, lastOp);
285
- return true;
286
- }
287
- return false;
288
- },
289
- projection
290
- );
291
-
292
- // Keep checkpoints available if the transaction retries after writing them.
293
- this.write_checkpoint_batch = [];
294
-
295
- if (clearedError) {
296
- this.clearedError = true;
297
- }
298
-
299
- // null if done, set if we need another flush
300
- this.batch = resumeBatch;
301
-
302
- if (last_op == null) {
303
- throw new ReplicationAssertionError('Unexpected last_op == null');
304
- }
305
-
306
- this.recordPersistedOp(last_op);
307
- this.last_flushed_op = last_op;
308
- await this.hooks?.afterBatchFlush?.(this);
309
- return { flushed_op: last_op };
310
- }
311
-
312
- private async replicateBatch(
313
- session: mongo.ClientSession,
314
- batch: OperationBatch,
315
- op_seq: MongoIdSequence,
316
- options?: storage.BucketBatchCommitOptions
317
- ): Promise<{ resumeBatch: OperationBatch | null; clearedError: boolean }> {
318
- let sizes: Map<string, number> | undefined = undefined;
319
- using _ = this.tracer.span('storage', 'replicate_batch');
320
- // Only look up current_data sizes if the batch stores current_data and at least one
321
- // table in it does too (per-table can disable it, e.g. Postgres REPLICA IDENTITY FULL).
322
- const anyTableStoresCurrentData =
323
- this.storeCurrentData && batch.batch.some((r) => r.record.sourceTable.storeCurrentData);
324
- if (anyTableStoresCurrentData && !this.skipExistingRows) {
325
- // We skip this step if no tables store current_data, since the sizes will
326
- // always be small in that case.
327
-
328
- // With skipExistingRows, we don't load the full documents into memory,
329
- // so we can also skip the size lookup step.
330
-
331
- // Find sizes of current_data documents, to assist in intelligent batching without
332
- // exceeding memory limits.
333
- //
334
- // A previous attempt tried to do batching by the results of the current_data query
335
- // (automatically limited to 48MB(?) per batch by MongoDB). The issue is that it changes
336
- // the order of processing, which then becomes really tricky to manage.
337
- // This now takes 2+ queries, but doesn't have any issues with order of operations.
338
- // Within this branch this.storeCurrentData is true, so the per-table flag is the
339
- // effective value - only look up sizes for tables that actually store current_data.
340
- const sizeLookups = batch.batch
341
- .filter((r) => r.record.sourceTable.storeCurrentData)
342
- .map((r) => ({
343
- sourceTableId: mongoTableId(r.record.sourceTable.id),
344
- replicaId: r.beforeId
345
- }));
346
-
347
- sizes = await this.sourceRecordStore.loadSizes(session, sizeLookups);
348
- }
349
-
350
- // If set, we need to start a new transaction with this batch.
351
- let resumeBatch: OperationBatch | null = null;
352
- let transactionSize = 0;
297
+ ): Promise<storage.FlushedResult | null>;
353
298
 
354
- let didFlush = false;
355
-
356
- // Now batch according to the sizes
357
- // This is a single batch if storeCurrentData == false
358
- for await (let b of batch.batched(sizes)) {
359
- if (resumeBatch) {
360
- for (let op of b) {
361
- resumeBatch.push(op);
362
- }
363
- continue;
364
- }
365
- using lookupSpan = this.tracer.span('storage', 'lookup');
366
- const lookups = b.map((r) => ({
367
- sourceTableId: mongoTableId(r.record.sourceTable.id),
368
- replicaId: r.beforeId
369
- }));
370
- let sourceRecordLookup = await this.sourceRecordStore.loadDocuments(session, lookups, this.skipExistingRows);
371
- lookupSpan.end();
372
-
373
- let persistedBatch: PersistedBatch | null = this.createPersistedBatch(transactionSize);
374
-
375
- // The current code structure makes it tricky to cleanly split this span from the one
376
- // where fluhsing. So we manually end and re-create this span whenever we flush.
377
- let evalSpan = this.tracer.span('evaluate');
378
- for (let op of b) {
379
- if (resumeBatch) {
380
- resumeBatch.push(op);
381
- continue;
382
- }
383
- const sourceRecord = sourceRecordLookup.get(op.internalBeforeKey) ?? null;
384
- if (sourceRecord != null) {
385
- // If it will be used again later, it will be set again using nextData below
386
- sourceRecordLookup.delete(op.internalBeforeKey);
387
- }
388
- const nextData = this.saveOperation(persistedBatch!, op, sourceRecord, op_seq);
389
- if (nextData != null) {
390
- // Update our current_data and size cache
391
- sourceRecordLookup.set(op.internalAfterKey!, nextData);
392
- sizes?.set(op.internalAfterKey!, nextData.data?.length() ?? 0);
393
- }
394
-
395
- if (persistedBatch!.shouldFlushTransaction()) {
396
- evalSpan.end();
397
- // Transaction is getting big.
398
- // Flush, and resume in a new transaction.
399
- using persistSpan = this.tracer.span('storage', 'persist_flush');
400
- const { flushedAny } = await persistedBatch!.flush(this.session, options);
401
-
402
- didFlush ||= flushedAny;
403
- persistedBatch = null;
404
- // Computing our current progress is a little tricky here, since
405
- // we're stopping in the middle of a batch.
406
- // We create a new batch, and push any remaining operations to it.
407
- resumeBatch = new OperationBatch();
408
- persistSpan.end();
409
- evalSpan = this.tracer.span('evaluate');
410
- }
411
- }
412
- evalSpan.end();
413
-
414
- if (persistedBatch) {
415
- transactionSize = persistedBatch.currentSize;
416
- using _ = this.tracer.span('storage', 'persist_flush');
417
- const { flushedAny } = await persistedBatch.flush(this.session, options);
418
- didFlush ||= flushedAny;
419
- }
420
- }
421
-
422
- const clearedError = didFlush && !this.clearedError;
423
- if (clearedError) {
424
- // No need to clear an error more than once per batch, since an error would always result in restarting the batch.
425
- using _ = this.tracer.span('storage', 'clear_error');
426
- await this.clearError(this.session);
427
- }
428
-
429
- return {
430
- resumeBatch: resumeBatch?.hasData() ? resumeBatch : null,
431
- clearedError
432
- };
433
- }
434
-
435
- private saveOperation(
299
+ /**
300
+ * Evaluate one source operation and append its storage writes to batch.
301
+ *
302
+ * Diagnostics run during evaluation. V1 calls markRecordUnavailable immediately;
303
+ * v3/v4 supply a collector and deliver repair notifications only after a
304
+ * confirmed publication. A failed row attempt may have registered a repair,
305
+ * so its caller must discard that row's collected notification on failure.
306
+ */
307
+ protected saveOperation(
436
308
  batch: PersistedBatch,
437
309
  operation: RecordOperation,
438
310
  sourceRecord: LoadedSourceRecord | null,
439
- opSeq: MongoIdSequence
311
+ opSeq: MongoIdSequence,
312
+ onRecordUnavailable: BucketStorageMarkRecordUnavailable | undefined
440
313
  ) {
441
314
  const record = operation.record;
442
315
  const beforeId = operation.beforeId;
@@ -474,19 +347,6 @@ export abstract class MongoBucketBatch
474
347
  // Not an error if we re-apply a transaction
475
348
  existing_buckets = [];
476
349
  existing_lookups = [];
477
- if (!isCompleteRow(storeCurrentData, after!)) {
478
- if (this.markRecordUnavailable != null) {
479
- // This will trigger a "resnapshot" of the record.
480
- // This is not relevant if storeCurrentData is false, since we'll get the full row
481
- // directly in the replication stream.
482
- this.markRecordUnavailable(record);
483
- } else {
484
- // Log to help with debugging if there was a consistency issue
485
- this.logger.warn(
486
- `Cannot find previous record for update on ${record.sourceTable.qualifiedName}: ${beforeId} / ${record.before?.id}`
487
- );
488
- }
489
- }
490
350
  } else {
491
351
  existing_buckets = result.buckets;
492
352
  existing_lookups = result.lookups;
@@ -495,6 +355,24 @@ export abstract class MongoBucketBatch
495
355
  after = storage.mergeToast<SqliteValue>(after!, data);
496
356
  }
497
357
  }
358
+ if (!isCompleteRow(storeCurrentData, after!)) {
359
+ if (onRecordUnavailable != null) {
360
+ // This will trigger a "resnapshot" of the record.
361
+ // This is not relevant if storeCurrentData is false, since we'll get the full row
362
+ // directly in the replication stream.
363
+ // Check after merging, including existing incomplete rows/tombstones.
364
+ // A flush can commit before a process dies or notification delivery
365
+ // fails. Its source position has not advanced, so WAL replay must
366
+ // request repair again rather than treating mere record existence as
367
+ // proof that the missing TOAST values have been recovered.
368
+ onRecordUnavailable(record);
369
+ } else if (result == null) {
370
+ // Log to help with debugging if there was a consistency issue
371
+ this.logger.warn(
372
+ `Cannot find previous record for update on ${record.sourceTable.qualifiedName}: ${beforeId} / ${record.before?.id}`
373
+ );
374
+ }
375
+ }
498
376
  } else if (record.tag == SaveOperationTag.DELETE) {
499
377
  const result = sourceRecord;
500
378
  if (result == null) {
@@ -504,7 +382,7 @@ export abstract class MongoBucketBatch
504
382
  // Log to help with debugging if there was a consistency issue.
505
383
  // Gate on the batch-level flag: FULL tables (per-record flag false) still get a
506
384
  // current_data entry, so a missing record on DELETE is meaningful for them too.
507
- if (this.storeCurrentData && this.markRecordUnavailable == null) {
385
+ if (this.storeCurrentData && onRecordUnavailable == null) {
508
386
  this.logger.warn(
509
387
  `Cannot find previous record for delete on ${record.sourceTable.qualifiedName}: ${beforeId} / ${record.before?.id}`
510
388
  );
@@ -707,42 +585,73 @@ export abstract class MongoBucketBatch
707
585
  return this.withFencedTransaction(() => this.fence(session), cb, session);
708
586
  }
709
587
 
710
- /** The first operation must modify the stream document conditional on lease ownership. */
588
+ /**
589
+ * Internal transaction helper: the public writer operation already holds the
590
+ * mutex. The first operation must modify the stream document conditional on
591
+ * lease ownership. This database fence is required on every retry even though
592
+ * local writers are serialized; another process may have taken over the lease.
593
+ */
711
594
  protected async withFencedTransaction<S, T>(
712
595
  acquireFence: () => Promise<S>,
713
596
  cb: (stream: S) => Promise<T>,
714
- session = this.session
597
+ session = this.session,
598
+ description?: string
715
599
  ): Promise<T> {
716
- return this.withWriterLock(() =>
717
- session.withTransaction(
718
- async () => {
600
+ this.options.replicationLock.throwIfAborted();
601
+ let attempts = 0;
602
+ // Includes driver commit retries and the delays between transaction attempts.
603
+ using _transactionSpan = this.tracer.span('storage', 'transaction');
604
+ const result = await session.withTransaction(
605
+ async () => {
606
+ attempts++;
607
+ // One callback invocation per transaction attempt. Commit happens after
608
+ // this span; command monitoring is needed to measure the full wire lifetime.
609
+ using attemptSpan = this.tracer.span('storage', 'transaction_body');
610
+ try {
719
611
  const stream = await acquireFence();
720
- try {
721
- const result = await cb(stream);
722
- this.options.replicationLock.throwIfAborted();
723
- return result;
724
- } catch (e: unknown) {
725
- if (e instanceof OpIdRangeExhausted) {
726
- throw e;
727
- }
728
- if (e instanceof mongo.MongoError && e.hasErrorLabel('TransientTransactionError')) {
729
- // Likely write conflict caused by concurrent writes to this replication stream.
730
- } else {
731
- this.logger.warn('Transaction error', e as Error);
732
- }
733
- const delay = Math.random() * 50;
734
- using _ = this.tracer.span('storage', 'retry_delay');
735
- await timers.setTimeout(delay);
612
+ const result = await cb(stream);
613
+ this.options.replicationLock.throwIfAborted();
614
+ return result;
615
+ } catch (e: unknown) {
616
+ attemptSpan.end();
617
+ if (e instanceof OpIdRangeExhausted) {
736
618
  throw e;
737
619
  }
738
- },
739
- { maxCommitTimeMS: 10000, writeConcern: { w: 'majority' } }
740
- )
620
+ if (e instanceof mongo.MongoError && e.hasErrorLabel('TransientTransactionError')) {
621
+ // Log every tenth attempt, including failures of the fence, without
622
+ // logging a stack or bulk result for every driver retry.
623
+ if ((attempts + 1) % 10 === 0) {
624
+ this.logger.info(`${description ?? 'Replication transaction'} - try ${attempts + 1}`, {
625
+ error: e.message,
626
+ code: e.code,
627
+ codeName: e instanceof mongo.MongoServerError ? e.codeName : undefined,
628
+ errorLabels: e.errorLabels
629
+ });
630
+ }
631
+ } else {
632
+ this.logger.warn('Transaction error', e as Error);
633
+ }
634
+ const delay = Math.random() * 50;
635
+ using _ = this.tracer.span('storage', 'retry_delay');
636
+ await timers.setTimeout(delay);
637
+ throw e;
638
+ }
639
+ },
640
+ { maxCommitTimeMS: 10000, ...REPLICATION_WRITE_OPTIONS }
741
641
  );
642
+ return result;
742
643
  }
743
644
 
645
+ /**
646
+ * Public database operations acquire the lease's mutex once, then call only
647
+ * internal helpers until they finish. Those helpers must never call a public
648
+ * database operation or reacquire this mutex. All session use, including reads,
649
+ * belongs inside this boundary so it cannot join another writer's transaction.
650
+ * Transaction helpers still fence database ownership on every attempt.
651
+ */
744
652
  private async withWriterLock<T>(callback: () => Promise<T>): Promise<T> {
745
653
  using lockSpan = this.tracer.span('storage', 'internal_lock');
654
+ this.options.replicationLock.throwIfAborted();
746
655
  return this.options.replicationLock.writerMutex.exclusiveLock(async () => {
747
656
  lockSpan.end();
748
657
  this.options.replicationLock.throwIfAborted();
@@ -750,13 +659,13 @@ export abstract class MongoBucketBatch
750
659
  });
751
660
  }
752
661
 
753
- /** Single-document updates enforce ownership atomically, without a separate transaction. */
662
+ /** Internal single-document update; the public operation already holds the mutex. */
754
663
  protected async updateStreamMetadata(
755
664
  set: mongo.Document,
756
665
  filter: mongo.Document = {},
757
- writeConcern: mongo.WriteConcernSettings = { w: 'majority' }
666
+ writeConcern: mongo.WriteConcernSettings = REPLICATION_WRITE_OPTIONS.writeConcern
758
667
  ): Promise<void> {
759
- await this.withWriterLock(() => this.writeStreamMetadata(set, filter, writeConcern));
668
+ await this.writeStreamMetadata(set, filter, writeConcern);
760
669
  }
761
670
 
762
671
  /** Caller must already be inside a fenced transaction holding the writer mutex. */
@@ -775,63 +684,7 @@ export abstract class MongoBucketBatch
775
684
  { session: this.session, ...(writeConcern == null ? {} : { writeConcern }) }
776
685
  );
777
686
  if (result.matchedCount === 0) {
778
- throw new ReplicationAbortedError('Replication writer no longer owns the stream');
779
- }
780
- }
781
-
782
- private async withReplicationTransaction(
783
- description: string,
784
- callback: (session: mongo.ClientSession, opSeq: MongoIdSequence) => Promise<void>,
785
- finish?: (stream: SyncRuleDocumentBase, lastOp: InternalOpId) => Promise<boolean>,
786
- projection?: mongo.Document
787
- ): Promise<void> {
788
- let flushTry = 0;
789
- const lastTry = Date.now() + 90000;
790
- const allocator = this.options.opIdAllocator;
791
- let lastOp = 0n;
792
- // Refill outside the publication transaction, before evaluating any rows.
793
- // Exhaustion remains a fallback for large batches or a newer stream head.
794
- this.options.replicationLock.throwIfAborted();
795
- await allocator.ensureCapacity();
796
- for (;;) {
797
- try {
798
- await this.withFencedTransaction(
799
- () => this.fence(this.session, projection),
800
- async (stream) => {
801
- flushTry += 1;
802
- if (flushTry % 10 == 0) {
803
- this.logger.info(`${description} - try ${flushTry}`);
804
- }
805
- if (flushTry > 20 && Date.now() > lastTry) {
806
- throw new ServiceError(ErrorCode.PSYNC_S1402, 'Max transaction tries exceeded');
807
- }
808
- // The fence already holds this stream document for the transaction.
809
- // A different writer may have used higher IDs since our last flush.
810
- const opSeq = allocator.sequence(this.persistedOpHead(stream));
811
- await callback(this.session, opSeq);
812
- lastOp = opSeq.last();
813
-
814
- if (!(await finish?.(stream, lastOp))) {
815
- const writes = this.db.createWriteBatch(this.session, { ordered: false });
816
- // Allow subclasses to persist additional flush-time state in the same transaction.
817
- this.onReplicationTransactionFlush(writes, lastOp);
818
- await writes.execute();
819
- }
820
- // Notifications and cleanup must wait until the transaction commits.
821
- }
822
- );
823
- allocator.committed(lastOp);
824
- return;
825
- } catch (error) {
826
- if (!(error instanceof OpIdRangeExhausted)) {
827
- throw error;
828
- }
829
- // withTransaction has aborted every write. Reserve outside that transaction,
830
- // then replay evaluation from the same committed stream head. Existing
831
- // ranges can be reused on retry because no operation from this attempt committed.
832
- this.options.replicationLock.throwIfAborted();
833
- await allocator.reserve();
834
- }
687
+ throw new ReplicationAbortedError('Replication writer lost its lease or metadata target changed');
835
688
  }
836
689
  }
837
690
 
@@ -841,21 +694,6 @@ export abstract class MongoBucketBatch
841
694
  /** Advance the persisted head in the same transaction as the operations. */
842
695
  protected abstract onReplicationTransactionFlush(writes: MongoWriteBatch, lastOp: InternalOpId): void;
843
696
 
844
- /**
845
- * Called after a replication transaction has successfully committed, with the last persisted op id.
846
- *
847
- * v1 storage tracks this in memory to fold into the next checkpoint. v3 storage does not need it:
848
- * the stream-level `last_persisted_op` is already advanced durably by
849
- * {@link onReplicationTransactionFlush} or the combined checkpoint update within the same
850
- * transaction, and empty commits read it from the document.
851
- *
852
- * Keep calls to this adjacent to {@link withReplicationTransaction} usage - both must observe
853
- * every path that persists ops.
854
- */
855
- protected recordPersistedOp(_lastOp: InternalOpId): void {
856
- // No-op by default.
857
- }
858
-
859
697
  async [Symbol.asyncDispose]() {
860
698
  if (this.batch?.hasData() || this.write_checkpoint_batch.length > 0) {
861
699
  // We don't error here, since:
@@ -863,7 +701,7 @@ export abstract class MongoBucketBatch
863
701
  // 2. SuppressedError is messy to deal with.
864
702
  this.logger.warn('Disposing writer with unflushed changes');
865
703
  }
866
- await this.session.endSession();
704
+ // The lease owns the shared session and ends it after all writers stop.
867
705
  super.clearListeners();
868
706
  }
869
707
 
@@ -921,8 +759,11 @@ export abstract class MongoBucketBatch
921
759
  * Drop is equivalent to TRUNCATE, plus removing our record of the table.
922
760
  */
923
761
  async drop(sourceTables: storage.SourceTable[]): Promise<storage.FlushedResult | null> {
924
- await this.truncate(sourceTables);
925
- const result = await this.flush();
762
+ return this.withWriterLock(() => this.dropInternal(sourceTables));
763
+ }
764
+
765
+ protected async dropInternal(sourceTables: storage.SourceTable[]): Promise<storage.FlushedResult | null> {
766
+ await this.truncateInternal(sourceTables);
926
767
 
927
768
  await this.withTransaction(async () => {
928
769
  for (let table of sourceTables) {
@@ -933,19 +774,22 @@ export abstract class MongoBucketBatch
933
774
  });
934
775
 
935
776
  await this.cleanupDroppedSourceTables(sourceTables);
936
- return result;
777
+ return null;
937
778
  }
938
779
 
939
780
  async truncate(sourceTables: storage.SourceTable[]): Promise<storage.FlushedResult | null> {
940
- await this.flush();
781
+ return this.withWriterLock(() => this.truncateInternal(sourceTables));
782
+ }
783
+
784
+ protected async truncateInternal(sourceTables: storage.SourceTable[]): Promise<storage.FlushedResult | null> {
785
+ await this.flushInternal();
941
786
 
942
787
  let last_op: InternalOpId | null = null;
943
788
  for (let table of sourceTables) {
944
- last_op = await this.truncateSingle(table);
789
+ last_op = await this.truncateSingleInternal(table);
945
790
  }
946
791
 
947
792
  if (last_op) {
948
- this.recordPersistedOp(last_op);
949
793
  return {
950
794
  flushed_op: last_op
951
795
  };
@@ -955,53 +799,19 @@ export abstract class MongoBucketBatch
955
799
  }
956
800
 
957
801
  async truncateSingle(sourceTable: storage.SourceTable): Promise<InternalOpId> {
958
- let last_op: InternalOpId | null = null;
959
-
960
- // To avoid too large transactions, we limit the amount of data we delete per transaction.
961
- // Since we don't use the record data here, we don't have explicit size limits per batch.
962
- const BATCH_LIMIT = 2000;
963
-
964
- let lastBatchCount = BATCH_LIMIT;
965
- while (lastBatchCount == BATCH_LIMIT) {
966
- await this.withReplicationTransaction(`Truncate ${sourceTable.qualifiedName}`, async (session, opSeq) => {
967
- using evalSpan = this.tracer.span('evaluate');
968
- const sourceTableId = mongoTableId(sourceTable.id);
969
- const batch = await this.sourceRecordStore.loadTruncateBatch(session, sourceTableId, BATCH_LIMIT);
970
- const persistedBatch = this.createPersistedBatch(0);
971
-
972
- for (let value of batch) {
973
- persistedBatch.saveBucketData({
974
- op_seq: opSeq,
975
- before_buckets: value.buckets,
976
- evaluated: [],
977
- table: sourceTable,
978
- sourceKey: value.replicaId
979
- });
980
- persistedBatch.saveParameterData({
981
- op_seq: opSeq,
982
- existing_lookups: value.lookups,
983
- evaluated: [],
984
- sourceTable: sourceTable,
985
- sourceKey: value.replicaId
986
- });
987
-
988
- // Since this is not from streaming replication, we can do a hard delete
989
- persistedBatch.hardDeleteCurrentData(sourceTableId, value.replicaId);
990
- }
991
- evalSpan.end();
992
-
993
- using _ = this.tracer.span('storage', 'persist_flush');
994
- await persistedBatch.flush(session);
995
- lastBatchCount = batch.length;
802
+ return this.withWriterLock(() => this.truncateSingleInternal(sourceTable));
803
+ }
996
804
 
997
- last_op = opSeq.last();
998
- });
999
- }
805
+ protected abstract truncateSingleInternal(sourceTable: storage.SourceTable): Promise<InternalOpId>;
1000
806
 
1001
- return last_op!;
807
+ async updateTableProgress(
808
+ table: storage.SourceTable,
809
+ progress: Partial<storage.TableSnapshotStatus>
810
+ ): Promise<storage.SourceTable> {
811
+ return this.withWriterLock(() => this.updateTableProgressInternal(table, progress));
1002
812
  }
1003
813
 
1004
- async updateTableProgress(
814
+ private async updateTableProgressInternal(
1005
815
  table: storage.SourceTable,
1006
816
  progress: Partial<storage.TableSnapshotStatus>
1007
817
  ): Promise<storage.SourceTable> {
@@ -1032,23 +842,6 @@ export abstract class MongoBucketBatch
1032
842
  return copy;
1033
843
  }
1034
844
 
1035
- protected async clearError(session: mongo.ClientSession): Promise<void> {
1036
- await this.db.sync_rules.updateOne(
1037
- {
1038
- _id: this.replicationStreamId
1039
- },
1040
- {
1041
- $set: {
1042
- last_fatal_error: null,
1043
- last_fatal_error_ts: null
1044
- }
1045
- },
1046
- {
1047
- session
1048
- }
1049
- );
1050
- }
1051
-
1052
845
  /**
1053
846
  * Gets relevant {@link HydratedEventDescriptor}s for the given {@link SourceTable}
1054
847
  */