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