@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.
- package/CHANGELOG.md +36 -0
- package/LICENSE +201 -67
- package/dist/storage/MongoBucketStorage.d.ts +1 -0
- package/dist/storage/MongoBucketStorage.js +3 -0
- package/dist/storage/MongoBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoBucketBatch.d.ts +60 -39
- package/dist/storage/implementation/MongoBucketBatch.js +123 -296
- package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
- package/dist/storage/implementation/MongoOpIdAllocator.d.ts +6 -1
- package/dist/storage/implementation/MongoOpIdAllocator.js +12 -5
- package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -1
- package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +7 -0
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js +28 -2
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
- package/dist/storage/implementation/MongoReplicationOptions.d.ts +27 -0
- package/dist/storage/implementation/MongoReplicationOptions.js +26 -0
- package/dist/storage/implementation/MongoReplicationOptions.js.map +1 -0
- package/dist/storage/implementation/MongoSyncBucketStorage.js +2 -3
- package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoSyncRulesLock.d.ts +15 -2
- package/dist/storage/implementation/MongoSyncRulesLock.js +36 -14
- package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
- package/dist/storage/implementation/common/BatchOperationBuffer.d.ts +79 -0
- package/dist/storage/implementation/common/BatchOperationBuffer.js +286 -0
- package/dist/storage/implementation/common/BatchOperationBuffer.js.map +1 -0
- package/dist/storage/implementation/common/PersistedBatch.d.ts +10 -103
- package/dist/storage/implementation/common/PersistedBatch.js +1 -291
- package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
- package/dist/storage/implementation/common/PreparedPublication.d.ts +43 -0
- package/dist/storage/implementation/common/PreparedPublication.js +2 -0
- package/dist/storage/implementation/common/PreparedPublication.js.map +1 -0
- package/dist/storage/implementation/common/SourceRecordStore.d.ts +0 -2
- package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +17 -13
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js +259 -18
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
- package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +21 -15
- package/dist/storage/implementation/v1/PersistedBatchV1.js +58 -24
- package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
- package/dist/storage/implementation/v1/SourceRecordStoreV1.d.ts +0 -2
- package/dist/storage/implementation/v1/SourceRecordStoreV1.js +0 -3
- package/dist/storage/implementation/v1/SourceRecordStoreV1.js.map +1 -1
- package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +25 -14
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js +350 -23
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +1 -0
- package/dist/storage/implementation/v3/MongoCompactorV3.js +10 -4
- package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +39 -28
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
- package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +46 -24
- package/dist/storage/implementation/v3/PersistedBatchV3.js +159 -83
- package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +12 -0
- package/dist/storage/implementation/v3/SourceRecordStoreV3.js +41 -13
- package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +4 -0
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +4 -0
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +27 -2
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +61 -5
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
- package/package.json +9 -10
- package/src/storage/MongoBucketStorage.ts +4 -0
- package/src/storage/implementation/MongoBucketBatch.ts +185 -392
- package/src/storage/implementation/MongoOpIdAllocator.ts +12 -5
- package/src/storage/implementation/MongoParsedSyncConfigSet.ts +40 -1
- package/src/storage/implementation/MongoReplicationOptions.ts +28 -0
- package/src/storage/implementation/MongoSyncBucketStorage.ts +2 -3
- package/src/storage/implementation/MongoSyncRulesLock.ts +39 -17
- package/src/storage/implementation/common/BatchOperationBuffer.ts +353 -0
- package/src/storage/implementation/common/PersistedBatch.ts +12 -390
- package/src/storage/implementation/common/PreparedPublication.ts +49 -0
- package/src/storage/implementation/common/SourceRecordStore.ts +0 -2
- package/src/storage/implementation/v1/MongoBucketBatchV1.ts +339 -27
- package/src/storage/implementation/v1/PersistedBatchV1.ts +74 -32
- package/src/storage/implementation/v1/SourceRecordStoreV1.ts +0 -5
- package/src/storage/implementation/v3/MongoBucketBatchV3.ts +430 -32
- package/src/storage/implementation/v3/MongoCompactorV3.ts +13 -4
- package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +43 -29
- package/src/storage/implementation/v3/PersistedBatchV3.ts +209 -108
- package/src/storage/implementation/v3/SourceRecordStoreV3.ts +45 -15
- package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +9 -0
- package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +79 -5
- package/test/src/checkpoint_cleanup.test.ts +75 -0
- package/test/src/concurrent_writers.test.ts +127 -53
- package/test/src/helpers/PublicationCommandMonitor.ts +119 -0
- package/test/src/helpers/concurrentWriter.mjs +2 -2
- package/test/src/invalid_sync_config.test.ts +9 -0
- package/test/src/object_storage_lifecycle.test.ts +72 -2
- package/test/src/prepared_publication.test.ts +388 -0
- package/test/src/replication_preparation.test.ts +829 -0
- package/test/src/replication_publication_baseline.test.ts +203 -0
- package/test/src/replication_publication_batching.test.ts +212 -0
- package/test/src/replication_uploads.test.ts +393 -0
- package/test/src/source_table_config.test.ts +4 -4
- package/test/src/storage_object_storage_inline_threshold.test.ts +1 -1
- package/test/src/storage_s3_compaction_lifecycle.test.ts +93 -1
- package/test/src/storage_sync.test.ts +83 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mongo } from '@powersync/lib-service-mongodb';
|
|
2
|
+
import { ReplicationAbortedError } from '@powersync/lib-services-framework';
|
|
2
3
|
import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
|
|
3
4
|
import { test_utils } from '@powersync/service-core-tests';
|
|
4
5
|
import { execFile } from 'node:child_process';
|
|
@@ -8,6 +9,9 @@ import { describe, expect, test, vi } from 'vitest';
|
|
|
8
9
|
import { PersistedBatch } from '../../src/storage/implementation/common/PersistedBatch.js';
|
|
9
10
|
import { MongoOpIdAllocator } from '../../src/storage/implementation/MongoOpIdAllocator.js';
|
|
10
11
|
import { MongoPersistedReplicationStream } from '../../src/storage/implementation/MongoPersistedReplicationStream.js';
|
|
12
|
+
import { PersistedBatchV1 } from '../../src/storage/implementation/v1/PersistedBatchV1.js';
|
|
13
|
+
import { VersionedPowerSyncMongoV1 } from '../../src/storage/implementation/v1/VersionedPowerSyncMongoV1.js';
|
|
14
|
+
import { PersistedBatchV3 } from '../../src/storage/implementation/v3/PersistedBatchV3.js';
|
|
11
15
|
import { mongoTestStorageFactoryGenerator } from '../../src/utils/test-utils.js';
|
|
12
16
|
import { env } from './env.js';
|
|
13
17
|
|
|
@@ -37,6 +41,10 @@ async function insert(writer: storage.BucketStorageBatch, table: storage.SourceT
|
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
|
|
44
|
+
const batchPrototype = version < 3 ? PersistedBatchV1.prototype : PersistedBatchV3.prototype;
|
|
45
|
+
const originalWrite = () => (version < 3 ? PersistedBatchV1.prototype.flush : PersistedBatchV3.prototype.publish);
|
|
46
|
+
const spyWrite = () =>
|
|
47
|
+
version < 3 ? vi.spyOn(PersistedBatchV1.prototype, 'flush') : vi.spyOn(PersistedBatchV3.prototype, 'publish');
|
|
40
48
|
test('requires a lease to create a writer, including after another lease is released', async () => {
|
|
41
49
|
await using factory = await factoryGen.factory();
|
|
42
50
|
const stream = await factory.updateSyncRules(updateSyncRulesFromYaml(rules, { storageVersion: version }));
|
|
@@ -98,13 +106,10 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
|
|
|
98
106
|
await insert(writer, a.table, 'warmup');
|
|
99
107
|
await writer.commit('1/2');
|
|
100
108
|
// Force a split after each row without needing a transaction-sized fixture.
|
|
101
|
-
const split = vi.spyOn(
|
|
102
|
-
const flush =
|
|
109
|
+
const split = vi.spyOn(batchPrototype, 'shouldFlushTransaction').mockReturnValue(true);
|
|
110
|
+
const flush = originalWrite();
|
|
103
111
|
const observed: bigint[] = [];
|
|
104
|
-
const inspect =
|
|
105
|
-
this: PersistedBatch,
|
|
106
|
-
...args
|
|
107
|
-
) {
|
|
112
|
+
const inspect = spyWrite().mockImplementation(async function (this: PersistedBatch, ...args) {
|
|
108
113
|
observed.push((await a.bucketStorage.getCheckpoint()).checkpoint);
|
|
109
114
|
return flush.apply(this, args);
|
|
110
115
|
});
|
|
@@ -206,15 +211,14 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
|
|
|
206
211
|
let admissions = 0;
|
|
207
212
|
const admission = vi.spyOn(mutex, 'exclusiveLock').mockImplementation(function (callback) {
|
|
208
213
|
const result = exclusiveLock.call(mutex, callback);
|
|
209
|
-
if (++admissions === 3)
|
|
214
|
+
if (++admissions === 3) {
|
|
215
|
+
queued.resolve();
|
|
216
|
+
}
|
|
210
217
|
return result;
|
|
211
218
|
});
|
|
212
219
|
const transactions = vi.spyOn(mongo.ClientSession.prototype, 'withTransaction');
|
|
213
|
-
const flush =
|
|
214
|
-
const stalled =
|
|
215
|
-
this: PersistedBatch,
|
|
216
|
-
...args
|
|
217
|
-
) {
|
|
220
|
+
const flush = originalWrite();
|
|
221
|
+
const stalled = spyWrite().mockImplementationOnce(async function (this: PersistedBatch, ...args) {
|
|
218
222
|
entered.resolve();
|
|
219
223
|
await release.promise;
|
|
220
224
|
return flush.apply(this, args);
|
|
@@ -246,17 +250,22 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
|
|
|
246
250
|
await using factory = await factoryGen.factory();
|
|
247
251
|
const a = await openStream(factory, version);
|
|
248
252
|
await using writerA = a.writer;
|
|
253
|
+
// Keep A active and B processing, so independent publication is tested
|
|
254
|
+
// without replacing/stopping A's config or activating B under A's fence.
|
|
255
|
+
if (version >= 3) {
|
|
256
|
+
await writerA.commit('1/1');
|
|
257
|
+
}
|
|
249
258
|
// An independent factory/client has no shared allocator or in-process coordination.
|
|
250
259
|
await using other = await factoryGen.factory({ doNotClear: true });
|
|
251
260
|
const b = await openStream(other, version);
|
|
252
261
|
await using writerB = b.writer;
|
|
262
|
+
if (version >= 3) {
|
|
263
|
+
await writerB.markTableSnapshotRequired(b.table);
|
|
264
|
+
}
|
|
253
265
|
const entered = Promise.withResolvers<void>();
|
|
254
266
|
const release = Promise.withResolvers<void>();
|
|
255
|
-
const flush =
|
|
256
|
-
const stalled =
|
|
257
|
-
this: PersistedBatch,
|
|
258
|
-
...args
|
|
259
|
-
) {
|
|
267
|
+
const flush = originalWrite();
|
|
268
|
+
const stalled = spyWrite().mockImplementationOnce(async function (this: PersistedBatch, ...args) {
|
|
260
269
|
entered.resolve();
|
|
261
270
|
await release.promise;
|
|
262
271
|
return flush.apply(this, args);
|
|
@@ -267,7 +276,8 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
|
|
|
267
276
|
await entered.promise;
|
|
268
277
|
await insert(writerB, b.table, 'b');
|
|
269
278
|
await writerB.commit('1/2');
|
|
270
|
-
expect(
|
|
279
|
+
expect(writerB.last_flushed_op).toBeGreaterThan(1n);
|
|
280
|
+
expect((await b.bucketStorage.getCheckpoint()).checkpoint).toBe(version >= 3 ? 0n : writerB.last_flushed_op);
|
|
271
281
|
expect((await a.bucketStorage.getCheckpoint()).checkpoint).toBe(0n);
|
|
272
282
|
} finally {
|
|
273
283
|
release.resolve();
|
|
@@ -281,20 +291,23 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
|
|
|
281
291
|
expect(writerA.last_flushed_op).toBe(2n);
|
|
282
292
|
});
|
|
283
293
|
|
|
284
|
-
test('another process can reserve and
|
|
294
|
+
test('another process can reserve and commit progress while this process holds a stream transaction', async () => {
|
|
285
295
|
await using factory = await factoryGen.factory();
|
|
286
296
|
const a = await openStream(factory, version);
|
|
287
297
|
await using writerA = a.writer;
|
|
298
|
+
if (version >= 3) {
|
|
299
|
+
await writerA.commit('1/1');
|
|
300
|
+
}
|
|
288
301
|
const b = await openStream(factory, version);
|
|
302
|
+
if (version >= 3) {
|
|
303
|
+
await b.writer.markTableSnapshotRequired(b.table);
|
|
304
|
+
}
|
|
289
305
|
await b.writer.dispose();
|
|
290
306
|
await test_utils.releaseTestStorageLease(factory, b.stream.replicationStreamId);
|
|
291
307
|
const entered = Promise.withResolvers<void>();
|
|
292
308
|
const release = Promise.withResolvers<void>();
|
|
293
|
-
const flush =
|
|
294
|
-
const stalled =
|
|
295
|
-
this: PersistedBatch,
|
|
296
|
-
...args
|
|
297
|
-
) {
|
|
309
|
+
const flush = originalWrite();
|
|
310
|
+
const stalled = spyWrite().mockImplementationOnce(async function (this: PersistedBatch, ...args) {
|
|
298
311
|
entered.resolve();
|
|
299
312
|
await release.promise;
|
|
300
313
|
return flush.apply(this, args);
|
|
@@ -314,7 +327,7 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
|
|
|
314
327
|
).catch((error) => {
|
|
315
328
|
throw new Error(`${error.message}\n${error.stdout}\n${error.stderr}`);
|
|
316
329
|
});
|
|
317
|
-
expect(child.stdout).toMatch(/checkpoint=[1-9][0-9]+/);
|
|
330
|
+
expect(child.stdout).toMatch(version >= 3 ? /persisted=[1-9][0-9]+/ : /checkpoint=[1-9][0-9]+/);
|
|
318
331
|
expect((await a.bucketStorage.getCheckpoint()).checkpoint).toBe(0n);
|
|
319
332
|
} finally {
|
|
320
333
|
release.resolve();
|
|
@@ -328,11 +341,8 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
|
|
|
328
341
|
const { writer: batch, table, bucketStorage } = await openStream(factory, version);
|
|
329
342
|
await using writer = batch;
|
|
330
343
|
const reservations = vi.spyOn(MongoOpIdAllocator.prototype, 'reserve');
|
|
331
|
-
const flush =
|
|
332
|
-
const retry =
|
|
333
|
-
this: PersistedBatch,
|
|
334
|
-
...args
|
|
335
|
-
) {
|
|
344
|
+
const flush = originalWrite();
|
|
345
|
+
const retry = spyWrite().mockImplementationOnce(async function (this: PersistedBatch, ...args) {
|
|
336
346
|
await flush.apply(this, args);
|
|
337
347
|
throw new mongo.MongoServerError({ message: 'retry', code: 112, errorLabels: ['TransientTransactionError'] });
|
|
338
348
|
});
|
|
@@ -364,11 +374,8 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
|
|
|
364
374
|
const abort = new AbortController();
|
|
365
375
|
await using writer = await a.bucketStorage.createWriter({ ...test_utils.BATCH_OPTIONS, signal: abort.signal });
|
|
366
376
|
const table = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, a.stream.replicationStreamId);
|
|
367
|
-
const flush =
|
|
368
|
-
const interrupted =
|
|
369
|
-
this: PersistedBatch,
|
|
370
|
-
...args
|
|
371
|
-
) {
|
|
377
|
+
const flush = originalWrite();
|
|
378
|
+
const interrupted = spyWrite().mockImplementationOnce(async function (this: PersistedBatch, ...args) {
|
|
372
379
|
const result = await flush.apply(this, args);
|
|
373
380
|
if (when === 'during flush') {
|
|
374
381
|
abort.abort();
|
|
@@ -455,10 +462,18 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
|
|
|
455
462
|
await writerB.flush();
|
|
456
463
|
const higher = writerB.last_flushed_op!;
|
|
457
464
|
await insert(writerA, a.table, 'c');
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
465
|
+
if (version >= 3) {
|
|
466
|
+
// Independent allocators on the same lease can reserve disjoint ranges in
|
|
467
|
+
// the opposite order from publication. The stale writer must stop.
|
|
468
|
+
await expect(writerA.flush()).rejects.toThrow('does not advance persisted stream head');
|
|
469
|
+
expect(writerA.last_flushed_op).toBeLessThan(higher);
|
|
470
|
+
await expect(writerA.commit('1/2')).rejects.toThrow('restart replication from persisted progress');
|
|
471
|
+
} else {
|
|
472
|
+
await writerA.flush();
|
|
473
|
+
expect(writerA.last_flushed_op).toBeGreaterThan(higher);
|
|
474
|
+
await writerA.commit('1/2');
|
|
475
|
+
expect((await a.bucketStorage.getCheckpoint()).checkpoint).toBe(writerA.last_flushed_op);
|
|
476
|
+
}
|
|
462
477
|
});
|
|
463
478
|
|
|
464
479
|
test('lease takeover fences stale row, metadata, and checkpoint writes', async () => {
|
|
@@ -468,7 +483,7 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
|
|
|
468
483
|
const stream = initial.stream as MongoPersistedReplicationStream;
|
|
469
484
|
await test_utils.releaseTestStorageLease(factory, stream.replicationStreamId);
|
|
470
485
|
const first = await stream.lock();
|
|
471
|
-
await using
|
|
486
|
+
await using _firstLifetime = { [Symbol.asyncDispose]: () => first.release() };
|
|
472
487
|
await using oldWriter = await factory.getInstance(stream).createWriter(test_utils.BATCH_OPTIONS);
|
|
473
488
|
await using oldCheckpointWriter = await factory.getInstance(stream).createWriter(test_utils.BATCH_OPTIONS);
|
|
474
489
|
const oldTable = await test_utils.resolveTestTable(oldWriter, 'items', ['id'], factoryGen, 1);
|
|
@@ -482,7 +497,7 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
|
|
|
482
497
|
await using other = await factoryGen.factory({ doNotClear: true });
|
|
483
498
|
const nextStream = (await other.getReplicatingReplicationStreams())[0];
|
|
484
499
|
const next = await nextStream.lock();
|
|
485
|
-
await using
|
|
500
|
+
await using _nextLifetime = { [Symbol.asyncDispose]: () => next.release() };
|
|
486
501
|
await using nextWriter = await other.getInstance(nextStream).createWriter(test_utils.BATCH_OPTIONS);
|
|
487
502
|
const nextTable = await test_utils.resolveTestTable(nextWriter, 'items', ['id'], factoryGen, 1);
|
|
488
503
|
await insert(nextWriter, nextTable, 'new');
|
|
@@ -490,12 +505,12 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
|
|
|
490
505
|
expect(nextWriter.last_flushed_op).toBeGreaterThan(abandonedEnd);
|
|
491
506
|
await insert(oldWriter, oldTable, 'stale');
|
|
492
507
|
await expect(oldWriter.flush()).rejects.toThrow('no longer owns');
|
|
493
|
-
await expect(oldWriter.setResumeLsn('9/9')).rejects.
|
|
494
|
-
await expect(oldWriter.markAllSnapshotDone('9/9')).rejects.
|
|
495
|
-
await expect(oldWriter.markTableSnapshotRequired(oldTable)).rejects.
|
|
496
|
-
await expect(oldWriter.commit('9/9')).rejects.
|
|
508
|
+
await expect(oldWriter.setResumeLsn('9/9')).rejects.toBeInstanceOf(ReplicationAbortedError);
|
|
509
|
+
await expect(oldWriter.markAllSnapshotDone('9/9')).rejects.toBeInstanceOf(ReplicationAbortedError);
|
|
510
|
+
await expect(oldWriter.markTableSnapshotRequired(oldTable)).rejects.toBeInstanceOf(ReplicationAbortedError);
|
|
511
|
+
await expect(oldWriter.commit('9/9')).rejects.toBeInstanceOf(ReplicationAbortedError);
|
|
497
512
|
// No pending rows: this exercises the checkpoint fence, not the flush fence.
|
|
498
|
-
await expect(oldCheckpointWriter.commit('9/9')).rejects.
|
|
513
|
+
await expect(oldCheckpointWriter.commit('9/9')).rejects.toBeInstanceOf(ReplicationAbortedError);
|
|
499
514
|
await first.release();
|
|
500
515
|
expect((await factory.db.sync_rules.findOne({ _id: stream.replicationStreamId }))!.lock?.id).toBe(
|
|
501
516
|
(next as typeof first).lock_id
|
|
@@ -511,7 +526,7 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
|
|
|
511
526
|
bucketStorage.replicationStream.current_lock!
|
|
512
527
|
);
|
|
513
528
|
await allocator.reserve();
|
|
514
|
-
allocator.
|
|
529
|
+
allocator.consume(65_535n);
|
|
515
530
|
// Simulate capacity becoming insufficient after the pre-transaction check.
|
|
516
531
|
// The fallback must still abort and retry safely in that case.
|
|
517
532
|
const capacity = vi.spyOn(allocator, 'ensureCapacity');
|
|
@@ -525,7 +540,7 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
|
|
|
525
540
|
await writer.commit('1/2');
|
|
526
541
|
expect(writer.last_flushed_op).toBe(65_537n);
|
|
527
542
|
expect((await bucketStorage.getCheckpoint()).checkpoint).toBe(65_537n);
|
|
528
|
-
expect(sequences).toHaveBeenCalledTimes(fallback ? 2 : 1);
|
|
543
|
+
expect(sequences).toHaveBeenCalledTimes(version < 3 ? (fallback ? 2 : 1) : fallback ? 3 : 2);
|
|
529
544
|
} finally {
|
|
530
545
|
capacity.mockRestore();
|
|
531
546
|
sequences.mockRestore();
|
|
@@ -533,11 +548,70 @@ describe.each([1, 2, 4])('concurrent writers v%s', (version) => {
|
|
|
533
548
|
});
|
|
534
549
|
});
|
|
535
550
|
|
|
551
|
+
describe.each([1, 2])('legacy batch lifecycle v%s', (version) => {
|
|
552
|
+
test('clears a successful flush and can accumulate the next writes independently', async () => {
|
|
553
|
+
await using factory = await factoryGen.factory();
|
|
554
|
+
const context = await openStream(factory, version);
|
|
555
|
+
await using _writer = context.writer;
|
|
556
|
+
const db = factory.db.versioned(
|
|
557
|
+
context.bucketStorage.replicationStream.getStorageConfig()
|
|
558
|
+
) as VersionedPowerSyncMongoV1;
|
|
559
|
+
const batch = new PersistedBatchV1(db, context.stream.replicationStreamId, 0);
|
|
560
|
+
await db.client.withSession(async (session) => {
|
|
561
|
+
for (const id of ['first', 'second']) {
|
|
562
|
+
batch.upsertCurrentData({
|
|
563
|
+
sourceTableId: context.table.id as mongo.ObjectId,
|
|
564
|
+
replicaId: test_utils.rid(id),
|
|
565
|
+
data: null,
|
|
566
|
+
buckets: [],
|
|
567
|
+
lookups: []
|
|
568
|
+
});
|
|
569
|
+
expect(batch.currentSize).toBeGreaterThan(0);
|
|
570
|
+
await session.withTransaction(async () => {
|
|
571
|
+
expect(await batch.flush(session)).toEqual({
|
|
572
|
+
bucketDataCount: 0,
|
|
573
|
+
parameterDataCount: 0,
|
|
574
|
+
currentDataCount: 1,
|
|
575
|
+
flushedAny: true
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
expect(batch.currentSize).toBe(0);
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
const records = await db.sourceRecordsV1.find({ '_id.g': context.stream.replicationStreamId }).toArray();
|
|
582
|
+
expect(records).toHaveLength(2);
|
|
583
|
+
expect(records.map((record) => record._id.k)).toEqual(
|
|
584
|
+
expect.arrayContaining([test_utils.rid('first'), test_utils.rid('second')])
|
|
585
|
+
);
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
|
|
536
589
|
describe('operation ID reservations', () => {
|
|
590
|
+
test('consumes abandoned IDs without advancing the durable head or discarding the unused tail', async () => {
|
|
591
|
+
await using factory = await factoryGen.factory();
|
|
592
|
+
const a = await openStream(factory, 4);
|
|
593
|
+
await using _writer = a.writer;
|
|
594
|
+
const allocator = factory.getOpIdAllocator(
|
|
595
|
+
a.bucketStorage.replicationStream,
|
|
596
|
+
a.bucketStorage.replicationStream.current_lock!
|
|
597
|
+
);
|
|
598
|
+
await allocator.reserve();
|
|
599
|
+
const sequence = allocator.sequence(0n);
|
|
600
|
+
expect(sequence.next()).toBe(1n);
|
|
601
|
+
expect(sequence.next()).toBe(2n);
|
|
602
|
+
allocator.consume(sequence.last());
|
|
603
|
+
// The sealed work is abandoned: a new sequence sees the same durable head.
|
|
604
|
+
expect(allocator.sequence(0n).next()).toBe(3n);
|
|
605
|
+
expect((await a.bucketStorage.getCheckpoint()).checkpoint).toBe(0n);
|
|
606
|
+
const watermark = (await factory.db.op_id_sequence.findOne({ _id: 'main' }))!.op_id;
|
|
607
|
+
await allocator.ensureCapacity();
|
|
608
|
+
expect((await factory.db.op_id_sequence.findOne({ _id: 'main' }))!.op_id).toBe(watermark);
|
|
609
|
+
});
|
|
610
|
+
|
|
537
611
|
test('initializes and reserves disjoint ranges concurrently in one command each', async () => {
|
|
538
612
|
await using factory = await factoryGen.factory();
|
|
539
613
|
const a = await openStream(factory, 4);
|
|
540
|
-
await using
|
|
614
|
+
await using _writer = a.writer;
|
|
541
615
|
const db = factory.db.versioned(a.bucketStorage.replicationStream.getStorageConfig());
|
|
542
616
|
const allocators = Array.from({ length: 4 }, () => new MongoOpIdAllocator(db));
|
|
543
617
|
const initialize = vi.spyOn(db.op_id_sequence, 'updateOne');
|
|
@@ -557,20 +631,20 @@ describe('operation ID reservations', () => {
|
|
|
557
631
|
test('refills below 16k remaining IDs, preserving the tail across disjoint ranges', async () => {
|
|
558
632
|
await using factory = await factoryGen.factory();
|
|
559
633
|
const a = await openStream(factory, 4);
|
|
560
|
-
await using
|
|
634
|
+
await using _writer = a.writer;
|
|
561
635
|
const db = factory.db.versioned(a.bucketStorage.replicationStream.getStorageConfig());
|
|
562
636
|
const allocator = factory.getOpIdAllocator(
|
|
563
637
|
a.bucketStorage.replicationStream,
|
|
564
638
|
a.bucketStorage.replicationStream.current_lock!
|
|
565
639
|
);
|
|
566
640
|
await allocator.ensureCapacity();
|
|
567
|
-
allocator.
|
|
641
|
+
allocator.consume(49_152n);
|
|
568
642
|
await allocator.ensureCapacity();
|
|
569
643
|
expect((await db.op_id_sequence.findOne({ _id: 'main' }))!.op_id).toBe(65_536n);
|
|
570
644
|
|
|
571
645
|
// Another stream's reservation creates a gap that must not count as capacity.
|
|
572
646
|
await new MongoOpIdAllocator(db).reserve();
|
|
573
|
-
allocator.
|
|
647
|
+
allocator.consume(49_153n);
|
|
574
648
|
await Promise.all([allocator.ensureCapacity(), allocator.ensureCapacity()]);
|
|
575
649
|
expect((await db.op_id_sequence.findOne({ _id: 'main' }))!.op_id).toBe(196_608n);
|
|
576
650
|
expect(allocator.sequence(49_153n).next()).toBe(49_154n);
|
|
@@ -584,7 +658,7 @@ describe('operation ID reservations', () => {
|
|
|
584
658
|
test('refuses a reservation that would overflow without changing the watermark', async () => {
|
|
585
659
|
await using factory = await factoryGen.factory();
|
|
586
660
|
const a = await openStream(factory, 4);
|
|
587
|
-
await using
|
|
661
|
+
await using _writer = a.writer;
|
|
588
662
|
const max = (1n << 63n) - 1n;
|
|
589
663
|
await factory.db.op_id_sequence.insertOne({ _id: 'main', op_id: max - 65_536n });
|
|
590
664
|
const allocator = factory.getOpIdAllocator(
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
2
|
+
|
|
3
|
+
interface CommandSample {
|
|
4
|
+
name: string;
|
|
5
|
+
startedAt: number;
|
|
6
|
+
endedAt?: number;
|
|
7
|
+
failed?: boolean;
|
|
8
|
+
transaction?: TransactionSample;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface TransactionSample {
|
|
12
|
+
key: string;
|
|
13
|
+
startedAt: number;
|
|
14
|
+
endedAt?: number;
|
|
15
|
+
outcome?: 'committed' | 'aborted';
|
|
16
|
+
replication: boolean;
|
|
17
|
+
commands: CommandSample[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Counts actual wire commands, including getMore, aborted attempts and commit retries.
|
|
22
|
+
* Attach after setup/warmup to keep index creation and allocator refills separate.
|
|
23
|
+
* Store timing and classification only, never source data or command payloads.
|
|
24
|
+
*/
|
|
25
|
+
export class PublicationCommandMonitor implements Disposable {
|
|
26
|
+
readonly commands: CommandSample[] = [];
|
|
27
|
+
readonly transactions = new Map<string, TransactionSample>();
|
|
28
|
+
private readonly requests = new Map<number, CommandSample>();
|
|
29
|
+
|
|
30
|
+
constructor(private readonly client: mongo.MongoClient) {
|
|
31
|
+
client.on('commandStarted', this.started);
|
|
32
|
+
client.on('commandSucceeded', this.succeeded);
|
|
33
|
+
client.on('commandFailed', this.failed);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get replicationAttempts() {
|
|
37
|
+
return [...this.transactions.values()].filter((transaction) => transaction.replication);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get activeReplicationAttempts() {
|
|
41
|
+
return this.replicationAttempts.filter((transaction) => transaction.endedAt == null);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
summary() {
|
|
45
|
+
const counts = (commands: CommandSample[]) => {
|
|
46
|
+
const result: Record<string, number> = {};
|
|
47
|
+
for (const command of commands) {
|
|
48
|
+
result[command.name] = (result[command.name] ?? 0) + 1;
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
commands: counts(this.commands),
|
|
54
|
+
outsideTransaction: counts(this.commands.filter((command) => command.transaction == null)),
|
|
55
|
+
replicationAttempts: this.replicationAttempts.map((transaction) => ({
|
|
56
|
+
outcome: transaction.outcome,
|
|
57
|
+
activeMs: transaction.endedAt == null ? null : transaction.endedAt - transaction.startedAt,
|
|
58
|
+
commands: counts(transaction.commands),
|
|
59
|
+
failedCommands: transaction.commands.filter((command) => command.failed).length
|
|
60
|
+
})),
|
|
61
|
+
otherTransactions: [...this.transactions.values()].filter((transaction) => !transaction.replication).length
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private started = (event: mongo.CommandStartedEvent) => {
|
|
66
|
+
const command = event.command;
|
|
67
|
+
const startedAt = performance.now();
|
|
68
|
+
let transaction: TransactionSample | undefined;
|
|
69
|
+
// txnNumber alone also appears on retryable writes outside transactions.
|
|
70
|
+
if (command.autocommit === false) {
|
|
71
|
+
const key = `${command.lsid.id.toString('hex')}:${command.txnNumber.toString()}`;
|
|
72
|
+
transaction = this.transactions.get(key);
|
|
73
|
+
if (transaction == null) {
|
|
74
|
+
transaction = { key, startedAt, replication: false, commands: [] };
|
|
75
|
+
this.transactions.set(key, transaction);
|
|
76
|
+
}
|
|
77
|
+
// The writer's first command is the conditional stream fence. Compaction
|
|
78
|
+
// shares the client but does not fence the replication stream document.
|
|
79
|
+
transaction.replication ||= event.commandName === 'findAndModify' && command.findAndModify === 'sync_rules';
|
|
80
|
+
}
|
|
81
|
+
const sample: CommandSample = { name: event.commandName, startedAt, transaction };
|
|
82
|
+
this.commands.push(sample);
|
|
83
|
+
transaction?.commands.push(sample);
|
|
84
|
+
this.requests.set(event.requestId, sample);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
private finish(requestId: number, failed: boolean, errorCode?: number) {
|
|
88
|
+
const sample = this.requests.get(requestId);
|
|
89
|
+
if (sample == null) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this.requests.delete(requestId);
|
|
93
|
+
sample.endedAt = performance.now();
|
|
94
|
+
sample.failed = failed;
|
|
95
|
+
// A failed commit can have an unknown outcome and may be retried. Only a
|
|
96
|
+
// successful commit or a confirmed abort closes the observed attempt.
|
|
97
|
+
// NoSuchTransaction also confirms that the server already aborted it.
|
|
98
|
+
if (
|
|
99
|
+
sample.transaction != null &&
|
|
100
|
+
((sample.name === 'abortTransaction' && (!failed || errorCode === 251)) ||
|
|
101
|
+
(sample.name === 'commitTransaction' && !failed))
|
|
102
|
+
) {
|
|
103
|
+
sample.transaction.endedAt = sample.endedAt;
|
|
104
|
+
sample.transaction.outcome = sample.name === 'commitTransaction' ? 'committed' : 'aborted';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private succeeded = (event: mongo.CommandSucceededEvent) => this.finish(event.requestId, false);
|
|
109
|
+
private failed = (event: mongo.CommandFailedEvent) => {
|
|
110
|
+
const errorCode = event.failure instanceof mongo.MongoServerError ? event.failure.code : undefined;
|
|
111
|
+
this.finish(event.requestId, true, typeof errorCode === 'number' ? errorCode : undefined);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
[Symbol.dispose]() {
|
|
115
|
+
this.client.off('commandStarted', this.started);
|
|
116
|
+
this.client.off('commandSucceeded', this.succeeded);
|
|
117
|
+
this.client.off('commandFailed', this.failed);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -10,7 +10,7 @@ async function main() {
|
|
|
10
10
|
const streamId = Number(process.argv[3]);
|
|
11
11
|
const stream = (await factory.getReplicatingReplicationStreams()).find((s) => s.replicationStreamId === streamId);
|
|
12
12
|
const lock = await stream.lock();
|
|
13
|
-
await using
|
|
13
|
+
await using _lockLifetime = { [Symbol.asyncDispose]: () => lock.release() };
|
|
14
14
|
const bucketStorage = factory.getInstance(stream, { replicationLock: lock });
|
|
15
15
|
await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
16
16
|
const table = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, streamId);
|
|
@@ -21,7 +21,7 @@ async function main() {
|
|
|
21
21
|
afterReplicaId: test_utils.rid('child')
|
|
22
22
|
});
|
|
23
23
|
await writer.commit('1/2');
|
|
24
|
-
console.log(`checkpoint=${(await bucketStorage.getCheckpoint()).checkpoint}`);
|
|
24
|
+
console.log(`checkpoint=${(await bucketStorage.getCheckpoint()).checkpoint}; persisted=${writer.last_flushed_op}`);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
await main();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { register } from '@powersync/service-core-tests';
|
|
2
|
+
import { describe } from 'vitest';
|
|
3
|
+
import { INITIALIZED_MONGO_STORAGE_FACTORY, TEST_STORAGE_VERSIONS } from './util.js';
|
|
4
|
+
|
|
5
|
+
for (const storageVersion of TEST_STORAGE_VERSIONS) {
|
|
6
|
+
describe(`storage v${storageVersion}`, () => {
|
|
7
|
+
register.registerInvalidSyncConfigTests(INITIALIZED_MONGO_STORAGE_FACTORY, storageVersion);
|
|
8
|
+
});
|
|
9
|
+
}
|
|
@@ -1,9 +1,79 @@
|
|
|
1
|
+
import { ObjectId } from 'bson';
|
|
1
2
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { describe, expect, test } from 'vitest';
|
|
3
|
-
import {
|
|
3
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
OBJECT_STORAGE_PUBLICATION_SAFETY_MARGIN_MS,
|
|
6
|
+
ObjectStorageLifecycle
|
|
7
|
+
} from '../../src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js';
|
|
4
8
|
import { MemoryObjectStorage } from './helpers/MemoryObjectStorage.js';
|
|
5
9
|
|
|
6
10
|
describe('ObjectStorageLifecycle', () => {
|
|
11
|
+
test('does not start PUTs when their protection has expired', async () => {
|
|
12
|
+
const objectStorage = new MemoryObjectStorage();
|
|
13
|
+
const lifecycle = new ObjectStorageLifecycle(null as any, 12, objectStorage);
|
|
14
|
+
using put = vi.spyOn(objectStorage, 'put');
|
|
15
|
+
await expect(
|
|
16
|
+
lifecycle.storeUploads([
|
|
17
|
+
{
|
|
18
|
+
upload: {
|
|
19
|
+
markerId: new ObjectId(),
|
|
20
|
+
path: 'expired',
|
|
21
|
+
deleteAfter: new Date(Date.now() + OBJECT_STORAGE_PUBLICATION_SAFETY_MARGIN_MS)
|
|
22
|
+
},
|
|
23
|
+
payload: new Uint8Array()
|
|
24
|
+
}
|
|
25
|
+
])
|
|
26
|
+
).rejects.toThrow('lease expired before PUT');
|
|
27
|
+
expect(put).not.toHaveBeenCalled();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('expiry cancels queued requests and joins an active request before rejecting', async () => {
|
|
31
|
+
vi.useFakeTimers();
|
|
32
|
+
using _clock = { [Symbol.dispose]: () => vi.useRealTimers() };
|
|
33
|
+
const objectStorage = new MemoryObjectStorage();
|
|
34
|
+
const lifecycle = new ObjectStorageLifecycle(null as any, 12, objectStorage);
|
|
35
|
+
const release = Promise.withResolvers<void>();
|
|
36
|
+
let activeSignal: AbortSignal | undefined;
|
|
37
|
+
let queuedStarted = false;
|
|
38
|
+
using _put = vi
|
|
39
|
+
.spyOn(objectStorage, 'put')
|
|
40
|
+
.mockImplementationOnce(async (_path, _data, _metadata, options) => {
|
|
41
|
+
activeSignal = options!.signal;
|
|
42
|
+
await release.promise;
|
|
43
|
+
activeSignal!.throwIfAborted();
|
|
44
|
+
})
|
|
45
|
+
.mockImplementationOnce(async (_path, _data, _metadata, options) => {
|
|
46
|
+
// Model waiting for the shared adapter's semaphore: observe abort before
|
|
47
|
+
// starting the remote request, even when the active request is draining.
|
|
48
|
+
await new Promise<void>((resolve) =>
|
|
49
|
+
options!.signal!.addEventListener('abort', () => resolve(), { once: true })
|
|
50
|
+
);
|
|
51
|
+
options!.signal!.throwIfAborted();
|
|
52
|
+
queuedStarted = true;
|
|
53
|
+
});
|
|
54
|
+
const uploads = ['active', 'queued'].map((path) => ({
|
|
55
|
+
upload: {
|
|
56
|
+
markerId: new ObjectId(),
|
|
57
|
+
path,
|
|
58
|
+
deleteAfter: new Date(Date.now() + OBJECT_STORAGE_PUBLICATION_SAFETY_MARGIN_MS + 1000)
|
|
59
|
+
},
|
|
60
|
+
payload: new Uint8Array()
|
|
61
|
+
}));
|
|
62
|
+
const pending = lifecycle.storeUploads(uploads);
|
|
63
|
+
let settled = false;
|
|
64
|
+
const failed = expect(
|
|
65
|
+
pending.finally(() => {
|
|
66
|
+
settled = true;
|
|
67
|
+
})
|
|
68
|
+
).rejects.toThrow('lease expired during PUT');
|
|
69
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
70
|
+
expect(activeSignal!.aborted).toBe(true);
|
|
71
|
+
expect(queuedStarted).toBe(false);
|
|
72
|
+
expect(settled).toBe(false);
|
|
73
|
+
release.resolve();
|
|
74
|
+
await failed;
|
|
75
|
+
});
|
|
76
|
+
|
|
7
77
|
test('escapes bucket names as path segments', () => {
|
|
8
78
|
const lifecycle = new ObjectStorageLifecycle(null as any, 12, null as any);
|
|
9
79
|
|