@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.d.ts +3 -0
  3. package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.js +42 -0
  4. package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.js.map +1 -0
  5. package/dist/storage/PostgresBucketStorageFactory.js +0 -2
  6. package/dist/storage/PostgresBucketStorageFactory.js.map +1 -1
  7. package/dist/storage/PostgresCompactor.d.ts +2 -0
  8. package/dist/storage/PostgresCompactor.js +20 -0
  9. package/dist/storage/PostgresCompactor.js.map +1 -1
  10. package/dist/storage/PostgresSyncRulesStorage.d.ts +16 -1
  11. package/dist/storage/PostgresSyncRulesStorage.js +200 -131
  12. package/dist/storage/PostgresSyncRulesStorage.js.map +1 -1
  13. package/dist/storage/batch/PostgresBucketBatch.d.ts +2 -20
  14. package/dist/storage/batch/PostgresBucketBatch.js +162 -151
  15. package/dist/storage/batch/PostgresBucketBatch.js.map +1 -1
  16. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.d.ts +1 -1
  17. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js +156 -36
  18. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js.map +1 -1
  19. package/dist/storage/current-data-store.d.ts +8 -2
  20. package/dist/storage/current-data-store.js +66 -11
  21. package/dist/storage/current-data-store.js.map +1 -1
  22. package/dist/types/models/WriteCheckpoint.d.ts +2 -0
  23. package/dist/types/models/WriteCheckpoint.js +5 -2
  24. package/dist/types/models/WriteCheckpoint.js.map +1 -1
  25. package/dist/utils/checkpoints.d.ts +9 -0
  26. package/dist/utils/checkpoints.js +26 -0
  27. package/dist/utils/checkpoints.js.map +1 -0
  28. package/package.json +8 -8
  29. package/src/migrations/scripts/1782950400000-checkpoint-requested-at.ts +51 -0
  30. package/src/storage/PostgresBucketStorageFactory.ts +0 -3
  31. package/src/storage/PostgresCompactor.ts +24 -0
  32. package/src/storage/PostgresSyncRulesStorage.ts +219 -137
  33. package/src/storage/batch/PostgresBucketBatch.ts +169 -154
  34. package/src/storage/checkpoints/PostgresWriteCheckpointAPI.ts +165 -37
  35. package/src/storage/current-data-store.ts +66 -11
  36. package/src/types/models/WriteCheckpoint.ts +5 -2
  37. package/src/utils/checkpoints.ts +31 -0
  38. package/test/src/__snapshots__/storage_sync.test.ts.snap +0 -582
  39. package/test/src/checkpoint_notifications.test.ts +522 -0
  40. package/test/src/storage.test.ts +214 -5
  41. package/test/src/storage_compacting.test.ts +3 -3
  42. package/test/src/storage_sync.test.ts +4 -4
  43. package/test/tsconfig.json +1 -1
  44. 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 { StatementParam } from '@powersync/service-jpgwire';
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(checkpoints: storage.ManagedWriteCheckpointOptions[]): Promise<Map<string, bigint>> {
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 filters = dataBuckets.map((request) => ({ bucket_name: request.bucket, start: request.start }));
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
- * It is possible to perform this query with JSONB join. e.g.
343
- * ```sql
344
- * WITH
345
- * filter_data AS (
346
- * SELECT
347
- * FILTER ->> 'bucket_name' AS bucket_name,
348
- * (FILTER ->> 'start')::BIGINT AS start_op_id
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
- SELECT
376
- *
377
- FROM
378
- bucket_data
379
- WHERE
380
- group_id = $1
381
- and op_id <= $2
382
- and (
383
- ${filters.map((f, index) => `(bucket_name = $${index * 2 + 4} and op_id > $${index * 2 + 5})`).join(' OR ')}
384
- )
385
- ORDER BY
386
- bucket_name ASC,
387
- op_id ASC
388
- LIMIT
389
- $3;`,
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
- ...filters.flatMap((f) => [
395
- { type: 'varchar' as const, value: f.bucket_name },
396
- { type: 'int8' as const, value: f.start } satisfies StatementParam
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
- // TODO: Cleanly abort the cleanup when the provided signal is aborted.
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
- await this.db.sql`
551
- DELETE FROM bucket_data
552
- WHERE
553
- group_id = ${{ type: 'int4', value: this.replicationStreamId }}
554
- `.execute();
555
-
556
- await this.db.sql`
557
- DELETE FROM bucket_parameters
558
- WHERE
559
- group_id = ${{ type: 'int4', value: this.replicationStreamId }}
560
- `.execute();
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
- await this.currentDataStore.deleteGroupRows(this.db, { groupId: this.replicationStreamId });
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
- await this.db.sql`
565
- DELETE FROM source_tables
566
- WHERE
567
- group_id = ${{ type: 'int4', value: this.replicationStreamId }}
568
- `.execute();
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 activeCheckpoint = await this.db.sql`
638
- SELECT
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 doc = await this.db.sql`
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
- notification: (notification) => sink.write(notification.payload)
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
- signal.addEventListener('aborted', async () => {
729
- disposeListener();
730
- sink.end();
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
- yield this.makeActiveCheckpoint(doc);
770
+ yield initialCheckpoint;
734
771
 
735
- let lastOp: storage.ReplicationCheckpoint | null = null;
736
- for await (const payload of sink.withSignal(signal)) {
737
- if (signal.aborted) {
738
- return;
739
- }
772
+ for await (const payload of sink.withSignal(signal)) {
773
+ if (signal.aborted) {
774
+ return;
775
+ }
740
776
 
741
- const notification = models.ActiveCheckpointNotification.decode(payload);
742
- if (notification.active_checkpoint == null) {
743
- continue;
744
- }
745
- if (Number(notification.active_checkpoint.id) != doc.id) {
746
- // Active replication stream changed - abort and restart the stream
747
- break;
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
- const activeCheckpoint = this.makeActiveCheckpoint(notification.active_checkpoint);
802
+ if (baseActiveCheckpoint.id != initialCheckpointDocument.id) {
803
+ // Active replication stream changed - abort and restart the stream
804
+ break;
805
+ }
751
806
 
752
- if (lastOp == null || activeCheckpoint.lsn != lastOp.lsn || activeCheckpoint.checkpoint != lastOp.checkpoint) {
753
- lastOp = activeCheckpoint;
754
- yield activeCheckpoint;
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
+ }