@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
@@ -8,17 +8,23 @@ import { mapOpEntry } from '../utils/bucket-data.js';
8
8
  import * as framework from '@powersync/lib-services-framework';
9
9
  import { wrapWithAbort } from 'ix/asynciterable/operators/withabort.js';
10
10
  import * as t from 'ts-codec';
11
+ import * as checkpointUtils from '../utils/checkpoints.js';
11
12
  import { pick } from '../utils/ts-codec.js';
12
13
  import { PostgresBucketBatch } from './batch/PostgresBucketBatch.js';
13
14
  import { PostgresWriteCheckpointAPI } from './checkpoints/PostgresWriteCheckpointAPI.js';
14
15
  import { PostgresCurrentDataStore } from './current-data-store.js';
15
16
  import { PostgresCompactor } from './PostgresCompactor.js';
17
+ /**
18
+ * Number of rows deleted per batch when clearing storage for a replication stream.
19
+ */
20
+ export const CLEAR_BATCH_LIMIT = 50_000;
16
21
  export class PostgresSyncRulesStorage extends framework.BaseObserver {
17
22
  options;
18
23
  [framework.DO_NOT_LOG] = true;
19
- group_id;
24
+ replicationStreamId;
25
+ replicationStream;
20
26
  sync_rules;
21
- slot_name;
27
+ replicationStreamName;
22
28
  factory;
23
29
  storageConfig;
24
30
  logger;
@@ -26,20 +32,27 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
26
32
  db;
27
33
  writeCheckpointAPI;
28
34
  currentDataStore;
29
- // TODO we might be able to share this in an abstract class
30
- parsedSyncRulesCache;
35
+ /**
36
+ * Canonical parsed sync config sets, keyed by defaultSchema. Entries are never evicted,
37
+ * so each parse options value maps to exactly one parsed set for the lifetime of this
38
+ * storage instance.
39
+ *
40
+ * TODO we might be able to share this in an abstract class
41
+ */
42
+ parsedSyncConfigSets = new Map();
31
43
  _checksumCache;
32
44
  constructor(options) {
33
45
  super();
34
46
  this.options = options;
35
- this.group_id = options.sync_rules.id;
47
+ this.replicationStream = options.replicationStream;
48
+ this.replicationStreamId = options.replicationStream.replicationStreamId;
36
49
  this.db = options.db;
37
- this.sync_rules = options.sync_rules;
38
- this.slot_name = options.sync_rules.slot_name;
50
+ this.sync_rules = options.replicationStream.syncConfigContent[0];
51
+ this.replicationStreamName = options.replicationStream.replicationStreamName;
39
52
  this.factory = options.factory;
40
- this.storageConfig = options.sync_rules.getStorageConfig();
53
+ this.storageConfig = options.replicationStream.getStorageConfig();
41
54
  this.currentDataStore = new PostgresCurrentDataStore(this.storageConfig);
42
- this.logger = options.sync_rules.logger;
55
+ this.logger = options.replicationStream.logger;
43
56
  this.writeCheckpointAPI = new PostgresWriteCheckpointAPI({
44
57
  db: this.db,
45
58
  mode: options.write_checkpoint_mode ?? storage.WriteCheckpointMode.MANAGED
@@ -52,6 +65,7 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
52
65
  */
53
66
  get checksumCache() {
54
67
  this._checksumCache ??= new storage.ChecksumCache({
68
+ ttlMs: this.options.checksumCacheTtlMs,
55
69
  fetchChecksums: (batch) => {
56
70
  return this.getChecksumsInternal(batch);
57
71
  }
@@ -61,17 +75,16 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
61
75
  get writeCheckpointMode() {
62
76
  return this.writeCheckpointAPI.writeCheckpointMode;
63
77
  }
64
- // TODO we might be able to share this in an abstract class
65
- getParsedSyncRules(options) {
66
- const { parsed, options: cachedOptions } = this.parsedSyncRulesCache ?? {};
67
- /**
68
- * Check if the cached sync config, if present, had the same options.
69
- * Parse sync config if the options are different or if there is no cached value.
70
- */
71
- if (!parsed || options.defaultSchema != cachedOptions?.defaultSchema) {
72
- this.parsedSyncRulesCache = { parsed: this.sync_rules.parsed(options).hydratedSyncConfig(), options };
78
+ getParsedSyncConfigSet(options) {
79
+ let parsed = this.parsedSyncConfigSets.get(options.defaultSchema);
80
+ if (parsed == null) {
81
+ parsed = this.replicationStream.parsed(options);
82
+ this.parsedSyncConfigSets.set(options.defaultSchema, parsed);
73
83
  }
74
- return this.parsedSyncRulesCache.parsed;
84
+ return parsed;
85
+ }
86
+ getParsedSyncRules(options) {
87
+ return this.getParsedSyncConfigSet(options).hydratedSyncConfig;
75
88
  }
76
89
  async reportError(e) {
77
90
  const message = String(e.message ?? 'Replication failure');
@@ -80,7 +93,7 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
80
93
  SET
81
94
  last_fatal_error = ${{ type: 'varchar', value: message }}
82
95
  WHERE
83
- id = ${{ type: 'int4', value: this.group_id }};
96
+ id = ${{ type: 'int4', value: this.replicationStreamId }};
84
97
  `.execute();
85
98
  }
86
99
  async compact(options) {
@@ -90,7 +103,7 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
90
103
  // Note: If there is no active checkpoint, this will be 0, in which case no compacting is performed
91
104
  maxOpId = checkpoint.checkpoint;
92
105
  }
93
- return new PostgresCompactor(this.db, this.group_id, {
106
+ return new PostgresCompactor(this.db, this.replicationStreamId, {
94
107
  ...options,
95
108
  maxOpId,
96
109
  logger: this.logger
@@ -103,14 +116,14 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
103
116
  lastWriteCheckpoint(filters) {
104
117
  return this.writeCheckpointAPI.lastWriteCheckpoint({
105
118
  ...filters,
106
- sync_rules_id: this.group_id
119
+ sync_rules_id: this.replicationStreamId
107
120
  });
108
121
  }
109
122
  setWriteCheckpointMode(mode) {
110
123
  return this.writeCheckpointAPI.setWriteCheckpointMode(mode);
111
124
  }
112
- createManagedWriteCheckpoint(checkpoint) {
113
- return this.writeCheckpointAPI.createManagedWriteCheckpoint(checkpoint);
125
+ createManagedWriteCheckpoints(checkpoints) {
126
+ return this.writeCheckpointAPI.createManagedWriteCheckpoints(checkpoints);
114
127
  }
115
128
  async getCheckpoint() {
116
129
  const checkpointRow = await this.db.sql `
@@ -120,7 +133,7 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
120
133
  FROM
121
134
  sync_rules
122
135
  WHERE
123
- id = ${{ type: 'int4', value: this.group_id }}
136
+ id = ${{ type: 'int4', value: this.replicationStreamId }}
124
137
  `
125
138
  .decoded(pick(models.SyncRules, ['last_checkpoint', 'last_checkpoint_lsn']))
126
139
  .first();
@@ -136,7 +149,7 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
136
149
  FROM
137
150
  sync_rules
138
151
  WHERE
139
- id = ${{ type: 'int4', value: this.group_id }}
152
+ id = ${{ type: 'int4', value: this.replicationStreamId }}
140
153
  `
141
154
  .decoded(pick(models.SyncRules, ['last_checkpoint_lsn', 'no_checkpoint_before', 'keepalive_op', 'snapshot_lsn']))
142
155
  .first();
@@ -144,9 +157,9 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
144
157
  const writer = new PostgresBucketBatch({
145
158
  logger: options.logger ?? this.logger,
146
159
  db: this.db,
147
- sync_rules: this.sync_rules.parsed(options).hydratedSyncConfig(),
148
- group_id: this.group_id,
149
- slot_name: this.slot_name,
160
+ sync_rules: this.getParsedSyncRules(options),
161
+ replicationStreamId: this.replicationStreamId,
162
+ replicationStreamName: this.replicationStreamName,
150
163
  last_checkpoint_lsn: checkpoint_lsn,
151
164
  keep_alive_op: syncRules?.keepalive_op,
152
165
  resumeFromLsn: maxLsn(syncRules?.snapshot_lsn, checkpoint_lsn),
@@ -183,7 +196,7 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
183
196
  value: lookups.map((l) => storage.serializeLookupBuffer(l).toString('hex'))
184
197
  }}) WITH ORDINALITY AS requested (value, index)
185
198
  WHERE
186
- group_id = ${{ type: 'int4', value: this.group_id }}
199
+ group_id = ${{ type: 'int4', value: this.replicationStreamId }}
187
200
  AND lookup = decode((requested.value ->> 0)::text, 'hex') -- Decode the hex string to bytea
188
201
  AND id <= ${{ type: 'int8', value: checkpoint.checkpoint }}
189
202
  ORDER BY
@@ -238,8 +251,8 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
238
251
  // There are also overall batch length and size limits.
239
252
  // Each batch query batch are streamed in separate sets of rows, which may or may
240
253
  // not match up with chunks.
241
- const end = checkpoint ?? BIGINT_MAX;
242
- const filters = dataBuckets.map((request) => ({ bucket_name: request.bucket, start: request.start }));
254
+ const end = checkpoint.checkpoint ?? BIGINT_MAX;
255
+ const sortedBuckets = [...dataBuckets].sort((a, b) => a.bucket.localeCompare(b.bucket));
243
256
  const startOpByBucket = new Map(dataBuckets.map((request) => [request.bucket, request.start]));
244
257
  const batchRowLimit = options?.limit ?? storage.DEFAULT_DOCUMENT_BATCH_LIMIT;
245
258
  const chunkSizeLimitBytes = options?.chunkLimitBytes ?? storage.DEFAULT_DOCUMENT_CHUNK_LIMIT_BYTES;
@@ -247,65 +260,64 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
247
260
  let currentChunk = null;
248
261
  let targetOp = null;
249
262
  let batchRowCount = 0;
250
- /**
251
- * It is possible to perform this query with JSONB join. e.g.
252
- * ```sql
253
- * WITH
254
- * filter_data AS (
255
- * SELECT
256
- * FILTER ->> 'bucket_name' AS bucket_name,
257
- * (FILTER ->> 'start')::BIGINT AS start_op_id
258
- * FROM
259
- * jsonb_array_elements($1::jsonb) AS FILTER
260
- * )
261
- * SELECT
262
- * b.*,
263
- * octet_length(b.data) AS data_size
264
- * FROM
265
- * bucket_data b
266
- * JOIN filter_data f ON b.bucket_name = f.bucket_name
267
- * AND b.op_id > f.start_op_id
268
- * AND b.op_id <= $2
269
- * WHERE
270
- * b.group_id = $3
271
- * ORDER BY
272
- * b.bucket_name ASC,
273
- * b.op_id ASC
274
- * LIMIT
275
- * $4;
276
- * ```
277
- * Which might be better for large volumes of buckets, but in testing the JSON method
278
- * was significantly slower than the method below. Syncing 2.5 million rows in a single
279
- * bucket takes 2 minutes and 11 seconds with the method below. With the JSON method
280
- * 1 million rows were only synced before a 5 minute timeout.
281
- */
282
- for await (const rows of this.db.streamRows({
263
+ const requestedBuckets = sortedBuckets
264
+ .map((_, index) => `($${index * 2 + 4}, $${index * 2 + 5}, ${index})`)
265
+ .join(', ');
266
+ // Use one round trip while keeping a parameterized index range scan per bucket. Ordering the non-flattened request
267
+ // subquery gives Postgres a presorted bucket key, allowing an incremental sort and the outer limit to stop the
268
+ // nested loop early. The inner limit cannot exclude rows needed by the outer batch because both limits are equal.
269
+ const query = {
283
270
  statement: `
284
- SELECT
285
- *
286
- FROM
287
- bucket_data
288
- WHERE
289
- group_id = $1
290
- and op_id <= $2
291
- and (
292
- ${filters.map((f, index) => `(bucket_name = $${index * 2 + 4} and op_id > $${index * 2 + 5})`).join(' OR ')}
293
- )
294
- ORDER BY
295
- bucket_name ASC,
296
- op_id ASC
297
- LIMIT
298
- $3;`,
271
+ SELECT
272
+ bucket_data.*
273
+ FROM
274
+ (
275
+ SELECT
276
+ *
277
+ FROM
278
+ (
279
+ VALUES ${requestedBuckets}
280
+ ) AS bucket_requests(bucket_name, start_op_id, bucket_order)
281
+ -- Present requests in bucket order so the final sort can be incremental.
282
+ ORDER BY
283
+ bucket_order ASC
284
+ -- Prevent the planner from flattening away the ordered request path.
285
+ OFFSET
286
+ 0
287
+ ) AS requested
288
+ CROSS JOIN LATERAL (
289
+ SELECT
290
+ *
291
+ FROM
292
+ bucket_data
293
+ WHERE
294
+ group_id = $1
295
+ AND bucket_name = requested.bucket_name
296
+ AND op_id > requested.start_op_id
297
+ AND op_id <= $2
298
+ -- Keep each bucket's index range scan ordered and bounded.
299
+ ORDER BY
300
+ op_id ASC
301
+ LIMIT
302
+ $3
303
+ ) AS bucket_data
304
+ -- Guarantee contiguous bucket chunks while allowing the presorted bucket key to stop scans early.
305
+ ORDER BY
306
+ requested.bucket_order ASC,
307
+ bucket_data.op_id ASC
308
+ LIMIT
309
+ $3;`,
299
310
  params: [
300
- { type: 'int4', value: this.group_id },
311
+ { type: 'int4', value: this.replicationStreamId },
301
312
  { type: 'int8', value: end },
302
313
  { type: 'int4', value: batchRowLimit },
303
- ...filters.flatMap((f) => [
304
- { type: 'varchar', value: f.bucket_name },
305
- { type: 'int8', value: f.start }
314
+ ...sortedBuckets.flatMap((request) => [
315
+ { type: 'varchar', value: request.bucket },
316
+ { type: 'int8', value: request.start }
306
317
  ])
307
318
  ]
308
- })) {
319
+ };
320
+ for await (const rows of this.db.streamRows(query)) {
309
321
  const decodedRows = rows.map((r) => models.BucketData.decode(r));
310
322
  for (const row of decodedRows) {
311
323
  const { bucket_name } = row;
@@ -378,8 +390,8 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
378
390
  targetOp = null;
379
391
  }
380
392
  }
381
- async getChecksums(checkpoint, buckets) {
382
- return this.checksumCache.getChecksumMap(checkpoint, buckets);
393
+ async getChecksums(checkpoint, buckets, _options) {
394
+ return this.checksumCache.getChecksumMap(checkpoint.checkpoint, buckets);
383
395
  }
384
396
  clearChecksumCache() {
385
397
  this.checksumCache.clear();
@@ -394,7 +406,7 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
394
406
  state = ${{ type: 'varchar', value: storage.SyncRuleState.TERMINATED }},
395
407
  snapshot_done = ${{ type: 'bool', value: false }}
396
408
  WHERE
397
- id = ${{ type: 'int4', value: this.group_id }}
409
+ id = ${{ type: 'int4', value: this.replicationStreamId }}
398
410
  `.execute();
399
411
  }
400
412
  async getStatus() {
@@ -403,28 +415,27 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
403
415
  snapshot_done,
404
416
  snapshot_lsn,
405
417
  last_checkpoint_lsn,
406
- state,
407
- keepalive_op
418
+ state
408
419
  FROM
409
420
  sync_rules
410
421
  WHERE
411
- id = ${{ type: 'int4', value: this.group_id }}
422
+ id = ${{ type: 'int4', value: this.replicationStreamId }}
412
423
  `
413
- .decoded(pick(models.SyncRules, ['snapshot_done', 'last_checkpoint_lsn', 'state', 'snapshot_lsn', 'keepalive_op']))
424
+ .decoded(pick(models.SyncRules, ['snapshot_done', 'last_checkpoint_lsn', 'snapshot_lsn']))
414
425
  .first();
415
426
  if (syncRulesRow == null) {
416
427
  throw new Error('Cannot find replication stream status');
417
428
  }
418
429
  return {
419
- snapshot_done: syncRulesRow.snapshot_done,
420
- active: syncRulesRow.state == storage.SyncRuleState.ACTIVE,
421
- checkpoint_lsn: syncRulesRow.last_checkpoint_lsn ?? null,
422
- snapshot_lsn: syncRulesRow.snapshot_lsn ?? null,
423
- keepalive_op: syncRulesRow.keepalive_op ?? null
430
+ snapshotDone: syncRulesRow.snapshot_done && syncRulesRow.last_checkpoint_lsn != null,
431
+ resumeLsn: maxLsn(syncRulesRow.snapshot_lsn, syncRulesRow.last_checkpoint_lsn)
424
432
  };
425
433
  }
426
434
  async clear(options) {
427
- // TODO: Cleanly abort the cleanup when the provided signal is aborted.
435
+ const signal = options?.signal;
436
+ if (signal?.aborted) {
437
+ throw new framework.ReplicationAbortedError('Aborted clearing data', signal.reason);
438
+ }
428
439
  await this.db.sql `
429
440
  UPDATE sync_rules
430
441
  SET
@@ -433,24 +444,62 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
433
444
  last_checkpoint = NULL,
434
445
  no_checkpoint_before = NULL
435
446
  WHERE
436
- id = ${{ type: 'int4', value: this.group_id }}
437
- `.execute();
438
- await this.db.sql `
439
- DELETE FROM bucket_data
440
- WHERE
441
- group_id = ${{ type: 'int4', value: this.group_id }}
442
- `.execute();
443
- await this.db.sql `
444
- DELETE FROM bucket_parameters
445
- WHERE
446
- group_id = ${{ type: 'int4', value: this.group_id }}
447
- `.execute();
448
- await this.currentDataStore.deleteGroupRows(this.db, { groupId: this.group_id });
449
- await this.db.sql `
450
- DELETE FROM source_tables
451
- WHERE
452
- group_id = ${{ type: 'int4', value: this.group_id }}
447
+ id = ${{ type: 'int4', value: this.replicationStreamId }}
453
448
  `.execute();
449
+ // Delete in batches - a single DELETE covering the entire group can run for
450
+ // hours on large deployments, never completing once it exceeds statement or
451
+ // socket timeouts. Each batch is its own autocommit statement, so progress
452
+ // is durable and a retry continues where the previous attempt stopped.
453
+ await this.clearBatched('bucket_data', signal, () => this.deleteGroupBatch('bucket_data'));
454
+ await this.clearBatched('bucket_parameters', signal, () => this.deleteGroupBatch('bucket_parameters'));
455
+ await this.clearBatched('current_data', signal, () => this.currentDataStore.deleteGroupRowsBatch(this.db, {
456
+ groupId: this.replicationStreamId,
457
+ limit: CLEAR_BATCH_LIMIT
458
+ }));
459
+ await this.clearBatched('source_tables', signal, () => this.deleteGroupBatch('source_tables'));
460
+ }
461
+ async clearBatched(label, signal, deleteBatch) {
462
+ while (true) {
463
+ if (signal?.aborted) {
464
+ throw new framework.ReplicationAbortedError('Aborted clearing data', signal.reason);
465
+ }
466
+ const count = await deleteBatch();
467
+ if (count < CLEAR_BATCH_LIMIT) {
468
+ return;
469
+ }
470
+ this.logger.info(`Cleared batch of ${count} ${label} rows, continuing...`);
471
+ }
472
+ }
473
+ /**
474
+ * Delete up to {@link CLEAR_BATCH_LIMIT} rows for this group from the given table,
475
+ * returning the number of candidate rows found by the scan.
476
+ *
477
+ * The count is taken from the scan (`batch`) rather than the delete: with a
478
+ * concurrent process deleting the same rows, the delete may remove fewer rows
479
+ * than the scan found, and counting deleted rows could stop the loop while
480
+ * rows remain.
481
+ */
482
+ async deleteGroupBatch(table) {
483
+ const [row] = await this.db.queryRows({
484
+ statement: `
485
+ WITH batch AS (
486
+ SELECT ctid FROM ${table}
487
+ WHERE group_id = $1
488
+ LIMIT $2
489
+ ),
490
+ deleted AS (
491
+ DELETE FROM ${table}
492
+ WHERE ctid IN (SELECT ctid FROM batch)
493
+ RETURNING 1
494
+ )
495
+ SELECT COUNT(*) AS count FROM batch
496
+ `,
497
+ params: [
498
+ { type: 'int4', value: this.replicationStreamId },
499
+ { type: 'int4', value: CLEAR_BATCH_LIMIT }
500
+ ]
501
+ });
502
+ return row?.count ?? 0n;
454
503
  }
455
504
  async getChecksumsInternal(batch) {
456
505
  if (batch.length == 0) {
@@ -487,7 +536,7 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
487
536
  AND b.op_id > f.start_op_id
488
537
  AND b.op_id <= f.end_op_id
489
538
  WHERE
490
- b.group_id = ${{ type: 'int4', value: this.group_id }}
539
+ b.group_id = ${{ type: 'int4', value: this.replicationStreamId }}
491
540
  GROUP BY
492
541
  b.bucket_name;
493
542
  `.rows();
@@ -511,24 +560,8 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
511
560
  }));
512
561
  }
513
562
  async getActiveCheckpoint() {
514
- const activeCheckpoint = await this.db.sql `
515
- SELECT
516
- id,
517
- last_checkpoint,
518
- last_checkpoint_lsn
519
- FROM
520
- sync_rules
521
- WHERE
522
- state = ${{ value: storage.SyncRuleState.ACTIVE, type: 'varchar' }}
523
- OR state = ${{ value: storage.SyncRuleState.ERRORED, type: 'varchar' }}
524
- ORDER BY
525
- id DESC
526
- LIMIT
527
- 1
528
- `
529
- .decoded(models.ActiveCheckpoint)
530
- .first();
531
- return this.makeActiveCheckpoint(activeCheckpoint);
563
+ const activeCheckpointDocument = await checkpointUtils.getActiveCheckpointDocument({ db: this.db });
564
+ return this.makeActiveCheckpoint(activeCheckpointDocument);
532
565
  }
533
566
  async *watchCheckpointChanges(options) {
534
567
  let lastCheckpoint = null;
@@ -565,53 +598,84 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
565
598
  }
566
599
  }
567
600
  async *watchActiveCheckpoint(signal) {
568
- const doc = await this.db.sql `
569
- SELECT
570
- id,
571
- last_checkpoint,
572
- last_checkpoint_lsn
573
- FROM
574
- sync_rules
575
- WHERE
576
- state = ${{ value: storage.SyncRuleState.ACTIVE, type: 'varchar' }}
577
- OR state = ${{ value: storage.SyncRuleState.ERRORED, type: 'varchar' }}
578
- LIMIT
579
- 1
580
- `
581
- .decoded(models.ActiveCheckpoint)
582
- .first();
583
- if (doc == null) {
584
- // Abort the connections - clients will have to retry later.
585
- throw new framework.ServiceError(framework.ErrorCode.PSYNC_S2302, 'No active replication stream available');
586
- }
587
- const sink = new LastValueSink(undefined);
601
+ const sink = new LastValueSink(null);
602
+ // Listen for changes before reading the initial doc
588
603
  const disposeListener = this.db.registerListener({
589
- notification: (notification) => sink.write(notification.payload)
590
- });
591
- signal.addEventListener('aborted', async () => {
592
- disposeListener();
593
- sink.end();
594
- });
595
- yield this.makeActiveCheckpoint(doc);
596
- let lastOp = null;
597
- for await (const payload of sink.withSignal(signal)) {
598
- if (signal.aborted) {
599
- return;
600
- }
601
- const notification = models.ActiveCheckpointNotification.decode(payload);
602
- if (notification.active_checkpoint == null) {
603
- continue;
604
- }
605
- if (Number(notification.active_checkpoint.id) != doc.id) {
606
- // Active replication stream changed - abort and restart the stream
607
- break;
604
+ notificationEvent: (event) => {
605
+ switch (event.type) {
606
+ case 'notification':
607
+ sink.write(event.notification.payload);
608
+ break;
609
+ case 'channels-registered':
610
+ // Add a null value to the stream, indicating the loop should query the value.
611
+ sink.write(null);
612
+ break;
613
+ case 'connection-error':
614
+ // End the watcher after reconnect attempts are exhausted. Consumers will
615
+ // retry the watcher, which registers a new listener and pokes the slot.
616
+ sink.error(event.error);
617
+ break;
618
+ }
608
619
  }
609
- const activeCheckpoint = this.makeActiveCheckpoint(notification.active_checkpoint);
610
- if (lastOp == null || activeCheckpoint.lsn != lastOp.lsn || activeCheckpoint.checkpoint != lastOp.checkpoint) {
611
- lastOp = activeCheckpoint;
612
- yield activeCheckpoint;
620
+ });
621
+ try {
622
+ const initialCheckpointDocument = requireActiveCheckpointDocument(await checkpointUtils.getActiveCheckpointDocument({ db: this.db }));
623
+ const initialCheckpoint = this.makeActiveCheckpoint(initialCheckpointDocument);
624
+ let lastOp = initialCheckpoint;
625
+ yield initialCheckpoint;
626
+ for await (const payload of sink.withSignal(signal)) {
627
+ if (signal.aborted) {
628
+ return;
629
+ }
630
+ let baseActiveCheckpoint = null;
631
+ if (payload == null) {
632
+ // Reconnected (or manually triggered) - re-query the current checkpoint.
633
+ // Unlike the initial read, a missing document here (e.g. sync rules being
634
+ // replaced) must not abort the stream: keep waiting for the next notification.
635
+ baseActiveCheckpoint = await checkpointUtils.getActiveCheckpointDocument({ db: this.db });
636
+ if (baseActiveCheckpoint == null) {
637
+ continue;
638
+ }
639
+ }
640
+ else {
641
+ let notification;
642
+ try {
643
+ notification = models.ActiveCheckpointNotification.decode(payload);
644
+ }
645
+ catch (error) {
646
+ // A malformed payload must not abort the shared stream for every
647
+ // subscriber. Skip it and wait for the next notification.
648
+ this.logger.warn('Failed to decode active checkpoint notification, ignoring', error);
649
+ continue;
650
+ }
651
+ if (notification.active_checkpoint == null) {
652
+ continue;
653
+ }
654
+ baseActiveCheckpoint = notification.active_checkpoint;
655
+ }
656
+ if (baseActiveCheckpoint.id != initialCheckpointDocument.id) {
657
+ // Active replication stream changed - abort and restart the stream
658
+ break;
659
+ }
660
+ const activeCheckpoint = this.makeActiveCheckpoint(baseActiveCheckpoint);
661
+ const checkpointAdvanced = activeCheckpoint.checkpoint > lastOp.checkpoint;
662
+ const checkpointRegressed = activeCheckpoint.checkpoint < lastOp.checkpoint;
663
+ const lsnAdvanced = lastOp.lsn == null
664
+ ? activeCheckpoint.lsn != null
665
+ : activeCheckpoint.lsn != null && activeCheckpoint.lsn > lastOp.lsn;
666
+ const lsnRegressed = lastOp.lsn != null && (activeCheckpoint.lsn == null || activeCheckpoint.lsn < lastOp.lsn);
667
+ // Notifications buffered before a query or reconnect may be stale. Neither
668
+ // coordinate may regress, but only one needs to advance: an empty checkpoint
669
+ // updates the LSN while keeping the operation checkpoint unchanged.
670
+ if (!checkpointRegressed && !lsnRegressed && (checkpointAdvanced || lsnAdvanced)) {
671
+ lastOp = activeCheckpoint;
672
+ yield activeCheckpoint;
673
+ }
613
674
  }
614
675
  }
676
+ finally {
677
+ disposeListener();
678
+ }
615
679
  }
616
680
  async getCheckpointChanges(options) {
617
681
  // We do not track individual changes yet
@@ -638,4 +702,13 @@ const parameterSetsRow = t.object({
638
702
  index: bigint,
639
703
  bucket_parameters: t.string
640
704
  });
705
+ function requireActiveCheckpointDocument(doc) {
706
+ if (doc == null) {
707
+ // Used for the initial checkpoint read only: with no active replication stream
708
+ // at stream start, fail fast so clients disconnect and retry later. Mid-stream
709
+ // reconnects tolerate a transiently missing document instead of aborting.
710
+ throw new framework.ServiceError(framework.ErrorCode.PSYNC_S2302, 'No active replication stream available');
711
+ }
712
+ return doc;
713
+ }
641
714
  //# sourceMappingURL=PostgresSyncRulesStorage.js.map