@powersync/service-module-mongodb-storage 0.22.0 → 0.23.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 (80) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +5 -1
  3. package/dist/storage/MongoBucketStorage.js +30 -6
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +23 -10
  6. package/dist/storage/implementation/MongoBucketBatch.js +132 -88
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoCompactor.d.ts +2 -0
  9. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  10. package/dist/storage/implementation/MongoIdSequence.d.ts +10 -1
  11. package/dist/storage/implementation/MongoIdSequence.js +14 -1
  12. package/dist/storage/implementation/MongoIdSequence.js.map +1 -1
  13. package/dist/storage/implementation/MongoOpIdAllocator.d.ts +27 -0
  14. package/dist/storage/implementation/MongoOpIdAllocator.js +97 -0
  15. package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -0
  16. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +5 -0
  17. package/dist/storage/implementation/MongoSyncBucketStorage.js +17 -0
  18. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  19. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +26 -2
  20. package/dist/storage/implementation/MongoSyncRulesLock.js +38 -1
  21. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  22. package/dist/storage/implementation/db.js +1 -0
  23. package/dist/storage/implementation/db.js.map +1 -1
  24. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +5 -0
  25. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +136 -133
  26. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  27. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +1 -1
  28. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  29. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +5 -1
  30. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +131 -148
  31. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  32. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +4 -3
  33. package/dist/storage/implementation/v3/MongoCompactorV3.js +14 -10
  34. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  35. package/dist/storage/implementation/v3/compact-utils.d.ts +3 -2
  36. package/dist/storage/implementation/v3/compact-utils.js +12 -5
  37. package/dist/storage/implementation/v3/compact-utils.js.map +1 -1
  38. package/dist/storage/implementation/v3/models.d.ts +1 -1
  39. package/dist/utils/test-utils.d.ts +2 -1
  40. package/dist/utils/test-utils.js +4 -3
  41. package/dist/utils/test-utils.js.map +1 -1
  42. package/package.json +7 -7
  43. package/src/storage/MongoBucketStorage.ts +35 -7
  44. package/src/storage/implementation/MongoBucketBatch.ts +201 -110
  45. package/src/storage/implementation/MongoCompactor.ts +2 -0
  46. package/src/storage/implementation/MongoIdSequence.ts +21 -1
  47. package/src/storage/implementation/MongoOpIdAllocator.ts +105 -0
  48. package/src/storage/implementation/MongoSyncBucketStorage.ts +19 -0
  49. package/src/storage/implementation/MongoSyncRulesLock.ts +56 -3
  50. package/src/storage/implementation/db.ts +1 -0
  51. package/src/storage/implementation/models.ts +1 -0
  52. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +150 -152
  53. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +1 -1
  54. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +167 -194
  55. package/src/storage/implementation/v3/MongoCompactorV3.ts +19 -22
  56. package/src/storage/implementation/v3/compact-utils.ts +16 -7
  57. package/src/storage/implementation/v3/models.ts +1 -1
  58. package/src/utils/test-utils.ts +5 -3
  59. package/test/src/__snapshots__/storage.test.ts.snap +102 -2
  60. package/test/src/__snapshots__/storage_compacting.test.ts.snap +2 -2
  61. package/test/src/__snapshots__/storage_sync.test.ts.snap +12 -12
  62. package/test/src/cleanup-stopped-sync-configs.test.ts +19 -13
  63. package/test/src/compact-utils.test.ts +33 -8
  64. package/test/src/concurrent_writers.test.ts +599 -0
  65. package/test/src/helpers/concurrentWriter.mjs +29 -0
  66. package/test/src/helpers/s3TestFactory.ts +12 -12
  67. package/test/src/object_storage_usage.test.ts +2 -1
  68. package/test/src/parameter_compacting_v1.test.ts +2 -2
  69. package/test/src/parameter_compacting_v3.test.ts +2 -2
  70. package/test/src/parameter_compaction_fence.test.ts +3 -3
  71. package/test/src/storage.test.ts +32 -33
  72. package/test/src/storage_compacting.test.ts +147 -21
  73. package/test/src/storage_object_storage_inline_threshold.test.ts +3 -3
  74. package/test/src/storage_s3_checksums.test.ts +4 -4
  75. package/test/src/storage_s3_compaction_lifecycle.test.ts +8 -8
  76. package/test/src/storage_s3_reading.test.ts +4 -4
  77. package/test/src/storage_s3_writing.test.ts +3 -3
  78. package/test/src/storage_sync.test.ts +52 -46
  79. package/test/src/storeCurrentData.test.ts +5 -5
  80. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,599 @@
