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

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 (133) hide show
  1. package/CHANGELOG.md +179 -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/migrations/scripts/1784900000000-source-metadata.d.ts +3 -0
  6. package/dist/migrations/scripts/1784900000000-source-metadata.js +22 -0
  7. package/dist/migrations/scripts/1784900000000-source-metadata.js.map +1 -0
  8. package/dist/migrations/scripts/1788000000000-sync-config-version-label.d.ts +3 -0
  9. package/dist/migrations/scripts/1788000000000-sync-config-version-label.js +12 -0
  10. package/dist/migrations/scripts/1788000000000-sync-config-version-label.js.map +1 -0
  11. package/dist/{@types/storage → storage}/PostgresBucketStorageFactory.d.ts +14 -12
  12. package/dist/storage/PostgresBucketStorageFactory.js +73 -62
  13. package/dist/storage/PostgresBucketStorageFactory.js.map +1 -1
  14. package/dist/{@types/storage → storage}/PostgresCompactor.d.ts +3 -1
  15. package/dist/storage/PostgresCompactor.js +21 -1
  16. package/dist/storage/PostgresCompactor.js.map +1 -1
  17. package/dist/storage/PostgresReportStorage.js.map +1 -1
  18. package/dist/storage/PostgresStorageProvider.js +3 -1
  19. package/dist/storage/PostgresStorageProvider.js.map +1 -1
  20. package/dist/{@types/storage → storage}/PostgresSyncRulesStorage.d.ts +39 -13
  21. package/dist/storage/PostgresSyncRulesStorage.js +266 -181
  22. package/dist/storage/PostgresSyncRulesStorage.js.map +1 -1
  23. package/dist/{@types/storage → storage}/batch/PostgresBucketBatch.d.ts +10 -26
  24. package/dist/storage/batch/PostgresBucketBatch.js +290 -286
  25. package/dist/storage/batch/PostgresBucketBatch.js.map +1 -1
  26. package/dist/{@types/storage → storage}/checkpoints/PostgresWriteCheckpointAPI.d.ts +2 -2
  27. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js +163 -26
  28. package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js.map +1 -1
  29. package/dist/{@types/storage → storage}/current-data-store.d.ts +8 -2
  30. package/dist/storage/current-data-store.js +66 -11
  31. package/dist/storage/current-data-store.js.map +1 -1
  32. package/dist/{@types/storage → storage}/storage-index.d.ts +1 -0
  33. package/dist/storage/storage-index.js +1 -0
  34. package/dist/storage/storage-index.js.map +1 -1
  35. package/dist/storage/sync-rules/PostgresPersistedSyncConfigContent.d.ts +17 -0
  36. package/dist/storage/sync-rules/PostgresPersistedSyncConfigContent.js +94 -0
  37. package/dist/storage/sync-rules/PostgresPersistedSyncConfigContent.js.map +1 -0
  38. package/dist/{@types/types → types}/models/ActiveCheckpoint.d.ts +2 -2
  39. package/dist/types/models/ActiveCheckpoint.js +3 -2
  40. package/dist/types/models/ActiveCheckpoint.js.map +1 -1
  41. package/dist/{@types/types → types}/models/ActiveCheckpointNotification.d.ts +2 -2
  42. package/dist/types/models/BucketData.d.ts +22 -0
  43. package/dist/types/models/BucketData.js +7 -6
  44. package/dist/types/models/BucketData.js.map +1 -1
  45. package/dist/{@types/types → types}/models/CurrentData.d.ts +1 -1
  46. package/dist/types/models/CurrentData.js +2 -1
  47. package/dist/types/models/CurrentData.js.map +1 -1
  48. package/dist/{@types/types → types}/models/SdkReporting.d.ts +3 -3
  49. package/dist/types/models/SdkReporting.js +3 -5
  50. package/dist/types/models/SdkReporting.js.map +1 -1
  51. package/dist/{@types/types → types}/models/SourceTable.d.ts +13 -8
  52. package/dist/types/models/SourceTable.js +11 -6
  53. package/dist/types/models/SourceTable.js.map +1 -1
  54. package/dist/{@types/types → types}/models/SyncRules.d.ts +11 -10
  55. package/dist/types/models/SyncRules.js +12 -11
  56. package/dist/types/models/SyncRules.js.map +1 -1
  57. package/dist/{@types/types → types}/models/WriteCheckpoint.d.ts +2 -0
  58. package/dist/types/models/WriteCheckpoint.js +6 -2
  59. package/dist/types/models/WriteCheckpoint.js.map +1 -1
  60. package/dist/{@types/types → types}/types.d.ts +2 -0
  61. package/dist/utils/checkpoints.d.ts +9 -0
  62. package/dist/utils/checkpoints.js +26 -0
  63. package/dist/utils/checkpoints.js.map +1 -0
  64. package/dist/utils/test-utils.js +2 -2
  65. package/dist/utils/test-utils.js.map +1 -1
  66. package/package.json +10 -22
  67. package/src/migrations/scripts/1782950400000-checkpoint-requested-at.ts +51 -0
  68. package/src/migrations/scripts/1784900000000-source-metadata.ts +31 -0
  69. package/src/migrations/scripts/1788000000000-sync-config-version-label.ts +18 -0
  70. package/src/storage/PostgresBucketStorageFactory.ts +82 -65
  71. package/src/storage/PostgresCompactor.ts +25 -1
  72. package/src/storage/PostgresReportStorage.ts +1 -2
  73. package/src/storage/PostgresStorageProvider.ts +3 -1
  74. package/src/storage/PostgresSyncRulesStorage.ts +295 -196
  75. package/src/storage/batch/PostgresBucketBatch.ts +321 -310
  76. package/src/storage/checkpoints/PostgresWriteCheckpointAPI.ts +176 -26
  77. package/src/storage/current-data-store.ts +66 -11
  78. package/src/storage/storage-index.ts +1 -0
  79. package/src/storage/sync-rules/PostgresPersistedSyncConfigContent.ts +105 -0
  80. package/src/types/models/ActiveCheckpoint.ts +3 -2
  81. package/src/types/models/BucketData.ts +7 -6
  82. package/src/types/models/CurrentData.ts +2 -1
  83. package/src/types/models/SdkReporting.ts +4 -4
  84. package/src/types/models/SourceTable.ts +12 -6
  85. package/src/types/models/SyncRules.ts +12 -11
  86. package/src/types/models/WriteCheckpoint.ts +6 -2
  87. package/src/utils/checkpoints.ts +31 -0
  88. package/src/utils/test-utils.ts +2 -2
  89. package/test/src/__snapshots__/storage_sync.test.ts.snap +44 -1621
  90. package/test/src/checkpoint_notifications.test.ts +528 -0
  91. package/test/src/migrations.test.ts +1 -1
  92. package/test/src/storage.test.ts +235 -11
  93. package/test/src/storage_compacting.test.ts +17 -9
  94. package/test/src/storage_sync.test.ts +55 -8
  95. package/test/src/util.ts +1 -1
  96. package/test/tsconfig.json +1 -3
  97. package/tsconfig.json +1 -4
  98. package/tsconfig.tsbuildinfo +1 -0
  99. package/dist/.tsbuildinfo +0 -1
  100. package/dist/@types/storage/sync-rules/PostgresPersistedSyncRulesContent.d.ts +0 -9
  101. package/dist/@types/types/models/BucketData.d.ts +0 -22
  102. package/dist/storage/sync-rules/PostgresPersistedSyncRulesContent.js +0 -49
  103. package/dist/storage/sync-rules/PostgresPersistedSyncRulesContent.js.map +0 -1
  104. package/src/storage/sync-rules/PostgresPersistedSyncRulesContent.ts +0 -54
  105. /package/dist/{@types/index.d.ts → index.d.ts} +0 -0
  106. /package/dist/{@types/migrations → migrations}/PostgresMigrationAgent.d.ts +0 -0
  107. /package/dist/{@types/migrations → migrations}/PostgresMigrationStore.d.ts +0 -0
  108. /package/dist/{@types/migrations → migrations}/migration-utils.d.ts +0 -0
  109. /package/dist/{@types/migrations → migrations}/scripts/1684951997326-init.d.ts +0 -0
  110. /package/dist/{@types/migrations → migrations}/scripts/1749024804042-snapshot-progress.d.ts +0 -0
  111. /package/dist/{@types/migrations → migrations}/scripts/1756282360128-connection-reporting.d.ts +0 -0
  112. /package/dist/{@types/migrations → migrations}/scripts/1771232439485-storage-version.d.ts +0 -0
  113. /package/dist/{@types/migrations → migrations}/scripts/1771424826685-current-data-pending-deletes.d.ts +0 -0
  114. /package/dist/{@types/migrations → migrations}/scripts/1771491856000-sync-plan.d.ts +0 -0
  115. /package/dist/{@types/module → module}/PostgresStorageModule.d.ts +0 -0
  116. /package/dist/{@types/storage → storage}/PostgresReportStorage.d.ts +0 -0
  117. /package/dist/{@types/storage → storage}/PostgresStorageProvider.d.ts +0 -0
  118. /package/dist/{@types/storage → storage}/batch/OperationBatch.d.ts +0 -0
  119. /package/dist/{@types/storage → storage}/batch/PostgresPersistedBatch.d.ts +0 -0
  120. /package/dist/{@types/storage → storage}/table-id.d.ts +0 -0
  121. /package/dist/{@types/types → types}/codecs.d.ts +0 -0
  122. /package/dist/{@types/types → types}/models/BucketParameters.d.ts +0 -0
  123. /package/dist/{@types/types → types}/models/Instance.d.ts +0 -0
  124. /package/dist/{@types/types → types}/models/Migration.d.ts +0 -0
  125. /package/dist/{@types/types → types}/models/json.d.ts +0 -0
  126. /package/dist/{@types/types → types}/models/models-index.d.ts +0 -0
  127. /package/dist/{@types/utils → utils}/application-name.d.ts +0 -0
  128. /package/dist/{@types/utils → utils}/bson.d.ts +0 -0
  129. /package/dist/{@types/utils → utils}/bucket-data.d.ts +0 -0
  130. /package/dist/{@types/utils → utils}/db.d.ts +0 -0
  131. /package/dist/{@types/utils → utils}/test-utils.d.ts +0 -0
  132. /package/dist/{@types/utils → utils}/ts-codec.d.ts +0 -0
  133. /package/dist/{@types/utils → utils}/utils-index.d.ts +0 -0
