@powersync/service-module-postgres 0.0.4 → 0.2.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.
@@ -1,6 +1,11 @@
1
1
  import * as pgwire from '@powersync/service-jpgwire';
2
2
  import { NormalizedPostgresConnectionConfig } from '../types/types.js';
3
3
 
4
+ /**
5
+ * Shorter timeout for snapshot connections than for replication connections.
6
+ */
7
+ const SNAPSHOT_SOCKET_TIMEOUT = 30_000;
8
+
4
9
  export class PgManager {
5
10
  /**
6
11
  * Do not use this for any transactions.
@@ -38,7 +43,20 @@ export class PgManager {
38
43
  async snapshotConnection(): Promise<pgwire.PgConnection> {
39
44
  const p = pgwire.connectPgWire(this.options, { type: 'standard' });
40
45
  this.connectionPromises.push(p);
41
- return await p;
46
+ const connection = await p;
47
+
48
+ // Use an shorter timeout for snapshot connections.
49
+ // This is to detect broken connections early, instead of waiting
50
+ // for the full 6 minutes.
51
+ // This we are constantly using the connection, we don't need any
52
+ // custom keepalives.
53
+ (connection as any)._socket.setTimeout(SNAPSHOT_SOCKET_TIMEOUT);
54
+
55
+ // Disable statement timeout for snapshot queries.
56
+ // On Supabase, the default is 2 minutes.
57
+ await connection.query(`set session statement_timeout = 0`);
58
+
59
+ return connection;
42
60
  }
43
61
 
44
62
  async end(): Promise<void> {
@@ -18,7 +18,10 @@ export interface WalStreamOptions {
18
18
  }
19
19
 
20
20
  interface InitResult {
21
+ /** True if initial snapshot is not yet done. */
21
22
  needsInitialSync: boolean;
23
+ /** True if snapshot must be started from scratch with a new slot. */
24
+ needsNewSlot: boolean;
22
25
  }
23
26
 
