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