@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/@types/migrations/scripts/1771424826685-current-data-pending-deletes.d.ts +3 -0
  4. package/dist/@types/storage/PostgresBucketStorageFactory.d.ts +4 -0
  5. package/dist/@types/storage/PostgresCompactor.d.ts +8 -2
  6. package/dist/@types/storage/PostgresSyncRulesStorage.d.ts +5 -3
  7. package/dist/@types/storage/batch/OperationBatch.d.ts +2 -2
  8. package/dist/@types/storage/batch/PostgresBucketBatch.d.ts +12 -9
  9. package/dist/@types/storage/batch/PostgresPersistedBatch.d.ts +17 -5
  10. package/dist/@types/storage/current-data-store.d.ts +85 -0
  11. package/dist/@types/storage/current-data-table.d.ts +9 -0
  12. package/dist/@types/storage/table-id.d.ts +2 -0
  13. package/dist/@types/types/models/CurrentData.d.ts +18 -3
  14. package/dist/@types/utils/bson.d.ts +1 -1
  15. package/dist/@types/utils/test-utils.d.ts +1 -1
  16. package/dist/migrations/scripts/1771424826685-current-data-pending-deletes.js +8 -0
  17. package/dist/migrations/scripts/1771424826685-current-data-pending-deletes.js.map +1 -0
  18. package/dist/storage/PostgresBucketStorageFactory.js +41 -4
  19. package/dist/storage/PostgresBucketStorageFactory.js.map +1 -1
  20. package/dist/storage/PostgresCompactor.js +14 -6
  21. package/dist/storage/PostgresCompactor.js.map +1 -1
  22. package/dist/storage/PostgresSyncRulesStorage.js +23 -15
  23. package/dist/storage/PostgresSyncRulesStorage.js.map +1 -1
  24. package/dist/storage/batch/OperationBatch.js +2 -1
  25. package/dist/storage/batch/OperationBatch.js.map +1 -1
  26. package/dist/storage/batch/PostgresBucketBatch.js +286 -213
  27. package/dist/storage/batch/PostgresBucketBatch.js.map +1 -1
  28. package/dist/storage/batch/PostgresPersistedBatch.js +86 -81
  29. package/dist/storage/batch/PostgresPersistedBatch.js.map +1 -1
  30. package/dist/storage/current-data-store.js +270 -0
  31. package/dist/storage/current-data-store.js.map +1 -0
  32. package/dist/storage/current-data-table.js +22 -0
  33. package/dist/storage/current-data-table.js.map +1 -0
  34. package/dist/storage/table-id.js +8 -0
  35. package/dist/storage/table-id.js.map +1 -0
  36. package/dist/types/models/CurrentData.js +11 -2
  37. package/dist/types/models/CurrentData.js.map +1 -1
  38. package/dist/utils/bson.js.map +1 -1
  39. package/dist/utils/db.js +9 -0
  40. package/dist/utils/db.js.map +1 -1
  41. package/dist/utils/test-utils.js +13 -6
  42. package/dist/utils/test-utils.js.map +1 -1
  43. package/package.json +8 -8
  44. package/src/migrations/scripts/1771424826685-current-data-pending-deletes.ts +10 -0
  45. package/src/storage/PostgresBucketStorageFactory.ts +53 -5
  46. package/src/storage/PostgresCompactor.ts +17 -8
  47. package/src/storage/PostgresSyncRulesStorage.ts +30 -17
  48. package/src/storage/batch/OperationBatch.ts +4 -3
  49. package/src/storage/batch/PostgresBucketBatch.ts +306 -238
  50. package/src/storage/batch/PostgresPersistedBatch.ts +92 -84
  51. package/src/storage/current-data-store.ts +326 -0
  52. package/src/storage/current-data-table.ts +26 -0
  53. package/src/storage/table-id.ts +9 -0
  54. package/src/types/models/CurrentData.ts +17 -4
  55. package/src/utils/bson.ts +1 -1
  56. package/src/utils/db.ts +10 -0
  57. package/src/utils/test-utils.ts +14 -7
  58. package/test/src/__snapshots__/storage.test.ts.snap +151 -0
  59. package/test/src/__snapshots__/storage_compacting.test.ts.snap +17 -0
  60. package/test/src/__snapshots__/storage_sync.test.ts.snap +1095 -0
  61. package/test/src/migrations.test.ts +1 -1
  62. package/test/src/storage.test.ts +136 -130
  63. package/test/src/storage_compacting.test.ts +65 -3
  64. package/test/src/storage_sync.test.ts +11 -9
  65. 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, CurrentData, CurrentDataDecoded } from '../../types/models/CurrentData.js';
