@powersync/service-module-postgres-storage 0.16.3 → 0.17.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 +33 -0
- package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.d.ts +3 -0
- package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.js +42 -0
- package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.js.map +1 -0
- package/dist/storage/PostgresBucketStorageFactory.js +0 -2
- package/dist/storage/PostgresBucketStorageFactory.js.map +1 -1
- package/dist/storage/PostgresCompactor.d.ts +2 -0
- package/dist/storage/PostgresCompactor.js +20 -0
- package/dist/storage/PostgresCompactor.js.map +1 -1
- package/dist/storage/PostgresSyncRulesStorage.d.ts +16 -1
- package/dist/storage/PostgresSyncRulesStorage.js +200 -131
- package/dist/storage/PostgresSyncRulesStorage.js.map +1 -1
- package/dist/storage/batch/PostgresBucketBatch.d.ts +2 -20
- package/dist/storage/batch/PostgresBucketBatch.js +162 -151
- package/dist/storage/batch/PostgresBucketBatch.js.map +1 -1
- package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.d.ts +1 -1
- package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js +156 -36
- package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js.map +1 -1
- package/dist/storage/current-data-store.d.ts +8 -2
- package/dist/storage/current-data-store.js +66 -11
- package/dist/storage/current-data-store.js.map +1 -1
- package/dist/types/models/WriteCheckpoint.d.ts +2 -0
- package/dist/types/models/WriteCheckpoint.js +5 -2
- package/dist/types/models/WriteCheckpoint.js.map +1 -1
- package/dist/utils/checkpoints.d.ts +9 -0
- package/dist/utils/checkpoints.js +26 -0
- package/dist/utils/checkpoints.js.map +1 -0
- package/package.json +8 -8
- package/src/migrations/scripts/1782950400000-checkpoint-requested-at.ts +51 -0
- package/src/storage/PostgresBucketStorageFactory.ts +0 -3
- package/src/storage/PostgresCompactor.ts +24 -0
- package/src/storage/PostgresSyncRulesStorage.ts +219 -137
- package/src/storage/batch/PostgresBucketBatch.ts +169 -154
- package/src/storage/checkpoints/PostgresWriteCheckpointAPI.ts +165 -37
- package/src/storage/current-data-store.ts +66 -11
- package/src/types/models/WriteCheckpoint.ts +5 -2
- package/src/utils/checkpoints.ts +31 -0
- package/test/src/__snapshots__/storage_sync.test.ts.snap +0 -582
- package/test/src/checkpoint_notifications.test.ts +522 -0
- package/test/src/storage.test.ts +214 -5
- package/test/src/storage_compacting.test.ts +3 -3
- package/test/src/storage_sync.test.ts +4 -4
- package/test/tsconfig.json +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -60,7 +60,6 @@ export interface PostgresBucketBatchOptions {
|
|
|
60
60
|
* via the Postgres NOTIFY protocol.
|
|
61
61
|
*/
|
|
62
62
|
const StatefulCheckpoint = models.ActiveCheckpoint.and(t.object({ state: t.Enum(storage.SyncRuleState) }));
|
|
63
|
-
type StatefulCheckpointDecoded = t.Decoded<typeof StatefulCheckpoint>;
|
|
64
63
|
|
|
65
64
|
const CheckpointWithStatus = StatefulCheckpoint.and(
|
|
66
65
|
t.object({
|
|
@@ -465,12 +464,11 @@ export class PostgresBucketBatch
|
|
|
465
464
|
}
|
|
466
465
|
const { flushedAny } = await persistedBatch.flush(db);
|
|
467
466
|
clearedError = flushedAny && !this.clearedError;
|
|
468
|
-
if (clearedError) {
|
|
469
|
-
// No need to clear an error more than once per batch, since an error would always result in restarting the batch.
|
|
470
|
-
await this.clearError(db);
|
|
471
|
-
}
|
|
472
467
|
});
|
|
473
468
|
if (clearedError) {
|
|
469
|
+
// No need to clear an error more than once per batch, since an error would always result in restarting the batch.
|
|
470
|
+
// Cleared outside the replication transaction - see flushInner.
|
|
471
|
+
await this.clearError();
|
|
474
472
|
this.clearedError = true;
|
|
475
473
|
}
|
|
476
474
|
}
|
|
@@ -537,6 +535,10 @@ export class PostgresBucketBatch
|
|
|
537
535
|
});
|
|
538
536
|
|
|
539
537
|
if (clearedError) {
|
|
538
|
+
// Clear the error outside the replication transaction (plain autocommit update,
|
|
539
|
+
// like the keepalive), to avoid serialization conflicts on the sync_rules row
|
|
540
|
+
// when multiple writers flush concurrently.
|
|
541
|
+
await this.clearError();
|
|
540
542
|
this.clearedError = true;
|
|
541
543
|
}
|
|
542
544
|
|
|
@@ -562,136 +564,144 @@ export class PostgresBucketBatch
|
|
|
562
564
|
|
|
563
565
|
const persisted_op = this.persisted_op ?? null;
|
|
564
566
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
567
|
+
// Transaction failures restart replication from the last durable checkpoint.
|
|
568
|
+
const result = await this.db.transaction(async (db) => {
|
|
569
|
+
const checkpoint = await db.sql`
|
|
570
|
+
WITH
|
|
571
|
+
selected AS (
|
|
572
|
+
SELECT
|
|
573
|
+
id,
|
|
574
|
+
state,
|
|
575
|
+
last_checkpoint,
|
|
576
|
+
last_checkpoint_lsn,
|
|
577
|
+
snapshot_done,
|
|
578
|
+
no_checkpoint_before,
|
|
579
|
+
keepalive_op,
|
|
580
|
+
(
|
|
581
|
+
snapshot_done = TRUE
|
|
582
|
+
AND (
|
|
583
|
+
last_checkpoint_lsn IS NULL
|
|
584
|
+
OR last_checkpoint_lsn <= ${{ type: 'varchar', value: lsn }}
|
|
585
|
+
)
|
|
586
|
+
AND (
|
|
587
|
+
no_checkpoint_before IS NULL
|
|
588
|
+
OR no_checkpoint_before <= ${{ type: 'varchar', value: lsn }}
|
|
589
|
+
)
|
|
590
|
+
) AS can_checkpoint
|
|
591
|
+
FROM
|
|
592
|
+
sync_rules
|
|
593
|
+
WHERE
|
|
594
|
+
id = ${{ type: 'int4', value: this.group_id }}
|
|
595
|
+
FOR UPDATE
|
|
596
|
+
),
|
|
597
|
+
computed AS (
|
|
598
|
+
SELECT
|
|
599
|
+
selected.*,
|
|
600
|
+
CASE
|
|
601
|
+
WHEN selected.can_checkpoint THEN GREATEST(
|
|
602
|
+
selected.last_checkpoint,
|
|
603
|
+
${{ type: 'int8', value: persisted_op }},
|
|
604
|
+
selected.keepalive_op,
|
|
605
|
+
0
|
|
606
|
+
)
|
|
607
|
+
ELSE selected.last_checkpoint
|
|
608
|
+
END AS new_last_checkpoint,
|
|
609
|
+
CASE
|
|
610
|
+
WHEN selected.can_checkpoint THEN NULL
|
|
611
|
+
ELSE GREATEST(
|
|
612
|
+
selected.keepalive_op,
|
|
613
|
+
${{ type: 'int8', value: persisted_op }},
|
|
614
|
+
0
|
|
615
|
+
)
|
|
616
|
+
END AS new_keepalive_op
|
|
617
|
+
FROM
|
|
618
|
+
selected
|
|
619
|
+
),
|
|
620
|
+
updated AS (
|
|
621
|
+
UPDATE sync_rules AS sr
|
|
622
|
+
SET
|
|
623
|
+
last_checkpoint_lsn = CASE
|
|
624
|
+
WHEN computed.can_checkpoint THEN ${{ type: 'varchar', value: lsn }}
|
|
625
|
+
ELSE sr.last_checkpoint_lsn
|
|
626
|
+
END,
|
|
627
|
+
last_checkpoint_ts = CASE
|
|
628
|
+
WHEN computed.can_checkpoint THEN ${{ type: 1184, value: now }}
|
|
629
|
+
ELSE sr.last_checkpoint_ts
|
|
630
|
+
END,
|
|
631
|
+
last_keepalive_ts = ${{ type: 1184, value: now }},
|
|
632
|
+
last_fatal_error = CASE
|
|
633
|
+
WHEN computed.can_checkpoint THEN NULL
|
|
634
|
+
ELSE sr.last_fatal_error
|
|
635
|
+
END,
|
|
636
|
+
keepalive_op = computed.new_keepalive_op,
|
|
637
|
+
last_checkpoint = computed.new_last_checkpoint,
|
|
638
|
+
snapshot_lsn = CASE
|
|
639
|
+
WHEN computed.can_checkpoint THEN NULL
|
|
640
|
+
ELSE sr.snapshot_lsn
|
|
641
|
+
END
|
|
642
|
+
FROM
|
|
643
|
+
computed
|
|
644
|
+
WHERE
|
|
645
|
+
sr.id = computed.id
|
|
582
646
|
AND (
|
|
583
|
-
|
|
584
|
-
OR
|
|
585
|
-
|
|
586
|
-
) AS can_checkpoint
|
|
587
|
-
FROM
|
|
588
|
-
sync_rules
|
|
589
|
-
WHERE
|
|
590
|
-
id = ${{ type: 'int4', value: this.group_id }}
|
|
591
|
-
FOR UPDATE
|
|
592
|
-
),
|
|
593
|
-
computed AS (
|
|
594
|
-
SELECT
|
|
595
|
-
selected.*,
|
|
596
|
-
CASE
|
|
597
|
-
WHEN selected.can_checkpoint THEN GREATEST(
|
|
598
|
-
selected.last_checkpoint,
|
|
599
|
-
${{ type: 'int8', value: persisted_op }},
|
|
600
|
-
selected.keepalive_op,
|
|
601
|
-
0
|
|
647
|
+
sr.keepalive_op IS DISTINCT FROM computed.new_keepalive_op
|
|
648
|
+
OR sr.last_checkpoint IS DISTINCT FROM computed.new_last_checkpoint
|
|
649
|
+
OR ${{ type: 'bool', value: createEmptyCheckpoints }}
|
|
602
650
|
)
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
)
|
|
658
|
-
SELECT
|
|
659
|
-
id,
|
|
660
|
-
state,
|
|
661
|
-
last_checkpoint,
|
|
662
|
-
last_checkpoint_lsn,
|
|
663
|
-
snapshot_done,
|
|
664
|
-
no_checkpoint_before,
|
|
665
|
-
can_checkpoint,
|
|
666
|
-
keepalive_op,
|
|
667
|
-
new_last_checkpoint,
|
|
668
|
-
TRUE AS created_checkpoint
|
|
669
|
-
FROM
|
|
670
|
-
updated
|
|
671
|
-
UNION ALL
|
|
672
|
-
SELECT
|
|
673
|
-
id,
|
|
674
|
-
state,
|
|
675
|
-
new_last_checkpoint AS last_checkpoint,
|
|
676
|
-
last_checkpoint_lsn,
|
|
677
|
-
snapshot_done,
|
|
678
|
-
no_checkpoint_before,
|
|
679
|
-
can_checkpoint,
|
|
680
|
-
keepalive_op,
|
|
681
|
-
new_last_checkpoint,
|
|
682
|
-
FALSE AS created_checkpoint
|
|
683
|
-
FROM
|
|
684
|
-
computed
|
|
685
|
-
WHERE
|
|
686
|
-
NOT EXISTS (
|
|
687
|
-
SELECT
|
|
688
|
-
1
|
|
689
|
-
FROM
|
|
690
|
-
updated
|
|
691
|
-
)
|
|
692
|
-
`
|
|
693
|
-
.decoded(CheckpointWithStatus)
|
|
694
|
-
.first();
|
|
651
|
+
RETURNING
|
|
652
|
+
sr.id,
|
|
653
|
+
sr.state,
|
|
654
|
+
sr.last_checkpoint,
|
|
655
|
+
sr.last_checkpoint_lsn,
|
|
656
|
+
sr.snapshot_done,
|
|
657
|
+
sr.no_checkpoint_before,
|
|
658
|
+
computed.can_checkpoint,
|
|
659
|
+
computed.keepalive_op,
|
|
660
|
+
computed.new_last_checkpoint
|
|
661
|
+
)
|
|
662
|
+
SELECT
|
|
663
|
+
id,
|
|
664
|
+
state,
|
|
665
|
+
last_checkpoint,
|
|
666
|
+
last_checkpoint_lsn,
|
|
667
|
+
snapshot_done,
|
|
668
|
+
no_checkpoint_before,
|
|
669
|
+
can_checkpoint,
|
|
670
|
+
keepalive_op,
|
|
671
|
+
new_last_checkpoint,
|
|
672
|
+
TRUE AS created_checkpoint
|
|
673
|
+
FROM
|
|
674
|
+
updated
|
|
675
|
+
UNION ALL
|
|
676
|
+
SELECT
|
|
677
|
+
id,
|
|
678
|
+
state,
|
|
679
|
+
new_last_checkpoint AS last_checkpoint,
|
|
680
|
+
last_checkpoint_lsn,
|
|
681
|
+
snapshot_done,
|
|
682
|
+
no_checkpoint_before,
|
|
683
|
+
can_checkpoint,
|
|
684
|
+
keepalive_op,
|
|
685
|
+
new_last_checkpoint,
|
|
686
|
+
FALSE AS created_checkpoint
|
|
687
|
+
FROM
|
|
688
|
+
computed
|
|
689
|
+
WHERE
|
|
690
|
+
NOT EXISTS (
|
|
691
|
+
SELECT
|
|
692
|
+
1
|
|
693
|
+
FROM
|
|
694
|
+
updated
|
|
695
|
+
)
|
|
696
|
+
`
|
|
697
|
+
.decoded(CheckpointWithStatus)
|
|
698
|
+
.first();
|
|
699
|
+
|
|
700
|
+
if (checkpoint?.can_checkpoint && checkpoint.state == storage.SyncRuleState.ACTIVE) {
|
|
701
|
+
await notifySyncRulesUpdate(db, checkpoint);
|
|
702
|
+
}
|
|
703
|
+
return checkpoint;
|
|
704
|
+
});
|
|
695
705
|
|
|
696
706
|
if (result == null) {
|
|
697
707
|
throw new ReplicationAssertionError('Failed to update sync_rules during checkpoint');
|
|
@@ -723,10 +733,8 @@ export class PostgresBucketBatch
|
|
|
723
733
|
});
|
|
724
734
|
}
|
|
725
735
|
}
|
|
726
|
-
await this.autoActivate(lsn
|
|
727
|
-
await notifySyncRulesUpdate(this.db, {
|
|
736
|
+
await this.autoActivate(lsn, {
|
|
728
737
|
id: result.id,
|
|
729
|
-
state: result.state,
|
|
730
738
|
last_checkpoint: result.last_checkpoint,
|
|
731
739
|
last_checkpoint_lsn: result.last_checkpoint_lsn
|
|
732
740
|
});
|
|
@@ -1033,10 +1041,8 @@ export class PostgresBucketBatch
|
|
|
1033
1041
|
}
|
|
1034
1042
|
}
|
|
1035
1043
|
|
|
1044
|
+
// The error itself is cleared by the caller, outside the replication transaction - see flushInner.
|
|
1036
1045
|
const clearedError = didFlush && !this.clearedError;
|
|
1037
|
-
if (clearedError) {
|
|
1038
|
-
await this.clearError(db);
|
|
1039
|
-
}
|
|
1040
1046
|
|
|
1041
1047
|
// Don't return empty batches
|
|
1042
1048
|
return {
|
|
@@ -1305,14 +1311,13 @@ export class PostgresBucketBatch
|
|
|
1305
1311
|
*
|
|
1306
1312
|
* Called on new commits.
|
|
1307
1313
|
*/
|
|
1308
|
-
private async autoActivate(lsn: string): Promise<void> {
|
|
1314
|
+
private async autoActivate(lsn: string, checkpoint: models.ActiveCheckpointDecoded): Promise<void> {
|
|
1309
1315
|
if (!this.needsActivation) {
|
|
1310
1316
|
// Already activated
|
|
1311
1317
|
return;
|
|
1312
1318
|
}
|
|
1313
1319
|
|
|
1314
|
-
|
|
1315
|
-
await this.db.transaction(async (db) => {
|
|
1320
|
+
const activationResult = await this.db.transaction(async (db) => {
|
|
1316
1321
|
const syncRulesRow = await db.sql`
|
|
1317
1322
|
SELECT
|
|
1318
1323
|
state,
|
|
@@ -1346,13 +1351,18 @@ export class PostgresBucketBatch
|
|
|
1346
1351
|
)
|
|
1347
1352
|
AND id != ${{ type: 'int4', value: this.group_id }}
|
|
1348
1353
|
`.execute();
|
|
1349
|
-
|
|
1350
|
-
|
|
1354
|
+
await notifySyncRulesUpdate(db, checkpoint);
|
|
1355
|
+
return 'activated';
|
|
1351
1356
|
} else if (syncRulesRow?.state != storage.SyncRuleState.PROCESSING) {
|
|
1352
|
-
|
|
1357
|
+
return 'not-processing';
|
|
1353
1358
|
}
|
|
1359
|
+
return 'pending';
|
|
1354
1360
|
});
|
|
1355
|
-
|
|
1361
|
+
|
|
1362
|
+
if (activationResult != 'pending') {
|
|
1363
|
+
this.needsActivation = false;
|
|
1364
|
+
}
|
|
1365
|
+
if (activationResult == 'activated') {
|
|
1356
1366
|
this.logger.info(`Activated new replication stream at ${lsn}`);
|
|
1357
1367
|
}
|
|
1358
1368
|
}
|
|
@@ -1438,12 +1448,17 @@ export class PostgresBucketBatch
|
|
|
1438
1448
|
* Uses Postgres' NOTIFY functionality to update different processes when the
|
|
1439
1449
|
* active checkpoint has been updated.
|
|
1440
1450
|
*/
|
|
1441
|
-
export const notifySyncRulesUpdate = async (
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1451
|
+
export const notifySyncRulesUpdate = async (
|
|
1452
|
+
db: lib_postgres.AbstractPostgresConnection,
|
|
1453
|
+
update: models.ActiveCheckpointDecoded
|
|
1454
|
+
) => {
|
|
1455
|
+
const payload = models.ActiveCheckpointNotification.encode({ active_checkpoint: update });
|
|
1456
|
+
|
|
1457
|
+
await db.sql`
|
|
1458
|
+
SELECT
|
|
1459
|
+
pg_notify (
|
|
1460
|
+
${{ type: 'varchar', value: NOTIFICATION_CHANNEL }},
|
|
1461
|
+
${{ type: 'varchar', value: payload }}
|
|
1462
|
+
)
|
|
1463
|
+
`.execute();
|
|
1449
1464
|
};
|
|
@@ -34,52 +34,170 @@ export class PostgresWriteCheckpointAPI implements storage.WriteCheckpointAPI {
|
|
|
34
34
|
|
|
35
35
|
async createManagedWriteCheckpoints(
|
|
36
36
|
checkpoints: storage.ManagedWriteCheckpointOptions[]
|
|
37
|
-
): Promise<
|
|
37
|
+
): Promise<storage.CreateManagedWriteCheckpointsResult> {
|
|
38
38
|
if (this.writeCheckpointMode !== storage.WriteCheckpointMode.MANAGED) {
|
|
39
39
|
throw new framework.errors.ValidationError(
|
|
40
40
|
`Attempting to create a managed Write Checkpoint when the current Write Checkpoint mode is set to "${this.writeCheckpointMode}"`
|
|
41
41
|
);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
const uniqueCheckpoints =
|
|
44
|
+
const uniqueCheckpoints = storage.uniqueManagedWriteCheckpoints(checkpoints);
|
|
45
45
|
if (uniqueCheckpoints.length == 0) {
|
|
46
|
-
return new Map();
|
|
46
|
+
return { writeCheckpoints: new Map(), shouldAdvance: false };
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}));
|
|
49
|
+
const writeCheckpoints = new Map<string, bigint>();
|
|
50
|
+
const generatedCheckpoints = uniqueCheckpoints.filter((checkpoint) => checkpoint.checkpoint_request_id == null);
|
|
51
|
+
const suppliedCheckpoints = uniqueCheckpoints.filter((checkpoint) => checkpoint.checkpoint_request_id != null);
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
if (generatedCheckpoints.length > 0) {
|
|
54
|
+
const mappedCheckpoints = generatedCheckpoints.map((checkpoint) => ({
|
|
55
|
+
user_id: checkpoint.user_id,
|
|
56
|
+
lsns: checkpoint.heads
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
const generatedRows = await this.db.sql`
|
|
60
|
+
WITH
|
|
61
|
+
json_data AS (
|
|
62
|
+
SELECT
|
|
63
|
+
CHECKPOINT ->> 'user_id' AS user_id,
|
|
64
|
+
CHECKPOINT -> 'lsns' AS lsns
|
|
65
|
+
FROM
|
|
66
|
+
jsonb_array_elements(${{ type: 'jsonb', value: mappedCheckpoints }}) AS
|
|
67
|
+
CHECKPOINT
|
|
68
|
+
)
|
|
69
|
+
INSERT INTO
|
|
70
|
+
write_checkpoints (
|
|
71
|
+
user_id,
|
|
72
|
+
lsns,
|
|
73
|
+
write_checkpoint,
|
|
74
|
+
checkpoint_requested_at
|
|
75
|
+
)
|
|
76
|
+
SELECT
|
|
77
|
+
user_id,
|
|
78
|
+
lsns,
|
|
79
|
+
1,
|
|
80
|
+
NULL
|
|
81
|
+
FROM
|
|
82
|
+
json_data
|
|
83
|
+
ON CONFLICT (user_id) DO UPDATE
|
|
84
|
+
SET
|
|
85
|
+
write_checkpoint = write_checkpoints.write_checkpoint + 1,
|
|
86
|
+
lsns = EXCLUDED.lsns,
|
|
87
|
+
checkpoint_requested_at = NULL
|
|
88
|
+
RETURNING
|
|
89
|
+
*;
|
|
90
|
+
`
|
|
91
|
+
.decoded(models.WriteCheckpoint)
|
|
92
|
+
.rows();
|
|
93
|
+
|
|
94
|
+
for (const row of generatedRows) {
|
|
95
|
+
writeCheckpoints.set(row.user_id, row.write_checkpoint);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (suppliedCheckpoints.length > 0) {
|
|
100
|
+
// Supplied request ids are monotonic. Greater values update the checkpoint
|
|
101
|
+
// id and heads, while equal retries only refresh the retention timestamp.
|
|
102
|
+
// Stale requests return the stored id without changing the row.
|
|
103
|
+
const mappedCheckpoints = suppliedCheckpoints.map((checkpoint) => ({
|
|
104
|
+
user_id: checkpoint.user_id,
|
|
105
|
+
lsns: checkpoint.heads,
|
|
106
|
+
checkpoint_request_id: String(checkpoint.checkpoint_request_id)
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
const suppliedRows = await this.db.sql`
|
|
110
|
+
WITH
|
|
111
|
+
json_data AS (
|
|
112
|
+
SELECT
|
|
113
|
+
CHECKPOINT ->> 'user_id' AS user_id,
|
|
114
|
+
CHECKPOINT -> 'lsns' AS lsns,
|
|
115
|
+
(
|
|
116
|
+
CHECKPOINT ->> 'checkpoint_request_id'
|
|
117
|
+
)::int8 AS checkpoint_request_id
|
|
118
|
+
FROM
|
|
119
|
+
jsonb_array_elements(${{ type: 'jsonb', value: mappedCheckpoints }}) AS
|
|
120
|
+
CHECKPOINT
|
|
121
|
+
)
|
|
122
|
+
INSERT INTO
|
|
123
|
+
write_checkpoints (
|
|
124
|
+
user_id,
|
|
125
|
+
lsns,
|
|
126
|
+
write_checkpoint,
|
|
127
|
+
checkpoint_requested_at
|
|
128
|
+
)
|
|
129
|
+
SELECT
|
|
130
|
+
user_id,
|
|
131
|
+
lsns,
|
|
132
|
+
checkpoint_request_id,
|
|
133
|
+
NOW()
|
|
134
|
+
FROM
|
|
135
|
+
json_data
|
|
136
|
+
ON CONFLICT (user_id) DO UPDATE
|
|
137
|
+
SET
|
|
138
|
+
write_checkpoint = CASE
|
|
139
|
+
WHEN EXCLUDED.write_checkpoint > write_checkpoints.write_checkpoint THEN EXCLUDED.write_checkpoint
|
|
140
|
+
ELSE write_checkpoints.write_checkpoint
|
|
141
|
+
END,
|
|
142
|
+
lsns = CASE
|
|
143
|
+
WHEN EXCLUDED.write_checkpoint > write_checkpoints.write_checkpoint THEN EXCLUDED.lsns
|
|
144
|
+
ELSE write_checkpoints.lsns
|
|
145
|
+
END,
|
|
146
|
+
checkpoint_requested_at = NOW()
|
|
147
|
+
WHERE
|
|
148
|
+
EXCLUDED.write_checkpoint >= write_checkpoints.write_checkpoint
|
|
149
|
+
RETURNING
|
|
150
|
+
*;
|
|
151
|
+
`
|
|
152
|
+
.decoded(models.WriteCheckpoint)
|
|
153
|
+
.rows();
|
|
154
|
+
|
|
155
|
+
for (const row of suppliedRows) {
|
|
156
|
+
writeCheckpoints.set(row.user_id, row.write_checkpoint);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// RETURNING only excludes stale requests rejected by the monotonic
|
|
160
|
+
// conflict condition. Those requests still need the stored id.
|
|
161
|
+
const returnedSuppliedUserIds = new Set(suppliedRows.map((row) => row.user_id));
|
|
162
|
+
const unchangedUserIds = suppliedCheckpoints
|
|
163
|
+
.map((checkpoint) => checkpoint.user_id)
|
|
164
|
+
.filter((userId) => !returnedSuppliedUserIds.has(userId));
|
|
165
|
+
|
|
166
|
+
if (unchangedUserIds.length > 0) {
|
|
167
|
+
const mappedUserIds = unchangedUserIds.map((user_id) => ({ user_id }));
|
|
168
|
+
const unchangedRows = await this.db.sql`
|
|
169
|
+
WITH
|
|
170
|
+
json_data AS (
|
|
171
|
+
SELECT
|
|
172
|
+
CHECKPOINT ->> 'user_id' AS user_id
|
|
173
|
+
FROM
|
|
174
|
+
jsonb_array_elements(${{ type: 'jsonb', value: mappedUserIds }}) AS
|
|
175
|
+
CHECKPOINT
|
|
176
|
+
)
|
|
57
177
|
SELECT
|
|
58
|
-
|
|
59
|
-
CHECKPOINT -> 'lsns' AS lsns
|
|
178
|
+
write_checkpoints.*
|
|
60
179
|
FROM
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
ON CONFLICT (user_id) DO UPDATE
|
|
73
|
-
SET
|
|
74
|
-
write_checkpoint = write_checkpoints.write_checkpoint + 1,
|
|
75
|
-
lsns = EXCLUDED.lsns
|
|
76
|
-
RETURNING
|
|
77
|
-
*;
|
|
78
|
-
`
|
|
79
|
-
.decoded(models.WriteCheckpoint)
|
|
80
|
-
.rows();
|
|
180
|
+
write_checkpoints
|
|
181
|
+
JOIN json_data ON write_checkpoints.user_id = json_data.user_id;
|
|
182
|
+
`
|
|
183
|
+
.decoded(models.WriteCheckpoint)
|
|
184
|
+
.rows();
|
|
185
|
+
|
|
186
|
+
for (const row of unchangedRows) {
|
|
187
|
+
writeCheckpoints.set(row.user_id, row.write_checkpoint);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
81
191
|
|
|
82
|
-
|
|
192
|
+
// Postgres storage does not track a per-row processed indicator: a write
|
|
193
|
+
// checkpoint is considered processed at read time by comparing its lsns
|
|
194
|
+
// against the replicated head (see lastManagedWriteCheckpoint). We therefore
|
|
195
|
+
// force the source marker whenever any checkpoint was matched, which also
|
|
196
|
+
// covers stale or duplicate requests whose stored checkpoint may still be
|
|
197
|
+
// pending. Forcing a marker for an already-processed checkpoint is wasteful
|
|
198
|
+
// but harmless.
|
|
199
|
+
const shouldAdvance = generatedCheckpoints.length > 0 || suppliedCheckpoints.length > 0;
|
|
200
|
+
return { writeCheckpoints, shouldAdvance };
|
|
83
201
|
}
|
|
84
202
|
|
|
85
203
|
async lastWriteCheckpoint(filters: storage.LastWriteCheckpointFilters): Promise<bigint | null> {
|
|
@@ -155,7 +273,8 @@ export async function batchCreateCustomWriteCheckpoints(
|
|
|
155
273
|
// Cannot encode bigint directly using JSON.stringify.
|
|
156
274
|
// The ::int8 in the query below will take care of casting back to a number
|
|
157
275
|
checkpoint: String(cp.checkpoint),
|
|
158
|
-
sync_rules_id: cp.sync_rules_id
|
|
276
|
+
sync_rules_id: cp.sync_rules_id,
|
|
277
|
+
checkpoint_requested_at: cp.checkpoint_requested_at?.toISOString() ?? null
|
|
159
278
|
};
|
|
160
279
|
});
|
|
161
280
|
|
|
@@ -167,7 +286,12 @@ export async function batchCreateCustomWriteCheckpoints(
|
|
|
167
286
|
CHECKPOINT
|
|
168
287
|
)
|
|
169
288
|
INSERT INTO
|
|
170
|
-
custom_write_checkpoints (
|
|
289
|
+
custom_write_checkpoints (
|
|
290
|
+
user_id,
|
|
291
|
+
write_checkpoint,
|
|
292
|
+
sync_rules_id,
|
|
293
|
+
checkpoint_requested_at
|
|
294
|
+
)
|
|
171
295
|
SELECT
|
|
172
296
|
CHECKPOINT ->> 'user_id'::varchar,
|
|
173
297
|
(
|
|
@@ -175,11 +299,15 @@ export async function batchCreateCustomWriteCheckpoints(
|
|
|
175
299
|
)::int8,
|
|
176
300
|
(
|
|
177
301
|
CHECKPOINT ->> 'sync_rules_id'
|
|
178
|
-
)::int4
|
|
302
|
+
)::int4,
|
|
303
|
+
(
|
|
304
|
+
CHECKPOINT ->> 'checkpoint_requested_at'
|
|
305
|
+
)::timestamptz
|
|
179
306
|
FROM
|
|
180
307
|
json_data
|
|
181
308
|
ON CONFLICT (user_id, sync_rules_id) DO UPDATE
|
|
182
309
|
SET
|
|
183
|
-
write_checkpoint = EXCLUDED.write_checkpoint
|
|
310
|
+
write_checkpoint = EXCLUDED.write_checkpoint,
|
|
311
|
+
checkpoint_requested_at = EXCLUDED.checkpoint_requested_at;
|
|
184
312
|
`.execute();
|
|
185
313
|
}
|