@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +45 -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 +10 -4
  7. package/dist/@types/storage/batch/OperationBatch.d.ts +2 -2
  8. package/dist/@types/storage/batch/PostgresBucketBatch.d.ts +13 -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 +98 -24
  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 +295 -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 +47 -31
  48. package/src/storage/batch/OperationBatch.ts +4 -3
  49. package/src/storage/batch/PostgresBucketBatch.ts +316 -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 +1111 -16
  61. package/test/src/env.ts +1 -1
  62. package/test/src/migrations.test.ts +1 -1
  63. package/test/src/storage.test.ts +138 -131
  64. package/test/src/storage_compacting.test.ts +80 -11
  65. package/test/src/storage_sync.test.ts +57 -54
  66. 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,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 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
- `)) {
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) => codec.decode(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<boolean> {
303
- const { createEmptyCheckpoints } = { ...storage.DEFAULT_BUCKET_BATCH_COMMIT_OPTIONS, ...options };
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
- 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;
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 (lsn < this.no_checkpoint_before_lsn) {
463
+ if (!result.can_checkpoint) {
316
464
  if (Date.now() - this.lastWaitingLogThrottled > 5_000) {
317
465
  this.logger.info(
318
- `Waiting until ${this.no_checkpoint_before_lsn} before creating checkpoint, currently at ${lsn}. Persisted op: ${this.persisted_op}`
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
- // 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;
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
- if (this.persisted_op != null) {
359
- update.last_checkpoint = this.persisted_op.toString();
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, doc!);
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
- 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
-
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
- 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
- }
504
+ async keepalive(lsn: string): Promise<CheckpointResult> {
505
+ return await this.commit(lsn, { createEmptyCheckpoints: true });
506
+ }
411
507
 
412
- const updated = await this.db.sql`
508
+ async setResumeLsn(lsn: string): Promise<void> {
509
+ await this.db.sql`
413
510
  UPDATE sync_rules
414
511
  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() }}
512
+ snapshot_lsn = ${{ type: 'varchar', value: lsn }}
420
513
  WHERE
421
514
  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!);
515
+ `.execute();
516
+ }
433
517
 
434
- this.last_checkpoint_lsn = lsn;
435
- return true;
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 setResumeLsn(lsn: string): Promise<void> {
539
+ async markTableSnapshotRequired(table: storage.SourceTable): Promise<void> {
439
540
  await this.db.sql`
440
541
  UPDATE sync_rules
441
542
  SET
442
- snapshot_lsn = ${{ type: 'varchar', value: lsn }}
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 markSnapshotDone(
549
+ async markTableSnapshotDone(
449
550
  tables: storage.SourceTable[],
450
- no_checkpoint_before_lsn: string
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 = ${{ type: 'bool', value: true }},
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 > this.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
- no_checkpoint_before = ${{ type: 'varchar', value: no_checkpoint_before_lsn }},
478
- last_keepalive_ts = ${{ type: 1184, value: new Date().toISOString() }}
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 = 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;
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.toString(),
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.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
- `)) {
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, 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
- ]
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 CurrentDataDecoded
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?: CurrentDataDecoded | null
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: CurrentDataDecoded | null = null;
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: record.sourceTable.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
- return await this.db.transaction(async (db) => {
1017
- return await callback(db);
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