23
+ import { CurrentBucket, V3CurrentDataDecoded } from '../../types/models/CurrentData.js';
23
24
  import { models, RequiredOperationBatchLimits } from '../../types/types.js';
24
- import { NOTIFICATION_CHANNEL, sql } from '../../utils/db.js';
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 db.streamRows<t.Encoded<typeof codec>>(sql`
191
- SELECT
192
- buckets,
193
- lookups,
194
- source_key
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) => codec.decode(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<boolean> {
303
- const { createEmptyCheckpoints } = { ...storage.DEFAULT_BUCKET_BATCH_COMMIT_OPTIONS, ...options };
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
- if (this.last_checkpoint_lsn != null && lsn < this.last_checkpoint_lsn) {
308
- // When re-applying transactions, don't create a new checkpoint until
309
- // we are past the last transaction.
310
- this.logger.info(`Re-applied transaction ${lsn} - skipping checkpoint`);
311
- // Cannot create a checkpoint yet - return false
312
- return false;
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 (lsn < this.no_checkpoint_before_lsn) {
453
+ if (!result.can_checkpoint) {
316
454
  if (Date.now() - this.lastWaitingLogThrottled > 5_000) {
317
455
  this.logger.info(
318
- `Waiting until ${this.no_checkpoint_before_lsn} before creating checkpoint, currently at ${lsn}. Persisted op: ${this.persisted_op}`
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
- 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
- };
469
+ if (result.created_checkpoint) {
470
+ this.logger.debug(`Created checkpoint at ${lsn}. Last op: ${result.last_checkpoint}`);
357
471
 
358
- if (this.persisted_op != null) {
359
- update.last_checkpoint = this.persisted_op.toString();
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, doc!);
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 (lsn < this.no_checkpoint_before_lsn) {
402
- return false;
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
- if (this.persisted_op != null) {
406
- // The commit may have been skipped due to "no_checkpoint_before_lsn".
407
- // Apply it now if relevant
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
- const updated = await this.db.sql`
498
+ async setResumeLsn(lsn: string): Promise<void> {
499
+ await this.db.sql`
413
500
  UPDATE sync_rules
414
501
  SET
415
- snapshot_done = ${{ type: 'bool', value: true }},
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
- RETURNING
423
- id,
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
- this.last_checkpoint_lsn = lsn;
435
- return true;
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 setResumeLsn(lsn: string): Promise<void> {
529
+ async markTableSnapshotRequired(table: storage.SourceTable): Promise<void> {
439
530
  await this.db.sql`
440
531
  UPDATE sync_rules
441
532
  SET
442
- snapshot_lsn = ${{ type: 'varchar', value: lsn }}
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 markSnapshotDone(
539
+ async markTableSnapshotDone(
449
540
  tables: storage.SourceTable[],
450
- no_checkpoint_before_lsn: string
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 = ${{ type: 'bool', value: true }},
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 > this.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
- no_checkpoint_before = ${{ type: 'varchar', value: no_checkpoint_before_lsn }},
478
- last_keepalive_ts = ${{ type: 1184, value: new Date().toISOString() }}
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 = new storage.SourceTable({
486
- id: table.id,
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.toString(),
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.streamRows<{
556
- source_table: string;
557
- source_key: storage.ReplicaId;
558
- data_size: number;
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, CurrentDataDecoded>();
609
- for await (const currentDataRows of db.streamRows<CurrentData>({
610
- statement: /* sql */ `
611
- SELECT
612
- ${this.options.skip_existing_rows ? `c.source_table, c.source_key` : 'c.*'}
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 CurrentDataDecoded
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?: CurrentDataDecoded | null
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: CurrentDataDecoded | null = null;
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: record.sourceTable.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
- return await this.db.transaction(async (db) => {
1017
- return await callback(db);
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