@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
|
@@ -3,9 +3,10 @@ import { JSONBig } from '@powersync/service-jsonbig';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import * as timers from 'timers/promises';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
|
-
import { expect, test } from 'vitest';
|
|
6
|
+
import { expect, test, vi } from 'vitest';
|
|
7
7
|
import * as test_utils from '../test-utils/test-utils-index.js';
|
|
8
8
|
import { bucketRequest, METRICS_HELPER } from '../test-utils/test-utils-index.js';
|
|
9
|
+
import { compactActive } from './util.js';
|
|
9
10
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
11
|
const __dirname = path.dirname(__filename);
|
|
11
12
|
const BASIC_SYNC_RULES = `
|
|
@@ -205,26 +206,33 @@ streams:
|
|
|
205
206
|
const lines = await consumeCheckpointLines(stream);
|
|
206
207
|
expect(lines).toMatchSnapshot();
|
|
207
208
|
});
|
|
208
|
-
test('
|
|
209
|
+
test('carries pending low-priority buckets into a new checkpoint', async () => {
|
|
209
210
|
await using f = await factory();
|
|
210
211
|
const syncRules = await updateSyncRules(f, {
|
|
211
212
|
content: `
|
|
212
213
|
bucket_definitions:
|
|
213
|
-
|
|
214
|
+
b0a:
|
|
214
215
|
priority: 2
|
|
215
216
|
data:
|
|
216
|
-
- SELECT * FROM test WHERE
|
|
217
|
+
- SELECT * FROM test WHERE substring(id, 1, 6) = 'first-';
|
|
218
|
+
b0b:
|
|
219
|
+
priority: 3
|
|
220
|
+
data:
|
|
221
|
+
- SELECT * FROM test WHERE substring(id, 1, 4) = 'low-';
|
|
217
222
|
b1:
|
|
218
223
|
priority: 1
|
|
219
224
|
data:
|
|
220
|
-
- SELECT * FROM test WHERE
|
|
225
|
+
- SELECT * FROM test WHERE substring(id, 1, 8) = 'highprio';
|
|
221
226
|
`
|
|
222
227
|
});
|
|
223
228
|
const bucketStorage = f.getInstance(syncRules);
|
|
224
229
|
await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
225
230
|
const testTable = await test_utils.resolveTestTable(writer, 'test', ['id'], config);
|
|
231
|
+
const syncRulesContent = syncRules.syncConfigContent[0];
|
|
232
|
+
const b0aBucket = test_utils.bucketRequest(syncRulesContent, 'b0a[]').bucket;
|
|
233
|
+
const b0bBucket = test_utils.bucketRequest(syncRulesContent, 'b0b[]').bucket;
|
|
234
|
+
const b1Bucket = test_utils.bucketRequest(syncRulesContent, 'b1[]').bucket;
|
|
226
235
|
await writer.markAllSnapshotDone('0/1');
|
|
227
|
-
// Initial data: Add one priority row and 10k low-priority rows.
|
|
228
236
|
await writer.save({
|
|
229
237
|
sourceTable: testTable,
|
|
230
238
|
tag: storage.SaveOperationTag.INSERT,
|
|
@@ -234,15 +242,26 @@ bucket_definitions:
|
|
|
234
242
|
},
|
|
235
243
|
afterReplicaId: 'highprio'
|
|
236
244
|
});
|
|
237
|
-
for (let i = 0; i <
|
|
245
|
+
for (let i = 0; i < 999; i++) {
|
|
238
246
|
await writer.save({
|
|
239
247
|
sourceTable: testTable,
|
|
240
248
|
tag: storage.SaveOperationTag.INSERT,
|
|
241
249
|
after: {
|
|
242
|
-
id:
|
|
243
|
-
description: 'low
|
|
250
|
+
id: `first-${i}`,
|
|
251
|
+
description: 'first low-priority bucket'
|
|
244
252
|
},
|
|
245
|
-
afterReplicaId:
|
|
253
|
+
afterReplicaId: `first-${i}`
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
for (let i = 0; i < 9_001; i++) {
|
|
257
|
+
await writer.save({
|
|
258
|
+
sourceTable: testTable,
|
|
259
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
260
|
+
after: {
|
|
261
|
+
id: `low-${i}`,
|
|
262
|
+
description: 'last low-priority bucket'
|
|
263
|
+
},
|
|
264
|
+
afterReplicaId: `low-${i}`
|
|
246
265
|
});
|
|
247
266
|
}
|
|
248
267
|
await writer.commit('0/1');
|
|
@@ -259,17 +278,24 @@ bucket_definitions:
|
|
|
259
278
|
token: new JwtPayload({ sub: '', exp: Date.now() / 1000 + 10 }),
|
|
260
279
|
isEncodingAsBson: false
|
|
261
280
|
});
|
|
262
|
-
|
|
263
|
-
|
|
281
|
+
const dataLines = [];
|
|
282
|
+
const partialCheckpoints = [];
|
|
283
|
+
const checkpoints = [];
|
|
284
|
+
const checkpointCompletes = [];
|
|
285
|
+
let afterCheckpointDiff = false;
|
|
286
|
+
let interruptionTriggered = false;
|
|
264
287
|
for await (let next of stream) {
|
|
265
288
|
if (typeof next == 'string') {
|
|
266
289
|
next = JSON.parse(next);
|
|
267
290
|
}
|
|
268
291
|
if (typeof next === 'object' && next !== null) {
|
|
269
292
|
if ('partial_checkpoint_complete' in next) {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
293
|
+
partialCheckpoints.push({
|
|
294
|
+
priority: next.partial_checkpoint_complete.priority,
|
|
295
|
+
afterCheckpointDiff
|
|
296
|
+
});
|
|
297
|
+
if (next.partial_checkpoint_complete.priority == 2 && !interruptionTriggered) {
|
|
298
|
+
interruptionTriggered = true;
|
|
273
299
|
await writer.save({
|
|
274
300
|
sourceTable: testTable,
|
|
275
301
|
tag: storage.SaveOperationTag.INSERT,
|
|
@@ -280,27 +306,69 @@ bucket_definitions:
|
|
|
280
306
|
afterReplicaId: 'highprio2'
|
|
281
307
|
});
|
|
282
308
|
await writer.commit('0/2');
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
// Low-priority sync from the first checkpoint was interrupted. This should not happen before
|
|
286
|
-
// 1000 low-priority items were synchronized.
|
|
287
|
-
expect(sentCheckpoints).toBe(2);
|
|
288
|
-
expect(sentRows).toBeGreaterThan(1000);
|
|
309
|
+
// Let the checkpoint watcher observe the update before requesting the next priority.
|
|
310
|
+
await timers.setTimeout(50);
|
|
289
311
|
}
|
|
290
312
|
}
|
|
291
313
|
if ('checkpoint' in next || 'checkpoint_diff' in next) {
|
|
292
|
-
|
|
314
|
+
checkpoints.push(next);
|
|
315
|
+
afterCheckpointDiff = 'checkpoint_diff' in next;
|
|
293
316
|
}
|
|
294
317
|
if ('data' in next) {
|
|
295
|
-
|
|
318
|
+
dataLines.push({
|
|
319
|
+
bucket: next.data.bucket,
|
|
320
|
+
objectIds: next.data.data.flatMap((entry) => (entry.object_id == null ? [] : [entry.object_id])),
|
|
321
|
+
afterCheckpointDiff
|
|
322
|
+
});
|
|
296
323
|
}
|
|
297
324
|
if ('checkpoint_complete' in next) {
|
|
325
|
+
checkpointCompletes.push(next);
|
|
298
326
|
break;
|
|
299
327
|
}
|
|
300
328
|
}
|
|
301
329
|
}
|
|
302
|
-
|
|
303
|
-
|
|
330
|
+
// Expected flow (data may be split into any number of chunks):
|
|
331
|
+
//
|
|
332
|
+
// checkpoint
|
|
333
|
+
// data: b1 contains highprio
|
|
334
|
+
// partial_checkpoint_complete: priority 1
|
|
335
|
+
// data: b0a contains all 999 first-* rows (1,000 operations including highprio)
|
|
336
|
+
// partial_checkpoint_complete: priority 2
|
|
337
|
+
// ## add highprio2, interrupting before b0b starts
|
|
338
|
+
// checkpoint_diff
|
|
339
|
+
// data: b1 contains highprio2
|
|
340
|
+
// partial_checkpoint_complete: priority 1
|
|
341
|
+
// data: b0b contains all 9,001 low-* rows carried over from the interrupted checkpoint
|
|
342
|
+
// checkpoint_complete: only for the new checkpoint
|
|
343
|
+
expect(interruptionTriggered).toBe(true);
|
|
344
|
+
expect(checkpoints).toHaveLength(2);
|
|
345
|
+
expect(checkpoints[0]).toHaveProperty('checkpoint');
|
|
346
|
+
expect(checkpoints[1]).toHaveProperty('checkpoint_diff');
|
|
347
|
+
expect(partialCheckpoints).toEqual([
|
|
348
|
+
{ priority: 1, afterCheckpointDiff: false },
|
|
349
|
+
{ priority: 2, afterCheckpointDiff: false },
|
|
350
|
+
{ priority: 1, afterCheckpointDiff: true }
|
|
351
|
+
]);
|
|
352
|
+
expect(checkpointCompletes).toEqual([
|
|
353
|
+
{
|
|
354
|
+
checkpoint_complete: {
|
|
355
|
+
last_op_id: checkpoints[1].checkpoint_diff.last_op_id
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
]);
|
|
359
|
+
const objectIdsFor = (bucket, afterDiff) => dataLines
|
|
360
|
+
.filter((line) => line.bucket == bucket && line.afterCheckpointDiff == afterDiff)
|
|
361
|
+
.flatMap((line) => line.objectIds);
|
|
362
|
+
expect(objectIdsFor(b1Bucket, false)).toEqual(['highprio']);
|
|
363
|
+
const b0aObjectIds = objectIdsFor(b0aBucket, false);
|
|
364
|
+
expect(new Set(b0aObjectIds)).toEqual(new Set(Array.from({ length: 999 }, (_, i) => `first-${i}`)));
|
|
365
|
+
expect(b0aObjectIds).toHaveLength(999);
|
|
366
|
+
expect(objectIdsFor(b0bBucket, false)).toEqual([]);
|
|
367
|
+
expect(objectIdsFor(b1Bucket, true)).toEqual(['highprio2']);
|
|
368
|
+
expect(objectIdsFor(b0aBucket, true)).toEqual([]);
|
|
369
|
+
const b0bObjectIds = objectIdsFor(b0bBucket, true);
|
|
370
|
+
expect(new Set(b0bObjectIds)).toEqual(new Set(Array.from({ length: 9_001 }, (_, i) => `low-${i}`)));
|
|
371
|
+
expect(b0bObjectIds).toHaveLength(9_001);
|
|
304
372
|
});
|
|
305
373
|
test('sync interruptions with unrelated data', async () => {
|
|
306
374
|
await using f = await factory();
|
|
@@ -420,33 +488,33 @@ bucket_definitions:
|
|
|
420
488
|
expect(sentCheckpoints).toBe(2);
|
|
421
489
|
expect(sentRows).toBe(10002);
|
|
422
490
|
});
|
|
423
|
-
test('
|
|
491
|
+
test('restarts updated low-priority buckets at a new checkpoint', async () => {
|
|
424
492
|
await using f = await factory();
|
|
425
|
-
// bucket0a -> send all data
|
|
426
|
-
// then interrupt checkpoint with new data for all buckets
|
|
427
|
-
// -> data for all buckets should be sent in the new checkpoint
|
|
428
493
|
const syncRules = await updateSyncRules(f, {
|
|
429
494
|
content: `
|
|
430
495
|
bucket_definitions:
|
|
431
496
|
b0a:
|
|
432
497
|
priority: 2
|
|
433
498
|
data:
|
|
434
|
-
- SELECT * FROM test WHERE
|
|
499
|
+
- SELECT * FROM test WHERE substring(id, 1, 6) = 'first-';
|
|
435
500
|
b0b:
|
|
436
|
-
priority:
|
|
501
|
+
priority: 3
|
|
437
502
|
data:
|
|
438
|
-
- SELECT * FROM test WHERE
|
|
503
|
+
- SELECT * FROM test WHERE substring(id, 1, 4) = 'low-';
|
|
439
504
|
b1:
|
|
440
505
|
priority: 1
|
|
441
506
|
data:
|
|
442
|
-
- SELECT * FROM test WHERE
|
|
507
|
+
- SELECT * FROM test WHERE substring(id, 1, 8) = 'highprio';
|
|
443
508
|
`
|
|
444
509
|
});
|
|
445
510
|
const bucketStorage = f.getInstance(syncRules);
|
|
446
511
|
await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
447
512
|
const testTable = await test_utils.resolveTestTable(writer, 'test', ['id'], config);
|
|
513
|
+
const syncRulesContent = syncRules.syncConfigContent[0];
|
|
514
|
+
const b0aBucket = test_utils.bucketRequest(syncRulesContent, 'b0a[]').bucket;
|
|
515
|
+
const b0bBucket = test_utils.bucketRequest(syncRulesContent, 'b0b[]').bucket;
|
|
516
|
+
const b1Bucket = test_utils.bucketRequest(syncRulesContent, 'b1[]').bucket;
|
|
448
517
|
await writer.markAllSnapshotDone('0/1');
|
|
449
|
-
// Initial data: Add one priority row and 10k low-priority rows.
|
|
450
518
|
await writer.save({
|
|
451
519
|
sourceTable: testTable,
|
|
452
520
|
tag: storage.SaveOperationTag.INSERT,
|
|
@@ -456,15 +524,26 @@ bucket_definitions:
|
|
|
456
524
|
},
|
|
457
525
|
afterReplicaId: 'highprio'
|
|
458
526
|
});
|
|
527
|
+
for (let i = 0; i < 999; i++) {
|
|
528
|
+
await writer.save({
|
|
529
|
+
sourceTable: testTable,
|
|
530
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
531
|
+
after: {
|
|
532
|
+
id: `first-${i}`,
|
|
533
|
+
description: 'first low-priority bucket'
|
|
534
|
+
},
|
|
535
|
+
afterReplicaId: `first-${i}`
|
|
536
|
+
});
|
|
537
|
+
}
|
|
459
538
|
for (let i = 0; i < 2_000; i++) {
|
|
460
539
|
await writer.save({
|
|
461
540
|
sourceTable: testTable,
|
|
462
541
|
tag: storage.SaveOperationTag.INSERT,
|
|
463
542
|
after: {
|
|
464
|
-
id:
|
|
543
|
+
id: `low-${i}`,
|
|
465
544
|
description: 'low prio'
|
|
466
545
|
},
|
|
467
|
-
afterReplicaId:
|
|
546
|
+
afterReplicaId: `low-${i}`
|
|
468
547
|
});
|
|
469
548
|
}
|
|
470
549
|
await writer.commit('0/1');
|
|
@@ -481,25 +560,24 @@ bucket_definitions:
|
|
|
481
560
|
token: new JwtPayload({ sub: '', exp: Date.now() / 1000 + 10 }),
|
|
482
561
|
isEncodingAsBson: false
|
|
483
562
|
});
|
|
484
|
-
|
|
485
|
-
|
|
563
|
+
const dataLines = [];
|
|
564
|
+
const partialCheckpoints = [];
|
|
565
|
+
const checkpoints = [];
|
|
566
|
+
const checkpointCompletes = [];
|
|
567
|
+
let afterCheckpointDiff = false;
|
|
568
|
+
let interruptionTriggered = false;
|
|
486
569
|
for await (let next of stream) {
|
|
487
570
|
if (typeof next == 'string') {
|
|
488
571
|
next = JSON.parse(next);
|
|
489
572
|
}
|
|
490
573
|
if (typeof next === 'object' && next !== null) {
|
|
491
574
|
if ('partial_checkpoint_complete' in next) {
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
lines.push({ data: { ...next.data, data: undefined } });
|
|
499
|
-
sentRows += next.data.data.length;
|
|
500
|
-
if (sentRows == 1001) {
|
|
501
|
-
// Save new data to interrupt the low-priority sync.
|
|
502
|
-
// Add another high-priority row. This should interrupt the long-running low-priority sync.
|
|
575
|
+
partialCheckpoints.push({
|
|
576
|
+
priority: next.partial_checkpoint_complete.priority,
|
|
577
|
+
afterCheckpointDiff
|
|
578
|
+
});
|
|
579
|
+
if (next.partial_checkpoint_complete.priority == 2 && !interruptionTriggered) {
|
|
580
|
+
interruptionTriggered = true;
|
|
503
581
|
await writer.save({
|
|
504
582
|
sourceTable: testTable,
|
|
505
583
|
tag: storage.SaveOperationTag.INSERT,
|
|
@@ -509,48 +587,79 @@ bucket_definitions:
|
|
|
509
587
|
},
|
|
510
588
|
afterReplicaId: 'highprio2'
|
|
511
589
|
});
|
|
512
|
-
// Also add a low-priority row
|
|
513
590
|
await writer.save({
|
|
514
591
|
sourceTable: testTable,
|
|
515
592
|
tag: storage.SaveOperationTag.INSERT,
|
|
516
593
|
after: {
|
|
517
|
-
id: '
|
|
594
|
+
id: 'low-2000',
|
|
518
595
|
description: 'Another low-priority row'
|
|
519
596
|
},
|
|
520
|
-
afterReplicaId: '
|
|
597
|
+
afterReplicaId: 'low-2000'
|
|
521
598
|
});
|
|
522
599
|
await writer.commit('0/2');
|
|
523
|
-
|
|
524
|
-
if (sentRows >= 1000 && sentRows <= 2001) {
|
|
525
|
-
// pause for a bit to give the stream time to process interruptions.
|
|
526
|
-
// This covers the data batch above and the next one.
|
|
600
|
+
// Let the checkpoint watcher observe the update before requesting the next priority.
|
|
527
601
|
await timers.setTimeout(50);
|
|
528
602
|
}
|
|
529
603
|
}
|
|
604
|
+
if ('checkpoint' in next || 'checkpoint_diff' in next) {
|
|
605
|
+
checkpoints.push(next);
|
|
606
|
+
afterCheckpointDiff = 'checkpoint_diff' in next;
|
|
607
|
+
}
|
|
608
|
+
if ('data' in next) {
|
|
609
|
+
dataLines.push({
|
|
610
|
+
bucket: next.data.bucket,
|
|
611
|
+
objectIds: next.data.data.flatMap((entry) => (entry.object_id == null ? [] : [entry.object_id])),
|
|
612
|
+
afterCheckpointDiff
|
|
613
|
+
});
|
|
614
|
+
}
|
|
530
615
|
if ('checkpoint_complete' in next) {
|
|
531
|
-
|
|
616
|
+
checkpointCompletes.push(next);
|
|
532
617
|
break;
|
|
533
618
|
}
|
|
534
619
|
}
|
|
535
620
|
}
|
|
536
|
-
// Expected
|
|
621
|
+
// Expected flow (data may be split into any number of chunks):
|
|
537
622
|
//
|
|
538
|
-
// checkpoint
|
|
539
|
-
// data
|
|
540
|
-
// partial_checkpoint_complete
|
|
541
|
-
// data (
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
// checkpoint_diff
|
|
545
|
-
// data
|
|
546
|
-
// partial_checkpoint_complete
|
|
547
|
-
// data
|
|
548
|
-
//
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
expect(
|
|
553
|
-
expect(
|
|
623
|
+
// checkpoint
|
|
624
|
+
// data: b1 contains highprio
|
|
625
|
+
// partial_checkpoint_complete: priority 1
|
|
626
|
+
// data: b0a contains all 999 first-* rows (1,000 operations including highprio)
|
|
627
|
+
// partial_checkpoint_complete: priority 2
|
|
628
|
+
// ## add highprio2 and low-2000, interrupting before b0b starts
|
|
629
|
+
// checkpoint_diff
|
|
630
|
+
// data: b1 contains highprio2
|
|
631
|
+
// partial_checkpoint_complete: priority 1
|
|
632
|
+
// data: b0b contains all 2,001 low-* rows
|
|
633
|
+
// checkpoint_complete: only for the new checkpoint
|
|
634
|
+
expect(interruptionTriggered).toBe(true);
|
|
635
|
+
expect(checkpoints).toHaveLength(2);
|
|
636
|
+
expect(checkpoints[0]).toHaveProperty('checkpoint');
|
|
637
|
+
expect(checkpoints[1]).toHaveProperty('checkpoint_diff');
|
|
638
|
+
expect(partialCheckpoints).toEqual([
|
|
639
|
+
{ priority: 1, afterCheckpointDiff: false },
|
|
640
|
+
{ priority: 2, afterCheckpointDiff: false },
|
|
641
|
+
{ priority: 1, afterCheckpointDiff: true }
|
|
642
|
+
]);
|
|
643
|
+
expect(checkpointCompletes).toEqual([
|
|
644
|
+
{
|
|
645
|
+
checkpoint_complete: {
|
|
646
|
+
last_op_id: checkpoints[1].checkpoint_diff.last_op_id
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
]);
|
|
650
|
+
const objectIdsFor = (bucket, afterDiff) => dataLines
|
|
651
|
+
.filter((line) => line.bucket == bucket && line.afterCheckpointDiff == afterDiff)
|
|
652
|
+
.flatMap((line) => line.objectIds);
|
|
653
|
+
expect(objectIdsFor(b1Bucket, false)).toEqual(['highprio']);
|
|
654
|
+
const b0aObjectIds = objectIdsFor(b0aBucket, false);
|
|
655
|
+
expect(new Set(b0aObjectIds)).toEqual(new Set(Array.from({ length: 999 }, (_, i) => `first-${i}`)));
|
|
656
|
+
expect(b0aObjectIds).toHaveLength(999);
|
|
657
|
+
expect(objectIdsFor(b0bBucket, false)).toEqual([]);
|
|
658
|
+
expect(objectIdsFor(b1Bucket, true)).toEqual(['highprio2']);
|
|
659
|
+
expect(objectIdsFor(b0aBucket, true)).toEqual([]);
|
|
660
|
+
const b0bObjectIds = objectIdsFor(b0bBucket, true);
|
|
661
|
+
expect(new Set(b0bObjectIds)).toEqual(new Set(Array.from({ length: 2_001 }, (_, i) => `low-${i}`)));
|
|
662
|
+
expect(b0bObjectIds).toHaveLength(2_001);
|
|
554
663
|
});
|
|
555
664
|
test('sends checkpoint complete line for empty checkpoint', async () => {
|
|
556
665
|
await using f = await factory();
|
|
@@ -596,7 +705,7 @@ bucket_definitions:
|
|
|
596
705
|
receivedCompletions++;
|
|
597
706
|
if (receivedCompletions == 1) {
|
|
598
707
|
// Trigger an empty bucket update.
|
|
599
|
-
await bucketStorage.
|
|
708
|
+
await bucketStorage.createManagedWriteCheckpoints([{ user_id: '', heads: { '1': '1/0' } }]);
|
|
600
709
|
await writer.commit('1/0');
|
|
601
710
|
}
|
|
602
711
|
else {
|
|
@@ -768,7 +877,8 @@ bucket_definitions:
|
|
|
768
877
|
});
|
|
769
878
|
await writer.commit('0/1');
|
|
770
879
|
const checkpoint2 = await getCheckpointLines(iter);
|
|
771
|
-
const
|
|
880
|
+
const syncRulesContent = syncRules.syncConfigContent[0];
|
|
881
|
+
const { bucket } = test_utils.bucketRequest(syncRulesContent, 'by_user["user1"]');
|
|
772
882
|
expect(checkpoint2[0].checkpoint_diff?.updated_buckets?.map((b) => b.bucket)).toEqual([bucket]);
|
|
773
883
|
expect(checkpoint2).toMatchSnapshot();
|
|
774
884
|
});
|
|
@@ -814,7 +924,8 @@ bucket_definitions:
|
|
|
814
924
|
context.onTestFinished(() => {
|
|
815
925
|
iter.return?.();
|
|
816
926
|
});
|
|
817
|
-
const
|
|
927
|
+
const syncRulesContent = syncRules.syncConfigContent[0];
|
|
928
|
+
const { bucket } = bucketRequest(syncRulesContent, 'by_user["user1"]');
|
|
818
929
|
const checkpoint1 = await getCheckpointLines(iter);
|
|
819
930
|
expect(checkpoint1[0].checkpoint?.buckets?.map((b) => b.bucket)).toEqual([bucket]);
|
|
820
931
|
expect(checkpoint1).toMatchSnapshot();
|
|
@@ -890,7 +1001,8 @@ bucket_definitions:
|
|
|
890
1001
|
afterReplicaId: 'user1'
|
|
891
1002
|
});
|
|
892
1003
|
await writer.commit('0/1');
|
|
893
|
-
const
|
|
1004
|
+
const syncRulesContent = syncRules.syncConfigContent[0];
|
|
1005
|
+
const { bucket } = test_utils.bucketRequest(syncRulesContent, 'by_user["user1"]');
|
|
894
1006
|
const checkpoint2 = await getCheckpointLines(iter);
|
|
895
1007
|
expect(checkpoint2[0].checkpoint_diff?.updated_buckets?.map((b) => b.bucket)).toEqual([bucket]);
|
|
896
1008
|
expect(checkpoint2).toMatchSnapshot();
|
|
@@ -928,6 +1040,82 @@ bucket_definitions:
|
|
|
928
1040
|
const expLines = await getCheckpointLines(iter);
|
|
929
1041
|
expect(expLines).toMatchSnapshot();
|
|
930
1042
|
});
|
|
1043
|
+
test('checksum invalidation skips the candidate checkpoint', async (context) => {
|
|
1044
|
+
await using f = await factory();
|
|
1045
|
+
const syncRules = await updateSyncRules(f, {
|
|
1046
|
+
content: BASIC_SYNC_RULES
|
|
1047
|
+
});
|
|
1048
|
+
const bucketStorage = await f.getInstance(syncRules);
|
|
1049
|
+
await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
1050
|
+
const testTable = await test_utils.resolveTestTable(writer, 'test', ['id'], config);
|
|
1051
|
+
await writer.markAllSnapshotDone('0/1');
|
|
1052
|
+
await writer.save({
|
|
1053
|
+
sourceTable: testTable,
|
|
1054
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
1055
|
+
after: {
|
|
1056
|
+
id: 't1',
|
|
1057
|
+
description: 'Test 1'
|
|
1058
|
+
},
|
|
1059
|
+
afterReplicaId: 't1'
|
|
1060
|
+
});
|
|
1061
|
+
await writer.commit('0/1');
|
|
1062
|
+
let resolveInvalidatedChecksum;
|
|
1063
|
+
const invalidatedChecksum = new Promise((resolve) => {
|
|
1064
|
+
resolveInvalidatedChecksum = resolve;
|
|
1065
|
+
});
|
|
1066
|
+
const checksumSpy = vi.spyOn(bucketStorage, 'getChecksums').mockImplementationOnce(async (checkpoint, buckets) => {
|
|
1067
|
+
resolveInvalidatedChecksum();
|
|
1068
|
+
throw new storage.CheckpointChecksumInvalidatedError(checkpoint.checkpoint, buckets[0].bucket);
|
|
1069
|
+
});
|
|
1070
|
+
const stream = sync.streamResponse({
|
|
1071
|
+
syncContext,
|
|
1072
|
+
bucketStorage,
|
|
1073
|
+
syncRules: bucketStorage.getParsedSyncRules(test_utils.PARSE_OPTIONS),
|
|
1074
|
+
params: {
|
|
1075
|
+
buckets: [],
|
|
1076
|
+
include_checksum: true,
|
|
1077
|
+
raw_data: true
|
|
1078
|
+
},
|
|
1079
|
+
tracker,
|
|
1080
|
+
token: new JwtPayload({ sub: '', exp: Date.now() / 1000 + 10 }),
|
|
1081
|
+
isEncodingAsBson: false
|
|
1082
|
+
});
|
|
1083
|
+
const iter = stream[Symbol.asyncIterator]();
|
|
1084
|
+
context.onTestFinished(() => {
|
|
1085
|
+
iter.return?.();
|
|
1086
|
+
});
|
|
1087
|
+
const linesPromise = getCheckpointLines(iter, { consume: true });
|
|
1088
|
+
await invalidatedChecksum;
|
|
1089
|
+
// The first candidate was discarded before CheckpointLine.advance(). A later
|
|
1090
|
+
// checkpoint must therefore be calculated from the original connection state.
|
|
1091
|
+
await writer.save({
|
|
1092
|
+
sourceTable: testTable,
|
|
1093
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
1094
|
+
after: {
|
|
1095
|
+
id: 't2',
|
|
1096
|
+
description: 'Test 2'
|
|
1097
|
+
},
|
|
1098
|
+
afterReplicaId: 't2'
|
|
1099
|
+
});
|
|
1100
|
+
await writer.commit('0/2');
|
|
1101
|
+
const lines = await linesPromise;
|
|
1102
|
+
expect(checksumSpy).toHaveBeenCalledTimes(2);
|
|
1103
|
+
expect(lines[0]).toEqual({
|
|
1104
|
+
checkpoint: expect.objectContaining({
|
|
1105
|
+
last_op_id: '2'
|
|
1106
|
+
})
|
|
1107
|
+
});
|
|
1108
|
+
expect(lines).not.toContainEqual({
|
|
1109
|
+
checkpoint: expect.objectContaining({
|
|
1110
|
+
last_op_id: '1'
|
|
1111
|
+
})
|
|
1112
|
+
});
|
|
1113
|
+
expect(lines.at(-1)).toEqual({
|
|
1114
|
+
checkpoint_complete: expect.objectContaining({
|
|
1115
|
+
last_op_id: '2'
|
|
1116
|
+
})
|
|
1117
|
+
});
|
|
1118
|
+
});
|
|
931
1119
|
test('compacting data - invalidate checkpoint', async (context) => {
|
|
932
1120
|
// This tests a case of a compact operation invalidating a checkpoint in the
|
|
933
1121
|
// middle of syncing data.
|
|
@@ -940,6 +1128,7 @@ bucket_definitions:
|
|
|
940
1128
|
const bucketStorage = await f.getInstance(syncRules);
|
|
941
1129
|
await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
942
1130
|
const testTable = await test_utils.resolveTestTable(writer, 'test', ['id'], config);
|
|
1131
|
+
const bucket = bucketRequest(syncRules.syncConfigContent[0], 'mybucket[]').bucket;
|
|
943
1132
|
await writer.markAllSnapshotDone('0/1');
|
|
944
1133
|
await writer.save({
|
|
945
1134
|
sourceTable: testTable,
|
|
@@ -1007,10 +1196,7 @@ bucket_definitions:
|
|
|
1007
1196
|
afterReplicaId: 't2'
|
|
1008
1197
|
});
|
|
1009
1198
|
await writer.commit('0/2');
|
|
1010
|
-
await
|
|
1011
|
-
minBucketChanges: 1,
|
|
1012
|
-
minChangeRatio: 0
|
|
1013
|
-
});
|
|
1199
|
+
await compactActive(f, { compactBuckets: [bucket] });
|
|
1014
1200
|
const lines2 = await getCheckpointLines(iter, { consume: true });
|
|
1015
1201
|
// Snapshot test checks for changes in general.
|
|
1016
1202
|
// The tests after that documents the specific things we're looking for
|
|
@@ -1054,6 +1240,136 @@ bucket_definitions:
|
|
|
1054
1240
|
})
|
|
1055
1241
|
});
|
|
1056
1242
|
});
|
|
1243
|
+
test('compacting high-priority data invalidates checkpoint before lower priorities', async (context) => {
|
|
1244
|
+
// Scenario:
|
|
1245
|
+
// 1. Bucket priorities are used.
|
|
1246
|
+
// 2. Client syncs a high-priority bucket.
|
|
1247
|
+
// 3. Due to a concurrent compaction, the checkpoint is invalidated (via target_op).
|
|
1248
|
+
// 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.
|
|
1249
|
+
// 5. However, the loop handling priorities does not see that invalidation, it only sees "bucket data for this priority is done".
|
|
1250
|
+
// 6. The priority loop incorrectly continues with the next priority.
|
|
1251
|
+
// 7. The service emits the data, as well as a final checkpoint_complete.
|
|
1252
|
+
// 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.
|
|
1253
|
+
await using f = await factory();
|
|
1254
|
+
const syncRules = await updateSyncRules(f, {
|
|
1255
|
+
content: `
|
|
1256
|
+
bucket_definitions:
|
|
1257
|
+
high_priority:
|
|
1258
|
+
priority: 1
|
|
1259
|
+
data:
|
|
1260
|
+
- SELECT * FROM test WHERE substring(id, 1, 4) = 'high';
|
|
1261
|
+
low_priority:
|
|
1262
|
+
priority: 2
|
|
1263
|
+
data:
|
|
1264
|
+
- SELECT * FROM test WHERE id = 'low';
|
|
1265
|
+
`
|
|
1266
|
+
});
|
|
1267
|
+
const bucketStorage = await f.getInstance(syncRules);
|
|
1268
|
+
await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
1269
|
+
const testTable = await test_utils.resolveTestTable(writer, 'test', ['id'], config);
|
|
1270
|
+
const highPriorityBucket = bucketRequest(syncRules.syncConfigContent[0], 'high_priority[]').bucket;
|
|
1271
|
+
await writer.markAllSnapshotDone('0/1');
|
|
1272
|
+
await writer.save({
|
|
1273
|
+
sourceTable: testTable,
|
|
1274
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
1275
|
+
after: {
|
|
1276
|
+
id: 'high1',
|
|
1277
|
+
description: 'High priority 1'
|
|
1278
|
+
},
|
|
1279
|
+
afterReplicaId: 'high1'
|
|
1280
|
+
});
|
|
1281
|
+
await writer.save({
|
|
1282
|
+
sourceTable: testTable,
|
|
1283
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
1284
|
+
after: {
|
|
1285
|
+
id: 'high2',
|
|
1286
|
+
description: 'High priority 2'
|
|
1287
|
+
},
|
|
1288
|
+
afterReplicaId: 'high2'
|
|
1289
|
+
});
|
|
1290
|
+
await writer.save({
|
|
1291
|
+
sourceTable: testTable,
|
|
1292
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
1293
|
+
after: {
|
|
1294
|
+
id: 'low',
|
|
1295
|
+
description: 'Low priority'
|
|
1296
|
+
},
|
|
1297
|
+
afterReplicaId: 'low'
|
|
1298
|
+
});
|
|
1299
|
+
await writer.commit('0/1');
|
|
1300
|
+
const stream = sync.streamResponse({
|
|
1301
|
+
syncContext,
|
|
1302
|
+
bucketStorage,
|
|
1303
|
+
syncRules: bucketStorage.getParsedSyncRules(test_utils.PARSE_OPTIONS),
|
|
1304
|
+
params: {
|
|
1305
|
+
buckets: [],
|
|
1306
|
+
include_checksum: true,
|
|
1307
|
+
raw_data: true
|
|
1308
|
+
},
|
|
1309
|
+
tracker,
|
|
1310
|
+
token: new JwtPayload({ sub: '', exp: Date.now() / 1000 + 10 }),
|
|
1311
|
+
isEncodingAsBson: false
|
|
1312
|
+
});
|
|
1313
|
+
const iter = stream[Symbol.asyncIterator]();
|
|
1314
|
+
await using _ = {
|
|
1315
|
+
async [Symbol.asyncDispose]() {
|
|
1316
|
+
await iter.return?.();
|
|
1317
|
+
}
|
|
1318
|
+
};
|
|
1319
|
+
const checkpoint = await consumeIterator(iter, {
|
|
1320
|
+
consume: false,
|
|
1321
|
+
isDone: (line) => line?.checkpoint != null
|
|
1322
|
+
});
|
|
1323
|
+
expect(checkpoint[0]).toEqual({
|
|
1324
|
+
checkpoint: expect.objectContaining({
|
|
1325
|
+
last_op_id: '3'
|
|
1326
|
+
})
|
|
1327
|
+
});
|
|
1328
|
+
await writer.save({
|
|
1329
|
+
sourceTable: testTable,
|
|
1330
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
1331
|
+
after: {
|
|
1332
|
+
id: 'high1',
|
|
1333
|
+
description: 'Updated high priority 1'
|
|
1334
|
+
},
|
|
1335
|
+
afterReplicaId: 'high1'
|
|
1336
|
+
});
|
|
1337
|
+
await writer.save({
|
|
1338
|
+
sourceTable: testTable,
|
|
1339
|
+
tag: storage.SaveOperationTag.UPDATE,
|
|
1340
|
+
after: {
|
|
1341
|
+
id: 'high2',
|
|
1342
|
+
description: 'Updated high priority 2'
|
|
1343
|
+
},
|
|
1344
|
+
afterReplicaId: 'high2'
|
|
1345
|
+
});
|
|
1346
|
+
await writer.commit('0/2');
|
|
1347
|
+
await compactActive(f, {
|
|
1348
|
+
// Explicitly compact the high-priority bucket: V3 schedules background
|
|
1349
|
+
// compaction, while this test needs compaction at this exact point.
|
|
1350
|
+
compactBuckets: [highPriorityBucket]
|
|
1351
|
+
});
|
|
1352
|
+
const lines = await getCheckpointLines(iter, { consume: true });
|
|
1353
|
+
const nextCheckpointIndex = lines.findIndex((line) => line?.checkpoint_diff != null);
|
|
1354
|
+
expect(nextCheckpointIndex).toBeGreaterThan(0);
|
|
1355
|
+
const invalidatedCheckpointLines = lines.slice(0, nextCheckpointIndex);
|
|
1356
|
+
expect(invalidatedCheckpointLines).not.toContainEqual(expect.objectContaining({ checkpoint_complete: expect.anything() }));
|
|
1357
|
+
expect(invalidatedCheckpointLines).not.toContainEqual(expect.objectContaining({
|
|
1358
|
+
data: expect.objectContaining({
|
|
1359
|
+
bucket: expect.stringContaining('low_priority')
|
|
1360
|
+
})
|
|
1361
|
+
}));
|
|
1362
|
+
expect(lines[nextCheckpointIndex]).toEqual({
|
|
1363
|
+
checkpoint_diff: expect.objectContaining({
|
|
1364
|
+
last_op_id: '5'
|
|
1365
|
+
})
|
|
1366
|
+
});
|
|
1367
|
+
expect(lines.at(-1)).toEqual({
|
|
1368
|
+
checkpoint_complete: expect.objectContaining({
|
|
1369
|
+
last_op_id: '5'
|
|
1370
|
+
})
|
|
1371
|
+
});
|
|
1372
|
+
});
|
|
1057
1373
|
test('write checkpoint', async () => {
|
|
1058
1374
|
await using f = await factory();
|
|
1059
1375
|
const syncRules = await updateSyncRules(f, {
|
|
@@ -1064,10 +1380,12 @@ bucket_definitions:
|
|
|
1064
1380
|
await writer.markAllSnapshotDone('0/1');
|
|
1065
1381
|
// <= the managed write checkpoint LSN below
|
|
1066
1382
|
await writer.commit('0/1');
|
|
1067
|
-
const checkpoint = await bucketStorage.
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1383
|
+
const checkpoint = (await bucketStorage.createManagedWriteCheckpoints([
|
|
1384
|
+
{
|
|
1385
|
+
user_id: 'test',
|
|
1386
|
+
heads: { '1': '1/0' }
|
|
1387
|
+
}
|
|
1388
|
+
])).writeCheckpoints.get('test');
|
|
1071
1389
|
const params = {
|
|
1072
1390
|
syncContext,
|
|
1073
1391
|
bucketStorage,
|