@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.
- package/CHANGELOG.md +101 -6
- package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.d.ts +3 -0
- package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.js +42 -0
- package/dist/migrations/scripts/1782950400000-checkpoint-requested-at.js.map +1 -0
- package/dist/{@types/storage → storage}/PostgresBucketStorageFactory.d.ts +13 -12
- package/dist/storage/PostgresBucketStorageFactory.js +59 -55
- package/dist/storage/PostgresBucketStorageFactory.js.map +1 -1
- package/dist/{@types/storage → storage}/PostgresCompactor.d.ts +3 -1
- package/dist/storage/PostgresCompactor.js +21 -1
- package/dist/storage/PostgresCompactor.js.map +1 -1
- package/dist/storage/PostgresReportStorage.js.map +1 -1
- package/dist/storage/PostgresStorageProvider.js +2 -1
- package/dist/storage/PostgresStorageProvider.js.map +1 -1
- package/dist/{@types/storage → storage}/PostgresSyncRulesStorage.d.ts +36 -11
- package/dist/storage/PostgresSyncRulesStorage.js +250 -177
- package/dist/storage/PostgresSyncRulesStorage.js.map +1 -1
- package/dist/{@types/storage → storage}/batch/PostgresBucketBatch.d.ts +8 -24
- package/dist/storage/batch/PostgresBucketBatch.js +229 -194
- package/dist/storage/batch/PostgresBucketBatch.js.map +1 -1
- package/dist/{@types/storage → storage}/checkpoints/PostgresWriteCheckpointAPI.d.ts +1 -1
- package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js +161 -24
- package/dist/storage/checkpoints/PostgresWriteCheckpointAPI.js.map +1 -1
- package/dist/{@types/storage → storage}/current-data-store.d.ts +8 -2
- package/dist/storage/current-data-store.js +66 -11
- package/dist/storage/current-data-store.js.map +1 -1
- package/dist/{@types/storage → storage}/storage-index.d.ts +1 -0
- package/dist/storage/storage-index.js +1 -0
- package/dist/storage/storage-index.js.map +1 -1
- package/dist/storage/sync-rules/PostgresPersistedSyncConfigContent.d.ts +17 -0
- package/dist/storage/sync-rules/PostgresPersistedSyncConfigContent.js +90 -0
- package/dist/storage/sync-rules/PostgresPersistedSyncConfigContent.js.map +1 -0
- package/dist/{@types/types → types}/models/WriteCheckpoint.d.ts +2 -0
- package/dist/types/models/WriteCheckpoint.js +5 -2
- package/dist/types/models/WriteCheckpoint.js.map +1 -1
- package/dist/utils/checkpoints.d.ts +9 -0
- package/dist/utils/checkpoints.js +26 -0
- package/dist/utils/checkpoints.js.map +1 -0
- package/dist/utils/test-utils.js +2 -2
- package/dist/utils/test-utils.js.map +1 -1
- package/package.json +10 -22
- package/src/migrations/scripts/1782950400000-checkpoint-requested-at.ts +51 -0
- package/src/storage/PostgresBucketStorageFactory.ts +67 -58
- package/src/storage/PostgresCompactor.ts +25 -1
- package/src/storage/PostgresReportStorage.ts +1 -2
- package/src/storage/PostgresStorageProvider.ts +2 -1
- package/src/storage/PostgresSyncRulesStorage.ts +275 -191
- package/src/storage/batch/PostgresBucketBatch.ts +256 -214
- package/src/storage/checkpoints/PostgresWriteCheckpointAPI.ts +174 -24
- package/src/storage/current-data-store.ts +66 -11
- package/src/storage/storage-index.ts +1 -0
- package/src/storage/sync-rules/PostgresPersistedSyncConfigContent.ts +98 -0
- package/src/types/models/WriteCheckpoint.ts +5 -2
- package/src/utils/checkpoints.ts +31 -0
- package/src/utils/test-utils.ts +2 -2
- package/test/src/__snapshots__/storage_sync.test.ts.snap +0 -582
- package/test/src/checkpoint_notifications.test.ts +522 -0
- package/test/src/migrations.test.ts +1 -1
- package/test/src/storage.test.ts +226 -10
- package/test/src/storage_compacting.test.ts +15 -7
- package/test/src/storage_sync.test.ts +11 -8
- package/test/tsconfig.json +1 -3
- package/tsconfig.json +1 -4
- package/tsconfig.tsbuildinfo +1 -0
- package/dist/.tsbuildinfo +0 -1
- package/dist/@types/storage/sync-rules/PostgresPersistedSyncRulesContent.d.ts +0 -9
- package/dist/storage/sync-rules/PostgresPersistedSyncRulesContent.js +0 -49
- package/dist/storage/sync-rules/PostgresPersistedSyncRulesContent.js.map +0 -1
- package/src/storage/sync-rules/PostgresPersistedSyncRulesContent.ts +0 -54
- /package/dist/{@types/index.d.ts → index.d.ts} +0 -0
- /package/dist/{@types/migrations → migrations}/PostgresMigrationAgent.d.ts +0 -0
- /package/dist/{@types/migrations → migrations}/PostgresMigrationStore.d.ts +0 -0
- /package/dist/{@types/migrations → migrations}/migration-utils.d.ts +0 -0
- /package/dist/{@types/migrations → migrations}/scripts/1684951997326-init.d.ts +0 -0
- /package/dist/{@types/migrations → migrations}/scripts/1749024804042-snapshot-progress.d.ts +0 -0
- /package/dist/{@types/migrations → migrations}/scripts/1756282360128-connection-reporting.d.ts +0 -0
- /package/dist/{@types/migrations → migrations}/scripts/1771232439485-storage-version.d.ts +0 -0
- /package/dist/{@types/migrations → migrations}/scripts/1771424826685-current-data-pending-deletes.d.ts +0 -0
- /package/dist/{@types/migrations → migrations}/scripts/1771491856000-sync-plan.d.ts +0 -0
- /package/dist/{@types/module → module}/PostgresStorageModule.d.ts +0 -0
- /package/dist/{@types/storage → storage}/PostgresReportStorage.d.ts +0 -0
- /package/dist/{@types/storage → storage}/PostgresStorageProvider.d.ts +0 -0
- /package/dist/{@types/storage → storage}/batch/OperationBatch.d.ts +0 -0
- /package/dist/{@types/storage → storage}/batch/PostgresPersistedBatch.d.ts +0 -0
- /package/dist/{@types/storage → storage}/table-id.d.ts +0 -0
- /package/dist/{@types/types → types}/codecs.d.ts +0 -0
- /package/dist/{@types/types → types}/models/ActiveCheckpoint.d.ts +0 -0
- /package/dist/{@types/types → types}/models/ActiveCheckpointNotification.d.ts +0 -0
- /package/dist/{@types/types → types}/models/BucketData.d.ts +0 -0
- /package/dist/{@types/types → types}/models/BucketParameters.d.ts +0 -0
- /package/dist/{@types/types → types}/models/CurrentData.d.ts +0 -0
- /package/dist/{@types/types → types}/models/Instance.d.ts +0 -0
- /package/dist/{@types/types → types}/models/Migration.d.ts +0 -0
- /package/dist/{@types/types → types}/models/SdkReporting.d.ts +0 -0
- /package/dist/{@types/types → types}/models/SourceTable.d.ts +0 -0
- /package/dist/{@types/types → types}/models/SyncRules.d.ts +0 -0
- /package/dist/{@types/types → types}/models/json.d.ts +0 -0
- /package/dist/{@types/types → types}/models/models-index.d.ts +0 -0
- /package/dist/{@types/types → types}/types.d.ts +0 -0
- /package/dist/{@types/utils → utils}/application-name.d.ts +0 -0
- /package/dist/{@types/utils → utils}/bson.d.ts +0 -0
- /package/dist/{@types/utils → utils}/bucket-data.d.ts +0 -0
- /package/dist/{@types/utils → utils}/db.d.ts +0 -0
- /package/dist/{@types/utils → utils}/test-utils.d.ts +0 -0
- /package/dist/{@types/utils → utils}/ts-codec.d.ts +0 -0
- /package/dist/{@types/utils → utils}/utils-index.d.ts +0 -0
|
@@ -28,9 +28,11 @@ import { replicaIdToSubkey } from '../utils/bson.js';
|
|
|
28
28
|
import { mapOpEntry } from '../utils/bucket-data.js';
|
|
29
29
|
|
|
30
30
|
import * as framework from '@powersync/lib-services-framework';
|
|
31
|
-
import {
|
|
31
|
+
import type { Statement } from '@powersync/service-jpgwire';
|
|
32
32
|
import { wrapWithAbort } from 'ix/asynciterable/operators/withabort.js';
|
|
33
33
|
import * as t from 'ts-codec';
|
|
34
|
+
import { ActiveCheckpointDecoded } from '../types/models/ActiveCheckpoint.js';
|
|
35
|
+
import * as checkpointUtils from '../utils/checkpoints.js';
|
|
34
36
|
import { pick } from '../utils/ts-codec.js';
|
|
35
37
|
import { PostgresBucketBatch } from './batch/PostgresBucketBatch.js';
|
|
36
38
|
import { PostgresWriteCheckpointAPI } from './checkpoints/PostgresWriteCheckpointAPI.js';
|
|
@@ -41,20 +43,27 @@ import { PostgresCompactor } from './PostgresCompactor.js';
|
|
|
41
43
|
export type PostgresSyncRulesStorageOptions = {
|
|
42
44
|
factory: PostgresBucketStorageFactory;
|
|
43
45
|
db: lib_postgres.DatabaseClient;
|
|
44
|
-
|
|
46
|
+
replicationStream: storage.PersistedReplicationStream;
|
|
45
47
|
write_checkpoint_mode?: storage.WriteCheckpointMode;
|
|
46
48
|
batchLimits: RequiredOperationBatchLimits;
|
|
49
|
+
checksumCacheTtlMs?: number;
|
|
47
50
|
};
|
|
48
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Number of rows deleted per batch when clearing storage for a replication stream.
|
|
54
|
+
*/
|
|
55
|
+
export const CLEAR_BATCH_LIMIT = 50_000;
|
|
56
|
+
|
|
49
57
|
export class PostgresSyncRulesStorage
|
|
50
58
|
extends framework.BaseObserver<storage.SyncRulesBucketStorageListener>
|
|
51
59
|
implements storage.SyncRulesBucketStorage
|
|
52
60
|
{
|
|
53
61
|
[framework.DO_NOT_LOG] = true;
|
|
54
62
|
|
|
55
|
-
public readonly
|
|
56
|
-
public readonly
|
|
57
|
-
public readonly
|
|
63
|
+
public readonly replicationStreamId: number;
|
|
64
|
+
public readonly replicationStream: storage.PersistedReplicationStream;
|
|
65
|
+
public readonly sync_rules: storage.PersistedSyncConfigContent;
|
|
66
|
+
public readonly replicationStreamName: string;
|
|
58
67
|
public readonly factory: PostgresBucketStorageFactory;
|
|
59
68
|
public readonly storageConfig: StorageVersionConfig;
|
|
60
69
|
public readonly logger: framework.Logger;
|
|
@@ -65,22 +74,27 @@ export class PostgresSyncRulesStorage
|
|
|
65
74
|
protected writeCheckpointAPI: PostgresWriteCheckpointAPI;
|
|
66
75
|
private readonly currentDataStore: PostgresCurrentDataStore;
|
|
67
76
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Canonical parsed sync config sets, keyed by defaultSchema. Entries are never evicted,
|
|
79
|
+
* so each parse options value maps to exactly one parsed set for the lifetime of this
|
|
80
|
+
* storage instance.
|
|
81
|
+
*
|
|
82
|
+
* TODO we might be able to share this in an abstract class
|
|
83
|
+
*/
|
|
84
|
+
private readonly parsedSyncConfigSets = new Map<string, storage.ParsedSyncConfigSet>();
|
|
72
85
|
private _checksumCache: storage.ChecksumCache | undefined;
|
|
73
86
|
|
|
74
87
|
constructor(protected options: PostgresSyncRulesStorageOptions) {
|
|
75
88
|
super();
|
|
76
|
-
this.
|
|
89
|
+
this.replicationStream = options.replicationStream;
|
|
90
|
+
this.replicationStreamId = options.replicationStream.replicationStreamId;
|
|
77
91
|
this.db = options.db;
|
|
78
|
-
this.sync_rules = options.
|
|
79
|
-
this.
|
|
92
|
+
this.sync_rules = options.replicationStream.syncConfigContent[0];
|
|
93
|
+
this.replicationStreamName = options.replicationStream.replicationStreamName;
|
|
80
94
|
this.factory = options.factory;
|
|
81
|
-
this.storageConfig = options.
|
|
95
|
+
this.storageConfig = options.replicationStream.getStorageConfig();
|
|
82
96
|
this.currentDataStore = new PostgresCurrentDataStore(this.storageConfig);
|
|
83
|
-
this.logger = options.
|
|
97
|
+
this.logger = options.replicationStream.logger;
|
|
84
98
|
|
|
85
99
|
this.writeCheckpointAPI = new PostgresWriteCheckpointAPI({
|
|
86
100
|
db: this.db,
|
|
@@ -95,6 +109,7 @@ export class PostgresSyncRulesStorage
|
|
|
95
109
|
*/
|
|
96
110
|
private get checksumCache(): storage.ChecksumCache {
|
|
97
111
|
this._checksumCache ??= new storage.ChecksumCache({
|
|
112
|
+
ttlMs: this.options.checksumCacheTtlMs,
|
|
98
113
|
fetchChecksums: (batch) => {
|
|
99
114
|
return this.getChecksumsInternal(batch);
|
|
100
115
|
}
|
|
@@ -106,18 +121,17 @@ export class PostgresSyncRulesStorage
|
|
|
106
121
|
return this.writeCheckpointAPI.writeCheckpointMode;
|
|
107
122
|
}
|
|
108
123
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
* Parse sync config if the options are different or if there is no cached value.
|
|
115
|
-
*/
|
|
116
|
-
if (!parsed || options.defaultSchema != cachedOptions?.defaultSchema) {
|
|
117
|
-
this.parsedSyncRulesCache = { parsed: this.sync_rules.parsed(options).hydratedSyncConfig(), options };
|
|
124
|
+
getParsedSyncConfigSet(options: storage.ParseSyncConfigOptions): storage.ParsedSyncConfigSet {
|
|
125
|
+
let parsed = this.parsedSyncConfigSets.get(options.defaultSchema);
|
|
126
|
+
if (parsed == null) {
|
|
127
|
+
parsed = this.replicationStream.parsed(options);
|
|
128
|
+
this.parsedSyncConfigSets.set(options.defaultSchema, parsed);
|
|
118
129
|
}
|
|
130
|
+
return parsed;
|
|
131
|
+
}
|
|
119
132
|
|
|
120
|
-
|
|
133
|
+
getParsedSyncRules(options: storage.ParseSyncConfigOptions): sync_rules.HydratedSyncConfig {
|
|
134
|
+
return this.getParsedSyncConfigSet(options).hydratedSyncConfig;
|
|
121
135
|
}
|
|
122
136
|
|
|
123
137
|
async reportError(e: any): Promise<void> {
|
|
@@ -127,7 +141,7 @@ export class PostgresSyncRulesStorage
|
|
|
127
141
|
SET
|
|
128
142
|
last_fatal_error = ${{ type: 'varchar', value: message }}
|
|
129
143
|
WHERE
|
|
130
|
-
id = ${{ type: 'int4', value: this.
|
|
144
|
+
id = ${{ type: 'int4', value: this.replicationStreamId }};
|
|
131
145
|
`.execute();
|
|
132
146
|
}
|
|
133
147
|
|
|
@@ -139,7 +153,7 @@ export class PostgresSyncRulesStorage
|
|
|
139
153
|
maxOpId = checkpoint.checkpoint;
|
|
140
154
|
}
|
|
141
155
|
|
|
142
|
-
return new PostgresCompactor(this.db, this.
|
|
156
|
+
return new PostgresCompactor(this.db, this.replicationStreamId, {
|
|
143
157
|
...options,
|
|
144
158
|
maxOpId,
|
|
145
159
|
logger: this.logger
|
|
@@ -154,7 +168,7 @@ export class PostgresSyncRulesStorage
|
|
|
154
168
|
lastWriteCheckpoint(filters: storage.SyncStorageLastWriteCheckpointFilters): Promise<bigint | null> {
|
|
155
169
|
return this.writeCheckpointAPI.lastWriteCheckpoint({
|
|
156
170
|
...filters,
|
|
157
|
-
sync_rules_id: this.
|
|
171
|
+
sync_rules_id: this.replicationStreamId
|
|
158
172
|
});
|
|
159
173
|
}
|
|
160
174
|
|
|
@@ -162,8 +176,10 @@ export class PostgresSyncRulesStorage
|
|
|
162
176
|
return this.writeCheckpointAPI.setWriteCheckpointMode(mode);
|
|
163
177
|
}
|
|
164
178
|
|
|
165
|
-
|
|
166
|
-
|
|
179
|
+
createManagedWriteCheckpoints(
|
|
180
|
+
checkpoints: storage.ManagedWriteCheckpointOptions[]
|
|
181
|
+
): Promise<storage.CreateManagedWriteCheckpointsResult> {
|
|
182
|
+
return this.writeCheckpointAPI.createManagedWriteCheckpoints(checkpoints);
|
|
167
183
|
}
|
|
168
184
|
|
|
169
185
|
async getCheckpoint(): Promise<storage.ReplicationCheckpoint> {
|
|
@@ -174,7 +190,7 @@ export class PostgresSyncRulesStorage
|
|
|
174
190
|
FROM
|
|
175
191
|
sync_rules
|
|
176
192
|
WHERE
|
|
177
|
-
id = ${{ type: 'int4', value: this.
|
|
193
|
+
id = ${{ type: 'int4', value: this.replicationStreamId }}
|
|
178
194
|
`
|
|
179
195
|
.decoded(pick(models.SyncRules, ['last_checkpoint', 'last_checkpoint_lsn']))
|
|
180
196
|
.first();
|
|
@@ -196,7 +212,7 @@ export class PostgresSyncRulesStorage
|
|
|
196
212
|
FROM
|
|
197
213
|
sync_rules
|
|
198
214
|
WHERE
|
|
199
|
-
id = ${{ type: 'int4', value: this.
|
|
215
|
+
id = ${{ type: 'int4', value: this.replicationStreamId }}
|
|
200
216
|
`
|
|
201
217
|
.decoded(pick(models.SyncRules, ['last_checkpoint_lsn', 'no_checkpoint_before', 'keepalive_op', 'snapshot_lsn']))
|
|
202
218
|
.first();
|
|
@@ -206,9 +222,9 @@ export class PostgresSyncRulesStorage
|
|
|
206
222
|
const writer = new PostgresBucketBatch({
|
|
207
223
|
logger: options.logger ?? this.logger,
|
|
208
224
|
db: this.db,
|
|
209
|
-
sync_rules: this.
|
|
210
|
-
|
|
211
|
-
|
|
225
|
+
sync_rules: this.getParsedSyncRules(options),
|
|
226
|
+
replicationStreamId: this.replicationStreamId,
|
|
227
|
+
replicationStreamName: this.replicationStreamName,
|
|
212
228
|
last_checkpoint_lsn: checkpoint_lsn,
|
|
213
229
|
keep_alive_op: syncRules?.keepalive_op,
|
|
214
230
|
resumeFromLsn: maxLsn(syncRules?.snapshot_lsn, checkpoint_lsn),
|
|
@@ -254,7 +270,7 @@ export class PostgresSyncRulesStorage
|
|
|
254
270
|
value: lookups.map((l) => storage.serializeLookupBuffer(l).toString('hex'))
|
|
255
271
|
}}) WITH ORDINALITY AS requested (value, index)
|
|
256
272
|
WHERE
|
|
257
|
-
group_id = ${{ type: 'int4', value: this.
|
|
273
|
+
group_id = ${{ type: 'int4', value: this.replicationStreamId }}
|
|
258
274
|
AND lookup = decode((requested.value ->> 0)::text, 'hex') -- Decode the hex string to bytea
|
|
259
275
|
AND id <= ${{ type: 'int8', value: checkpoint.checkpoint }}
|
|
260
276
|
ORDER BY
|
|
@@ -304,7 +320,7 @@ export class PostgresSyncRulesStorage
|
|
|
304
320
|
}
|
|
305
321
|
|
|
306
322
|
async *getBucketDataBatch(
|
|
307
|
-
checkpoint:
|
|
323
|
+
checkpoint: storage.ReplicationCheckpoint,
|
|
308
324
|
dataBuckets: storage.BucketDataRequest[],
|
|
309
325
|
options?: storage.BucketDataBatchOptions
|
|
310
326
|
): AsyncIterable<storage.SyncBucketDataChunk> {
|
|
@@ -319,8 +335,8 @@ export class PostgresSyncRulesStorage
|
|
|
319
335
|
// Each batch query batch are streamed in separate sets of rows, which may or may
|
|
320
336
|
// not match up with chunks.
|
|
321
337
|
|
|
322
|
-
const end = checkpoint ?? BIGINT_MAX;
|
|
323
|
-
const
|
|
338
|
+
const end = checkpoint.checkpoint ?? BIGINT_MAX;
|
|
339
|
+
const sortedBuckets = [...dataBuckets].sort((a, b) => a.bucket.localeCompare(b.bucket));
|
|
324
340
|
const startOpByBucket = new Map(dataBuckets.map((request) => [request.bucket, request.start]));
|
|
325
341
|
|
|
326
342
|
const batchRowLimit = options?.limit ?? storage.DEFAULT_DOCUMENT_BATCH_LIMIT;
|
|
@@ -331,65 +347,66 @@ export class PostgresSyncRulesStorage
|
|
|
331
347
|
let targetOp: InternalOpId | null = null;
|
|
332
348
|
let batchRowCount = 0;
|
|
333
349
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
* FROM
|
|
343
|
-
* jsonb_array_elements($1::jsonb) AS FILTER
|
|
344
|
-
* )
|
|
345
|
-
* SELECT
|
|
346
|
-
* b.*,
|
|
347
|
-
* octet_length(b.data) AS data_size
|
|
348
|
-
* FROM
|
|
349
|
-
* bucket_data b
|
|
350
|
-
* JOIN filter_data f ON b.bucket_name = f.bucket_name
|
|
351
|
-
* AND b.op_id > f.start_op_id
|
|
352
|
-
* AND b.op_id <= $2
|
|
353
|
-
* WHERE
|
|
354
|
-
* b.group_id = $3
|
|
355
|
-
* ORDER BY
|
|
356
|
-
* b.bucket_name ASC,
|
|
357
|
-
* b.op_id ASC
|
|
358
|
-
* LIMIT
|
|
359
|
-
* $4;
|
|
360
|
-
* ```
|
|
361
|
-
* Which might be better for large volumes of buckets, but in testing the JSON method
|
|
362
|
-
* was significantly slower than the method below. Syncing 2.5 million rows in a single
|
|
363
|
-
* bucket takes 2 minutes and 11 seconds with the method below. With the JSON method
|
|
364
|
-
* 1 million rows were only synced before a 5 minute timeout.
|
|
365
|
-
*/
|
|
366
|
-
for await (const rows of this.db.streamRows({
|
|
350
|
+
const requestedBuckets = sortedBuckets
|
|
351
|
+
.map((_, index) => `($${index * 2 + 4}, $${index * 2 + 5}, ${index})`)
|
|
352
|
+
.join(', ');
|
|
353
|
+
|
|
354
|
+
// Use one round trip while keeping a parameterized index range scan per bucket. Ordering the non-flattened request
|
|
355
|
+
// subquery gives Postgres a presorted bucket key, allowing an incremental sort and the outer limit to stop the
|
|
356
|
+
// nested loop early. The inner limit cannot exclude rows needed by the outer batch because both limits are equal.
|
|
357
|
+
const query: Statement = {
|
|
367
358
|
statement: `
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
359
|
+
SELECT
|
|
360
|
+
bucket_data.*
|
|
361
|
+
FROM
|
|
362
|
+
(
|
|
363
|
+
SELECT
|
|
364
|
+
*
|
|
365
|
+
FROM
|
|
366
|
+
(
|
|
367
|
+
VALUES ${requestedBuckets}
|
|
368
|
+
) AS bucket_requests(bucket_name, start_op_id, bucket_order)
|
|
369
|
+
-- Present requests in bucket order so the final sort can be incremental.
|
|
370
|
+
ORDER BY
|
|
371
|
+
bucket_order ASC
|
|
372
|
+
-- Prevent the planner from flattening away the ordered request path.
|
|
373
|
+
OFFSET
|
|
374
|
+
0
|
|
375
|
+
) AS requested
|
|
376
|
+
CROSS JOIN LATERAL (
|
|
377
|
+
SELECT
|
|
378
|
+
*
|
|
379
|
+
FROM
|
|
380
|
+
bucket_data
|
|
381
|
+
WHERE
|
|
382
|
+
group_id = $1
|
|
383
|
+
AND bucket_name = requested.bucket_name
|
|
384
|
+
AND op_id > requested.start_op_id
|
|
385
|
+
AND op_id <= $2
|
|
386
|
+
-- Keep each bucket's index range scan ordered and bounded.
|
|
387
|
+
ORDER BY
|
|
388
|
+
op_id ASC
|
|
389
|
+
LIMIT
|
|
390
|
+
$3
|
|
391
|
+
) AS bucket_data
|
|
392
|
+
-- Guarantee contiguous bucket chunks while allowing the presorted bucket key to stop scans early.
|
|
393
|
+
ORDER BY
|
|
394
|
+
requested.bucket_order ASC,
|
|
395
|
+
bucket_data.op_id ASC
|
|
396
|
+
LIMIT
|
|
397
|
+
$3;`,
|
|
383
398
|
params: [
|
|
384
|
-
{ type: 'int4', value: this.
|
|
399
|
+
{ type: 'int4', value: this.replicationStreamId },
|
|
385
400
|
{ type: 'int8', value: end },
|
|
386
401
|
{ type: 'int4', value: batchRowLimit },
|
|
387
|
-
...
|
|
388
|
-
{ type: 'varchar' as const, value:
|
|
389
|
-
{ type: 'int8' as const, value:
|
|
402
|
+
...sortedBuckets.flatMap((request) => [
|
|
403
|
+
{ type: 'varchar' as const, value: request.bucket },
|
|
404
|
+
{ type: 'int8' as const, value: request.start }
|
|
390
405
|
])
|
|
391
406
|
]
|
|
392
|
-
}
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
for await (const rows of this.db.streamRows(query)) {
|
|
393
410
|
const decodedRows = rows.map((r) => models.BucketData.decode(r as any));
|
|
394
411
|
|
|
395
412
|
for (const row of decodedRows) {
|
|
@@ -477,10 +494,11 @@ export class PostgresSyncRulesStorage
|
|
|
477
494
|
}
|
|
478
495
|
|
|
479
496
|
async getChecksums(
|
|
480
|
-
checkpoint:
|
|
481
|
-
buckets: storage.BucketChecksumRequest[]
|
|
497
|
+
checkpoint: storage.ReplicationCheckpoint,
|
|
498
|
+
buckets: storage.BucketChecksumRequest[],
|
|
499
|
+
_options?: storage.BucketChecksumOptions
|
|
482
500
|
): Promise<utils.ChecksumMap> {
|
|
483
|
-
return this.checksumCache.getChecksumMap(checkpoint, buckets);
|
|
501
|
+
return this.checksumCache.getChecksumMap(checkpoint.checkpoint, buckets);
|
|
484
502
|
}
|
|
485
503
|
|
|
486
504
|
clearChecksumCache() {
|
|
@@ -497,26 +515,23 @@ export class PostgresSyncRulesStorage
|
|
|
497
515
|
state = ${{ type: 'varchar', value: storage.SyncRuleState.TERMINATED }},
|
|
498
516
|
snapshot_done = ${{ type: 'bool', value: false }}
|
|
499
517
|
WHERE
|
|
500
|
-
id = ${{ type: 'int4', value: this.
|
|
518
|
+
id = ${{ type: 'int4', value: this.replicationStreamId }}
|
|
501
519
|
`.execute();
|
|
502
520
|
}
|
|
503
521
|
|
|
504
|
-
async getStatus(): Promise<storage.
|
|
522
|
+
async getStatus(): Promise<storage.ReplicationStreamStatus> {
|
|
505
523
|
const syncRulesRow = await this.db.sql`
|
|
506
524
|
SELECT
|
|
507
525
|
snapshot_done,
|
|
508
526
|
snapshot_lsn,
|
|
509
527
|
last_checkpoint_lsn,
|
|
510
|
-
state
|
|
511
|
-
keepalive_op
|
|
528
|
+
state
|
|
512
529
|
FROM
|
|
513
530
|
sync_rules
|
|
514
531
|
WHERE
|
|
515
|
-
id = ${{ type: 'int4', value: this.
|
|
532
|
+
id = ${{ type: 'int4', value: this.replicationStreamId }}
|
|
516
533
|
`
|
|
517
|
-
.decoded(
|
|
518
|
-
pick(models.SyncRules, ['snapshot_done', 'last_checkpoint_lsn', 'state', 'snapshot_lsn', 'keepalive_op'])
|
|
519
|
-
)
|
|
534
|
+
.decoded(pick(models.SyncRules, ['snapshot_done', 'last_checkpoint_lsn', 'snapshot_lsn']))
|
|
520
535
|
.first();
|
|
521
536
|
|
|
522
537
|
if (syncRulesRow == null) {
|
|
@@ -524,16 +539,17 @@ export class PostgresSyncRulesStorage
|
|
|
524
539
|
}
|
|
525
540
|
|
|
526
541
|
return {
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
checkpoint_lsn: syncRulesRow.last_checkpoint_lsn ?? null,
|
|
530
|
-
snapshot_lsn: syncRulesRow.snapshot_lsn ?? null,
|
|
531
|
-
keepalive_op: syncRulesRow.keepalive_op ?? null
|
|
542
|
+
snapshotDone: syncRulesRow.snapshot_done && syncRulesRow.last_checkpoint_lsn != null,
|
|
543
|
+
resumeLsn: maxLsn(syncRulesRow.snapshot_lsn, syncRulesRow.last_checkpoint_lsn)
|
|
532
544
|
};
|
|
533
545
|
}
|
|
534
546
|
|
|
535
547
|
async clear(options?: storage.ClearStorageOptions): Promise<void> {
|
|
536
|
-
|
|
548
|
+
const signal = options?.signal;
|
|
549
|
+
if (signal?.aborted) {
|
|
550
|
+
throw new framework.ReplicationAbortedError('Aborted clearing data', signal.reason);
|
|
551
|
+
}
|
|
552
|
+
|
|
537
553
|
await this.db.sql`
|
|
538
554
|
UPDATE sync_rules
|
|
539
555
|
SET
|
|
@@ -542,28 +558,71 @@ export class PostgresSyncRulesStorage
|
|
|
542
558
|
last_checkpoint = NULL,
|
|
543
559
|
no_checkpoint_before = NULL
|
|
544
560
|
WHERE
|
|
545
|
-
id = ${{ type: 'int4', value: this.
|
|
546
|
-
`.execute();
|
|
547
|
-
|
|
548
|
-
await this.db.sql`
|
|
549
|
-
DELETE FROM bucket_data
|
|
550
|
-
WHERE
|
|
551
|
-
group_id = ${{ type: 'int4', value: this.group_id }}
|
|
561
|
+
id = ${{ type: 'int4', value: this.replicationStreamId }}
|
|
552
562
|
`.execute();
|
|
553
563
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
564
|
+
// Delete in batches - a single DELETE covering the entire group can run for
|
|
565
|
+
// hours on large deployments, never completing once it exceeds statement or
|
|
566
|
+
// socket timeouts. Each batch is its own autocommit statement, so progress
|
|
567
|
+
// is durable and a retry continues where the previous attempt stopped.
|
|
568
|
+
await this.clearBatched('bucket_data', signal, () => this.deleteGroupBatch('bucket_data'));
|
|
569
|
+
await this.clearBatched('bucket_parameters', signal, () => this.deleteGroupBatch('bucket_parameters'));
|
|
570
|
+
await this.clearBatched('current_data', signal, () =>
|
|
571
|
+
this.currentDataStore.deleteGroupRowsBatch(this.db, {
|
|
572
|
+
groupId: this.replicationStreamId,
|
|
573
|
+
limit: CLEAR_BATCH_LIMIT
|
|
574
|
+
})
|
|
575
|
+
);
|
|
576
|
+
await this.clearBatched('source_tables', signal, () => this.deleteGroupBatch('source_tables'));
|
|
577
|
+
}
|
|
559
578
|
|
|
560
|
-
|
|
579
|
+
private async clearBatched(
|
|
580
|
+
label: string,
|
|
581
|
+
signal: AbortSignal | undefined,
|
|
582
|
+
deleteBatch: () => Promise<bigint>
|
|
583
|
+
): Promise<void> {
|
|
584
|
+
while (true) {
|
|
585
|
+
if (signal?.aborted) {
|
|
586
|
+
throw new framework.ReplicationAbortedError('Aborted clearing data', signal.reason);
|
|
587
|
+
}
|
|
588
|
+
const count = await deleteBatch();
|
|
589
|
+
if (count < CLEAR_BATCH_LIMIT) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
this.logger.info(`Cleared batch of ${count} ${label} rows, continuing...`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
561
595
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
596
|
+
/**
|
|
597
|
+
* Delete up to {@link CLEAR_BATCH_LIMIT} rows for this group from the given table,
|
|
598
|
+
* returning the number of candidate rows found by the scan.
|
|
599
|
+
*
|
|
600
|
+
* The count is taken from the scan (`batch`) rather than the delete: with a
|
|
601
|
+
* concurrent process deleting the same rows, the delete may remove fewer rows
|
|
602
|
+
* than the scan found, and counting deleted rows could stop the loop while
|
|
603
|
+
* rows remain.
|
|
604
|
+
*/
|
|
605
|
+
private async deleteGroupBatch(table: 'bucket_data' | 'bucket_parameters' | 'source_tables'): Promise<bigint> {
|
|
606
|
+
const [row] = await this.db.queryRows<{ count: bigint }>({
|
|
607
|
+
statement: `
|
|
608
|
+
WITH batch AS (
|
|
609
|
+
SELECT ctid FROM ${table}
|
|
610
|
+
WHERE group_id = $1
|
|
611
|
+
LIMIT $2
|
|
612
|
+
),
|
|
613
|
+
deleted AS (
|
|
614
|
+
DELETE FROM ${table}
|
|
615
|
+
WHERE ctid IN (SELECT ctid FROM batch)
|
|
616
|
+
RETURNING 1
|
|
617
|
+
)
|
|
618
|
+
SELECT COUNT(*) AS count FROM batch
|
|
619
|
+
`,
|
|
620
|
+
params: [
|
|
621
|
+
{ type: 'int4', value: this.replicationStreamId },
|
|
622
|
+
{ type: 'int4', value: CLEAR_BATCH_LIMIT }
|
|
623
|
+
]
|
|
624
|
+
});
|
|
625
|
+
return row?.count ?? 0n;
|
|
567
626
|
}
|
|
568
627
|
|
|
569
628
|
private async getChecksumsInternal(batch: storage.FetchPartialBucketChecksum[]): Promise<storage.PartialChecksumMap> {
|
|
@@ -603,7 +662,7 @@ export class PostgresSyncRulesStorage
|
|
|
603
662
|
AND b.op_id > f.start_op_id
|
|
604
663
|
AND b.op_id <= f.end_op_id
|
|
605
664
|
WHERE
|
|
606
|
-
b.group_id = ${{ type: 'int4', value: this.
|
|
665
|
+
b.group_id = ${{ type: 'int4', value: this.replicationStreamId }}
|
|
607
666
|
GROUP BY
|
|
608
667
|
b.bucket_name;
|
|
609
668
|
`.rows<{ bucket: string; checksum_total: bigint; total: bigint; has_clear_op: number }>();
|
|
@@ -632,25 +691,8 @@ export class PostgresSyncRulesStorage
|
|
|
632
691
|
}
|
|
633
692
|
|
|
634
693
|
async getActiveCheckpoint(): Promise<storage.ReplicationCheckpoint> {
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
id,
|
|
638
|
-
last_checkpoint,
|
|
639
|
-
last_checkpoint_lsn
|
|
640
|
-
FROM
|
|
641
|
-
sync_rules
|
|
642
|
-
WHERE
|
|
643
|
-
state = ${{ value: storage.SyncRuleState.ACTIVE, type: 'varchar' }}
|
|
644
|
-
OR state = ${{ value: storage.SyncRuleState.ERRORED, type: 'varchar' }}
|
|
645
|
-
ORDER BY
|
|
646
|
-
id DESC
|
|
647
|
-
LIMIT
|
|
648
|
-
1
|
|
649
|
-
`
|
|
650
|
-
.decoded(models.ActiveCheckpoint)
|
|
651
|
-
.first();
|
|
652
|
-
|
|
653
|
-
return this.makeActiveCheckpoint(activeCheckpoint);
|
|
694
|
+
const activeCheckpointDocument = await checkpointUtils.getActiveCheckpointDocument({ db: this.db });
|
|
695
|
+
return this.makeActiveCheckpoint(activeCheckpointDocument);
|
|
654
696
|
}
|
|
655
697
|
|
|
656
698
|
async *watchCheckpointChanges(options: WatchWriteCheckpointOptions): AsyncIterable<storage.StorageCheckpointUpdate> {
|
|
@@ -696,61 +738,92 @@ export class PostgresSyncRulesStorage
|
|
|
696
738
|
}
|
|
697
739
|
|
|
698
740
|
protected async *watchActiveCheckpoint(signal: AbortSignal): AsyncIterable<storage.ReplicationCheckpoint> {
|
|
699
|
-
const
|
|
700
|
-
SELECT
|
|
701
|
-
id,
|
|
702
|
-
last_checkpoint,
|
|
703
|
-
last_checkpoint_lsn
|
|
704
|
-
FROM
|
|
705
|
-
sync_rules
|
|
706
|
-
WHERE
|
|
707
|
-
state = ${{ value: storage.SyncRuleState.ACTIVE, type: 'varchar' }}
|
|
708
|
-
OR state = ${{ value: storage.SyncRuleState.ERRORED, type: 'varchar' }}
|
|
709
|
-
LIMIT
|
|
710
|
-
1
|
|
711
|
-
`
|
|
712
|
-
.decoded(models.ActiveCheckpoint)
|
|
713
|
-
.first();
|
|
714
|
-
|
|
715
|
-
if (doc == null) {
|
|
716
|
-
// Abort the connections - clients will have to retry later.
|
|
717
|
-
throw new framework.ServiceError(framework.ErrorCode.PSYNC_S2302, 'No active replication stream available');
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
const sink = new LastValueSink<string>(undefined);
|
|
741
|
+
const sink = new LastValueSink<string | null>(null);
|
|
721
742
|
|
|
743
|
+
// Listen for changes before reading the initial doc
|
|
722
744
|
const disposeListener = this.db.registerListener({
|
|
723
|
-
|
|
745
|
+
notificationEvent: (event) => {
|
|
746
|
+
switch (event.type) {
|
|
747
|
+
case 'notification':
|
|
748
|
+
sink.write(event.notification.payload);
|
|
749
|
+
break;
|
|
750
|
+
case 'channels-registered':
|
|
751
|
+
// Add a null value to the stream, indicating the loop should query the value.
|
|
752
|
+
sink.write(null);
|
|
753
|
+
break;
|
|
754
|
+
case 'connection-error':
|
|
755
|
+
// End the watcher after reconnect attempts are exhausted. Consumers will
|
|
756
|
+
// retry the watcher, which registers a new listener and pokes the slot.
|
|
757
|
+
sink.error(event.error);
|
|
758
|
+
break;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
724
761
|
});
|
|
725
762
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
763
|
+
try {
|
|
764
|
+
const initialCheckpointDocument = requireActiveCheckpointDocument(
|
|
765
|
+
await checkpointUtils.getActiveCheckpointDocument({ db: this.db })
|
|
766
|
+
);
|
|
767
|
+
const initialCheckpoint = this.makeActiveCheckpoint(initialCheckpointDocument);
|
|
768
|
+
let lastOp = initialCheckpoint;
|
|
730
769
|
|
|
731
|
-
|
|
770
|
+
yield initialCheckpoint;
|
|
732
771
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
}
|
|
772
|
+
for await (const payload of sink.withSignal(signal)) {
|
|
773
|
+
if (signal.aborted) {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
738
776
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
777
|
+
let baseActiveCheckpoint: ActiveCheckpointDecoded | null = null;
|
|
778
|
+
if (payload == null) {
|
|
779
|
+
// Reconnected (or manually triggered) - re-query the current checkpoint.
|
|
780
|
+
// Unlike the initial read, a missing document here (e.g. sync rules being
|
|
781
|
+
// replaced) must not abort the stream: keep waiting for the next notification.
|
|
782
|
+
baseActiveCheckpoint = await checkpointUtils.getActiveCheckpointDocument({ db: this.db });
|
|
783
|
+
if (baseActiveCheckpoint == null) {
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
} else {
|
|
787
|
+
let notification: models.ActiveCheckpointNotificationDecoded;
|
|
788
|
+
try {
|
|
789
|
+
notification = models.ActiveCheckpointNotification.decode(payload);
|
|
790
|
+
} catch (error) {
|
|
791
|
+
// A malformed payload must not abort the shared stream for every
|
|
792
|
+
// subscriber. Skip it and wait for the next notification.
|
|
793
|
+
this.logger.warn('Failed to decode active checkpoint notification, ignoring', error);
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
if (notification.active_checkpoint == null) {
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
baseActiveCheckpoint = notification.active_checkpoint;
|
|
800
|
+
}
|
|
747
801
|
|
|
748
|
-
|
|
802
|
+
if (baseActiveCheckpoint.id != initialCheckpointDocument.id) {
|
|
803
|
+
// Active replication stream changed - abort and restart the stream
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
749
806
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
807
|
+
const activeCheckpoint = this.makeActiveCheckpoint(baseActiveCheckpoint);
|
|
808
|
+
|
|
809
|
+
const checkpointAdvanced = activeCheckpoint.checkpoint > lastOp.checkpoint;
|
|
810
|
+
const checkpointRegressed = activeCheckpoint.checkpoint < lastOp.checkpoint;
|
|
811
|
+
const lsnAdvanced =
|
|
812
|
+
lastOp.lsn == null
|
|
813
|
+
? activeCheckpoint.lsn != null
|
|
814
|
+
: activeCheckpoint.lsn != null && activeCheckpoint.lsn > lastOp.lsn;
|
|
815
|
+
const lsnRegressed = lastOp.lsn != null && (activeCheckpoint.lsn == null || activeCheckpoint.lsn < lastOp.lsn);
|
|
816
|
+
|
|
817
|
+
// Notifications buffered before a query or reconnect may be stale. Neither
|
|
818
|
+
// coordinate may regress, but only one needs to advance: an empty checkpoint
|
|
819
|
+
// updates the LSN while keeping the operation checkpoint unchanged.
|
|
820
|
+
if (!checkpointRegressed && !lsnRegressed && (checkpointAdvanced || lsnAdvanced)) {
|
|
821
|
+
lastOp = activeCheckpoint;
|
|
822
|
+
yield activeCheckpoint;
|
|
823
|
+
}
|
|
753
824
|
}
|
|
825
|
+
} finally {
|
|
826
|
+
disposeListener();
|
|
754
827
|
}
|
|
755
828
|
}
|
|
756
829
|
|
|
@@ -783,3 +856,14 @@ const parameterSetsRow = t.object({
|
|
|
783
856
|
index: bigint,
|
|
784
857
|
bucket_parameters: t.string
|
|
785
858
|
});
|
|
859
|
+
|
|
860
|
+
function requireActiveCheckpointDocument(doc: models.ActiveCheckpointDecoded | null): models.ActiveCheckpointDecoded {
|
|
861
|
+
if (doc == null) {
|
|
862
|
+
// Used for the initial checkpoint read only: with no active replication stream
|
|
863
|
+
// at stream start, fail fast so clients disconnect and retry later. Mid-stream
|
|
864
|
+
// reconnects tolerate a transiently missing document instead of aborting.
|
|
865
|
+
throw new framework.ServiceError(framework.ErrorCode.PSYNC_S2302, 'No active replication stream available');
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
return doc;
|
|
869
|
+
}
|