@powersync/service-module-postgres-storage 0.12.0 → 0.13.0
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 +22 -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 +5 -3
- package/dist/@types/storage/batch/OperationBatch.d.ts +2 -2
- package/dist/@types/storage/batch/PostgresBucketBatch.d.ts +12 -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 +23 -15
- 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 +286 -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 +30 -17
- package/src/storage/batch/OperationBatch.ts +4 -3
- package/src/storage/batch/PostgresBucketBatch.ts +306 -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 +1095 -0
- package/test/src/migrations.test.ts +1 -1
- package/test/src/storage.test.ts +136 -130
- package/test/src/storage_compacting.test.ts +65 -3
- package/test/src/storage_sync.test.ts +11 -9
- package/test/src/util.ts +4 -4
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from '@powersync/lib-services-framework';
|
|
12
12
|
import {
|
|
13
13
|
BucketStorageMarkRecordUnavailable,
|
|
14
|
+
CheckpointResult,
|
|
14
15
|
deserializeReplicaId,
|
|
15
16
|
InternalOpId,
|
|
16
17
|
storage,
|
|
@@ -19,13 +20,16 @@ import {
|
|
|
19
20
|
import * as sync_rules from '@powersync/service-sync-rules';
|
|
20
21
|
import * as timers from 'timers/promises';
|
|
21
22
|
import * as t from 'ts-codec';
|
|
22
|
-
import { CurrentBucket,
|
|
23
|
+
import { CurrentBucket, V3CurrentDataDecoded } from '../../types/models/CurrentData.js';
|
|
23
24
|
import { models, RequiredOperationBatchLimits } from '../../types/types.js';
|
|
24
|
-
import { NOTIFICATION_CHANNEL
|
|
25
|
+
import { NOTIFICATION_CHANNEL } from '../../utils/db.js';
|
|
25
26
|
import { pick } from '../../utils/ts-codec.js';
|
|
26
27
|
import { batchCreateCustomWriteCheckpoints } from '../checkpoints/PostgresWriteCheckpointAPI.js';
|
|
27
28
|
import { cacheKey, encodedCacheKey, OperationBatch, RecordOperation } from './OperationBatch.js';
|
|
28
29
|
import { PostgresPersistedBatch } from './PostgresPersistedBatch.js';
|
|
30
|
+
import { bigint } from '../../types/codecs.js';
|
|
31
|
+
import { PostgresCurrentDataStore } from '../current-data-store.js';
|
|
32
|
+
import { postgresTableId } from '../table-id.js';
|
|
29
33
|
|
|
30
34
|
export interface PostgresBucketBatchOptions {
|
|
31
35
|
logger: Logger;
|
|
@@ -34,7 +38,6 @@ export interface PostgresBucketBatchOptions {
|
|
|
34
38
|
group_id: number;
|
|
35
39
|
slot_name: string;
|
|
36
40
|
last_checkpoint_lsn: string | null;
|
|
37
|
-
no_checkpoint_before_lsn: string;
|
|
38
41
|
store_current_data: boolean;
|
|
39
42
|
keep_alive_op?: InternalOpId | null;
|
|
40
43
|
resumeFromLsn: string | null;
|
|
@@ -45,6 +48,7 @@ export interface PostgresBucketBatchOptions {
|
|
|
45
48
|
batch_limits: RequiredOperationBatchLimits;
|
|
46
49
|
|
|
47
50
|
markRecordUnavailable: BucketStorageMarkRecordUnavailable | undefined;
|
|
51
|
+
storageConfig: storage.StorageVersionConfig;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
/**
|
|
@@ -54,6 +58,18 @@ export interface PostgresBucketBatchOptions {
|
|
|
54
58
|
const StatefulCheckpoint = models.ActiveCheckpoint.and(t.object({ state: t.Enum(storage.SyncRuleState) }));
|
|
55
59
|
type StatefulCheckpointDecoded = t.Decoded<typeof StatefulCheckpoint>;
|
|
56
60
|
|
|
61
|
+
const CheckpointWithStatus = StatefulCheckpoint.and(
|
|
62
|
+
t.object({
|
|
63
|
+
snapshot_done: t.boolean,
|
|
64
|
+
no_checkpoint_before: t.string.or(t.Null),
|
|
65
|
+
can_checkpoint: t.boolean,
|
|
66
|
+
keepalive_op: bigint.or(t.Null),
|
|
67
|
+
new_last_checkpoint: bigint.or(t.Null),
|
|
68
|
+
created_checkpoint: t.boolean
|
|
69
|
+
})
|
|
70
|
+
);
|
|
71
|
+
type CheckpointWithStatusDecoded = t.Decoded<typeof CheckpointWithStatus>;
|
|
72
|
+
|
|
57
73
|
/**
|
|
58
74
|
* 15MB. Currently matches MongoDB.
|
|
59
75
|
* This could be increased in future.
|
|
@@ -73,7 +89,6 @@ export class PostgresBucketBatch
|
|
|
73
89
|
protected db: lib_postgres.DatabaseClient;
|
|
74
90
|
protected group_id: number;
|
|
75
91
|
protected last_checkpoint_lsn: string | null;
|
|
76
|
-
protected no_checkpoint_before_lsn: string;
|
|
77
92
|
|
|
78
93
|
protected persisted_op: InternalOpId | null;
|
|
79
94
|
|
|
@@ -84,6 +99,8 @@ export class PostgresBucketBatch
|
|
|
84
99
|
private markRecordUnavailable: BucketStorageMarkRecordUnavailable | undefined;
|
|
85
100
|
private needsActivation = true;
|
|
86
101
|
private clearedError = false;
|
|
102
|
+
private readonly storageConfig: storage.StorageVersionConfig;
|
|
103
|
+
private readonly currentDataStore: PostgresCurrentDataStore;
|
|
87
104
|
|
|
88
105
|
constructor(protected options: PostgresBucketBatchOptions) {
|
|
89
106
|
super();
|
|
@@ -91,13 +108,14 @@ export class PostgresBucketBatch
|
|
|
91
108
|
this.db = options.db;
|
|
92
109
|
this.group_id = options.group_id;
|
|
93
110
|
this.last_checkpoint_lsn = options.last_checkpoint_lsn;
|
|
94
|
-
this.no_checkpoint_before_lsn = options.no_checkpoint_before_lsn;
|
|
95
111
|
this.resumeFromLsn = options.resumeFromLsn;
|
|
96
112
|
this.write_checkpoint_batch = [];
|
|
97
113
|
this.sync_rules = options.sync_rules;
|
|
98
114
|
this.markRecordUnavailable = options.markRecordUnavailable;
|
|
99
115
|
this.batch = null;
|
|
100
116
|
this.persisted_op = null;
|
|
117
|
+
this.storageConfig = options.storageConfig;
|
|
118
|
+
this.currentDataStore = new PostgresCurrentDataStore(this.storageConfig);
|
|
101
119
|
if (options.keep_alive_op) {
|
|
102
120
|
this.persisted_op = options.keep_alive_op;
|
|
103
121
|
}
|
|
@@ -107,10 +125,6 @@ export class PostgresBucketBatch
|
|
|
107
125
|
return this.last_checkpoint_lsn;
|
|
108
126
|
}
|
|
109
127
|
|
|
110
|
-
get noCheckpointBeforeLsn() {
|
|
111
|
-
return this.no_checkpoint_before_lsn;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
128
|
async [Symbol.asyncDispose]() {
|
|
115
129
|
super.clearListeners();
|
|
116
130
|
}
|
|
@@ -177,33 +191,24 @@ export class PostgresBucketBatch
|
|
|
177
191
|
const BATCH_LIMIT = 2000;
|
|
178
192
|
let lastBatchCount = BATCH_LIMIT;
|
|
179
193
|
let processedCount = 0;
|
|
180
|
-
const codec = pick(models.CurrentData, ['buckets', 'lookups', 'source_key']);
|
|
181
|
-
|
|
182
194
|
while (lastBatchCount == BATCH_LIMIT) {
|
|
183
195
|
lastBatchCount = 0;
|
|
184
196
|
await this.withReplicationTransaction(async (db) => {
|
|
185
197
|
const persistedBatch = new PostgresPersistedBatch({
|
|
186
198
|
group_id: this.group_id,
|
|
199
|
+
storageConfig: this.storageConfig,
|
|
187
200
|
...this.options.batch_limits
|
|
188
201
|
});
|
|
189
202
|
|
|
190
|
-
for await (const rows of
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
FROM
|
|
196
|
-
current_data
|
|
197
|
-
WHERE
|
|
198
|
-
group_id = ${{ type: 'int4', value: this.group_id }}
|
|
199
|
-
AND source_table = ${{ type: 'varchar', value: sourceTable.id }}
|
|
200
|
-
LIMIT
|
|
201
|
-
${{ type: 'int4', value: BATCH_LIMIT }}
|
|
202
|
-
`)) {
|
|
203
|
+
for await (const rows of this.currentDataStore.streamTruncateRows(db, {
|
|
204
|
+
groupId: this.group_id,
|
|
205
|
+
sourceTableId: postgresTableId(sourceTable.id),
|
|
206
|
+
limit: BATCH_LIMIT
|
|
207
|
+
})) {
|
|
203
208
|
lastBatchCount += rows.length;
|
|
204
209
|
processedCount += rows.length;
|
|
205
210
|
|
|
206
|
-
const decodedRows = rows.map((row) =>
|
|
211
|
+
const decodedRows = rows.map((row) => this.currentDataStore.decodeTruncateRow(row));
|
|
207
212
|
for (const value of decodedRows) {
|
|
208
213
|
const source_key = deserializeReplicaId(value.source_key);
|
|
209
214
|
persistedBatch.saveBucketData({
|
|
@@ -221,7 +226,9 @@ export class PostgresBucketBatch
|
|
|
221
226
|
persistedBatch.deleteCurrentData({
|
|
222
227
|
// This is serialized since we got it from a DB query
|
|
223
228
|
serialized_source_key: value.source_key,
|
|
224
|
-
source_table_id: sourceTable.id
|
|
229
|
+
source_table_id: postgresTableId(sourceTable.id),
|
|
230
|
+
// No need for soft delete, since this is not streaming replication
|
|
231
|
+
soft: false
|
|
225
232
|
});
|
|
226
233
|
}
|
|
227
234
|
}
|
|
@@ -299,155 +306,239 @@ export class PostgresBucketBatch
|
|
|
299
306
|
return { flushed_op: lastOp };
|
|
300
307
|
}
|
|
301
308
|
|
|
302
|
-
async commit(lsn: string, options?: storage.BucketBatchCommitOptions): Promise<
|
|
303
|
-
const
|
|
309
|
+
async commit(lsn: string, options?: storage.BucketBatchCommitOptions): Promise<CheckpointResult> {
|
|
310
|
+
const createEmptyCheckpoints = options?.createEmptyCheckpoints ?? true;
|
|
304
311
|
|
|
305
312
|
await this.flush();
|
|
306
313
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
314
|
+
const now = new Date().toISOString();
|
|
315
|
+
|
|
316
|
+
const persisted_op = this.persisted_op ?? null;
|
|
317
|
+
|
|
318
|
+
const result = await this.db.sql`
|
|
319
|
+
WITH
|
|
320
|
+
selected AS (
|
|
321
|
+
SELECT
|
|
322
|
+
id,
|
|
323
|
+
state,
|
|
324
|
+
last_checkpoint,
|
|
325
|
+
last_checkpoint_lsn,
|
|
326
|
+
snapshot_done,
|
|
327
|
+
no_checkpoint_before,
|
|
328
|
+
keepalive_op,
|
|
329
|
+
(
|
|
330
|
+
snapshot_done = TRUE
|
|
331
|
+
AND (
|
|
332
|
+
last_checkpoint_lsn IS NULL
|
|
333
|
+
OR last_checkpoint_lsn <= ${{ type: 'varchar', value: lsn }}
|
|
334
|
+
)
|
|
335
|
+
AND (
|
|
336
|
+
no_checkpoint_before IS NULL
|
|
337
|
+
OR no_checkpoint_before <= ${{ type: 'varchar', value: lsn }}
|
|
338
|
+
)
|
|
339
|
+
) AS can_checkpoint
|
|
340
|
+
FROM
|
|
341
|
+
sync_rules
|
|
342
|
+
WHERE
|
|
343
|
+
id = ${{ type: 'int4', value: this.group_id }}
|
|
344
|
+
FOR UPDATE
|
|
345
|
+
),
|
|
346
|
+
computed AS (
|
|
347
|
+
SELECT
|
|
348
|
+
selected.*,
|
|
349
|
+
CASE
|
|
350
|
+
WHEN selected.can_checkpoint THEN GREATEST(
|
|
351
|
+
selected.last_checkpoint,
|
|
352
|
+
${{ type: 'int8', value: persisted_op }},
|
|
353
|
+
selected.keepalive_op,
|
|
354
|
+
0
|
|
355
|
+
)
|
|
356
|
+
ELSE selected.last_checkpoint
|
|
357
|
+
END AS new_last_checkpoint,
|
|
358
|
+
CASE
|
|
359
|
+
WHEN selected.can_checkpoint THEN NULL
|
|
360
|
+
ELSE GREATEST(
|
|
361
|
+
selected.keepalive_op,
|
|
362
|
+
${{ type: 'int8', value: persisted_op }},
|
|
363
|
+
0
|
|
364
|
+
)
|
|
365
|
+
END AS new_keepalive_op
|
|
366
|
+
FROM
|
|
367
|
+
selected
|
|
368
|
+
),
|
|
369
|
+
updated AS (
|
|
370
|
+
UPDATE sync_rules AS sr
|
|
371
|
+
SET
|
|
372
|
+
last_checkpoint_lsn = CASE
|
|
373
|
+
WHEN computed.can_checkpoint THEN ${{ type: 'varchar', value: lsn }}
|
|
374
|
+
ELSE sr.last_checkpoint_lsn
|
|
375
|
+
END,
|
|
376
|
+
last_checkpoint_ts = CASE
|
|
377
|
+
WHEN computed.can_checkpoint THEN ${{ type: 1184, value: now }}
|
|
378
|
+
ELSE sr.last_checkpoint_ts
|
|
379
|
+
END,
|
|
380
|
+
last_keepalive_ts = ${{ type: 1184, value: now }},
|
|
381
|
+
last_fatal_error = CASE
|
|
382
|
+
WHEN computed.can_checkpoint THEN NULL
|
|
383
|
+
ELSE sr.last_fatal_error
|
|
384
|
+
END,
|
|
385
|
+
keepalive_op = computed.new_keepalive_op,
|
|
386
|
+
last_checkpoint = computed.new_last_checkpoint,
|
|
387
|
+
snapshot_lsn = CASE
|
|
388
|
+
WHEN computed.can_checkpoint THEN NULL
|
|
389
|
+
ELSE sr.snapshot_lsn
|
|
390
|
+
END
|
|
391
|
+
FROM
|
|
392
|
+
computed
|
|
393
|
+
WHERE
|
|
394
|
+
sr.id = computed.id
|
|
395
|
+
AND (
|
|
396
|
+
sr.keepalive_op IS DISTINCT FROM computed.new_keepalive_op
|
|
397
|
+
OR sr.last_checkpoint IS DISTINCT FROM computed.new_last_checkpoint
|
|
398
|
+
OR ${{ type: 'bool', value: createEmptyCheckpoints }}
|
|
399
|
+
)
|
|
400
|
+
RETURNING
|
|
401
|
+
sr.id,
|
|
402
|
+
sr.state,
|
|
403
|
+
sr.last_checkpoint,
|
|
404
|
+
sr.last_checkpoint_lsn,
|
|
405
|
+
sr.snapshot_done,
|
|
406
|
+
sr.no_checkpoint_before,
|
|
407
|
+
computed.can_checkpoint,
|
|
408
|
+
computed.keepalive_op,
|
|
409
|
+
computed.new_last_checkpoint
|
|
410
|
+
)
|
|
411
|
+
SELECT
|
|
412
|
+
id,
|
|
413
|
+
state,
|
|
414
|
+
last_checkpoint,
|
|
415
|
+
last_checkpoint_lsn,
|
|
416
|
+
snapshot_done,
|
|
417
|
+
no_checkpoint_before,
|
|
418
|
+
can_checkpoint,
|
|
419
|
+
keepalive_op,
|
|
420
|
+
new_last_checkpoint,
|
|
421
|
+
TRUE AS created_checkpoint
|
|
422
|
+
FROM
|
|
423
|
+
updated
|
|
424
|
+
UNION ALL
|
|
425
|
+
SELECT
|
|
426
|
+
id,
|
|
427
|
+
state,
|
|
428
|
+
new_last_checkpoint AS last_checkpoint,
|
|
429
|
+
last_checkpoint_lsn,
|
|
430
|
+
snapshot_done,
|
|
431
|
+
no_checkpoint_before,
|
|
432
|
+
can_checkpoint,
|
|
433
|
+
keepalive_op,
|
|
434
|
+
new_last_checkpoint,
|
|
435
|
+
FALSE AS created_checkpoint
|
|
436
|
+
FROM
|
|
437
|
+
computed
|
|
438
|
+
WHERE
|
|
439
|
+
NOT EXISTS (
|
|
440
|
+
SELECT
|
|
441
|
+
1
|
|
442
|
+
FROM
|
|
443
|
+
updated
|
|
444
|
+
)
|
|
445
|
+
`
|
|
446
|
+
.decoded(CheckpointWithStatus)
|
|
447
|
+
.first();
|
|
448
|
+
|
|
449
|
+
if (result == null) {
|
|
450
|
+
throw new ReplicationAssertionError('Failed to update sync_rules during checkpoint');
|
|
313
451
|
}
|
|
314
452
|
|
|
315
|
-
if (
|
|
453
|
+
if (!result.can_checkpoint) {
|
|
316
454
|
if (Date.now() - this.lastWaitingLogThrottled > 5_000) {
|
|
317
455
|
this.logger.info(
|
|
318
|
-
`Waiting
|
|
456
|
+
`Waiting before creating checkpoint, currently at ${lsn}. Last op: ${result.keepalive_op}. Current state: ${JSON.stringify(
|
|
457
|
+
{
|
|
458
|
+
snapshot_done: result.snapshot_done,
|
|
459
|
+
last_checkpoint_lsn: result.last_checkpoint_lsn,
|
|
460
|
+
no_checkpoint_before: result.no_checkpoint_before
|
|
461
|
+
}
|
|
462
|
+
)}`
|
|
319
463
|
);
|
|
320
464
|
this.lastWaitingLogThrottled = Date.now();
|
|
321
465
|
}
|
|
322
|
-
|
|
323
|
-
// Edge case: During initial replication, we have a no_checkpoint_before_lsn set,
|
|
324
|
-
// and don't actually commit the snapshot.
|
|
325
|
-
// The first commit can happen from an implicit keepalive message.
|
|
326
|
-
// That needs the persisted_op to get an accurate checkpoint, so
|
|
327
|
-
// we persist that in keepalive_op.
|
|
328
|
-
|
|
329
|
-
await this.db.sql`
|
|
330
|
-
UPDATE sync_rules
|
|
331
|
-
SET
|
|
332
|
-
keepalive_op = ${{ type: 'int8', value: this.persisted_op }}
|
|
333
|
-
WHERE
|
|
334
|
-
id = ${{ type: 'int4', value: this.group_id }}
|
|
335
|
-
`.execute();
|
|
336
|
-
|
|
337
|
-
// Cannot create a checkpoint yet - return false
|
|
338
|
-
return false;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// Don't create a checkpoint if there were no changes
|
|
342
|
-
if (!createEmptyCheckpoints && this.persisted_op == null) {
|
|
343
|
-
// Nothing to commit - return true
|
|
344
|
-
await this.autoActivate(lsn);
|
|
345
|
-
return true;
|
|
466
|
+
return { checkpointBlocked: true, checkpointCreated: false };
|
|
346
467
|
}
|
|
347
468
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
last_checkpoint_lsn: lsn,
|
|
351
|
-
last_checkpoint_ts: now,
|
|
352
|
-
last_keepalive_ts: now,
|
|
353
|
-
snapshot_done: true,
|
|
354
|
-
last_fatal_error: null,
|
|
355
|
-
keepalive_op: null
|
|
356
|
-
};
|
|
469
|
+
if (result.created_checkpoint) {
|
|
470
|
+
this.logger.debug(`Created checkpoint at ${lsn}. Last op: ${result.last_checkpoint}`);
|
|
357
471
|
|
|
358
|
-
|
|
359
|
-
|
|
472
|
+
if (result.last_checkpoint != null) {
|
|
473
|
+
await this.currentDataStore.cleanupPendingDeletes(this.db, {
|
|
474
|
+
groupId: this.group_id,
|
|
475
|
+
lastCheckpoint: result.last_checkpoint
|
|
476
|
+
});
|
|
477
|
+
}
|
|
360
478
|
}
|
|
361
|
-
|
|
362
|
-
const doc = await this.db.sql`
|
|
363
|
-
UPDATE sync_rules
|
|
364
|
-
SET
|
|
365
|
-
keepalive_op = ${{ type: 'int8', value: update.keepalive_op }},
|
|
366
|
-
last_fatal_error = ${{ type: 'varchar', value: update.last_fatal_error }},
|
|
367
|
-
snapshot_done = ${{ type: 'bool', value: update.snapshot_done }},
|
|
368
|
-
snapshot_lsn = NULL,
|
|
369
|
-
last_keepalive_ts = ${{ type: 1184, value: update.last_keepalive_ts }},
|
|
370
|
-
last_checkpoint = COALESCE(
|
|
371
|
-
${{ type: 'int8', value: update.last_checkpoint }},
|
|
372
|
-
last_checkpoint
|
|
373
|
-
),
|
|
374
|
-
last_checkpoint_ts = ${{ type: 1184, value: update.last_checkpoint_ts }},
|
|
375
|
-
last_checkpoint_lsn = ${{ type: 'varchar', value: update.last_checkpoint_lsn }}
|
|
376
|
-
WHERE
|
|
377
|
-
id = ${{ type: 'int4', value: this.group_id }}
|
|
378
|
-
RETURNING
|
|
379
|
-
id,
|
|
380
|
-
state,
|
|
381
|
-
last_checkpoint,
|
|
382
|
-
last_checkpoint_lsn
|
|
383
|
-
`
|
|
384
|
-
.decoded(StatefulCheckpoint)
|
|
385
|
-
.first();
|
|
386
|
-
|
|
387
479
|
await this.autoActivate(lsn);
|
|
388
|
-
await notifySyncRulesUpdate(this.db,
|
|
480
|
+
await notifySyncRulesUpdate(this.db, {
|
|
481
|
+
id: result.id,
|
|
482
|
+
state: result.state,
|
|
483
|
+
last_checkpoint: result.last_checkpoint,
|
|
484
|
+
last_checkpoint_lsn: result.last_checkpoint_lsn
|
|
485
|
+
});
|
|
389
486
|
|
|
390
487
|
this.persisted_op = null;
|
|
391
488
|
this.last_checkpoint_lsn = lsn;
|
|
392
|
-
return true;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
async keepalive(lsn: string): Promise<boolean> {
|
|
396
|
-
if (this.last_checkpoint_lsn != null && lsn < this.last_checkpoint_lsn) {
|
|
397
|
-
// No-op
|
|
398
|
-
return false;
|
|
399
|
-
}
|
|
400
489
|
|
|
401
|
-
if
|
|
402
|
-
|
|
403
|
-
|
|
490
|
+
// Even if created_checkpoint is false, if can_checkpoint is true, we need to return not blocked.
|
|
491
|
+
return { checkpointBlocked: false, checkpointCreated: result.created_checkpoint };
|
|
492
|
+
}
|
|
404
493
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
this.logger.info(`Commit due to keepalive at ${lsn} / ${this.persisted_op}`);
|
|
409
|
-
return await this.commit(lsn);
|
|
410
|
-
}
|
|
494
|
+
async keepalive(lsn: string): Promise<CheckpointResult> {
|
|
495
|
+
return await this.commit(lsn, { createEmptyCheckpoints: true });
|
|
496
|
+
}
|
|
411
497
|
|
|
412
|
-
|
|
498
|
+
async setResumeLsn(lsn: string): Promise<void> {
|
|
499
|
+
await this.db.sql`
|
|
413
500
|
UPDATE sync_rules
|
|
414
501
|
SET
|
|
415
|
-
|
|
416
|
-
snapshot_lsn = NULL,
|
|
417
|
-
last_checkpoint_lsn = ${{ type: 'varchar', value: lsn }},
|
|
418
|
-
last_fatal_error = ${{ type: 'varchar', value: null }},
|
|
419
|
-
last_keepalive_ts = ${{ type: 1184, value: new Date().toISOString() }}
|
|
502
|
+
snapshot_lsn = ${{ type: 'varchar', value: lsn }}
|
|
420
503
|
WHERE
|
|
421
504
|
id = ${{ type: 'int4', value: this.group_id }}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
state,
|
|
425
|
-
last_checkpoint,
|
|
426
|
-
last_checkpoint_lsn
|
|
427
|
-
`
|
|
428
|
-
.decoded(StatefulCheckpoint)
|
|
429
|
-
.first();
|
|
430
|
-
|
|
431
|
-
await this.autoActivate(lsn);
|
|
432
|
-
await notifySyncRulesUpdate(this.db, updated!);
|
|
505
|
+
`.execute();
|
|
506
|
+
}
|
|
433
507
|
|
|
434
|
-
|
|
435
|
-
|
|
508
|
+
async markAllSnapshotDone(no_checkpoint_before_lsn: string): Promise<void> {
|
|
509
|
+
await this.db.transaction(async (db) => {
|
|
510
|
+
await db.sql`
|
|
511
|
+
UPDATE sync_rules
|
|
512
|
+
SET
|
|
513
|
+
snapshot_done = TRUE,
|
|
514
|
+
last_keepalive_ts = ${{ type: 1184, value: new Date().toISOString() }},
|
|
515
|
+
no_checkpoint_before = CASE
|
|
516
|
+
WHEN no_checkpoint_before IS NULL
|
|
517
|
+
OR no_checkpoint_before < ${{ type: 'varchar', value: no_checkpoint_before_lsn }} THEN ${{
|
|
518
|
+
type: 'varchar',
|
|
519
|
+
value: no_checkpoint_before_lsn
|
|
520
|
+
}}
|
|
521
|
+
ELSE no_checkpoint_before
|
|
522
|
+
END
|
|
523
|
+
WHERE
|
|
524
|
+
id = ${{ type: 'int4', value: this.group_id }}
|
|
525
|
+
`.execute();
|
|
526
|
+
});
|
|
436
527
|
}
|
|
437
528
|
|
|
438
|
-
async
|
|
529
|
+
async markTableSnapshotRequired(table: storage.SourceTable): Promise<void> {
|
|
439
530
|
await this.db.sql`
|
|
440
531
|
UPDATE sync_rules
|
|
441
532
|
SET
|
|
442
|
-
|
|
533
|
+
snapshot_done = FALSE
|
|
443
534
|
WHERE
|
|
444
535
|
id = ${{ type: 'int4', value: this.group_id }}
|
|
445
536
|
`.execute();
|
|
446
537
|
}
|
|
447
538
|
|
|
448
|
-
async
|
|
539
|
+
async markTableSnapshotDone(
|
|
449
540
|
tables: storage.SourceTable[],
|
|
450
|
-
no_checkpoint_before_lsn
|
|
541
|
+
no_checkpoint_before_lsn?: string
|
|
451
542
|
): Promise<storage.SourceTable[]> {
|
|
452
543
|
const ids = tables.map((table) => table.id.toString());
|
|
453
544
|
|
|
@@ -455,7 +546,7 @@ export class PostgresBucketBatch
|
|
|
455
546
|
await db.sql`
|
|
456
547
|
UPDATE source_tables
|
|
457
548
|
SET
|
|
458
|
-
snapshot_done =
|
|
549
|
+
snapshot_done = TRUE,
|
|
459
550
|
snapshot_total_estimated_count = NULL,
|
|
460
551
|
snapshot_replicated_count = NULL,
|
|
461
552
|
snapshot_last_key = NULL
|
|
@@ -468,31 +559,27 @@ export class PostgresBucketBatch
|
|
|
468
559
|
);
|
|
469
560
|
`.execute();
|
|
470
561
|
|
|
471
|
-
if (no_checkpoint_before_lsn
|
|
472
|
-
this.no_checkpoint_before_lsn = no_checkpoint_before_lsn;
|
|
473
|
-
|
|
562
|
+
if (no_checkpoint_before_lsn != null) {
|
|
474
563
|
await db.sql`
|
|
475
564
|
UPDATE sync_rules
|
|
476
565
|
SET
|
|
477
|
-
|
|
478
|
-
|
|
566
|
+
last_keepalive_ts = ${{ type: 1184, value: new Date().toISOString() }},
|
|
567
|
+
no_checkpoint_before = CASE
|
|
568
|
+
WHEN no_checkpoint_before IS NULL
|
|
569
|
+
OR no_checkpoint_before < ${{ type: 'varchar', value: no_checkpoint_before_lsn }} THEN ${{
|
|
570
|
+
type: 'varchar',
|
|
571
|
+
value: no_checkpoint_before_lsn
|
|
572
|
+
}}
|
|
573
|
+
ELSE no_checkpoint_before
|
|
574
|
+
END
|
|
479
575
|
WHERE
|
|
480
576
|
id = ${{ type: 'int4', value: this.group_id }}
|
|
481
577
|
`.execute();
|
|
482
578
|
}
|
|
483
579
|
});
|
|
484
580
|
return tables.map((table) => {
|
|
485
|
-
const copy =
|
|
486
|
-
|
|
487
|
-
connectionTag: table.connectionTag,
|
|
488
|
-
objectId: table.objectId,
|
|
489
|
-
schema: table.schema,
|
|
490
|
-
name: table.name,
|
|
491
|
-
replicaIdColumns: table.replicaIdColumns,
|
|
492
|
-
snapshotComplete: table.snapshotComplete
|
|
493
|
-
});
|
|
494
|
-
copy.syncData = table.syncData;
|
|
495
|
-
copy.syncParameters = table.syncParameters;
|
|
581
|
+
const copy = table.clone();
|
|
582
|
+
copy.snapshotComplete = true;
|
|
496
583
|
return copy;
|
|
497
584
|
});
|
|
498
585
|
}
|
|
@@ -542,7 +629,7 @@ export class PostgresBucketBatch
|
|
|
542
629
|
// exceeding memory limits.
|
|
543
630
|
const sizeLookups = batch.batch.map((r) => {
|
|
544
631
|
return {
|
|
545
|
-
source_table: r.record.sourceTable.id
|
|
632
|
+
source_table: postgresTableId(r.record.sourceTable.id),
|
|
546
633
|
/**
|
|
547
634
|
* Encode to hex in order to pass a jsonb
|
|
548
635
|
*/
|
|
@@ -552,30 +639,10 @@ export class PostgresBucketBatch
|
|
|
552
639
|
|
|
553
640
|
sizes = new Map<string, number>();
|
|
554
641
|
|
|
555
|
-
for await (const rows of db
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
}>(lib_postgres.sql`
|
|
560
|
-
WITH
|
|
561
|
-
filter_data AS (
|
|
562
|
-
SELECT
|
|
563
|
-
decode(FILTER ->> 'source_key', 'hex') AS source_key, -- Decoding from hex to bytea
|
|
564
|
-
(FILTER ->> 'source_table') AS source_table_id
|
|
565
|
-
FROM
|
|
566
|
-
jsonb_array_elements(${{ type: 'jsonb', value: sizeLookups }}::jsonb) AS FILTER
|
|
567
|
-
)
|
|
568
|
-
SELECT
|
|
569
|
-
octet_length(c.data) AS data_size,
|
|
570
|
-
c.source_table,
|
|
571
|
-
c.source_key
|
|
572
|
-
FROM
|
|
573
|
-
current_data c
|
|
574
|
-
JOIN filter_data f ON c.source_table = f.source_table_id
|
|
575
|
-
AND c.source_key = f.source_key
|
|
576
|
-
WHERE
|
|
577
|
-
c.group_id = ${{ type: 'int4', value: this.group_id }}
|
|
578
|
-
`)) {
|
|
642
|
+
for await (const rows of this.currentDataStore.streamSizeRows(db, {
|
|
643
|
+
groupId: this.group_id,
|
|
644
|
+
lookups: sizeLookups
|
|
645
|
+
})) {
|
|
579
646
|
for (const row of rows) {
|
|
580
647
|
const key = cacheKey(row.source_table, row.source_key);
|
|
581
648
|
sizes.set(key, row.data_size);
|
|
@@ -600,53 +667,29 @@ export class PostgresBucketBatch
|
|
|
600
667
|
|
|
601
668
|
const lookups = b.map((r) => {
|
|
602
669
|
return {
|
|
603
|
-
source_table: r.record.sourceTable.id,
|
|
670
|
+
source_table: postgresTableId(r.record.sourceTable.id),
|
|
604
671
|
source_key: storage.serializeReplicaId(r.beforeId).toString('hex')
|
|
605
672
|
};
|
|
606
673
|
});
|
|
607
674
|
|
|
608
|
-
const current_data_lookup = new Map<string,
|
|
609
|
-
for await (const currentDataRows of
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
FROM
|
|
614
|
-
current_data c
|
|
615
|
-
JOIN (
|
|
616
|
-
SELECT
|
|
617
|
-
decode(FILTER ->> 'source_key', 'hex') AS source_key,
|
|
618
|
-
FILTER ->> 'source_table' AS source_table_id
|
|
619
|
-
FROM
|
|
620
|
-
jsonb_array_elements($1::jsonb) AS FILTER
|
|
621
|
-
) f ON c.source_table = f.source_table_id
|
|
622
|
-
AND c.source_key = f.source_key
|
|
623
|
-
WHERE
|
|
624
|
-
c.group_id = $2;
|
|
625
|
-
`,
|
|
626
|
-
params: [
|
|
627
|
-
{
|
|
628
|
-
type: 'jsonb',
|
|
629
|
-
value: lookups
|
|
630
|
-
},
|
|
631
|
-
{
|
|
632
|
-
type: 'int4',
|
|
633
|
-
value: this.group_id
|
|
634
|
-
}
|
|
635
|
-
]
|
|
675
|
+
const current_data_lookup = new Map<string, V3CurrentDataDecoded>();
|
|
676
|
+
for await (const currentDataRows of this.currentDataStore.streamLookupRows(db, {
|
|
677
|
+
groupId: this.group_id,
|
|
678
|
+
lookups,
|
|
679
|
+
skipExistingRows: this.options.skip_existing_rows
|
|
636
680
|
})) {
|
|
637
681
|
for (const row of currentDataRows) {
|
|
638
|
-
const decoded = this.options.skip_existing_rows
|
|
639
|
-
? pick(CurrentData, ['source_key', 'source_table']).decode(row)
|
|
640
|
-
: CurrentData.decode(row);
|
|
682
|
+
const decoded = this.currentDataStore.decodeLookupRow(row, this.options.skip_existing_rows);
|
|
641
683
|
current_data_lookup.set(
|
|
642
684
|
encodedCacheKey(decoded.source_table, decoded.source_key),
|
|
643
|
-
decoded as
|
|
685
|
+
decoded as V3CurrentDataDecoded
|
|
644
686
|
);
|
|
645
687
|
}
|
|
646
688
|
}
|
|
647
689
|
|
|
648
690
|
let persistedBatch: PostgresPersistedBatch | null = new PostgresPersistedBatch({
|
|
649
691
|
group_id: this.group_id,
|
|
692
|
+
storageConfig: this.storageConfig,
|
|
650
693
|
...this.options.batch_limits
|
|
651
694
|
});
|
|
652
695
|
|
|
@@ -703,7 +746,7 @@ export class PostgresBucketBatch
|
|
|
703
746
|
protected async saveOperation(
|
|
704
747
|
persistedBatch: PostgresPersistedBatch,
|
|
705
748
|
operation: RecordOperation,
|
|
706
|
-
currentData?:
|
|
749
|
+
currentData?: V3CurrentDataDecoded | null
|
|
707
750
|
) {
|
|
708
751
|
const record = operation.record;
|
|
709
752
|
// We store bytea colums for source keys
|
|
@@ -919,7 +962,7 @@ export class PostgresBucketBatch
|
|
|
919
962
|
}
|
|
920
963
|
}
|
|
921
964
|
|
|
922
|
-
let result:
|
|
965
|
+
let result: V3CurrentDataDecoded | null = null;
|
|
923
966
|
|
|
924
967
|
// 5. TOAST: Update current data and bucket list.
|
|
925
968
|
if (afterId) {
|
|
@@ -928,9 +971,10 @@ export class PostgresBucketBatch
|
|
|
928
971
|
source_key: afterId,
|
|
929
972
|
group_id: this.group_id,
|
|
930
973
|
data: afterData!,
|
|
931
|
-
source_table: sourceTable.id,
|
|
974
|
+
source_table: postgresTableId(sourceTable.id),
|
|
932
975
|
buckets: newBuckets,
|
|
933
|
-
lookups: newLookups
|
|
976
|
+
lookups: newLookups,
|
|
977
|
+
pending_delete: null
|
|
934
978
|
};
|
|
935
979
|
persistedBatch.upsertCurrentData(result);
|
|
936
980
|
}
|
|
@@ -938,8 +982,9 @@ export class PostgresBucketBatch
|
|
|
938
982
|
if (afterId == null || !storage.replicaIdEquals(beforeId, afterId)) {
|
|
939
983
|
// Either a delete (afterId == null), or replaced the old replication id
|
|
940
984
|
persistedBatch.deleteCurrentData({
|
|
941
|
-
source_table_id:
|
|
942
|
-
source_key: beforeId
|
|
985
|
+
source_table_id: postgresTableId(sourceTable.id),
|
|
986
|
+
source_key: beforeId!,
|
|
987
|
+
soft: true
|
|
943
988
|
});
|
|
944
989
|
}
|
|
945
990
|
|
|
@@ -961,16 +1006,18 @@ export class PostgresBucketBatch
|
|
|
961
1006
|
await this.db.transaction(async (db) => {
|
|
962
1007
|
const syncRulesRow = await db.sql`
|
|
963
1008
|
SELECT
|
|
964
|
-
state
|
|
1009
|
+
state,
|
|
1010
|
+
snapshot_done
|
|
965
1011
|
FROM
|
|
966
1012
|
sync_rules
|
|
967
1013
|
WHERE
|
|
968
1014
|
id = ${{ type: 'int4', value: this.group_id }}
|
|
1015
|
+
FOR NO KEY UPDATE;
|
|
969
1016
|
`
|
|
970
|
-
.decoded(pick(models.SyncRules, ['state']))
|
|
1017
|
+
.decoded(pick(models.SyncRules, ['state', 'snapshot_done']))
|
|
971
1018
|
.first();
|
|
972
1019
|
|
|
973
|
-
if (syncRulesRow && syncRulesRow.state == storage.SyncRuleState.PROCESSING) {
|
|
1020
|
+
if (syncRulesRow && syncRulesRow.state == storage.SyncRuleState.PROCESSING && syncRulesRow.snapshot_done) {
|
|
974
1021
|
await db.sql`
|
|
975
1022
|
UPDATE sync_rules
|
|
976
1023
|
SET
|
|
@@ -978,25 +1025,27 @@ export class PostgresBucketBatch
|
|
|
978
1025
|
WHERE
|
|
979
1026
|
id = ${{ type: 'int4', value: this.group_id }}
|
|
980
1027
|
`.execute();
|
|
1028
|
+
|
|
1029
|
+
await db.sql`
|
|
1030
|
+
UPDATE sync_rules
|
|
1031
|
+
SET
|
|
1032
|
+
state = ${{ type: 'varchar', value: storage.SyncRuleState.STOP }}
|
|
1033
|
+
WHERE
|
|
1034
|
+
(
|
|
1035
|
+
state = ${{ value: storage.SyncRuleState.ACTIVE, type: 'varchar' }}
|
|
1036
|
+
OR state = ${{ value: storage.SyncRuleState.ERRORED, type: 'varchar' }}
|
|
1037
|
+
)
|
|
1038
|
+
AND id != ${{ type: 'int4', value: this.group_id }}
|
|
1039
|
+
`.execute();
|
|
981
1040
|
didActivate = true;
|
|
1041
|
+
this.needsActivation = false;
|
|
1042
|
+
} else if (syncRulesRow?.state != storage.SyncRuleState.PROCESSING) {
|
|
1043
|
+
this.needsActivation = false;
|
|
982
1044
|
}
|
|
983
|
-
|
|
984
|
-
await db.sql`
|
|
985
|
-
UPDATE sync_rules
|
|
986
|
-
SET
|
|
987
|
-
state = ${{ type: 'varchar', value: storage.SyncRuleState.STOP }}
|
|
988
|
-
WHERE
|
|
989
|
-
(
|
|
990
|
-
state = ${{ value: storage.SyncRuleState.ACTIVE, type: 'varchar' }}
|
|
991
|
-
OR state = ${{ value: storage.SyncRuleState.ERRORED, type: 'varchar' }}
|
|
992
|
-
)
|
|
993
|
-
AND id != ${{ type: 'int4', value: this.group_id }}
|
|
994
|
-
`.execute();
|
|
995
1045
|
});
|
|
996
1046
|
if (didActivate) {
|
|
997
1047
|
this.logger.info(`Activated new sync rules at ${lsn}`);
|
|
998
1048
|
}
|
|
999
|
-
this.needsActivation = false;
|
|
1000
1049
|
}
|
|
1001
1050
|
|
|
1002
1051
|
/**
|
|
@@ -1013,9 +1062,28 @@ export class PostgresBucketBatch
|
|
|
1013
1062
|
callback: (tx: lib_postgres.WrappedConnection) => Promise<T>
|
|
1014
1063
|
): Promise<T> {
|
|
1015
1064
|
try {
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1065
|
+
// Try for up to a minute
|
|
1066
|
+
const lastTry = Date.now() + 60_000;
|
|
1067
|
+
while (true) {
|
|
1068
|
+
try {
|
|
1069
|
+
return await this.db.transaction(async (db) => {
|
|
1070
|
+
// The isolation level is required to protect against concurrent updates to the same data.
|
|
1071
|
+
// In theory the "select ... for update" locks may be able to protect against this, but we
|
|
1072
|
+
// still have failing tests if we use that as the only isolation mechanism.
|
|
1073
|
+
await db.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;');
|
|
1074
|
+
return await callback(db);
|
|
1075
|
+
});
|
|
1076
|
+
} catch (err) {
|
|
1077
|
+
const code = err.cause?.code;
|
|
1078
|
+
if ((code == '40001' || code == '40P01') && Date.now() < lastTry) {
|
|
1079
|
+
// Serialization (lock) failure, retry
|
|
1080
|
+
this.logger.warn(`Serialization failure during replication transaction, retrying: ${err.message}`);
|
|
1081
|
+
await timers.setTimeout(100 + Math.random() * 200);
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
throw err;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1019
1087
|
} finally {
|
|
1020
1088
|
await this.db.sql`
|
|
1021
1089
|
UPDATE sync_rules
|