24
27
  export class MissingReplicationSlotError extends Error {
@@ -173,88 +176,114 @@ export class WalStream {
173
176
  const slotName = this.slot_name;
174
177
 
175
178
  const status = await this.storage.getStatus();
176
- if (status.snapshot_done && status.checkpoint_lsn) {
179
+ const snapshotDone = status.snapshot_done && status.checkpoint_lsn != null;
180
+ if (snapshotDone) {
181
+ // Snapshot is done, but we still need to check the replication slot status
177
182
  logger.info(`${slotName} Initial replication already done`);
183
+ }
178
184
 
179
- let last_error = null;
185
+ // Check if replication slot exists
186
+ const rs = await this.connections.pool.query({
187
+ statement: 'SELECT 1 FROM pg_replication_slots WHERE slot_name = $1',
188
+ params: [{ type: 'varchar', value: slotName }]
189
+ });
190
+ const slotExists = rs.rows.length > 0;
191
+
192
+ if (slotExists) {
193
+ // This checks that the slot is still valid
194
+ const r = await this.checkReplicationSlot();
195
+ return {
196
+ needsInitialSync: !snapshotDone,
197
+ needsNewSlot: r.needsNewSlot
198
+ };
199
+ } else {
200
+ return { needsInitialSync: true, needsNewSlot: true };
201
+ }
202
+ }
180
203
 
181
- // Check that replication slot exists
182
- for (let i = 120; i >= 0; i--) {
183
- await touch();
204
+ /**
205
+ * If a replication slot exists, check that it is healthy.
206
+ */
207
+ private async checkReplicationSlot(): Promise<InitResult> {
208
+ let last_error = null;
209
+ const slotName = this.slot_name;
184
210
 
185
- if (i == 0) {
186
- container.reporter.captureException(last_error, {
187
- level: errors.ErrorSeverity.ERROR,
188
- metadata: {
189
- replication_slot: slotName
190
- }
191
- });
211
+ // Check that replication slot exists
212
+ for (let i = 120; i >= 0; i--) {
213
+ await touch();
214
+
215
+ if (i == 0) {
216
+ container.reporter.captureException(last_error, {
217
+ level: errors.ErrorSeverity.ERROR,
218
+ metadata: {
219
+ replication_slot: slotName
220
+ }
221
+ });
222
+
223
+ throw last_error;
224
+ }
225
+ try {
226
+ // We peek a large number of changes here, to make it more likely to pick up replication slot errors.
227
+ // For example, "publication does not exist" only occurs here if the peek actually includes changes related
228
+ // to the slot.
229
+ logger.info(`Checking ${slotName}`);
230
+
231
+ // The actual results can be quite large, so we don't actually return everything
232
+ // due to memory and processing overhead that would create.
233
+ const cursor = await this.connections.pool.stream({
234
+ statement: `SELECT 1 FROM pg_catalog.pg_logical_slot_peek_binary_changes($1, NULL, 1000, 'proto_version', '1', 'publication_names', $2)`,
235
+ params: [
236
+ { type: 'varchar', value: slotName },
237
+ { type: 'varchar', value: PUBLICATION_NAME }
238
+ ]
239
+ });
192
240
 
193
- throw last_error;
241
+ for await (let _chunk of cursor) {
242
+ // No-op, just exhaust the cursor
194
243
  }
195
- try {
196
- // We peek a large number of changes here, to make it more likely to pick up replication slot errors.
197
- // For example, "publication does not exist" only occurs here if the peek actually includes changes related
198
- // to the slot.
199
- logger.info(`Checking ${slotName}`);
200
-
201
- // The actual results can be quite large, so we don't actually return everything
202
- // due to memory and processing overhead that would create.
203
- const cursor = await this.connections.pool.stream({
204
- statement: `SELECT 1 FROM pg_catalog.pg_logical_slot_peek_binary_changes($1, NULL, 1000, 'proto_version', '1', 'publication_names', $2)`,
205
- params: [
206
- { type: 'varchar', value: slotName },
207
- { type: 'varchar', value: PUBLICATION_NAME }
208
- ]
209
- });
210
244
 
211
- for await (let _chunk of cursor) {
212
- // No-op, just exhaust the cursor
213
- }
245
+ // Success
246
+ logger.info(`Slot ${slotName} appears healthy`);
247
+ return { needsInitialSync: false, needsNewSlot: false };
248
+ } catch (e) {
249
+ last_error = e;
250
+ logger.warn(`${slotName} Replication slot error`, e);
214
251
 
215
- // Success
216
- logger.info(`Slot ${slotName} appears healthy`);
217
- return { needsInitialSync: false };
218
- } catch (e) {
219
- last_error = e;
220
- logger.warn(`${slotName} Replication slot error`, e);
252
+ if (this.stopped) {
253
+ throw e;
254
+ }
221
255
 
222
- if (this.stopped) {
223
- throw e;
224
- }
256
+ // Could also be `publication "powersync" does not exist`, although this error may show up much later
257
+ // in some cases.
225
258
 
226
- // Could also be `publication "powersync" does not exist`, although this error may show up much later
227
- // in some cases.
228
-
229
- if (
230
- /incorrect prev-link/.test(e.message) ||
231
- /replication slot.*does not exist/.test(e.message) ||
232
- /publication.*does not exist/.test(e.message)
233
- ) {
234
- container.reporter.captureException(e, {
235
- level: errors.ErrorSeverity.WARNING,
236
- metadata: {
237
- try_index: i,
238
- replication_slot: slotName
239
- }
240
- });
241
- // Sample: record with incorrect prev-link 10000/10000 at 0/18AB778
242
- // Seen during development. Some internal error, fixed by re-creating slot.
243
- //
244
- // Sample: publication "powersync" does not exist
245
- // Happens when publication deleted or never created.
246
- // Slot must be re-created in this case.
247
- logger.info(`${slotName} does not exist anymore, will create new slot`);
248
-
249
- throw new MissingReplicationSlotError(`Replication slot ${slotName} does not exist anymore`);
250
- }
251
- // Try again after a pause
252
- await new Promise((resolve) => setTimeout(resolve, 1000));
259
+ if (
260
+ /incorrect prev-link/.test(e.message) ||
261
+ /replication slot.*does not exist/.test(e.message) ||
262
+ /publication.*does not exist/.test(e.message)
263
+ ) {
264
+ container.reporter.captureException(e, {
265
+ level: errors.ErrorSeverity.WARNING,
266
+ metadata: {
267
+ try_index: i,
268
+ replication_slot: slotName
269
+ }
270
+ });
271
+ // Sample: record with incorrect prev-link 10000/10000 at 0/18AB778
272
+ // Seen during development. Some internal error, fixed by re-creating slot.
273
+ //
274
+ // Sample: publication "powersync" does not exist
275
+ // Happens when publication deleted or never created.
276
+ // Slot must be re-created in this case.
277
+ logger.info(`${slotName} does not exist anymore, will create new slot`);
278
+
279
+ return { needsInitialSync: true, needsNewSlot: true };
253
280
  }
281
+ // Try again after a pause
282
+ await new Promise((resolve) => setTimeout(resolve, 1000));
254
283
  }
255
284
  }
256
285
 
257
- return { needsInitialSync: true };
286
+ throw new Error('Unreachable');
258
287
  }
259
288
 
260
289
  async estimatedCount(db: pgwire.PgConnection, table: storage.SourceTable): Promise<string> {
@@ -278,81 +307,70 @@ WHERE oid = $1::regclass`,
278
307
  * If (partial) replication was done before on this slot, this clears the state
279
308
  * and starts again from scratch.
280
309
  */
281
- async startInitialReplication(replicationConnection: pgwire.PgConnection) {
310
+ async startInitialReplication(replicationConnection: pgwire.PgConnection, status: InitResult) {
282
311
  // If anything here errors, the entire replication process is aborted,
283
- // and all connections closed, including this one.
312
+ // and all connections are closed, including this one.
284
313
  const db = await this.connections.snapshotConnection();
285
314
 
286
315
  const slotName = this.slot_name;
287
316
 
288
- await this.storage.clear();
317
+ if (status.needsNewSlot) {
318
+ // This happens when there is no existing replication slot, or if the
319
+ // existing one is unhealthy.
320
+ // In those cases, we have to start replication from scratch.
321
+ // If there is an existing healthy slot, we can skip this and continue
322
+ // initial replication where we left off.
323
+ await this.storage.clear();
324
+
325
+ await db.query({
326
+ statement: 'SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_name = $1',
327
+ params: [{ type: 'varchar', value: slotName }]
328
+ });
289
329
 
290
- await db.query({
291
- statement: 'SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_name = $1',
292
- params: [{ type: 'varchar', value: slotName }]
293
- });
330
+ // We use the replication connection here, not a pool.
331
+ // The replication slot must be created before we start snapshotting tables.
332
+ await replicationConnection.query(`CREATE_REPLICATION_SLOT ${slotName} LOGICAL pgoutput`);
294
333
 
295
- // We use the replication connection here, not a pool.
296
- // This connection needs to stay open at least until the snapshot is used below.
297
- const result = await replicationConnection.query(
298
- `CREATE_REPLICATION_SLOT ${slotName} LOGICAL pgoutput EXPORT_SNAPSHOT`
299
- );
300
- const columns = result.columns;
301
- const row = result.rows[0]!;
302
- if (columns[1]?.name != 'consistent_point' || columns[2]?.name != 'snapshot_name' || row == null) {
303
- throw new Error(`Invalid CREATE_REPLICATION_SLOT output: ${JSON.stringify(columns)}`);
334
+ logger.info(`Created replication slot ${slotName}`);
304
335
  }
305
- // This LSN could be used in initialReplication below.
306
- // But it's also safe to just use ZERO_LSN - we won't get any changes older than this lsn
307
- // with streaming replication.
308
- const lsn = pgwire.lsnMakeComparable(row[1]);
309
- const snapshot = row[2];
310
- logger.info(`Created replication slot ${slotName} at ${lsn} with snapshot ${snapshot}`);
311
-
312
- // https://stackoverflow.com/questions/70160769/postgres-logical-replication-starting-from-given-lsn
313
- await db.query('BEGIN');
314
- // Use the snapshot exported above.
315
- // Using SERIALIZABLE isolation level may give stronger guarantees, but that complicates
316
- // the replication slot + snapshot above. And we still won't have SERIALIZABLE
317
- // guarantees with streaming replication.
318
- // See: ./docs/serializability.md for details.
319
- //
320
- // Another alternative here is to use the same pgwire connection for initial replication as well,
321
- // instead of synchronizing a separate transaction to the snapshot.
322
336
 
323
- try {
324
- await db.query(`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`);
325
- await db.query(`SET TRANSACTION READ ONLY`);
326
- await db.query(`SET TRANSACTION SNAPSHOT '${snapshot}'`);
327
-
328
- // Disable statement timeout for the duration of this transaction.
329
- // On Supabase, the default is 2 minutes.
330
- await db.query(`set local statement_timeout = 0`);
331
-
332
- logger.info(`${slotName} Starting initial replication`);
333
- await this.initialReplication(db, lsn);
334
- logger.info(`${slotName} Initial replication done`);
335
- await db.query('COMMIT');
336
- } catch (e) {
337
- await db.query('ROLLBACK');
338
- throw e;
339
- }
337
+ await this.initialReplication(db);
340
338
  }
341
339
 
342
- async initialReplication(db: pgwire.PgConnection, lsn: string) {
340
+ async initialReplication(db: pgwire.PgConnection) {
343
341
  const sourceTables = this.sync_rules.getSourceTables();
344
342
  await this.storage.startBatch(
345
- { zeroLSN: ZERO_LSN, defaultSchema: POSTGRES_DEFAULT_SCHEMA, storeCurrentData: true },
343
+ { zeroLSN: ZERO_LSN, defaultSchema: POSTGRES_DEFAULT_SCHEMA, storeCurrentData: true, skipExistingRows: true },
346
344
  async (batch) => {
347
345
  for (let tablePattern of sourceTables) {
348
346
  const tables = await this.getQualifiedTableNames(batch, db, tablePattern);
349
347
  for (let table of tables) {
350
- await this.snapshotTable(batch, db, table);
351
- await batch.markSnapshotDone([table], lsn);
348
+ if (table.snapshotComplete) {
349
+ logger.info(`${this.slot_name} Skipping ${table.qualifiedName} - snapshot already done`);
350
+ continue;
351
+ }
352
+ let tableLsnNotBefore: string;
353
+ await db.query('BEGIN');
354
+ try {
355
+ await this.snapshotTable(batch, db, table);
356
+
357
+ const rs = await db.query(`select pg_current_wal_lsn() as lsn`);
358
+ tableLsnNotBefore = rs.rows[0][0];
359
+ } finally {
360
+ // Read-only transaction, commit does not actually do anything.
361
+ await db.query('COMMIT');
362
+ }
363
+
364
+ await batch.markSnapshotDone([table], tableLsnNotBefore);
352
365
  await touch();
353
366
  }
354
367
  }
355
- await batch.commit(lsn);
368
+
369
+ // Always commit the initial snapshot at zero.
370
+ // This makes sure we don't skip any changes applied before starting this snapshot,
371
+ // in the case of snapshot retries.
372
+ // We could alternatively commit at the replication slot LSN.
373
+ await batch.commit(ZERO_LSN);
356
374
  }
357
375
  );
358
376
  }
@@ -368,51 +386,70 @@ WHERE oid = $1::regclass`,
368
386
  const estimatedCount = await this.estimatedCount(db, table);
369
387
  let at = 0;
370
388
  let lastLogIndex = 0;
371
- const cursor = db.stream({ statement: `SELECT * FROM ${table.escapedIdentifier}` });
372
- let columns: { i: number; name: string }[] = [];
373
- // pgwire streams rows in chunks.
374
- // These chunks can be quite small (as little as 16KB), so we don't flush chunks automatically.
375
-
376
- for await (let chunk of cursor) {
377
- if (chunk.tag == 'RowDescription') {
378
- let i = 0;
379
- columns = chunk.payload.map((c) => {
380
- return { i: i++, name: c.name };
381
- });
382
- continue;
383
- }
384
389
 
385
- const rows = chunk.rows.map((row) => {
386
- let q: DatabaseInputRow = {};
387
- for (let c of columns) {
388
- q[c.name] = row[c.i];
389
- }
390
- return q;
390
+ // We do streaming on two levels:
391
+ // 1. Coarse level: DELCARE CURSOR, FETCH 10000 at a time.
392
+ // 2. Fine level: Stream chunks from each fetch call.
393
+ await db.query(`DECLARE powersync_cursor CURSOR FOR SELECT * FROM ${table.escapedIdentifier}`);
394
+
395
+ let columns: { i: number; name: string }[] = [];
396
+ let hasRemainingData = true;
397
+ while (hasRemainingData) {
398
+ // Fetch 10k at a time.
399
+ // The balance here is between latency overhead per FETCH call,
400
+ // and not spending too much time on each FETCH call.
401
+ // We aim for a couple of seconds on each FETCH call.
402
+ const cursor = db.stream({
403
+ statement: `FETCH 10000 FROM powersync_cursor`
391
404
  });
392
- if (rows.length > 0 && at - lastLogIndex >= 5000) {
393
- logger.info(`${this.slot_name} Replicating ${table.qualifiedName} ${at}/${estimatedCount}`);
394
- lastLogIndex = at;
395
- }
396
- if (this.abort_signal.aborted) {
397
- throw new Error(`Aborted initial replication of ${this.slot_name}`);
398
- }
405
+ hasRemainingData = false;
406
+ // pgwire streams rows in chunks.
407
+ // These chunks can be quite small (as little as 16KB), so we don't flush chunks automatically.
408
+ // There are typically 100-200 rows per chunk.
409
+ for await (let chunk of cursor) {
410
+ if (chunk.tag == 'RowDescription') {
411
+ // We get a RowDescription for each FETCH call, but they should
412
+ // all be the same.
413
+ let i = 0;
414
+ columns = chunk.payload.map((c) => {
415
+ return { i: i++, name: c.name };
416
+ });
417
+ continue;
418
+ }
399
419
 
400
- for (const record of WalStream.getQueryData(rows)) {
401
- // This auto-flushes when the batch reaches its size limit
402
- await batch.save({
403
- tag: storage.SaveOperationTag.INSERT,
404
- sourceTable: table,
405
- before: undefined,
406
- beforeReplicaId: undefined,
407
- after: record,
408
- afterReplicaId: getUuidReplicaIdentityBson(record, table.replicaIdColumns)
420
+ const rows = chunk.rows.map((row) => {
421
+ let q: DatabaseInputRow = {};
422
+ for (let c of columns) {
423
+ q[c.name] = row[c.i];
424
+ }
425
+ return q;
409
426
  });
410
- }
427
+ if (rows.length > 0 && at - lastLogIndex >= 5000) {
428
+ logger.info(`${this.slot_name} Replicating ${table.qualifiedName} ${at}/${estimatedCount}`);
429
+ lastLogIndex = at;
430
+ hasRemainingData = true;
431
+ }
432
+ if (this.abort_signal.aborted) {
433
+ throw new Error(`Aborted initial replication of ${this.slot_name}`);
434
+ }
411
435
 
412
- at += rows.length;
413
- Metrics.getInstance().rows_replicated_total.add(rows.length);
436
+ for (const record of WalStream.getQueryData(rows)) {
437
+ // This auto-flushes when the batch reaches its size limit
438
+ await batch.save({
439
+ tag: storage.SaveOperationTag.INSERT,
440
+ sourceTable: table,
441
+ before: undefined,
442
+ beforeReplicaId: undefined,
443
+ after: record,
444
+ afterReplicaId: getUuidReplicaIdentityBson(record, table.replicaIdColumns)
445
+ });
446
+ }
414
447
 
415
- await touch();
448
+ at += rows.length;
449
+ Metrics.getInstance().rows_replicated_total.add(rows.length);
450
+
451
+ await touch();
452
+ }
416
453
  }
417
454
 
418
455
  await batch.flush();
@@ -451,12 +488,15 @@ WHERE oid = $1::regclass`,
451
488
  try {
452
489
  await db.query('BEGIN');
453
490
  try {
491
+ await this.snapshotTable(batch, db, result.table);
492
+
454
493
  // Get the current LSN.
455
494
  // The data will only be consistent once incremental replication
456
495
  // has passed that point.
496
+ // We have to get this LSN _after_ we have started the snapshot query.
457
497
  const rs = await db.query(`select pg_current_wal_lsn() as lsn`);
458
498
  lsn = rs.rows[0][0];
459
- await this.snapshotTable(batch, db, result.table);
499
+
460
500
  await db.query('COMMIT');
461
501
  } catch (e) {
462
502
  await db.query('ROLLBACK');
@@ -561,7 +601,7 @@ WHERE oid = $1::regclass`,
561
601
  async initReplication(replicationConnection: pgwire.PgConnection) {
562
602
  const result = await this.initSlot();
563
603
  if (result.needsInitialSync) {
564
- await this.startInitialReplication(replicationConnection);
604
+ await this.startInitialReplication(replicationConnection, result);
565
605
  }
566
606
  }
567
607
 
@@ -581,7 +621,7 @@ WHERE oid = $1::regclass`,
581
621
  await this.storage.autoActivate();
582
622
 
583
623
  await this.storage.startBatch(
584
- { zeroLSN: ZERO_LSN, defaultSchema: POSTGRES_DEFAULT_SCHEMA, storeCurrentData: true },
624
+ { zeroLSN: ZERO_LSN, defaultSchema: POSTGRES_DEFAULT_SCHEMA, storeCurrentData: true, skipExistingRows: false },
585
625
  async (batch) => {
586
626
  // Replication never starts in the middle of a transaction
587
627
  let inTx = false;