@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
@@ -4,6 +4,8 @@ import {
4
4
  BucketChecksum,
5
5
  CHECKPOINT_INVALIDATE_ALL,
6
6
  CheckpointChanges,
7
+ CompactInitialReplicationOptions,
8
+ CompactInitialReplicationResults,
7
9
  GetCheckpointChangesOptions,
8
10
  InternalOpId,
9
11
  internalToExternalOpId,
@@ -11,11 +13,10 @@ import {
11
13
  maxLsn,
12
14
  ParameterSetLimitExceededError,
13
15
  PartialChecksum,
14
- PopulateChecksumCacheOptions,
15
- PopulateChecksumCacheResults,
16
16
  ReplicationCheckpoint,
17
17
  storage,
18
18
  StorageVersionConfig,
19
+ SyncRuleState,
19
20
  utils,
20
21
  WatchWriteCheckpointOptions
21
22
  } from '@powersync/service-core';
@@ -28,9 +29,11 @@ import { replicaIdToSubkey } from '../utils/bson.js';
28
29
  import { mapOpEntry } from '../utils/bucket-data.js';
29
30
 
30
31
  import * as framework from '@powersync/lib-services-framework';
31
- import { StatementParam } from '@powersync/service-jpgwire';
32
+ import type { Statement } from '@powersync/service-jpgwire';
32
33
  import { wrapWithAbort } from 'ix/asynciterable/operators/withabort.js';
33
34
  import * as t from 'ts-codec';
35
+ import { ActiveCheckpointDecoded } from '../types/models/ActiveCheckpoint.js';
36
+ import * as checkpointUtils from '../utils/checkpoints.js';
34
37
  import { pick } from '../utils/ts-codec.js';
35
38
  import { PostgresBucketBatch } from './batch/PostgresBucketBatch.js';
36
39
  import { PostgresWriteCheckpointAPI } from './checkpoints/PostgresWriteCheckpointAPI.js';
@@ -41,20 +44,27 @@ import { PostgresCompactor } from './PostgresCompactor.js';
41
44
  export type PostgresSyncRulesStorageOptions = {
42
45
  factory: PostgresBucketStorageFactory;
43
46
  db: lib_postgres.DatabaseClient;
44
- sync_rules: storage.PersistedSyncRulesContent;
47
+ replicationStream: storage.PersistedReplicationStream;
45
48
  write_checkpoint_mode?: storage.WriteCheckpointMode;
46
49
  batchLimits: RequiredOperationBatchLimits;
50
+ checksumCacheTtlMs?: number;
47
51
  };
48
52
 
53
+ /**
54
+ * Number of rows deleted per batch when clearing storage for a replication stream.
55
+ */
56
+ export const CLEAR_BATCH_LIMIT = 50_000;
57
+
49
58
  export class PostgresSyncRulesStorage
50
59
  extends framework.BaseObserver<storage.SyncRulesBucketStorageListener>
51
60
  implements storage.SyncRulesBucketStorage
52
61
  {
53
62
  [framework.DO_NOT_LOG] = true;
54
63
 
55
- public readonly group_id: number;
56
- public readonly sync_rules: storage.PersistedSyncRulesContent;
57
- public readonly slot_name: string;
64
+ public readonly replicationStreamId: number;
65
+ public readonly replicationStream: storage.PersistedReplicationStream;
66
+ public readonly sync_rules: storage.PersistedSyncConfigContent;
67
+ public readonly replicationStreamName: string;
58
68
  public readonly factory: PostgresBucketStorageFactory;
59
69
  public readonly storageConfig: StorageVersionConfig;
60
70
  public readonly logger: framework.Logger;
@@ -65,22 +75,27 @@ export class PostgresSyncRulesStorage
65
75
  protected writeCheckpointAPI: PostgresWriteCheckpointAPI;
66
76
  private readonly currentDataStore: PostgresCurrentDataStore;
67
77
 
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;
78
+ /**
79
+ * Canonical parsed sync config sets, keyed by defaultSchema. Entries are never evicted,
80
+ * so each parse options value maps to exactly one parsed set for the lifetime of this
81
+ * storage instance.
82
+ *
83
+ * TODO we might be able to share this in an abstract class
84
+ */
85
+ private readonly parsedSyncConfigSets = new Map<string, storage.ParsedSyncConfigSet>();
72
86
  private _checksumCache: storage.ChecksumCache | undefined;
73
87
 
74
88
  constructor(protected options: PostgresSyncRulesStorageOptions) {
75
89
  super();
76
- this.group_id = options.sync_rules.id;
90
+ this.replicationStream = options.replicationStream;
91
+ this.replicationStreamId = options.replicationStream.replicationStreamId;
77
92
  this.db = options.db;
78
- this.sync_rules = options.sync_rules;
79
- this.slot_name = options.sync_rules.slot_name;
93
+ this.sync_rules = options.replicationStream.syncConfigContent[0];
94
+ this.replicationStreamName = options.replicationStream.replicationStreamName;
80
95
  this.factory = options.factory;
81
- this.storageConfig = options.sync_rules.getStorageConfig();
96
+ this.storageConfig = options.replicationStream.getStorageConfig();
82
97
  this.currentDataStore = new PostgresCurrentDataStore(this.storageConfig);
83
- this.logger = options.sync_rules.logger;
98
+ this.logger = this.sync_rules.logger;
84
99
 
85
100
  this.writeCheckpointAPI = new PostgresWriteCheckpointAPI({
86
101
  db: this.db,
@@ -95,6 +110,7 @@ export class PostgresSyncRulesStorage
95
110
  */
96
111
  private get checksumCache(): storage.ChecksumCache {
97
112
  this._checksumCache ??= new storage.ChecksumCache({
113
+ ttlMs: this.options.checksumCacheTtlMs,
98
114
  fetchChecksums: (batch) => {
99
115
  return this.getChecksumsInternal(batch);
100
116
  }
@@ -106,18 +122,17 @@ export class PostgresSyncRulesStorage
106
122
  return this.writeCheckpointAPI.writeCheckpointMode;
107
123
  }
108
124
 
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 };
125
+ getParsedSyncConfigSet(options: storage.ParseSyncConfigOptions): storage.ParsedSyncConfigSet {
126
+ let parsed = this.parsedSyncConfigSets.get(options.defaultSchema);
127
+ if (parsed == null) {
128
+ parsed = this.replicationStream.parsed(options);
129
+ this.parsedSyncConfigSets.set(options.defaultSchema, parsed);
118
130
  }
131
+ return parsed;
132
+ }
119
133
 
120
- return this.parsedSyncRulesCache!.parsed;
134
+ getParsedSyncRules(options: storage.ParseSyncConfigOptions): sync_rules.HydratedSyncConfig {
135
+ return this.getParsedSyncConfigSet(options).hydratedSyncConfig;
121
136
  }
122
137
 
123
138
  async reportError(e: any): Promise<void> {
@@ -127,11 +142,23 @@ export class PostgresSyncRulesStorage
127
142
  SET
128
143
  last_fatal_error = ${{ type: 'varchar', value: message }}
129
144
  WHERE
130
- id = ${{ type: 'int4', value: this.group_id }};
145
+ id = ${{ type: 'int4', value: this.replicationStreamId }};
131
146
  `.execute();
132
147
  }
133
148
 
149
+ supportsIncrementalParameterCompaction(): boolean {
150
+ return false;
151
+ }
152
+
134
153
  async compact(options?: storage.CompactOptions): Promise<void> {
154
+ if (options?.incrementalOnly) {
155
+ // Not supported yet
156
+ this.logger.info('Incremental compacting is not supported on Postgres storage yet.');
157
+ return;
158
+ } else if (this.replicationStream.state != SyncRuleState.ACTIVE) {
159
+ this.logger.info(`Skipping compacting of replication stream in ${this.replicationStream.state} state.`);
160
+ return;
161
+ }
135
162
  let maxOpId = options?.maxOpId;
136
163
  if (maxOpId == null) {
137
164
  const checkpoint = await this.getCheckpoint();
@@ -139,14 +166,16 @@ export class PostgresSyncRulesStorage
139
166
  maxOpId = checkpoint.checkpoint;
140
167
  }
141
168
 
142
- return new PostgresCompactor(this.db, this.group_id, {
169
+ return new PostgresCompactor(this.db, this.replicationStreamId, {
143
170
  ...options,
144
171
  maxOpId,
145
172
  logger: this.logger
146
173
  }).compact();
147
174
  }
148
175
 
149
- async populatePersistentChecksumCache(_options: PopulateChecksumCacheOptions): Promise<PopulateChecksumCacheResults> {
176
+ async compactInitialReplication(
177
+ _options: CompactInitialReplicationOptions
178
+ ): Promise<CompactInitialReplicationResults> {
150
179
  // no-op - checksum cache is not implemented for Postgres yet
151
180
  return { buckets: 0 };
152
181
  }
@@ -154,16 +183,18 @@ export class PostgresSyncRulesStorage
154
183
  lastWriteCheckpoint(filters: storage.SyncStorageLastWriteCheckpointFilters): Promise<bigint | null> {
155
184
  return this.writeCheckpointAPI.lastWriteCheckpoint({
156
185
  ...filters,
157
- sync_rules_id: this.group_id
186
+ sync_rules_id: this.replicationStreamId
158
187
  });
159
188
  }
160
189
 
161
- setWriteCheckpointMode(mode: storage.WriteCheckpointMode): void {
162
- return this.writeCheckpointAPI.setWriteCheckpointMode(mode);
190
+ setWriteCheckpointMode(config: storage.WriteCheckpointModeConfig): void {
191
+ return this.writeCheckpointAPI.setWriteCheckpointMode(config);
163
192
  }
164
193
 
165
- createManagedWriteCheckpoint(checkpoint: storage.ManagedWriteCheckpointOptions): Promise<bigint> {
166
- return this.writeCheckpointAPI.createManagedWriteCheckpoint(checkpoint);
194
+ createManagedWriteCheckpoints(
195
+ checkpoints: storage.ManagedWriteCheckpointOptions[]
196
+ ): Promise<storage.CreateManagedWriteCheckpointsResult> {
197
+ return this.writeCheckpointAPI.createManagedWriteCheckpoints(checkpoints);
167
198
  }
168
199
 
169
200
  async getCheckpoint(): Promise<storage.ReplicationCheckpoint> {
@@ -174,7 +205,7 @@ export class PostgresSyncRulesStorage
174
205
  FROM
175
206
  sync_rules
176
207
  WHERE
177
- id = ${{ type: 'int4', value: this.group_id }}
208
+ id = ${{ type: 'int4', value: this.replicationStreamId }}
178
209
  `
179
210
  .decoded(pick(models.SyncRules, ['last_checkpoint', 'last_checkpoint_lsn']))
180
211
  .first();
@@ -196,7 +227,7 @@ export class PostgresSyncRulesStorage
196
227
  FROM
197
228
  sync_rules
198
229
  WHERE
199
- id = ${{ type: 'int4', value: this.group_id }}
230
+ id = ${{ type: 'int4', value: this.replicationStreamId }}
200
231
  `
201
232
  .decoded(pick(models.SyncRules, ['last_checkpoint_lsn', 'no_checkpoint_before', 'keepalive_op', 'snapshot_lsn']))
202
233
  .first();
@@ -206,9 +237,9 @@ export class PostgresSyncRulesStorage
206
237
  const writer = new PostgresBucketBatch({
207
238
  logger: options.logger ?? this.logger,
208
239
  db: this.db,
209
- sync_rules: this.sync_rules.parsed(options).hydratedSyncConfig(),
210
- group_id: this.group_id,
211
- slot_name: this.slot_name,
240
+ sync_rules: this.getParsedSyncRules(options),
241
+ replicationStreamId: this.replicationStreamId,
242
+ replicationStreamName: this.replicationStreamName,
212
243
  last_checkpoint_lsn: checkpoint_lsn,
213
244
  keep_alive_op: syncRules?.keepalive_op,
214
245
  resumeFromLsn: maxLsn(syncRules?.snapshot_lsn, checkpoint_lsn),
@@ -254,7 +285,7 @@ export class PostgresSyncRulesStorage
254
285
  value: lookups.map((l) => storage.serializeLookupBuffer(l).toString('hex'))
255
286
  }}) WITH ORDINALITY AS requested (value, index)
256
287
  WHERE
257
- group_id = ${{ type: 'int4', value: this.group_id }}
288
+ group_id = ${{ type: 'int4', value: this.replicationStreamId }}
258
289
  AND lookup = decode((requested.value ->> 0)::text, 'hex') -- Decode the hex string to bytea
259
290
  AND id <= ${{ type: 'int8', value: checkpoint.checkpoint }}
260
291
  ORDER BY
@@ -304,7 +335,7 @@ export class PostgresSyncRulesStorage
304
335
  }
305
336
 
306
337
  async *getBucketDataBatch(
307
- checkpoint: InternalOpId,
338
+ checkpoint: storage.ReplicationCheckpoint,
308
339
  dataBuckets: storage.BucketDataRequest[],
309
340
  options?: storage.BucketDataBatchOptions
310
341
  ): AsyncIterable<storage.SyncBucketDataChunk> {
@@ -319,8 +350,8 @@ export class PostgresSyncRulesStorage
319
350
  // Each batch query batch are streamed in separate sets of rows, which may or may
320
351
  // not match up with chunks.
321
352
 
322
- const end = checkpoint ?? BIGINT_MAX;
323
- const filters = dataBuckets.map((request) => ({ bucket_name: request.bucket, start: request.start }));
353
+ const end = checkpoint.checkpoint ?? BIGINT_MAX;
354
+ const sortedBuckets = [...dataBuckets].sort((a, b) => a.bucket.localeCompare(b.bucket));
324
355
  const startOpByBucket = new Map(dataBuckets.map((request) => [request.bucket, request.start]));
325
356
 
326
357
  const batchRowLimit = options?.limit ?? storage.DEFAULT_DOCUMENT_BATCH_LIMIT;
@@ -331,65 +362,66 @@ export class PostgresSyncRulesStorage
331
362
  let targetOp: InternalOpId | null = null;
332
363
  let batchRowCount = 0;
333
364
 
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({
365
+ const requestedBuckets = sortedBuckets
366
+ .map((_, index) => `($${index * 2 + 4}, $${index * 2 + 5}, ${index})`)
367
+ .join(', ');
368
+
369
+ // Use one round trip while keeping a parameterized index range scan per bucket. Ordering the non-flattened request
370
+ // subquery gives Postgres a presorted bucket key, allowing an incremental sort and the outer limit to stop the
371
+ // nested loop early. The inner limit cannot exclude rows needed by the outer batch because both limits are equal.
372
+ const query: Statement = {
367
373
  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;`,
374
+ SELECT
375
+ bucket_data.*
376
+ FROM
377
+ (
378
+ SELECT
379
+ *
380
+ FROM
381
+ (
382
+ VALUES ${requestedBuckets}
383
+ ) AS bucket_requests(bucket_name, start_op_id, bucket_order)
384
+ -- Present requests in bucket order so the final sort can be incremental.
385
+ ORDER BY
386
+ bucket_order ASC
387
+ -- Prevent the planner from flattening away the ordered request path.
388
+ OFFSET
389
+ 0
390
+ ) AS requested
391
+ CROSS JOIN LATERAL (
392
+ SELECT
393
+ *
394
+ FROM
395
+ bucket_data
396
+ WHERE
397
+ group_id = $1
398
+ AND bucket_name = requested.bucket_name
399
+ AND op_id > requested.start_op_id
400
+ AND op_id <= $2
401
+ -- Keep each bucket's index range scan ordered and bounded.
402
+ ORDER BY
403
+ op_id ASC
404
+ LIMIT
405
+ $3
406
+ ) AS bucket_data
407
+ -- Guarantee contiguous bucket chunks while allowing the presorted bucket key to stop scans early.
408
+ ORDER BY
409
+ requested.bucket_order ASC,
410
+ bucket_data.op_id ASC
411
+ LIMIT
412
+ $3;`,
383
413
  params: [
384
- { type: 'int4', value: this.group_id },
414
+ { type: 'int4', value: this.replicationStreamId },
385
415
  { type: 'int8', value: end },
386
416
  { 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
417
+ ...sortedBuckets.flatMap((request) => [
418
+ { type: 'varchar' as const, value: request.bucket },
419
+ { type: 'int8' as const, value: request.start }
390
420
  ])
391
421
  ]
392
- })) {
422
+ };
423
+
424
+ for await (const rows of this.db.streamRows(query)) {
393
425
  const decodedRows = rows.map((r) => models.BucketData.decode(r as any));
394
426
 
395
427
  for (const row of decodedRows) {
@@ -477,10 +509,11 @@ export class PostgresSyncRulesStorage
477
509
  }
478
510
 
479
511
  async getChecksums(
480
- checkpoint: utils.InternalOpId,
481
- buckets: storage.BucketChecksumRequest[]
512
+ checkpoint: storage.ReplicationCheckpoint,
513
+ buckets: storage.BucketChecksumRequest[],
514
+ _options?: storage.BucketChecksumOptions
482
515
  ): Promise<utils.ChecksumMap> {
483
- return this.checksumCache.getChecksumMap(checkpoint, buckets);
516
+ return this.checksumCache.getChecksumMap(checkpoint.checkpoint, buckets);
484
517
  }
485
518
 
486
519
  clearChecksumCache() {
@@ -497,26 +530,23 @@ export class PostgresSyncRulesStorage
497
530
  state = ${{ type: 'varchar', value: storage.SyncRuleState.TERMINATED }},
498
531
  snapshot_done = ${{ type: 'bool', value: false }}
499
532
  WHERE
500
- id = ${{ type: 'int4', value: this.group_id }}
533
+ id = ${{ type: 'int4', value: this.replicationStreamId }}
501
534
  `.execute();
502
535
  }
503
536
 
504
- async getStatus(): Promise<storage.SyncRuleStatus> {
537
+ async getStatus(): Promise<storage.ReplicationStreamStatus> {
505
538
  const syncRulesRow = await this.db.sql`
506
539
  SELECT
507
540
  snapshot_done,
508
541
  snapshot_lsn,
509
542
  last_checkpoint_lsn,
510
- state,
511
- keepalive_op
543
+ state
512
544
  FROM
513
545
  sync_rules
514
546
  WHERE
515
- id = ${{ type: 'int4', value: this.group_id }}
547
+ id = ${{ type: 'int4', value: this.replicationStreamId }}
516
548
  `
517
- .decoded(
518
- pick(models.SyncRules, ['snapshot_done', 'last_checkpoint_lsn', 'state', 'snapshot_lsn', 'keepalive_op'])
519
- )
549
+ .decoded(pick(models.SyncRules, ['snapshot_done', 'last_checkpoint_lsn', 'snapshot_lsn']))
520
550
  .first();
521
551
 
522
552
  if (syncRulesRow == null) {
@@ -524,16 +554,17 @@ export class PostgresSyncRulesStorage
524
554
  }
525
555
 
526
556
  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
557
+ snapshotDone: syncRulesRow.snapshot_done && syncRulesRow.last_checkpoint_lsn != null,
558
+ resumeLsn: maxLsn(syncRulesRow.snapshot_lsn, syncRulesRow.last_checkpoint_lsn)
532
559
  };
533
560
  }
534
561
 
535
562
  async clear(options?: storage.ClearStorageOptions): Promise<void> {
536
- // TODO: Cleanly abort the cleanup when the provided signal is aborted.
563
+ const signal = options?.signal;
564
+ if (signal?.aborted) {
565
+ throw new framework.ReplicationAbortedError('Aborted clearing data', signal.reason);
566
+ }
567
+
537
568
  await this.db.sql`
538
569
  UPDATE sync_rules
539
570
  SET
@@ -542,28 +573,71 @@ export class PostgresSyncRulesStorage
542
573
  last_checkpoint = NULL,
543
574
  no_checkpoint_before = NULL
544
575
  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 }}
576
+ id = ${{ type: 'int4', value: this.replicationStreamId }}
552
577
  `.execute();
553
578
 
554
- await this.db.sql`
555
- DELETE FROM bucket_parameters
556
- WHERE
557
- group_id = ${{ type: 'int4', value: this.group_id }}
558
- `.execute();
579
+ // Delete in batches - a single DELETE covering the entire group can run for
580
+ // hours on large deployments, never completing once it exceeds statement or
581
+ // socket timeouts. Each batch is its own autocommit statement, so progress
582
+ // is durable and a retry continues where the previous attempt stopped.
583
+ await this.clearBatched('bucket_data', signal, () => this.deleteGroupBatch('bucket_data'));
584
+ await this.clearBatched('bucket_parameters', signal, () => this.deleteGroupBatch('bucket_parameters'));
585
+ await this.clearBatched('current_data', signal, () =>
586
+ this.currentDataStore.deleteGroupRowsBatch(this.db, {
587
+ groupId: this.replicationStreamId,
588
+ limit: CLEAR_BATCH_LIMIT
589
+ })
590
+ );
591
+ await this.clearBatched('source_tables', signal, () => this.deleteGroupBatch('source_tables'));
592
+ }
559
593
 
560
- await this.currentDataStore.deleteGroupRows(this.db, { groupId: this.group_id });
594
+ private async clearBatched(
595
+ label: string,
596
+ signal: AbortSignal | undefined,
597
+ deleteBatch: () => Promise<bigint>
598
+ ): Promise<void> {
599
+ while (true) {
600
+ if (signal?.aborted) {
601
+ throw new framework.ReplicationAbortedError('Aborted clearing data', signal.reason);
602
+ }
603
+ const count = await deleteBatch();
604
+ if (count < CLEAR_BATCH_LIMIT) {
605
+ return;
606
+ }
607
+ this.logger.info(`Cleared batch of ${count} ${label} rows, continuing...`);
608
+ }
609
+ }
561
610
 
562
- await this.db.sql`
563
- DELETE FROM source_tables
564
- WHERE
565
- group_id = ${{ type: 'int4', value: this.group_id }}
566
- `.execute();
611
+ /**
612
+ * Delete up to {@link CLEAR_BATCH_LIMIT} rows for this group from the given table,
613
+ * returning the number of candidate rows found by the scan.
614
+ *
615
+ * The count is taken from the scan (`batch`) rather than the delete: with a
616
+ * concurrent process deleting the same rows, the delete may remove fewer rows
617
+ * than the scan found, and counting deleted rows could stop the loop while
618
+ * rows remain.
619
+ */
620
+ private async deleteGroupBatch(table: 'bucket_data' | 'bucket_parameters' | 'source_tables'): Promise<bigint> {
621
+ const [row] = await this.db.queryRows<{ count: bigint }>({
622
+ statement: `
623
+ WITH batch AS (
624
+ SELECT ctid FROM ${table}
625
+ WHERE group_id = $1
626
+ LIMIT $2
627
+ ),
628
+ deleted AS (
629
+ DELETE FROM ${table}
630
+ WHERE ctid IN (SELECT ctid FROM batch)
631
+ RETURNING 1
632
+ )
633
+ SELECT COUNT(*) AS count FROM batch
634
+ `,
635
+ params: [
636
+ { type: 'int4', value: this.replicationStreamId },
637
+ { type: 'int4', value: CLEAR_BATCH_LIMIT }
638
+ ]
639
+ });
640
+ return row?.count ?? 0n;
567
641
  }
568
642
 
569
643
  private async getChecksumsInternal(batch: storage.FetchPartialBucketChecksum[]): Promise<storage.PartialChecksumMap> {
@@ -603,7 +677,7 @@ export class PostgresSyncRulesStorage
603
677
  AND b.op_id > f.start_op_id
604
678
  AND b.op_id <= f.end_op_id
605
679
  WHERE
606
- b.group_id = ${{ type: 'int4', value: this.group_id }}
680
+ b.group_id = ${{ type: 'int4', value: this.replicationStreamId }}
607
681
  GROUP BY
608
682
  b.bucket_name;
609
683
  `.rows<{ bucket: string; checksum_total: bigint; total: bigint; has_clear_op: number }>();
@@ -632,25 +706,8 @@ export class PostgresSyncRulesStorage
632
706
  }
633
707
 
634
708
  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);
709
+ const activeCheckpointDocument = await checkpointUtils.getActiveCheckpointDocument({ db: this.db });
710
+ return this.makeActiveCheckpoint(activeCheckpointDocument);
654
711
  }
655
712
 
656
713
  async *watchCheckpointChanges(options: WatchWriteCheckpointOptions): AsyncIterable<storage.StorageCheckpointUpdate> {
@@ -696,61 +753,92 @@ export class PostgresSyncRulesStorage
696
753
  }
697
754
 
698
755
  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);
756
+ const sink = new LastValueSink<string | null>(null);
721
757
 
758
+ // Listen for changes before reading the initial doc
722
759
  const disposeListener = this.db.registerListener({
723
- notification: (notification) => sink.write(notification.payload)
760
+ notificationEvent: (event) => {
761
+ switch (event.type) {
762
+ case 'notification':
763
+ sink.write(event.notification.payload);
764
+ break;
765
+ case 'channels-registered':
766
+ // Add a null value to the stream, indicating the loop should query the value.
767
+ sink.write(null);
768
+ break;
769
+ case 'connection-error':
770
+ // End the watcher after reconnect attempts are exhausted. Consumers will
771
+ // retry the watcher, which registers a new listener and pokes the slot.
772
+ sink.error(event.error);
773
+ break;
774
+ }
775
+ }
724
776
  });
725
777
 
726
- signal.addEventListener('aborted', async () => {
727
- disposeListener();
728
- sink.end();
729
- });
778
+ try {
779
+ const initialCheckpointDocument = requireActiveCheckpointDocument(
780
+ await checkpointUtils.getActiveCheckpointDocument({ db: this.db })
781
+ );
782
+ const initialCheckpoint = this.makeActiveCheckpoint(initialCheckpointDocument);
783
+ let lastOp = initialCheckpoint;
730
784
 
731
- yield this.makeActiveCheckpoint(doc);
785
+ yield initialCheckpoint;
732
786
 
733
- let lastOp: storage.ReplicationCheckpoint | null = null;
734
- for await (const payload of sink.withSignal(signal)) {
735
- if (signal.aborted) {
736
- return;
737
- }
787
+ for await (const payload of sink.withSignal(signal)) {
788
+ if (signal.aborted) {
789
+ return;
790
+ }
738
791
 
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
- }
792
+ let baseActiveCheckpoint: ActiveCheckpointDecoded | null = null;
793
+ if (payload == null) {
794
+ // Reconnected (or manually triggered) - re-query the current checkpoint.
795
+ // Unlike the initial read, a missing document here (e.g. sync rules being
796
+ // replaced) must not abort the stream: keep waiting for the next notification.
797
+ baseActiveCheckpoint = await checkpointUtils.getActiveCheckpointDocument({ db: this.db });
798
+ if (baseActiveCheckpoint == null) {
799
+ continue;
800
+ }
801
+ } else {
802
+ let notification: models.ActiveCheckpointNotificationDecoded;
803
+ try {
804
+ notification = models.ActiveCheckpointNotification.decode(payload);
805
+ } catch (error) {
806
+ // A malformed payload must not abort the shared stream for every
807
+ // subscriber. Skip it and wait for the next notification.
808
+ this.logger.warn('Failed to decode active checkpoint notification, ignoring', error);
809
+ continue;
810
+ }
811
+ if (notification.active_checkpoint == null) {
812
+ continue;
813
+ }
814
+ baseActiveCheckpoint = notification.active_checkpoint;
815
+ }
747
816
 
748
- const activeCheckpoint = this.makeActiveCheckpoint(notification.active_checkpoint);
817
+ if (baseActiveCheckpoint.id != initialCheckpointDocument.id) {
818
+ // Active replication stream changed - abort and restart the stream
819
+ break;
820
+ }
749
821
 
750
- if (lastOp == null || activeCheckpoint.lsn != lastOp.lsn || activeCheckpoint.checkpoint != lastOp.checkpoint) {
751
- lastOp = activeCheckpoint;
752
- yield activeCheckpoint;
822
+ const activeCheckpoint = this.makeActiveCheckpoint(baseActiveCheckpoint);
823
+
824
+ const checkpointAdvanced = activeCheckpoint.checkpoint > lastOp.checkpoint;
825
+ const checkpointRegressed = activeCheckpoint.checkpoint < lastOp.checkpoint;
826
+ const lsnAdvanced =
827
+ lastOp.lsn == null
828
+ ? activeCheckpoint.lsn != null
829
+ : activeCheckpoint.lsn != null && activeCheckpoint.lsn > lastOp.lsn;
830
+ const lsnRegressed = lastOp.lsn != null && (activeCheckpoint.lsn == null || activeCheckpoint.lsn < lastOp.lsn);
831
+
832
+ // Notifications buffered before a query or reconnect may be stale. Neither
833
+ // coordinate may regress, but only one needs to advance: an empty checkpoint
834
+ // updates the LSN while keeping the operation checkpoint unchanged.
835
+ if (!checkpointRegressed && !lsnRegressed && (checkpointAdvanced || lsnAdvanced)) {
836
+ lastOp = activeCheckpoint;
837
+ yield activeCheckpoint;
838
+ }
753
839
  }
840
+ } finally {
841
+ disposeListener();
754
842
  }
755
843
  }
756
844
 
@@ -783,3 +871,14 @@ const parameterSetsRow = t.object({
783
871
  index: bigint,
784
872
  bucket_parameters: t.string
785
873
  });
874
+
875
+ function requireActiveCheckpointDocument(doc: models.ActiveCheckpointDecoded | null): models.ActiveCheckpointDecoded {
876
+ if (doc == null) {
877
+ // Used for the initial checkpoint read only: with no active replication stream
878
+ // at stream start, fail fast so clients disconnect and retry later. Mid-stream
879
+ // reconnects tolerate a transiently missing document instead of aborting.
880
+ throw new framework.ServiceError(framework.ErrorCode.PSYNC_S2302, 'No active replication stream available');
881
+ }
882
+
883
+ return doc;
884
+ }