@powersync/service-module-postgres-storage 0.12.0 → 0.13.1
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 +45 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/@types/migrations/scripts/1771424826685-current-data-pending-deletes.d.ts +3 -0
- package/dist/@types/storage/PostgresBucketStorageFactory.d.ts +4 -0
- package/dist/@types/storage/PostgresCompactor.d.ts +8 -2
- package/dist/@types/storage/PostgresSyncRulesStorage.d.ts +10 -4
- package/dist/@types/storage/batch/OperationBatch.d.ts +2 -2
- package/dist/@types/storage/batch/PostgresBucketBatch.d.ts +13 -9
- package/dist/@types/storage/batch/PostgresPersistedBatch.d.ts +17 -5
- package/dist/@types/storage/current-data-store.d.ts +85 -0
- package/dist/@types/storage/current-data-table.d.ts +9 -0
- package/dist/@types/storage/table-id.d.ts +2 -0
- package/dist/@types/types/models/CurrentData.d.ts +18 -3
- package/dist/@types/utils/bson.d.ts +1 -1
- package/dist/@types/utils/test-utils.d.ts +1 -1
- package/dist/migrations/scripts/1771424826685-current-data-pending-deletes.js +8 -0
- package/dist/migrations/scripts/1771424826685-current-data-pending-deletes.js.map +1 -0
- package/dist/storage/PostgresBucketStorageFactory.js +41 -4
- package/dist/storage/PostgresBucketStorageFactory.js.map +1 -1
- package/dist/storage/PostgresCompactor.js +14 -6
- package/dist/storage/PostgresCompactor.js.map +1 -1
- package/dist/storage/PostgresSyncRulesStorage.js +98 -24
- package/dist/storage/PostgresSyncRulesStorage.js.map +1 -1
- package/dist/storage/batch/OperationBatch.js +2 -1
- package/dist/storage/batch/OperationBatch.js.map +1 -1
- package/dist/storage/batch/PostgresBucketBatch.js +295 -213
- package/dist/storage/batch/PostgresBucketBatch.js.map +1 -1
- package/dist/storage/batch/PostgresPersistedBatch.js +86 -81
- package/dist/storage/batch/PostgresPersistedBatch.js.map +1 -1
- package/dist/storage/current-data-store.js +270 -0
- package/dist/storage/current-data-store.js.map +1 -0
- package/dist/storage/current-data-table.js +22 -0
- package/dist/storage/current-data-table.js.map +1 -0
- package/dist/storage/table-id.js +8 -0
- package/dist/storage/table-id.js.map +1 -0
- package/dist/types/models/CurrentData.js +11 -2
- package/dist/types/models/CurrentData.js.map +1 -1
- package/dist/utils/bson.js.map +1 -1
- package/dist/utils/db.js +9 -0
- package/dist/utils/db.js.map +1 -1
- package/dist/utils/test-utils.js +13 -6
- package/dist/utils/test-utils.js.map +1 -1
- package/package.json +8 -8
- package/src/migrations/scripts/1771424826685-current-data-pending-deletes.ts +10 -0
- package/src/storage/PostgresBucketStorageFactory.ts +53 -5
- package/src/storage/PostgresCompactor.ts +17 -8
- package/src/storage/PostgresSyncRulesStorage.ts +47 -31
- package/src/storage/batch/OperationBatch.ts +4 -3
- package/src/storage/batch/PostgresBucketBatch.ts +316 -238
- package/src/storage/batch/PostgresPersistedBatch.ts +92 -84
- package/src/storage/current-data-store.ts +326 -0
- package/src/storage/current-data-table.ts +26 -0
- package/src/storage/table-id.ts +9 -0
- package/src/types/models/CurrentData.ts +17 -4
- package/src/utils/bson.ts +1 -1
- package/src/utils/db.ts +10 -0
- package/src/utils/test-utils.ts +14 -7
- package/test/src/__snapshots__/storage.test.ts.snap +151 -0
- package/test/src/__snapshots__/storage_compacting.test.ts.snap +17 -0
- package/test/src/__snapshots__/storage_sync.test.ts.snap +1111 -16
- package/test/src/env.ts +1 -1
- package/test/src/migrations.test.ts +1 -1
- package/test/src/storage.test.ts +138 -131
- package/test/src/storage_compacting.test.ts +80 -11
- package/test/src/storage_sync.test.ts +57 -54
- package/test/src/util.ts +4 -4
|
@@ -1,20 +1,29 @@
|
|
|
1
|
-
import * as lib_postgres from '@powersync/lib-service-postgres';
|
|
2
1
|
import { BaseObserver, container, ErrorCode, errors, ReplicationAssertionError, ServiceAssertionError, ServiceError } from '@powersync/lib-services-framework';
|
|
3
2
|
import { deserializeReplicaId, storage, utils } from '@powersync/service-core';
|
|
4
3
|
import * as timers from 'timers/promises';
|
|
5
4
|
import * as t from 'ts-codec';
|
|
6
|
-
import { CurrentData } from '../../types/models/CurrentData.js';
|
|
7
5
|
import { models } from '../../types/types.js';
|
|
8
|
-
import { NOTIFICATION_CHANNEL
|
|
6
|
+
import { NOTIFICATION_CHANNEL } from '../../utils/db.js';
|
|
9
7
|
import { pick } from '../../utils/ts-codec.js';
|
|
10
8
|
import { batchCreateCustomWriteCheckpoints } from '../checkpoints/PostgresWriteCheckpointAPI.js';
|
|
11
9
|
import { cacheKey, encodedCacheKey, OperationBatch, RecordOperation } from './OperationBatch.js';
|
|
12
10
|
import { PostgresPersistedBatch } from './PostgresPersistedBatch.js';
|
|
11
|
+
import { bigint } from '../../types/codecs.js';
|
|
12
|
+
import { PostgresCurrentDataStore } from '../current-data-store.js';
|
|
13
|
+
import { postgresTableId } from '../table-id.js';
|
|
13
14
|
/**
|
|
14
15
|
* Intermediate type which helps for only watching the active sync rules
|
|
15
16
|
* via the Postgres NOTIFY protocol.
|
|
16
17
|
*/
|
|
17
18
|
const StatefulCheckpoint = models.ActiveCheckpoint.and(t.object({ state: t.Enum(storage.SyncRuleState) }));
|
|
19
|
+
const CheckpointWithStatus = StatefulCheckpoint.and(t.object({
|
|
20
|
+
snapshot_done: t.boolean,
|
|
21
|
+
no_checkpoint_before: t.string.or(t.Null),
|
|
22
|
+
can_checkpoint: t.boolean,
|
|
23
|
+
keepalive_op: bigint.or(t.Null),
|
|
24
|
+
new_last_checkpoint: bigint.or(t.Null),
|
|
25
|
+
created_checkpoint: t.boolean
|
|
26
|
+
}));
|
|
18
27
|
/**
|
|
19
28
|
* 15MB. Currently matches MongoDB.
|
|
20
29
|
* This could be increased in future.
|
|
@@ -28,7 +37,6 @@ export class PostgresBucketBatch extends BaseObserver {
|
|
|
28
37
|
db;
|
|
29
38
|
group_id;
|
|
30
39
|
last_checkpoint_lsn;
|
|
31
|
-
no_checkpoint_before_lsn;
|
|
32
40
|
persisted_op;
|
|
33
41
|
write_checkpoint_batch;
|
|
34
42
|
sync_rules;
|
|
@@ -37,6 +45,8 @@ export class PostgresBucketBatch extends BaseObserver {
|
|
|
37
45
|
markRecordUnavailable;
|
|
38
46
|
needsActivation = true;
|
|
39
47
|
clearedError = false;
|
|
48
|
+
storageConfig;
|
|
49
|
+
currentDataStore;
|
|
40
50
|
constructor(options) {
|
|
41
51
|
super();
|
|
42
52
|
this.options = options;
|
|
@@ -44,13 +54,14 @@ export class PostgresBucketBatch extends BaseObserver {
|
|
|
44
54
|
this.db = options.db;
|
|
45
55
|
this.group_id = options.group_id;
|
|
46
56
|
this.last_checkpoint_lsn = options.last_checkpoint_lsn;
|
|
47
|
-
this.no_checkpoint_before_lsn = options.no_checkpoint_before_lsn;
|
|
48
57
|
this.resumeFromLsn = options.resumeFromLsn;
|
|
49
58
|
this.write_checkpoint_batch = [];
|
|
50
59
|
this.sync_rules = options.sync_rules;
|
|
51
60
|
this.markRecordUnavailable = options.markRecordUnavailable;
|
|
52
61
|
this.batch = null;
|
|
53
62
|
this.persisted_op = null;
|
|
63
|
+
this.storageConfig = options.storageConfig;
|
|
64
|
+
this.currentDataStore = new PostgresCurrentDataStore(this.storageConfig);
|
|
54
65
|
if (options.keep_alive_op) {
|
|
55
66
|
this.persisted_op = options.keep_alive_op;
|
|
56
67
|
}
|
|
@@ -58,12 +69,18 @@ export class PostgresBucketBatch extends BaseObserver {
|
|
|
58
69
|
get lastCheckpointLsn() {
|
|
59
70
|
return this.last_checkpoint_lsn;
|
|
60
71
|
}
|
|
61
|
-
get noCheckpointBeforeLsn() {
|
|
62
|
-
return this.no_checkpoint_before_lsn;
|
|
63
|
-
}
|
|
64
72
|
async [Symbol.asyncDispose]() {
|
|
73
|
+
if (this.batch != null || this.write_checkpoint_batch.length > 0) {
|
|
74
|
+
// We don't error here, since:
|
|
75
|
+
// 1. In error states, this is expected (we can't distinguish between disposing after success or error).
|
|
76
|
+
// 2. SuppressedError is messy to deal with.
|
|
77
|
+
this.logger.warn('Disposing writer with unflushed changes');
|
|
78
|
+
}
|
|
65
79
|
super.clearListeners();
|
|
66
80
|
}
|
|
81
|
+
async dispose() {
|
|
82
|
+
await this[Symbol.asyncDispose]();
|
|
83
|
+
}
|
|
67
84
|
async save(record) {
|
|
68
85
|
// TODO maybe share with abstract class
|
|
69
86
|
const { after, before, sourceTable, tag } = record;
|
|
@@ -118,30 +135,22 @@ export class PostgresBucketBatch extends BaseObserver {
|
|
|
118
135
|
const BATCH_LIMIT = 2000;
|
|
119
136
|
let lastBatchCount = BATCH_LIMIT;
|
|
120
137
|
let processedCount = 0;
|
|
121
|
-
const codec = pick(models.CurrentData, ['buckets', 'lookups', 'source_key']);
|
|
122
138
|
while (lastBatchCount == BATCH_LIMIT) {
|
|
123
139
|
lastBatchCount = 0;
|
|
124
140
|
await this.withReplicationTransaction(async (db) => {
|
|
125
141
|
const persistedBatch = new PostgresPersistedBatch({
|
|
126
142
|
group_id: this.group_id,
|
|
143
|
+
storageConfig: this.storageConfig,
|
|
127
144
|
...this.options.batch_limits
|
|
128
145
|
});
|
|
129
|
-
for await (const rows of
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
FROM
|
|
135
|
-
current_data
|
|
136
|
-
WHERE
|
|
137
|
-
group_id = ${{ type: 'int4', value: this.group_id }}
|
|
138
|
-
AND source_table = ${{ type: 'varchar', value: sourceTable.id }}
|
|
139
|
-
LIMIT
|
|
140
|
-
${{ type: 'int4', value: BATCH_LIMIT }}
|
|
141
|
-
`)) {
|
|
146
|
+
for await (const rows of this.currentDataStore.streamTruncateRows(db, {
|
|
147
|
+
groupId: this.group_id,
|
|
148
|
+
sourceTableId: postgresTableId(sourceTable.id),
|
|
149
|
+
limit: BATCH_LIMIT
|
|
150
|
+
})) {
|
|
142
151
|
lastBatchCount += rows.length;
|
|
143
152
|
processedCount += rows.length;
|
|
144
|
-
const decodedRows = rows.map((row) =>
|
|
153
|
+
const decodedRows = rows.map((row) => this.currentDataStore.decodeTruncateRow(row));
|
|
145
154
|
for (const value of decodedRows) {
|
|
146
155
|
const source_key = deserializeReplicaId(value.source_key);
|
|
147
156
|
persistedBatch.saveBucketData({
|
|
@@ -159,7 +168,9 @@ export class PostgresBucketBatch extends BaseObserver {
|
|
|
159
168
|
persistedBatch.deleteCurrentData({
|
|
160
169
|
// This is serialized since we got it from a DB query
|
|
161
170
|
serialized_source_key: value.source_key,
|
|
162
|
-
source_table_id: sourceTable.id
|
|
171
|
+
source_table_id: postgresTableId(sourceTable.id),
|
|
172
|
+
// No need for soft delete, since this is not streaming replication
|
|
173
|
+
soft: false
|
|
163
174
|
});
|
|
164
175
|
}
|
|
165
176
|
}
|
|
@@ -226,136 +237,223 @@ export class PostgresBucketBatch extends BaseObserver {
|
|
|
226
237
|
return { flushed_op: lastOp };
|
|
227
238
|
}
|
|
228
239
|
async commit(lsn, options) {
|
|
229
|
-
const
|
|
240
|
+
const createEmptyCheckpoints = options?.createEmptyCheckpoints ?? true;
|
|
230
241
|
await this.flush();
|
|
231
|
-
if (this.last_checkpoint_lsn != null && lsn < this.last_checkpoint_lsn) {
|
|
232
|
-
// When re-applying transactions, don't create a new checkpoint until
|
|
233
|
-
// we are past the last transaction.
|
|
234
|
-
this.logger.info(`Re-applied transaction ${lsn} - skipping checkpoint`);
|
|
235
|
-
// Cannot create a checkpoint yet - return false
|
|
236
|
-
return false;
|
|
237
|
-
}
|
|
238
|
-
if (lsn < this.no_checkpoint_before_lsn) {
|
|
239
|
-
if (Date.now() - this.lastWaitingLogThrottled > 5_000) {
|
|
240
|
-
this.logger.info(`Waiting until ${this.no_checkpoint_before_lsn} before creating checkpoint, currently at ${lsn}. Persisted op: ${this.persisted_op}`);
|
|
241
|
-
this.lastWaitingLogThrottled = Date.now();
|
|
242
|
-
}
|
|
243
|
-
// Edge case: During initial replication, we have a no_checkpoint_before_lsn set,
|
|
244
|
-
// and don't actually commit the snapshot.
|
|
245
|
-
// The first commit can happen from an implicit keepalive message.
|
|
246
|
-
// That needs the persisted_op to get an accurate checkpoint, so
|
|
247
|
-
// we persist that in keepalive_op.
|
|
248
|
-
await this.db.sql `
|
|
249
|
-
UPDATE sync_rules
|
|
250
|
-
SET
|
|
251
|
-
keepalive_op = ${{ type: 'int8', value: this.persisted_op }}
|
|
252
|
-
WHERE
|
|
253
|
-
id = ${{ type: 'int4', value: this.group_id }}
|
|
254
|
-
`.execute();
|
|
255
|
-
// Cannot create a checkpoint yet - return false
|
|
256
|
-
return false;
|
|
257
|
-
}
|
|
258
|
-
// Don't create a checkpoint if there were no changes
|
|
259
|
-
if (!createEmptyCheckpoints && this.persisted_op == null) {
|
|
260
|
-
// Nothing to commit - return true
|
|
261
|
-
await this.autoActivate(lsn);
|
|
262
|
-
return true;
|
|
263
|
-
}
|
|
264
242
|
const now = new Date().toISOString();
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
243
|
+
const persisted_op = this.persisted_op ?? null;
|
|
244
|
+
const result = await this.db.sql `
|
|
245
|
+
WITH
|
|
246
|
+
selected AS (
|
|
247
|
+
SELECT
|
|
248
|
+
id,
|
|
249
|
+
state,
|
|
250
|
+
last_checkpoint,
|
|
251
|
+
last_checkpoint_lsn,
|
|
252
|
+
snapshot_done,
|
|
253
|
+
no_checkpoint_before,
|
|
254
|
+
keepalive_op,
|
|
255
|
+
(
|
|
256
|
+
snapshot_done = TRUE
|
|
257
|
+
AND (
|
|
258
|
+
last_checkpoint_lsn IS NULL
|
|
259
|
+
OR last_checkpoint_lsn <= ${{ type: 'varchar', value: lsn }}
|
|
260
|
+
)
|
|
261
|
+
AND (
|
|
262
|
+
no_checkpoint_before IS NULL
|
|
263
|
+
OR no_checkpoint_before <= ${{ type: 'varchar', value: lsn }}
|
|
264
|
+
)
|
|
265
|
+
) AS can_checkpoint
|
|
266
|
+
FROM
|
|
267
|
+
sync_rules
|
|
268
|
+
WHERE
|
|
269
|
+
id = ${{ type: 'int4', value: this.group_id }}
|
|
270
|
+
FOR UPDATE
|
|
287
271
|
),
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
272
|
+
computed AS (
|
|
273
|
+
SELECT
|
|
274
|
+
selected.*,
|
|
275
|
+
CASE
|
|
276
|
+
WHEN selected.can_checkpoint THEN GREATEST(
|
|
277
|
+
selected.last_checkpoint,
|
|
278
|
+
${{ type: 'int8', value: persisted_op }},
|
|
279
|
+
selected.keepalive_op,
|
|
280
|
+
0
|
|
281
|
+
)
|
|
282
|
+
ELSE selected.last_checkpoint
|
|
283
|
+
END AS new_last_checkpoint,
|
|
284
|
+
CASE
|
|
285
|
+
WHEN selected.can_checkpoint THEN NULL
|
|
286
|
+
ELSE GREATEST(
|
|
287
|
+
selected.keepalive_op,
|
|
288
|
+
${{ type: 'int8', value: persisted_op }},
|
|
289
|
+
0
|
|
290
|
+
)
|
|
291
|
+
END AS new_keepalive_op
|
|
292
|
+
FROM
|
|
293
|
+
selected
|
|
294
|
+
),
|
|
295
|
+
updated AS (
|
|
296
|
+
UPDATE sync_rules AS sr
|
|
297
|
+
SET
|
|
298
|
+
last_checkpoint_lsn = CASE
|
|
299
|
+
WHEN computed.can_checkpoint THEN ${{ type: 'varchar', value: lsn }}
|
|
300
|
+
ELSE sr.last_checkpoint_lsn
|
|
301
|
+
END,
|
|
302
|
+
last_checkpoint_ts = CASE
|
|
303
|
+
WHEN computed.can_checkpoint THEN ${{ type: 1184, value: now }}
|
|
304
|
+
ELSE sr.last_checkpoint_ts
|
|
305
|
+
END,
|
|
306
|
+
last_keepalive_ts = ${{ type: 1184, value: now }},
|
|
307
|
+
last_fatal_error = CASE
|
|
308
|
+
WHEN computed.can_checkpoint THEN NULL
|
|
309
|
+
ELSE sr.last_fatal_error
|
|
310
|
+
END,
|
|
311
|
+
keepalive_op = computed.new_keepalive_op,
|
|
312
|
+
last_checkpoint = computed.new_last_checkpoint,
|
|
313
|
+
snapshot_lsn = CASE
|
|
314
|
+
WHEN computed.can_checkpoint THEN NULL
|
|
315
|
+
ELSE sr.snapshot_lsn
|
|
316
|
+
END
|
|
317
|
+
FROM
|
|
318
|
+
computed
|
|
319
|
+
WHERE
|
|
320
|
+
sr.id = computed.id
|
|
321
|
+
AND (
|
|
322
|
+
sr.keepalive_op IS DISTINCT FROM computed.new_keepalive_op
|
|
323
|
+
OR sr.last_checkpoint IS DISTINCT FROM computed.new_last_checkpoint
|
|
324
|
+
OR ${{ type: 'bool', value: createEmptyCheckpoints }}
|
|
325
|
+
)
|
|
326
|
+
RETURNING
|
|
327
|
+
sr.id,
|
|
328
|
+
sr.state,
|
|
329
|
+
sr.last_checkpoint,
|
|
330
|
+
sr.last_checkpoint_lsn,
|
|
331
|
+
sr.snapshot_done,
|
|
332
|
+
sr.no_checkpoint_before,
|
|
333
|
+
computed.can_checkpoint,
|
|
334
|
+
computed.keepalive_op,
|
|
335
|
+
computed.new_last_checkpoint
|
|
336
|
+
)
|
|
337
|
+
SELECT
|
|
293
338
|
id,
|
|
294
339
|
state,
|
|
295
340
|
last_checkpoint,
|
|
296
|
-
last_checkpoint_lsn
|
|
341
|
+
last_checkpoint_lsn,
|
|
342
|
+
snapshot_done,
|
|
343
|
+
no_checkpoint_before,
|
|
344
|
+
can_checkpoint,
|
|
345
|
+
keepalive_op,
|
|
346
|
+
new_last_checkpoint,
|
|
347
|
+
TRUE AS created_checkpoint
|
|
348
|
+
FROM
|
|
349
|
+
updated
|
|
350
|
+
UNION ALL
|
|
351
|
+
SELECT
|
|
352
|
+
id,
|
|
353
|
+
state,
|
|
354
|
+
new_last_checkpoint AS last_checkpoint,
|
|
355
|
+
last_checkpoint_lsn,
|
|
356
|
+
snapshot_done,
|
|
357
|
+
no_checkpoint_before,
|
|
358
|
+
can_checkpoint,
|
|
359
|
+
keepalive_op,
|
|
360
|
+
new_last_checkpoint,
|
|
361
|
+
FALSE AS created_checkpoint
|
|
362
|
+
FROM
|
|
363
|
+
computed
|
|
364
|
+
WHERE
|
|
365
|
+
NOT EXISTS (
|
|
366
|
+
SELECT
|
|
367
|
+
1
|
|
368
|
+
FROM
|
|
369
|
+
updated
|
|
370
|
+
)
|
|
297
371
|
`
|
|
298
|
-
.decoded(
|
|
372
|
+
.decoded(CheckpointWithStatus)
|
|
299
373
|
.first();
|
|
374
|
+
if (result == null) {
|
|
375
|
+
throw new ReplicationAssertionError('Failed to update sync_rules during checkpoint');
|
|
376
|
+
}
|
|
377
|
+
if (!result.can_checkpoint) {
|
|
378
|
+
if (Date.now() - this.lastWaitingLogThrottled > 5_000) {
|
|
379
|
+
this.logger.info(`Waiting before creating checkpoint, currently at ${lsn}. Last op: ${result.keepalive_op}. Current state: ${JSON.stringify({
|
|
380
|
+
snapshot_done: result.snapshot_done,
|
|
381
|
+
last_checkpoint_lsn: result.last_checkpoint_lsn,
|
|
382
|
+
no_checkpoint_before: result.no_checkpoint_before
|
|
383
|
+
})}`);
|
|
384
|
+
this.lastWaitingLogThrottled = Date.now();
|
|
385
|
+
}
|
|
386
|
+
return { checkpointBlocked: true, checkpointCreated: false };
|
|
387
|
+
}
|
|
388
|
+
if (result.created_checkpoint) {
|
|
389
|
+
this.logger.debug(`Created checkpoint at ${lsn}. Last op: ${result.last_checkpoint}`);
|
|
390
|
+
if (result.last_checkpoint != null) {
|
|
391
|
+
await this.currentDataStore.cleanupPendingDeletes(this.db, {
|
|
392
|
+
groupId: this.group_id,
|
|
393
|
+
lastCheckpoint: result.last_checkpoint
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
300
397
|
await this.autoActivate(lsn);
|
|
301
|
-
await notifySyncRulesUpdate(this.db,
|
|
398
|
+
await notifySyncRulesUpdate(this.db, {
|
|
399
|
+
id: result.id,
|
|
400
|
+
state: result.state,
|
|
401
|
+
last_checkpoint: result.last_checkpoint,
|
|
402
|
+
last_checkpoint_lsn: result.last_checkpoint_lsn
|
|
403
|
+
});
|
|
302
404
|
this.persisted_op = null;
|
|
303
405
|
this.last_checkpoint_lsn = lsn;
|
|
304
|
-
return
|
|
406
|
+
// Even if created_checkpoint is false, if can_checkpoint is true, we need to return not blocked.
|
|
407
|
+
return { checkpointBlocked: false, checkpointCreated: result.created_checkpoint };
|
|
305
408
|
}
|
|
306
409
|
async keepalive(lsn) {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if (lsn < this.no_checkpoint_before_lsn) {
|
|
312
|
-
return false;
|
|
313
|
-
}
|
|
314
|
-
if (this.persisted_op != null) {
|
|
315
|
-
// The commit may have been skipped due to "no_checkpoint_before_lsn".
|
|
316
|
-
// Apply it now if relevant
|
|
317
|
-
this.logger.info(`Commit due to keepalive at ${lsn} / ${this.persisted_op}`);
|
|
318
|
-
return await this.commit(lsn);
|
|
319
|
-
}
|
|
320
|
-
const updated = await this.db.sql `
|
|
410
|
+
return await this.commit(lsn, { createEmptyCheckpoints: true });
|
|
411
|
+
}
|
|
412
|
+
async setResumeLsn(lsn) {
|
|
413
|
+
await this.db.sql `
|
|
321
414
|
UPDATE sync_rules
|
|
322
415
|
SET
|
|
323
|
-
|
|
324
|
-
snapshot_lsn = NULL,
|
|
325
|
-
last_checkpoint_lsn = ${{ type: 'varchar', value: lsn }},
|
|
326
|
-
last_fatal_error = ${{ type: 'varchar', value: null }},
|
|
327
|
-
last_keepalive_ts = ${{ type: 1184, value: new Date().toISOString() }}
|
|
416
|
+
snapshot_lsn = ${{ type: 'varchar', value: lsn }}
|
|
328
417
|
WHERE
|
|
329
418
|
id = ${{ type: 'int4', value: this.group_id }}
|
|
330
|
-
|
|
331
|
-
id,
|
|
332
|
-
state,
|
|
333
|
-
last_checkpoint,
|
|
334
|
-
last_checkpoint_lsn
|
|
335
|
-
`
|
|
336
|
-
.decoded(StatefulCheckpoint)
|
|
337
|
-
.first();
|
|
338
|
-
await this.autoActivate(lsn);
|
|
339
|
-
await notifySyncRulesUpdate(this.db, updated);
|
|
340
|
-
this.last_checkpoint_lsn = lsn;
|
|
341
|
-
return true;
|
|
419
|
+
`.execute();
|
|
342
420
|
}
|
|
343
|
-
async
|
|
421
|
+
async markAllSnapshotDone(no_checkpoint_before_lsn) {
|
|
422
|
+
await this.db.transaction(async (db) => {
|
|
423
|
+
await db.sql `
|
|
424
|
+
UPDATE sync_rules
|
|
425
|
+
SET
|
|
426
|
+
snapshot_done = TRUE,
|
|
427
|
+
last_keepalive_ts = ${{ type: 1184, value: new Date().toISOString() }},
|
|
428
|
+
no_checkpoint_before = CASE
|
|
429
|
+
WHEN no_checkpoint_before IS NULL
|
|
430
|
+
OR no_checkpoint_before < ${{ type: 'varchar', value: no_checkpoint_before_lsn }} THEN ${{
|
|
431
|
+
type: 'varchar',
|
|
432
|
+
value: no_checkpoint_before_lsn
|
|
433
|
+
}}
|
|
434
|
+
ELSE no_checkpoint_before
|
|
435
|
+
END
|
|
436
|
+
WHERE
|
|
437
|
+
id = ${{ type: 'int4', value: this.group_id }}
|
|
438
|
+
`.execute();
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
async markTableSnapshotRequired(table) {
|
|
344
442
|
await this.db.sql `
|
|
345
443
|
UPDATE sync_rules
|
|
346
444
|
SET
|
|
347
|
-
|
|
445
|
+
snapshot_done = FALSE
|
|
348
446
|
WHERE
|
|
349
447
|
id = ${{ type: 'int4', value: this.group_id }}
|
|
350
448
|
`.execute();
|
|
351
449
|
}
|
|
352
|
-
async
|
|
450
|
+
async markTableSnapshotDone(tables, no_checkpoint_before_lsn) {
|
|
353
451
|
const ids = tables.map((table) => table.id.toString());
|
|
354
452
|
await this.db.transaction(async (db) => {
|
|
355
453
|
await db.sql `
|
|
356
454
|
UPDATE source_tables
|
|
357
455
|
SET
|
|
358
|
-
snapshot_done =
|
|
456
|
+
snapshot_done = TRUE,
|
|
359
457
|
snapshot_total_estimated_count = NULL,
|
|
360
458
|
snapshot_replicated_count = NULL,
|
|
361
459
|
snapshot_last_key = NULL
|
|
@@ -367,30 +465,27 @@ export class PostgresBucketBatch extends BaseObserver {
|
|
|
367
465
|
jsonb_array_elements(${{ type: 'jsonb', value: ids }}) AS value
|
|
368
466
|
);
|
|
369
467
|
`.execute();
|
|
370
|
-
if (no_checkpoint_before_lsn
|
|
371
|
-
this.no_checkpoint_before_lsn = no_checkpoint_before_lsn;
|
|
468
|
+
if (no_checkpoint_before_lsn != null) {
|
|
372
469
|
await db.sql `
|
|
373
470
|
UPDATE sync_rules
|
|
374
471
|
SET
|
|
375
|
-
|
|
376
|
-
|
|
472
|
+
last_keepalive_ts = ${{ type: 1184, value: new Date().toISOString() }},
|
|
473
|
+
no_checkpoint_before = CASE
|
|
474
|
+
WHEN no_checkpoint_before IS NULL
|
|
475
|
+
OR no_checkpoint_before < ${{ type: 'varchar', value: no_checkpoint_before_lsn }} THEN ${{
|
|
476
|
+
type: 'varchar',
|
|
477
|
+
value: no_checkpoint_before_lsn
|
|
478
|
+
}}
|
|
479
|
+
ELSE no_checkpoint_before
|
|
480
|
+
END
|
|
377
481
|
WHERE
|
|
378
482
|
id = ${{ type: 'int4', value: this.group_id }}
|
|
379
483
|
`.execute();
|
|
380
484
|
}
|
|
381
485
|
});
|
|
382
486
|
return tables.map((table) => {
|
|
383
|
-
const copy =
|
|
384
|
-
|
|
385
|
-
connectionTag: table.connectionTag,
|
|
386
|
-
objectId: table.objectId,
|
|
387
|
-
schema: table.schema,
|
|
388
|
-
name: table.name,
|
|
389
|
-
replicaIdColumns: table.replicaIdColumns,
|
|
390
|
-
snapshotComplete: table.snapshotComplete
|
|
391
|
-
});
|
|
392
|
-
copy.syncData = table.syncData;
|
|
393
|
-
copy.syncParameters = table.syncParameters;
|
|
487
|
+
const copy = table.clone();
|
|
488
|
+
copy.snapshotComplete = true;
|
|
394
489
|
return copy;
|
|
395
490
|
});
|
|
396
491
|
}
|
|
@@ -430,7 +525,7 @@ export class PostgresBucketBatch extends BaseObserver {
|
|
|
430
525
|
// exceeding memory limits.
|
|
431
526
|
const sizeLookups = batch.batch.map((r) => {
|
|
432
527
|
return {
|
|
433
|
-
source_table: r.record.sourceTable.id
|
|
528
|
+
source_table: postgresTableId(r.record.sourceTable.id),
|
|
434
529
|
/**
|
|
435
530
|
* Encode to hex in order to pass a jsonb
|
|
436
531
|
*/
|
|
@@ -438,26 +533,10 @@ export class PostgresBucketBatch extends BaseObserver {
|
|
|
438
533
|
};
|
|
439
534
|
});
|
|
440
535
|
sizes = new Map();
|
|
441
|
-
for await (const rows of
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
decode(FILTER ->> 'source_key', 'hex') AS source_key, -- Decoding from hex to bytea
|
|
446
|
-
(FILTER ->> 'source_table') AS source_table_id
|
|
447
|
-
FROM
|
|
448
|
-
jsonb_array_elements(${{ type: 'jsonb', value: sizeLookups }}::jsonb) AS FILTER
|
|
449
|
-
)
|
|
450
|
-
SELECT
|
|
451
|
-
octet_length(c.data) AS data_size,
|
|
452
|
-
c.source_table,
|
|
453
|
-
c.source_key
|
|
454
|
-
FROM
|
|
455
|
-
current_data c
|
|
456
|
-
JOIN filter_data f ON c.source_table = f.source_table_id
|
|
457
|
-
AND c.source_key = f.source_key
|
|
458
|
-
WHERE
|
|
459
|
-
c.group_id = ${{ type: 'int4', value: this.group_id }}
|
|
460
|
-
`)) {
|
|
536
|
+
for await (const rows of this.currentDataStore.streamSizeRows(db, {
|
|
537
|
+
groupId: this.group_id,
|
|
538
|
+
lookups: sizeLookups
|
|
539
|
+
})) {
|
|
461
540
|
for (const row of rows) {
|
|
462
541
|
const key = cacheKey(row.source_table, row.source_key);
|
|
463
542
|
sizes.set(key, row.data_size);
|
|
@@ -479,48 +558,24 @@ export class PostgresBucketBatch extends BaseObserver {
|
|
|
479
558
|
}
|
|
480
559
|
const lookups = b.map((r) => {
|
|
481
560
|
return {
|
|
482
|
-
source_table: r.record.sourceTable.id,
|
|
561
|
+
source_table: postgresTableId(r.record.sourceTable.id),
|
|
483
562
|
source_key: storage.serializeReplicaId(r.beforeId).toString('hex')
|
|
484
563
|
};
|
|
485
564
|
});
|
|
486
565
|
const current_data_lookup = new Map();
|
|
487
|
-
for await (const currentDataRows of
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
FROM
|
|
492
|
-
current_data c
|
|
493
|
-
JOIN (
|
|
494
|
-
SELECT
|
|
495
|
-
decode(FILTER ->> 'source_key', 'hex') AS source_key,
|
|
496
|
-
FILTER ->> 'source_table' AS source_table_id
|
|
497
|
-
FROM
|
|
498
|
-
jsonb_array_elements($1::jsonb) AS FILTER
|
|
499
|
-
) f ON c.source_table = f.source_table_id
|
|
500
|
-
AND c.source_key = f.source_key
|
|
501
|
-
WHERE
|
|
502
|
-
c.group_id = $2;
|
|
503
|
-
`,
|
|
504
|
-
params: [
|
|
505
|
-
{
|
|
506
|
-
type: 'jsonb',
|
|
507
|
-
value: lookups
|
|
508
|
-
},
|
|
509
|
-
{
|
|
510
|
-
type: 'int4',
|
|
511
|
-
value: this.group_id
|
|
512
|
-
}
|
|
513
|
-
]
|
|
566
|
+
for await (const currentDataRows of this.currentDataStore.streamLookupRows(db, {
|
|
567
|
+
groupId: this.group_id,
|
|
568
|
+
lookups,
|
|
569
|
+
skipExistingRows: this.options.skip_existing_rows
|
|
514
570
|
})) {
|
|
515
571
|
for (const row of currentDataRows) {
|
|
516
|
-
const decoded = this.options.skip_existing_rows
|
|
517
|
-
? pick(CurrentData, ['source_key', 'source_table']).decode(row)
|
|
518
|
-
: CurrentData.decode(row);
|
|
572
|
+
const decoded = this.currentDataStore.decodeLookupRow(row, this.options.skip_existing_rows);
|
|
519
573
|
current_data_lookup.set(encodedCacheKey(decoded.source_table, decoded.source_key), decoded);
|
|
520
574
|
}
|
|
521
575
|
}
|
|
522
576
|
let persistedBatch = new PostgresPersistedBatch({
|
|
523
577
|
group_id: this.group_id,
|
|
578
|
+
storageConfig: this.storageConfig,
|
|
524
579
|
...this.options.batch_limits
|
|
525
580
|
});
|
|
526
581
|
for (const op of b) {
|
|
@@ -758,17 +813,19 @@ export class PostgresBucketBatch extends BaseObserver {
|
|
|
758
813
|
source_key: afterId,
|
|
759
814
|
group_id: this.group_id,
|
|
760
815
|
data: afterData,
|
|
761
|
-
source_table: sourceTable.id,
|
|
816
|
+
source_table: postgresTableId(sourceTable.id),
|
|
762
817
|
buckets: newBuckets,
|
|
763
|
-
lookups: newLookups
|
|
818
|
+
lookups: newLookups,
|
|
819
|
+
pending_delete: null
|
|
764
820
|
};
|
|
765
821
|
persistedBatch.upsertCurrentData(result);
|
|
766
822
|
}
|
|
767
823
|
if (afterId == null || !storage.replicaIdEquals(beforeId, afterId)) {
|
|
768
824
|
// Either a delete (afterId == null), or replaced the old replication id
|
|
769
825
|
persistedBatch.deleteCurrentData({
|
|
770
|
-
source_table_id:
|
|
771
|
-
source_key: beforeId
|
|
826
|
+
source_table_id: postgresTableId(sourceTable.id),
|
|
827
|
+
source_key: beforeId,
|
|
828
|
+
soft: true
|
|
772
829
|
});
|
|
773
830
|
}
|
|
774
831
|
return result;
|
|
@@ -787,40 +844,45 @@ export class PostgresBucketBatch extends BaseObserver {
|
|
|
787
844
|
await this.db.transaction(async (db) => {
|
|
788
845
|
const syncRulesRow = await db.sql `
|
|
789
846
|
SELECT
|
|
790
|
-
state
|
|
847
|
+
state,
|
|
848
|
+
snapshot_done
|
|
791
849
|
FROM
|
|
792
850
|
sync_rules
|
|
793
851
|
WHERE
|
|
794
852
|
id = ${{ type: 'int4', value: this.group_id }}
|
|
853
|
+
FOR NO KEY UPDATE;
|
|
795
854
|
`
|
|
796
|
-
.decoded(pick(models.SyncRules, ['state']))
|
|
855
|
+
.decoded(pick(models.SyncRules, ['state', 'snapshot_done']))
|
|
797
856
|
.first();
|
|
798
|
-
if (syncRulesRow && syncRulesRow.state == storage.SyncRuleState.PROCESSING) {
|
|
857
|
+
if (syncRulesRow && syncRulesRow.state == storage.SyncRuleState.PROCESSING && syncRulesRow.snapshot_done) {
|
|
799
858
|
await db.sql `
|
|
800
859
|
UPDATE sync_rules
|
|
801
860
|
SET
|
|
802
861
|
state = ${{ type: 'varchar', value: storage.SyncRuleState.ACTIVE }}
|
|
803
862
|
WHERE
|
|
804
863
|
id = ${{ type: 'int4', value: this.group_id }}
|
|
864
|
+
`.execute();
|
|
865
|
+
await db.sql `
|
|
866
|
+
UPDATE sync_rules
|
|
867
|
+
SET
|
|
868
|
+
state = ${{ type: 'varchar', value: storage.SyncRuleState.STOP }}
|
|
869
|
+
WHERE
|
|
870
|
+
(
|
|
871
|
+
state = ${{ value: storage.SyncRuleState.ACTIVE, type: 'varchar' }}
|
|
872
|
+
OR state = ${{ value: storage.SyncRuleState.ERRORED, type: 'varchar' }}
|
|
873
|
+
)
|
|
874
|
+
AND id != ${{ type: 'int4', value: this.group_id }}
|
|
805
875
|
`.execute();
|
|
806
876
|
didActivate = true;
|
|
877
|
+
this.needsActivation = false;
|
|
878
|
+
}
|
|
879
|
+
else if (syncRulesRow?.state != storage.SyncRuleState.PROCESSING) {
|
|
880
|
+
this.needsActivation = false;
|
|
807
881
|
}
|
|
808
|
-
await db.sql `
|
|
809
|
-
UPDATE sync_rules
|
|
810
|
-
SET
|
|
811
|
-
state = ${{ type: 'varchar', value: storage.SyncRuleState.STOP }}
|
|
812
|
-
WHERE
|
|
813
|
-
(
|
|
814
|
-
state = ${{ value: storage.SyncRuleState.ACTIVE, type: 'varchar' }}
|
|
815
|
-
OR state = ${{ value: storage.SyncRuleState.ERRORED, type: 'varchar' }}
|
|
816
|
-
)
|
|
817
|
-
AND id != ${{ type: 'int4', value: this.group_id }}
|
|
818
|
-
`.execute();
|
|
819
882
|
});
|
|
820
883
|
if (didActivate) {
|
|
821
884
|
this.logger.info(`Activated new sync rules at ${lsn}`);
|
|
822
885
|
}
|
|
823
|
-
this.needsActivation = false;
|
|
824
886
|
}
|
|
825
887
|
/**
|
|
826
888
|
* Gets relevant {@link SqlEventDescriptor}s for the given {@link SourceTable}
|
|
@@ -831,9 +893,29 @@ export class PostgresBucketBatch extends BaseObserver {
|
|
|
831
893
|
}
|
|
832
894
|
async withReplicationTransaction(callback) {
|
|
833
895
|
try {
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
896
|
+
// Try for up to a minute
|
|
897
|
+
const lastTry = Date.now() + 60_000;
|
|
898
|
+
while (true) {
|
|
899
|
+
try {
|
|
900
|
+
return await this.db.transaction(async (db) => {
|
|
901
|
+
// The isolation level is required to protect against concurrent updates to the same data.
|
|
902
|
+
// In theory the "select ... for update" locks may be able to protect against this, but we
|
|
903
|
+
// still have failing tests if we use that as the only isolation mechanism.
|
|
904
|
+
await db.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;');
|
|
905
|
+
return await callback(db);
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
catch (err) {
|
|
909
|
+
const code = err.cause?.code;
|
|
910
|
+
if ((code == '40001' || code == '40P01') && Date.now() < lastTry) {
|
|
911
|
+
// Serialization (lock) failure, retry
|
|
912
|
+
this.logger.warn(`Serialization failure during replication transaction, retrying: ${err.message}`);
|
|
913
|
+
await timers.setTimeout(100 + Math.random() * 200);
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
throw err;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
837
919
|
}
|
|
838
920
|
finally {
|
|
839
921
|
await this.db.sql `
|