@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
|
@@ -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,14 +125,20 @@ 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]() {
|
|
129
|
+
if (this.batch != null || this.write_checkpoint_batch.length > 0) {
|
|
130
|
+
// We don't error here, since:
|
|
131
|
+
// 1. In error states, this is expected (we can't distinguish between disposing after success or error).
|
|
132
|
+
// 2. SuppressedError is messy to deal with.
|
|
133
|
+
this.logger.warn('Disposing writer with unflushed changes');
|
|
134
|
+
}
|
|
115
135
|
super.clearListeners();
|
|
116
136
|
}
|
|
117
137
|
|
|
138
|
+
async dispose() {
|
|
139
|
+
await this[Symbol.asyncDispose]();
|
|
140
|
+
}
|
|
141
|
+
|
|
118
142
|
async save(record: storage.SaveOptions): Promise<storage.FlushedResult | null> {
|
|
119
143
|
// TODO maybe share with abstract class
|
|
120
144
|
const { after, before, sourceTable, tag } = record;
|
|
@@ -177,33 +201,24 @@ export class PostgresBucketBatch
|
|
|
177
201
|
const BATCH_LIMIT = 2000;
|
|
178
202
|
let lastBatchCount = BATCH_LIMIT;
|
|
179
203
|
let processedCount = 0;
|
|
180
|
-
const codec = pick(models.CurrentData, ['buckets', 'lookups', 'source_key']);
|
|
181
|
-
|
|
182
204
|
while (lastBatchCount == BATCH_LIMIT) {
|
|
183
205
|
lastBatchCount = 0;
|
|
184
206
|
await this.withReplicationTransaction(async (db) => {
|
|
185
207
|
const persistedBatch = new PostgresPersistedBatch({
|
|
186
208
|
group_id: this.group_id,
|
|
209
|
+
storageConfig: this.storageConfig,
|
|
187
210
|
...this.options.batch_limits
|
|
188
211
|
});
|
|
189
212
|
|
|
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
|
-
`)) {
|
|
213
|
+
for await (const rows of this.currentDataStore.streamTruncateRows(db, {
|
|
214
|
+
groupId: this.group_id,
|
|
215
|
+
sourceTableId: postgresTableId(sourceTable.id),
|
|
216
|
+
limit: BATCH_LIMIT
|
|
217
|
+
})) {
|
|
203
218
|
lastBatchCount += rows.length;
|
|
204
219
|
processedCount += rows.length;
|
|
205
220
|
|
|
206
|
-
const decodedRows = rows.map((row) =>
|
|
221
|
+
const decodedRows = rows.map((row) => this.currentDataStore.decodeTruncateRow(row));
|
|
207
222
|
for (const value of decodedRows) {
|
|
208
223
|
const source_key = deserializeReplicaId(value.source_key);
|
|
209
224
|
persistedBatch.saveBucketData({
|
|
@@ -221,7 +236,9 @@ export class PostgresBucketBatch
|
|
|
221
236
|
persistedBatch.deleteCurrentData({
|
|
222
237
|
// This is serialized since we got it from a DB query
|
|
223
238
|
serialized_source_key: value.source_key,
|
|
224
|
-
source_table_id: sourceTable.id
|
|
239
|
+
source_table_id: postgresTableId(sourceTable.id),
|
|
240
|
+
// No need for soft delete, since this is not streaming replication
|
|
241
|
+
soft: false
|
|
225
242
|
});
|
|
226
243
|
}
|
|
227
244
|
}
|
|
@@ -299,155 +316,239 @@ export class PostgresBucketBatch
|
|
|
299
316
|
return { flushed_op: lastOp };
|
|
300
317
|
}
|
|
301
318
|
|
|
302
|
-
async commit(lsn: string, options?: storage.BucketBatchCommitOptions): Promise<
|
|
303
|
-
const
|
|
319
|
+
async commit(lsn: string, options?: storage.BucketBatchCommitOptions): Promise<CheckpointResult> {
|
|
320
|
+
const createEmptyCheckpoints = options?.createEmptyCheckpoints ?? true;
|
|
304
321
|
|
|
305
322
|
await this.flush();
|
|
306
323
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
324
|
+
const now = new Date().toISOString();
|
|
325
|
+
|
|
326
|
+
const persisted_op = this.persisted_op ?? null;
|
|
327
|
+
|
|
328
|
+
const result = await this.db.sql`
|
|
329
|
+
WITH
|
|
330
|
+
selected AS (
|
|
331
|
+
SELECT
|
|
332
|
+
id,
|
|
333
|
+
state,
|
|
334
|
+
last_checkpoint,
|
|
335
|
+
last_checkpoint_lsn,
|
|
336
|
+
snapshot_done,
|
|
337
|
+
no_checkpoint_before,
|
|
338
|
+
keepalive_op,
|
|
339
|
+
(
|
|
340
|
+
snapshot_done = TRUE
|
|
341
|
+
AND (
|
|
342
|
+
last_checkpoint_lsn IS NULL
|
|
343
|
+
OR last_checkpoint_lsn <= ${{ type: 'varchar', value: lsn }}
|
|
344
|
+
)
|
|
345
|
+
AND (
|
|
346
|
+
no_checkpoint_before IS NULL
|
|
347
|
+
OR no_checkpoint_before <= ${{ type: 'varchar', value: lsn }}
|
|
348
|
+
)
|
|
349
|
+
) AS can_checkpoint
|
|
350
|
+
FROM
|
|
351
|
+
sync_rules
|
|
352
|
+
WHERE
|
|
353
|
+
id = ${{ type: 'int4', value: this.group_id }}
|
|
354
|
+
FOR UPDATE
|
|
355
|
+
),
|
|
356
|
+
computed AS (
|
|
357
|
+
SELECT
|
|
358
|
+
selected.*,
|
|
359
|
+
CASE
|
|
360
|
+
WHEN selected.can_checkpoint THEN GREATEST(
|
|
361
|
+
selected.last_checkpoint,
|
|
362
|
+
${{ type: 'int8', value: persisted_op }},
|
|
363
|
+
selected.keepalive_op,
|
|
364
|
+
0
|
|
365
|
+
)
|
|
366
|
+
ELSE selected.last_checkpoint
|
|
367
|
+
END AS new_last_checkpoint,
|
|
368
|
+
CASE
|
|
369
|
+
WHEN selected.can_checkpoint THEN NULL
|
|
370
|
+
ELSE GREATEST(
|
|
371
|
+
selected.keepalive_op,
|
|
372
|
+
${{ type: 'int8', value: persisted_op }},
|
|
373
|
+
0
|
|
374
|
+
)
|
|
375
|
+
END AS new_keepalive_op
|
|
376
|
+
FROM
|
|
377
|
+
selected
|
|
378
|
+
),
|
|
379
|
+
updated AS (
|
|
380
|
+
UPDATE sync_rules AS sr
|
|
381
|
+
SET
|
|
382
|
+
last_checkpoint_lsn = CASE
|
|
383
|
+
WHEN computed.can_checkpoint THEN ${{ type: 'varchar', value: lsn }}
|
|
384
|
+
ELSE sr.last_checkpoint_lsn
|
|
385
|
+
END,
|
|
386
|
+
last_checkpoint_ts = CASE
|
|
387
|
+
WHEN computed.can_checkpoint THEN ${{ type: 1184, value: now }}
|
|
388
|
+
ELSE sr.last_checkpoint_ts
|
|
389
|
+
END,
|
|
390
|
+
last_keepalive_ts = ${{ type: 1184, value: now }},
|
|
391
|
+
last_fatal_error = CASE
|
|
392
|
+
WHEN computed.can_checkpoint THEN NULL
|
|
393
|
+
ELSE sr.last_fatal_error
|
|
394
|
+
END,
|
|
395
|
+
keepalive_op = computed.new_keepalive_op,
|
|
396
|
+
last_checkpoint = computed.new_last_checkpoint,
|
|
397
|
+
snapshot_lsn = CASE
|
|
398
|
+
WHEN computed.can_checkpoint THEN NULL
|
|
399
|
+
ELSE sr.snapshot_lsn
|
|
400
|
+
END
|
|
401
|
+
FROM
|
|
402
|
+
computed
|
|
403
|
+
WHERE
|
|
404
|
+
sr.id = computed.id
|
|
405
|
+
AND (
|
|
406
|
+
sr.keepalive_op IS DISTINCT FROM computed.new_keepalive_op
|
|
407
|
+
OR sr.last_checkpoint IS DISTINCT FROM computed.new_last_checkpoint
|
|
408
|
+
OR ${{ type: 'bool', value: createEmptyCheckpoints }}
|
|
409
|
+
)
|
|
410
|
+
RETURNING
|
|
411
|
+
sr.id,
|
|
412
|
+
sr.state,
|
|
413
|
+
sr.last_checkpoint,
|
|
414
|
+
sr.last_checkpoint_lsn,
|
|
415
|
+
sr.snapshot_done,
|
|
416
|
+
sr.no_checkpoint_before,
|
|
417
|
+
computed.can_checkpoint,
|
|
418
|
+
computed.keepalive_op,
|
|
419
|
+
computed.new_last_checkpoint
|
|
420
|
+
)
|
|
421
|
+
SELECT
|
|
422
|
+
id,
|
|
423
|
+
state,
|
|
424
|
+
last_checkpoint,
|
|
425
|
+
last_checkpoint_lsn,
|
|
426
|
+
snapshot_done,
|
|
427
|
+
no_checkpoint_before,
|
|
428
|
+
can_checkpoint,
|
|
429
|
+
keepalive_op,
|
|
430
|
+
new_last_checkpoint,
|
|
431
|
+
TRUE AS created_checkpoint
|
|
432
|
+
FROM
|
|
433
|
+
updated
|
|
434
|
+
UNION ALL
|
|
435
|
+
SELECT
|
|
436
|
+
id,
|
|
437
|
+
state,
|
|
438
|
+
new_last_checkpoint AS last_checkpoint,
|
|
439
|
+
last_checkpoint_lsn,
|
|
440
|
+
snapshot_done,
|
|
441
|
+
no_checkpoint_before,
|
|
442
|
+
can_checkpoint,
|
|
443
|
+
keepalive_op,
|
|
444
|
+
new_last_checkpoint,
|
|
445
|
+
FALSE AS created_checkpoint
|
|
446
|
+
FROM
|
|
447
|
+
computed
|
|
448
|
+
WHERE
|
|
449
|
+
NOT EXISTS (
|
|
450
|
+
SELECT
|
|
451
|
+
1
|
|
452
|
+
FROM
|
|
453
|
+
updated
|
|
454
|
+
)
|
|
455
|
+
`
|
|
456
|
+
.decoded(CheckpointWithStatus)
|
|
457
|
+
.first();
|
|
458
|
+
|
|
459
|
+
if (result == null) {
|
|
460
|
+
throw new ReplicationAssertionError('Failed to update sync_rules during checkpoint');
|
|
313
461
|
}
|
|
314
462
|
|
|
315
|
-
if (
|
|
463
|
+
if (!result.can_checkpoint) {
|
|
316
464
|
if (Date.now() - this.lastWaitingLogThrottled > 5_000) {
|
|
317
465
|
this.logger.info(
|
|
318
|
-
`Waiting
|
|
466
|
+
`Waiting before creating checkpoint, currently at ${lsn}. Last op: ${result.keepalive_op}. Current state: ${JSON.stringify(
|
|
467
|
+
{
|
|
468
|
+
snapshot_done: result.snapshot_done,
|
|
469
|
+
last_checkpoint_lsn: result.last_checkpoint_lsn,
|
|
470
|
+
no_checkpoint_before: result.no_checkpoint_before
|
|
471
|
+
}
|
|
472
|
+
)}`
|
|
319
473
|
);
|
|
320
474
|
this.lastWaitingLogThrottled = Date.now();
|
|
321
475
|
}
|
|
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;
|
|
476
|
+
return { checkpointBlocked: true, checkpointCreated: false };
|
|
339
477
|
}
|
|
340
478
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
// Nothing to commit - return true
|
|
344
|
-
await this.autoActivate(lsn);
|
|
345
|
-
return true;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
const now = new Date().toISOString();
|
|
349
|
-
const update: Partial<models.SyncRules> = {
|
|
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
|
-
};
|
|
479
|
+
if (result.created_checkpoint) {
|
|
480
|
+
this.logger.debug(`Created checkpoint at ${lsn}. Last op: ${result.last_checkpoint}`);
|
|
357
481
|
|
|
358
|
-
|
|
359
|
-
|
|
482
|
+
if (result.last_checkpoint != null) {
|
|
483
|
+
await this.currentDataStore.cleanupPendingDeletes(this.db, {
|
|
484
|
+
groupId: this.group_id,
|
|
485
|
+
lastCheckpoint: result.last_checkpoint
|
|
486
|
+
});
|
|
487
|
+
}
|
|
360
488
|
}
|
|
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
489
|
await this.autoActivate(lsn);
|
|
388
|
-
await notifySyncRulesUpdate(this.db,
|
|
490
|
+
await notifySyncRulesUpdate(this.db, {
|
|
491
|
+
id: result.id,
|
|
492
|
+
state: result.state,
|
|
493
|
+
last_checkpoint: result.last_checkpoint,
|
|
494
|
+
last_checkpoint_lsn: result.last_checkpoint_lsn
|
|
495
|
+
});
|
|
389
496
|
|
|
390
497
|
this.persisted_op = null;
|
|
391
498
|
this.last_checkpoint_lsn = lsn;
|
|
392
|
-
return true;
|
|
393
|
-
}
|
|
394
499
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
return false;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
if (lsn < this.no_checkpoint_before_lsn) {
|
|
402
|
-
return false;
|
|
403
|
-
}
|
|
500
|
+
// Even if created_checkpoint is false, if can_checkpoint is true, we need to return not blocked.
|
|
501
|
+
return { checkpointBlocked: false, checkpointCreated: result.created_checkpoint };
|
|
502
|
+
}
|
|
404
503
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
this.logger.info(`Commit due to keepalive at ${lsn} / ${this.persisted_op}`);
|
|
409
|
-
return await this.commit(lsn);
|
|
410
|
-
}
|
|
504
|
+
async keepalive(lsn: string): Promise<CheckpointResult> {
|
|
505
|
+
return await this.commit(lsn, { createEmptyCheckpoints: true });
|
|
506
|
+
}
|
|
411
507
|
|
|
412
|
-
|
|
508
|
+
async setResumeLsn(lsn: string): Promise<void> {
|
|
509
|
+
await this.db.sql`
|
|
413
510
|
UPDATE sync_rules
|
|
414
511
|
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() }}
|
|
512
|
+
snapshot_lsn = ${{ type: 'varchar', value: lsn }}
|
|
420
513
|
WHERE
|
|
421
514
|
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!);
|
|
515
|
+
`.execute();
|
|
516
|
+
}
|
|
433
517
|
|
|
434
|
-
|
|
435
|
-
|
|
518
|
+
async markAllSnapshotDone(no_checkpoint_before_lsn: string): Promise<void> {
|
|
519
|
+
await this.db.transaction(async (db) => {
|
|
520
|
+
await db.sql`
|
|
521
|
+
UPDATE sync_rules
|
|
522
|
+
SET
|
|
523
|
+
snapshot_done = TRUE,
|
|
524
|
+
last_keepalive_ts = ${{ type: 1184, value: new Date().toISOString() }},
|
|
525
|
+
no_checkpoint_before = CASE
|
|
526
|
+
WHEN no_checkpoint_before IS NULL
|
|
527
|
+
OR no_checkpoint_before < ${{ type: 'varchar', value: no_checkpoint_before_lsn }} THEN ${{
|
|
528
|
+
type: 'varchar',
|
|
529
|
+
value: no_checkpoint_before_lsn
|
|
530
|
+
}}
|
|
531
|
+
ELSE no_checkpoint_before
|
|
532
|
+
END
|
|
533
|
+
WHERE
|
|
534
|
+
id = ${{ type: 'int4', value: this.group_id }}
|
|
535
|
+
`.execute();
|
|
536
|
+
});
|
|
436
537
|
}
|
|
437
538
|
|
|
438
|
-
async
|
|
539
|
+
async markTableSnapshotRequired(table: storage.SourceTable): Promise<void> {
|
|
439
540
|
await this.db.sql`
|
|
440
541
|
UPDATE sync_rules
|
|
441
542
|
SET
|
|
442
|
-
|
|
543
|
+
snapshot_done = FALSE
|
|
443
544
|
WHERE
|
|
444
545
|
id = ${{ type: 'int4', value: this.group_id }}
|
|
445
546
|
`.execute();
|
|
446
547
|
}
|
|
447
548
|
|
|
448
|
-
async
|
|
549
|
+
async markTableSnapshotDone(
|
|
449
550
|
tables: storage.SourceTable[],
|
|
450
|
-
no_checkpoint_before_lsn
|
|
551
|
+
no_checkpoint_before_lsn?: string
|
|
451
552
|
): Promise<storage.SourceTable[]> {
|
|
452
553
|
const ids = tables.map((table) => table.id.toString());
|
|
453
554
|
|
|
@@ -455,7 +556,7 @@ export class PostgresBucketBatch
|
|
|
455
556
|
await db.sql`
|
|
456
557
|
UPDATE source_tables
|
|
457
558
|
SET
|
|
458
|
-
snapshot_done =
|
|
559
|
+
snapshot_done = TRUE,
|
|
459
560
|
snapshot_total_estimated_count = NULL,
|
|
460
561
|
snapshot_replicated_count = NULL,
|
|
461
562
|
snapshot_last_key = NULL
|
|
@@ -468,31 +569,27 @@ export class PostgresBucketBatch
|
|
|
468
569
|
);
|
|
469
570
|
`.execute();
|
|
470
571
|
|
|
471
|
-
if (no_checkpoint_before_lsn
|
|
472
|
-
this.no_checkpoint_before_lsn = no_checkpoint_before_lsn;
|
|
473
|
-
|
|
572
|
+
if (no_checkpoint_before_lsn != null) {
|
|
474
573
|
await db.sql`
|
|
475
574
|
UPDATE sync_rules
|
|
476
575
|
SET
|
|
477
|
-
|
|
478
|
-
|
|
576
|
+
last_keepalive_ts = ${{ type: 1184, value: new Date().toISOString() }},
|
|
577
|
+
no_checkpoint_before = CASE
|
|
578
|
+
WHEN no_checkpoint_before IS NULL
|
|
579
|
+
OR no_checkpoint_before < ${{ type: 'varchar', value: no_checkpoint_before_lsn }} THEN ${{
|
|
580
|
+
type: 'varchar',
|
|
581
|
+
value: no_checkpoint_before_lsn
|
|
582
|
+
}}
|
|
583
|
+
ELSE no_checkpoint_before
|
|
584
|
+
END
|
|
479
585
|
WHERE
|
|
480
586
|
id = ${{ type: 'int4', value: this.group_id }}
|
|
481
587
|
`.execute();
|
|
482
588
|
}
|
|
483
589
|
});
|
|
484
590
|
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;
|
|
591
|
+
const copy = table.clone();
|
|
592
|
+
copy.snapshotComplete = true;
|
|
496
593
|
return copy;
|
|
497
594
|
});
|
|
498
595
|
}
|
|
@@ -542,7 +639,7 @@ export class PostgresBucketBatch
|
|
|
542
639
|
// exceeding memory limits.
|
|
543
640
|
const sizeLookups = batch.batch.map((r) => {
|
|
544
641
|
return {
|
|
545
|
-
source_table: r.record.sourceTable.id
|
|
642
|
+
source_table: postgresTableId(r.record.sourceTable.id),
|
|
546
643
|
/**
|
|
547
644
|
* Encode to hex in order to pass a jsonb
|
|
548
645
|
*/
|
|
@@ -552,30 +649,10 @@ export class PostgresBucketBatch
|
|
|
552
649
|
|
|
553
650
|
sizes = new Map<string, number>();
|
|
554
651
|
|
|
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
|
-
`)) {
|
|
652
|
+
for await (const rows of this.currentDataStore.streamSizeRows(db, {
|
|
653
|
+
groupId: this.group_id,
|
|
654
|
+
lookups: sizeLookups
|
|
655
|
+
})) {
|
|
579
656
|
for (const row of rows) {
|
|
580
657
|
const key = cacheKey(row.source_table, row.source_key);
|
|
581
658
|
sizes.set(key, row.data_size);
|
|
@@ -600,53 +677,29 @@ export class PostgresBucketBatch
|
|
|
600
677
|
|
|
601
678
|
const lookups = b.map((r) => {
|
|
602
679
|
return {
|
|
603
|
-
source_table: r.record.sourceTable.id,
|
|
680
|
+
source_table: postgresTableId(r.record.sourceTable.id),
|
|
604
681
|
source_key: storage.serializeReplicaId(r.beforeId).toString('hex')
|
|
605
682
|
};
|
|
606
683
|
});
|
|
607
684
|
|
|
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
|
-
]
|
|
685
|
+
const current_data_lookup = new Map<string, V3CurrentDataDecoded>();
|
|
686
|
+
for await (const currentDataRows of this.currentDataStore.streamLookupRows(db, {
|
|
687
|
+
groupId: this.group_id,
|
|
688
|
+
lookups,
|
|
689
|
+
skipExistingRows: this.options.skip_existing_rows
|
|
636
690
|
})) {
|
|
637
691
|
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);
|
|
692
|
+
const decoded = this.currentDataStore.decodeLookupRow(row, this.options.skip_existing_rows);
|
|
641
693
|
current_data_lookup.set(
|
|
642
694
|
encodedCacheKey(decoded.source_table, decoded.source_key),
|
|
643
|
-
decoded as
|
|
695
|
+
decoded as V3CurrentDataDecoded
|
|
644
696
|
);
|
|
645
697
|
}
|
|
646
698
|
}
|
|
647
699
|
|
|
648
700
|
let persistedBatch: PostgresPersistedBatch | null = new PostgresPersistedBatch({
|
|
649
701
|
group_id: this.group_id,
|
|
702
|
+
storageConfig: this.storageConfig,
|
|
650
703
|
...this.options.batch_limits
|
|
651
704
|
});
|
|
652
705
|
|
|
@@ -703,7 +756,7 @@ export class PostgresBucketBatch
|
|
|
703
756
|
protected async saveOperation(
|
|
704
757
|
persistedBatch: PostgresPersistedBatch,
|
|
705
758
|
operation: RecordOperation,
|
|
706
|
-
currentData?:
|
|
759
|
+
currentData?: V3CurrentDataDecoded | null
|
|
707
760
|
) {
|
|
708
761
|
const record = operation.record;
|
|
709
762
|
// We store bytea colums for source keys
|
|
@@ -919,7 +972,7 @@ export class PostgresBucketBatch
|
|
|
919
972
|
}
|
|
920
973
|
}
|
|
921
974
|
|
|
922
|
-
let result:
|
|
975
|
+
let result: V3CurrentDataDecoded | null = null;
|
|
923
976
|
|
|
924
977
|
// 5. TOAST: Update current data and bucket list.
|
|
925
978
|
if (afterId) {
|
|
@@ -928,9 +981,10 @@ export class PostgresBucketBatch
|
|
|
928
981
|
source_key: afterId,
|
|
929
982
|
group_id: this.group_id,
|
|
930
983
|
data: afterData!,
|
|
931
|
-
source_table: sourceTable.id,
|
|
984
|
+
source_table: postgresTableId(sourceTable.id),
|
|
932
985
|
buckets: newBuckets,
|
|
933
|
-
lookups: newLookups
|
|
986
|
+
lookups: newLookups,
|
|
987
|
+
pending_delete: null
|
|
934
988
|
};
|
|
935
989
|
persistedBatch.upsertCurrentData(result);
|
|
936
990
|
}
|
|
@@ -938,8 +992,9 @@ export class PostgresBucketBatch
|
|
|
938
992
|
if (afterId == null || !storage.replicaIdEquals(beforeId, afterId)) {
|
|
939
993
|
// Either a delete (afterId == null), or replaced the old replication id
|
|
940
994
|
persistedBatch.deleteCurrentData({
|
|
941
|
-
source_table_id:
|
|
942
|
-
source_key: beforeId
|
|
995
|
+
source_table_id: postgresTableId(sourceTable.id),
|
|
996
|
+
source_key: beforeId!,
|
|
997
|
+
soft: true
|
|
943
998
|
});
|
|
944
999
|
}
|
|
945
1000
|
|
|
@@ -961,16 +1016,18 @@ export class PostgresBucketBatch
|
|
|
961
1016
|
await this.db.transaction(async (db) => {
|
|
962
1017
|
const syncRulesRow = await db.sql`
|
|
963
1018
|
SELECT
|
|
964
|
-
state
|
|
1019
|
+
state,
|
|
1020
|
+
snapshot_done
|
|
965
1021
|
FROM
|
|
966
1022
|
sync_rules
|
|
967
1023
|
WHERE
|
|
968
1024
|
id = ${{ type: 'int4', value: this.group_id }}
|
|
1025
|
+
FOR NO KEY UPDATE;
|
|
969
1026
|
`
|
|
970
|
-
.decoded(pick(models.SyncRules, ['state']))
|
|
1027
|
+
.decoded(pick(models.SyncRules, ['state', 'snapshot_done']))
|
|
971
1028
|
.first();
|
|
972
1029
|
|
|
973
|
-
if (syncRulesRow && syncRulesRow.state == storage.SyncRuleState.PROCESSING) {
|
|
1030
|
+
if (syncRulesRow && syncRulesRow.state == storage.SyncRuleState.PROCESSING && syncRulesRow.snapshot_done) {
|
|
974
1031
|
await db.sql`
|
|
975
1032
|
UPDATE sync_rules
|
|
976
1033
|
SET
|
|
@@ -978,25 +1035,27 @@ export class PostgresBucketBatch
|
|
|
978
1035
|
WHERE
|
|
979
1036
|
id = ${{ type: 'int4', value: this.group_id }}
|
|
980
1037
|
`.execute();
|
|
1038
|
+
|
|
1039
|
+
await db.sql`
|
|
1040
|
+
UPDATE sync_rules
|
|
1041
|
+
SET
|
|
1042
|
+
state = ${{ type: 'varchar', value: storage.SyncRuleState.STOP }}
|
|
1043
|
+
WHERE
|
|
1044
|
+
(
|
|
1045
|
+
state = ${{ value: storage.SyncRuleState.ACTIVE, type: 'varchar' }}
|
|
1046
|
+
OR state = ${{ value: storage.SyncRuleState.ERRORED, type: 'varchar' }}
|
|
1047
|
+
)
|
|
1048
|
+
AND id != ${{ type: 'int4', value: this.group_id }}
|
|
1049
|
+
`.execute();
|
|
981
1050
|
didActivate = true;
|
|
1051
|
+
this.needsActivation = false;
|
|
1052
|
+
} else if (syncRulesRow?.state != storage.SyncRuleState.PROCESSING) {
|
|
1053
|
+
this.needsActivation = false;
|
|
982
1054
|
}
|
|
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
1055
|
});
|
|
996
1056
|
if (didActivate) {
|
|
997
1057
|
this.logger.info(`Activated new sync rules at ${lsn}`);
|
|
998
1058
|
}
|
|
999
|
-
this.needsActivation = false;
|
|
1000
1059
|
}
|
|
1001
1060
|
|
|
1002
1061
|
/**
|
|
@@ -1013,9 +1072,28 @@ export class PostgresBucketBatch
|
|
|
1013
1072
|
callback: (tx: lib_postgres.WrappedConnection) => Promise<T>
|
|
1014
1073
|
): Promise<T> {
|
|
1015
1074
|
try {
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1075
|
+
// Try for up to a minute
|
|
1076
|
+
const lastTry = Date.now() + 60_000;
|
|
1077
|
+
while (true) {
|
|
1078
|
+
try {
|
|
1079
|
+
return await this.db.transaction(async (db) => {
|
|
1080
|
+
// The isolation level is required to protect against concurrent updates to the same data.
|
|
1081
|
+
// In theory the "select ... for update" locks may be able to protect against this, but we
|
|
1082
|
+
// still have failing tests if we use that as the only isolation mechanism.
|
|
1083
|
+
await db.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;');
|
|
1084
|
+
return await callback(db);
|
|
1085
|
+
});
|
|
1086
|
+
} catch (err) {
|
|
1087
|
+
const code = err.cause?.code;
|
|
1088
|
+
if ((code == '40001' || code == '40P01') && Date.now() < lastTry) {
|
|
1089
|
+
// Serialization (lock) failure, retry
|
|
1090
|
+
this.logger.warn(`Serialization failure during replication transaction, retrying: ${err.message}`);
|
|
1091
|
+
await timers.setTimeout(100 + Math.random() * 200);
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
throw err;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1019
1097
|
} finally {
|
|
1020
1098
|
await this.db.sql`
|
|
1021
1099
|
UPDATE sync_rules
|