@powersync/service-module-postgres-storage 0.0.0-dev-20260602073133 → 0.0.0-dev-20260803144812

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 (105) hide show
  1. package/CHANGELOG.md +101 -6
  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/{@types/storage → storage}/PostgresBucketStorageFactory.d.ts +13 -12
  6. package/dist/storage/PostgresBucketStorageFactory.js +59 -55
  7. package/dist/storage/PostgresBucketStorageFactory.js.map +1 -1
  8. package/dist/{@types/storage → storage}/PostgresCompactor.d.ts +3 -1
  9. package/dist/storage/PostgresCompactor.js +21 -1
  10. package/dist/storage/PostgresCompactor.js.map +1 -1
  11. package/dist/storage/PostgresReportStorage.js.map +1 -1
  12. package/dist/storage/PostgresStorageProvider.js +2 -1
  13. package/dist/storage/PostgresStorageProvider.js.map +1 -1
  14. package/dist/{@types/storage → storage}/PostgresSyncRulesStorage.d.ts +36 -11
  15. package/dist/storage/PostgresSyncRulesStorage.js +250 -177
  16. package/dist/storage/PostgresSyncRulesStorage.js.map +1 -1
  17. package/dist/{@types/storage → storage}/batch/PostgresBucketBatch.d.ts +8 -24
  18. package/dist/storage/batch/PostgresBucketBatch.js +229 -194
  19. package/dist/storage/batch/PostgresBucketBatch.js.map +1 -1
  20. package/dist/{@types/storage → storage}/checkpoints/PostgresWriteCheckpointAPI.d.ts +1 -1
  21. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js +161 -24
  22. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js.map +1 -1
  23. package/dist/{@types/storage → storage}/current-data-store.d.ts +8 -2
  24. package/dist/storage/current-data-store.js +66 -11
  25. package/dist/storage/current-data-store.js.map +1 -1
  26. package/dist/{@types/storage → storage}/storage-index.d.ts +1 -0
  27. package/dist/storage/storage-index.js +1 -0
  28. package/dist/storage/storage-index.js.map +1 -1
  29. package/dist/storage/sync-rules/PostgresPersistedSyncConfigContent.d.ts +17 -0
  30. package/dist/storage/sync-rules/PostgresPersistedSyncConfigContent.js +90 -0
  31. package/dist/storage/sync-rules/PostgresPersistedSyncConfigContent.js.map +1 -0
  32. package/dist/{@types/types → types}/models/WriteCheckpoint.d.ts +2 -0
  33. package/dist/types/models/WriteCheckpoint.js +5 -2
  34. package/dist/types/models/WriteCheckpoint.js.map +1 -1
  35. package/dist/utils/checkpoints.d.ts +9 -0
  36. package/dist/utils/checkpoints.js +26 -0
  37. package/dist/utils/checkpoints.js.map +1 -0
  38. package/dist/utils/test-utils.js +2 -2
  39. package/dist/utils/test-utils.js.map +1 -1
  40. package/package.json +10 -22
  41. package/src/migrations/scripts/1782950400000-checkpoint-requested-at.ts +51 -0
  42. package/src/storage/PostgresBucketStorageFactory.ts +67 -58
  43. package/src/storage/PostgresCompactor.ts +25 -1
  44. package/src/storage/PostgresReportStorage.ts +1 -2
  45. package/src/storage/PostgresStorageProvider.ts +2 -1
  46. package/src/storage/PostgresSyncRulesStorage.ts +275 -191
  47. package/src/storage/batch/PostgresBucketBatch.ts +256 -214
  48. package/src/storage/checkpoints/PostgresWriteCheckpointAPI.ts +174 -24
  49. package/src/storage/current-data-store.ts +66 -11
  50. package/src/storage/storage-index.ts +1 -0
  51. package/src/storage/sync-rules/PostgresPersistedSyncConfigContent.ts +98 -0
  52. package/src/types/models/WriteCheckpoint.ts +5 -2
  53. package/src/utils/checkpoints.ts +31 -0
  54. package/src/utils/test-utils.ts +2 -2
  55. package/test/src/__snapshots__/storage_sync.test.ts.snap +0 -582
  56. package/test/src/checkpoint_notifications.test.ts +522 -0
  57. package/test/src/migrations.test.ts +1 -1
  58. package/test/src/storage.test.ts +226 -10
  59. package/test/src/storage_compacting.test.ts +15 -7
  60. package/test/src/storage_sync.test.ts +11 -8
  61. package/test/tsconfig.json +1 -3
  62. package/tsconfig.json +1 -4
  63. package/tsconfig.tsbuildinfo +1 -0
  64. package/dist/.tsbuildinfo +0 -1
  65. package/dist/@types/storage/sync-rules/PostgresPersistedSyncRulesContent.d.ts +0 -9
  66. package/dist/storage/sync-rules/PostgresPersistedSyncRulesContent.js +0 -49
  67. package/dist/storage/sync-rules/PostgresPersistedSyncRulesContent.js.map +0 -1
  68. package/src/storage/sync-rules/PostgresPersistedSyncRulesContent.ts +0 -54
  69. /package/dist/{@types/index.d.ts → index.d.ts} +0 -0
  70. /package/dist/{@types/migrations → migrations}/PostgresMigrationAgent.d.ts +0 -0
  71. /package/dist/{@types/migrations → migrations}/PostgresMigrationStore.d.ts +0 -0
  72. /package/dist/{@types/migrations → migrations}/migration-utils.d.ts +0 -0
  73. /package/dist/{@types/migrations → migrations}/scripts/1684951997326-init.d.ts +0 -0
  74. /package/dist/{@types/migrations → migrations}/scripts/1749024804042-snapshot-progress.d.ts +0 -0
  75. /package/dist/{@types/migrations → migrations}/scripts/1756282360128-connection-reporting.d.ts +0 -0
  76. /package/dist/{@types/migrations → migrations}/scripts/1771232439485-storage-version.d.ts +0 -0
  77. /package/dist/{@types/migrations → migrations}/scripts/1771424826685-current-data-pending-deletes.d.ts +0 -0
  78. /package/dist/{@types/migrations → migrations}/scripts/1771491856000-sync-plan.d.ts +0 -0
  79. /package/dist/{@types/module → module}/PostgresStorageModule.d.ts +0 -0
  80. /package/dist/{@types/storage → storage}/PostgresReportStorage.d.ts +0 -0
  81. /package/dist/{@types/storage → storage}/PostgresStorageProvider.d.ts +0 -0
  82. /package/dist/{@types/storage → storage}/batch/OperationBatch.d.ts +0 -0
  83. /package/dist/{@types/storage → storage}/batch/PostgresPersistedBatch.d.ts +0 -0
  84. /package/dist/{@types/storage → storage}/table-id.d.ts +0 -0
  85. /package/dist/{@types/types → types}/codecs.d.ts +0 -0
  86. /package/dist/{@types/types → types}/models/ActiveCheckpoint.d.ts +0 -0
  87. /package/dist/{@types/types → types}/models/ActiveCheckpointNotification.d.ts +0 -0
  88. /package/dist/{@types/types → types}/models/BucketData.d.ts +0 -0
  89. /package/dist/{@types/types → types}/models/BucketParameters.d.ts +0 -0
  90. /package/dist/{@types/types → types}/models/CurrentData.d.ts +0 -0
  91. /package/dist/{@types/types → types}/models/Instance.d.ts +0 -0
  92. /package/dist/{@types/types → types}/models/Migration.d.ts +0 -0
  93. /package/dist/{@types/types → types}/models/SdkReporting.d.ts +0 -0
  94. /package/dist/{@types/types → types}/models/SourceTable.d.ts +0 -0
  95. /package/dist/{@types/types → types}/models/SyncRules.d.ts +0 -0
  96. /package/dist/{@types/types → types}/models/json.d.ts +0 -0
  97. /package/dist/{@types/types → types}/models/models-index.d.ts +0 -0
  98. /package/dist/{@types/types → types}/types.d.ts +0 -0
  99. /package/dist/{@types/utils → utils}/application-name.d.ts +0 -0
  100. /package/dist/{@types/utils → utils}/bson.d.ts +0 -0
  101. /package/dist/{@types/utils → utils}/bucket-data.d.ts +0 -0
  102. /package/dist/{@types/utils → utils}/db.d.ts +0 -0
  103. /package/dist/{@types/utils → utils}/test-utils.d.ts +0 -0
  104. /package/dist/{@types/utils → utils}/ts-codec.d.ts +0 -0
  105. /package/dist/{@types/utils → utils}/utils-index.d.ts +0 -0