@@ -1,4 +1,4 @@
1
- import { BroadcastIterable, CHECKPOINT_INVALIDATE_ALL, internalToExternalOpId, LastValueSink, maxLsn, ParameterSetLimitExceededError, storage } from '@powersync/service-core';
1
+ import { BroadcastIterable, CHECKPOINT_INVALIDATE_ALL, internalToExternalOpId, LastValueSink, maxLsn, ParameterSetLimitExceededError, storage, SyncRuleState } from '@powersync/service-core';
2
2
  import { JSONBig } from '@powersync/service-jsonbig';
3
3
  import * as timers from 'timers/promises';
4
4
  import { bigint, BIGINT_MAX } from '../types/codecs.js';
@@ -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 = this.sync_rules.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,37 +93,49 @@ 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
  }
99
+ supportsIncrementalParameterCompaction() {
100
+ return false;
101
+ }
86
102
  async compact(options) {
103
+ if (options?.incrementalOnly) {
104
+ // Not supported yet
105
+ this.logger.info('Incremental compacting is not supported on Postgres storage yet.');
106
+ return;
107
+ }
108
+ else if (this.replicationStream.state != SyncRuleState.ACTIVE) {
109
+ this.logger.info(`Skipping compacting of replication stream in ${this.replicationStream.state} state.`);
110
+ return;
111
+ }
87
112
  let maxOpId = options?.maxOpId;
88
113
  if (maxOpId == null) {
89
114
  const checkpoint = await this.getCheckpoint();
90
115
  // Note: If there is no active checkpoint, this will be 0, in which case no compacting is performed
91
116
  maxOpId = checkpoint.checkpoint;
92
117
  }
93
- return new PostgresCompactor(this.db, this.group_id, {
118
+ return new PostgresCompactor(this.db, this.replicationStreamId, {
94
119
  ...options,
95
120
  maxOpId,
96
121
  logger: this.logger
97
122
  }).compact();
98
123
  }
99
- async populatePersistentChecksumCache(_options) {
124
+ async compactInitialReplication(_options) {
100
125
  // no-op - checksum cache is not implemented for Postgres yet
101
126
  return { buckets: 0 };
102
127
  }
103
128
  lastWriteCheckpoint(filters) {
104
129
  return this.writeCheckpointAPI.lastWriteCheckpoint({
105
130
  ...filters,
106
- sync_rules_id: this.group_id
131
+ sync_rules_id: this.replicationStreamId
107
132
  });
108
133
  }
109
- setWriteCheckpointMode(mode) {
110
- return this.writeCheckpointAPI.setWriteCheckpointMode(mode);
134
+ setWriteCheckpointMode(config) {
135
+ return this.writeCheckpointAPI.setWriteCheckpointMode(config);
111
136
  }
112
- createManagedWriteCheckpoint(checkpoint) {
113
- return this.writeCheckpointAPI.createManagedWriteCheckpoint(checkpoint);
137
+ createManagedWriteCheckpoints(checkpoints) {
138
+ return this.writeCheckpointAPI.createManagedWriteCheckpoints(checkpoints);
114
139
  }
115
140
  async getCheckpoint() {
116
141
  const checkpointRow = await this.db.sql `
@@ -120,7 +145,7 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
120
145
  FROM
121
146
  sync_rules
122
147
  WHERE
123
- id = ${{ type: 'int4', value: this.group_id }}
148
+ id = ${{ type: 'int4', value: this.replicationStreamId }}
124
149
  `
125
150
  .decoded(pick(models.SyncRules, ['last_checkpoint', 'last_checkpoint_lsn']))
126
151
  .first();
@@ -136,7 +161,7 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
136
161
  FROM
137
162
  sync_rules
138
163
  WHERE
139
- id = ${{ type: 'int4', value: this.group_id }}
164
+ id = ${{ type: 'int4', value: this.replicationStreamId }}
140
165
  `
141
166
  .decoded(pick(models.SyncRules, ['last_checkpoint_lsn', 'no_checkpoint_before', 'keepalive_op', 'snapshot_lsn']))
142
167
  .first();
@@ -144,9 +169,9 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
144
169
  const writer = new PostgresBucketBatch({
145
170
  logger: options.logger ?? this.logger,
146
171
  db: this.db,
147
- sync_rules: this.sync_rules.parsed(options).hydratedSyncConfig(),
148
- group_id: this.group_id,
149
- slot_name: this.slot_name,
172
+ sync_rules: this.getParsedSyncRules(options),
173
+ replicationStreamId: this.replicationStreamId,
174
+ replicationStreamName: this.replicationStreamName,
150
175
  last_checkpoint_lsn: checkpoint_lsn,
151
176
  keep_alive_op: syncRules?.keepalive_op,
152
177
  resumeFromLsn: maxLsn(syncRules?.snapshot_lsn, checkpoint_lsn),
@@ -183,7 +208,7 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
183
208
  value: lookups.map((l) => storage.serializeLookupBuffer(l).toString('hex'))
184
209
  }}) WITH ORDINALITY AS requested (value, index)
185
210
  WHERE
186
- group_id = ${{ type: 'int4', value: this.group_id }}
211
+ group_id = ${{ type: 'int4', value: this.replicationStreamId }}
187
212
  AND lookup = decode((requested.value ->> 0)::text, 'hex') -- Decode the hex string to bytea
188
213
  AND id <= ${{ type: 'int8', value: checkpoint.checkpoint }}
189
214
  ORDER BY
@@ -238,8 +263,8 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
238
263
  // There are also overall batch length and size limits.
239
264
  // Each batch query batch are streamed in separate sets of rows, which may or may
240
265
  // not match up with chunks.
241
- const end = checkpoint ?? BIGINT_MAX;
242
- const filters = dataBuckets.map((request) => ({ bucket_name: request.bucket, start: request.start }));
266
+ const end = checkpoint.checkpoint ?? BIGINT_MAX;
267
+ const sortedBuckets = [...dataBuckets].sort((a, b) => a.bucket.localeCompare(b.bucket));
243
268
  const startOpByBucket = new Map(dataBuckets.map((request) => [request.bucket, request.start]));
244
269
  const batchRowLimit = options?.limit ?? storage.DEFAULT_DOCUMENT_BATCH_LIMIT;
245
270
  const chunkSizeLimitBytes = options?.chunkLimitBytes ?? storage.DEFAULT_DOCUMENT_CHUNK_LIMIT_BYTES;
@@ -247,65 +272,64 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
247
272
  let currentChunk = null;
248
273
  let targetOp = null;
249
274
  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({
275
+ const requestedBuckets = sortedBuckets
276
+ .map((_, index) => `($${index * 2 + 4}, $${index * 2 + 5}, ${index})`)
277
+ .join(', ');
278
+ // Use one round trip while keeping a parameterized index range scan per bucket. Ordering the non-flattened request
279
+ // subquery gives Postgres a presorted bucket key, allowing an incremental sort and the outer limit to stop the
280
+ // nested loop early. The inner limit cannot exclude rows needed by the outer batch because both limits are equal.
281
+ const query = {
283
282
  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;`,
283
+ SELECT
284
+ bucket_data.*
285
+ FROM
286
+ (
287
+ SELECT
288
+ *
289
+ FROM
290
+ (
291
+ VALUES ${requestedBuckets}
292
+ ) AS bucket_requests(bucket_name, start_op_id, bucket_order)
293
+ -- Present requests in bucket order so the final sort can be incremental.
294
+ ORDER BY
295
+ bucket_order ASC
296
+ -- Prevent the planner from flattening away the ordered request path.
297
+ OFFSET
298
+ 0
299
+ ) AS requested
300
+ CROSS JOIN LATERAL (
301
+ SELECT
302
+ *
303
+ FROM
304
+ bucket_data
305
+ WHERE
306
+ group_id = $1
307
+ AND bucket_name = requested.bucket_name
308
+ AND op_id > requested.start_op_id
309
+ AND op_id <= $2
310
+ -- Keep each bucket's index range scan ordered and bounded.
311
+ ORDER BY
312
+ op_id ASC
313
+ LIMIT
314
+ $3
315
+ ) AS bucket_data
316
+ -- Guarantee contiguous bucket chunks while allowing the presorted bucket key to stop scans early.
317
+ ORDER BY
318
+ requested.bucket_order ASC,
319
+ bucket_data.op_id ASC
320
+ LIMIT
321
+ $3;`,
299
322
  params: [
300
- { type: 'int4', value: this.group_id },
323
+ { type: 'int4', value: this.replicationStreamId },
301
324
  { type: 'int8', value: end },
302
325
  { type: 'int4', value: batchRowLimit },
303
- ...filters.flatMap((f) => [
304
- { type: 'varchar', value: f.bucket_name },
305
- { type: 'int8', value: f.start }
326
+ ...sortedBuckets.flatMap((request) => [
327
+ { type: 'varchar', value: request.bucket },
328
+ { type: 'int8', value: request.start }
306
329
  ])
307
330
  ]
308
- })) {
331
+ };
332
+ for await (const rows of this.db.streamRows(query)) {
309
333
  const decodedRows = rows.map((r) => models.BucketData.decode(r));
310
334
  for (const row of decodedRows) {
311
335
  const { bucket_name } = row;
@@ -378,8 +402,8 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
378
402
  targetOp = null;
379
403
  }
380
404
  }
381
- async getChecksums(checkpoint, buckets) {
382
- return this.checksumCache.getChecksumMap(checkpoint, buckets);
405
+ async getChecksums(checkpoint, buckets, _options) {
406
+ return this.checksumCache.getChecksumMap(checkpoint.checkpoint, buckets);
383
407
  }
384
408
  clearChecksumCache() {
385
409
  this.checksumCache.clear();
@@ -394,7 +418,7 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
394
418
  state = ${{ type: 'varchar', value: storage.SyncRuleState.TERMINATED }},
395
419
  snapshot_done = ${{ type: 'bool', value: false }}
396
420
  WHERE
397
- id = ${{ type: 'int4', value: this.group_id }}
421
+ id = ${{ type: 'int4', value: this.replicationStreamId }}
398
422
  `.execute();
399
423
  }
400
424
  async getStatus() {
@@ -403,28 +427,27 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
403
427
  snapshot_done,
404
428
  snapshot_lsn,
405
429
  last_checkpoint_lsn,
406
- state,
407
- keepalive_op
430
+ state
408
431
  FROM
409
432
  sync_rules
410
433
  WHERE
411
- id = ${{ type: 'int4', value: this.group_id }}
434
+ id = ${{ type: 'int4', value: this.replicationStreamId }}
412
435
  `
413
- .decoded(pick(models.SyncRules, ['snapshot_done', 'last_checkpoint_lsn', 'state', 'snapshot_lsn', 'keepalive_op']))
436
+ .decoded(pick(models.SyncRules, ['snapshot_done', 'last_checkpoint_lsn', 'snapshot_lsn']))
414
437
  .first();
415
438
  if (syncRulesRow == null) {
416
439
  throw new Error('Cannot find replication stream status');
417
440
  }
418
441
  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
442
+ snapshotDone: syncRulesRow.snapshot_done && syncRulesRow.last_checkpoint_lsn != null,
443
+ resumeLsn: maxLsn(syncRulesRow.snapshot_lsn, syncRulesRow.last_checkpoint_lsn)
424
444
  };
425
445
  }
426
446
  async clear(options) {
427
- // TODO: Cleanly abort the cleanup when the provided signal is aborted.
447
+ const signal = options?.signal;
448
+ if (signal?.aborted) {
449
+ throw new framework.ReplicationAbortedError('Aborted clearing data', signal.reason);
450
+ }
428
451
  await this.db.sql `
429
452
  UPDATE sync_rules
430
453
  SET
@@ -433,24 +456,62 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
433
456
  last_checkpoint = NULL,
434
457
  no_checkpoint_before = NULL
435
458
  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 }}
459
+ id = ${{ type: 'int4', value: this.replicationStreamId }}
453
460
  `.execute();
461
+ // Delete in batches - a single DELETE covering the entire group can run for
462
+ // hours on large deployments, never completing once it exceeds statement or
463
+ // socket timeouts. Each batch is its own autocommit statement, so progress
464
+ // is durable and a retry continues where the previous attempt stopped.
465
+ await this.clearBatched('bucket_data', signal, () => this.deleteGroupBatch('bucket_data'));
466
+ await this.clearBatched('bucket_parameters', signal, () => this.deleteGroupBatch('bucket_parameters'));
467
+ await this.clearBatched('current_data', signal, () => this.currentDataStore.deleteGroupRowsBatch(this.db, {
468
+ groupId: this.replicationStreamId,
469
+ limit: CLEAR_BATCH_LIMIT
470
+ }));
471
+ await this.clearBatched('source_tables', signal, () => this.deleteGroupBatch('source_tables'));
472
+ }
473
+ async clearBatched(label, signal, deleteBatch) {
474
+ while (true) {
475
+ if (signal?.aborted) {
476
+ throw new framework.ReplicationAbortedError('Aborted clearing data', signal.reason);
477
+ }
478
+ const count = await deleteBatch();
479
+ if (count < CLEAR_BATCH_LIMIT) {
480
+ return;
481
+ }
482
+ this.logger.info(`Cleared batch of ${count} ${label} rows, continuing...`);
483
+ }
484
+ }
485
+ /**
486
+ * Delete up to {@link CLEAR_BATCH_LIMIT} rows for this group from the given table,
487
+ * returning the number of candidate rows found by the scan.
488
+ *
489
+ * The count is taken from the scan (`batch`) rather than the delete: with a
490
+ * concurrent process deleting the same rows, the delete may remove fewer rows
491
+ * than the scan found, and counting deleted rows could stop the loop while
492
+ * rows remain.
493
+ */
494
+ async deleteGroupBatch(table) {
495
+ const [row] = await this.db.queryRows({
496
+ statement: `
497
+ WITH batch AS (
498
+ SELECT ctid FROM ${table}
499
+ WHERE group_id = $1
500
+ LIMIT $2
501
+ ),
502
+ deleted AS (
503
+ DELETE FROM ${table}
504
+ WHERE ctid IN (SELECT ctid FROM batch)
505
+ RETURNING 1
506
+ )
507
+ SELECT COUNT(*) AS count FROM batch
508
+ `,
509
+ params: [
510
+ { type: 'int4', value: this.replicationStreamId },
511
+ { type: 'int4', value: CLEAR_BATCH_LIMIT }
512
+ ]
513
+ });
514
+ return row?.count ?? 0n;
454
515
  }
455
516
  async getChecksumsInternal(batch) {
456
517
  if (batch.length == 0) {
@@ -487,7 +548,7 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
487
548
  AND b.op_id > f.start_op_id
488
549
  AND b.op_id <= f.end_op_id
489
550
  WHERE
490
- b.group_id = ${{ type: 'int4', value: this.group_id }}
551
+ b.group_id = ${{ type: 'int4', value: this.replicationStreamId }}
491
552
  GROUP BY
492
553
  b.bucket_name;
493
554
  `.rows();
@@ -511,24 +572,8 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
511
572
  }));
512
573
  }