1
+ import { mongo } from '@powersync/lib-service-mongodb';
2
+ import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
3
+ import { test_utils } from '@powersync/service-core-tests';
4
+ import { execFile } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { promisify } from 'node:util';
7
+ import { describe, expect, test, vi } from 'vitest';
8
+ import { PersistedBatch } from '../../src/storage/implementation/common/PersistedBatch.js';
9
+ import { MongoOpIdAllocator } from '../../src/storage/implementation/MongoOpIdAllocator.js';
10
+ import { MongoPersistedReplicationStream } from '../../src/storage/implementation/MongoPersistedReplicationStream.js';
11
+ import { mongoTestStorageFactoryGenerator } from '../../src/utils/test-utils.js';
12
+ import { env } from './env.js';
13
+
14
+ const factoryGen = mongoTestStorageFactoryGenerator({ url: env.MONGO_TEST_URL, isCI: env.CI });
15
+ const rules = `bucket_definitions:
16
+ global:
17
+ data:
18
+ - SELECT id, description FROM items
19
+ `;
20
+
21
+ async function openStream(factory: Awaited<ReturnType<typeof factoryGen.factory>>, version: number) {
22
+ const stream = await factory.updateSyncRules(updateSyncRulesFromYaml(rules, { storageVersion: version }));
23
+ const bucketStorage = await test_utils.getTestStorage(factory, stream);
24
+ const writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
25
+ const table = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, stream.replicationStreamId);
26
+ await writer.markAllSnapshotDone('1/1');
27
+ return { stream, bucketStorage, writer, table };
28
+ }
29
+
30
+ async function insert(writer: storage.BucketStorageBatch, table: storage.SourceTable, id: string) {
31
+ await writer.save({
32
+ sourceTable: table,
33
+ tag: storage.SaveOperationTag.INSERT,
34
+ after: { id, description: id },
35
+ afterReplicaId: test_utils.rid(id)
36
+ });
37
+ }
38
+
39
+ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
40
+ test('requires a lease to create a writer, including after another lease is released', async () => {
41
+ await using factory = await factoryGen.factory();
42
+ const stream = await factory.updateSyncRules(updateSyncRulesFromYaml(rules, { storageVersion: version }));
43
+ const unleased = factory.getInstance(stream);
44
+ await expect(unleased.createWriter(test_utils.BATCH_OPTIONS)).rejects.toThrow('replication lease is required');
45
+ const lock = await stream.lock();
46
+ try {
47
+ const leased = factory.getInstance(stream, { replicationLock: lock });
48
+ await using writer = await leased.createWriter(test_utils.BATCH_OPTIONS);
49
+ await lock.release();
50
+ await expect(writer.commit('1/2')).rejects.toThrow('Replication lock released');
51
+ await expect(leased.createWriter(test_utils.BATCH_OPTIONS)).rejects.toThrow('Replication lock released');
52
+ await expect(unleased.createWriter(test_utils.BATCH_OPTIONS)).rejects.toThrow('replication lease is required');
53
+ } finally {
54
+ await lock.release();
55
+ }
56
+ });
57
+
58
+ test('combines the final flush and checkpoint into one transaction', async () => {
59
+ const monitored = mongoTestStorageFactoryGenerator({
60
+ url: env.MONGO_TEST_URL,
61
+ isCI: env.CI,
62
+ monitorCommands: true
63
+ });
64
+ await using factory = await monitored.factory();
65
+ const a = await openStream(factory, version);
66
+ await using writer = a.writer;
67
+ // Warm up activation, the reservation and error clearing before measuring.
68
+ await insert(writer, a.table, 'warmup');
69
+ await writer.commit('1/2');
70
+ const commands: string[] = [];
71
+ const listener = (event: mongo.CommandStartedEvent) => {
72
+ if (event.command.autocommit === false) {
73
+ commands.push(event.commandName);
74
+ }
75
+ };
76
+ factory.db.client.on('commandStarted', listener);
77
+ try {
78
+ await insert(writer, a.table, 'separate');
79
+ await writer.flush();
80
+ await writer.commit('1/3');
81
+ const separate = [...commands];
82
+ commands.length = 0;
83
+ await insert(writer, a.table, 'combined');
84
+ await writer.commit('1/4');
85
+ expect(separate.filter((name) => name === 'commitTransaction')).toHaveLength(2);
86
+ expect(commands.filter((name) => name === 'commitTransaction')).toHaveLength(1);
87
+ expect(separate.length - commands.length).toBe(version < 3 ? 2 : 3);
88
+ expect((await a.bucketStorage.getCheckpoint()).checkpoint).toBe(3n);
89
+ } finally {
90
+ factory.db.client.off('commandStarted', listener);
91
+ }
92
+ });
93
+
94
+ test('only checkpoints the last piece of a split flush', async () => {
95
+ await using factory = await factoryGen.factory();
96
+ const a = await openStream(factory, version);
97
+ await using writer = a.writer;
98
+ await insert(writer, a.table, 'warmup');
99
+ await writer.commit('1/2');
100
+ // Force a split after each row without needing a transaction-sized fixture.
101
+ const split = vi.spyOn(PersistedBatch.prototype, 'shouldFlushTransaction').mockReturnValue(true);
102
+ const flush = PersistedBatch.prototype.flush;
103
+ const observed: bigint[] = [];
104
+ const inspect = vi.spyOn(PersistedBatch.prototype, 'flush').mockImplementation(async function (
105
+ this: PersistedBatch,
106
+ ...args
107
+ ) {
108
+ observed.push((await a.bucketStorage.getCheckpoint()).checkpoint);
109
+ return flush.apply(this, args);
110
+ });
111
+ try {
112
+ await insert(writer, a.table, 'first');
113
+ await insert(writer, a.table, 'last');
114
+ await writer.commit('1/3');
115
+ expect(observed).toEqual([1n, 1n]);
116
+ expect((await a.bucketStorage.getCheckpoint()).checkpoint).toBe(3n);
117
+ } finally {
118
+ inspect.mockRestore();
119
+ split.mockRestore();
120
+ }
121
+ });
122
+
123
+ test('retries the final rows and checkpoint together after a transaction conflict', async () => {
124
+ await using factory = await factoryGen.factory();
125
+ const a = await openStream(factory, version);
126
+ await using writer = a.writer;
127
+ await insert(writer, a.table, 'warmup');
128
+ await writer.commit('1/2');
129
+ const withTransaction = mongo.ClientSession.prototype.withTransaction;
130
+ let attempts = 0;
131
+ const retry = vi.spyOn(mongo.ClientSession.prototype, 'withTransaction').mockImplementationOnce(function (
132
+ this: mongo.ClientSession,
133
+ callback,
134
+ options
135
+ ) {
136
+ return withTransaction.call(
137
+ this,
138
+ async (session) => {
139
+ const result = await callback(session);
140
+ // Neither the final rows nor their checkpoint have committed yet.
141
+ expect((await a.bucketStorage.getCheckpoint()).checkpoint).toBe(1n);
142
+ expect(writer.last_flushed_op).toBe(1n);
143
+ attempts += 1;
144
+ if (attempts === 1) {
145
+ throw new mongo.MongoServerError({
146
+ message: 'retry checkpoint',
147
+ code: 112,
148
+ errorLabels: ['TransientTransactionError']
149
+ });
150
+ }
151
+ return result;
152
+ },
153
+ options
154
+ );
155
+ });
156
+ try {
157
+ await insert(writer, a.table, 'retried');
158
+ await writer.commit('1/3');
159
+ expect(attempts).toBe(2);
160
+ expect(writer.last_flushed_op).toBe(2n);
161
+ expect((await a.bucketStorage.getCheckpoint()).checkpoint).toBe(2n);
162
+ } finally {
163
+ retry.mockRestore();
164
+ }
165
+ });
166
+
167
+ test('a blocked combined checkpoint preserves the head for a new writer', async () => {
168
+ await using factory = await factoryGen.factory();
169
+ const a = await openStream(factory, version);
170
+ await using writer = a.writer;
171
+ await writer.markAllSnapshotDone('1/9');
172
+ await insert(writer, a.table, 'blocked');
173
+ expect(await writer.commit('1/2')).toMatchObject({ checkpointBlocked: true, checkpointCreated: false });
174
+ expect((await a.bucketStorage.getCheckpoint()).checkpoint).toBe(0n);
175
+ await using resumed = await a.bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
176
+ await resumed.commit('1/9');
177
+ expect((await a.bucketStorage.getCheckpoint()).checkpoint).toBe(writer.last_flushed_op);
178
+ expect(writer.last_flushed_op).toBe(1n);
179
+ });
180
+
181
+ test('same-stream writers queue FIFO before starting transactions', async () => {
182
+ await using factory = await factoryGen.factory();
183
+ const a = await openStream(factory, version);
184
+ await using first = a.writer;
185
+ await insert(first, a.table, 'warmup');
186
+ await first.commit('1/2');
187
+ // Separate storage instances must still share the lease's queue.
188
+ const otherStorage = factory.getInstance(a.stream);
189
+ await using checkpointWriter = await otherStorage.createWriter(test_utils.BATCH_OPTIONS);
190
+ await using last = await otherStorage.createWriter(test_utils.BATCH_OPTIONS);
191
+ const lastTable = await test_utils.resolveTestTable(
192
+ last,
193
+ 'items',
194
+ ['id'],
195
+ factoryGen,
196
+ a.stream.replicationStreamId
197
+ );
198
+ await insert(first, a.table, 'first');
199
+ await insert(last, lastTable, 'last');
200
+
201
+ const entered = Promise.withResolvers<void>();
202
+ const release = Promise.withResolvers<void>();
203
+ const queued = Promise.withResolvers<void>();
204
+ const mutex = (a.stream as MongoPersistedReplicationStream).current_lock!.writerMutex;
205
+ const exclusiveLock = mutex.exclusiveLock;
206
+ let admissions = 0;
207
+ const admission = vi.spyOn(mutex, 'exclusiveLock').mockImplementation(function (callback) {
208
+ const result = exclusiveLock.call(mutex, callback);
209
+ if (++admissions === 3) queued.resolve();
210
+ return result;
211
+ });
212
+ const transactions = vi.spyOn(mongo.ClientSession.prototype, 'withTransaction');
213
+ const flush = PersistedBatch.prototype.flush;
214
+ const stalled = vi.spyOn(PersistedBatch.prototype, 'flush').mockImplementationOnce(async function (
215
+ this: PersistedBatch,
216
+ ...args
217
+ ) {
218
+ entered.resolve();
219
+ await release.promise;
220
+ return flush.apply(this, args);
221
+ });
222
+ const pending: Promise<unknown>[] = [];
223
+ try {
224
+ pending.push(first.flush());
225
+ await entered.promise;
226
+ // The empty checkpoint must run before the later flush.
227
+ pending.push(checkpointWriter.commit('1/3'));
228
+ pending.push(last.flush());
229
+ await queued.promise;
230
+ expect(transactions).toHaveBeenCalledTimes(1);
231
+ release.resolve();
232
+ await Promise.all(pending);
233
+ expect(first.last_flushed_op).toBe(2n);
234
+ expect(last.last_flushed_op).toBe(3n);
235
+ expect((await a.bucketStorage.getCheckpoint()).checkpoint).toBe(2n);
236
+ } finally {
237
+ release.resolve();
238
+ await Promise.allSettled(pending);
239
+ stalled.mockRestore();
240
+ transactions.mockRestore();
241
+ admission.mockRestore();
242
+ }
243
+ });
244
+
245
+ test('another stream publishes while a transaction is stalled, without touching its reserved IDs', async () => {
246
+ await using factory = await factoryGen.factory();
247
+ const a = await openStream(factory, version);
248
+ await using writerA = a.writer;
249
+ // An independent factory/client has no shared allocator or in-process coordination.
250
+ await using other = await factoryGen.factory({ doNotClear: true });
251
+ const b = await openStream(other, version);
252
+ await using writerB = b.writer;
253
+ const entered = Promise.withResolvers<void>();
254
+ const release = Promise.withResolvers<void>();
255
+ const flush = PersistedBatch.prototype.flush;
256
+ const stalled = vi.spyOn(PersistedBatch.prototype, 'flush').mockImplementationOnce(async function (
257
+ this: PersistedBatch,
258
+ ...args
259
+ ) {
260
+ entered.resolve();
261
+ await release.promise;
262
+ return flush.apply(this, args);
263
+ });
264
+ await insert(writerA, a.table, 'a');
265
+ const publishingA = writerA.flush();
266
+ try {
267
+ await entered.promise;
268
+ await insert(writerB, b.table, 'b');
269
+ await writerB.commit('1/2');
270
+ expect((await b.bucketStorage.getCheckpoint()).checkpoint).toBeGreaterThan(1n);
271
+ expect((await a.bucketStorage.getCheckpoint()).checkpoint).toBe(0n);
272
+ } finally {
273
+ release.resolve();
274
+ await publishingA;
275
+ stalled.mockRestore();
276
+ }
277
+ expect(writerA.last_flushed_op).toBe(1n);
278
+ // A can keep using its lower range: B's checkpoint belongs to another stream.
279
+ await insert(writerA, a.table, 'a2');
280
+ await writerA.flush();
281
+ expect(writerA.last_flushed_op).toBe(2n);
282
+ });
283
+
284
+ test('another process can reserve and checkpoint while this process holds a stream transaction', async () => {
285
+ await using factory = await factoryGen.factory();
286
+ const a = await openStream(factory, version);
287
+ await using writerA = a.writer;
288
+ const b = await openStream(factory, version);
289
+ await b.writer.dispose();
290
+ await test_utils.releaseTestStorageLease(factory, b.stream.replicationStreamId);
291
+ const entered = Promise.withResolvers<void>();
292
+ const release = Promise.withResolvers<void>();
293
+ const flush = PersistedBatch.prototype.flush;
294
+ const stalled = vi.spyOn(PersistedBatch.prototype, 'flush').mockImplementationOnce(async function (
295
+ this: PersistedBatch,
296
+ ...args
297
+ ) {
298
+ entered.resolve();
299
+ await release.promise;
300
+ return flush.apply(this, args);
301
+ });
302
+ await insert(writerA, a.table, 'a');
303
+ const pending = writerA.flush();
304
+ try {
305
+ await entered.promise;
306
+ const child = await promisify(execFile)(
307
+ process.execPath,
308
+ [
309
+ fileURLToPath(new URL('./helpers/concurrentWriter.mjs', import.meta.url)),
310
+ env.MONGO_TEST_URL,
311
+ `${b.stream.replicationStreamId}`
312
+ ],
313
+ { timeout: 10_000 }
314
+ ).catch((error) => {
315
+ throw new Error(`${error.message}\n${error.stdout}\n${error.stderr}`);
316
+ });
317
+ expect(child.stdout).toMatch(/checkpoint=[1-9][0-9]+/);
318
+ expect((await a.bucketStorage.getCheckpoint()).checkpoint).toBe(0n);
319
+ } finally {
320
+ release.resolve();
321
+ await pending;
322
+ stalled.mockRestore();
323
+ }
324
+ }, 15_000);
325
+
326
+ test('reuses one reservation across writers and flushes and retries rolled-back writes', async () => {
327
+ await using factory = await factoryGen.factory();
328
+ const { writer: batch, table, bucketStorage } = await openStream(factory, version);
329
+ await using writer = batch;
330
+ const reservations = vi.spyOn(MongoOpIdAllocator.prototype, 'reserve');
331
+ const flush = PersistedBatch.prototype.flush;
332
+ const retry = vi.spyOn(PersistedBatch.prototype, 'flush').mockImplementationOnce(async function (
333
+ this: PersistedBatch,
334
+ ...args
335
+ ) {
336
+ await flush.apply(this, args);
337
+ throw new mongo.MongoServerError({ message: 'retry', code: 112, errorLabels: ['TransientTransactionError'] });
338
+ });
339
+ try {
340
+ for (let i = 0; i < 3; i++) {
341
+ await insert(writer, table, `${i}`);
342
+ await writer.flush();
343
+ }
344
+ await writer.commit('1/2');
345
+ expect(reservations).toHaveBeenCalledTimes(1);
346
+ expect((await bucketStorage.getCheckpoint()).checkpoint).toBe(3n);
347
+ await using nextWriter = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
348
+ const nextTable = await test_utils.resolveTestTable(nextWriter, 'items', ['id'], factoryGen, 1);
349
+ await insert(nextWriter, nextTable, 'next');
350
+ await nextWriter.commit('1/3');
351
+ expect((await bucketStorage.getCheckpoint()).checkpoint).toBe(4n);
352
+ expect(reservations).toHaveBeenCalledTimes(1);
353
+ expect((await factory.db.op_id_sequence.findOne({ _id: 'main' }))!.op_id).toBeGreaterThan(3n);
354
+ } finally {
355
+ reservations.mockRestore();
356
+ retry.mockRestore();
357
+ }
358
+ });
359
+
360
+ test.each(['during flush', 'after flush'])('graceful cancellation %s preserves snapshot progress', async (when) => {
361
+ await using factory = await factoryGen.factory();
362
+ const a = await openStream(factory, version);
363
+ await a.writer.dispose();
364
+ const abort = new AbortController();
365
+ await using writer = await a.bucketStorage.createWriter({ ...test_utils.BATCH_OPTIONS, signal: abort.signal });
366
+ const table = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, a.stream.replicationStreamId);
367
+ const flush = PersistedBatch.prototype.flush;
368
+ const interrupted = vi.spyOn(PersistedBatch.prototype, 'flush').mockImplementationOnce(async function (
369
+ this: PersistedBatch,
370
+ ...args
371
+ ) {
372
+ const result = await flush.apply(this, args);
373
+ if (when === 'during flush') {
374
+ abort.abort();
375
+ }
376
+ return result;
377
+ });
378
+ try {
379
+ await insert(writer, table, 'page');
380
+ await writer.flush();
381
+ if (when === 'after flush') {
382
+ abort.abort();
383
+ }
384
+ await writer.updateTableProgress(table, { replicatedCount: 1, totalEstimatedCount: 1 });
385
+ const persisted = await writer.getSourceTableStatus(table);
386
+ expect(persisted?.snapshotStatus?.replicatedCount).toBe(1);
387
+ await writer.commit('1/2');
388
+ expect((await a.bucketStorage.getCheckpoint()).checkpoint).toBe(1n);
389
+ } finally {
390
+ interrupted.mockRestore();
391
+ }
392
+ });
393
+
394
+ test('recovers flushed operations through existing version-specific fields', async () => {
395
+ await using factory = await factoryGen.factory();
396
+ const a = await openStream(factory, version);
397
+ await using writer = a.writer;
398
+ await insert(writer, a.table, 'checkpointed');
399
+ await writer.commit('1/2');
400
+ await insert(writer, a.table, 'flushed');
401
+ await writer.flush();
402
+ const document = await factory.db.sync_rules.findOne({ _id: a.stream.replicationStreamId });
403
+ expect(document).not.toHaveProperty('writer_transaction');
404
+ if (version < 3) {
405
+ expect(document).not.toHaveProperty('last_persisted_op');
406
+ expect(document).toMatchObject({ last_checkpoint: 1n, keepalive_op: '2' });
407
+ } else {
408
+ expect(document).toHaveProperty('last_persisted_op', 2n);
409
+ }
410
+ await using other = await factoryGen.factory({ doNotClear: true });
411
+ const stream = (await other.getReplicatingReplicationStreams())[0];
412
+ await test_utils.releaseTestStorageLease(factory, a.stream.replicationStreamId);
413
+ const bucketStorage = await test_utils.getTestStorage(other, stream);
414
+ await using resumed = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
415
+ await resumed.commit('1/3');
416
+ expect((await bucketStorage.getCheckpoint()).checkpoint).toBe(2n);
417
+ if (version < 3) {
418
+ expect(await factory.db.sync_rules.findOne({ _id: stream.replicationStreamId })).toMatchObject({
419
+ last_checkpoint: 2n,
420
+ keepalive_op: null
421
+ });
422
+ }
423
+ });
424
+
425
+ test('fencing changes the heartbeat even when it is ahead of the clock', async () => {
426
+ await using factory = await factoryGen.factory();
427
+ const a = await openStream(factory, version);
428
+ await using writer = a.writer;
429
+ const future = new Date(Date.now() + 60_000);
430
+ await factory.db.sync_rules.updateOne(
431
+ { _id: a.stream.replicationStreamId },
432
+ { $set: { last_keepalive_ts: future } }
433
+ );
434
+ await writer.setResumeLsn('1/2');
435
+ const document = await factory.db.sync_rules.findOne({ _id: a.stream.replicationStreamId });
436
+ expect(document!.last_keepalive_ts!.getTime()).toBe(future.getTime() + 1);
437
+ expect(document).not.toHaveProperty('writer_transaction');
438
+ });
439
+
440
+ test('independent writers of the same stream never publish behind its durable head', async () => {
441
+ await using factory = await factoryGen.factory();
442
+ const a = await openStream(factory, version);
443
+ await using writerA = a.writer;
444
+ await insert(writerA, a.table, 'a');
445
+ await writerA.flush();
446
+ await using other = await factoryGen.factory({ doNotClear: true });
447
+ const reloaded = (await other.getReplicatingReplicationStreams()).find(
448
+ (s) => s.replicationStreamId === a.stream.replicationStreamId
449
+ )!;
450
+ await using writerB = await other
451
+ .getInstance(reloaded, { replicationLock: (a.stream as MongoPersistedReplicationStream).current_lock! })
452
+ .createWriter(test_utils.BATCH_OPTIONS);
453
+ const tableB = await test_utils.resolveTestTable(writerB, 'items', ['id'], factoryGen, 1);
454
+ await insert(writerB, tableB, 'b');
455
+ await writerB.flush();
456
+ const higher = writerB.last_flushed_op!;
457
+ await insert(writerA, a.table, 'c');
458
+ await writerA.flush();
459
+ expect(writerA.last_flushed_op).toBeGreaterThan(higher);
460
+ await writerA.commit('1/2');
461
+ expect((await a.bucketStorage.getCheckpoint()).checkpoint).toBe(writerA.last_flushed_op);
462
+ });
463
+
464
+ test('lease takeover fences stale row, metadata, and checkpoint writes', async () => {
465
+ await using factory = await factoryGen.factory();
466
+ const initial = await openStream(factory, version);
467
+ await initial.writer.dispose();
468
+ const stream = initial.stream as MongoPersistedReplicationStream;
469
+ await test_utils.releaseTestStorageLease(factory, stream.replicationStreamId);
470
+ const first = await stream.lock();
471
+ await using firstLifetime = { [Symbol.asyncDispose]: () => first.release() };
472
+ await using oldWriter = await factory.getInstance(stream).createWriter(test_utils.BATCH_OPTIONS);
473
+ await using oldCheckpointWriter = await factory.getInstance(stream).createWriter(test_utils.BATCH_OPTIONS);
474
+ const oldTable = await test_utils.resolveTestTable(oldWriter, 'items', ['id'], factoryGen, 1);
475
+ await insert(oldWriter, oldTable, 'old');
476
+ await oldWriter.flush();
477
+ const abandonedEnd = (await factory.db.op_id_sequence.findOne({ _id: 'main' }))!.op_id;
478
+ await factory.db.sync_rules.updateOne(
479
+ { _id: stream.replicationStreamId },
480
+ { $set: { 'lock.expires_at': new Date(0) } }
481
+ );
482
+ await using other = await factoryGen.factory({ doNotClear: true });
483
+ const nextStream = (await other.getReplicatingReplicationStreams())[0];
484
+ const next = await nextStream.lock();
485
+ await using nextLifetime = { [Symbol.asyncDispose]: () => next.release() };
486
+ await using nextWriter = await other.getInstance(nextStream).createWriter(test_utils.BATCH_OPTIONS);
487
+ const nextTable = await test_utils.resolveTestTable(nextWriter, 'items', ['id'], factoryGen, 1);
488
+ await insert(nextWriter, nextTable, 'new');
489
+ await nextWriter.commit('1/2');
490
+ expect(nextWriter.last_flushed_op).toBeGreaterThan(abandonedEnd);
491
+ await insert(oldWriter, oldTable, 'stale');
492
+ await expect(oldWriter.flush()).rejects.toThrow('no longer owns');
493
+ await expect(oldWriter.setResumeLsn('9/9')).rejects.toThrow('no longer owns');
494
+ await expect(oldWriter.markAllSnapshotDone('9/9')).rejects.toThrow('no longer owns');
495
+ await expect(oldWriter.markTableSnapshotRequired(oldTable)).rejects.toThrow('no longer owns');
496
+ await expect(oldWriter.commit('9/9')).rejects.toThrow('no longer owns');
497
+ // No pending rows: this exercises the checkpoint fence, not the flush fence.
498
+ await expect(oldCheckpointWriter.commit('9/9')).rejects.toThrow('no longer owns');
499
+ await first.release();
500
+ expect((await factory.db.sync_rules.findOne({ _id: stream.replicationStreamId }))!.lock?.id).toBe(
501
+ (next as typeof first).lock_id
502
+ );
503
+ });
504
+
505
+ test.each([false, true])('extends a nearly exhausted range (fallback: %s)', async (fallback) => {
506
+ await using factory = await factoryGen.factory();
507
+ const { writer: batch, table, bucketStorage } = await openStream(factory, version);
508
+ await using writer = batch;
509
+ const allocator = factory.getOpIdAllocator(
510
+ bucketStorage.replicationStream,
511
+ bucketStorage.replicationStream.current_lock!
512
+ );
513
+ await allocator.reserve();
514
+ allocator.committed(65_535n);
515
+ // Simulate capacity becoming insufficient after the pre-transaction check.
516
+ // The fallback must still abort and retry safely in that case.
517
+ const capacity = vi.spyOn(allocator, 'ensureCapacity');
518
+ if (fallback) {
519
+ capacity.mockResolvedValueOnce(undefined);
520
+ }
521
+ const sequences = vi.spyOn(allocator, 'sequence');
522
+ try {
523
+ await insert(writer, table, 'first');
524
+ await insert(writer, table, 'second');
525
+ await writer.commit('1/2');
526
+ expect(writer.last_flushed_op).toBe(65_537n);
527
+ expect((await bucketStorage.getCheckpoint()).checkpoint).toBe(65_537n);
528
+ expect(sequences).toHaveBeenCalledTimes(fallback ? 2 : 1);
529
+ } finally {
530
+ capacity.mockRestore();
531
+ sequences.mockRestore();
532
+ }
533
+ });
534
+ });
535
+
536
+ describe('operation ID reservations', () => {
537
+ test('initializes and reserves disjoint ranges concurrently in one command each', async () => {
538
+ await using factory = await factoryGen.factory();
539
+ const a = await openStream(factory, 4);
540
+ await using writer = a.writer;
541
+ const db = factory.db.versioned(a.bucketStorage.replicationStream.getStorageConfig());
542
+ const allocators = Array.from({ length: 4 }, () => new MongoOpIdAllocator(db));
543
+ const initialize = vi.spyOn(db.op_id_sequence, 'updateOne');
544
+ const reserve = vi.spyOn(db.op_id_sequence, 'findOneAndUpdate');
545
+ try {
546
+ await Promise.all(allocators.map((allocator) => allocator.reserve()));
547
+ const firstIds = allocators.map((allocator) => allocator.sequence(0n).next()).sort((a, b) => Number(a - b));
548
+ expect(firstIds).toEqual([1n, 65_537n, 131_073n, 196_609n]);
549
+ expect(initialize).not.toHaveBeenCalled();
550
+ expect(reserve).toHaveBeenCalledTimes(4);
551
+ } finally {
552
+ initialize.mockRestore();
553
+ reserve.mockRestore();
554
+ }
555
+ });
556
+
557
+ test('refills below 16k remaining IDs, preserving the tail across disjoint ranges', async () => {
558
+ await using factory = await factoryGen.factory();
559
+ const a = await openStream(factory, 4);
560
+ await using writer = a.writer;
561
+ const db = factory.db.versioned(a.bucketStorage.replicationStream.getStorageConfig());
562
+ const allocator = factory.getOpIdAllocator(
563
+ a.bucketStorage.replicationStream,
564
+ a.bucketStorage.replicationStream.current_lock!
565
+ );
566
+ await allocator.ensureCapacity();
567
+ allocator.committed(49_152n);
568
+ await allocator.ensureCapacity();
569
+ expect((await db.op_id_sequence.findOne({ _id: 'main' }))!.op_id).toBe(65_536n);
570
+
571
+ // Another stream's reservation creates a gap that must not count as capacity.
572
+ await new MongoOpIdAllocator(db).reserve();
573
+ allocator.committed(49_153n);
574
+ await Promise.all([allocator.ensureCapacity(), allocator.ensureCapacity()]);
575
+ expect((await db.op_id_sequence.findOne({ _id: 'main' }))!.op_id).toBe(196_608n);
576
+ expect(allocator.sequence(49_153n).next()).toBe(49_154n);
577
+ const sequence = allocator.sequence(65_535n);
578
+ expect(sequence.next()).toBe(65_536n);
579
+ expect(sequence.next()).toBe(131_073n);
580
+ await allocator.ensureCapacity();
581
+ expect((await db.op_id_sequence.findOne({ _id: 'main' }))!.op_id).toBe(196_608n);
582
+ });
583
+
584
+ test('refuses a reservation that would overflow without changing the watermark', async () => {
585
+ await using factory = await factoryGen.factory();
586
+ const a = await openStream(factory, 4);
587
+ await using writer = a.writer;
588
+ const max = (1n << 63n) - 1n;
589
+ await factory.db.op_id_sequence.insertOne({ _id: 'main', op_id: max - 65_536n });
590
+ const allocator = factory.getOpIdAllocator(
591
+ a.bucketStorage.replicationStream,
592
+ a.bucketStorage.replicationStream.current_lock!
593
+ );
594
+ await allocator.reserve();
595
+ expect(allocator.sequence(max - 1n).next()).toBe(max);
596
+ await expect(allocator.reserve()).rejects.toThrow('Operation ID sequence exhausted');
597
+ expect((await factory.db.op_id_sequence.findOne({ _id: 'main' }))!.op_id).toBe(max);
598
+ });
599
+ });
@@ -0,0 +1,29 @@
1
+ import { storage } from '@powersync/service-core';
2
+ import { test_utils } from '@powersync/service-core-tests';
3
+ import { mongoTestStorageFactoryGenerator } from '../../../dist/utils/test-utils.js';
4
+
5
+ // A separate process exercises the database protocol without sharing any mutex,
6
+ // allocator, MongoClient, or lease object with the test runner.
7
+ async function main() {
8
+ const factoryGen = mongoTestStorageFactoryGenerator({ url: process.argv[2], isCI: true });
9
+ await using factory = await factoryGen.factory({ doNotClear: true });
10
+ const streamId = Number(process.argv[3]);
11
+ const stream = (await factory.getReplicatingReplicationStreams()).find((s) => s.replicationStreamId === streamId);
12
+ const lock = await stream.lock();
13
+ await using lockLifetime = { [Symbol.asyncDispose]: () => lock.release() };
14
+ const bucketStorage = factory.getInstance(stream, { replicationLock: lock });
15
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
16
+ const table = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, streamId);
17
+ await writer.save({
18
+ sourceTable: table,
19
+ tag: storage.SaveOperationTag.INSERT,
20
+ after: { id: 'child', description: 'child' },
21
+ afterReplicaId: test_utils.rid('child')
22
+ });
23
+ await writer.commit('1/2');
24
+ console.log(`checkpoint=${(await bucketStorage.getCheckpoint()).checkpoint}`);
25
+ }
26
+
27
+ await main();
28
+ // Workspace test utilities start background timers. Exit after all resources are disposed.
29
+ process.exit(0);
@@ -4,7 +4,7 @@ import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { MemoryObjectStorage } from './MemoryObjectStorage.js';
6
6
 