@@ -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';
@@ -41,20 +43,27 @@ import { PostgresCompactor } from './PostgresCompactor.js';
41
43
  export type PostgresSyncRulesStorageOptions = {
42
44
  factory: PostgresBucketStorageFactory;
43
45
  db: lib_postgres.DatabaseClient;
44
- sync_rules: storage.PersistedSyncRulesContent;
46
+ replicationStream: storage.PersistedReplicationStream;
45
47
  write_checkpoint_mode?: storage.WriteCheckpointMode;
46
48
  batchLimits: RequiredOperationBatchLimits;
49
+ checksumCacheTtlMs?: number;
47
50
  };
48
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
+
49
57
  export class PostgresSyncRulesStorage
50
58
  extends framework.BaseObserver<storage.SyncRulesBucketStorageListener>
51
59
  implements storage.SyncRulesBucketStorage
52
60
  {
53
61
  [framework.DO_NOT_LOG] = true;
54
62
 
55
- public readonly group_id: number;
56
- public readonly sync_rules: storage.PersistedSyncRulesContent;
57
- public readonly slot_name: string;
63
+ public readonly replicationStreamId: number;
64
+ public readonly replicationStream: storage.PersistedReplicationStream;
65
+ public readonly sync_rules: storage.PersistedSyncConfigContent;
66
+ public readonly replicationStreamName: string;
58
67
  public readonly factory: PostgresBucketStorageFactory;
59
68
  public readonly storageConfig: StorageVersionConfig;
60
69
  public readonly logger: framework.Logger;
@@ -65,22 +74,27 @@ export class PostgresSyncRulesStorage
65
74
  protected writeCheckpointAPI: PostgresWriteCheckpointAPI;
66
75
  private readonly currentDataStore: PostgresCurrentDataStore;
67
76
 
68
- // TODO we might be able to share this in an abstract class
69
- private parsedSyncRulesCache:
70
- | { parsed: sync_rules.HydratedSyncConfig; options: storage.ParseSyncRulesOptions }
71
- | undefined;
77
+ /**
78
+ * Canonical parsed sync config sets, keyed by defaultSchema. Entries are never evicted,
79
+ * so each parse options value maps to exactly one parsed set for the lifetime of this
80
+ * storage instance.
81
+ *
82
+ * TODO we might be able to share this in an abstract class
83
+ */
84
+ private readonly parsedSyncConfigSets = new Map<string, storage.ParsedSyncConfigSet>();
72
85
  private _checksumCache: storage.ChecksumCache | undefined;
73
86
 
74
87
  constructor(protected options: PostgresSyncRulesStorageOptions) {
75
88
  super();
76
- this.group_id = options.sync_rules.id;
89
+ this.replicationStream = options.replicationStream;
90
+ this.replicationStreamId = options.replicationStream.replicationStreamId;
77
91
  this.db = options.db;
78
- this.sync_rules = options.sync_rules;
79
- this.slot_name = options.sync_rules.slot_name;
92
+ this.sync_rules = options.replicationStream.syncConfigContent[0];
93
+ this.replicationStreamName = options.replicationStream.replicationStreamName;
80
94
  this.factory = options.factory;
81
- this.storageConfig = options.sync_rules.getStorageConfig();
95
+ this.storageConfig = options.replicationStream.getStorageConfig();
82
96
  this.currentDataStore = new PostgresCurrentDataStore(this.storageConfig);
83
- this.logger = options.sync_rules.logger;
97
+ this.logger = options.replicationStream.logger;
84
98
 
85
99
  this.writeCheckpointAPI = new PostgresWriteCheckpointAPI({
86
100
  db: this.db,
@@ -95,6 +109,7 @@ export class PostgresSyncRulesStorage
95
109
  */
96
110
  private get checksumCache(): storage.ChecksumCache {
97
111
  this._checksumCache ??= new storage.ChecksumCache({
112
+ ttlMs: this.options.checksumCacheTtlMs,
98
113
  fetchChecksums: (batch) => {
99
114
  return this.getChecksumsInternal(batch);
100
115
  }
@@ -106,18 +121,17 @@ export class PostgresSyncRulesStorage
106
121
  return this.writeCheckpointAPI.writeCheckpointMode;
107
122
  }
108
123
 
109
- // TODO we might be able to share this in an abstract class
110
- getParsedSyncRules(options: storage.ParseSyncRulesOptions): sync_rules.HydratedSyncConfig {
111
- const { parsed, options: cachedOptions } = this.parsedSyncRulesCache ?? {};
112
- /**
113
- * Check if the cached sync config, if present, had the same options.
114
- * Parse sync config if the options are different or if there is no cached value.
115
- */
116
- if (!parsed || options.defaultSchema != cachedOptions?.defaultSchema) {
117
- this.parsedSyncRulesCache = { parsed: this.sync_rules.parsed(options).hydratedSyncConfig(), options };
124
+ getParsedSyncConfigSet(options: storage.ParseSyncConfigOptions): storage.ParsedSyncConfigSet {
125
+ let parsed = this.parsedSyncConfigSets.get(options.defaultSchema);
126
+ if (parsed == null) {
127
+ parsed = this.replicationStream.parsed(options);
128
+ this.parsedSyncConfigSets.set(options.defaultSchema, parsed);
118
129
  }
130
+ return parsed;
131
+ }
119
132
 
120
- return this.parsedSyncRulesCache!.parsed;
133
+ getParsedSyncRules(options: storage.ParseSyncConfigOptions): sync_rules.HydratedSyncConfig {
134
+ return this.getParsedSyncConfigSet(options).hydratedSyncConfig;
121
135
  }
122
136
 
123
137
  async reportError(e: any): Promise<void> {
@@ -127,7 +141,7 @@ export class PostgresSyncRulesStorage
127
141
  SET
128
142
  last_fatal_error = ${{ type: 'varchar', value: message }}
129
143
  WHERE
130
- id = ${{ type: 'int4', value: this.group_id }};
144
+ id = ${{ type: 'int4', value: this.replicationStreamId }};
131
145
  `.execute();
132
146
  }
133
147
 
@@ -139,7 +153,7 @@ export class PostgresSyncRulesStorage
139
153
  maxOpId = checkpoint.checkpoint;
140
154
  }
141
155
 
142
- return new PostgresCompactor(this.db, this.group_id, {
156
+ return new PostgresCompactor(this.db, this.replicationStreamId, {
143
157
  ...options,
144
158
  maxOpId,
145
159
  logger: this.logger
@@ -154,7 +168,7 @@ export class PostgresSyncRulesStorage
154
168
  lastWriteCheckpoint(filters: storage.SyncStorageLastWriteCheckpointFilters): Promise<bigint | null> {
155
169
  return this.writeCheckpointAPI.lastWriteCheckpoint({
156
170
  ...filters,
157
- sync_rules_id: this.group_id
171
+ sync_rules_id: this.replicationStreamId
158
172
  });
159
173
  }
160
174
 
@@ -162,8 +176,10 @@ export class PostgresSyncRulesStorage
162
176
  return this.writeCheckpointAPI.setWriteCheckpointMode(mode);
163
177
  }
164
178
 
165
- createManagedWriteCheckpoint(checkpoint: storage.ManagedWriteCheckpointOptions): Promise<bigint> {
166
- return this.writeCheckpointAPI.createManagedWriteCheckpoint(checkpoint);
179
+ createManagedWriteCheckpoints(
180
+ checkpoints: storage.ManagedWriteCheckpointOptions[]
181
+ ): Promise<storage.CreateManagedWriteCheckpointsResult> {
182
+ return this.writeCheckpointAPI.createManagedWriteCheckpoints(checkpoints);
167
183
  }
168
184
 
169
185
  async getCheckpoint(): Promise<storage.ReplicationCheckpoint> {
@@ -174,7 +190,7 @@ export class PostgresSyncRulesStorage
174
190
  FROM
175
191
  sync_rules
176
192
  WHERE
177
- id = ${{ type: 'int4', value: this.group_id }}
193
+ id = ${{ type: 'int4', value: this.replicationStreamId }}
178
194
  `
179
195
  .decoded(pick(models.SyncRules, ['last_checkpoint', 'last_checkpoint_lsn']))
180
196
  .first();
@@ -196,7 +212,7 @@ export class PostgresSyncRulesStorage
196
212
  FROM
197
213
  sync_rules
198
214
  WHERE
199
- id = ${{ type: 'int4', value: this.group_id }}
215
+ id = ${{ type: 'int4', value: this.replicationStreamId }}
200
216
  `
201
217
  .decoded(pick(models.SyncRules, ['last_checkpoint_lsn', 'no_checkpoint_before', 'keepalive_op', 'snapshot_lsn']))
202
218
  .first();
@@ -206,9 +222,9 @@ export class PostgresSyncRulesStorage
206
222
  const writer = new PostgresBucketBatch({
207
223
  logger: options.logger ?? this.logger,
208
224
  db: this.db,
209
- sync_rules: this.sync_rules.parsed(options).hydratedSyncConfig(),
210
- group_id: this.group_id,
211
- slot_name: this.slot_name,
225
+ sync_rules: this.getParsedSyncRules(options),
226
+ replicationStreamId: this.replicationStreamId,
227
+ replicationStreamName: this.replicationStreamName,
212
228
  last_checkpoint_lsn: checkpoint_lsn,
213
229
  keep_alive_op: syncRules?.keepalive_op,
214
230
  resumeFromLsn: maxLsn(syncRules?.snapshot_lsn, checkpoint_lsn),
@@ -254,7 +270,7 @@ export class PostgresSyncRulesStorage
254
270
  value: lookups.map((l) => storage.serializeLookupBuffer(l).toString('hex'))
255
271
  }}) WITH ORDINALITY AS requested (value, index)
256
272
  WHERE
257
- group_id = ${{ type: 'int4', value: this.group_id }}
273
+ group_id = ${{ type: 'int4', value: this.replicationStreamId }}
258
274
  AND lookup = decode((requested.value ->> 0)::text, 'hex') -- Decode the hex string to bytea
259
275
  AND id <= ${{ type: 'int8', value: checkpoint.checkpoint }}
260
276
  ORDER BY
@@ -304,7 +320,7 @@ export class PostgresSyncRulesStorage
304
320
  }
305
321
 
306
322
  async *getBucketDataBatch(
307
- checkpoint: InternalOpId,
323
+ checkpoint: storage.ReplicationCheckpoint,
308
324
  dataBuckets: storage.BucketDataRequest[],
309
325
  options?: storage.BucketDataBatchOptions
310
326
  ): AsyncIterable<storage.SyncBucketDataChunk> {
@@ -319,8 +335,8 @@ export class PostgresSyncRulesStorage
319
335
  // Each batch query batch are streamed in separate sets of rows, which may or may
320
336
  // not match up with chunks.
321
337
 
322
- const end = checkpoint ?? BIGINT_MAX;
323
- const filters = dataBuckets.map((request) => ({ bucket_name: request.bucket, start: request.start }));
338
+ const end = checkpoint.checkpoint ?? BIGINT_MAX;
339
+ const sortedBuckets = [...dataBuckets].sort((a, b) => a.bucket.localeCompare(b.bucket));
324
340
  const startOpByBucket = new Map(dataBuckets.map((request) => [request.bucket, request.start]));
325
341
 
326
342
  const batchRowLimit = options?.limit ?? storage.DEFAULT_DOCUMENT_BATCH_LIMIT;
@@ -331,65 +347,66 @@ export class PostgresSyncRulesStorage
331
347
  let targetOp: InternalOpId | null = null;
332
348
  let batchRowCount = 0;
333
349
 
334
- /**
335
- * It is possible to perform this query with JSONB join. e.g.
336
- * ```sql
337
- * WITH
338
- * filter_data AS (
339
- * SELECT
340
- * FILTER ->> 'bucket_name' AS bucket_name,
341
- * (FILTER ->> 'start')::BIGINT AS start_op_id
342
- * FROM
343
- * jsonb_array_elements($1::jsonb) AS FILTER
344
- * )
345
- * SELECT
346
- * b.*,
347
- * octet_length(b.data) AS data_size
348
- * FROM
349
- * bucket_data b
350
- * JOIN filter_data f ON b.bucket_name = f.bucket_name
351
- * AND b.op_id > f.start_op_id
352
- * AND b.op_id <= $2
353
- * WHERE
354
- * b.group_id = $3
355
- * ORDER BY
356
- * b.bucket_name ASC,
357
- * b.op_id ASC
358
- * LIMIT
359
- * $4;
360
- * ```
361
- * Which might be better for large volumes of buckets, but in testing the JSON method
362
- * was significantly slower than the method below. Syncing 2.5 million rows in a single
363
- * bucket takes 2 minutes and 11 seconds with the method below. With the JSON method
364
- * 1 million rows were only synced before a 5 minute timeout.
365
- */
366
- 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 = {
367
358
  statement: `
368
- SELECT
369
- *
370
- FROM
371
- bucket_data
372
- WHERE
373
- group_id = $1
374
- and op_id <= $2
375
- and (
376
- ${filters.map((f, index) => `(bucket_name = $${index * 2 + 4} and op_id > $${index * 2 + 5})`).join(' OR ')}
377
- )
378
- ORDER BY
379
- bucket_name ASC,
380
- op_id ASC
381
- LIMIT
382
- $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;`,
383
398
  params: [
384
- { type: 'int4', value: this.group_id },
399
+ { type: 'int4', value: this.replicationStreamId },
385
400
  { type: 'int8', value: end },
386
401
  { type: 'int4', value: batchRowLimit },
387
- ...filters.flatMap((f) => [
388
- { type: 'varchar' as const, value: f.bucket_name },
389
- { 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 }
390
405
  ])
391
406
  ]
392
- })) {
407
+ };
408
+
409
+ for await (const rows of this.db.streamRows(query)) {
393
410
  const decodedRows = rows.map((r) => models.BucketData.decode(r as any));
394
411
 
395
412
  for (const row of decodedRows) {
@@ -477,10 +494,11 @@ export class PostgresSyncRulesStorage
477
494
  }
478
495
 
479
496
  async getChecksums(
480
- checkpoint: utils.InternalOpId,
481
- buckets: storage.BucketChecksumRequest[]
497
+ checkpoint: storage.ReplicationCheckpoint,
498
+ buckets: storage.BucketChecksumRequest[],
499
+ _options?: storage.BucketChecksumOptions
482
500
  ): Promise<utils.ChecksumMap> {
483
- return this.checksumCache.getChecksumMap(checkpoint, buckets);
501
+ return this.checksumCache.getChecksumMap(checkpoint.checkpoint, buckets);
484
502
  }
485
503
 
486
504
  clearChecksumCache() {
@@ -497,26 +515,23 @@ export class PostgresSyncRulesStorage
497
515
  state = ${{ type: 'varchar', value: storage.SyncRuleState.TERMINATED }},
498
516
  snapshot_done = ${{ type: 'bool', value: false }}
499
517
  WHERE
500
- id = ${{ type: 'int4', value: this.group_id }}
518
+ id = ${{ type: 'int4', value: this.replicationStreamId }}
501
519
  `.execute();
502
520
  }
503
521
 
504
- async getStatus(): Promise<storage.SyncRuleStatus> {
522
+ async getStatus(): Promise<storage.ReplicationStreamStatus> {
505
523
  const syncRulesRow = await this.db.sql`
506
524
  SELECT
507
525
  snapshot_done,
508
526
  snapshot_lsn,
509
527
  last_checkpoint_lsn,
510
- state,
511
- keepalive_op
528
+ state
512
529
  FROM
513
530
  sync_rules
514
531
  WHERE
515
- id = ${{ type: 'int4', value: this.group_id }}
532
+ id = ${{ type: 'int4', value: this.replicationStreamId }}
516
533
  `
517
- .decoded(
518
- pick(models.SyncRules, ['snapshot_done', 'last_checkpoint_lsn', 'state', 'snapshot_lsn', 'keepalive_op'])
519
- )
534
+ .decoded(pick(models.SyncRules, ['snapshot_done', 'last_checkpoint_lsn', 'snapshot_lsn']))
520
535
  .first();
521
536
 
522
537
  if (syncRulesRow == null) {
@@ -524,16 +539,17 @@ export class PostgresSyncRulesStorage
524
539
  }
525
540
 
526
541
  return {
527
- snapshot_done: syncRulesRow.snapshot_done,
528
- active: syncRulesRow.state == storage.SyncRuleState.ACTIVE,
529
- checkpoint_lsn: syncRulesRow.last_checkpoint_lsn ?? null,
530
- snapshot_lsn: syncRulesRow.snapshot_lsn ?? null,
531
- keepalive_op: syncRulesRow.keepalive_op ?? null
542
+ snapshotDone: syncRulesRow.snapshot_done && syncRulesRow.last_checkpoint_lsn != null,
543
+ resumeLsn: maxLsn(syncRulesRow.snapshot_lsn, syncRulesRow.last_checkpoint_lsn)
532
544
  };
533
545
  }
534
546
 
535
547
  async clear(options?: storage.ClearStorageOptions): Promise<void> {
536
- // 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
+
537
553
  await this.db.sql`
538
554
  UPDATE sync_rules
539
555
  SET
@@ -542,28 +558,71 @@ export class PostgresSyncRulesStorage
542
558
  last_checkpoint = NULL,
543
559
  no_checkpoint_before = NULL
544
560
  WHERE
545
- id = ${{ type: 'int4', value: this.group_id }}
546
- `.execute();
547
-
548
- await this.db.sql`
549
- DELETE FROM bucket_data
550
- WHERE
551
- group_id = ${{ type: 'int4', value: this.group_id }}
561
+ id = ${{ type: 'int4', value: this.replicationStreamId }}
552
562
  `.execute();
553
563
 
554
- await this.db.sql`
555
- DELETE FROM bucket_parameters
556
- WHERE
557
- group_id = ${{ type: 'int4', value: this.group_id }}
558
- `.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
+ }
559
578
 
560
- await this.currentDataStore.deleteGroupRows(this.db, { groupId: this.group_id });
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
+ }
561
595
 
562
- await this.db.sql`
563
- DELETE FROM source_tables
564
- WHERE
565
- group_id = ${{ type: 'int4', value: this.group_id }}
566
- `.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;
567
626
  }
568
627
 
569
628
  private async getChecksumsInternal(batch: storage.FetchPartialBucketChecksum[]): Promise<storage.PartialChecksumMap> {
@@ -603,7 +662,7 @@ export class PostgresSyncRulesStorage
603
662
  AND b.op_id > f.start_op_id
604
663
  AND b.op_id <= f.end_op_id
605
664
  WHERE
606
- b.group_id = ${{ type: 'int4', value: this.group_id }}
665
+ b.group_id = ${{ type: 'int4', value: this.replicationStreamId }}
607
666
  GROUP BY
608
667
  b.bucket_name;
609
668
  `.rows<{ bucket: string; checksum_total: bigint; total: bigint; has_clear_op: number }>();
@@ -632,25 +691,8 @@ export class PostgresSyncRulesStorage
632
691
  }
633
692
 
634
693
  async getActiveCheckpoint(): Promise<storage.ReplicationCheckpoint> {
635
- const activeCheckpoint = await this.db.sql`
636
- SELECT
637
- id,
638
- last_checkpoint,
639
- last_checkpoint_lsn
640
- FROM
641
- sync_rules
642
- WHERE
643
- state = ${{ value: storage.SyncRuleState.ACTIVE, type: 'varchar' }}
644
- OR state = ${{ value: storage.SyncRuleState.ERRORED, type: 'varchar' }}
645
- ORDER BY
646
- id DESC
647
- LIMIT
648
- 1
649
- `
650
- .decoded(models.ActiveCheckpoint)
651
- .first();
652
-
653
- return this.makeActiveCheckpoint(activeCheckpoint);
694
+ const activeCheckpointDocument = await checkpointUtils.getActiveCheckpointDocument({ db: this.db });
695
+ return this.makeActiveCheckpoint(activeCheckpointDocument);
654
696
  }
655
697
 
656
698
  async *watchCheckpointChanges(options: WatchWriteCheckpointOptions): AsyncIterable<storage.StorageCheckpointUpdate> {
@@ -696,61 +738,92 @@ export class PostgresSyncRulesStorage
696
738
  }
697
739
 
698
740
  protected async *watchActiveCheckpoint(signal: AbortSignal): AsyncIterable<storage.ReplicationCheckpoint> {
699
- const doc = await this.db.sql`
700
- SELECT
701
- id,
702
- last_checkpoint,
703
- last_checkpoint_lsn
704
- FROM
705
- sync_rules
706
- WHERE
707
- state = ${{ value: storage.SyncRuleState.ACTIVE, type: 'varchar' }}
708
- OR state = ${{ value: storage.SyncRuleState.ERRORED, type: 'varchar' }}
709
- LIMIT
710
- 1
711
- `
712
- .decoded(models.ActiveCheckpoint)
713
- .first();
714
-
715
- if (doc == null) {
716
- // Abort the connections - clients will have to retry later.
717
- throw new framework.ServiceError(framework.ErrorCode.PSYNC_S2302, 'No active replication stream available');
718
- }
719
-
720
- const sink = new LastValueSink<string>(undefined);
741
+ const sink = new LastValueSink<string | null>(null);
721
742
 
743
+ // Listen for changes before reading the initial doc
722
744
  const disposeListener = this.db.registerListener({
723
- 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
+ }
724
761
  });
725
762
 
726
- signal.addEventListener('aborted', async () => {
727
- disposeListener();
728
- sink.end();
729
- });
763
+ try {
764
+ const initialCheckpointDocument = requireActiveCheckpointDocument(
765
+ await checkpointUtils.getActiveCheckpointDocument({ db: this.db })
766
+ );
767
+ const initialCheckpoint = this.makeActiveCheckpoint(initialCheckpointDocument);
768
+ let lastOp = initialCheckpoint;
730
769
 
731
- yield this.makeActiveCheckpoint(doc);
770
+ yield initialCheckpoint;
732
771
 
733
- let lastOp: storage.ReplicationCheckpoint | null = null;
734
- for await (const payload of sink.withSignal(signal)) {
735
- if (signal.aborted) {
736
- return;
737
- }
772
+ for await (const payload of sink.withSignal(signal)) {
773
+ if (signal.aborted) {
774
+ return;
775
+ }
738
776
 
739
- const notification = models.ActiveCheckpointNotification.decode(payload);
740
- if (notification.active_checkpoint == null) {
741
- continue;
742
- }
743
- if (Number(notification.active_checkpoint.id) != doc.id) {
744
- // Active replication stream changed - abort and restart the stream
745
- break;
746
- }
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
+ }
747
801
 
748
- 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
+ }
749
806
 
750
- if (lastOp == null || activeCheckpoint.lsn != lastOp.lsn || activeCheckpoint.checkpoint != lastOp.checkpoint) {
751
- lastOp = activeCheckpoint;
752
- 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
+ }
753
824
  }
825
+ } finally {
826
+ disposeListener();
754
827
  }
755
828
  }
756
829
 
@@ -783,3 +856,14 @@ const parameterSetsRow = t.object({
783
856
  index: bigint,
784
857
  bucket_parameters: t.string
785
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
+ }