@powersync/service-module-mongodb-storage 0.23.2 → 0.24.1

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 +45 -0
  2. package/LICENSE +201 -67
  3. package/dist/storage/MongoBucketStorage.d.ts +1 -0
  4. package/dist/storage/MongoBucketStorage.js +45 -83
  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 +55 -122
  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 +101 -2
  99. package/tsconfig.tsbuildinfo +1 -1
@@ -1,16 +1,24 @@
1
1
  import * as lib_mongo from '@powersync/lib-service-mongodb';
2
- import { ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
2
+ import {
3
+ ErrorCode,
4
+ ReplicationAbortedError,
5
+ ReplicationAssertionError,
6
+ ServiceAssertionError,
7
+ ServiceError
8
+ } from '@powersync/lib-services-framework';
3
9
  import { InternalOpId, storage } from '@powersync/service-core';
4
10
  import { EventDefinitionId } from '@powersync/service-sync-rules';
5
11
  import * as bson from 'bson';
6
12
  import { mongoTableId } from '../../../utils/util.js';
7
13
  import { canCheckpointState } from '../CheckpointState.js';
8
14
  import { MongoBucketBatch, MongoBucketBatchOptions } from '../MongoBucketBatch.js';
15
+ import { OpIdRangeExhausted } from '../MongoIdSequence.js';
9
16
  import { MongoParsedSyncConfigSet } from '../MongoParsedSyncConfigSet.js';
10
17
  import { MongoWriteBatch } from '../MongoWriteBatch.js';
18
+ import { OperationBatch } from '../OperationBatch.js';
11
19
  import { stopReplicationStreamPipeline } from '../SyncRuleStateUpdate.js';
12
- import { PersistedBatch } from '../common/PersistedBatch.js';
13
- import { SourceRecordStore } from '../common/SourceRecordStore.js';
20
+ import { PreparedPublication } from '../common/PreparedPublication.js';
21
+ import { LoadedSourceRecord } from '../common/SourceRecordStore.js';
14
22
  import { SyncRuleDocumentBase } from '../models.js';
15
23
  import { PersistedBatchV3 } from './PersistedBatchV3.js';
16
24
  import { SourceRecordStoreV3 } from './SourceRecordStoreV3.js';
@@ -28,7 +36,7 @@ import {
28
36
  export class MongoBucketBatchV3 extends MongoBucketBatch {
29
37
  declare readonly db: VersionedPowerSyncMongoV3;
30
38
 
31
- private readonly store: SourceRecordStore;
39
+ private readonly store: SourceRecordStoreV3;
32
40
  /** Avoid repeating idempotent createIndexes calls for every checkpoint flush. */
33
41
  private readonly initializedCustomCheckpointEventIds = new Set<EventDefinitionId>();
34
42
  // Used only to validate supplied checkpoint ids; it does not initialize collections.
@@ -48,7 +56,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
48
56
  this.store = new SourceRecordStoreV3(this.db, this.replicationStreamId, this.mapping);
49
57
  }
50
58
 
51
- protected createPersistedBatch(writtenSize: number): PersistedBatch {
59
+ protected createPersistedBatch(writtenSize: number): PersistedBatchV3 {
52
60
  return new PersistedBatchV3(this.db, this.replicationStreamId, this.mapping, writtenSize, {
53
61
  logger: this.logger,
54
62
  objectStorage: this.options.objectStorage,
@@ -58,10 +66,374 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
58
66
  });
59
67
  }
60
68
 
61
- protected get sourceRecordStore(): SourceRecordStore {
69
+ protected get sourceRecordStore(): SourceRecordStoreV3 {
62
70
  return this.store;
63
71
  }
64
72
 
73
+ protected override async withTransaction<T>(
74
+ callback: (stream: SyncRuleDocumentBase) => Promise<T>,
75
+ session = this.session
76
+ ): Promise<T> {
77
+ return this.withFencedTransaction(
78
+ () => this.fence(session, { state: 1, sync_configs: 1 }),
79
+ async (stream) => {
80
+ this.assertPreparationContext(stream as ReplicationStreamDocumentV3);
81
+ return callback(stream);
82
+ },
83
+ session
84
+ );
85
+ }
86
+
87
+ protected override async flushInner(
88
+ options?: storage.BatchBucketFlushOptions,
89
+ checkpoint?: (stream: SyncRuleDocumentBase, lastOp: InternalOpId, writes?: MongoWriteBatch) => Promise<void>,
90
+ projection?: lib_mongo.mongo.Document
91
+ ): Promise<storage.FlushedResult | null> {
92
+ using _span = this.tracer.span('storage', 'flush');
93
+ await this.hooks?.beforeBatchFlush?.(this);
94
+ const input = this.batch;
95
+ const checkpoints = this.write_checkpoint_batch.slice();
96
+ const allocator = this.options.opIdAllocator;
97
+ await allocator.ensureCapacity();
98
+ const contextProjection = { ...projection, state: 1, last_persisted_op: 1, sync_configs: 1 };
99
+ if (checkpoints.length > 0) {
100
+ await this.prepareCustomWriteCheckpoints();
101
+ }
102
+ const prepared = await this.prepareRows(this.session, input);
103
+ let lastOp = prepared.lastOp;
104
+ let checkpointOp: bigint | undefined;
105
+ if (checkpoints.length > 0) {
106
+ while (true) {
107
+ try {
108
+ checkpointOp = allocator.sequence(lastOp).next();
109
+ lastOp = checkpointOp;
110
+ break;
111
+ } catch (error) {
112
+ if (!(error instanceof OpIdRangeExhausted)) {
113
+ throw error;
114
+ }
115
+ await allocator.reserve();
116
+ }
117
+ }
118
+ }
119
+ // Reservations are globally disjoint and this lease's allocator consumes IDs
120
+ // assigned to sealed work, even if publication is abandoned. The writer mutex
121
+ // excludes other writers under this lease during preparation and publication;
122
+ // the transaction fence excludes a different lease owner. Thus preparation
123
+ // does not need to read the persisted head to allocate IDs or reuse records.
124
+ allocator.consume(lastOp);
125
+ const clearError = !this.clearedError && prepared.hasWrites;
126
+ await this.publishPrepared(lastOp, contextProjection, prepared.publications, options, async (stream, writes) => {
127
+ // An empty flush allocates no ID. Use the durable head from the
128
+ // fresh fence for its checkpoint and returned flushed operation.
129
+ if (lastOp === 0n) {
130
+ lastOp = this.persistedOpHead(stream);
131
+ }
132
+ if (clearError) {
133
+ writes.updateOne(
134
+ this.db.sync_rules,
135
+ { _id: this.replicationStreamId },
136
+ {
137
+ $set: { last_fatal_error: null, last_fatal_error_ts: null }
138
+ }
139
+ );
140
+ }
141
+ if (checkpointOp != null) {
142
+ this.queueCustomWriteCheckpoints(writes, checkpointOp, checkpoints);
143
+ }
144
+ if (checkpoint != null && prepared.resumeBatch == null) {
145
+ // Checkpoint decisions use this attempt's freshly fenced state.
146
+ await checkpoint(stream, lastOp, writes);
147
+ } else {
148
+ this.onReplicationTransactionFlush(writes, lastOp);
149
+ }
150
+ });
151
+ this.batch = prepared.resumeBatch;
152
+ this.write_checkpoint_batch.splice(0, checkpoints.length);
153
+ this.clearedError ||= clearError;
154
+ this.last_flushed_op = lastOp;
155
+ // markRecordUnavailable only synchronously queues connector-owned repair
156
+ // keys. It must not mutate input, perform async work, or re-enter storage:
157
+ // the writer mutex is still held. Delivery must finish before flush() returns;
158
+ // WalStream drains that queue before commit()/ack().
159
+ // Once committed, these writes cannot be rolled back if the callback
160
+ // throws. Invalidate the whole local lease while the mutex is held, so
161
+ // no writer sharing it (including queued calls) can advance progress
162
+ // past an undelivered repair. A fresh replication attempt must replay
163
+ // from the old source position and redetect incomplete rows.
164
+ try {
165
+ const notify = this.markRecordUnavailable;
166
+ if (notify != null) {
167
+ for (const record of prepared.repairs) {
168
+ notify(record);
169
+ }
170
+ }
171
+ } catch (error) {
172
+ this.options.replicationLock.invalidate(
173
+ new ReplicationAbortedError(
174
+ 'Committed replication repair notification failed; restart replication before advancing source progress',
175
+ error
176
+ )
177
+ );
178
+ throw error;
179
+ }
180
+ await this.hooks?.afterBatchFlush?.(this);
181
+ return { flushed_op: lastOp };
182
+ }
183
+
184
+ private assertPreparationContext(stream: ReplicationStreamDocumentV3): void {
185
+ if ([storage.SyncRuleState.STOP, storage.SyncRuleState.TERMINATED].includes(stream.state)) {
186
+ throw new ReplicationAbortedError('Replication stream has stopped');
187
+ }
188
+ this.options.parsedSyncConfig.assertPublicationConfigs(stream.sync_configs);
189
+ }
190
+
191
+ /**
192
+ * Internal publisher: the public operation holds the mutex through commit.
193
+ * finish() must only queue writes independent of the final data group, using
194
+ * the fresh fence and planned IDs; it must not execute the supplied batch or
195
+ * read results from unpublished data. Activation and cleanup remain separate.
196
+ */
197
+ private async publishPrepared(
198
+ lastOp: InternalOpId,
199
+ projection: lib_mongo.mongo.Document,
200
+ publications: PreparedPublication[],
201
+ options: storage.BatchBucketFlushOptions | undefined,
202
+ finish: (stream: ReplicationStreamDocumentV3, writes: MongoWriteBatch) => Promise<void>
203
+ ): Promise<void> {
204
+ // This gate covers every sealed batch in the prefix, including truncate/drop.
205
+ // The transaction must never wait for source reads, evaluation or S3 PUTs.
206
+ // Sequential preparation also avoids leaving another group's uploads running
207
+ // when one group fails; each group's readiness joins all of its own uploads.
208
+ for (const publication of publications) {
209
+ await publication.preparePayloads();
210
+ }
211
+ let attempts = 0;
212
+ const retryDeadline = Date.now() + 90_000;
213
+ try {
214
+ await this.withFencedTransaction(
215
+ () => this.fence(this.session, projection),
216
+ async (document) => {
217
+ attempts++;
218
+ if (attempts > 20 && Date.now() > retryDeadline) {
219
+ throw new ServiceError(ErrorCode.PSYNC_S1402, 'Max transaction tries exceeded');
220
+ }
221
+ const stream = document as ReplicationStreamDocumentV3;
222
+ this.assertPreparationContext(stream);
223
+ const head = this.persistedOpHead(stream);
224
+ if (lastOp !== 0n && lastOp <= head) {
225
+ throw new ReplicationAssertionError(
226
+ `Prepared operation ${lastOp} does not advance persisted stream head ${head}`
227
+ );
228
+ }
229
+ // Earlier groups may replace the same source record or bucket state.
230
+ // Preserve their execution order; only the final group's writes are
231
+ // independent of the head/checkpoint tail built from this fresh fence.
232
+ for (const publication of publications.slice(0, -1)) {
233
+ using _persist = this.tracer.span('storage', 'persist_flush');
234
+ await publication.publish(this.session, options);
235
+ }
236
+ const writes = this.db.createWriteBatch(this.session, { ordered: false });
237
+ await finish(stream, writes);
238
+ const finalPublication = publications.at(-1);
239
+ if (finalPublication != null) {
240
+ using _persist = this.tracer.span('storage', 'persist_flush');
241
+ await finalPublication.publish(this.session, options, writes);
242
+ } else {
243
+ await writes.execute();
244
+ }
245
+ },
246
+ this.session,
247
+ 'Publishing prepared work'
248
+ );
249
+ } catch (error) {
250
+ // Publication failed after the driver's retries or our attempt limit.
251
+ // Leave uploaded objects and markers alone: the commit may have succeeded.
252
+ // Stop every writer on this lease and recover from persisted source progress.
253
+ this.options.replicationLock.invalidate(
254
+ new ReplicationAbortedError('Publication failed; restart replication from persisted progress', error)
255
+ );
256
+ throw error;
257
+ }
258
+ }
259
+
260
+ private async prepareRows(session: lib_mongo.mongo.ClientSession, input: OperationBatch | null) {
261
+ const publications: PreparedPublication[] = [];
262
+ const repairs: storage.SaveUpdate[] = [];
263
+ let resumeBatch: OperationBatch | null = null;
264
+ let lastOp = 0n;
265
+ let hasWrites = false;
266
+ if (input == null) {
267
+ return { publications, repairs, resumeBatch, lastOp, hasWrites };
268
+ }
269
+ using _preparation = this.tracer.span('storage', 'prepare');
270
+ const allocator = this.options.opIdAllocator;
271
+ let sizes: Map<string, number> | undefined;
272
+ // Keep the existing size-based read partitioning, including per-table raw
273
+ // data storage and snapshot skip-existing exceptions. Move these reads;
274
+ // publication never repeats them as a validation pass.
275
+ if (
276
+ this.options.storeCurrentData &&
277
+ !this.skipExistingRows &&
278
+ input.batch.some((op) => op.record.sourceTable.storeCurrentData)
279
+ ) {
280
+ sizes = await this.sourceRecordStore.loadSizes(
281
+ session,
282
+ input.batch
283
+ .filter((op) => op.record.sourceTable.storeCurrentData)
284
+ .map((op) => ({ sourceTableId: mongoTableId(op.record.sourceTable.id), replicaId: op.beforeId }))
285
+ );
286
+ }
287
+ // Only this bounded publication's observations/evaluated rows are retained.
288
+ // No state is shared with another pending publication or another writer.
289
+ const records = new Map<string, LoadedSourceRecord | null>();
290
+ let writtenSize = 0;
291
+ for (const block of input.batched(sizes)) {
292
+ if (resumeBatch != null) {
293
+ for (const op of block) {
294
+ resumeBatch.push(op);
295
+ }
296
+ continue;
297
+ }
298
+ const missing = block.filter((op) => !records.has(op.internalBeforeKey));
299
+ if (missing.length > 0) {
300
+ using _lookup = this.tracer.span('storage', 'lookup');
301
+ const loaded = await this.sourceRecordStore.loadDocuments(
302
+ session,
303
+ missing.map((op) => ({
304
+ sourceTableId: mongoTableId(op.record.sourceTable.id),
305
+ replicaId: op.beforeId
306
+ })),
307
+ this.skipExistingRows
308
+ );
309
+ for (const op of missing) {
310
+ records.set(op.internalBeforeKey, loaded.get(op.internalBeforeKey) ?? null);
311
+ }
312
+ }
313
+ let group: PersistedBatchV3 | null = this.createPersistedBatch(writtenSize);
314
+ for (const op of block) {
315
+ if (resumeBatch != null) {
316
+ resumeBatch.push(op);
317
+ continue;
318
+ }
319
+ let next: LoadedSourceRecord | null;
320
+ while (true) {
321
+ const rowRepairs: storage.SaveUpdate[] = [];
322
+ const row = this.createPersistedBatch(0);
323
+ const sequence = allocator.sequence(lastOp);
324
+ try {
325
+ using _evaluate = this.tracer.span('evaluate');
326
+ next = this.saveOperation(
327
+ row,
328
+ op,
329
+ records.get(op.internalBeforeKey) ?? null,
330
+ sequence,
331
+ this.markRecordUnavailable == null
332
+ ? undefined
333
+ : (record) => {
334
+ rowRepairs.push(record);
335
+ }
336
+ );
337
+ group!.append(row);
338
+ hasWrites ||= row.currentSize > 0;
339
+ repairs.push(...rowRepairs);
340
+ lastOp = sequence.last();
341
+ break;
342
+ } catch (error) {
343
+ if (!(error instanceof OpIdRangeExhausted)) {
344
+ throw error;
345
+ }
346
+ // Retry only the unfinished row; no payload has been uploaded yet.
347
+ this.options.replicationLock.throwIfAborted();
348
+ await allocator.reserve();
349
+ }
350
+ }
351
+ if (op.afterId == null || !storage.replicaIdEquals(op.beforeId, op.afterId)) {
352
+ // Preserve evaluation's existing repeated-row behavior: a deleted
353
+ // identity no longer supplies raw values or membership to later rows.
354
+ records.set(op.internalBeforeKey, null);
355
+ }
356
+ if (next != null) {
357
+ records.set(op.internalAfterKey!, next);
358
+ sizes?.set(op.internalAfterKey!, next.data?.length() ?? 0);
359
+ }
360
+ if (group!.shouldFlushTransaction()) {
361
+ publications.push(group!.seal());
362
+ group = null;
363
+ resumeBatch = new OperationBatch();
364
+ }
365
+ }
366
+ if (group != null) {
367
+ writtenSize = group.currentSize;
368
+ publications.push(group.seal());
369
+ }
370
+ }
371
+ return { publications, repairs, resumeBatch: resumeBatch?.hasData() ? resumeBatch : null, lastOp, hasWrites };
372
+ }
373
+
374
+ protected override async truncateSingleInternal(sourceTable: storage.SourceTable): Promise<InternalOpId> {
375
+ const sourceId = mongoTableId(sourceTable.id);
376
+ const session = this.session;
377
+ const projection = { state: 1, sync_configs: 1, last_persisted_op: 1 };
378
+ const allocator = this.options.opIdAllocator;
379
+ while (true) {
380
+ await allocator.ensureCapacity();
381
+ // Preserve the existing bounded table scan. Hard deletion lets the next
382
+ // scan resume at remaining live records without retaining a table overlay.
383
+ const records = await this.sourceRecordStore.loadTruncateBatch(session, sourceId, 2000);
384
+ const group = this.createPersistedBatch(0);
385
+ let lastOp = 0n;
386
+ let processed = 0;
387
+ for (const record of records) {
388
+ while (true) {
389
+ const row = this.createPersistedBatch(0);
390
+ const sequence = allocator.sequence(lastOp);
391
+ try {
392
+ row.saveBucketData({
393
+ op_seq: sequence,
394
+ before_buckets: record.buckets,
395
+ evaluated: [],
396
+ table: sourceTable,
397
+ sourceKey: record.replicaId
398
+ });
399
+ row.saveParameterData({
400
+ op_seq: sequence,
401
+ existing_lookups: record.lookups,
402
+ evaluated: [],
403
+ sourceTable,
404
+ sourceKey: record.replicaId
405
+ });
406
+ // Since this is not from streaming replication, we can do a hard delete.
407
+ row.hardDeleteCurrentData(sourceId, record.replicaId);
408
+ group.append(row);
409
+ lastOp = sequence.last();
410
+ break;
411
+ } catch (error) {
412
+ if (!(error instanceof OpIdRangeExhausted)) {
413
+ throw error;
414
+ }
415
+ this.options.replicationLock.throwIfAborted();
416
+ await allocator.reserve();
417
+ }
418
+ }
419
+ processed++;
420
+ if (group.shouldFlushTransaction()) {
421
+ break;
422
+ }
423
+ }
424
+ allocator.consume(lastOp);
425
+ await this.publishPrepared(lastOp, projection, [group.seal()], undefined, async (stream, writes) => {
426
+ if (lastOp === 0n) {
427
+ lastOp = this.persistedOpHead(stream);
428
+ }
429
+ this.onReplicationTransactionFlush(writes, lastOp);
430
+ });
431
+ if (processed === records.length && records.length < 2000) {
432
+ return lastOp;
433
+ }
434
+ }
435
+ }
436
+
65
437
  protected override persistedOpHead(stream: SyncRuleDocumentBase): InternalOpId {
66
438
  return (stream as ReplicationStreamDocumentV3).last_persisted_op ?? 0n;
67
439
  }
@@ -151,7 +523,9 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
151
523
  }
152
524
  }
153
525
 
154
- async resolveTables(options: storage.ResolveTablesOptions): Promise<storage.ResolveTablesResult> {
526
+ protected override async resolveTablesInternal(
527
+ options: storage.ResolveTablesOptions
528
+ ): Promise<storage.ResolveTablesResult> {
155
529
  // The test-only override is a whole parsed set, so the sync rules and the mapping
156
530
  // used below always come from the same parse.
157
531
  const parsedOverride = options.parsedSyncConfig as MongoParsedSyncConfigSet | undefined;
@@ -173,8 +547,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
173
547
  };
174
548
 
175
549
  let result: storage.ResolveTablesResult | null = null;
176
- const session = this.db.client.startSession();
177
- await using _ = { [Symbol.asyncDispose]: () => session.endSession() };
550
+ const session = this.session;
178
551
 
179
552
  await this.withTransaction(async () => {
180
553
  const col = this.db.sourceTables(this.replicationStreamId);
@@ -251,7 +624,9 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
251
624
  return result!;
252
625
  }
253
626
 
254
- async getSourceTableStatus(table: storage.SourceTable): Promise<storage.SourceTable | null> {
627
+ protected override async getSourceTableStatusInternal(
628
+ table: storage.SourceTable
629
+ ): Promise<storage.SourceTable | null> {
255
630
  const doc = (await this.db
256
631
  .sourceTables(this.replicationStreamId)
257
632
  .findOne({ _id: mongoTableId(table.id) }, { session: this.session })) as SourceTableDocumentV3 | null;
@@ -268,15 +643,38 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
268
643
  );
269
644
  }
270
645
 
271
- async commit(lsn: string, options?: storage.BucketBatchCommitOptions): Promise<storage.CheckpointResult> {
646
+ protected override async commitInternal(
647
+ lsn: string,
648
+ options?: storage.BucketBatchCommitOptions
649
+ ): Promise<storage.CheckpointResult> {
650
+ this.options.replicationLock.throwIfAborted();
651
+ if (this.markRecordUnavailable != null && this.batch?.hasData()) {
652
+ // A combined data+checkpoint commit cannot return control to the connector
653
+ // to process repair notifications between those two durable boundaries.
654
+ // Require its explicit flush -> repair -> commit ordering even when this
655
+ // particular input might turn out not to need repair. An empty batch does
656
+ // not prove its external repair queue is drained; that remains the caller's
657
+ // responsibility. keepalive() delegates here and has the same requirement.
658
+ throw new ReplicationAssertionError(
659
+ 'Flush pending rows and process markRecordUnavailable repairs before committing source progress'
660
+ );
661
+ }
272
662
  const { createEmptyCheckpoints } = { ...storage.DEFAULT_BUCKET_BATCH_COMMIT_OPTIONS, ...options };
273
663
 
274
664
  using _ = this.tracer.span('storage', 'commit');
275
665
 
276
- const checkpoint = async (stream: SyncRuleDocumentBase, lastOp?: InternalOpId) => {
666
+ const checkpoint = async (
667
+ stream: SyncRuleDocumentBase,
668
+ lastOp?: InternalOpId,
669
+ additionalWrites?: MongoWriteBatch
670
+ ) => {
277
671
  const now = new Date();
278
672
  const preUpdateDocument = stream as ReplicationStreamDocumentV3;
279
- const writes = this.db.createWriteBatch(this.session, { ordered: false });
673
+ this.assertPreparationContext(preUpdateDocument);
674
+ // Combined commits join the final data group's batch. Empty commits own
675
+ // and execute their batch here. Decisions depend only on the fresh fence
676
+ // and planned IDs, so no read of the queued data writes is required.
677
+ const writes = additionalWrites ?? this.db.createWriteBatch(this.session, { ordered: false });
280
678
  writes.updateMany(
281
679
  this.db.write_checkpoints,
282
680
  { processed_at_lsn: null, 'lsns.1': { $lte: lsn } },
@@ -398,10 +796,12 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
398
796
  );
399
797
  }
400
798
 
401
- await writes.execute();
799
+ if (additionalWrites == null) {
800
+ await writes.execute();
801
+ }
402
802
  return { checkpointBlocked, checkpointCreated, checkpointLogState, newCheckpoint };
403
803
  };
404
- const projection = { sync_configs: 1, last_persisted_op: 1 };
804
+ const projection = { state: 1, sync_configs: 1, last_persisted_op: 1 };
405
805
  const { checkpointBlocked, checkpointCreated, checkpointLogState, newCheckpoint } = await this.flushAndCommit(
406
806
  checkpoint,
407
807
  () => this.withFencedTransaction(() => this.fence(this.session, projection), checkpoint),
@@ -432,11 +832,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
432
832
  };
433
833
  }
434
834
 
435
- async keepalive(lsn: string): Promise<storage.CheckpointResult> {
436
- return await this.commit(lsn, { createEmptyCheckpoints: true });
437
- }
438
-
439
- async setResumeLsn(lsn: string): Promise<void> {
835
+ protected override async setResumeLsnInternal(lsn: string): Promise<void> {
440
836
  using _ = this.tracer.span('storage', 'set_resume_lsn');
441
837
  // Losing occasional resume LSN would only reprocess source changes.
442
838
  // Keep the lease check atomic, but do not wait for majority replication of this resume hint.
@@ -574,13 +970,16 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
574
970
  });
575
971
  }
576
972
 
577
- async markAllSnapshotDone(no_checkpoint_before_lsn: string): Promise<void> {
973
+ protected override async markAllSnapshotDoneInternal(no_checkpoint_before_lsn: string): Promise<void> {
578
974
  await this.updateStreamMetadata(this.snapshotDoneUpdate(no_checkpoint_before_lsn), {
579
975
  'sync_configs._id': { $in: this.syncConfigIds }
580
976
  });
581
977
  }
582
978
 
583
- async markSnapshotDone(no_checkpoint_before_lsn: string, options?: { throwOnConflict?: boolean }): Promise<void> {
979
+ protected override async markSnapshotDoneInternal(
980
+ no_checkpoint_before_lsn: string,
981
+ options?: { throwOnConflict?: boolean }
982
+ ): Promise<void> {
584
983
  await this.withTransaction(async () => {
585
984
  // Protect against race conditions
586
985
  const blockingTables = await this.db
@@ -607,7 +1006,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
607
1006
  });
608
1007
  }
609
1008
 
610
- async markTableSnapshotRequired(table: storage.SourceTable): Promise<void> {
1009
+ protected override async markTableSnapshotRequiredInternal(table: storage.SourceTable): Promise<void> {
611
1010
  const syncConfigIds = this.relevantSyncConfigIds(table);
612
1011
  if (syncConfigIds.length == 0) {
613
1012
  return;
@@ -617,7 +1016,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
617
1016
  });
618
1017
  }
619
1018
 
620
- async markTableSnapshotDone(
1019
+ protected override async markTableSnapshotDoneInternal(
621
1020
  tables: storage.SourceTable[],
622
1021
  no_checkpoint_before_lsn?: string
623
1022
  ): Promise<storage.SourceTable[]> {
@@ -667,23 +1066,22 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
667
1066
  });
668
1067
  }
669
1068
 
670
- protected override async batchCreateCustomWriteCheckpoints(
671
- session: lib_mongo.mongo.ClientSession,
672
- opId: InternalOpId
673
- ): Promise<void> {
674
- if (this.write_checkpoint_batch.length == 0) {
1069
+ private queueCustomWriteCheckpoints(
1070
+ writes: MongoWriteBatch,
1071
+ opId: InternalOpId,
1072
+ checkpoints: storage.CustomWriteCheckpointOptions[]
1073
+ ): void {
1074
+ if (checkpoints.length == 0) {
675
1075
  return;
676
1076
  }
677
1077
 
678
- const checkpointsByEvent = Map.groupBy(this.write_checkpoint_batch, (checkpoint) =>
1078
+ const checkpointsByEvent = Map.groupBy(checkpoints, (checkpoint) =>
679
1079
  this.validateCustomCheckpointEventId(checkpoint.event_id)
680
1080
  );
681
1081
 
682
- const writes = this.db.createWriteBatch(session, { ordered: false });
683
1082
  for (const [eventId, checkpoints] of checkpointsByEvent) {
684
1083
  this.batchCreateEventCustomWriteCheckpoints(writes, opId, eventId, checkpoints);
685
1084
  }
686
- await writes.execute();
687
1085
  }
688
1086
 
689
1087
  protected override async prepareCustomWriteCheckpoints(): Promise<void> {
@@ -645,7 +645,7 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
645
645
  bucketContext: BucketDataContextV3,
646
646
  objectStorageUsage: ObjectStorageUsage
647
647
  ): Promise<BucketStatsWithChecksum> {
648
- if (inputs.length == 1) {
648
+ if (inputs.length == 1 && !this.shouldMoveToObjectStorage(inputs[0])) {
649
649
  return statsForDocument(inputs[0]);
650
650
  }
651
651
 
@@ -1093,7 +1093,7 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1093
1093
  context: { replicationStreamId: number; definitionId: string },
1094
1094
  objectStorageUsage: ObjectStorageUsage
1095
1095
  ): Promise<CompactionGroupResult> {
1096
- if (group.inputs.length == 1 && !group.changed) {
1096
+ if (group.inputs.length == 1 && !group.changed && !this.shouldMoveToObjectStorage(group.inputs[0])) {
1097
1097
  return {
1098
1098
  documentId: group.inputs[0]._id,
1099
1099
  stats: statsForDocument(group.inputs[0])
@@ -1588,6 +1588,14 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1588
1588
  objectStorageUsage.applyDelta(definitionId, newBytes - oldBytes, writes);
1589
1589
  }
1590
1590
 
1591
+ private shouldMoveToObjectStorage(document: BucketDataDocumentV3): boolean {
1592
+ return (
1593
+ this.storage.objectStorage != null &&
1594
+ document.storage_ref == null &&
1595
+ document.size > this.storage.inlineThresholdBytes
1596
+ );
1597
+ }
1598
+
1591
1599
  private async persistBucketData(
1592
1600
  bucket: string,
1593
1601
  chunks: BucketDataDoc[][],
@@ -1607,8 +1615,9 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
1607
1615
  const store = new BucketDataObjectStorage(this.storage.objectStorage);
1608
1616
  const storagePaths = new Set<string>();
1609
1617
  const lifecycle = this.objectStorageLifecycle;
1610
- // Base placement on the final compacted size. Unchanged documents are not
1611
- // rewritten, while small MOVE/merge results and CLEAR ops stay inline.
1618
+ // Base placement on the final compacted size. Unchanged documents are only
1619
+ // rewritten to move them to object storage, while small MOVE/merge results
1620
+ // and CLEAR ops stay inline.
1612
1621
  const storedIndexes = serializedChunks.flatMap((document, index) =>
1613
1622
  document.size > this.storage.inlineThresholdBytes ? [index] : []
1614
1623
  );