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