@powersync/service-core-tests 0.0.0-dev-20260602073133 → 0.0.0-dev-20260909133214
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 +112 -4
- package/dist/test-utils/AbstractStreamTestContext.d.ts +7 -6
- package/dist/test-utils/AbstractStreamTestContext.js +28 -25
- package/dist/test-utils/AbstractStreamTestContext.js.map +1 -1
- package/dist/test-utils/StorageDataHelpers.d.ts +6 -5
- package/dist/test-utils/StorageDataHelpers.js +40 -2
- package/dist/test-utils/StorageDataHelpers.js.map +1 -1
- package/dist/test-utils/bucket-validation.d.ts +3 -3
- package/dist/test-utils/bucket-validation.js +3 -3
- package/dist/test-utils/general-utils.d.ts +16 -3
- package/dist/test-utils/general-utils.js +37 -4
- package/dist/test-utils/general-utils.js.map +1 -1
- package/dist/test-utils/storage-combinations.d.ts +13 -0
- package/dist/test-utils/storage-combinations.js +26 -0
- package/dist/test-utils/storage-combinations.js.map +1 -0
- package/dist/test-utils/stream_utils.d.ts +0 -1
- package/dist/test-utils/stream_utils.js +0 -10
- package/dist/test-utils/stream_utils.js.map +1 -1
- package/dist/test-utils/test-utils-index.d.ts +1 -0
- package/dist/test-utils/test-utils-index.js +1 -0
- package/dist/test-utils/test-utils-index.js.map +1 -1
- package/dist/tests/register-compacting-tests.js +36 -29
- package/dist/tests/register-compacting-tests.js.map +1 -1
- package/dist/tests/register-data-storage-checkpoint-tests.js +138 -14
- package/dist/tests/register-data-storage-checkpoint-tests.js.map +1 -1
- package/dist/tests/register-data-storage-data-tests.js +205 -138
- package/dist/tests/register-data-storage-data-tests.js.map +1 -1
- package/dist/tests/register-data-storage-parameter-tests.js +44 -42
- package/dist/tests/register-data-storage-parameter-tests.js.map +1 -1
- package/dist/tests/register-parameter-compacting-tests.js +3 -2
- package/dist/tests/register-parameter-compacting-tests.js.map +1 -1
- package/dist/tests/register-sync-tests.js +404 -86
- package/dist/tests/register-sync-tests.js.map +1 -1
- package/dist/tests/util.d.ts +8 -2
- package/dist/tests/util.js +12 -0
- package/dist/tests/util.js.map +1 -1
- package/package.json +5 -6
- package/src/test-utils/AbstractStreamTestContext.ts +31 -27
- package/src/test-utils/StorageDataHelpers.ts +52 -8
- package/src/test-utils/bucket-validation.ts +3 -3
- package/src/test-utils/general-utils.ts +62 -10
- package/src/test-utils/storage-combinations.ts +51 -0
- package/src/test-utils/stream_utils.ts +0 -11
- package/src/test-utils/test-utils-index.ts +1 -0
- package/src/tests/register-compacting-tests.ts +55 -32
- package/src/tests/register-data-storage-checkpoint-tests.ts +171 -14
- package/src/tests/register-data-storage-data-tests.ts +251 -150
- package/src/tests/register-data-storage-parameter-tests.ts +58 -42
- package/src/tests/register-parameter-compacting-tests.ts +3 -2
- package/src/tests/register-sync-tests.ts +443 -88
- package/src/tests/util.ts +15 -2
- package/tsconfig.json +1 -4
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -12,9 +12,10 @@ import { JSONBig } from '@powersync/service-jsonbig';
|
|
|
12
12
|
import path from 'path';
|
|
13
13
|
import * as timers from 'timers/promises';
|
|
14
14
|
import { fileURLToPath } from 'url';
|
|
15
|
-
import { expect, test } from 'vitest';
|
|
15
|
+
import { expect, test, vi } from 'vitest';
|
|
16
16
|
import * as test_utils from '../test-utils/test-utils-index.js';
|
|
17
17
|
import { bucketRequest, METRICS_HELPER } from '../test-utils/test-utils-index.js';
|
|
18
|
+
import { compactActive } from './util.js';
|
|
18
19
|
|
|
19
20
|
const __filename = fileURLToPath(import.meta.url);
|
|
20
21
|
const __dirname = path.dirname(__filename);
|
|
@@ -251,29 +252,36 @@ streams:
|
|
|
251
252
|
expect(lines).toMatchSnapshot();
|
|
252
253
|
});
|
|
253
254
|
|
|
254
|
-
test('
|
|
255
|
+
test('carries pending low-priority buckets into a new checkpoint', async () => {
|
|
255
256
|
await using f = await factory();
|
|
256
257
|
|
|
257
258
|
const syncRules = await updateSyncRules(f, {
|
|
258
259
|
content: `
|
|
259
260
|
bucket_definitions:
|
|
260
|
-
|
|
261
|
+
b0a:
|
|
261
262
|
priority: 2
|
|
262
263
|
data:
|
|
263
|
-
- SELECT * FROM test WHERE
|
|
264
|
+
- SELECT * FROM test WHERE substring(id, 1, 6) = 'first-';
|
|
265
|
+
b0b:
|
|
266
|
+
priority: 3
|
|
267
|
+
data:
|
|
268
|
+
- SELECT * FROM test WHERE substring(id, 1, 4) = 'low-';
|
|
264
269
|
b1:
|
|
265
270
|
priority: 1
|
|
266
271
|
data:
|
|
267
|
-
- SELECT * FROM test WHERE
|
|
272
|
+
- SELECT * FROM test WHERE substring(id, 1, 8) = 'highprio';
|
|
268
273
|
`
|
|
269
274
|
});
|
|
270
275
|
|
|
271
276
|
const bucketStorage = f.getInstance(syncRules);
|
|
272
277
|
await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
273
278
|
const testTable = await test_utils.resolveTestTable(writer, 'test', ['id'], config);
|
|
279
|
+
const syncRulesContent = syncRules.syncConfigContent[0];
|
|
280
|
+
const b0aBucket = test_utils.bucketRequest(syncRulesContent, 'b0a[]').bucket;
|
|
281
|
+
const b0bBucket = test_utils.bucketRequest(syncRulesContent, 'b0b[]').bucket;
|
|
282
|
+
const b1Bucket = test_utils.bucketRequest(syncRulesContent, 'b1[]').bucket;
|
|
274
283
|
|
|
275
284
|
await writer.markAllSnapshotDone('0/1');
|
|
276
|
-
// Initial data: Add one priority row and 10k low-priority rows.
|
|
277
285
|
await writer.save({
|
|
278
286
|
sourceTable: testTable,
|
|
279
287
|
tag: storage.SaveOperationTag.INSERT,
|
|
@@ -283,15 +291,26 @@ bucket_definitions:
|
|
|
283
291
|
},
|
|
284
292
|
afterReplicaId: 'highprio'
|
|
285
293
|
});
|
|
286
|
-
for (let i = 0; i <
|
|
294
|
+
for (let i = 0; i < 999; i++) {
|
|
287
295
|
await writer.save({
|
|
288
296
|
sourceTable: testTable,
|
|
289
297
|
tag: storage.SaveOperationTag.INSERT,
|
|
290
298
|
after: {
|
|
291
|
-
id:
|
|
292
|
-
description: 'low
|
|
299
|
+
id: `first-${i}`,
|
|
300
|
+
description: 'first low-priority bucket'
|
|
293
301
|
},
|
|
294
|
-
afterReplicaId:
|
|
302
|
+
afterReplicaId: `first-${i}`
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
for (let i = 0; i < 9_001; i++) {
|
|
306
|
+
await writer.save({
|
|
307
|
+
sourceTable: testTable,
|
|
308
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
309
|
+
after: {
|
|
310
|
+
id: `low-${i}`,
|
|
311
|
+
description: 'last low-priority bucket'
|
|
312
|
+
},
|
|
313
|
+
afterReplicaId: `low-${i}`
|
|
295
314
|
});
|
|
296
315
|
}
|
|
297
316
|
|
|
@@ -311,8 +330,12 @@ bucket_definitions:
|
|
|
311
330
|
isEncodingAsBson: false
|
|
312
331
|
});
|
|
313
332
|
|
|
314
|
-
|
|
315
|
-
|
|
333
|
+
const dataLines: Array<{ bucket: string; objectIds: string[]; afterCheckpointDiff: boolean }> = [];
|
|
334
|
+
const partialCheckpoints: Array<{ priority: number; afterCheckpointDiff: boolean }> = [];
|
|
335
|
+
const checkpoints: any[] = [];
|
|
336
|
+
const checkpointCompletes: any[] = [];
|
|
337
|
+
let afterCheckpointDiff = false;
|
|
338
|
+
let interruptionTriggered = false;
|
|
316
339
|
|
|
317
340
|
for await (let next of stream) {
|
|
318
341
|
if (typeof next == 'string') {
|
|
@@ -320,10 +343,13 @@ bucket_definitions:
|
|
|
320
343
|
}
|
|
321
344
|
if (typeof next === 'object' && next !== null) {
|
|
322
345
|
if ('partial_checkpoint_complete' in next) {
|
|
323
|
-
|
|
324
|
-
|
|
346
|
+
partialCheckpoints.push({
|
|
347
|
+
priority: next.partial_checkpoint_complete.priority,
|
|
348
|
+
afterCheckpointDiff
|
|
349
|
+
});
|
|
325
350
|
|
|
326
|
-
|
|
351
|
+
if (next.partial_checkpoint_complete.priority == 2 && !interruptionTriggered) {
|
|
352
|
+
interruptionTriggered = true;
|
|
327
353
|
await writer.save({
|
|
328
354
|
sourceTable: testTable,
|
|
329
355
|
tag: storage.SaveOperationTag.INSERT,
|
|
@@ -335,28 +361,76 @@ bucket_definitions:
|
|
|
335
361
|
});
|
|
336
362
|
|
|
337
363
|
await writer.commit('0/2');
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
// 1000 low-priority items were synchronized.
|
|
341
|
-
expect(sentCheckpoints).toBe(2);
|
|
342
|
-
expect(sentRows).toBeGreaterThan(1000);
|
|
364
|
+
// Let the checkpoint watcher observe the update before requesting the next priority.
|
|
365
|
+
await timers.setTimeout(50);
|
|
343
366
|
}
|
|
344
367
|
}
|
|
345
368
|
if ('checkpoint' in next || 'checkpoint_diff' in next) {
|
|
346
|
-
|
|
369
|
+
checkpoints.push(next);
|
|
370
|
+
afterCheckpointDiff = 'checkpoint_diff' in next;
|
|
347
371
|
}
|
|
348
372
|
|
|
349
373
|
if ('data' in next) {
|
|
350
|
-
|
|
374
|
+
dataLines.push({
|
|
375
|
+
bucket: next.data.bucket,
|
|
376
|
+
objectIds: next.data.data.flatMap((entry: any) => (entry.object_id == null ? [] : [entry.object_id])),
|
|
377
|
+
afterCheckpointDiff
|
|
378
|
+
});
|
|
351
379
|
}
|
|
352
380
|
if ('checkpoint_complete' in next) {
|
|
381
|
+
checkpointCompletes.push(next);
|
|
353
382
|
break;
|
|
354
383
|
}
|
|
355
384
|
}
|
|
356
385
|
}
|
|
357
386
|
|
|
358
|
-
|
|
359
|
-
|
|
387
|
+
// Expected flow (data may be split into any number of chunks):
|
|
388
|
+
//
|
|
389
|
+
// checkpoint
|
|
390
|
+
// data: b1 contains highprio
|
|
391
|
+
// partial_checkpoint_complete: priority 1
|
|
392
|
+
// data: b0a contains all 999 first-* rows (1,000 operations including highprio)
|
|
393
|
+
// partial_checkpoint_complete: priority 2
|
|
394
|
+
// ## add highprio2, interrupting before b0b starts
|
|
395
|
+
// checkpoint_diff
|
|
396
|
+
// data: b1 contains highprio2
|
|
397
|
+
// partial_checkpoint_complete: priority 1
|
|
398
|
+
// data: b0b contains all 9,001 low-* rows carried over from the interrupted checkpoint
|
|
399
|
+
// checkpoint_complete: only for the new checkpoint
|
|
400
|
+
expect(interruptionTriggered).toBe(true);
|
|
401
|
+
expect(checkpoints).toHaveLength(2);
|
|
402
|
+
expect(checkpoints[0]).toHaveProperty('checkpoint');
|
|
403
|
+
expect(checkpoints[1]).toHaveProperty('checkpoint_diff');
|
|
404
|
+
expect(partialCheckpoints).toEqual([
|
|
405
|
+
{ priority: 1, afterCheckpointDiff: false },
|
|
406
|
+
{ priority: 2, afterCheckpointDiff: false },
|
|
407
|
+
{ priority: 1, afterCheckpointDiff: true }
|
|
408
|
+
]);
|
|
409
|
+
expect(checkpointCompletes).toEqual([
|
|
410
|
+
{
|
|
411
|
+
checkpoint_complete: {
|
|
412
|
+
last_op_id: checkpoints[1].checkpoint_diff.last_op_id
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
]);
|
|
416
|
+
|
|
417
|
+
const objectIdsFor = (bucket: string, afterDiff: boolean) =>
|
|
418
|
+
dataLines
|
|
419
|
+
.filter((line) => line.bucket == bucket && line.afterCheckpointDiff == afterDiff)
|
|
420
|
+
.flatMap((line) => line.objectIds);
|
|
421
|
+
|
|
422
|
+
expect(objectIdsFor(b1Bucket, false)).toEqual(['highprio']);
|
|
423
|
+
const b0aObjectIds = objectIdsFor(b0aBucket, false);
|
|
424
|
+
expect(new Set(b0aObjectIds)).toEqual(new Set(Array.from({ length: 999 }, (_, i) => `first-${i}`)));
|
|
425
|
+
expect(b0aObjectIds).toHaveLength(999);
|
|
426
|
+
expect(objectIdsFor(b0bBucket, false)).toEqual([]);
|
|
427
|
+
|
|
428
|
+
expect(objectIdsFor(b1Bucket, true)).toEqual(['highprio2']);
|
|
429
|
+
expect(objectIdsFor(b0aBucket, true)).toEqual([]);
|
|
430
|
+
|
|
431
|
+
const b0bObjectIds = objectIdsFor(b0bBucket, true);
|
|
432
|
+
expect(new Set(b0bObjectIds)).toEqual(new Set(Array.from({ length: 9_001 }, (_, i) => `low-${i}`)));
|
|
433
|
+
expect(b0bObjectIds).toHaveLength(9_001);
|
|
360
434
|
});
|
|
361
435
|
|
|
362
436
|
test('sync interruptions with unrelated data', async () => {
|
|
@@ -490,37 +564,36 @@ bucket_definitions:
|
|
|
490
564
|
expect(sentRows).toBe(10002);
|
|
491
565
|
});
|
|
492
566
|
|
|
493
|
-
test('
|
|
567
|
+
test('restarts updated low-priority buckets at a new checkpoint', async () => {
|
|
494
568
|
await using f = await factory();
|
|
495
569
|
|
|
496
|
-
// bucket0a -> send all data
|
|
497
|
-
// then interrupt checkpoint with new data for all buckets
|
|
498
|
-
// -> data for all buckets should be sent in the new checkpoint
|
|
499
|
-
|
|
500
570
|
const syncRules = await updateSyncRules(f, {
|
|
501
571
|
content: `
|
|
502
572
|
bucket_definitions:
|
|
503
573
|
b0a:
|
|
504
574
|
priority: 2
|
|
505
575
|
data:
|
|
506
|
-
- SELECT * FROM test WHERE
|
|
576
|
+
- SELECT * FROM test WHERE substring(id, 1, 6) = 'first-';
|
|
507
577
|
b0b:
|
|
508
|
-
priority:
|
|
578
|
+
priority: 3
|
|
509
579
|
data:
|
|
510
|
-
- SELECT * FROM test WHERE
|
|
580
|
+
- SELECT * FROM test WHERE substring(id, 1, 4) = 'low-';
|
|
511
581
|
b1:
|
|
512
582
|
priority: 1
|
|
513
583
|
data:
|
|
514
|
-
- SELECT * FROM test WHERE
|
|
584
|
+
- SELECT * FROM test WHERE substring(id, 1, 8) = 'highprio';
|
|
515
585
|
`
|
|
516
586
|
});
|
|
517
587
|
|
|
518
588
|
const bucketStorage = f.getInstance(syncRules);
|
|
519
589
|
await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
520
590
|
const testTable = await test_utils.resolveTestTable(writer, 'test', ['id'], config);
|
|
591
|
+
const syncRulesContent = syncRules.syncConfigContent[0];
|
|
592
|
+
const b0aBucket = test_utils.bucketRequest(syncRulesContent, 'b0a[]').bucket;
|
|
593
|
+
const b0bBucket = test_utils.bucketRequest(syncRulesContent, 'b0b[]').bucket;
|
|
594
|
+
const b1Bucket = test_utils.bucketRequest(syncRulesContent, 'b1[]').bucket;
|
|
521
595
|
|
|
522
596
|
await writer.markAllSnapshotDone('0/1');
|
|
523
|
-
// Initial data: Add one priority row and 10k low-priority rows.
|
|
524
597
|
await writer.save({
|
|
525
598
|
sourceTable: testTable,
|
|
526
599
|
tag: storage.SaveOperationTag.INSERT,
|
|
@@ -530,15 +603,26 @@ bucket_definitions:
|
|
|
530
603
|
},
|
|
531
604
|
afterReplicaId: 'highprio'
|
|
532
605
|
});
|
|
606
|
+
for (let i = 0; i < 999; i++) {
|
|
607
|
+
await writer.save({
|
|
608
|
+
sourceTable: testTable,
|
|
609
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
610
|
+
after: {
|
|
611
|
+
id: `first-${i}`,
|
|
612
|
+
description: 'first low-priority bucket'
|
|
613
|
+
},
|
|
614
|
+
afterReplicaId: `first-${i}`
|
|
615
|
+
});
|
|
616
|
+
}
|
|
533
617
|
for (let i = 0; i < 2_000; i++) {
|
|
534
618
|
await writer.save({
|
|
535
619
|
sourceTable: testTable,
|
|
536
620
|
tag: storage.SaveOperationTag.INSERT,
|
|
537
621
|
after: {
|
|
538
|
-
id:
|
|
622
|
+
id: `low-${i}`,
|
|
539
623
|
description: 'low prio'
|
|
540
624
|
},
|
|
541
|
-
afterReplicaId:
|
|
625
|
+
afterReplicaId: `low-${i}`
|
|
542
626
|
});
|
|
543
627
|
}
|
|
544
628
|
|
|
@@ -558,8 +642,12 @@ bucket_definitions:
|
|
|
558
642
|
isEncodingAsBson: false
|
|
559
643
|
});
|
|
560
644
|
|
|
561
|
-
|
|
562
|
-
|
|
645
|
+
const dataLines: Array<{ bucket: string; objectIds: string[]; afterCheckpointDiff: boolean }> = [];
|
|
646
|
+
const partialCheckpoints: Array<{ priority: number; afterCheckpointDiff: boolean }> = [];
|
|
647
|
+
const checkpoints: any[] = [];
|
|
648
|
+
const checkpointCompletes: any[] = [];
|
|
649
|
+
let afterCheckpointDiff = false;
|
|
650
|
+
let interruptionTriggered = false;
|
|
563
651
|
|
|
564
652
|
for await (let next of stream) {
|
|
565
653
|
if (typeof next == 'string') {
|
|
@@ -567,19 +655,13 @@ bucket_definitions:
|
|
|
567
655
|
}
|
|
568
656
|
if (typeof next === 'object' && next !== null) {
|
|
569
657
|
if ('partial_checkpoint_complete' in next) {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
if ('data' in next) {
|
|
577
|
-
lines.push({ data: { ...next.data, data: undefined } });
|
|
578
|
-
sentRows += next.data.data.length;
|
|
658
|
+
partialCheckpoints.push({
|
|
659
|
+
priority: next.partial_checkpoint_complete.priority,
|
|
660
|
+
afterCheckpointDiff
|
|
661
|
+
});
|
|
579
662
|
|
|
580
|
-
if (
|
|
581
|
-
|
|
582
|
-
// Add another high-priority row. This should interrupt the long-running low-priority sync.
|
|
663
|
+
if (next.partial_checkpoint_complete.priority == 2 && !interruptionTriggered) {
|
|
664
|
+
interruptionTriggered = true;
|
|
583
665
|
await writer.save({
|
|
584
666
|
sourceTable: testTable,
|
|
585
667
|
tag: storage.SaveOperationTag.INSERT,
|
|
@@ -590,51 +672,87 @@ bucket_definitions:
|
|
|
590
672
|
afterReplicaId: 'highprio2'
|
|
591
673
|
});
|
|
592
674
|
|
|
593
|
-
// Also add a low-priority row
|
|
594
675
|
await writer.save({
|
|
595
676
|
sourceTable: testTable,
|
|
596
677
|
tag: storage.SaveOperationTag.INSERT,
|
|
597
678
|
after: {
|
|
598
|
-
id: '
|
|
679
|
+
id: 'low-2000',
|
|
599
680
|
description: 'Another low-priority row'
|
|
600
681
|
},
|
|
601
|
-
afterReplicaId: '
|
|
682
|
+
afterReplicaId: 'low-2000'
|
|
602
683
|
});
|
|
603
684
|
|
|
604
685
|
await writer.commit('0/2');
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
if (sentRows >= 1000 && sentRows <= 2001) {
|
|
608
|
-
// pause for a bit to give the stream time to process interruptions.
|
|
609
|
-
// This covers the data batch above and the next one.
|
|
686
|
+
// Let the checkpoint watcher observe the update before requesting the next priority.
|
|
610
687
|
await timers.setTimeout(50);
|
|
611
688
|
}
|
|
612
689
|
}
|
|
690
|
+
if ('checkpoint' in next || 'checkpoint_diff' in next) {
|
|
691
|
+
checkpoints.push(next);
|
|
692
|
+
afterCheckpointDiff = 'checkpoint_diff' in next;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if ('data' in next) {
|
|
696
|
+
dataLines.push({
|
|
697
|
+
bucket: next.data.bucket,
|
|
698
|
+
objectIds: next.data.data.flatMap((entry: any) => (entry.object_id == null ? [] : [entry.object_id])),
|
|
699
|
+
afterCheckpointDiff
|
|
700
|
+
});
|
|
701
|
+
}
|
|
613
702
|
if ('checkpoint_complete' in next) {
|
|
614
|
-
|
|
703
|
+
checkpointCompletes.push(next);
|
|
615
704
|
break;
|
|
616
705
|
}
|
|
617
706
|
}
|
|
618
707
|
}
|
|
619
708
|
|
|
620
|
-
// Expected
|
|
709
|
+
// Expected flow (data may be split into any number of chunks):
|
|
621
710
|
//
|
|
622
|
-
// checkpoint
|
|
623
|
-
// data
|
|
624
|
-
// partial_checkpoint_complete
|
|
625
|
-
// data (
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
// checkpoint_diff
|
|
629
|
-
// data
|
|
630
|
-
// partial_checkpoint_complete
|
|
631
|
-
// data
|
|
632
|
-
//
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
expect(
|
|
637
|
-
expect(
|
|
711
|
+
// checkpoint
|
|
712
|
+
// data: b1 contains highprio
|
|
713
|
+
// partial_checkpoint_complete: priority 1
|
|
714
|
+
// data: b0a contains all 999 first-* rows (1,000 operations including highprio)
|
|
715
|
+
// partial_checkpoint_complete: priority 2
|
|
716
|
+
// ## add highprio2 and low-2000, interrupting before b0b starts
|
|
717
|
+
// checkpoint_diff
|
|
718
|
+
// data: b1 contains highprio2
|
|
719
|
+
// partial_checkpoint_complete: priority 1
|
|
720
|
+
// data: b0b contains all 2,001 low-* rows
|
|
721
|
+
// checkpoint_complete: only for the new checkpoint
|
|
722
|
+
expect(interruptionTriggered).toBe(true);
|
|
723
|
+
expect(checkpoints).toHaveLength(2);
|
|
724
|
+
expect(checkpoints[0]).toHaveProperty('checkpoint');
|
|
725
|
+
expect(checkpoints[1]).toHaveProperty('checkpoint_diff');
|
|
726
|
+
expect(partialCheckpoints).toEqual([
|
|
727
|
+
{ priority: 1, afterCheckpointDiff: false },
|
|
728
|
+
{ priority: 2, afterCheckpointDiff: false },
|
|
729
|
+
{ priority: 1, afterCheckpointDiff: true }
|
|
730
|
+
]);
|
|
731
|
+
expect(checkpointCompletes).toEqual([
|
|
732
|
+
{
|
|
733
|
+
checkpoint_complete: {
|
|
734
|
+
last_op_id: checkpoints[1].checkpoint_diff.last_op_id
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
]);
|
|
738
|
+
|
|
739
|
+
const objectIdsFor = (bucket: string, afterDiff: boolean) =>
|
|
740
|
+
dataLines
|
|
741
|
+
.filter((line) => line.bucket == bucket && line.afterCheckpointDiff == afterDiff)
|
|
742
|
+
.flatMap((line) => line.objectIds);
|
|
743
|
+
|
|
744
|
+
expect(objectIdsFor(b1Bucket, false)).toEqual(['highprio']);
|
|
745
|
+
const b0aObjectIds = objectIdsFor(b0aBucket, false);
|
|
746
|
+
expect(new Set(b0aObjectIds)).toEqual(new Set(Array.from({ length: 999 }, (_, i) => `first-${i}`)));
|
|
747
|
+
expect(b0aObjectIds).toHaveLength(999);
|
|
748
|
+
expect(objectIdsFor(b0bBucket, false)).toEqual([]);
|
|
749
|
+
|
|
750
|
+
expect(objectIdsFor(b1Bucket, true)).toEqual(['highprio2']);
|
|
751
|
+
expect(objectIdsFor(b0aBucket, true)).toEqual([]);
|
|
752
|
+
|
|
753
|
+
const b0bObjectIds = objectIdsFor(b0bBucket, true);
|
|
754
|
+
expect(new Set(b0bObjectIds)).toEqual(new Set(Array.from({ length: 2_001 }, (_, i) => `low-${i}`)));
|
|
755
|
+
expect(b0bObjectIds).toHaveLength(2_001);
|
|
638
756
|
});
|
|
639
757
|
|
|
640
758
|
test('sends checkpoint complete line for empty checkpoint', async () => {
|
|
@@ -687,7 +805,7 @@ bucket_definitions:
|
|
|
687
805
|
receivedCompletions++;
|
|
688
806
|
if (receivedCompletions == 1) {
|
|
689
807
|
// Trigger an empty bucket update.
|
|
690
|
-
await bucketStorage.
|
|
808
|
+
await bucketStorage.createManagedWriteCheckpoints([{ user_id: '', heads: { '1': '1/0' } }]);
|
|
691
809
|
await writer.commit('1/0');
|
|
692
810
|
} else {
|
|
693
811
|
break;
|
|
@@ -892,7 +1010,8 @@ bucket_definitions:
|
|
|
892
1010
|
|
|
893
1011
|
const checkpoint2 = await getCheckpointLines(iter);
|
|
894
1012
|
|
|
895
|
-
const
|
|
1013
|
+
const syncRulesContent = syncRules.syncConfigContent[0];
|
|
1014
|
+
const { bucket } = test_utils.bucketRequest(syncRulesContent, 'by_user["user1"]');
|
|
896
1015
|
expect(
|
|
897
1016
|
(checkpoint2[0] as StreamingSyncCheckpointDiff).checkpoint_diff?.updated_buckets?.map((b) => b.bucket)
|
|
898
1017
|
).toEqual([bucket]);
|
|
@@ -947,7 +1066,8 @@ bucket_definitions:
|
|
|
947
1066
|
iter.return?.();
|
|
948
1067
|
});
|
|
949
1068
|
|
|
950
|
-
const
|
|
1069
|
+
const syncRulesContent = syncRules.syncConfigContent[0];
|
|
1070
|
+
const { bucket } = bucketRequest(syncRulesContent, 'by_user["user1"]');
|
|
951
1071
|
const checkpoint1 = await getCheckpointLines(iter);
|
|
952
1072
|
|
|
953
1073
|
expect((checkpoint1[0] as StreamingSyncCheckpoint).checkpoint?.buckets?.map((b) => b.bucket)).toEqual([bucket]);
|
|
@@ -1038,7 +1158,8 @@ bucket_definitions:
|
|
|
1038
1158
|
|
|
1039
1159
|
await writer.commit('0/1');
|
|
1040
1160
|
|
|
1041
|
-
const
|
|
1161
|
+
const syncRulesContent = syncRules.syncConfigContent[0];
|
|
1162
|
+
const { bucket } = test_utils.bucketRequest(syncRulesContent, 'by_user["user1"]');
|
|
1042
1163
|
|
|
1043
1164
|
const checkpoint2 = await getCheckpointLines(iter);
|
|
1044
1165
|
expect(
|
|
@@ -1087,6 +1208,92 @@ bucket_definitions:
|
|
|
1087
1208
|
expect(expLines).toMatchSnapshot();
|
|
1088
1209
|
});
|
|
1089
1210
|
|
|
1211
|
+
test('checksum invalidation skips the candidate checkpoint', async (context) => {
|
|
1212
|
+
await using f = await factory();
|
|
1213
|
+
|
|
1214
|
+
const syncRules = await updateSyncRules(f, {
|
|
1215
|
+
content: BASIC_SYNC_RULES
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
const bucketStorage = await f.getInstance(syncRules);
|
|
1219
|
+
await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
1220
|
+
const testTable = await test_utils.resolveTestTable(writer, 'test', ['id'], config);
|
|
1221
|
+
|
|
1222
|
+
await writer.markAllSnapshotDone('0/1');
|
|
1223
|
+
await writer.save({
|
|
1224
|
+
sourceTable: testTable,
|
|
1225
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
1226
|
+
after: {
|
|
1227
|
+
id: 't1',
|
|
1228
|
+
description: 'Test 1'
|
|
1229
|
+
},
|
|
1230
|
+
afterReplicaId: 't1'
|
|
1231
|
+
});
|
|
1232
|
+
await writer.commit('0/1');
|
|
1233
|
+
|
|
1234
|
+
let resolveInvalidatedChecksum!: () => void;
|
|
1235
|
+
const invalidatedChecksum = new Promise<void>((resolve) => {
|
|
1236
|
+
resolveInvalidatedChecksum = resolve;
|
|
1237
|
+
});
|
|
1238
|
+
const checksumSpy = vi.spyOn(bucketStorage, 'getChecksums').mockImplementationOnce(async (checkpoint, buckets) => {
|
|
1239
|
+
resolveInvalidatedChecksum();
|
|
1240
|
+
throw new storage.CheckpointChecksumInvalidatedError(checkpoint.checkpoint, buckets[0].bucket);
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
const stream = sync.streamResponse({
|
|
1244
|
+
syncContext,
|
|
1245
|
+
bucketStorage,
|
|
1246
|
+
syncRules: bucketStorage.getParsedSyncRules(test_utils.PARSE_OPTIONS),
|
|
1247
|
+
params: {
|
|
1248
|
+
buckets: [],
|
|
1249
|
+
include_checksum: true,
|
|
1250
|
+
raw_data: true
|
|
1251
|
+
},
|
|
1252
|
+
tracker,
|
|
1253
|
+
token: new JwtPayload({ sub: '', exp: Date.now() / 1000 + 10 }),
|
|
1254
|
+
isEncodingAsBson: false
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
const iter = stream[Symbol.asyncIterator]();
|
|
1258
|
+
context.onTestFinished(() => {
|
|
1259
|
+
iter.return?.();
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
const linesPromise = getCheckpointLines(iter, { consume: true });
|
|
1263
|
+
await invalidatedChecksum;
|
|
1264
|
+
|
|
1265
|
+
// The first candidate was discarded before CheckpointLine.advance(). A later
|
|
1266
|
+
// checkpoint must therefore be calculated from the original connection state.
|
|
1267
|
+
await writer.save({
|
|
1268
|
+
sourceTable: testTable,
|
|
1269
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
1270
|
+
after: {
|
|
1271
|
+
id: 't2',
|
|
1272
|
+
description: 'Test 2'
|
|
1273
|
+
},
|
|
1274
|
+
afterReplicaId: 't2'
|
|
1275
|
+
});
|
|
1276
|
+
await writer.commit('0/2');
|
|
1277
|
+
|
|
1278
|
+
const lines = await linesPromise;
|
|
1279
|
+
expect(checksumSpy).toHaveBeenCalledTimes(2);
|
|
1280
|
+
expect(lines[0]).toEqual({
|
|
1281
|
+
checkpoint: expect.objectContaining({
|
|
1282
|
+
last_op_id: '2'
|
|
1283
|
+
})
|
|
1284
|
+
});
|
|
1285
|
+
expect(lines).not.toContainEqual({
|
|
1286
|
+
checkpoint: expect.objectContaining({
|
|
1287
|
+
last_op_id: '1'
|
|
1288
|
+
})
|
|
1289
|
+
});
|
|
1290
|
+
expect(lines.at(-1)).toEqual({
|
|
1291
|
+
checkpoint_complete: expect.objectContaining({
|
|
1292
|
+
last_op_id: '2'
|
|
1293
|
+
})
|
|
1294
|
+
});
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1090
1297
|
test('compacting data - invalidate checkpoint', async (context) => {
|
|
1091
1298
|
// This tests a case of a compact operation invalidating a checkpoint in the
|
|
1092
1299
|
// middle of syncing data.
|
|
@@ -1102,6 +1309,7 @@ bucket_definitions:
|
|
|
1102
1309
|
const bucketStorage = await f.getInstance(syncRules);
|
|
1103
1310
|
await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
1104
1311
|
const testTable = await test_utils.resolveTestTable(writer, 'test', ['id'], config);
|
|
1312
|
+
const bucket = bucketRequest(syncRules.syncConfigContent[0], 'mybucket[]').bucket;
|
|
1105
1313
|
|
|
1106
1314
|
await writer.markAllSnapshotDone('0/1');
|
|
1107
1315
|
await writer.save({
|
|
@@ -1180,10 +1388,7 @@ bucket_definitions:
|
|
|
1180
1388
|
|
|
1181
1389
|
await writer.commit('0/2');
|
|
1182
1390
|
|
|
1183
|
-
await
|
|
1184
|
-
minBucketChanges: 1,
|
|
1185
|
-
minChangeRatio: 0
|
|
1186
|
-
});
|
|
1391
|
+
await compactActive(f, { compactBuckets: [bucket] });
|
|
1187
1392
|
|
|
1188
1393
|
const lines2 = await getCheckpointLines(iter, { consume: true });
|
|
1189
1394
|
|
|
@@ -1235,6 +1440,152 @@ bucket_definitions:
|
|
|
1235
1440
|
});
|
|
1236
1441
|
});
|
|
1237
1442
|
|
|
1443
|
+
test('compacting high-priority data invalidates checkpoint before lower priorities', async (context) => {
|
|
1444
|
+
// Scenario:
|
|
1445
|
+
// 1. Bucket priorities are used.
|
|
1446
|
+
// 2. Client syncs a high-priority bucket.
|
|
1447
|
+
// 3. Due to a concurrent compaction, the checkpoint is invalidated (via target_op).
|
|
1448
|
+
// 4. The service picks up that invalidation, and the batch stops, without emitting a "partial_checkpoint_complete". The intention is continuing with the next checkpoint.
|
|
1449
|
+
// 5. However, the loop handling priorities does not see that invalidation, it only sees "bucket data for this priority is done".
|
|
1450
|
+
// 6. The priority loop incorrectly continues with the next priority.
|
|
1451
|
+
// 7. The service emits the data, as well as a final checkpoint_complete.
|
|
1452
|
+
// 8. The client now received a checkpoint_complete without the full data for that checkpoint. It would pick up the missing data in the checksum check, requiring a full re-download of the affected buckets.
|
|
1453
|
+
await using f = await factory();
|
|
1454
|
+
|
|
1455
|
+
const syncRules = await updateSyncRules(f, {
|
|
1456
|
+
content: `
|
|
1457
|
+
bucket_definitions:
|
|
1458
|
+
high_priority:
|
|
1459
|
+
priority: 1
|
|
1460
|
+
data:
|
|
1461
|
+
- SELECT * FROM test WHERE substring(id, 1, 4) = 'high';
|
|
1462
|
+
low_priority:
|
|
1463
|
+
priority: 2
|
|
1464
|
+
data:
|
|
1465
|
+
- SELECT * FROM test WHERE id = 'low';
|
|
1466
|
+
`
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
const bucketStorage = await f.getInstance(syncRules);
|
|
1470
|
+
await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
1471
|
+
const testTable = await test_utils.resolveTestTable(writer, 'test', ['id'], config);
|
|
1472
|
+
const highPriorityBucket = bucketRequest(syncRules.syncConfigContent[0], 'high_priority[]').bucket;
|
|
1473
|
+
|
|
1474
|
+
await writer.markAllSnapshotDone('0/1');
|
|
1475
|
+
await writer.save({
|
|
1476
|
+
sourceTable: testTable,
|
|
1477
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
1478
|
+
after: {
|
|
1479
|
+
id: 'high1',
|
|
1480
|
+
description: 'High priority 1'
|
|
1481
|
+
},
|
|
1482
|
+
afterReplicaId: 'high1'
|
|
1483
|
+
});
|
|
1484
|
+
await writer.save({
|
|
1485
|
+
sourceTable: testTable,
|
|
1486
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
1487
|
+
after: {
|
|
1488
|
+
id: 'high2',
|
|
1489
|
+
description: 'High priority 2'
|
|
1490
|
+
},
|
|
1491
|
+
afterReplicaId: 'high2'
|
|
1492
|
+
});
|
|
1493
|
+
await writer.save({
|
|
1494
|
+
sourceTable: testTable,
|
|
1495
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
1496
|
+
after: {
|
|
1497
|
+
id: 'low',
|
|
1498
|
+
description: 'Low priority'
|
|
1499
|
+
},
|
|
1500
|
+
afterReplicaId: 'low'
|
|
1501
|
+
});
|
|
1502
|
+
await writer.commit('0/1');
|
|
1503
|
+
|
|
1504
|
+
const stream = sync.streamResponse({
|
|
1505
|
+
syncContext,
|
|
1506
|
+
bucketStorage,
|
|
1507
|
+
syncRules: bucketStorage.getParsedSyncRules(test_utils.PARSE_OPTIONS),
|
|
1508
|
+
params: {
|
|
1509
|
+
buckets: [],
|
|
1510
|
+
include_checksum: true,
|
|
1511
|
+
raw_data: true
|
|
1512
|
+
},
|
|
1513
|
+
tracker,
|
|
1514
|
+
token: new JwtPayload({ sub: '', exp: Date.now() / 1000 + 10 }),
|
|
1515
|
+
isEncodingAsBson: false
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
const iter = stream[Symbol.asyncIterator]();
|
|
1519
|
+
await using _ = {
|
|
1520
|
+
async [Symbol.asyncDispose]() {
|
|
1521
|
+
await iter.return?.();
|
|
1522
|
+
}
|
|
1523
|
+
};
|
|
1524
|
+
|
|
1525
|
+
const checkpoint = await consumeIterator(iter, {
|
|
1526
|
+
consume: false,
|
|
1527
|
+
isDone: (line) => (line as any)?.checkpoint != null
|
|
1528
|
+
});
|
|
1529
|
+
expect(checkpoint[0]).toEqual({
|
|
1530
|
+
checkpoint: expect.objectContaining({
|
|
1531
|
+
last_op_id: '3'
|
|
1532
|
+
})
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
await writer.save({
|
|
1536
|
+
sourceTable: testTable,
|
|
1537
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
1538
|
+
after: {
|
|
1539
|
+
id: 'high1',
|
|
1540
|
+
description: 'Updated high priority 1'
|
|
1541
|
+
},
|
|
1542
|
+
afterReplicaId: 'high1'
|
|
1543
|
+
});
|
|
1544
|
+
await writer.save({
|
|
1545
|
+
sourceTable: testTable,
|
|
1546
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
1547
|
+
after: {
|
|
1548
|
+
id: 'high2',
|
|
1549
|
+
description: 'Updated high priority 2'
|
|
1550
|
+
},
|
|
1551
|
+
afterReplicaId: 'high2'
|
|
1552
|
+
});
|
|
1553
|
+
await writer.commit('0/2');
|
|
1554
|
+
|
|
1555
|
+
await compactActive(f, {
|
|
1556
|
+
// Explicitly compact the high-priority bucket: V3 schedules background
|
|
1557
|
+
// compaction, while this test needs compaction at this exact point.
|
|
1558
|
+
compactBuckets: [highPriorityBucket]
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
const lines = await getCheckpointLines(iter, { consume: true });
|
|
1562
|
+
const nextCheckpointIndex = lines.findIndex((line) => (line as any)?.checkpoint_diff != null);
|
|
1563
|
+
expect(nextCheckpointIndex).toBeGreaterThan(0);
|
|
1564
|
+
|
|
1565
|
+
const invalidatedCheckpointLines = lines.slice(0, nextCheckpointIndex);
|
|
1566
|
+
expect(invalidatedCheckpointLines).not.toContainEqual(
|
|
1567
|
+
expect.objectContaining({ checkpoint_complete: expect.anything() })
|
|
1568
|
+
);
|
|
1569
|
+
expect(invalidatedCheckpointLines).not.toContainEqual(
|
|
1570
|
+
expect.objectContaining({
|
|
1571
|
+
data: expect.objectContaining({
|
|
1572
|
+
bucket: expect.stringContaining('low_priority')
|
|
1573
|
+
})
|
|
1574
|
+
})
|
|
1575
|
+
);
|
|
1576
|
+
|
|
1577
|
+
expect(lines[nextCheckpointIndex]).toEqual({
|
|
1578
|
+
checkpoint_diff: expect.objectContaining({
|
|
1579
|
+
last_op_id: '5'
|
|
1580
|
+
})
|
|
1581
|
+
});
|
|
1582
|
+
expect(lines.at(-1)).toEqual({
|
|
1583
|
+
checkpoint_complete: expect.objectContaining({
|
|
1584
|
+
last_op_id: '5'
|
|
1585
|
+
})
|
|
1586
|
+
});
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1238
1589
|
test('write checkpoint', async () => {
|
|
1239
1590
|
await using f = await factory();
|
|
1240
1591
|
|
|
@@ -1249,10 +1600,14 @@ bucket_definitions:
|
|
|
1249
1600
|
// <= the managed write checkpoint LSN below
|
|
1250
1601
|
await writer.commit('0/1');
|
|
1251
1602
|
|
|
1252
|
-
const checkpoint =
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1603
|
+
const checkpoint = (
|
|
1604
|
+
await bucketStorage.createManagedWriteCheckpoints([
|
|
1605
|
+
{
|
|
1606
|
+
user_id: 'test',
|
|
1607
|
+
heads: { '1': '1/0' }
|
|
1608
|
+
}
|
|
1609
|
+
])
|
|
1610
|
+
).writeCheckpoints.get('test')!;
|
|
1256
1611
|
|
|
1257
1612
|
const params: sync.SyncStreamParameters = {
|
|
1258
1613
|
syncContext,
|