513
574
  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);
575
+ const activeCheckpointDocument = await checkpointUtils.getActiveCheckpointDocument({ db: this.db });
576
+ return this.makeActiveCheckpoint(activeCheckpointDocument);
532
577
  }
533
578
  async *watchCheckpointChanges(options) {
534
579
  let lastCheckpoint = null;
@@ -565,53 +610,84 @@ export class PostgresSyncRulesStorage extends framework.BaseObserver {
565
610
  }
566
611
  }
567
612
  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);
613
+ const sink = new LastValueSink(null);
614
+ // Listen for changes before reading the initial doc
588
615
  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;
616
+ notificationEvent: (event) => {
617
+ switch (event.type) {
618
+ case 'notification':
619
+ sink.write(event.notification.payload);
620
+ break;
621
+ case 'channels-registered':
622
+ // Add a null value to the stream, indicating the loop should query the value.
623
+ sink.write(null);
624
+ break;
625
+ case 'connection-error':
626
+ // End the watcher after reconnect attempts are exhausted. Consumers will
627
+ // retry the watcher, which registers a new listener and pokes the slot.
628
+ sink.error(event.error);
629
+ break;
630
+ }
608
631
  }
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;
632
+ });
633
+ try {
634
+ const initialCheckpointDocument = requireActiveCheckpointDocument(await checkpointUtils.getActiveCheckpointDocument({ db: this.db }));
635
+ const initialCheckpoint = this.makeActiveCheckpoint(initialCheckpointDocument);
636
+ let lastOp = initialCheckpoint;
637
+ yield initialCheckpoint;
638
+ for await (const payload of sink.withSignal(signal)) {
639
+ if (signal.aborted) {
640
+ return;
641
+ }
642
+ let baseActiveCheckpoint = null;
643
+ if (payload == null) {
644
+ // Reconnected (or manually triggered) - re-query the current checkpoint.
645
+ // Unlike the initial read, a missing document here (e.g. sync rules being
646
+ // replaced) must not abort the stream: keep waiting for the next notification.
647
+ baseActiveCheckpoint = await checkpointUtils.getActiveCheckpointDocument({ db: this.db });
648
+ if (baseActiveCheckpoint == null) {
649
+ continue;
650
+ }
651
+ }
652
+ else {
653
+ let notification;
654
+ try {
655
+ notification = models.ActiveCheckpointNotification.decode(payload);
656
+ }
657
+ catch (error) {
658
+ // A malformed payload must not abort the shared stream for every
659
+ // subscriber. Skip it and wait for the next notification.
660
+ this.logger.warn('Failed to decode active checkpoint notification, ignoring', error);
661
+ continue;
662
+ }
663
+ if (notification.active_checkpoint == null) {
664
+ continue;
665
+ }
666
+ baseActiveCheckpoint = notification.active_checkpoint;
667
+ }
668
+ if (baseActiveCheckpoint.id != initialCheckpointDocument.id) {
669
+ // Active replication stream changed - abort and restart the stream
670
+ break;
671
+ }
672
+ const activeCheckpoint = this.makeActiveCheckpoint(baseActiveCheckpoint);
673
+ const checkpointAdvanced = activeCheckpoint.checkpoint > lastOp.checkpoint;
674
+ const checkpointRegressed = activeCheckpoint.checkpoint < lastOp.checkpoint;
675
+ const lsnAdvanced = lastOp.lsn == null
676
+ ? activeCheckpoint.lsn != null
677
+ : activeCheckpoint.lsn != null && activeCheckpoint.lsn > lastOp.lsn;
678
+ const lsnRegressed = lastOp.lsn != null && (activeCheckpoint.lsn == null || activeCheckpoint.lsn < lastOp.lsn);
679
+ // Notifications buffered before a query or reconnect may be stale. Neither
680
+ // coordinate may regress, but only one needs to advance: an empty checkpoint
681
+ // updates the LSN while keeping the operation checkpoint unchanged.
682
+ if (!checkpointRegressed && !lsnRegressed && (checkpointAdvanced || lsnAdvanced)) {
683
+ lastOp = activeCheckpoint;
684
+ yield activeCheckpoint;
685
+ }
613
686
  }
614
687
  }
688
+ finally {
689
+ disposeListener();
690
+ }
615
691
  }
616
692
  async getCheckpointChanges(options) {
617
693
  // We do not track individual changes yet
@@ -638,4 +714,13 @@ const parameterSetsRow = t.object({
638
714
  index: bigint,
639
715
  bucket_parameters: t.string
640
716
  });
717
+ function requireActiveCheckpointDocument(doc) {
718
+ if (doc == null) {
719
+ // Used for the initial checkpoint read only: with no active replication stream
720
+ // at stream start, fail fast so clients disconnect and retry later. Mid-stream
721
+ // reconnects tolerate a transiently missing document instead of aborting.
722
+ throw new framework.ServiceError(framework.ErrorCode.PSYNC_S2302, 'No active replication stream available');
723
+ }
724
+ return doc;
725
+ }
641
726
  //# sourceMappingURL=PostgresSyncRulesStorage.js.map