@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
@@ -0,0 +1,203 @@
1
+ import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
2
+ import { PerformanceTracer, Span, storage, updateSyncRulesFromYaml } from '@powersync/service-core';
3
+ import { compactActive, getTestStorage, test_utils } from '@powersync/service-core-tests';
4
+ import { describe, expect, test, vi } from 'vitest';
5
+ import { MongoSyncBucketStorageV3 } from '../../src/storage/implementation/v3/MongoSyncBucketStorageV3.js';
6
+ import { PersistedBatchV3 } from '../../src/storage/implementation/v3/PersistedBatchV3.js';
7
+ import { env } from './env.js';
8
+ import { MemoryObjectStorage } from './helpers/MemoryObjectStorage.js';
9
+ import { PublicationCommandMonitor } from './helpers/PublicationCommandMonitor.js';
10
+
11
+ const SYNC_CONFIG = `
12
+ config:
13
+ edition: 3
14
+ streams:
15
+ items:
16
+ query: SELECT id, description FROM items
17
+ `;
18
+
19
+ describe.each([3, 4])('replication publication baseline v%s', (storageVersion) => {
20
+ test.each([
21
+ { enabled: false, offloaded: false, compaction: false },
22
+ { enabled: false, offloaded: false, compaction: true },
23
+ { enabled: true, offloaded: false, compaction: false },
24
+ { enabled: true, offloaded: true, compaction: false },
25
+ { enabled: true, offloaded: false, compaction: true },
26
+ { enabled: true, offloaded: true, compaction: true }
27
+ ])('records wire commands and uploads: %j', async ({ enabled, offloaded, compaction }) => {
28
+ const objectStorage = new MemoryObjectStorage();
29
+ const factoryGen = mongoTestStorageFactoryGenerator({
30
+ url: env.MONGO_TEST_URL,
31
+ isCI: env.CI,
32
+ monitorCommands: true,
33
+ objectStorage: enabled ? objectStorage : undefined,
34
+ inlineThresholdBytes: offloaded ? 0 : 16 * 1024
35
+ });
36
+ await using factory = await factoryGen.factory();
37
+ const stream = await factory.updateSyncRules(updateSyncRulesFromYaml(SYNC_CONFIG, { storageVersion }));
38
+ const bucketStorage = (await getTestStorage(factory, stream)) as MongoSyncBucketStorageV3;
39
+ const tracer = new PerformanceTracer<'storage' | 'evaluate'>('replication publication baseline');
40
+ await using writer = await bucketStorage.createWriter({ ...test_utils.BATCH_OPTIONS, tracer });
41
+ const table = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 1);
42
+ await writer.markAllSnapshotDone('1/1');
43
+
44
+ async function save(description: string) {
45
+ await writer.save({
46
+ sourceTable: table,
47
+ tag: storage.SaveOperationTag.UPDATE,
48
+ after: { id: 'A', description: description.repeat(1000) },
49
+ afterReplicaId: test_utils.rid('A')
50
+ });
51
+ }
52
+
53
+ // Activate the config, reserve IDs and clear errors before measurement.
54
+ // Two versions ensure real compaction can replace a chunk and bucket state.
55
+ await save('a');
56
+ await writer.commit('1/2');
57
+ await save('b');
58
+ await writer.commit('1/3');
59
+ const horizon = (await bucketStorage.getCheckpoint()).checkpoint;
60
+ const definitionId = stream.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
61
+ const bucketStates = bucketStorage.db.bucketState(bucketStorage.replicationStreamId);
62
+ const stateBefore = await bucketStates.findOne({ '_id.d': definitionId });
63
+ expect(stateBefore?.bucket_stats.count).toBe(2);
64
+
65
+ using monitor = new PublicationCommandMonitor(factory.db.client);
66
+ const spans: Span[] = [];
67
+ const span = tracer.span.bind(tracer);
68
+ using _traceSpy = vi.spyOn(tracer, 'span').mockImplementation((...args) => {
69
+ const result = span(...args);
70
+ spans.push(result);
71
+ return result;
72
+ });
73
+ let compacted = false;
74
+ let compacting = false;
75
+ async function compactOnce() {
76
+ if (!compaction || compacted) {
77
+ return;
78
+ }
79
+ compacted = true;
80
+ compacting = true;
81
+ try {
82
+ await compactActive(factory, {
83
+ maxOpId: horizon,
84
+ compactBuckets: [stateBefore!._id.b],
85
+ minBucketChanges: 1,
86
+ minChangeRatio: 0
87
+ });
88
+ } finally {
89
+ compacting = false;
90
+ }
91
+ }
92
+
93
+ const uploads: { path: string; bytes: number; startedAt: number; endedAt: number; transactions: string[] }[] = [];
94
+ const put = objectStorage.put.bind(objectStorage);
95
+ using _putSpy = vi.spyOn(objectStorage, 'put').mockImplementation(async (...args) => {
96
+ if (compacting) {
97
+ return put(...args);
98
+ }
99
+ const sample = {
100
+ path: args[0],
101
+ bytes: args[1].byteLength,
102
+ startedAt: performance.now(),
103
+ endedAt: 0,
104
+ transactions: monitor.activeReplicationAttempts.map((attempt) => attempt.key)
105
+ };
106
+ uploads.push(sample);
107
+ // Hold the replication PUT open while real compaction commits. No elapsed
108
+ // time assertion or scheduler race is needed to force the stale snapshot.
109
+ await compactOnce();
110
+ await put(...args);
111
+ sample.endedAt = performance.now();
112
+ });
113
+ const flush = PersistedBatchV3.prototype.publish;
114
+ using _flushSpy = vi.spyOn(PersistedBatchV3.prototype, 'publish').mockImplementation(async function (
115
+ this: PersistedBatchV3,
116
+ ...args
117
+ ) {
118
+ // Inline batches have no PUT to pause. Compact after lookup/evaluation,
119
+ // immediately before the writer persists its bucket-state delta instead.
120
+ if (!offloaded) {
121
+ await compactOnce();
122
+ }
123
+ return flush.apply(this, args);
124
+ });
125
+
126
+ await save('c');
127
+ await writer.commit('1/4');
128
+ const summary = monitor.summary();
129
+ const attempts = monitor.replicationAttempts;
130
+ // Compaction during an offloaded PUT now finishes before replication opens
131
+ // its transaction. Inline work still injects compaction after the fence.
132
+ const conflict = compaction && !offloaded;
133
+ expect(compacted).toBe(compaction);
134
+ expect(attempts).toHaveLength(conflict ? 2 : 1);
135
+ expect(attempts.map((attempt) => attempt.outcome)).toEqual(conflict ? ['aborted', 'committed'] : ['committed']);
136
+ expect(monitor.activeReplicationAttempts).toHaveLength(0);
137
+ const bodies = spans.filter((span) => span.name === 'storage:transaction_body');
138
+ expect(bodies).toHaveLength(attempts.length);
139
+ expect(bodies.every((span) => span.endAt >= span.startAt)).toBe(true);
140
+ const transactions = spans.filter((span) => span.name === 'storage:transaction');
141
+ expect(transactions).toHaveLength(1);
142
+ for (const body of bodies) {
143
+ expect(transactions[0].startAt).toBeLessThanOrEqual(body.startAt);
144
+ expect(transactions[0].endAt).toBeGreaterThanOrEqual(body.endAt);
145
+ }
146
+ // Ending a parent span also ends its children. Check containment of work
147
+ // spans to catch a transaction span disposed before its promise settles.
148
+ for (const persist of spans.filter((span) => span.name === 'storage:persist_flush')) {
149
+ expect(bodies.some((body) => body.startAt <= persist.startAt && body.endAt >= persist.endAt)).toBe(true);
150
+ }
151
+ expect(tracer.stack).toHaveLength(0);
152
+ expect(uploads).toHaveLength(offloaded ? 1 : 0);
153
+ // Real compaction can run throughout a replication PUT without extending or
154
+ // invalidating a replication transaction: the fence has not started yet.
155
+ for (const upload of uploads) {
156
+ expect(upload.transactions).toHaveLength(0);
157
+ expect(attempts[0].startedAt).toBeGreaterThanOrEqual(upload.endedAt);
158
+ }
159
+ expect(new Set(uploads.map((upload) => upload.path)).size).toBe(uploads.length);
160
+ expect(summary.replicationAttempts.at(-1)?.commands).toMatchObject({ findAndModify: 1, commitTransaction: 1 });
161
+ if (summary.replicationAttempts.at(-1)?.commands.bulkWrite != null) {
162
+ // The source-size query and grouped source-record read (R = 2) now run
163
+ // outside the transaction, once per preparation. A conflict omits the
164
+ // commit but adds an abort command; replay sends no source reads. The
165
+ // final data group now executes the independent head/checkpoint tail too.
166
+ expect(summary.replicationAttempts.map((attempt) => attempt.commands)).toEqual([
167
+ ...(conflict ? [{ findAndModify: 1, bulkWrite: 1, abortTransaction: 1 }] : []),
168
+ { findAndModify: 1, bulkWrite: 1, commitTransaction: 1 }
169
+ ]);
170
+ }
171
+
172
+ if (process.env.POWERSYNC_PUBLICATION_BASELINE) {
173
+ console.info(
174
+ JSON.stringify({
175
+ storageVersion,
176
+ objectStorageEnabled: enabled,
177
+ offloaded,
178
+ compaction,
179
+ ...summary,
180
+ uploads: uploads.map((upload) => ({ bytes: upload.bytes, durationMs: upload.endedAt - upload.startedAt })),
181
+ transactionBodyMs: bodies.map((span) => (span.endAt - span.startAt) / 1000)
182
+ })
183
+ );
184
+ }
185
+
186
+ // Keep verification commands outside the measured summary.
187
+ const checkpoint = await bucketStorage.getCheckpoint();
188
+ expect(checkpoint.lsn).toBe('1/4');
189
+ expect(checkpoint.checkpoint).toBe(horizon + 1n);
190
+ const documents = await bucketStorage.db
191
+ .bucketData(bucketStorage.replicationStreamId, definitionId)
192
+ .find({})
193
+ .toArray();
194
+ const referencedPaths = documents.flatMap((doc) => (doc.storage_ref ? [doc.storage_ref.path] : []));
195
+ for (const path of referencedPaths) {
196
+ expect(objectStorage.store.has(path)).toBe(true);
197
+ }
198
+ if (offloaded && compaction) {
199
+ expect(referencedPaths).toContain(uploads[0].path);
200
+ expect(objectStorage.store.has(uploads[0].path)).toBe(true);
201
+ }
202
+ });
203
+ });
@@ -0,0 +1,212 @@
1
+ import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
2
+ import { mongo } from '@powersync/lib-service-mongodb';
3
+ import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
4
+ import { getTestStorage, test_utils } from '@powersync/service-core-tests';
5
+ import { describe, expect, test, vi } from 'vitest';
6
+ import { MongoWriteBatch } from '../../src/storage/implementation/MongoWriteBatch.js';
7
+ import { MongoBucketBatchV3 } from '../../src/storage/implementation/v3/MongoBucketBatchV3.js';
8
+ import { MongoSyncBucketStorageV3 } from '../../src/storage/implementation/v3/MongoSyncBucketStorageV3.js';
9
+ import { PersistedBatchV3 } from '../../src/storage/implementation/v3/PersistedBatchV3.js';
10
+ import { ObjectStorageLifecycle } from '../../src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js';
11
+ import { env } from './env.js';
12
+ import { MemoryObjectStorage } from './helpers/MemoryObjectStorage.js';
13
+ import { PublicationCommandMonitor } from './helpers/PublicationCommandMonitor.js';
14
+
15
+ const CONFIG = `
16
+ config:
17
+ edition: 3
18
+ streams:
19
+ items:
20
+ query: SELECT * FROM items
21
+ `;
22
+
23
+ async function save(writer: storage.BucketStorageBatch, table: storage.SourceTable, id: string) {
24
+ await writer.save({
25
+ sourceTable: table,
26
+ tag: storage.SaveOperationTag.UPDATE,
27
+ after: { id, description: id },
28
+ afterReplicaId: test_utils.rid(id)
29
+ });
30
+ }
31
+
32
+ async function open(version: number, mode = 'disabled') {
33
+ const objectStorage = new MemoryObjectStorage();
34
+ const factoryGen = mongoTestStorageFactoryGenerator({
35
+ url: env.MONGO_TEST_URL,
36
+ isCI: env.CI,
37
+ monitorCommands: true,
38
+ objectStorage: mode === 'disabled' ? undefined : objectStorage,
39
+ inlineThresholdBytes: mode === 'inline' ? 16 * 1024 : 0
40
+ });
41
+ const factory = await factoryGen.factory();
42
+ const stream = await factory.updateSyncRules(updateSyncRulesFromYaml(CONFIG, { storageVersion: version }));
43
+ const bucketStorage = (await getTestStorage(factory, stream)) as MongoSyncBucketStorageV3;
44
+ const writer = (await bucketStorage.createWriter(test_utils.BATCH_OPTIONS)) as MongoBucketBatchV3;
45
+ const table = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 1);
46
+ await writer.markAllSnapshotDone('1/1');
47
+ // Reserve IDs and activate the config before measuring steady-state publication.
48
+ await save(writer, table, 'A');
49
+ await writer.commit('1/1');
50
+ await save(writer, table, 'B');
51
+ await writer.commit('1/2');
52
+ return { factory, factoryGen, stream, bucketStorage, writer, table, objectStorage };
53
+ }
54
+
55
+ function countStreamReads(client: mongo.MongoClient) {
56
+ let count = 0;
57
+ const observe = (event: mongo.CommandStartedEvent) => {
58
+ if (event.commandName === 'find' && event.command.find === 'sync_rules' && event.command.autocommit !== false) {
59
+ count++;
60
+ }
61
+ };
62
+ client.on('commandStarted', observe);
63
+ return {
64
+ get count() {
65
+ return count;
66
+ },
67
+ [Symbol.dispose]: () => client.off('commandStarted', observe)
68
+ };
69
+ }
70
+
71
+ async function supportsClientBulkWrites(db: mongo.Db): Promise<boolean> {
72
+ // These integration fixtures run a replica set with one server version. Keep
73
+ // wire expectations conditional on that server's capabilities; forcing the
74
+ // client API on MongoDB 6/7 bypasses the production collection fallback.
75
+ const hello = await db.admin().command({ hello: 1 });
76
+ return hello.maxWireVersion >= 25;
77
+ }
78
+
79
+ describe.each([3, 4])('publication batching v%s', (version) => {
80
+ test.each([
81
+ { mode: 'disabled', fallback: false },
82
+ { mode: 'inline', fallback: false },
83
+ { mode: 'offloaded', fallback: false },
84
+ { mode: 'disabled', fallback: true },
85
+ { mode: 'inline', fallback: true },
86
+ { mode: 'offloaded', fallback: true }
87
+ ])('measures steady-state wire commands: %j', async ({ mode, fallback }) => {
88
+ const context = await open(version, mode);
89
+ await using factory = context.factory;
90
+ await using writer = context.writer;
91
+ const clientBulks = !fallback && (await supportsClientBulkWrites(factory.db.db));
92
+ const createWriteBatch = factory.db.createWriteBatch.bind(factory.db);
93
+ using _bulk = vi
94
+ .spyOn(factory.db, 'createWriteBatch')
95
+ .mockImplementation((session, options) =>
96
+ fallback ? new MongoWriteBatch(factory.db.client, false, session, options) : createWriteBatch(session, options)
97
+ );
98
+ using monitor = new PublicationCommandMonitor(factory.db.client);
99
+ using reads = countStreamReads(factory.db.client);
100
+ using registration = vi.spyOn(ObjectStorageLifecycle.prototype, 'prepareUploads');
101
+ await save(writer, context.table, 'C');
102
+ await writer.commit('1/3');
103
+ expect(reads.count).toBe(0);
104
+ expect(registration).toHaveBeenCalledTimes(mode === 'offloaded' ? 1 : 0);
105
+ expect(monitor.summary().replicationAttempts.map((attempt) => attempt.commands)).toEqual([
106
+ !clientBulks
107
+ ? {
108
+ findAndModify: 1,
109
+ // Offloaded storage also updates the writer's usage document.
110
+ update: mode === 'offloaded' ? 5 : 4,
111
+ insert: 1,
112
+ ...(mode === 'offloaded' ? { delete: 1 } : {}),
113
+ commitTransaction: 1
114
+ }
115
+ : { findAndModify: 1, bulkWrite: 1, commitTransaction: 1 }
116
+ ]);
117
+ const checkpoint = await context.bucketStorage.getCheckpoint();
118
+ expect(checkpoint).toMatchObject({ checkpoint: 3n, lsn: '1/3' });
119
+ });
120
+
121
+ test('shares confirmed heads between writers without retaining writer-local progress', async () => {
122
+ const context = await open(version);
123
+ await using factory = context.factory;
124
+ await using first = context.writer;
125
+ await using second = (await context.bucketStorage.createWriter(test_utils.BATCH_OPTIONS)) as MongoBucketBatchV3;
126
+ using reads = countStreamReads(factory.db.client);
127
+ await save(first, context.table, 'C');
128
+ await first.flush();
129
+ await save(second, context.table, 'D');
130
+ await second.commit('1/3');
131
+ await first.commit('1/4');
132
+ expect(reads.count).toBe(0);
133
+ expect(first.last_flushed_op).toBe(3n);
134
+ expect(second.last_flushed_op).toBe(4n);
135
+ expect(await context.bucketStorage.getCheckpoint()).toMatchObject({ checkpoint: 4n, lsn: '1/4' });
136
+ });
137
+
138
+ test('aborted publication leaves progress unchanged and stops the lease', async () => {
139
+ const context = await open(version);
140
+ await using factory = context.factory;
141
+ await using writer = context.writer;
142
+ using reads = countStreamReads(factory.db.client);
143
+ const publish = PersistedBatchV3.prototype.publish;
144
+ using _failure = vi.spyOn(PersistedBatchV3.prototype, 'publish').mockImplementationOnce(async function (
145
+ this: PersistedBatchV3,
146
+ ...args
147
+ ) {
148
+ await publish.apply(this, args);
149
+ throw new Error('abort combined data and checkpoint');
150
+ });
151
+ await save(writer, context.table, 'C');
152
+ await expect(writer.commit('1/3')).rejects.toThrow('abort combined data and checkpoint');
153
+ expect(reads.count).toBe(0);
154
+ await expect(writer.commit('1/3')).rejects.toThrow('restart replication from persisted progress');
155
+ expect(await context.bucketStorage.getCheckpoint()).toMatchObject({ checkpoint: 2n, lsn: '1/2' });
156
+ });
157
+
158
+ test('validates live configs from the fence', async () => {
159
+ const context = await open(version);
160
+ await using factory = context.factory;
161
+ await using writer = context.writer;
162
+ await factory.db.sync_rules.updateOne(
163
+ { _id: context.stream.replicationStreamId },
164
+ { $set: { 'sync_configs.$[].state': storage.SyncRuleState.STOP } },
165
+ { writeConcern: { w: 'majority' } }
166
+ );
167
+ using reads = countStreamReads(factory.db.client);
168
+ await save(writer, context.table, 'C');
169
+ await expect(writer.flush()).rejects.toThrow('reload');
170
+ expect(reads.count).toBe(0);
171
+ expect(writer.last_flushed_op).toBe(2n);
172
+ await expect(writer.commit('1/3')).rejects.toThrow('restart replication from persisted progress');
173
+ });
174
+
175
+ test('replacement lease publishes from durable progress', async () => {
176
+ const context = await open(version);
177
+ await using factory = context.factory;
178
+ await using previous = context.writer;
179
+ await test_utils.releaseTestStorageLease(factory, context.stream.replicationStreamId);
180
+ const stream = (await factory.getReplicatingReplicationStreams())[0];
181
+ const restarted = await getTestStorage(factory, stream);
182
+ await using next = await restarted.createWriter(test_utils.BATCH_OPTIONS);
183
+ const table = await test_utils.resolveTestTable(next, 'items', ['id'], context.factoryGen, 1);
184
+ using reads = countStreamReads(factory.db.client);
185
+ await save(next, table, 'C');
186
+ await next.commit('1/3');
187
+ expect(reads.count).toBe(0);
188
+ await expect(previous.commit('1/4')).rejects.toThrow('Replication lock released');
189
+ });
190
+
191
+ test('measures checked-bulk result cursors for 1000 unique rows', async () => {
192
+ const context = await open(version, 'offloaded');
193
+ await using factory = context.factory;
194
+ await using writer = context.writer;
195
+ const clientBulks = await supportsClientBulkWrites(factory.db.db);
196
+ using monitor = new PublicationCommandMonitor(factory.db.client);
197
+ using reads = countStreamReads(factory.db.client);
198
+ using put = vi.spyOn(context.objectStorage, 'put');
199
+ for (let i = 0; i < 1000; i++) {
200
+ await save(writer, context.table, `row${i}`);
201
+ }
202
+ await writer.commit('1/3');
203
+ expect(reads.count).toBe(0);
204
+ expect(put).toHaveBeenCalledTimes(1);
205
+ expect(monitor.summary().replicationAttempts.map((attempt) => attempt.commands)).toEqual([
206
+ clientBulks
207
+ ? { findAndModify: 1, bulkWrite: 1, commitTransaction: 1 }
208
+ : { findAndModify: 1, update: 5, delete: 1, insert: 1, commitTransaction: 1 }
209
+ ]);
210
+ expect((await context.bucketStorage.getCheckpoint()).checkpoint).toBe(1002n);
211
+ });
212
+ });