@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
@@ -0,0 +1,829 @@
1
+ import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
2
+ import { mongo } from '@powersync/lib-service-mongodb';
3
+ import { logger as defaultLogger } from '@powersync/lib-services-framework';
4
+ import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
5
+ import { getTestStorage, test_utils } from '@powersync/service-core-tests';
6
+ import * as bson from 'bson';
7
+ import { describe, expect, test, vi } from 'vitest';
8
+ import { MongoIdSequence } from '../../src/storage/implementation/MongoIdSequence.js';
9
+ import { OperationBatch } from '../../src/storage/implementation/OperationBatch.js';
10
+ import { MongoBucketBatchV3 } from '../../src/storage/implementation/v3/MongoBucketBatchV3.js';
11
+ import { MongoSyncBucketStorageV3 } from '../../src/storage/implementation/v3/MongoSyncBucketStorageV3.js';
12
+ import { PersistedBatchV3 } from '../../src/storage/implementation/v3/PersistedBatchV3.js';
13
+ import { SourceRecordStoreV3 } from '../../src/storage/implementation/v3/SourceRecordStoreV3.js';
14
+ import { env } from './env.js';
15
+ import { MemoryObjectStorage } from './helpers/MemoryObjectStorage.js';
16
+ import { PublicationCommandMonitor } from './helpers/PublicationCommandMonitor.js';
17
+
18
+ const CONFIG = `
19
+ config:
20
+ edition: 3
21
+ streams:
22
+ items:
23
+ query: SELECT * FROM items
24
+ `;
25
+
26
+ async function open(version: number, enabled = false, options: Partial<storage.CreateWriterOptions> = {}) {
27
+ const objectStorage = new MemoryObjectStorage();
28
+ const factoryGen = mongoTestStorageFactoryGenerator({
29
+ url: env.MONGO_TEST_URL,
30
+ isCI: env.CI,
31
+ monitorCommands: true,
32
+ objectStorage: enabled ? objectStorage : undefined,
33
+ inlineThresholdBytes: 0
34
+ });
35
+ const factory = await factoryGen.factory();
36
+ const stream = await factory.updateSyncRules(updateSyncRulesFromYaml(CONFIG, { storageVersion: version }));
37
+ const bucketStorage = (await getTestStorage(factory, stream)) as MongoSyncBucketStorageV3;
38
+ const writer = (await bucketStorage.createWriter({ ...test_utils.BATCH_OPTIONS, ...options })) as MongoBucketBatchV3;
39
+ const table = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 1);
40
+ await writer.markAllSnapshotDone('1/1');
41
+ await writer.save({
42
+ sourceTable: table,
43
+ tag: storage.SaveOperationTag.INSERT,
44
+ after: { id: 'A', description: 'initial', retained: 'old' },
45
+ afterReplicaId: test_utils.rid('A')
46
+ });
47
+ await writer.flush();
48
+ await writer.commit('1/1');
49
+ return { factory, factoryGen, stream, bucketStorage, writer, table, objectStorage };
50
+ }
51
+
52
+ async function save(writer: storage.BucketStorageBatch, table: storage.SourceTable, description: string) {
53
+ await writer.save({
54
+ sourceTable: table,
55
+ tag: storage.SaveOperationTag.UPDATE,
56
+ after: { id: 'A', description, retained: 'old' },
57
+ afterReplicaId: test_utils.rid('A')
58
+ });
59
+ }
60
+
61
+ describe.each([3, 4])('eager replication preparation v%s', (version) => {
62
+ test.each([false, true])(
63
+ 'reads/evaluates once and delivers repairs after commit across rollback (object storage: %s)',
64
+ async (enabled) => {
65
+ const unavailable = vi.fn();
66
+ const context = await open(version, enabled, { markRecordUnavailable: unavailable });
67
+ await using factory = context.factory;
68
+ await using writer = context.writer;
69
+ using commands = new PublicationCommandMonitor(factory.db.client);
70
+ const read = SourceRecordStoreV3.prototype.loadDocuments;
71
+ using documents = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments').mockImplementation(function (
72
+ this: SourceRecordStoreV3,
73
+ ...args
74
+ ) {
75
+ expect(args[0].inTransaction()).toBe(false);
76
+ return read.apply(this, args);
77
+ });
78
+ const sizesRead = SourceRecordStoreV3.prototype.loadSizes;
79
+ using sizes = vi.spyOn(SourceRecordStoreV3.prototype, 'loadSizes').mockImplementation(function (
80
+ this: SourceRecordStoreV3,
81
+ ...args
82
+ ) {
83
+ expect(args[0].inTransaction()).toBe(false);
84
+ return sizesRead.apply(this, args);
85
+ });
86
+ using put = vi.spyOn(context.objectStorage, 'put');
87
+ const publish = PersistedBatchV3.prototype.publish;
88
+ let attempts = 0;
89
+ using _retry = vi.spyOn(PersistedBatchV3.prototype, 'publish').mockImplementation(async function (
90
+ this: PersistedBatchV3,
91
+ ...args
92
+ ) {
93
+ expect(unavailable).not.toHaveBeenCalled();
94
+ const result = await publish.apply(this, args);
95
+ if (++attempts === 1) {
96
+ throw new mongo.MongoServerError({
97
+ message: 'retry prepared writes',
98
+ code: 112,
99
+ errorLabels: ['TransientTransactionError']
100
+ });
101
+ }
102
+ return result;
103
+ });
104
+ await save(writer, context.table, 'updated');
105
+ await writer.save({
106
+ sourceTable: context.table,
107
+ tag: storage.SaveOperationTag.UPDATE,
108
+ after: { id: 'missing', description: undefined },
109
+ afterReplicaId: test_utils.rid('missing')
110
+ });
111
+ await writer.flush();
112
+ expect(attempts).toBe(2);
113
+ expect(documents).toHaveBeenCalledTimes(1);
114
+ expect(sizes).toHaveBeenCalledTimes(1);
115
+ expect(unavailable).toHaveBeenCalledTimes(1);
116
+ expect(put).toHaveBeenCalledTimes(enabled ? 1 : 0);
117
+ expect(writer.last_flushed_op).toBe(2n);
118
+ // The connector must be able to repair before advancing its source position.
119
+ expect(await factory.db.sync_rules.findOne({ _id: context.stream.replicationStreamId })).toMatchObject({
120
+ resume_lsn: '1/1',
121
+ sync_configs: [{ last_checkpoint: 1n, last_checkpoint_lsn: '1/1' }]
122
+ });
123
+ for (const attempt of commands.replicationAttempts) {
124
+ expect(attempt.commands.some((command) => ['find', 'aggregate'].includes(command.name))).toBe(false);
125
+ }
126
+ }
127
+ );
128
+
129
+ test('logs oversized output during preparation without repeating it on publication retry', async () => {
130
+ const logger = defaultLogger.child({ test: 'oversized output diagnostics' });
131
+ const context = await open(version, false, { logger });
132
+ await using _factory = context.factory;
133
+ await using writer = context.writer;
134
+ using logged = vi.spyOn(logger, 'error');
135
+ const publish = PersistedBatchV3.prototype.publish;
136
+ let attempts = 0;
137
+ using _retry = vi.spyOn(PersistedBatchV3.prototype, 'publish').mockImplementation(async function (
138
+ this: PersistedBatchV3,
139
+ ...args
140
+ ) {
141
+ expect(logged).toHaveBeenCalledTimes(1);
142
+ const result = await publish.apply(this, args);
143
+ if (++attempts === 1) {
144
+ throw new mongo.MongoServerError({
145
+ message: 'retry oversized row removal',
146
+ code: 112,
147
+ errorLabels: ['TransientTransactionError']
148
+ });
149
+ }
150
+ return result;
151
+ });
152
+ // BSON can store this source row, but JSON escaping doubles its output size
153
+ // beyond the bucket-row limit. save() auto-flushes this large input.
154
+ await writer.save({
155
+ sourceTable: context.table,
156
+ tag: storage.SaveOperationTag.UPDATE,
157
+ after: { id: 'A', description: '"'.repeat(8 * 1024 * 1024), retained: 'old' },
158
+ afterReplicaId: test_utils.rid('A')
159
+ });
160
+ expect(attempts).toBe(2);
161
+ expect(logged).toHaveBeenCalledTimes(1);
162
+ expect(logged).toHaveBeenCalledWith(expect.stringMatching(/^Row .* too large: .* bytes\. Removing\.$/));
163
+ });
164
+
165
+ test('requires explicit flush/repair before commit, including split batches', async () => {
166
+ const repairs: storage.SaveUpdate[] = [];
167
+ const context = await open(version, false, {
168
+ markRecordUnavailable: (record) => {
169
+ repairs.push(record);
170
+ }
171
+ });
172
+ await using factory = context.factory;
173
+ await using writer = context.writer;
174
+ using _split = vi.spyOn(PersistedBatchV3.prototype, 'shouldFlushTransaction').mockReturnValue(true);
175
+ using reads = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments');
176
+ for (const id of ['missing1', 'missing2']) {
177
+ await writer.save({
178
+ sourceTable: context.table,
179
+ tag: storage.SaveOperationTag.UPDATE,
180
+ after: { id, description: undefined },
181
+ afterReplicaId: id
182
+ });
183
+ }
184
+ await expect(writer.commit('1/2')).rejects.toThrow('Flush pending rows');
185
+ expect(reads).not.toHaveBeenCalled();
186
+ expect(repairs).toEqual([]);
187
+ await writer.flush();
188
+ expect(repairs.map((record) => record.after.id)).toEqual(['missing1', 'missing2']);
189
+ expect(await factory.db.sync_rules.findOne({ _id: context.stream.replicationStreamId })).toMatchObject({
190
+ resume_lsn: '1/1',
191
+ sync_configs: [{ last_checkpoint: 1n }]
192
+ });
193
+ // Model WalStream's separate resnapshot phase, outside writer admission.
194
+ for (const repair of repairs) {
195
+ await writer.save({
196
+ sourceTable: repair.sourceTable,
197
+ tag: storage.SaveOperationTag.INSERT,
198
+ after: { id: repair.after.id!, description: 'recovered' },
199
+ afterReplicaId: repair.afterReplicaId
200
+ });
201
+ }
202
+ await writer.flush();
203
+ await writer.commit('1/2');
204
+ expect((await context.bucketStorage.getCheckpoint()).checkpoint).toBe(3n);
205
+ expect(repairs).toHaveLength(2);
206
+ });
207
+
208
+ test.each(['before-commit', 'after-commit'])('failed %s flush does not lose repair on replay', async (phase) => {
209
+ const unavailable = vi.fn();
210
+ const context = await open(version, false, { markRecordUnavailable: unavailable });
211
+ await using factory = context.factory;
212
+ await using writer = context.writer;
213
+ const failure = new Error('publication failed');
214
+ const publish = PersistedBatchV3.prototype.publish;
215
+ const withTransaction = writer.session.withTransaction.bind(writer.session);
216
+ using aborted = vi.spyOn(PersistedBatchV3.prototype, 'publish');
217
+ using uncertain = vi.spyOn(writer.session, 'withTransaction');
218
+ if (phase === 'before-commit') {
219
+ aborted.mockImplementationOnce(async function (this: PersistedBatchV3, ...args) {
220
+ await publish.apply(this, args);
221
+ throw failure;
222
+ });
223
+ } else {
224
+ // Simulate a terminal lost commit response: the data committed, but the
225
+ // writer cannot confirm it and therefore must not deliver repairs.
226
+ uncertain.mockImplementationOnce(async (...args) => {
227
+ await withTransaction(...args);
228
+ throw failure;
229
+ });
230
+ }
231
+ await writer.save({
232
+ sourceTable: context.table,
233
+ tag: storage.SaveOperationTag.UPDATE,
234
+ after: { id: 'missing', description: undefined },
235
+ afterReplicaId: 'missing'
236
+ });
237
+ await expect(writer.flush()).rejects.toThrow(failure);
238
+ expect(unavailable).not.toHaveBeenCalled();
239
+ const collection = context.bucketStorage.db.sourceRecords(
240
+ context.stream.replicationStreamId,
241
+ context.table.id as bson.ObjectId
242
+ );
243
+ const partial = (await collection.find({}).toArray()).find((record) => record._id === 'missing');
244
+ expect(partial != null).toBe(phase === 'after-commit');
245
+ expect(await factory.db.sync_rules.findOne({ _id: context.stream.replicationStreamId })).toMatchObject({
246
+ resume_lsn: '1/1',
247
+ sync_configs: [{ last_checkpoint: 1n }]
248
+ });
249
+ aborted.mockRestore();
250
+ uncertain.mockRestore();
251
+ await expect(writer.setResumeLsn('1/2')).rejects.toThrow('restart replication');
252
+ await test_utils.releaseTestStorageLease(factory, context.stream.replicationStreamId);
253
+ const replayStorage = await getTestStorage(factory, (await factory.getReplicatingReplicationStreams())[0]);
254
+ const repairs = vi.fn();
255
+ await using replay = await replayStorage.createWriter({
256
+ ...test_utils.BATCH_OPTIONS,
257
+ markRecordUnavailable: repairs
258
+ });
259
+ const replayTable = await test_utils.resolveTestTable(replay, 'items', ['id'], context.factoryGen, 1);
260
+ await replay.save({
261
+ sourceTable: replayTable,
262
+ tag: storage.SaveOperationTag.UPDATE,
263
+ after: { id: 'missing', description: undefined },
264
+ afterReplicaId: 'missing'
265
+ });
266
+ await replay.flush();
267
+ expect(repairs).toHaveBeenCalledTimes(1);
268
+ await replay.save({
269
+ sourceTable: replayTable,
270
+ tag: storage.SaveOperationTag.INSERT,
271
+ after: { id: 'missing', description: 'repaired' },
272
+ afterReplicaId: 'missing'
273
+ });
274
+ await replay.flush();
275
+ await replay.commit('1/2');
276
+ expect((await context.bucketStorage.getCheckpoint()).lsn).toBe('1/2');
277
+ });
278
+
279
+ test('a throwing repair callback stops all writers sharing the lease, including queued progress', async () => {
280
+ const failure = new Error('repair callback failed');
281
+ const callback = () => {
282
+ throw failure;
283
+ };
284
+ const context = await open(version, false, { markRecordUnavailable: callback });
285
+ await using factory = context.factory;
286
+ await using writer = context.writer;
287
+ await using other = await context.bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
288
+ const entered = Promise.withResolvers<void>();
289
+ const release = Promise.withResolvers<void>();
290
+ const load = SourceRecordStoreV3.prototype.loadDocuments;
291
+ using _pause = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments').mockImplementationOnce(async function (
292
+ this: SourceRecordStoreV3,
293
+ ...args
294
+ ) {
295
+ const records = await load.apply(this, args);
296
+ entered.resolve();
297
+ await release.promise;
298
+ return records;
299
+ });
300
+ await writer.save({
301
+ sourceTable: context.table,
302
+ tag: storage.SaveOperationTag.UPDATE,
303
+ after: { id: 'missing', description: undefined },
304
+ afterReplicaId: 'missing'
305
+ });
306
+ const flushing = expect(writer.flush()).rejects.toThrow(failure);
307
+ await entered.promise;
308
+ const queued = expect(other.setResumeLsn('1/2')).rejects.toThrow('restart replication');
309
+ release.resolve();
310
+ await Promise.all([flushing, queued]);
311
+ await expect(writer.commit('1/2')).rejects.toThrow('restart replication');
312
+ await expect(writer.keepalive('1/2')).rejects.toThrow('restart replication');
313
+ await expect(writer.setResumeLsn('1/2')).rejects.toThrow('restart replication');
314
+ await expect(other.commit('1/2')).rejects.toThrow('restart replication');
315
+ expect(await factory.db.sync_rules.findOne({ _id: context.stream.replicationStreamId })).toMatchObject({
316
+ resume_lsn: '1/1',
317
+ sync_configs: [{ last_checkpoint: 1n }]
318
+ });
319
+ const repairs = vi.fn();
320
+ await test_utils.releaseTestStorageLease(factory, context.stream.replicationStreamId);
321
+ const restarted = await getTestStorage(factory, context.stream);
322
+ await using replay = await restarted.createWriter({
323
+ ...test_utils.BATCH_OPTIONS,
324
+ markRecordUnavailable: repairs
325
+ });
326
+ await replay.save({
327
+ sourceTable: context.table,
328
+ tag: storage.SaveOperationTag.UPDATE,
329
+ after: { id: 'missing', description: undefined },
330
+ afterReplicaId: 'missing'
331
+ });
332
+ await replay.flush();
333
+ expect(repairs).toHaveBeenCalledTimes(1);
334
+ });
335
+
336
+ test('discards an unfinished row notification when operation IDs run out', async () => {
337
+ const repairs = vi.fn();
338
+ const context = await open(version, false, { markRecordUnavailable: repairs });
339
+ await using factory = context.factory;
340
+ await using writer = context.writer;
341
+ const allocator = factory.getOpIdAllocator(
342
+ context.bucketStorage.replicationStream,
343
+ context.bucketStorage.replicationStream.current_lock!
344
+ );
345
+ using sequence = vi.spyOn(allocator, 'sequence').mockImplementationOnce((head) => new MongoIdSequence(head, []));
346
+ await writer.save({
347
+ sourceTable: context.table,
348
+ tag: storage.SaveOperationTag.UPDATE,
349
+ after: { id: 'new', description: undefined },
350
+ beforeReplicaId: 'old',
351
+ afterReplicaId: 'new'
352
+ });
353
+ await writer.flush();
354
+ expect(sequence).toHaveBeenCalledTimes(2);
355
+ expect(repairs).toHaveBeenCalledTimes(1);
356
+ expect(repairs.mock.calls[0][0].afterReplicaId).toBe('new');
357
+ });
358
+
359
+ test('disposing one writer preserves the lease session for other writers', async () => {
360
+ const context = await open(version);
361
+ await using factory = context.factory;
362
+ await using writer = context.writer;
363
+ const disposable = (await context.bucketStorage.createWriter(test_utils.BATCH_OPTIONS)) as MongoBucketBatchV3;
364
+ const session = writer.session;
365
+ expect(disposable.session).toBe(session);
366
+ await disposable.dispose();
367
+ expect(session.hasEnded).toBe(false);
368
+ await save(writer, context.table, 'after writer disposal');
369
+ await writer.commit('1/2');
370
+ expect((await context.bucketStorage.getCheckpoint()).lsn).toBe('1/2');
371
+ await test_utils.releaseTestStorageLease(factory, context.stream.replicationStreamId);
372
+ expect(session.hasEnded).toBe(true);
373
+ });
374
+
375
+ test('lease release waits for active session use and rejects queued operations', async () => {
376
+ const context = await open(version);
377
+ await using _factory = context.factory;
378
+ await using writer = context.writer;
379
+ await using other = await context.bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
380
+ const lease = context.bucketStorage.replicationStream.current_lock!;
381
+ const entered = Promise.withResolvers<void>();
382
+ const release = Promise.withResolvers<void>();
383
+ const load = SourceRecordStoreV3.prototype.loadDocuments;
384
+ using _pause = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments').mockImplementationOnce(async function (
385
+ this: SourceRecordStoreV3,
386
+ ...args
387
+ ) {
388
+ const result = await load.apply(this, args);
389
+ entered.resolve();
390
+ await release.promise;
391
+ return result;
392
+ });
393
+ await save(writer, context.table, 'abandoned during release');
394
+ const publishing = expect(writer.flush()).rejects.toThrow('Replication lock released');
395
+ await entered.promise;
396
+ const queued = expect(other.getSourceTableStatus(context.table)).rejects.toThrow('Replication lock released');
397
+ const releasing = lease.release();
398
+ try {
399
+ expect(writer.session.hasEnded).toBe(false);
400
+ release.resolve();
401
+ await Promise.all([publishing, queued, releasing]);
402
+ expect(writer.session.hasEnded).toBe(true);
403
+ } finally {
404
+ release.resolve();
405
+ await Promise.allSettled([publishing, queued, releasing]);
406
+ }
407
+ });
408
+
409
+ test('uses the shared causal session across writers and majority preparation reads', async () => {
410
+ const context = await open(version);
411
+ await using factory = context.factory;
412
+ await using first = context.writer;
413
+ await using second = (await context.bucketStorage.createWriter(test_utils.BATCH_OPTIONS)) as MongoBucketBatchV3;
414
+ expect(second.session).toBe(first.session);
415
+ await save(first, context.table, 'second publication');
416
+ await first.commit('1/2');
417
+ const committedTime = first.session.operationTime!;
418
+ const reads: mongo.Document[] = [];
419
+ const observe = (event: mongo.CommandStartedEvent) => {
420
+ if (
421
+ (event.commandName === 'find' && event.command.find.startsWith('source_records_')) ||
422
+ event.commandName === 'aggregate'
423
+ ) {
424
+ reads.push(event.command);
425
+ }
426
+ };
427
+ factory.db.client.on('commandStarted', observe);
428
+ try {
429
+ await second.save({
430
+ sourceTable: context.table,
431
+ tag: storage.SaveOperationTag.UPDATE,
432
+ after: { id: undefined, description: undefined, retained: 'new' },
433
+ afterReplicaId: test_utils.rid('A')
434
+ });
435
+ await second.commit('1/3');
436
+ } finally {
437
+ factory.db.client.off('commandStarted', observe);
438
+ }
439
+ expect(reads).toHaveLength(2);
440
+ for (const read of reads) {
441
+ expect(read.lsid).toEqual(first.session.id);
442
+ expect(read.autocommit).toBeUndefined();
443
+ expect(read.readConcern.level).toBe('majority');
444
+ expect(read.readConcern.afterClusterTime.compare(committedTime)).toBeGreaterThanOrEqual(0);
445
+ }
446
+ const [record] = await context.bucketStorage.db
447
+ .sourceRecords(context.stream.replicationStreamId, context.table.id as bson.ObjectId)
448
+ .find({})
449
+ .toArray();
450
+ expect(bson.deserialize(record.data!.buffer)).toEqual({
451
+ id: 'A',
452
+ description: 'second publication',
453
+ retained: 'new'
454
+ });
455
+ });
456
+
457
+ test.each(['metadata', 'truncate', 'drop'])(
458
+ '%s waits for prepared work, including physical table cleanup',
459
+ async (mutation) => {
460
+ const context = await open(version);
461
+ await using factory = context.factory;
462
+ await using writer = context.writer;
463
+ await using other = (await context.bucketStorage.createWriter(test_utils.BATCH_OPTIONS)) as MongoBucketBatchV3;
464
+ const entered = Promise.withResolvers<void>();
465
+ const release = Promise.withResolvers<void>();
466
+ const read = SourceRecordStoreV3.prototype.loadDocuments;
467
+ using _pause = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments').mockImplementationOnce(async function (
468
+ this: SourceRecordStoreV3,
469
+ ...args
470
+ ) {
471
+ const result = await read.apply(this, args);
472
+ entered.resolve();
473
+ await release.promise;
474
+ return result;
475
+ });
476
+ using commands = new PublicationCommandMonitor(factory.db.client);
477
+ await save(writer, context.table, 'prepared');
478
+ const publishing = writer.flush();
479
+ const pending: Promise<unknown>[] = [publishing];
480
+ try {
481
+ await entered.promise;
482
+ expect(commands.activeReplicationAttempts).toHaveLength(0);
483
+ using transactions = vi.spyOn(other.session, 'withTransaction');
484
+ pending.push(
485
+ mutation === 'metadata'
486
+ ? other.markTableSnapshotDone([context.table])
487
+ : mutation === 'truncate'
488
+ ? other.truncate([context.table])
489
+ : other.drop([context.table])
490
+ );
491
+ expect(transactions).not.toHaveBeenCalled();
492
+ release.resolve();
493
+ await Promise.all(pending);
494
+ expect(transactions).toHaveBeenCalled();
495
+ } finally {
496
+ release.resolve();
497
+ await Promise.allSettled(pending);
498
+ }
499
+ if (mutation === 'drop') {
500
+ expect(
501
+ await context.bucketStorage.db.db
502
+ .listCollections({
503
+ name: context.bucketStorage.db.sourceRecords(
504
+ context.stream.replicationStreamId,
505
+ context.table.id as bson.ObjectId
506
+ ).collectionName
507
+ })
508
+ .hasNext()
509
+ ).toBe(false);
510
+ }
511
+ }
512
+ );
513
+
514
+ test('soft-delete cleanup holds admission until its database work finishes', async () => {
515
+ const context = await open(version);
516
+ await using _factory = context.factory;
517
+ await using writer = context.writer;
518
+ await using other = await context.bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
519
+ const entered = Promise.withResolvers<void>();
520
+ const release = Promise.withResolvers<void>();
521
+ const cleanup = SourceRecordStoreV3.prototype.postCommitCleanup;
522
+ using _pause = vi.spyOn(SourceRecordStoreV3.prototype, 'postCommitCleanup').mockImplementationOnce(async function (
523
+ this: SourceRecordStoreV3,
524
+ ...args
525
+ ) {
526
+ entered.resolve();
527
+ await release.promise;
528
+ return cleanup.apply(this, args);
529
+ });
530
+ const cleaning = other.commit('1/2');
531
+ const pending: Promise<unknown>[] = [cleaning];
532
+ try {
533
+ await entered.promise;
534
+ using read = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments');
535
+ await save(writer, context.table, 'after cleanup');
536
+ pending.push(writer.flush());
537
+ expect(read).not.toHaveBeenCalled();
538
+ release.resolve();
539
+ await Promise.all(pending);
540
+ expect(read).toHaveBeenCalledTimes(1);
541
+ } finally {
542
+ release.resolve();
543
+ await Promise.allSettled(pending);
544
+ }
545
+ });
546
+
547
+ test.each(['config', 'lease'])('rejects stale %s after preparation without rereading records', async (change) => {
548
+ const context = await open(version, true);
549
+ await using factory = context.factory;
550
+ await using writer = context.writer;
551
+ const entered = Promise.withResolvers<void>();
552
+ const release = Promise.withResolvers<void>();
553
+ const original = SourceRecordStoreV3.prototype.loadDocuments;
554
+ using read = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments').mockImplementationOnce(async function (
555
+ this: SourceRecordStoreV3,
556
+ ...args
557
+ ) {
558
+ const result = await original.apply(this, args);
559
+ entered.resolve();
560
+ await release.promise;
561
+ return result;
562
+ });
563
+ using put = vi.spyOn(context.objectStorage, 'put');
564
+ await save(writer, context.table, 'stale');
565
+ const publishing = writer.flush();
566
+ const rejected = expect(publishing).rejects.toThrow(change === 'lease' ? 'no longer owns' : 'reload');
567
+ try {
568
+ await entered.promise;
569
+ await factory.db.sync_rules.updateOne(
570
+ { _id: context.stream.replicationStreamId },
571
+ {
572
+ $set:
573
+ change === 'lease'
574
+ ? { 'lock.id': 'replacement-owner' }
575
+ : { 'sync_configs.$[].state': storage.SyncRuleState.STOP }
576
+ },
577
+ { writeConcern: { w: 'majority' } }
578
+ );
579
+ release.resolve();
580
+ await rejected;
581
+ expect(read).toHaveBeenCalledTimes(1);
582
+ // Ownership/config are revalidated after uploads. Work abandoned at that
583
+ // boundary retains its marker instead of publishing a stale reference.
584
+ expect(put).toHaveBeenCalledTimes(1);
585
+ expect(
586
+ await context.bucketStorage.db
587
+ .pendingObjectStorageDeletes(context.stream.replicationStreamId)
588
+ .countDocuments({ path: put.mock.calls[0][0] })
589
+ ).toBe(1);
590
+ expect(writer.last_flushed_op).toBe(1n);
591
+ } finally {
592
+ release.resolve();
593
+ await Promise.allSettled([publishing]);
594
+ }
595
+ });
596
+
597
+ test('uses fresh fenced snapshot metadata for the checkpoint decision', async () => {
598
+ const context = await open(version);
599
+ await using factory = context.factory;
600
+ await using writer = context.writer;
601
+ const read = SourceRecordStoreV3.prototype.loadDocuments;
602
+ using update = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments').mockImplementationOnce(async function (
603
+ this: SourceRecordStoreV3,
604
+ ...args
605
+ ) {
606
+ const records = await read.apply(this, args);
607
+ await factory.db.sync_rules.updateOne(
608
+ { _id: context.stream.replicationStreamId },
609
+ { $set: { 'sync_configs.$[].snapshot_done': false } },
610
+ { writeConcern: { w: 'majority' } }
611
+ );
612
+ return records;
613
+ });
614
+ await save(writer, context.table, 'not visible yet');
615
+ expect((await writer.commit('1/2')).checkpointBlocked).toBe(true);
616
+ expect(writer.last_flushed_op).toBe(2n);
617
+ expect((await context.bucketStorage.getCheckpoint()).checkpoint).toBe(0n);
618
+ expect(await factory.db.sync_rules.findOne({ _id: context.stream.replicationStreamId })).toMatchObject({
619
+ last_persisted_op: 2n,
620
+ sync_configs: [{ last_checkpoint: 1n, last_checkpoint_lsn: '1/1', snapshot_done: false }]
621
+ });
622
+ await writer.markAllSnapshotDone('1/2');
623
+ expect((await writer.commit('1/3')).checkpointBlocked).toBe(false);
624
+ expect((await context.bucketStorage.getCheckpoint()).checkpoint).toBe(2n);
625
+ expect(update).toHaveBeenCalledTimes(1);
626
+ });
627
+
628
+ test('preserves read partitions and repeated-row state when a later data bulk rolls back', async () => {
629
+ const context = await open(version, true);
630
+ await using factory = context.factory;
631
+ await using writer = context.writer;
632
+ using _blocks = vi.spyOn(OperationBatch.prototype, 'batched').mockImplementation(function* (this: OperationBatch) {
633
+ for (const op of this.batch) {
634
+ yield [op];
635
+ }
636
+ });
637
+ using reads = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments');
638
+ using monitor = new PublicationCommandMonitor(factory.db.client);
639
+ const put = context.objectStorage.put.bind(context.objectStorage);
640
+ using uploads = vi.spyOn(context.objectStorage, 'put').mockImplementation(async (...args) => {
641
+ // All groups in this prefix must finish uploading before its first fence,
642
+ // even though each group later emits a separate data bulk on every retry.
643
+ expect(monitor.activeReplicationAttempts).toHaveLength(0);
644
+ await put(...args);
645
+ });
646
+ const publish = PersistedBatchV3.prototype.publish;
647
+ let writes = 0;
648
+ using _retry = vi.spyOn(PersistedBatchV3.prototype, 'publish').mockImplementation(async function (
649
+ this: PersistedBatchV3,
650
+ ...args
651
+ ) {
652
+ const result = await publish.apply(this, args);
653
+ if (++writes === 2) {
654
+ throw new mongo.MongoServerError({
655
+ message: 'rollback both blocks',
656
+ code: 112,
657
+ errorLabels: ['TransientTransactionError']
658
+ });
659
+ }
660
+ return result;
661
+ });
662
+ await save(writer, context.table, 'first block');
663
+ await writer.save({
664
+ sourceTable: context.table,
665
+ tag: storage.SaveOperationTag.UPDATE,
666
+ after: { id: undefined, description: undefined, retained: 'second block' },
667
+ afterReplicaId: test_utils.rid('A')
668
+ });
669
+ await writer.commit('1/2');
670
+ expect(reads).toHaveBeenCalledTimes(1);
671
+ expect(writes).toBe(4);
672
+ expect(uploads).toHaveBeenCalledTimes(2);
673
+ const db = context.bucketStorage.db;
674
+ const [record] = await db
675
+ .sourceRecords(context.stream.replicationStreamId, context.table.id as bson.ObjectId)
676
+ .find({})
677
+ .toArray();
678
+ expect(bson.deserialize(record.data!.buffer)).toEqual({
679
+ id: 'A',
680
+ description: 'first block',
681
+ retained: 'second block'
682
+ });
683
+ expect(await db.bucketState(context.stream.replicationStreamId).findOne({})).toMatchObject({
684
+ last_op: 3n,
685
+ bucket_stats: { count: 3, chunks: 3 }
686
+ });
687
+ expect((await context.bucketStorage.getCheckpoint()).checkpoint).toBe(3n);
688
+ });
689
+
690
+ test('a replacement lease publishes while the previous owner is preparing', async () => {
691
+ const context = await open(version, true);
692
+ await using factory = context.factory;
693
+ await using writer = context.writer;
694
+ const entered = Promise.withResolvers<void>();
695
+ const release = Promise.withResolvers<void>();
696
+ const load = SourceRecordStoreV3.prototype.loadDocuments;
697
+ using read = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments').mockImplementationOnce(async function (
698
+ this: SourceRecordStoreV3,
699
+ ...args
700
+ ) {
701
+ const result = await load.apply(this, args);
702
+ entered.resolve();
703
+ await release.promise;
704
+ return result;
705
+ });
706
+ using put = vi.spyOn(context.objectStorage, 'put');
707
+ await save(writer, context.table, 'previous owner');
708
+ const pending = writer.flush();
709
+ const rejected = expect(pending).rejects.toThrow('no longer owns');
710
+ try {
711
+ await entered.promise;
712
+ await factory.db.sync_rules.updateOne(
713
+ { _id: context.stream.replicationStreamId },
714
+ { $set: { 'lock.expires_at': new Date(0) } }
715
+ );
716
+ await using replacement = await context.factoryGen.factory({ doNotClear: true });
717
+ const stream = (await replacement.getReplicatingReplicationStreams())[0];
718
+ const lease = await stream.lock();
719
+ await using _leaseLifetime = { [Symbol.asyncDispose]: () => lease.release() };
720
+ const storage = replacement.getInstance(stream, { replicationLock: lease });
721
+ await using next = await storage.createWriter(test_utils.BATCH_OPTIONS);
722
+ const table = await test_utils.resolveTestTable(next, 'items', ['id'], context.factoryGen, 1);
723
+ await save(next, table, 'replacement owner');
724
+ await next.commit('1/2');
725
+ expect(next.last_flushed_op).toBeGreaterThan(65_536n);
726
+ expect(put).toHaveBeenCalledTimes(1);
727
+ release.resolve();
728
+ await rejected;
729
+ expect(put).toHaveBeenCalledTimes(2);
730
+ expect(read).toHaveBeenCalledTimes(2);
731
+ expect(writer.last_flushed_op).toBe(1n);
732
+ const records = await context.bucketStorage.db
733
+ .sourceRecords(context.stream.replicationStreamId, context.table.id as bson.ObjectId)
734
+ .find({})
735
+ .toArray();
736
+ expect(bson.deserialize(records[0].data!.buffer).description).toBe('replacement owner');
737
+ } finally {
738
+ release.resolve();
739
+ await Promise.allSettled([pending]);
740
+ }
741
+ });
742
+
743
+ test('retains a snapshot tombstone observation if previous-owner cleanup removes it during preparation', async () => {
744
+ const context = await open(version);
745
+ await using _factory = context.factory;
746
+ await using _writer = context.writer;
747
+ const db = context.bucketStorage.db;
748
+ const streamId = context.stream.replicationStreamId;
749
+ const sourceId = context.table.id as bson.ObjectId;
750
+ const collection = db.sourceRecords(streamId, sourceId);
751
+ await collection.insertOne({ _id: 'deleted', data: null, buckets: [], lookups: [], pending_delete: 1n });
752
+ await db.sourceTables(streamId).updateOne({ _id: sourceId }, { $set: { latest_pending_delete: 1n } });
753
+ await using snapshot = await context.bucketStorage.createWriter({
754
+ ...test_utils.BATCH_OPTIONS,
755
+ skipExistingRows: true
756
+ });
757
+ const cleanup = new SourceRecordStoreV3(
758
+ db,
759
+ streamId,
760
+ context.bucketStorage.getParsedSyncConfigSet(test_utils.BATCH_OPTIONS).mapping
761
+ );
762
+ const load = SourceRecordStoreV3.prototype.loadDocuments;
763
+ using read = vi.spyOn(SourceRecordStoreV3.prototype, 'loadDocuments').mockImplementationOnce(async function (
764
+ this: SourceRecordStoreV3,
765
+ ...args
766
+ ) {
767
+ const records = await load.apply(this, args);
768
+ // A previous process does not share this lease's local admission mutex.
769
+ await cleanup.postCommitCleanup(1n, context.stream.logger);
770
+ expect((await collection.find({}).toArray()).find((row) => row._id === 'deleted') ?? null).toBeNull();
771
+ return records;
772
+ });
773
+ await snapshot.save({
774
+ sourceTable: context.table,
775
+ tag: storage.SaveOperationTag.INSERT,
776
+ after: { id: 'deleted', description: 'old snapshot' },
777
+ afterReplicaId: 'deleted'
778
+ });
779
+ await snapshot.flush();
780
+ expect(read).toHaveBeenCalledTimes(1);
781
+ expect((await collection.find({}).toArray()).find((row) => row._id === 'deleted') ?? null).toBeNull();
782
+ expect(snapshot.last_flushed_op).toBe(1n);
783
+ });
784
+
785
+ test('previous-owner cleanup cannot delete a newly live record', async () => {
786
+ const context = await open(version);
787
+ await using _factory = context.factory;
788
+ await using writer = context.writer;
789
+ const db = context.bucketStorage.db;
790
+ const streamId = context.stream.replicationStreamId;
791
+ const sourceId = context.table.id as bson.ObjectId;
792
+ const collection = db.sourceRecords(streamId, sourceId);
793
+ await collection.insertOne({ _id: 'resurrected', data: null, buckets: [], lookups: [], pending_delete: 1n });
794
+ await db.sourceTables(streamId).updateOne({ _id: sourceId }, { $set: { latest_pending_delete: 1n } });
795
+ using _collections = vi.spyOn(db, 'sourceRecords').mockReturnValue(collection);
796
+ const entered = Promise.withResolvers<void>();
797
+ const release = Promise.withResolvers<void>();
798
+ const deleteMany = collection.deleteMany.bind(collection);
799
+ using _deletion = vi.spyOn(collection, 'deleteMany').mockImplementationOnce(async (...args) => {
800
+ entered.resolve();
801
+ await release.promise;
802
+ return deleteMany(...args);
803
+ });
804
+ const cleanup = new SourceRecordStoreV3(
805
+ db,
806
+ streamId,
807
+ context.bucketStorage.getParsedSyncConfigSet(test_utils.BATCH_OPTIONS).mapping
808
+ );
809
+ const cleaning = cleanup.postCommitCleanup(1n, context.stream.logger);
810
+ try {
811
+ await entered.promise;
812
+ await writer.save({
813
+ sourceTable: context.table,
814
+ tag: storage.SaveOperationTag.UPDATE,
815
+ after: { id: 'resurrected', description: 'live' },
816
+ afterReplicaId: 'resurrected'
817
+ });
818
+ await writer.flush();
819
+ release.resolve();
820
+ await cleaning;
821
+ const record = (await collection.find({}).toArray()).find((row) => row._id === 'resurrected');
822
+ expect(record!.pending_delete).toBeUndefined();
823
+ expect(bson.deserialize(record!.data!.buffer)).toEqual({ id: 'resurrected', description: 'live' });
824
+ } finally {
825
+ release.resolve();
826
+ await Promise.allSettled([cleaning]);
827
+ }
828
+ });
829
+ });