@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
@@ -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,9 +69,6 @@ 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]() {
65
73
  super.clearListeners();
66
74
  }
@@ -118,30 +126,22 @@ export class PostgresBucketBatch extends BaseObserver {
118
126
  const BATCH_LIMIT = 2000;
119
127
  let lastBatchCount = BATCH_LIMIT;
120
128
  let processedCount = 0;
121
- const codec = pick(models.CurrentData, ['buckets', 'lookups', 'source_key']);
122
129
  while (lastBatchCount == BATCH_LIMIT) {
123
130
  lastBatchCount = 0;
124
131
  await this.withReplicationTransaction(async (db) => {
125
132
  const persistedBatch = new PostgresPersistedBatch({
126
133
  group_id: this.group_id,
134
+ storageConfig: this.storageConfig,
127
135
  ...this.options.batch_limits
128
136
  });
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
- `)) {
137
+ for await (const rows of this.currentDataStore.streamTruncateRows(db, {
138
+ groupId: this.group_id,
139
+ sourceTableId: postgresTableId(sourceTable.id),
140
+ limit: BATCH_LIMIT
141
+ })) {
142
142
  lastBatchCount += rows.length;
143
143
  processedCount += rows.length;
144
- const decodedRows = rows.map((row) => codec.decode(row));
144
+ const decodedRows = rows.map((row) => this.currentDataStore.decodeTruncateRow(row));
145
145
  for (const value of decodedRows) {
146
146
  const source_key = deserializeReplicaId(value.source_key);
147
147
  persistedBatch.saveBucketData({
@@ -159,7 +159,9 @@ export class PostgresBucketBatch extends BaseObserver {
159
159
  persistedBatch.deleteCurrentData({
160
160
  // This is serialized since we got it from a DB query
161
161
  serialized_source_key: value.source_key,
162
- source_table_id: sourceTable.id
162
+ source_table_id: postgresTableId(sourceTable.id),
163
+ // No need for soft delete, since this is not streaming replication
164
+ soft: false
163
165
  });
164
166
  }
165
167
  }
@@ -226,136 +228,223 @@ export class PostgresBucketBatch extends BaseObserver {
226
228
  return { flushed_op: lastOp };
227
229
  }
228
230
  async commit(lsn, options) {
229
- const { createEmptyCheckpoints } = { ...storage.DEFAULT_BUCKET_BATCH_COMMIT_OPTIONS, ...options };
231
+ const createEmptyCheckpoints = options?.createEmptyCheckpoints ?? true;
230
232
  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
233
  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
234
+ const persisted_op = this.persisted_op ?? null;
235
+ const result = await this.db.sql `
236
+ WITH
237
+ selected AS (
238
+ SELECT
239
+ id,
240
+ state,
241
+ last_checkpoint,
242
+ last_checkpoint_lsn,
243
+ snapshot_done,
244
+ no_checkpoint_before,
245
+ keepalive_op,
246
+ (
247
+ snapshot_done = TRUE
248
+ AND (
249
+ last_checkpoint_lsn IS NULL
250
+ OR last_checkpoint_lsn <= ${{ type: 'varchar', value: lsn }}
251
+ )
252
+ AND (
253
+ no_checkpoint_before IS NULL
254
+ OR no_checkpoint_before <= ${{ type: 'varchar', value: lsn }}
255
+ )
256
+ ) AS can_checkpoint
257
+ FROM
258
+ sync_rules
259
+ WHERE
260
+ id = ${{ type: 'int4', value: this.group_id }}
261
+ FOR UPDATE
287
262
  ),
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
263
+ computed AS (
264
+ SELECT
265
+ selected.*,
266
+ CASE
267
+ WHEN selected.can_checkpoint THEN GREATEST(
268
+ selected.last_checkpoint,
269
+ ${{ type: 'int8', value: persisted_op }},
270
+ selected.keepalive_op,
271
+ 0
272
+ )
273
+ ELSE selected.last_checkpoint
274
+ END AS new_last_checkpoint,
275
+ CASE
276
+ WHEN selected.can_checkpoint THEN NULL
277
+ ELSE GREATEST(
278
+ selected.keepalive_op,
279
+ ${{ type: 'int8', value: persisted_op }},
280
+ 0
281
+ )
282
+ END AS new_keepalive_op
283
+ FROM
284
+ selected
285
+ ),
286
+ updated AS (
287
+ UPDATE sync_rules AS sr
288
+ SET
289
+ last_checkpoint_lsn = CASE
290
+ WHEN computed.can_checkpoint THEN ${{ type: 'varchar', value: lsn }}
291
+ ELSE sr.last_checkpoint_lsn
292
+ END,
293
+ last_checkpoint_ts = CASE
294
+ WHEN computed.can_checkpoint THEN ${{ type: 1184, value: now }}
295
+ ELSE sr.last_checkpoint_ts
296
+ END,
297
+ last_keepalive_ts = ${{ type: 1184, value: now }},
298
+ last_fatal_error = CASE
299
+ WHEN computed.can_checkpoint THEN NULL
300
+ ELSE sr.last_fatal_error
301
+ END,
302
+ keepalive_op = computed.new_keepalive_op,
303
+ last_checkpoint = computed.new_last_checkpoint,
304
+ snapshot_lsn = CASE
305
+ WHEN computed.can_checkpoint THEN NULL
306
+ ELSE sr.snapshot_lsn
307
+ END
308
+ FROM
309
+ computed
310
+ WHERE
311
+ sr.id = computed.id
312
+ AND (
313
+ sr.keepalive_op IS DISTINCT FROM computed.new_keepalive_op
314
+ OR sr.last_checkpoint IS DISTINCT FROM computed.new_last_checkpoint
315
+ OR ${{ type: 'bool', value: createEmptyCheckpoints }}
316
+ )
317
+ RETURNING
318
+ sr.id,
319
+ sr.state,
320
+ sr.last_checkpoint,
321
+ sr.last_checkpoint_lsn,
322
+ sr.snapshot_done,
323
+ sr.no_checkpoint_before,
324
+ computed.can_checkpoint,
325
+ computed.keepalive_op,
326
+ computed.new_last_checkpoint
327
+ )
328
+ SELECT
293
329
  id,
294
330
  state,
295
331
  last_checkpoint,
296
- last_checkpoint_lsn
332
+ last_checkpoint_lsn,
333
+ snapshot_done,
334
+ no_checkpoint_before,
335
+ can_checkpoint,
336
+ keepalive_op,
337
+ new_last_checkpoint,
338
+ TRUE AS created_checkpoint
339
+ FROM
340
+ updated
341
+ UNION ALL
342
+ SELECT
343
+ id,
344
+ state,
345
+ new_last_checkpoint AS last_checkpoint,
346
+ last_checkpoint_lsn,
347
+ snapshot_done,
348
+ no_checkpoint_before,
349
+ can_checkpoint,
350
+ keepalive_op,
351
+ new_last_checkpoint,
352
+ FALSE AS created_checkpoint
353
+ FROM
354
+ computed
355
+ WHERE
356
+ NOT EXISTS (
357
+ SELECT
358
+ 1
359
+ FROM
360
+ updated
361
+ )
297
362
  `
298
- .decoded(StatefulCheckpoint)
363
+ .decoded(CheckpointWithStatus)
299
364
  .first();
365
+ if (result == null) {
366
+ throw new ReplicationAssertionError('Failed to update sync_rules during checkpoint');
367
+ }
368
+ if (!result.can_checkpoint) {
369
+ if (Date.now() - this.lastWaitingLogThrottled > 5_000) {
370
+ this.logger.info(`Waiting before creating checkpoint, currently at ${lsn}. Last op: ${result.keepalive_op}. Current state: ${JSON.stringify({
371
+ snapshot_done: result.snapshot_done,
372
+ last_checkpoint_lsn: result.last_checkpoint_lsn,
373
+ no_checkpoint_before: result.no_checkpoint_before
374
+ })}`);
375
+ this.lastWaitingLogThrottled = Date.now();
376
+ }
377
+ return { checkpointBlocked: true, checkpointCreated: false };
378
+ }
379
+ if (result.created_checkpoint) {
380
+ this.logger.debug(`Created checkpoint at ${lsn}. Last op: ${result.last_checkpoint}`);
381
+ if (result.last_checkpoint != null) {
382
+ await this.currentDataStore.cleanupPendingDeletes(this.db, {
383
+ groupId: this.group_id,
384
+ lastCheckpoint: result.last_checkpoint
385
+ });
386
+ }
387
+ }
300
388
  await this.autoActivate(lsn);
301
- await notifySyncRulesUpdate(this.db, doc);
389
+ await notifySyncRulesUpdate(this.db, {
390
+ id: result.id,
391
+ state: result.state,
392
+ last_checkpoint: result.last_checkpoint,
393
+ last_checkpoint_lsn: result.last_checkpoint_lsn
394
+ });
302
395
  this.persisted_op = null;
303
396
  this.last_checkpoint_lsn = lsn;
304
- return true;
397
+ // Even if created_checkpoint is false, if can_checkpoint is true, we need to return not blocked.
398
+ return { checkpointBlocked: false, checkpointCreated: result.created_checkpoint };
305
399
  }
306
400
  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 `
401
+ return await this.commit(lsn, { createEmptyCheckpoints: true });
402
+ }
403
+ async setResumeLsn(lsn) {
404
+ await this.db.sql `
321
405
  UPDATE sync_rules
322
406
  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() }}
407
+ snapshot_lsn = ${{ type: 'varchar', value: lsn }}
328
408
  WHERE
329
409
  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;
410
+ `.execute();
342
411
  }
343
- async setResumeLsn(lsn) {
412
+ async markAllSnapshotDone(no_checkpoint_before_lsn) {
413
+ await this.db.transaction(async (db) => {
414
+ await db.sql `
415
+ UPDATE sync_rules
416
+ SET
417
+ snapshot_done = TRUE,
418
+ last_keepalive_ts = ${{ type: 1184, value: new Date().toISOString() }},
419
+ no_checkpoint_before = CASE
420
+ WHEN no_checkpoint_before IS NULL
421
+ OR no_checkpoint_before < ${{ type: 'varchar', value: no_checkpoint_before_lsn }} THEN ${{
422
+ type: 'varchar',
423
+ value: no_checkpoint_before_lsn
424
+ }}
425
+ ELSE no_checkpoint_before
426
+ END
427
+ WHERE
428
+ id = ${{ type: 'int4', value: this.group_id }}
429
+ `.execute();
430
+ });
431
+ }
432
+ async markTableSnapshotRequired(table) {
344
433
  await this.db.sql `
345
434
  UPDATE sync_rules
346
435
  SET
347
- snapshot_lsn = ${{ type: 'varchar', value: lsn }}
436
+ snapshot_done = FALSE
348
437
  WHERE
349
438
  id = ${{ type: 'int4', value: this.group_id }}
350
439
  `.execute();
351
440
  }
352
- async markSnapshotDone(tables, no_checkpoint_before_lsn) {
441
+ async markTableSnapshotDone(tables, no_checkpoint_before_lsn) {
353
442
  const ids = tables.map((table) => table.id.toString());
354
443
  await this.db.transaction(async (db) => {
355
444
  await db.sql `
356
445
  UPDATE source_tables
357
446
  SET
358
- snapshot_done = ${{ type: 'bool', value: true }},
447
+ snapshot_done = TRUE,
359
448
  snapshot_total_estimated_count = NULL,
360
449
  snapshot_replicated_count = NULL,
361
450
  snapshot_last_key = NULL
@@ -367,30 +456,27 @@ export class PostgresBucketBatch extends BaseObserver {
367
456
  jsonb_array_elements(${{ type: 'jsonb', value: ids }}) AS value
368
457
  );
369
458
  `.execute();
370
- if (no_checkpoint_before_lsn > this.no_checkpoint_before_lsn) {
371
- this.no_checkpoint_before_lsn = no_checkpoint_before_lsn;
459
+ if (no_checkpoint_before_lsn != null) {
372
460
  await db.sql `
373
461
  UPDATE sync_rules
374
462
  SET
375
- no_checkpoint_before = ${{ type: 'varchar', value: no_checkpoint_before_lsn }},
376
- last_keepalive_ts = ${{ type: 1184, value: new Date().toISOString() }}
463
+ last_keepalive_ts = ${{ type: 1184, value: new Date().toISOString() }},
464
+ no_checkpoint_before = CASE
465
+ WHEN no_checkpoint_before IS NULL
466
+ OR no_checkpoint_before < ${{ type: 'varchar', value: no_checkpoint_before_lsn }} THEN ${{
467
+ type: 'varchar',
468
+ value: no_checkpoint_before_lsn
469
+ }}
470
+ ELSE no_checkpoint_before
471
+ END
377
472
  WHERE
378
473
  id = ${{ type: 'int4', value: this.group_id }}
379
474
  `.execute();
380
475
  }
381
476
  });
382
477
  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;
478
+ const copy = table.clone();
479
+ copy.snapshotComplete = true;
394
480
  return copy;
395
481
  });
396
482
  }
@@ -430,7 +516,7 @@ export class PostgresBucketBatch extends BaseObserver {
430
516
  // exceeding memory limits.
431
517
  const sizeLookups = batch.batch.map((r) => {
432
518
  return {
433
- source_table: r.record.sourceTable.id.toString(),
519
+ source_table: postgresTableId(r.record.sourceTable.id),
434
520
  /**
435
521
  * Encode to hex in order to pass a jsonb
436
522
  */
@@ -438,26 +524,10 @@ export class PostgresBucketBatch extends BaseObserver {
438
524
  };
439
525
  });
440
526
  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
- `)) {
527
+ for await (const rows of this.currentDataStore.streamSizeRows(db, {
528
+ groupId: this.group_id,
529
+ lookups: sizeLookups
530
+ })) {
461
531
  for (const row of rows) {
462
532
  const key = cacheKey(row.source_table, row.source_key);
463
533
  sizes.set(key, row.data_size);
@@ -479,48 +549,24 @@ export class PostgresBucketBatch extends BaseObserver {
479
549
  }
480
550
  const lookups = b.map((r) => {
481
551
  return {
482
- source_table: r.record.sourceTable.id,
552
+ source_table: postgresTableId(r.record.sourceTable.id),
483
553
  source_key: storage.serializeReplicaId(r.beforeId).toString('hex')
484
554
  };
485
555
  });
486
556
  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
- ]
557
+ for await (const currentDataRows of this.currentDataStore.streamLookupRows(db, {
558
+ groupId: this.group_id,
559
+ lookups,
560
+ skipExistingRows: this.options.skip_existing_rows
514
561
  })) {
515
562
  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);
563
+ const decoded = this.currentDataStore.decodeLookupRow(row, this.options.skip_existing_rows);
519
564
  current_data_lookup.set(encodedCacheKey(decoded.source_table, decoded.source_key), decoded);
520
565
  }
521
566
  }
522
567
  let persistedBatch = new PostgresPersistedBatch({
523
568
  group_id: this.group_id,
569
+ storageConfig: this.storageConfig,
524
570
  ...this.options.batch_limits
525
571
  });
526
572
  for (const op of b) {
@@ -758,17 +804,19 @@ export class PostgresBucketBatch extends BaseObserver {
758
804
  source_key: afterId,
759
805
  group_id: this.group_id,
760
806
  data: afterData,
761
- source_table: sourceTable.id,
807
+ source_table: postgresTableId(sourceTable.id),
762
808
  buckets: newBuckets,
763
- lookups: newLookups
809
+ lookups: newLookups,
810
+ pending_delete: null
764
811
  };
765
812
  persistedBatch.upsertCurrentData(result);
766
813
  }
767
814
  if (afterId == null || !storage.replicaIdEquals(beforeId, afterId)) {
768
815
  // Either a delete (afterId == null), or replaced the old replication id
769
816
  persistedBatch.deleteCurrentData({
770
- source_table_id: record.sourceTable.id,
771
- source_key: beforeId
817
+ source_table_id: postgresTableId(sourceTable.id),
818
+ source_key: beforeId,
819
+ soft: true
772
820
  });
773
821
  }
774
822
  return result;
@@ -787,40 +835,45 @@ export class PostgresBucketBatch extends BaseObserver {
787
835
  await this.db.transaction(async (db) => {
788
836
  const syncRulesRow = await db.sql `
789
837
  SELECT
790
- state
838
+ state,
839
+ snapshot_done
791
840
  FROM
792
841
  sync_rules
793
842
  WHERE
794
843
  id = ${{ type: 'int4', value: this.group_id }}
844
+ FOR NO KEY UPDATE;
795
845
  `
796
- .decoded(pick(models.SyncRules, ['state']))
846
+ .decoded(pick(models.SyncRules, ['state', 'snapshot_done']))
797
847
  .first();
798
- if (syncRulesRow && syncRulesRow.state == storage.SyncRuleState.PROCESSING) {
848
+ if (syncRulesRow && syncRulesRow.state == storage.SyncRuleState.PROCESSING && syncRulesRow.snapshot_done) {
799
849
  await db.sql `
800
850
  UPDATE sync_rules
801
851
  SET
802
852
  state = ${{ type: 'varchar', value: storage.SyncRuleState.ACTIVE }}
803
853
  WHERE
804
854
  id = ${{ type: 'int4', value: this.group_id }}
855
+ `.execute();
856
+ await db.sql `
857
+ UPDATE sync_rules
858
+ SET
859
+ state = ${{ type: 'varchar', value: storage.SyncRuleState.STOP }}
860
+ WHERE
861
+ (
862
+ state = ${{ value: storage.SyncRuleState.ACTIVE, type: 'varchar' }}
863
+ OR state = ${{ value: storage.SyncRuleState.ERRORED, type: 'varchar' }}
864
+ )
865
+ AND id != ${{ type: 'int4', value: this.group_id }}
805
866
  `.execute();
806
867
  didActivate = true;
868
+ this.needsActivation = false;
869
+ }
870
+ else if (syncRulesRow?.state != storage.SyncRuleState.PROCESSING) {
871
+ this.needsActivation = false;
807
872
  }
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
873
  });
820
874
  if (didActivate) {
821
875
  this.logger.info(`Activated new sync rules at ${lsn}`);
822
876
  }
823
- this.needsActivation = false;
824
877
  }
825
878
  /**
826
879
  * Gets relevant {@link SqlEventDescriptor}s for the given {@link SourceTable}
@@ -831,9 +884,29 @@ export class PostgresBucketBatch extends BaseObserver {
831
884
  }
832
885
  async withReplicationTransaction(callback) {
833
886
  try {
834
- return await this.db.transaction(async (db) => {
835
- return await callback(db);
836
- });
887
+ // Try for up to a minute
888
+ const lastTry = Date.now() + 60_000;
889
+ while (true) {
890
+ try {
891
+ return await this.db.transaction(async (db) => {
892
+ // The isolation level is required to protect against concurrent updates to the same data.
893
+ // In theory the "select ... for update" locks may be able to protect against this, but we
894
+ // still have failing tests if we use that as the only isolation mechanism.
895
+ await db.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;');
896
+ return await callback(db);
897
+ });
898
+ }
899
+ catch (err) {
900
+ const code = err.cause?.code;
901
+ if ((code == '40001' || code == '40P01') && Date.now() < lastTry) {
902
+ // Serialization (lock) failure, retry
903
+ this.logger.warn(`Serialization failure during replication transaction, retrying: ${err.message}`);
904
+ await timers.setTimeout(100 + Math.random() * 200);
905
+ continue;
906
+ }
907
+ throw err;
908
+ }
909
+ }
837
910
  }
838
911
  finally {
839
912
  await this.db.sql `