@powersync/service-module-mongodb-storage 0.18.2 → 0.19.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 +48 -0
- package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.d.ts +3 -0
- package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js +41 -0
- package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js.map +1 -0
- package/dist/storage/MongoBucketStorage.d.ts +5 -1
- package/dist/storage/MongoBucketStorage.js +5 -1
- package/dist/storage/MongoBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoBucketBatch.d.ts +3 -0
- package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
- package/dist/storage/implementation/MongoChecksums.d.ts +2 -0
- package/dist/storage/implementation/MongoChecksums.js +3 -2
- package/dist/storage/implementation/MongoChecksums.js.map +1 -1
- package/dist/storage/implementation/MongoCompactor.d.ts +15 -19
- package/dist/storage/implementation/MongoCompactor.js +68 -260
- package/dist/storage/implementation/MongoCompactor.js.map +1 -1
- package/dist/storage/implementation/MongoStorageProvider.js +26 -3
- package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
- package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +12 -5
- package/dist/storage/implementation/MongoSyncBucketStorage.js +25 -14
- package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +2 -1
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js +146 -34
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
- package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +2 -0
- package/dist/storage/implementation/common/PersistedBatch.d.ts +6 -0
- package/dist/storage/implementation/common/PersistedBatch.js +15 -8
- package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
- package/dist/storage/implementation/models.d.ts +16 -0
- package/dist/storage/implementation/models.js.map +1 -1
- package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +11 -2
- package/dist/storage/implementation/v1/MongoCompactorV1.js +250 -1
- package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +20 -11
- package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
- package/dist/storage/implementation/v1/SingleBucketStoreV1.d.ts +9 -10
- package/dist/storage/implementation/v1/SingleBucketStoreV1.js +2 -8
- package/dist/storage/implementation/v1/SingleBucketStoreV1.js.map +1 -1
- package/dist/storage/implementation/v3/BucketDataContextV3.d.ts +13 -0
- package/dist/storage/implementation/v3/BucketDataContextV3.js +23 -0
- package/dist/storage/implementation/v3/BucketDataContextV3.js.map +1 -0
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js +3 -1
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +1 -0
- package/dist/storage/implementation/v3/MongoChecksumsV3.js +59 -103
- package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +23 -2
- package/dist/storage/implementation/v3/MongoCompactorV3.js +354 -219
- package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +5 -0
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +12 -11
- package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +5 -3
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +109 -106
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
- package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +5 -2
- package/dist/storage/implementation/v3/PersistedBatchV3.js +53 -15
- package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +2 -1
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +3 -0
- package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
- package/dist/storage/implementation/v3/bucket-format.d.ts +10 -1
- package/dist/storage/implementation/v3/bucket-format.js +28 -9
- package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
- package/dist/storage/implementation/v3/chunking.js +3 -1
- package/dist/storage/implementation/v3/chunking.js.map +1 -1
- package/dist/storage/implementation/v3/models.d.ts +38 -1
- package/dist/storage/implementation/v3/models.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +21 -0
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +44 -0
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +30 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorage.js +12 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorage.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +70 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +146 -0
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +42 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +285 -0
- package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -0
- package/dist/types/types.d.ts +35 -0
- package/dist/types/types.js +35 -1
- package/dist/types/types.js.map +1 -1
- package/dist/utils/test-utils.js +3 -1
- package/dist/utils/test-utils.js.map +1 -1
- package/dist/utils/util.d.ts +8 -1
- package/dist/utils/util.js +125 -2
- package/dist/utils/util.js.map +1 -1
- package/package.json +10 -7
- package/src/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.ts +55 -0
- package/src/storage/MongoBucketStorage.ts +10 -2
- package/src/storage/implementation/MongoBucketBatch.ts +4 -0
- package/src/storage/implementation/MongoChecksums.ts +5 -2
- package/src/storage/implementation/MongoCompactor.ts +90 -314
- package/src/storage/implementation/MongoStorageProvider.ts +32 -3
- package/src/storage/implementation/MongoSyncBucketStorage.ts +34 -24
- package/src/storage/implementation/MongoWriteCheckpointAPI.ts +164 -37
- package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +2 -0
- package/src/storage/implementation/common/PersistedBatch.ts +22 -10
- package/src/storage/implementation/models.ts +16 -0
- package/src/storage/implementation/v1/MongoCompactorV1.ts +292 -8
- package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +54 -40
- package/src/storage/implementation/v1/SingleBucketStoreV1.ts +18 -27
- package/src/storage/implementation/v3/BucketDataContextV3.ts +32 -0
- package/src/storage/implementation/v3/MongoBucketBatchV3.ts +3 -1
- package/src/storage/implementation/v3/MongoChecksumsV3.ts +75 -105
- package/src/storage/implementation/v3/MongoCompactorV3.ts +456 -258
- package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +21 -19
- package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +123 -126
- package/src/storage/implementation/v3/PersistedBatchV3.ts +69 -16
- package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +5 -0
- package/src/storage/implementation/v3/bucket-format.ts +36 -9
- package/src/storage/implementation/v3/chunking.ts +3 -1
- package/src/storage/implementation/v3/models.ts +40 -1
- package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +54 -0
- package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +29 -0
- package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +171 -0
- package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +343 -0
- package/src/types/types.ts +42 -1
- package/src/utils/test-utils.ts +3 -1
- package/src/utils/util.ts +182 -4
- package/test/src/__snapshots__/storage_sync.test.ts.snap +1 -583
- package/test/src/cleanup-stopped-sync-configs.test.ts +24 -1
- package/test/src/helpers/MemoryObjectStorage.ts +53 -0
- package/test/src/helpers/s3TestFactory.ts +69 -0
- package/test/src/object_storage_lifecycle.test.ts +59 -0
- package/test/src/setup.ts +6 -1
- package/test/src/storage.test.ts +216 -2
- package/test/src/storage_compacting.test.ts +176 -187
- package/test/src/storage_object_storage_inline_threshold.test.ts +116 -0
- package/test/src/storage_s3_checksums.test.ts +191 -0
- package/test/src/storage_s3_compaction_lifecycle.test.ts +417 -0
- package/test/src/storage_s3_reading.test.ts +526 -0
- package/test/src/storage_s3_writing.test.ts +135 -0
- package/test/src/storage_sync.test.ts +95 -5
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/storage/implementation/common/SingleBucketStore.d.ts +0 -54
- package/dist/storage/implementation/common/SingleBucketStore.js +0 -3
- package/dist/storage/implementation/common/SingleBucketStore.js.map +0 -1
- package/dist/storage/implementation/v3/SingleBucketStoreV3.d.ts +0 -18
- package/dist/storage/implementation/v3/SingleBucketStoreV3.js +0 -84
- package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +0 -1
- package/src/storage/implementation/common/SingleBucketStore.ts +0 -63
- package/src/storage/implementation/v3/SingleBucketStoreV3.ts +0 -108
|
@@ -4,7 +4,13 @@ import { loadBucketDataDocument, serializeBucketData } from '@module/storage/imp
|
|
|
4
4
|
import { chunkBucketData, DEFAULT_MAX_DOC_SIZE_BYTES } from '@module/storage/implementation/v3/chunking.js';
|
|
5
5
|
import { BucketDataDocumentV3 } from '@module/storage/implementation/v3/models.js';
|
|
6
6
|
import { VersionedPowerSyncMongoV3 } from '@module/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
addChecksums,
|
|
9
|
+
CheckpointChecksumInvalidatedError,
|
|
10
|
+
storage,
|
|
11
|
+
SyncRulesBucketStorage,
|
|
12
|
+
updateSyncRulesFromYaml
|
|
13
|
+
} from '@powersync/service-core';
|
|
8
14
|
import { bucketRequest, register, test_utils } from '@powersync/service-core-tests';
|
|
9
15
|
import * as bson from 'bson';
|
|
10
16
|
import { describe, expect, test } from 'vitest';
|
|
@@ -88,8 +94,7 @@ bucket_definitions:
|
|
|
88
94
|
moveBatchQueryLimit: 10,
|
|
89
95
|
minBucketChanges: 1,
|
|
90
96
|
minChangeRatio: 0,
|
|
91
|
-
maxOpId: checkpoint
|
|
92
|
-
signal: null as any
|
|
97
|
+
maxOpId: checkpoint
|
|
93
98
|
});
|
|
94
99
|
|
|
95
100
|
const users = ['u1', 'u2'];
|
|
@@ -123,7 +128,6 @@ bucket_definitions:
|
|
|
123
128
|
);
|
|
124
129
|
const bucketStorage = factory.getInstance(syncRules);
|
|
125
130
|
const syncRulesContent = syncRules.syncConfigContent[0];
|
|
126
|
-
const storageDb = (bucketStorage as any).db;
|
|
127
131
|
|
|
128
132
|
await populate(bucketStorage, 2);
|
|
129
133
|
const { checkpoint } = await bucketStorage.getCheckpoint();
|
|
@@ -236,7 +240,7 @@ bucket_definitions:
|
|
|
236
240
|
expect(typeof firstBatch.value[0].estimatedCount).toBe('number');
|
|
237
241
|
expect(firstBatch.value[0].dirtyRatio).toBeCloseTo(5 / 12);
|
|
238
242
|
|
|
239
|
-
const checksumBuckets = await
|
|
243
|
+
const checksumBuckets = await compactor.dirtyBucketBatchForChecksums({
|
|
240
244
|
minBucketChanges: 1
|
|
241
245
|
});
|
|
242
246
|
expect(checksumBuckets).toEqual([
|
|
@@ -292,7 +296,6 @@ describe('V3 invariant verification', () => {
|
|
|
292
296
|
row_id: 'row1',
|
|
293
297
|
checksum: 1n,
|
|
294
298
|
data: '{"id":"row1"}',
|
|
295
|
-
target_op: null,
|
|
296
299
|
...overrides
|
|
297
300
|
};
|
|
298
301
|
}
|
|
@@ -347,8 +350,7 @@ bucket_definitions:
|
|
|
347
350
|
table: TABLE,
|
|
348
351
|
row_id: rowId,
|
|
349
352
|
checksum: BigInt(opId * 7),
|
|
350
|
-
data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
|
|
351
|
-
target_op: null
|
|
353
|
+
data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
|
|
352
354
|
};
|
|
353
355
|
}
|
|
354
356
|
|
|
@@ -371,8 +373,7 @@ bucket_definitions:
|
|
|
371
373
|
moveBatchQueryLimit: 10,
|
|
372
374
|
minBucketChanges: 1,
|
|
373
375
|
minChangeRatio: 0,
|
|
374
|
-
maxOpId
|
|
375
|
-
signal: null as any
|
|
376
|
+
maxOpId
|
|
376
377
|
});
|
|
377
378
|
}
|
|
378
379
|
|
|
@@ -385,7 +386,7 @@ bucket_definitions:
|
|
|
385
386
|
|
|
386
387
|
const doc = serializeBucketData('test[]', ops);
|
|
387
388
|
|
|
388
|
-
expect(doc.ops
|
|
389
|
+
expect(doc.ops!.map((op) => op.o)).toEqual([5n, 3n, 7n]);
|
|
389
390
|
});
|
|
390
391
|
|
|
391
392
|
test('1. ops[] ordering - preserved after compaction', async () => {
|
|
@@ -403,8 +404,8 @@ bucket_definitions:
|
|
|
403
404
|
|
|
404
405
|
const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
405
406
|
for (const doc of docs) {
|
|
406
|
-
for (let i = 1; i < doc.ops
|
|
407
|
-
expect(doc.ops[i].o).toBeGreaterThanOrEqual(doc.ops[i - 1].o);
|
|
407
|
+
for (let i = 1; i < doc.ops!.length; i++) {
|
|
408
|
+
expect(doc.ops![i].o).toBeGreaterThanOrEqual(doc.ops![i - 1].o);
|
|
408
409
|
}
|
|
409
410
|
}
|
|
410
411
|
});
|
|
@@ -422,7 +423,15 @@ bucket_definitions:
|
|
|
422
423
|
expect(doc.min_op).toBe(3n);
|
|
423
424
|
expect(doc.count).toBe(3);
|
|
424
425
|
expect(doc.checksum).toBe(60n);
|
|
425
|
-
expect(doc.size).toBe(
|
|
426
|
+
expect(doc.size).toBe(bson.calculateObjectSize(doc.ops!));
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('2. has_clear_op is only stored when true', () => {
|
|
430
|
+
const putDoc = serializeBucketData('test[]', [makeBucketDataDoc({ o: 1n })]);
|
|
431
|
+
const clearDoc = serializeBucketData('test[]', [makeBucketDataDoc({ o: 2n, op: 'CLEAR', data: null })]);
|
|
432
|
+
|
|
433
|
+
expect(putDoc).not.toHaveProperty('has_clear_op');
|
|
434
|
+
expect(clearDoc.has_clear_op).toBe(true);
|
|
426
435
|
});
|
|
427
436
|
|
|
428
437
|
test('2. range metadata consistency - after compaction', async () => {
|
|
@@ -436,33 +445,14 @@ bucket_definitions:
|
|
|
436
445
|
|
|
437
446
|
const docs = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
438
447
|
for (const doc of docs) {
|
|
439
|
-
expect(doc._id.o).toBe(doc.ops
|
|
440
|
-
expect(doc.min_op).toBe(doc.ops
|
|
441
|
-
expect(doc.count).toBe(doc.ops
|
|
442
|
-
expect(doc.checksum).toBe(doc.ops
|
|
443
|
-
expect(doc.size).toBe(doc.ops
|
|
448
|
+
expect(doc._id.o).toBe(doc.ops!.reduce((max, op) => (op.o > max ? op.o : max), 0n));
|
|
449
|
+
expect(doc.min_op).toBe(doc.ops!.reduce((min, op) => (op.o < min ? op.o : min), doc.ops![0].o));
|
|
450
|
+
expect(doc.count).toBe(doc.ops!.length);
|
|
451
|
+
expect(doc.checksum).toBe(doc.ops!.reduce((sum, op) => sum + op.checksum, 0n));
|
|
452
|
+
expect(doc.size).toBe(bson.calculateObjectSize(doc.ops!));
|
|
444
453
|
}
|
|
445
454
|
});
|
|
446
455
|
|
|
447
|
-
test('3. target_op correctness - max of non-null target_ops', () => {
|
|
448
|
-
const ops = [
|
|
449
|
-
makeBucketDataDoc({ o: 1n, target_op: null }),
|
|
450
|
-
makeBucketDataDoc({ o: 2n, target_op: 10n }),
|
|
451
|
-
makeBucketDataDoc({ o: 3n, target_op: 5n }),
|
|
452
|
-
makeBucketDataDoc({ o: 4n, target_op: null })
|
|
453
|
-
];
|
|
454
|
-
|
|
455
|
-
const doc = serializeBucketData('test[]', ops);
|
|
456
|
-
expect(doc.target_op).toBe(10n);
|
|
457
|
-
});
|
|
458
|
-
|
|
459
|
-
test('3. target_op correctness - all null yields null', () => {
|
|
460
|
-
const ops = [makeBucketDataDoc({ o: 1n, target_op: null }), makeBucketDataDoc({ o: 2n, target_op: null })];
|
|
461
|
-
|
|
462
|
-
const doc = serializeBucketData('test[]', ops);
|
|
463
|
-
expect(doc.target_op).toBeNull();
|
|
464
|
-
});
|
|
465
|
-
|
|
466
456
|
test('4. no overlapping ranges - multiple documents', () => {
|
|
467
457
|
const opsA = [makeBucketDataDoc({ o: 1n }), makeBucketDataDoc({ o: 3n })];
|
|
468
458
|
const opsB = [makeBucketDataDoc({ o: 5n }), makeBucketDataDoc({ o: 8n })];
|
|
@@ -522,7 +512,7 @@ bucket_definitions:
|
|
|
522
512
|
await compact(bucketStorage, 30n);
|
|
523
513
|
|
|
524
514
|
const docs = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
525
|
-
const allOps = docs.flatMap((d) => d.ops);
|
|
515
|
+
const allOps = docs.flatMap((d) => d.ops!);
|
|
526
516
|
const moveOps = allOps.filter((op) => op.op === 'MOVE');
|
|
527
517
|
expect(moveOps.length).toBe(1);
|
|
528
518
|
expect(moveOps[0].o).toBe(10n);
|
|
@@ -547,7 +537,7 @@ bucket_definitions:
|
|
|
547
537
|
await compact(bucketStorage, 30n);
|
|
548
538
|
|
|
549
539
|
const docs = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
550
|
-
const allOps = docs.flatMap((d) => d.ops);
|
|
540
|
+
const allOps = docs.flatMap((d) => d.ops!);
|
|
551
541
|
const moveOps = allOps.filter((op) => op.op === 'MOVE');
|
|
552
542
|
// Pre-existing MOVE@20 + new MOVE@10 are collapsed into CLEAR
|
|
553
543
|
expect(moveOps.length).toBe(0);
|
|
@@ -573,7 +563,7 @@ bucket_definitions:
|
|
|
573
563
|
await compact(bucketStorage, 25n);
|
|
574
564
|
|
|
575
565
|
const docs = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
576
|
-
const allOps = docs.flatMap((d) => d.ops);
|
|
566
|
+
const allOps = docs.flatMap((d) => d.ops!);
|
|
577
567
|
const clearOps = allOps.filter((op) => op.op === 'CLEAR');
|
|
578
568
|
expect(clearOps.length).toBe(1);
|
|
579
569
|
});
|
|
@@ -591,7 +581,7 @@ bucket_definitions:
|
|
|
591
581
|
await compact(bucketStorage, 20n);
|
|
592
582
|
|
|
593
583
|
const docs = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
594
|
-
const allOps = docs.flatMap((d) => d.ops);
|
|
584
|
+
const allOps = docs.flatMap((d) => d.ops!);
|
|
595
585
|
// MOVE@10 + REMOVE@20 collapsed into CLEAR@20
|
|
596
586
|
const clearOps = allOps.filter((op) => op.op === 'CLEAR');
|
|
597
587
|
expect(clearOps.length).toBe(1);
|
|
@@ -609,8 +599,7 @@ bucket_definitions:
|
|
|
609
599
|
moveBatchQueryLimit: 10,
|
|
610
600
|
minBucketChanges: 1,
|
|
611
601
|
minChangeRatio: 0,
|
|
612
|
-
maxOpId: 1n
|
|
613
|
-
signal: null as any
|
|
602
|
+
maxOpId: 1n
|
|
614
603
|
});
|
|
615
604
|
|
|
616
605
|
const docs = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
@@ -640,7 +629,7 @@ bucket_definitions:
|
|
|
640
629
|
test('9. serialization fidelity - null data preserved', () => {
|
|
641
630
|
const ops = [makeBucketDataDoc({ o: 1n, data: null })];
|
|
642
631
|
const doc = serializeBucketData('test[]', ops);
|
|
643
|
-
expect(doc.ops[0].data).toBeNull();
|
|
632
|
+
expect(doc.ops![0].data).toBeNull();
|
|
644
633
|
|
|
645
634
|
const context = { replicationStreamId: 1, definitionId: '1' };
|
|
646
635
|
const deserialized = [...loadBucketDataDocument(context, doc)];
|
|
@@ -650,7 +639,7 @@ bucket_definitions:
|
|
|
650
639
|
test('9. serialization fidelity - empty string data preserved', () => {
|
|
651
640
|
const ops = [makeBucketDataDoc({ o: 1n, data: '' })];
|
|
652
641
|
const doc = serializeBucketData('test[]', ops);
|
|
653
|
-
expect(doc.ops[0].data).toBe('');
|
|
642
|
+
expect(doc.ops![0].data).toBe('');
|
|
654
643
|
|
|
655
644
|
const context = { replicationStreamId: 1, definitionId: '1' };
|
|
656
645
|
const deserialized = [...loadBucketDataDocument(context, doc)];
|
|
@@ -661,7 +650,7 @@ bucket_definitions:
|
|
|
661
650
|
const unicodeData = '{"name":"日本語テスト","emoji":"🎉"}';
|
|
662
651
|
const ops = [makeBucketDataDoc({ o: 1n, data: unicodeData })];
|
|
663
652
|
const doc = serializeBucketData('test[]', ops);
|
|
664
|
-
expect(doc.ops[0].data).toBe(unicodeData);
|
|
653
|
+
expect(doc.ops![0].data).toBe(unicodeData);
|
|
665
654
|
|
|
666
655
|
const context = { replicationStreamId: 1, definitionId: '1' };
|
|
667
656
|
const deserialized = [...loadBucketDataDocument(context, doc)];
|
|
@@ -696,12 +685,12 @@ bucket_definitions:
|
|
|
696
685
|
|
|
697
686
|
const docs = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
698
687
|
for (const doc of docs) {
|
|
699
|
-
const maxO = doc.ops
|
|
688
|
+
const maxO = doc.ops!.reduce((max, op) => (op.o > max ? op.o : max), 0n);
|
|
700
689
|
expect(doc._id.o).toBe(maxO);
|
|
701
690
|
}
|
|
702
691
|
});
|
|
703
692
|
|
|
704
|
-
test('compaction with maxOpId filtering
|
|
693
|
+
test('compaction with maxOpId filtering excludes a straddling document', async () => {
|
|
705
694
|
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3Storage();
|
|
706
695
|
const ops = [
|
|
707
696
|
makeOp(10, 'A', 'a1', ctx, sourceTableId),
|
|
@@ -715,15 +704,13 @@ bucket_definitions:
|
|
|
715
704
|
await compact(bucketStorage, 15n);
|
|
716
705
|
|
|
717
706
|
const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
718
|
-
const allOpsAfter = docsAfter.flatMap((d) => d.ops);
|
|
707
|
+
const allOpsAfter = docsAfter.flatMap((d) => d.ops!);
|
|
719
708
|
|
|
720
|
-
//
|
|
709
|
+
// The document ends above maxOpId, so the database query excludes it as a unit.
|
|
721
710
|
expect(allOpsAfter.length).toBe(3);
|
|
722
|
-
// Ops <= maxOpId still present
|
|
723
711
|
const opsBelow = allOpsAfter.filter((op) => op.o <= 15n);
|
|
724
712
|
expect(opsBelow.length).toBe(1);
|
|
725
713
|
expect(opsBelow[0].op).toBe('PUT');
|
|
726
|
-
// Ops > maxOpId preserved unchanged
|
|
727
714
|
const opsAbove = allOpsAfter.filter((op) => op.o > 15n);
|
|
728
715
|
expect(opsAbove.length).toBe(2);
|
|
729
716
|
expect(opsAbove.every((op) => op.op === 'PUT')).toBe(true);
|
|
@@ -747,7 +734,7 @@ bucket_definitions:
|
|
|
747
734
|
}
|
|
748
735
|
|
|
749
736
|
const docs = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
750
|
-
const storedOps = docs.flatMap((d) => d.ops);
|
|
737
|
+
const storedOps = docs.flatMap((d) => d.ops!);
|
|
751
738
|
let storedChecksum = 0;
|
|
752
739
|
for (const op of storedOps) {
|
|
753
740
|
storedChecksum = addChecksums(storedChecksum, Number(op.checksum));
|
|
@@ -809,14 +796,27 @@ bucket_definitions:
|
|
|
809
796
|
table: TABLE,
|
|
810
797
|
row_id: rowId,
|
|
811
798
|
checksum: BigInt(opId * 7),
|
|
812
|
-
data: JSON.stringify({ id: rowId, description: data })
|
|
813
|
-
target_op: null
|
|
799
|
+
data: JSON.stringify({ id: rowId, description: data })
|
|
814
800
|
};
|
|
815
801
|
}
|
|
816
802
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
803
|
+
function checksumRequest(): storage.BucketChecksumRequest {
|
|
804
|
+
return {
|
|
805
|
+
bucket: BUCKET,
|
|
806
|
+
source: {
|
|
807
|
+
uniqueName: 'global',
|
|
808
|
+
bucketParameters: [],
|
|
809
|
+
getSourceTables: () => new Set(),
|
|
810
|
+
tableSyncsData: () => false,
|
|
811
|
+
evaluateRow: () => [],
|
|
812
|
+
inferSchema: () => ({ objects: {} }),
|
|
813
|
+
bucketQuery: () => ({ ast: {} as any, parameters: [] })
|
|
814
|
+
} as any
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
test('start straddle recalculates a cached checksum from the beginning', async () => {
|
|
819
|
+
const { bucketStorage, collection, bucketStateCollection, definitionId, sourceTableId, ctx } = await setup();
|
|
820
820
|
|
|
821
821
|
// Single document with ops 10-60, min_op=10, _id.o=60
|
|
822
822
|
const ops = [
|
|
@@ -827,57 +827,37 @@ bucket_definitions:
|
|
|
827
827
|
makeOp(50, 'E', 'e1', ctx, sourceTableId),
|
|
828
828
|
makeOp(60, 'F', 'f1', ctx, sourceTableId)
|
|
829
829
|
];
|
|
830
|
-
|
|
831
|
-
|
|
830
|
+
// Cache the first half before compaction changes the document boundary.
|
|
831
|
+
const firstDoc = serializeBucketData(BUCKET, ops.slice(0, 3));
|
|
832
|
+
await collection.insertOne(firstDoc);
|
|
832
833
|
|
|
833
|
-
// Set compacted_state.op_id = 30 to create a partial range starting at 30.
|
|
834
|
-
// The pipeline will query ops where o > 30 and o <= 60.
|
|
835
|
-
// The document has min_op=10 < 30, so it's partially included.
|
|
836
|
-
// The pipeline must $filter ops to only sum those with o > 30 (ops 40, 50, 60).
|
|
837
834
|
const fullChecksum = ops.reduce((sum, op) => addChecksums(sum, Number(op.checksum)), 0);
|
|
838
|
-
const partialChecksum = ops
|
|
839
|
-
.filter((op) => op.o > 30n)
|
|
840
|
-
.reduce((sum, op) => addChecksums(sum, Number(op.checksum)), 0);
|
|
841
|
-
|
|
842
|
-
// Partial and full must differ, otherwise the document is fully included and no straddling occurs.
|
|
843
|
-
expect(partialChecksum).not.toBe(fullChecksum);
|
|
844
|
-
|
|
845
835
|
await bucketStateCollection.insertOne({
|
|
846
836
|
_id: { d: definitionId, b: BUCKET },
|
|
847
837
|
last_op: 30n,
|
|
848
|
-
compacted_state: {
|
|
849
|
-
op_id: 30n,
|
|
850
|
-
count: 3,
|
|
851
|
-
checksum: BigInt(
|
|
852
|
-
ops.filter((op) => op.o <= 30n).reduce((sum, op) => addChecksums(sum, Number(op.checksum)), 0)
|
|
853
|
-
),
|
|
854
|
-
bytes: null
|
|
855
|
-
},
|
|
856
838
|
estimate_since_compact: { count: 3, bytes: 100 }
|
|
857
839
|
});
|
|
858
840
|
|
|
859
|
-
const request
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
bucketQuery: () => ({ ast: {} as any, parameters: [] })
|
|
869
|
-
} as any
|
|
870
|
-
};
|
|
841
|
+
const request = checksumRequest();
|
|
842
|
+
|
|
843
|
+
const cached = await bucketStorage.getChecksums(test_utils.testCheckpoint(30n), [request]);
|
|
844
|
+
expect(cached.get(BUCKET)).toMatchObject({ count: 3 });
|
|
845
|
+
|
|
846
|
+
// Compaction rechunks the bucket into one document spanning the cached
|
|
847
|
+
// checkpoint. The requested endpoint is the end of the new document.
|
|
848
|
+
await collection.deleteMany({});
|
|
849
|
+
await collection.insertOne(serializeBucketData(BUCKET, ops, { targetOp: 60n }));
|
|
871
850
|
|
|
872
851
|
const result = await bucketStorage.getChecksums(test_utils.testCheckpoint(60n), [request]);
|
|
873
852
|
const checksumResult = result.get(BUCKET)!;
|
|
874
853
|
|
|
875
|
-
// The
|
|
854
|
+
// The cached checkpoint at 30 is inside the new document. The full result
|
|
855
|
+
// must replace it rather than add the document checksum to it.
|
|
876
856
|
expect(checksumResult.checksum).toBe(fullChecksum);
|
|
877
857
|
expect(checksumResult.count).toBe(6);
|
|
878
858
|
});
|
|
879
859
|
|
|
880
|
-
test('
|
|
860
|
+
test('end straddle invalidates the old checkpoint and a later checkpoint succeeds', async () => {
|
|
881
861
|
const { bucketStorage, collection, bucketStateCollection, definitionId, sourceTableId, ctx } = await setup();
|
|
882
862
|
|
|
883
863
|
// Document with ops 40-60, _id.o=60, min_op=40
|
|
@@ -886,49 +866,75 @@ bucket_definitions:
|
|
|
886
866
|
makeOp(50, 'E', 'e1', ctx, sourceTableId),
|
|
887
867
|
makeOp(60, 'F', 'f1', ctx, sourceTableId)
|
|
888
868
|
];
|
|
889
|
-
const doc = serializeBucketData(BUCKET, ops);
|
|
869
|
+
const doc = serializeBucketData(BUCKET, ops, { targetOp: 60n });
|
|
890
870
|
await collection.insertMany([doc]);
|
|
891
871
|
|
|
892
|
-
// No compacted_state — start from beginning, so the full document is in range.
|
|
893
|
-
// Request checksums with checkpoint=45 (falls between ops 40 and 50).
|
|
894
|
-
// createBucketFilter produces _id.o <= 45.
|
|
895
|
-
// This document has _id.o=60 > 45, so the filter excludes it.
|
|
896
|
-
// But the document contains op 40 which should be included (40 <= 45).
|
|
897
|
-
const checksumUpTo45 = ops
|
|
898
|
-
.filter((op) => op.o <= 45n)
|
|
899
|
-
.reduce((sum, op) => addChecksums(sum, Number(op.checksum)), 0);
|
|
900
|
-
|
|
901
872
|
const checksumAllOps = ops.reduce((sum, op) => addChecksums(sum, Number(op.checksum)), 0);
|
|
902
873
|
|
|
903
|
-
|
|
904
|
-
|
|
874
|
+
await bucketStateCollection.insertOne({
|
|
875
|
+
_id: { d: definitionId, b: BUCKET },
|
|
876
|
+
last_op: 0n,
|
|
877
|
+
estimate_since_compact: { count: 0, bytes: 0 }
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
const request = checksumRequest();
|
|
905
881
|
|
|
882
|
+
await expect(bucketStorage.getChecksums(test_utils.testCheckpoint(45n), [request])).rejects.toBeInstanceOf(
|
|
883
|
+
CheckpointChecksumInvalidatedError
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
const result = await bucketStorage.getChecksums(test_utils.testCheckpoint(60n), [request]);
|
|
887
|
+
expect(result.get(BUCKET)).toEqual({ bucket: BUCKET, checksum: checksumAllOps, count: 3 });
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
test('end straddle without target_op also invalidates the old checkpoint', async () => {
|
|
891
|
+
const { bucketStorage, collection, bucketStateCollection, definitionId, sourceTableId, ctx } = await setup();
|
|
892
|
+
const ops = [makeOp(40, 'D', 'd1', ctx, sourceTableId), makeOp(60, 'F', 'f1', ctx, sourceTableId)];
|
|
893
|
+
await collection.insertOne(serializeBucketData(BUCKET, ops));
|
|
906
894
|
await bucketStateCollection.insertOne({
|
|
907
895
|
_id: { d: definitionId, b: BUCKET },
|
|
908
896
|
last_op: 0n,
|
|
909
897
|
estimate_since_compact: { count: 0, bytes: 0 }
|
|
910
898
|
});
|
|
911
899
|
|
|
912
|
-
const request
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
getSourceTables: () => new Set(),
|
|
918
|
-
tableSyncsData: () => false,
|
|
919
|
-
evaluateRow: () => [],
|
|
920
|
-
inferSchema: () => ({ objects: {} }),
|
|
921
|
-
bucketQuery: () => ({ ast: {} as any, parameters: [] })
|
|
922
|
-
} as any
|
|
923
|
-
};
|
|
900
|
+
const request = checksumRequest();
|
|
901
|
+
await expect(bucketStorage.getChecksums(test_utils.testCheckpoint(45n), [request])).rejects.toBeInstanceOf(
|
|
902
|
+
CheckpointChecksumInvalidatedError
|
|
903
|
+
);
|
|
904
|
+
});
|
|
924
905
|
|
|
925
|
-
|
|
926
|
-
const
|
|
906
|
+
test('has_clear_op replaces a cached checksum', async () => {
|
|
907
|
+
const { bucketStorage, collection, bucketStateCollection, definitionId, sourceTableId, ctx } = await setup();
|
|
908
|
+
const beforeClear = makeOp(10, 'A', 'a1', ctx, sourceTableId);
|
|
909
|
+
const clear = { ...makeOp(20, 'clear', '', ctx, sourceTableId), op: 'CLEAR' as const, checksum: 101n, data: null };
|
|
910
|
+
const afterClear = makeOp(30, 'B', 'b1', ctx, sourceTableId);
|
|
927
911
|
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
912
|
+
await collection.insertOne(serializeBucketData(BUCKET, [beforeClear]));
|
|
913
|
+
await bucketStateCollection.insertOne({
|
|
914
|
+
_id: { d: definitionId, b: BUCKET },
|
|
915
|
+
last_op: 10n,
|
|
916
|
+
estimate_since_compact: { count: 1, bytes: 100 }
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
const request = checksumRequest();
|
|
920
|
+
const before = await bucketStorage.getChecksums(test_utils.testCheckpoint(10n), [request]);
|
|
921
|
+
expect(before.get(BUCKET)).toEqual({ bucket: BUCKET, checksum: 70, count: 1 });
|
|
922
|
+
|
|
923
|
+
// Compaction replaces everything preceding CLEAR, while the checksum cache
|
|
924
|
+
// still contains the old checkpoint.
|
|
925
|
+
await collection.deleteMany({});
|
|
926
|
+
await collection.insertOne(serializeBucketData(BUCKET, [clear, afterClear]));
|
|
927
|
+
await bucketStateCollection.updateOne(
|
|
928
|
+
{ _id: { d: definitionId, b: BUCKET } },
|
|
929
|
+
{ $set: { last_op: 30n, 'estimate_since_compact.count': 2 } }
|
|
930
|
+
);
|
|
931
|
+
|
|
932
|
+
const after = await bucketStorage.getChecksums(test_utils.testCheckpoint(30n), [request]);
|
|
933
|
+
expect(after.get(BUCKET)).toEqual({
|
|
934
|
+
bucket: BUCKET,
|
|
935
|
+
checksum: addChecksums(Number(clear.checksum), Number(afterClear.checksum)),
|
|
936
|
+
count: 2
|
|
937
|
+
});
|
|
932
938
|
});
|
|
933
939
|
});
|
|
934
940
|
|
|
@@ -986,8 +992,7 @@ bucket_definitions:
|
|
|
986
992
|
table: TABLE,
|
|
987
993
|
row_id: rowId,
|
|
988
994
|
checksum: BigInt(opId * 7),
|
|
989
|
-
data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
|
|
990
|
-
target_op: null
|
|
995
|
+
data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
|
|
991
996
|
};
|
|
992
997
|
}
|
|
993
998
|
|
|
@@ -1010,15 +1015,14 @@ bucket_definitions:
|
|
|
1010
1015
|
moveBatchQueryLimit: 10,
|
|
1011
1016
|
minBucketChanges: 1,
|
|
1012
1017
|
minChangeRatio: 0,
|
|
1013
|
-
maxOpId
|
|
1014
|
-
signal: null as any
|
|
1018
|
+
maxOpId
|
|
1015
1019
|
});
|
|
1016
1020
|
}
|
|
1017
1021
|
|
|
1018
1022
|
async function readAllOps(collection: any): Promise<{ row_id: string; o: bigint; op: string }[]> {
|
|
1019
1023
|
const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1020
1024
|
return docs.flatMap((d: any) =>
|
|
1021
|
-
d.ops
|
|
1025
|
+
d.ops!.map((op: any) => ({ row_id: op.row_id!, o: op.o, op: op.op, target_op: op.target_op ?? undefined }))
|
|
1022
1026
|
);
|
|
1023
1027
|
}
|
|
1024
1028
|
|
|
@@ -1255,8 +1259,7 @@ bucket_definitions:
|
|
|
1255
1259
|
table: TABLE,
|
|
1256
1260
|
row_id: rowId,
|
|
1257
1261
|
checksum: BigInt(opId * 7),
|
|
1258
|
-
data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
|
|
1259
|
-
target_op: null
|
|
1262
|
+
data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
|
|
1260
1263
|
};
|
|
1261
1264
|
}
|
|
1262
1265
|
|
|
@@ -1279,8 +1282,7 @@ bucket_definitions:
|
|
|
1279
1282
|
moveBatchQueryLimit: 10,
|
|
1280
1283
|
minBucketChanges: 1,
|
|
1281
1284
|
minChangeRatio: 0,
|
|
1282
|
-
maxOpId
|
|
1283
|
-
signal: null as any
|
|
1285
|
+
maxOpId
|
|
1284
1286
|
});
|
|
1285
1287
|
}
|
|
1286
1288
|
|
|
@@ -1307,7 +1309,7 @@ bucket_definitions:
|
|
|
1307
1309
|
|
|
1308
1310
|
expect(checksumAfter).toBe(checksumBefore);
|
|
1309
1311
|
|
|
1310
|
-
const allOpsAfter = docsAfter.flatMap((d) => d.ops);
|
|
1312
|
+
const allOpsAfter = docsAfter.flatMap((d) => d.ops!);
|
|
1311
1313
|
// Two MOVEs collapsed into one CLEAR
|
|
1312
1314
|
expect(allOpsAfter.length).toBe(4);
|
|
1313
1315
|
const clearOps = allOpsAfter.filter((op) => op.op === 'CLEAR');
|
|
@@ -1336,7 +1338,7 @@ bucket_definitions:
|
|
|
1336
1338
|
|
|
1337
1339
|
expect(checksumAfter).toBe(checksumBefore);
|
|
1338
1340
|
|
|
1339
|
-
const allOpsAfter = docsAfter.flatMap((d) => d.ops);
|
|
1341
|
+
const allOpsAfter = docsAfter.flatMap((d) => d.ops!);
|
|
1340
1342
|
// Two MOVEs collapsed into one CLEAR
|
|
1341
1343
|
expect(allOpsAfter.length).toBe(5);
|
|
1342
1344
|
const clearOps = allOpsAfter.filter((op) => op.op === 'CLEAR');
|
|
@@ -1357,7 +1359,7 @@ bucket_definitions:
|
|
|
1357
1359
|
await compact(bucketStorage, 30n);
|
|
1358
1360
|
|
|
1359
1361
|
const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1360
|
-
const allOps = docs.flatMap((d) => d.ops);
|
|
1362
|
+
const allOps = docs.flatMap((d) => d.ops!);
|
|
1361
1363
|
const moveOps = allOps.filter((op) => op.op === 'MOVE');
|
|
1362
1364
|
expect(moveOps.length).toBe(1);
|
|
1363
1365
|
expect(moveOps[0].o).toBe(10n);
|
|
@@ -1368,8 +1370,9 @@ bucket_definitions:
|
|
|
1368
1370
|
expect(putOps.every((op) => op.data != null)).toBe(true);
|
|
1369
1371
|
|
|
1370
1372
|
expect(docs.length).toBe(1);
|
|
1371
|
-
|
|
1372
|
-
expect(docs[0].size).
|
|
1373
|
+
// Size calculation is not exact - we just check for a range
|
|
1374
|
+
expect(docs[0].size).toBeGreaterThan(1_000_000);
|
|
1375
|
+
expect(docs[0].size).toBeLessThan(1_001_000);
|
|
1373
1376
|
});
|
|
1374
1377
|
|
|
1375
1378
|
test('tombstones and survivors end up in same document after rechunking', async () => {
|
|
@@ -1394,15 +1397,15 @@ bucket_definitions:
|
|
|
1394
1397
|
const checksumAfter = docs.reduce((sum, d) => addChecksums(sum, Number(d.checksum)), 0);
|
|
1395
1398
|
expect(checksumAfter).toBe(checksumBefore);
|
|
1396
1399
|
|
|
1397
|
-
const allOps = docs.flatMap((d) => d.ops);
|
|
1400
|
+
const allOps = docs.flatMap((d) => d.ops!);
|
|
1398
1401
|
expect(allOps.length).toBe(5);
|
|
1399
1402
|
const moveOp = allOps.find((op) => op.op === 'MOVE');
|
|
1400
1403
|
expect(moveOp).toMatchObject({ o: 10n, data: null });
|
|
1401
1404
|
|
|
1402
1405
|
expect(docs.length).toBe(1);
|
|
1403
1406
|
|
|
1404
|
-
const moveOpsInDoc = docs[0].ops
|
|
1405
|
-
const putOpsInDoc = docs[0].ops
|
|
1407
|
+
const moveOpsInDoc = docs[0].ops!.filter((op) => op.op === 'MOVE');
|
|
1408
|
+
const putOpsInDoc = docs[0].ops!.filter((op) => op.op === 'PUT');
|
|
1406
1409
|
expect(moveOpsInDoc.length).toBe(1);
|
|
1407
1410
|
expect(putOpsInDoc.length).toBe(4);
|
|
1408
1411
|
});
|
|
@@ -1468,8 +1471,7 @@ bucket_definitions:
|
|
|
1468
1471
|
table: TABLE,
|
|
1469
1472
|
row_id: rowId,
|
|
1470
1473
|
checksum: BigInt(opId * 7),
|
|
1471
|
-
data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
|
|
1472
|
-
target_op: null
|
|
1474
|
+
data: overrides?.op === 'REMOVE' ? null : JSON.stringify({ id: rowId, description: data })
|
|
1473
1475
|
};
|
|
1474
1476
|
}
|
|
1475
1477
|
|
|
@@ -1490,7 +1492,7 @@ bucket_definitions:
|
|
|
1490
1492
|
): Promise<{ row_id: string | undefined; o: bigint; op: string; target_op: bigint | undefined }[]> {
|
|
1491
1493
|
const docs = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1492
1494
|
return docs.flatMap((d: any) =>
|
|
1493
|
-
d.ops
|
|
1495
|
+
d.ops!.map((op: any) => ({
|
|
1494
1496
|
row_id: op.row_id ?? undefined,
|
|
1495
1497
|
o: op.o,
|
|
1496
1498
|
op: op.op,
|
|
@@ -1554,8 +1556,7 @@ bucket_definitions:
|
|
|
1554
1556
|
moveBatchQueryLimit: 2,
|
|
1555
1557
|
minBucketChanges: 1,
|
|
1556
1558
|
minChangeRatio: 0,
|
|
1557
|
-
maxOpId: 24n
|
|
1558
|
-
signal: null as any
|
|
1559
|
+
maxOpId: 24n
|
|
1559
1560
|
});
|
|
1560
1561
|
|
|
1561
1562
|
// Verify checksum preserved
|
|
@@ -1604,14 +1605,13 @@ bucket_definitions:
|
|
|
1604
1605
|
moveBatchQueryLimit: 2,
|
|
1605
1606
|
minBucketChanges: 1,
|
|
1606
1607
|
minChangeRatio: 0,
|
|
1607
|
-
maxOpId: 500n
|
|
1608
|
-
signal: null as any
|
|
1608
|
+
maxOpId: 500n
|
|
1609
1609
|
});
|
|
1610
1610
|
|
|
1611
1611
|
// The document at _id.o=600 must still be present and unmodified after compaction.
|
|
1612
1612
|
// The streaming compactor should only touch documents containing ops <= maxOpId.
|
|
1613
1613
|
const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
1614
|
-
const allOpsAfter = docsAfter.flatMap((d) => d.ops);
|
|
1614
|
+
const allOpsAfter = docsAfter.flatMap((d) => d.ops!);
|
|
1615
1615
|
const op600 = allOpsAfter.find((op) => op.o === 600n);
|
|
1616
1616
|
expect(op600).toBeDefined();
|
|
1617
1617
|
expect(op600!.op).toBe('PUT');
|
|
@@ -1654,8 +1654,7 @@ bucket_definitions:
|
|
|
1654
1654
|
minBucketChanges: 1,
|
|
1655
1655
|
minChangeRatio: 0,
|
|
1656
1656
|
maxOpId: 40n,
|
|
1657
|
-
memoryLimitMB: 0.001
|
|
1658
|
-
signal: null as any
|
|
1657
|
+
memoryLimitMB: 0.001
|
|
1659
1658
|
});
|
|
1660
1659
|
|
|
1661
1660
|
// Bucket checksum must be correct even with overflow — old ops that pass through
|
|
@@ -1706,8 +1705,7 @@ bucket_definitions:
|
|
|
1706
1705
|
moveBatchQueryLimit: 10,
|
|
1707
1706
|
minBucketChanges: 1,
|
|
1708
1707
|
minChangeRatio: 0,
|
|
1709
|
-
maxOpId: 300n
|
|
1710
|
-
signal: null as any
|
|
1708
|
+
maxOpId: 300n
|
|
1711
1709
|
});
|
|
1712
1710
|
|
|
1713
1711
|
// The sandwiched document (doc2, ops 340+350) must survive because ALL
|
|
@@ -1718,7 +1716,7 @@ bucket_definitions:
|
|
|
1718
1716
|
expect(sandwichOps.every((op) => op.op === 'PUT')).toBe(true);
|
|
1719
1717
|
});
|
|
1720
1718
|
|
|
1721
|
-
test('6. byte-based
|
|
1719
|
+
test('6. byte-based read batches do not constrain output merging', async () => {
|
|
1722
1720
|
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1723
1721
|
|
|
1724
1722
|
// 6 documents, each with 2 PUT ops. With moveBatchByteLimit=400,
|
|
@@ -1734,6 +1732,7 @@ bucket_definitions:
|
|
|
1734
1732
|
])
|
|
1735
1733
|
);
|
|
1736
1734
|
}
|
|
1735
|
+
expect(docs[0].size + docs[1].size).toBeGreaterThan(400);
|
|
1737
1736
|
await insertDocs(collection, docs);
|
|
1738
1737
|
await insertBucketState(bucketStateCollection, ctx.definitionId, 12n);
|
|
1739
1738
|
|
|
@@ -1747,8 +1746,7 @@ bucket_definitions:
|
|
|
1747
1746
|
moveBatchByteLimit: 400,
|
|
1748
1747
|
minBucketChanges: 1,
|
|
1749
1748
|
minChangeRatio: 0,
|
|
1750
|
-
maxOpId: 12n
|
|
1751
|
-
signal: null as any
|
|
1749
|
+
maxOpId: 12n
|
|
1752
1750
|
});
|
|
1753
1751
|
|
|
1754
1752
|
const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
@@ -1760,8 +1758,13 @@ bucket_definitions:
|
|
|
1760
1758
|
expect(allOps.length).toBe(12);
|
|
1761
1759
|
expect(allOps.every((op) => op.op === 'PUT')).toBe(true);
|
|
1762
1760
|
|
|
1763
|
-
//
|
|
1764
|
-
|
|
1761
|
+
// Read batches are only a memory bound. The pending output group crosses
|
|
1762
|
+
// those boundaries and merges all post-compaction results that fit.
|
|
1763
|
+
expect(docsAfter).toHaveLength(1);
|
|
1764
|
+
expect(docsAfter[0]).toMatchObject({
|
|
1765
|
+
min_op: docs[0].min_op,
|
|
1766
|
+
_id: docs[docs.length - 1]._id
|
|
1767
|
+
});
|
|
1765
1768
|
});
|
|
1766
1769
|
|
|
1767
1770
|
test('7. cross-batch seen map overflow - dedup continues across batches', async () => {
|
|
@@ -1801,8 +1804,7 @@ bucket_definitions:
|
|
|
1801
1804
|
minBucketChanges: 1,
|
|
1802
1805
|
minChangeRatio: 0,
|
|
1803
1806
|
maxOpId: 20n,
|
|
1804
|
-
memoryLimitMB: 0.001
|
|
1805
|
-
signal: null as any
|
|
1807
|
+
memoryLimitMB: 0.001
|
|
1806
1808
|
});
|
|
1807
1809
|
|
|
1808
1810
|
const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
@@ -1824,7 +1826,7 @@ bucket_definitions:
|
|
|
1824
1826
|
|
|
1825
1827
|
// MOVEs have target_op stored at the document level (not per-op in V3).
|
|
1826
1828
|
// Documents containing MOVEs should have non-null target_op.
|
|
1827
|
-
const docsWithMoves = docsAfter.filter((d) => d.ops
|
|
1829
|
+
const docsWithMoves = docsAfter.filter((d) => d.ops!.some((op: any) => op.op === 'MOVE'));
|
|
1828
1830
|
expect(docsWithMoves.length).toBeGreaterThan(0);
|
|
1829
1831
|
for (const doc of docsWithMoves) {
|
|
1830
1832
|
expect(doc.target_op).toBeDefined();
|
|
@@ -1854,8 +1856,7 @@ bucket_definitions:
|
|
|
1854
1856
|
moveBatchQueryLimit: 2,
|
|
1855
1857
|
minBucketChanges: 1,
|
|
1856
1858
|
minChangeRatio: 0,
|
|
1857
|
-
maxOpId: 10n
|
|
1858
|
-
signal: null as any
|
|
1859
|
+
maxOpId: 10n
|
|
1859
1860
|
});
|
|
1860
1861
|
|
|
1861
1862
|
// All ops are > maxOpId, so processableDocs is always empty.
|
|
@@ -1872,7 +1873,7 @@ bucket_definitions:
|
|
|
1872
1873
|
expect(allOps.every((op) => op.op === 'PUT')).toBe(true);
|
|
1873
1874
|
});
|
|
1874
1875
|
|
|
1875
|
-
test('9.
|
|
1876
|
+
test('9. maxOpId filtering leaves a straddling document untouched', async () => {
|
|
1876
1877
|
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1877
1878
|
|
|
1878
1879
|
// Single document with two ops: one <= maxOpId, one > maxOpId
|
|
@@ -1892,8 +1893,7 @@ bucket_definitions:
|
|
|
1892
1893
|
moveBatchQueryLimit: 10,
|
|
1893
1894
|
minBucketChanges: 1,
|
|
1894
1895
|
minChangeRatio: 0,
|
|
1895
|
-
maxOpId: 300n
|
|
1896
|
-
signal: null as any
|
|
1896
|
+
maxOpId: 300n
|
|
1897
1897
|
});
|
|
1898
1898
|
|
|
1899
1899
|
const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
@@ -1913,7 +1913,7 @@ bucket_definitions:
|
|
|
1913
1913
|
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
1914
1914
|
|
|
1915
1915
|
// Four documents with truly disjoint [min_op, _id.o] ranges.
|
|
1916
|
-
// maxOpId=350: doc1
|
|
1916
|
+
// maxOpId=350: only doc1 is processable; doc2 straddles the horizon.
|
|
1917
1917
|
// Compaction must preserve range disjointness after rechunking.
|
|
1918
1918
|
const doc1 = serializeBucketData(BUCKET, [
|
|
1919
1919
|
makeOp(100, 'A', 'a1', ctx, sourceTableId),
|
|
@@ -1944,8 +1944,7 @@ bucket_definitions:
|
|
|
1944
1944
|
moveBatchQueryLimit: 10,
|
|
1945
1945
|
minBucketChanges: 1,
|
|
1946
1946
|
minChangeRatio: 0,
|
|
1947
|
-
maxOpId: 350n
|
|
1948
|
-
signal: null as any
|
|
1947
|
+
maxOpId: 350n
|
|
1949
1948
|
});
|
|
1950
1949
|
|
|
1951
1950
|
const docsAfter = await collection.find({ '_id.b': BUCKET }).sort({ '_id.o': 1 }).toArray();
|
|
@@ -1972,8 +1971,7 @@ bucket_definitions:
|
|
|
1972
1971
|
moveBatchQueryLimit: 10,
|
|
1973
1972
|
minBucketChanges: 1,
|
|
1974
1973
|
minChangeRatio: 0,
|
|
1975
|
-
maxOpId
|
|
1976
|
-
signal: null as any
|
|
1974
|
+
maxOpId
|
|
1977
1975
|
});
|
|
1978
1976
|
}
|
|
1979
1977
|
|
|
@@ -2073,8 +2071,7 @@ bucket_definitions:
|
|
|
2073
2071
|
moveBatchQueryLimit: 1,
|
|
2074
2072
|
minBucketChanges: 1,
|
|
2075
2073
|
minChangeRatio: 0,
|
|
2076
|
-
maxOpId: 15n
|
|
2077
|
-
signal: null as any
|
|
2074
|
+
maxOpId: 15n
|
|
2078
2075
|
});
|
|
2079
2076
|
|
|
2080
2077
|
const docsAfter = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
@@ -2104,12 +2101,12 @@ bucket_definitions:
|
|
|
2104
2101
|
await doCompact(bucketStorage, 30n);
|
|
2105
2102
|
|
|
2106
2103
|
const docsAfter = await collection.find({ '_id.b': BUCKET }).toArray();
|
|
2107
|
-
const clearDoc = docsAfter.find((d: any) => d.ops
|
|
2104
|
+
const clearDoc = docsAfter.find((d: any) => d.ops!.some((op: any) => op.op === 'CLEAR'));
|
|
2108
2105
|
expect(clearDoc).toBeDefined();
|
|
2109
2106
|
expect(clearDoc!.target_op).toBe(30n);
|
|
2110
2107
|
});
|
|
2111
2108
|
|
|
2112
|
-
test('
|
|
2109
|
+
test('straddling document is excluded from compacted state', async () => {
|
|
2113
2110
|
const { bucketStorage, collection, bucketStateCollection, ctx, sourceTableId } = await setupV3();
|
|
2114
2111
|
|
|
2115
2112
|
// Row A at 10, 30, 50 — ops 10+30 ≤ maxOpId=40, op 50 > 40 (pass-through)
|
|
@@ -2131,19 +2128,12 @@ bucket_definitions:
|
|
|
2131
2128
|
_id: { d: ctx.definitionId, b: BUCKET }
|
|
2132
2129
|
});
|
|
2133
2130
|
expect(state).toBeDefined();
|
|
2134
|
-
expect(state!.compacted_state).
|
|
2135
|
-
|
|
2136
|
-
// MOVE dedup: A@50 pass-through, C@60 pass-through survive as PUT.
|
|
2137
|
-
// A@30 (first ≤ 40) survives, B@20 survives. A@10 → MOVE.
|
|
2138
|
-
// compacted_state covers only ops ≤ 40: MOVE@10, PUT@20, PUT@30 = 3 ops.
|
|
2131
|
+
expect(state!.compacted_state).toBeUndefined();
|
|
2139
2132
|
|
|
2140
|
-
//
|
|
2141
|
-
const opsWithinHorizon = [10, 20, 30];
|
|
2142
|
-
const expectedChecksum = opsWithinHorizon.reduce((sum, id) => addChecksums(sum, id * 7), 0);
|
|
2143
|
-
expect(state!.compacted_state!.checksum).toBe(BigInt(expectedChecksum));
|
|
2144
|
-
// All 5 ops survive (3 counted + 2 pass-through)
|
|
2133
|
+
// The document ends beyond maxOpId, so it is not read or modified.
|
|
2145
2134
|
const allOps = await readAllOps(collection);
|
|
2146
2135
|
expect(allOps.length).toBe(5);
|
|
2136
|
+
expect(allOps.every((op) => op.op == 'PUT')).toBe(true);
|
|
2147
2137
|
});
|
|
2148
2138
|
|
|
2149
2139
|
test('upperBound pagination prevents re-reading replacement documents', async () => {
|
|
@@ -2167,8 +2157,7 @@ bucket_definitions:
|
|
|
2167
2157
|
moveBatchQueryLimit: 1,
|
|
2168
2158
|
minBucketChanges: 1,
|
|
2169
2159
|
minChangeRatio: 0,
|
|
2170
|
-
maxOpId: 40n
|
|
2171
|
-
signal: null as any
|
|
2160
|
+
maxOpId: 40n
|
|
2172
2161
|
});
|
|
2173
2162
|
|
|
2174
2163
|
const docsAfter = await collection.find({ '_id.b': BUCKET }).toArray();
|