7
- const minioStorages: S3ObjectStorage[] = [];
7
+ const rustfsStorages: S3ObjectStorage[] = [];
8
8
 
9
9
  export interface S3TestFactoryOptions {
10
10
  url: string;
@@ -26,24 +26,24 @@ function createTestStorageSuite(options: S3TestFactoryOptions, objectStorage: Ob
26
26
 
27
27
  /**
28
28
  * Creates an ObjectStorage instance for S3 tests.
29
- * Set MINIO_ENDPOINT to switch all S3 tests from MemoryObjectStorage
30
- * to a real MinIO/S3 endpoint.
31
- * MINIO_ENDPOINT=http://localhost:9000
29
+ * Set RUSTFS_ENDPOINT to switch all S3 tests from MemoryObjectStorage
30
+ * to a real RustFS/S3 endpoint.
31
+ * RUSTFS_ENDPOINT=http://localhost:9000
32
32
  */
33
33
  export function createS3TestStorageSuite(options: S3TestFactoryOptions) {
34
- const minioEndpoint = process.env.MINIO_ENDPOINT;
34
+ const rustfsEndpoint = process.env.RUSTFS_ENDPOINT;
35
35
  let objectStorage: ObjectStorage;
36
- if (minioEndpoint) {
36
+ if (rustfsEndpoint) {
37
37
  const s3 = new S3ObjectStorage({
38
38
  bucket: 'powersync-s3-test',
39
39
  region: 'us-east-1',
40
40
  prefix: `test-${process.pid}-${randomUUID()}`,
41
- endpoint: minioEndpoint,
41
+ endpoint: rustfsEndpoint,
42
42
  forcePathStyle: true,
43
- accessKeyId: process.env.MINIO_ACCESS_KEY ?? 'minioadmin',
44
- secretAccessKey: process.env.MINIO_SECRET_KEY ?? 'minioadmin'
43
+ accessKeyId: process.env.RUSTFS_ACCESS_KEY ?? 'rustfsadmin',
44
+ secretAccessKey: process.env.RUSTFS_SECRET_KEY ?? 'rustfsadmin'
45
45
  });
46
- minioStorages.push(s3);
46
+ rustfsStorages.push(s3);
47
47
  objectStorage = s3;
48
48
  } else {
49
49
  objectStorage = new MemoryObjectStorage();
@@ -52,9 +52,9 @@ export function createS3TestStorageSuite(options: S3TestFactoryOptions) {
52
52
  return createTestStorageSuite(options, objectStorage);
53
53
  }
54
54
 
55
- /** Remove all objects created by MinIO-backed suites in the current test. */
55
+ /** Remove all objects created by RustFS-backed suites in the current test. */
56
56
  export async function cleanupS3TestStorage(): Promise<void> {
57
- for (const storage of minioStorages.splice(0)) {
57
+ for (const storage of rustfsStorages.splice(0)) {
58
58
  await storage.deletePrefix('bucket-data/');
59
59
  }
60
60
  }