@powersync/service-module-postgres-storage 0.16.3 → 0.18.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 +60 -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/migrations/scripts/1784900000000-source-metadata.d.ts +3 -0
- package/dist/migrations/scripts/1784900000000-source-metadata.js +22 -0
- package/dist/migrations/scripts/1784900000000-source-metadata.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 +217 -240
- 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/SourceTable.d.ts +7 -2
- package/dist/types/models/SourceTable.js +6 -2
- package/dist/types/models/SourceTable.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/migrations/scripts/1784900000000-source-metadata.ts +31 -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 +227 -246
- package/src/storage/checkpoints/PostgresWriteCheckpointAPI.ts +165 -37
- package/src/storage/current-data-store.ts +66 -11
- package/src/types/models/SourceTable.ts +7 -2
- 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 +48 -4
- package/test/tsconfig.json +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -28,9 +28,11 @@ import { replicaIdToSubkey } from '../utils/bson.js';
|
|
|
28
28
|
import { mapOpEntry } from '../utils/bucket-data.js';
|
|
29
29
|
|
|
30
30
|
import * as framework from '@powersync/lib-services-framework';
|
|
31
|
-
import {
|
|
31
|
+
import type { Statement } from '@powersync/service-jpgwire';
|
|
32
32
|
import { wrapWithAbort } from 'ix/asynciterable/operators/withabort.js';
|
|
33
33
|
import * as t from 'ts-codec';
|
|
34
|
+
import { ActiveCheckpointDecoded } from '../types/models/ActiveCheckpoint.js';
|
|
35
|
+
import * as checkpointUtils from '../utils/checkpoints.js';
|
|
34
36
|
import { pick } from '../utils/ts-codec.js';
|
|
35
37
|
import { PostgresBucketBatch } from './batch/PostgresBucketBatch.js';
|
|
36
38
|
import { PostgresWriteCheckpointAPI } from './checkpoints/PostgresWriteCheckpointAPI.js';
|
|
@@ -47,6 +49,11 @@ export type PostgresSyncRulesStorageOptions = {
|
|
|
47
49
|
checksumCacheTtlMs?: number;
|
|
48
50
|
};
|
|
49
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Number of rows deleted per batch when clearing storage for a replication stream.
|
|
54
|
+
*/
|
|
55
|
+
export const CLEAR_BATCH_LIMIT = 50_000;
|
|
56
|
+
|
|
50
57
|
export class PostgresSyncRulesStorage
|
|
51
58
|
extends framework.BaseObserver<storage.SyncRulesBucketStorageListener>
|
|
52
59
|
implements storage.SyncRulesBucketStorage
|
|
@@ -169,7 +176,9 @@ export class PostgresSyncRulesStorage
|
|
|
169
176
|
return this.writeCheckpointAPI.setWriteCheckpointMode(mode);
|
|
170
177
|
}
|
|
171
178
|
|
|
172
|
-
createManagedWriteCheckpoints(
|
|
179
|
+
createManagedWriteCheckpoints(
|
|
180
|
+
checkpoints: storage.ManagedWriteCheckpointOptions[]
|
|
181
|
+
): Promise<storage.CreateManagedWriteCheckpointsResult> {
|
|
173
182
|
return this.writeCheckpointAPI.createManagedWriteCheckpoints(checkpoints);
|
|
174
183
|
}
|
|
175
184
|
|
|
@@ -327,7 +336,7 @@ export class PostgresSyncRulesStorage
|
|
|
327
336
|
// not match up with chunks.
|
|
328
337
|
|
|
329
338
|
const end = checkpoint.checkpoint ?? BIGINT_MAX;
|
|
330
|
-
const
|
|
339
|
+
const sortedBuckets = [...dataBuckets].sort((a, b) => a.bucket.localeCompare(b.bucket));
|
|
331
340
|
const startOpByBucket = new Map(dataBuckets.map((request) => [request.bucket, request.start]));
|
|
332
341
|
|
|
333
342
|
const batchRowLimit = options?.limit ?? storage.DEFAULT_DOCUMENT_BATCH_LIMIT;
|
|
@@ -338,65 +347,66 @@ export class PostgresSyncRulesStorage
|
|
|
338
347
|
let targetOp: InternalOpId | null = null;
|
|
339
348
|
let batchRowCount = 0;
|
|
340
349
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
* FROM
|
|
350
|
-
* jsonb_array_elements($1::jsonb) AS FILTER
|
|
351
|
-
* )
|
|
352
|
-
* SELECT
|
|
353
|
-
* b.*,
|
|
354
|
-
* octet_length(b.data) AS data_size
|
|
355
|
-
* FROM
|
|
356
|
-
* bucket_data b
|
|
357
|
-
* JOIN filter_data f ON b.bucket_name = f.bucket_name
|
|
358
|
-
* AND b.op_id > f.start_op_id
|
|
359
|
-
* AND b.op_id <= $2
|
|
360
|
-
* WHERE
|
|
361
|
-
* b.group_id = $3
|
|
362
|
-
* ORDER BY
|
|
363
|
-
* b.bucket_name ASC,
|
|
364
|
-
* b.op_id ASC
|
|
365
|
-
* LIMIT
|
|
366
|
-
* $4;
|
|
367
|
-
* ```
|
|
368
|
-
* Which might be better for large volumes of buckets, but in testing the JSON method
|
|
369
|
-
* was significantly slower than the method below. Syncing 2.5 million rows in a single
|
|
370
|
-
* bucket takes 2 minutes and 11 seconds with the method below. With the JSON method
|
|
371
|
-
* 1 million rows were only synced before a 5 minute timeout.
|
|
372
|
-
*/
|
|
373
|
-
for await (const rows of this.db.streamRows({
|
|
350
|
+
const requestedBuckets = sortedBuckets
|
|
351
|
+
.map((_, index) => `($${index * 2 + 4}, $${index * 2 + 5}, ${index})`)
|
|
352
|
+
.join(', ');
|
|
353
|
+
|
|
354
|
+
// Use one round trip while keeping a parameterized index range scan per bucket. Ordering the non-flattened request
|
|
355
|
+
// subquery gives Postgres a presorted bucket key, allowing an incremental sort and the outer limit to stop the
|
|
356
|
+
// nested loop early. The inner limit cannot exclude rows needed by the outer batch because both limits are equal.
|
|
357
|
+
const query: Statement = {
|
|
374
358
|
statement: `
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
359
|
+
SELECT
|
|
360
|
+
bucket_data.*
|
|
361
|
+
FROM
|
|
362
|
+
(
|
|
363
|
+
SELECT
|
|
364
|
+
*
|
|
365
|
+
FROM
|
|
366
|
+
(
|
|
367
|
+
VALUES ${requestedBuckets}
|
|
368
|
+
) AS bucket_requests(bucket_name, start_op_id, bucket_order)
|
|
369
|
+
-- Present requests in bucket order so the final sort can be incremental.
|
|
370
|
+
ORDER BY
|
|
371
|
+
bucket_order ASC
|
|
372
|
+
-- Prevent the planner from flattening away the ordered request path.
|
|
373
|
+
OFFSET
|
|
374
|
+
0
|
|
375
|
+
) AS requested
|
|
376
|
+
CROSS JOIN LATERAL (
|
|
377
|
+
SELECT
|
|
378
|
+
*
|
|
379
|
+
FROM
|
|
380
|
+
bucket_data
|
|
381
|
+
WHERE
|
|
382
|
+
group_id = $1
|
|
383
|
+
AND bucket_name = requested.bucket_name
|
|
384
|
+
AND op_id > requested.start_op_id
|
|
385
|
+
AND op_id <= $2
|
|
386
|
+
-- Keep each bucket's index range scan ordered and bounded.
|
|
387
|
+
ORDER BY
|
|
388
|
+
op_id ASC
|
|
389
|
+
LIMIT
|
|
390
|
+
$3
|
|
391
|
+
) AS bucket_data
|
|
392
|
+
-- Guarantee contiguous bucket chunks while allowing the presorted bucket key to stop scans early.
|
|
393
|
+
ORDER BY
|
|
394
|
+
requested.bucket_order ASC,
|
|
395
|
+
bucket_data.op_id ASC
|
|
396
|
+
LIMIT
|
|
397
|
+
$3;`,
|
|
390
398
|
params: [
|
|
391
399
|
{ type: 'int4', value: this.replicationStreamId },
|
|
392
400
|
{ type: 'int8', value: end },
|
|
393
401
|
{ type: 'int4', value: batchRowLimit },
|
|
394
|
-
...
|
|
395
|
-
{ type: 'varchar' as const, value:
|
|
396
|
-
{ type: 'int8' as const, value:
|
|
402
|
+
...sortedBuckets.flatMap((request) => [
|
|
403
|
+
{ type: 'varchar' as const, value: request.bucket },
|
|
404
|
+
{ type: 'int8' as const, value: request.start }
|
|
397
405
|
])
|
|
398
406
|
]
|
|
399
|
-
}
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
for await (const rows of this.db.streamRows(query)) {
|
|
400
410
|
const decodedRows = rows.map((r) => models.BucketData.decode(r as any));
|
|
401
411
|
|
|
402
412
|
for (const row of decodedRows) {
|
|
@@ -535,7 +545,11 @@ export class PostgresSyncRulesStorage
|
|
|
535
545
|
}
|
|
536
546
|
|
|
537
547
|
async clear(options?: storage.ClearStorageOptions): Promise<void> {
|
|
538
|
-
|
|
548
|
+
const signal = options?.signal;
|
|
549
|
+
if (signal?.aborted) {
|
|
550
|
+
throw new framework.ReplicationAbortedError('Aborted clearing data', signal.reason);
|
|
551
|
+
}
|
|
552
|
+
|
|
539
553
|
await this.db.sql`
|
|
540
554
|
UPDATE sync_rules
|
|
541
555
|
SET
|
|
@@ -547,25 +561,68 @@ export class PostgresSyncRulesStorage
|
|
|
547
561
|
id = ${{ type: 'int4', value: this.replicationStreamId }}
|
|
548
562
|
`.execute();
|
|
549
563
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
await this.
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
564
|
+
// Delete in batches - a single DELETE covering the entire group can run for
|
|
565
|
+
// hours on large deployments, never completing once it exceeds statement or
|
|
566
|
+
// socket timeouts. Each batch is its own autocommit statement, so progress
|
|
567
|
+
// is durable and a retry continues where the previous attempt stopped.
|
|
568
|
+
await this.clearBatched('bucket_data', signal, () => this.deleteGroupBatch('bucket_data'));
|
|
569
|
+
await this.clearBatched('bucket_parameters', signal, () => this.deleteGroupBatch('bucket_parameters'));
|
|
570
|
+
await this.clearBatched('current_data', signal, () =>
|
|
571
|
+
this.currentDataStore.deleteGroupRowsBatch(this.db, {
|
|
572
|
+
groupId: this.replicationStreamId,
|
|
573
|
+
limit: CLEAR_BATCH_LIMIT
|
|
574
|
+
})
|
|
575
|
+
);
|
|
576
|
+
await this.clearBatched('source_tables', signal, () => this.deleteGroupBatch('source_tables'));
|
|
577
|
+
}
|
|
561
578
|
|
|
562
|
-
|
|
579
|
+
private async clearBatched(
|
|
580
|
+
label: string,
|
|
581
|
+
signal: AbortSignal | undefined,
|
|
582
|
+
deleteBatch: () => Promise<bigint>
|
|
583
|
+
): Promise<void> {
|
|
584
|
+
while (true) {
|
|
585
|
+
if (signal?.aborted) {
|
|
586
|
+
throw new framework.ReplicationAbortedError('Aborted clearing data', signal.reason);
|
|
587
|
+
}
|
|
588
|
+
const count = await deleteBatch();
|
|
589
|
+
if (count < CLEAR_BATCH_LIMIT) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
this.logger.info(`Cleared batch of ${count} ${label} rows, continuing...`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
563
595
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
596
|
+
/**
|
|
597
|
+
* Delete up to {@link CLEAR_BATCH_LIMIT} rows for this group from the given table,
|
|
598
|
+
* returning the number of candidate rows found by the scan.
|
|
599
|
+
*
|
|
600
|
+
* The count is taken from the scan (`batch`) rather than the delete: with a
|
|
601
|
+
* concurrent process deleting the same rows, the delete may remove fewer rows
|
|
602
|
+
* than the scan found, and counting deleted rows could stop the loop while
|
|
603
|
+
* rows remain.
|
|
604
|
+
*/
|
|
605
|
+
private async deleteGroupBatch(table: 'bucket_data' | 'bucket_parameters' | 'source_tables'): Promise<bigint> {
|
|
606
|
+
const [row] = await this.db.queryRows<{ count: bigint }>({
|
|
607
|
+
statement: `
|
|
608
|
+
WITH batch AS (
|
|
609
|
+
SELECT ctid FROM ${table}
|
|
610
|
+
WHERE group_id = $1
|
|
611
|
+
LIMIT $2
|
|
612
|
+
),
|
|
613
|
+
deleted AS (
|
|
614
|
+
DELETE FROM ${table}
|
|
615
|
+
WHERE ctid IN (SELECT ctid FROM batch)
|
|
616
|
+
RETURNING 1
|
|
617
|
+
)
|
|
618
|
+
SELECT COUNT(*) AS count FROM batch
|
|
619
|
+
`,
|
|
620
|
+
params: [
|
|
621
|
+
{ type: 'int4', value: this.replicationStreamId },
|
|
622
|
+
{ type: 'int4', value: CLEAR_BATCH_LIMIT }
|
|
623
|
+
]
|
|
624
|
+
});
|
|
625
|
+
return row?.count ?? 0n;
|
|
569
626
|
}
|
|
570
627
|
|
|
571
628
|
private async getChecksumsInternal(batch: storage.FetchPartialBucketChecksum[]): Promise<storage.PartialChecksumMap> {
|
|
@@ -634,25 +691,8 @@ export class PostgresSyncRulesStorage
|
|
|
634
691
|
}
|
|
635
692
|
|
|
636
693
|
async getActiveCheckpoint(): Promise<storage.ReplicationCheckpoint> {
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
id,
|
|
640
|
-
last_checkpoint,
|
|
641
|
-
last_checkpoint_lsn
|
|
642
|
-
FROM
|
|
643
|
-
sync_rules
|
|
644
|
-
WHERE
|
|
645
|
-
state = ${{ value: storage.SyncRuleState.ACTIVE, type: 'varchar' }}
|
|
646
|
-
OR state = ${{ value: storage.SyncRuleState.ERRORED, type: 'varchar' }}
|
|
647
|
-
ORDER BY
|
|
648
|
-
id DESC
|
|
649
|
-
LIMIT
|
|
650
|
-
1
|
|
651
|
-
`
|
|
652
|
-
.decoded(models.ActiveCheckpoint)
|
|
653
|
-
.first();
|
|
654
|
-
|
|
655
|
-
return this.makeActiveCheckpoint(activeCheckpoint);
|
|
694
|
+
const activeCheckpointDocument = await checkpointUtils.getActiveCheckpointDocument({ db: this.db });
|
|
695
|
+
return this.makeActiveCheckpoint(activeCheckpointDocument);
|
|
656
696
|
}
|
|
657
697
|
|
|
658
698
|
async *watchCheckpointChanges(options: WatchWriteCheckpointOptions): AsyncIterable<storage.StorageCheckpointUpdate> {
|
|
@@ -698,61 +738,92 @@ export class PostgresSyncRulesStorage
|
|
|
698
738
|
}
|
|
699
739
|
|
|
700
740
|
protected async *watchActiveCheckpoint(signal: AbortSignal): AsyncIterable<storage.ReplicationCheckpoint> {
|
|
701
|
-
const
|
|
702
|
-
SELECT
|
|
703
|
-
id,
|
|
704
|
-
last_checkpoint,
|
|
705
|
-
last_checkpoint_lsn
|
|
706
|
-
FROM
|
|
707
|
-
sync_rules
|
|
708
|
-
WHERE
|
|
709
|
-
state = ${{ value: storage.SyncRuleState.ACTIVE, type: 'varchar' }}
|
|
710
|
-
OR state = ${{ value: storage.SyncRuleState.ERRORED, type: 'varchar' }}
|
|
711
|
-
LIMIT
|
|
712
|
-
1
|
|
713
|
-
`
|
|
714
|
-
.decoded(models.ActiveCheckpoint)
|
|
715
|
-
.first();
|
|
716
|
-
|
|
717
|
-
if (doc == null) {
|
|
718
|
-
// Abort the connections - clients will have to retry later.
|
|
719
|
-
throw new framework.ServiceError(framework.ErrorCode.PSYNC_S2302, 'No active replication stream available');
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
const sink = new LastValueSink<string>(undefined);
|
|
741
|
+
const sink = new LastValueSink<string | null>(null);
|
|
723
742
|
|
|
743
|
+
// Listen for changes before reading the initial doc
|
|
724
744
|
const disposeListener = this.db.registerListener({
|
|
725
|
-
|
|
745
|
+
notificationEvent: (event) => {
|
|
746
|
+
switch (event.type) {
|
|
747
|
+
case 'notification':
|
|
748
|
+
sink.write(event.notification.payload);
|
|
749
|
+
break;
|
|
750
|
+
case 'channels-registered':
|
|
751
|
+
// Add a null value to the stream, indicating the loop should query the value.
|
|
752
|
+
sink.write(null);
|
|
753
|
+
break;
|
|
754
|
+
case 'connection-error':
|
|
755
|
+
// End the watcher after reconnect attempts are exhausted. Consumers will
|
|
756
|
+
// retry the watcher, which registers a new listener and pokes the slot.
|
|
757
|
+
sink.error(event.error);
|
|
758
|
+
break;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
726
761
|
});
|
|
727
762
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
763
|
+
try {
|
|
764
|
+
const initialCheckpointDocument = requireActiveCheckpointDocument(
|
|
765
|
+
await checkpointUtils.getActiveCheckpointDocument({ db: this.db })
|
|
766
|
+
);
|
|
767
|
+
const initialCheckpoint = this.makeActiveCheckpoint(initialCheckpointDocument);
|
|
768
|
+
let lastOp = initialCheckpoint;
|
|
732
769
|
|
|
733
|
-
|
|
770
|
+
yield initialCheckpoint;
|
|
734
771
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
}
|
|
772
|
+
for await (const payload of sink.withSignal(signal)) {
|
|
773
|
+
if (signal.aborted) {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
740
776
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
777
|
+
let baseActiveCheckpoint: ActiveCheckpointDecoded | null = null;
|
|
778
|
+
if (payload == null) {
|
|
779
|
+
// Reconnected (or manually triggered) - re-query the current checkpoint.
|
|
780
|
+
// Unlike the initial read, a missing document here (e.g. sync rules being
|
|
781
|
+
// replaced) must not abort the stream: keep waiting for the next notification.
|
|
782
|
+
baseActiveCheckpoint = await checkpointUtils.getActiveCheckpointDocument({ db: this.db });
|
|
783
|
+
if (baseActiveCheckpoint == null) {
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
} else {
|
|
787
|
+
let notification: models.ActiveCheckpointNotificationDecoded;
|
|
788
|
+
try {
|
|
789
|
+
notification = models.ActiveCheckpointNotification.decode(payload);
|
|
790
|
+
} catch (error) {
|
|
791
|
+
// A malformed payload must not abort the shared stream for every
|
|
792
|
+
// subscriber. Skip it and wait for the next notification.
|
|
793
|
+
this.logger.warn('Failed to decode active checkpoint notification, ignoring', error);
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
if (notification.active_checkpoint == null) {
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
baseActiveCheckpoint = notification.active_checkpoint;
|
|
800
|
+
}
|
|
749
801
|
|
|
750
|
-
|
|
802
|
+
if (baseActiveCheckpoint.id != initialCheckpointDocument.id) {
|
|
803
|
+
// Active replication stream changed - abort and restart the stream
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
751
806
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
807
|
+
const activeCheckpoint = this.makeActiveCheckpoint(baseActiveCheckpoint);
|
|
808
|
+
|
|
809
|
+
const checkpointAdvanced = activeCheckpoint.checkpoint > lastOp.checkpoint;
|
|
810
|
+
const checkpointRegressed = activeCheckpoint.checkpoint < lastOp.checkpoint;
|
|
811
|
+
const lsnAdvanced =
|
|
812
|
+
lastOp.lsn == null
|
|
813
|
+
? activeCheckpoint.lsn != null
|
|
814
|
+
: activeCheckpoint.lsn != null && activeCheckpoint.lsn > lastOp.lsn;
|
|
815
|
+
const lsnRegressed = lastOp.lsn != null && (activeCheckpoint.lsn == null || activeCheckpoint.lsn < lastOp.lsn);
|
|
816
|
+
|
|
817
|
+
// Notifications buffered before a query or reconnect may be stale. Neither
|
|
818
|
+
// coordinate may regress, but only one needs to advance: an empty checkpoint
|
|
819
|
+
// updates the LSN while keeping the operation checkpoint unchanged.
|
|
820
|
+
if (!checkpointRegressed && !lsnRegressed && (checkpointAdvanced || lsnAdvanced)) {
|
|
821
|
+
lastOp = activeCheckpoint;
|
|
822
|
+
yield activeCheckpoint;
|
|
823
|
+
}
|
|
755
824
|
}
|
|
825
|
+
} finally {
|
|
826
|
+
disposeListener();
|
|
756
827
|
}
|
|
757
828
|
}
|
|
758
829
|
|
|
@@ -785,3 +856,14 @@ const parameterSetsRow = t.object({
|
|
|
785
856
|
index: bigint,
|
|
786
857
|
bucket_parameters: t.string
|
|
787
858
|
});
|
|
859
|
+
|
|
860
|
+
function requireActiveCheckpointDocument(doc: models.ActiveCheckpointDecoded | null): models.ActiveCheckpointDecoded {
|
|
861
|
+
if (doc == null) {
|
|
862
|
+
// Used for the initial checkpoint read only: with no active replication stream
|
|
863
|
+
// at stream start, fail fast so clients disconnect and retry later. Mid-stream
|
|
864
|
+
// reconnects tolerate a transiently missing document instead of aborting.
|
|
865
|
+
throw new framework.ServiceError(framework.ErrorCode.PSYNC_S2302, 'No active replication stream available');
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
return doc;
|
|
869
|
+
}
|