@powersync/service-module-postgres 0.0.0-dev-20240918092408

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 (87) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +67 -0
  3. package/README.md +3 -0
  4. package/dist/api/PostgresRouteAPIAdapter.d.ts +22 -0
  5. package/dist/api/PostgresRouteAPIAdapter.js +273 -0
  6. package/dist/api/PostgresRouteAPIAdapter.js.map +1 -0
  7. package/dist/auth/SupabaseKeyCollector.d.ts +22 -0
  8. package/dist/auth/SupabaseKeyCollector.js +64 -0
  9. package/dist/auth/SupabaseKeyCollector.js.map +1 -0
  10. package/dist/index.d.ts +3 -0
  11. package/dist/index.js +4 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/module/PostgresModule.d.ts +14 -0
  14. package/dist/module/PostgresModule.js +108 -0
  15. package/dist/module/PostgresModule.js.map +1 -0
  16. package/dist/replication/ConnectionManagerFactory.d.ts +10 -0
  17. package/dist/replication/ConnectionManagerFactory.js +21 -0
  18. package/dist/replication/ConnectionManagerFactory.js.map +1 -0
  19. package/dist/replication/PgManager.d.ts +25 -0
  20. package/dist/replication/PgManager.js +60 -0
  21. package/dist/replication/PgManager.js.map +1 -0
  22. package/dist/replication/PgRelation.d.ts +6 -0
  23. package/dist/replication/PgRelation.js +27 -0
  24. package/dist/replication/PgRelation.js.map +1 -0
  25. package/dist/replication/PostgresErrorRateLimiter.d.ts +11 -0
  26. package/dist/replication/PostgresErrorRateLimiter.js +43 -0
  27. package/dist/replication/PostgresErrorRateLimiter.js.map +1 -0
  28. package/dist/replication/WalStream.d.ts +53 -0
  29. package/dist/replication/WalStream.js +536 -0
  30. package/dist/replication/WalStream.js.map +1 -0
  31. package/dist/replication/WalStreamReplicationJob.d.ts +27 -0
  32. package/dist/replication/WalStreamReplicationJob.js +131 -0
  33. package/dist/replication/WalStreamReplicationJob.js.map +1 -0
  34. package/dist/replication/WalStreamReplicator.d.ts +13 -0
  35. package/dist/replication/WalStreamReplicator.js +36 -0
  36. package/dist/replication/WalStreamReplicator.js.map +1 -0
  37. package/dist/replication/replication-index.d.ts +5 -0
  38. package/dist/replication/replication-index.js +6 -0
  39. package/dist/replication/replication-index.js.map +1 -0
  40. package/dist/replication/replication-utils.d.ts +32 -0
  41. package/dist/replication/replication-utils.js +272 -0
  42. package/dist/replication/replication-utils.js.map +1 -0
  43. package/dist/types/types.d.ts +76 -0
  44. package/dist/types/types.js +110 -0
  45. package/dist/types/types.js.map +1 -0
  46. package/dist/utils/migration_lib.d.ts +11 -0
  47. package/dist/utils/migration_lib.js +64 -0
  48. package/dist/utils/migration_lib.js.map +1 -0
  49. package/dist/utils/pgwire_utils.d.ts +16 -0
  50. package/dist/utils/pgwire_utils.js +70 -0
  51. package/dist/utils/pgwire_utils.js.map +1 -0
  52. package/dist/utils/populate_test_data.d.ts +8 -0
  53. package/dist/utils/populate_test_data.js +65 -0
  54. package/dist/utils/populate_test_data.js.map +1 -0
  55. package/package.json +49 -0
  56. package/src/api/PostgresRouteAPIAdapter.ts +307 -0
  57. package/src/auth/SupabaseKeyCollector.ts +70 -0
  58. package/src/index.ts +5 -0
  59. package/src/module/PostgresModule.ts +122 -0
  60. package/src/replication/ConnectionManagerFactory.ts +28 -0
  61. package/src/replication/PgManager.ts +70 -0
  62. package/src/replication/PgRelation.ts +31 -0
  63. package/src/replication/PostgresErrorRateLimiter.ts +44 -0
  64. package/src/replication/WalStream.ts +639 -0
  65. package/src/replication/WalStreamReplicationJob.ts +142 -0
  66. package/src/replication/WalStreamReplicator.ts +45 -0
  67. package/src/replication/replication-index.ts +5 -0
  68. package/src/replication/replication-utils.ts +329 -0
  69. package/src/types/types.ts +159 -0
  70. package/src/utils/migration_lib.ts +79 -0
  71. package/src/utils/pgwire_utils.ts +73 -0
  72. package/src/utils/populate_test_data.ts +77 -0
  73. package/test/src/__snapshots__/pg_test.test.ts.snap +256 -0
  74. package/test/src/env.ts +7 -0
  75. package/test/src/large_batch.test.ts +195 -0
  76. package/test/src/pg_test.test.ts +450 -0
  77. package/test/src/schema_changes.test.ts +543 -0
  78. package/test/src/setup.ts +7 -0
  79. package/test/src/slow_tests.test.ts +335 -0
  80. package/test/src/util.ts +105 -0
  81. package/test/src/validation.test.ts +64 -0
  82. package/test/src/wal_stream.test.ts +319 -0
  83. package/test/src/wal_stream_utils.ts +121 -0
  84. package/test/tsconfig.json +28 -0
  85. package/tsconfig.json +31 -0
  86. package/tsconfig.tsbuildinfo +1 -0
  87. package/vitest.config.ts +9 -0
@@ -0,0 +1,639 @@
1
+ import * as pgwire from '@powersync/service-jpgwire';
2
+ import * as util from '../utils/pgwire_utils.js';
3
+ import { container, errors, logger } from '@powersync/lib-services-framework';
4
+ import { DatabaseInputRow, SqliteRow, SqlSyncRules, TablePattern, toSyncRulesRow } from '@powersync/service-sync-rules';
5
+ import { getPgOutputRelation, getRelId } from './PgRelation.js';
6
+ import { getUuidReplicaIdentityBson, Metrics, SourceEntityDescriptor, storage } from '@powersync/service-core';
7
+ import { checkSourceConfiguration, getReplicationIdentityColumns } from './replication-utils.js';
8
+ import { PgManager } from './PgManager.js';
9
+
10
+ export const ZERO_LSN = '00000000/00000000';
11
+ export const PUBLICATION_NAME = 'powersync';
12
+ export const POSTGRES_DEFAULT_SCHEMA = 'public';
13
+
14
+ export interface WalStreamOptions {
15
+ connections: PgManager;
16
+ storage: storage.SyncRulesBucketStorage;
17
+ abort_signal: AbortSignal;
18
+ }
19
+
20
+ interface InitResult {
21
+ needsInitialSync: boolean;
22
+ }
23
+
24
+ export class MissingReplicationSlotError extends Error {
25
+ constructor(message: string) {
26
+ super(message);
27
+ }
28
+ }
29
+
30
+ export class WalStream {
31
+ sync_rules: SqlSyncRules;
32
+ group_id: number;
33
+
34
+ connection_id = 1;
35
+
36
+ private readonly storage: storage.SyncRulesBucketStorage;
37
+
38
+ private readonly slot_name: string;
39
+
40
+ private connections: PgManager;
41
+
42
+ private abort_signal: AbortSignal;
43
+
44
+ private relation_cache = new Map<string | number, storage.SourceTable>();
45
+
46
+ private startedStreaming = false;
47
+
48
+ constructor(options: WalStreamOptions) {
49
+ this.storage = options.storage;
50
+ this.sync_rules = options.storage.getParsedSyncRules({ defaultSchema: POSTGRES_DEFAULT_SCHEMA });
51
+ this.group_id = options.storage.group_id;
52
+ this.slot_name = options.storage.slot_name;
53
+ this.connections = options.connections;
54
+
55
+ this.abort_signal = options.abort_signal;
56
+ this.abort_signal.addEventListener(
57
+ 'abort',
58
+ () => {
59
+ if (this.startedStreaming) {
60
+ // Ping to speed up cancellation of streaming replication
61
+ // We're not using pg_snapshot here, since it could be in the middle of
62
+ // an initial replication transaction.
63
+ const promise = util.retriedQuery(
64
+ this.connections.pool,
65
+ `SELECT * FROM pg_logical_emit_message(false, 'powersync', 'ping')`
66
+ );
67
+ promise.catch((e) => {
68
+ // Failures here are okay - this only speeds up stopping the process.
69
+ logger.warn('Failed to ping connection', e);
70
+ });
71
+ } else {
72
+ // If we haven't started streaming yet, it could be due to something like
73
+ // and invalid password. In that case, don't attempt to ping.
74
+ }
75
+ },
76
+ { once: true }
77
+ );
78
+ }
79
+
80
+ get stopped() {
81
+ return this.abort_signal.aborted;
82
+ }
83
+
84
+ async getQualifiedTableNames(
85
+ batch: storage.BucketStorageBatch,
86
+ db: pgwire.PgConnection,
87
+ tablePattern: TablePattern
88
+ ): Promise<storage.SourceTable[]> {
89
+ const schema = tablePattern.schema;
90
+ if (tablePattern.connectionTag != this.connections.connectionTag) {
91
+ return [];
92
+ }
93
+
94
+ let tableRows: any[];
95
+ const prefix = tablePattern.isWildcard ? tablePattern.tablePrefix : undefined;
96
+ if (tablePattern.isWildcard) {
97
+ const result = await db.query({
98
+ statement: `SELECT c.oid AS relid, c.relname AS table_name
99
+ FROM pg_class c
100
+ JOIN pg_namespace n ON n.oid = c.relnamespace
101
+ WHERE n.nspname = $1
102
+ AND c.relkind = 'r'
103
+ AND c.relname LIKE $2`,
104
+ params: [
105
+ { type: 'varchar', value: schema },
106
+ { type: 'varchar', value: tablePattern.tablePattern }
107
+ ]
108
+ });
109
+ tableRows = pgwire.pgwireRows(result);
110
+ } else {
111
+ const result = await db.query({
112
+ statement: `SELECT c.oid AS relid, c.relname AS table_name
113
+ FROM pg_class c
114
+ JOIN pg_namespace n ON n.oid = c.relnamespace
115
+ WHERE n.nspname = $1
116
+ AND c.relkind = 'r'
117
+ AND c.relname = $2`,
118
+ params: [
119
+ { type: 'varchar', value: schema },
120
+ { type: 'varchar', value: tablePattern.tablePattern }
121
+ ]
122
+ });
123
+
124
+ tableRows = pgwire.pgwireRows(result);
125
+ }
126
+ let result: storage.SourceTable[] = [];
127
+
128
+ for (let row of tableRows) {
129
+ const name = row.table_name as string;
130
+ if (typeof row.relid != 'bigint') {
131
+ throw new Error(`missing relid for ${name}`);
132
+ }
133
+ const relid = Number(row.relid as bigint);
134
+
135
+ if (prefix && !name.startsWith(prefix)) {
136
+ continue;
137
+ }
138
+
139
+ const rs = await db.query({
140
+ statement: `SELECT 1 FROM pg_publication_tables WHERE pubname = $1 AND schemaname = $2 AND tablename = $3`,
141
+ params: [
142
+ { type: 'varchar', value: PUBLICATION_NAME },
143
+ { type: 'varchar', value: tablePattern.schema },
144
+ { type: 'varchar', value: name }
145
+ ]
146
+ });
147
+ if (rs.rows.length == 0) {
148
+ logger.info(`Skipping ${tablePattern.schema}.${name} - not part of ${PUBLICATION_NAME} publication`);
149
+ continue;
150
+ }
151
+
152
+ const cresult = await getReplicationIdentityColumns(db, relid);
153
+
154
+ const table = await this.handleRelation(
155
+ batch,
156
+ {
157
+ name,
158
+ schema,
159
+ objectId: relid,
160
+ replicationColumns: cresult.replicationColumns
161
+ } as SourceEntityDescriptor,
162
+ false
163
+ );
164
+
165
+ result.push(table);
166
+ }
167
+ return result;
168
+ }
169
+
170
+ async initSlot(): Promise<InitResult> {
171
+ await checkSourceConfiguration(this.connections.pool, PUBLICATION_NAME);
172
+
173
+ const slotName = this.slot_name;
174
+
175
+ const status = await this.storage.getStatus();
176
+ if (status.snapshot_done && status.checkpoint_lsn) {
177
+ logger.info(`${slotName} Initial replication already done`);
178
+
179
+ let last_error = null;
180
+
181
+ // Check that replication slot exists
182
+ for (let i = 120; i >= 0; i--) {
183
+ await touch();
184
+
185
+ if (i == 0) {
186
+ container.reporter.captureException(last_error, {
187
+ level: errors.ErrorSeverity.ERROR,
188
+ metadata: {
189
+ replication_slot: slotName
190
+ }
191
+ });
192
+
193
+ throw last_error;
194
+ }
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
+ await this.connections.pool.query({
200
+ statement: `SELECT *
201
+ FROM pg_catalog.pg_logical_slot_peek_binary_changes($1, NULL, 1000, 'proto_version', '1',
202
+ 'publication_names', $2)`,
203
+ params: [
204
+ { type: 'varchar', value: slotName },
205
+ { type: 'varchar', value: PUBLICATION_NAME }
206
+ ]
207
+ });
208
+ // Success
209
+ logger.info(`Slot ${slotName} appears healthy`);
210
+ return { needsInitialSync: false };
211
+ } catch (e) {
212
+ last_error = e;
213
+ logger.warn(`${slotName} Replication slot error`, e);
214
+
215
+ if (this.stopped) {
216
+ throw e;
217
+ }
218
+
219
+ // Could also be `publication "powersync" does not exist`, although this error may show up much later
220
+ // in some cases.
221
+
222
+ if (
223
+ /incorrect prev-link/.test(e.message) ||
224
+ /replication slot.*does not exist/.test(e.message) ||
225
+ /publication.*does not exist/.test(e.message)
226
+ ) {
227
+ container.reporter.captureException(e, {
228
+ level: errors.ErrorSeverity.WARNING,
229
+ metadata: {
230
+ try_index: i,
231
+ replication_slot: slotName
232
+ }
233
+ });
234
+ // Sample: record with incorrect prev-link 10000/10000 at 0/18AB778
235
+ // Seen during development. Some internal error, fixed by re-creating slot.
236
+ //
237
+ // Sample: publication "powersync" does not exist
238
+ // Happens when publication deleted or never created.
239
+ // Slot must be re-created in this case.
240
+ logger.info(`${slotName} does not exist anymore, will create new slot`);
241
+
242
+ throw new MissingReplicationSlotError(`Replication slot ${slotName} does not exist anymore`);
243
+ }
244
+ // Try again after a pause
245
+ await new Promise((resolve) => setTimeout(resolve, 1000));
246
+ }
247
+ }
248
+ }
249
+
250
+ return { needsInitialSync: true };
251
+ }
252
+
253
+ async estimatedCount(db: pgwire.PgConnection, table: storage.SourceTable): Promise<string> {
254
+ const results = await db.query({
255
+ statement: `SELECT reltuples::bigint AS estimate
256
+ FROM pg_class
257
+ WHERE oid = $1::regclass`,
258
+ params: [{ value: table.qualifiedName, type: 'varchar' }]
259
+ });
260
+ const row = results.rows[0];
261
+ if ((row?.[0] ?? -1n) == -1n) {
262
+ return '?';
263
+ } else {
264
+ return `~${row[0]}`;
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Start initial replication.
270
+ *
271
+ * If (partial) replication was done before on this slot, this clears the state
272
+ * and starts again from scratch.
273
+ */
274
+ async startInitialReplication(replicationConnection: pgwire.PgConnection) {
275
+ // If anything here errors, the entire replication process is aborted,
276
+ // and all connections closed, including this one.
277
+ const db = await this.connections.snapshotConnection();
278
+
279
+ const slotName = this.slot_name;
280
+
281
+ await this.storage.clear();
282
+
283
+ await db.query({
284
+ statement: 'SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_name = $1',
285
+ params: [{ type: 'varchar', value: slotName }]
286
+ });
287
+
288
+ // We use the replication connection here, not a pool.
289
+ // This connection needs to stay open at least until the snapshot is used below.
290
+ const result = await replicationConnection.query(
291
+ `CREATE_REPLICATION_SLOT ${slotName} LOGICAL pgoutput EXPORT_SNAPSHOT`
292
+ );
293
+ const columns = result.columns;
294
+ const row = result.rows[0]!;
295
+ if (columns[1]?.name != 'consistent_point' || columns[2]?.name != 'snapshot_name' || row == null) {
296
+ throw new Error(`Invalid CREATE_REPLICATION_SLOT output: ${JSON.stringify(columns)}`);
297
+ }
298
+ // This LSN could be used in initialReplication below.
299
+ // But it's also safe to just use ZERO_LSN - we won't get any changes older than this lsn
300
+ // with streaming replication.
301
+ const lsn = pgwire.lsnMakeComparable(row[1]);
302
+ const snapshot = row[2];
303
+ logger.info(`Created replication slot ${slotName} at ${lsn} with snapshot ${snapshot}`);
304
+
305
+ // https://stackoverflow.com/questions/70160769/postgres-logical-replication-starting-from-given-lsn
306
+ await db.query('BEGIN');
307
+ // Use the snapshot exported above.
308
+ // Using SERIALIZABLE isolation level may give stronger guarantees, but that complicates
309
+ // the replication slot + snapshot above. And we still won't have SERIALIZABLE
310
+ // guarantees with streaming replication.
311
+ // See: ./docs/serializability.md for details.
312
+ //
313
+ // Another alternative here is to use the same pgwire connection for initial replication as well,
314
+ // instead of synchronizing a separate transaction to the snapshot.
315
+
316
+ try {
317
+ await db.query(`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`);
318
+ await db.query(`SET TRANSACTION READ ONLY`);
319
+ await db.query(`SET TRANSACTION SNAPSHOT '${snapshot}'`);
320
+
321
+ // Disable statement timeout for the duration of this transaction.
322
+ // On Supabase, the default is 2 minutes.
323
+ await db.query(`set local statement_timeout = 0`);
324
+
325
+ logger.info(`${slotName} Starting initial replication`);
326
+ await this.initialReplication(db, lsn);
327
+ logger.info(`${slotName} Initial replication done`);
328
+ await db.query('COMMIT');
329
+ } catch (e) {
330
+ await db.query('ROLLBACK');
331
+ throw e;
332
+ }
333
+ }
334
+
335
+ async initialReplication(db: pgwire.PgConnection, lsn: string) {
336
+ const sourceTables = this.sync_rules.getSourceTables();
337
+ await this.storage.startBatch({ zeroLSN: ZERO_LSN, defaultSchema: POSTGRES_DEFAULT_SCHEMA }, async (batch) => {
338
+ for (let tablePattern of sourceTables) {
339
+ const tables = await this.getQualifiedTableNames(batch, db, tablePattern);
340
+ for (let table of tables) {
341
+ await this.snapshotTable(batch, db, table);
342
+ await batch.markSnapshotDone([table], lsn);
343
+
344
+ await touch();
345
+ }
346
+ }
347
+ await batch.commit(lsn);
348
+ });
349
+ }
350
+
351
+ static *getQueryData(results: Iterable<DatabaseInputRow>): Generator<SqliteRow> {
352
+ for (let row of results) {
353
+ yield toSyncRulesRow(row);
354
+ }
355
+ }
356
+
357
+ private async snapshotTable(batch: storage.BucketStorageBatch, db: pgwire.PgConnection, table: storage.SourceTable) {
358
+ logger.info(`${this.slot_name} Replicating ${table.qualifiedName}`);
359
+ const estimatedCount = await this.estimatedCount(db, table);
360
+ let at = 0;
361
+ let lastLogIndex = 0;
362
+ const cursor = db.stream({ statement: `SELECT * FROM ${table.escapedIdentifier}` });
363
+ let columns: { i: number; name: string }[] = [];
364
+ // pgwire streams rows in chunks.
365
+ // These chunks can be quite small (as little as 16KB), so we don't flush chunks automatically.
366
+
367
+ for await (let chunk of cursor) {
368
+ if (chunk.tag == 'RowDescription') {
369
+ let i = 0;
370
+ columns = chunk.payload.map((c) => {
371
+ return { i: i++, name: c.name };
372
+ });
373
+ continue;
374
+ }
375
+
376
+ const rows = chunk.rows.map((row) => {
377
+ let q: DatabaseInputRow = {};
378
+ for (let c of columns) {
379
+ q[c.name] = row[c.i];
380
+ }
381
+ return q;
382
+ });
383
+ if (rows.length > 0 && at - lastLogIndex >= 5000) {
384
+ logger.info(`${this.slot_name} Replicating ${table.qualifiedName} ${at}/${estimatedCount}`);
385
+ lastLogIndex = at;
386
+ }
387
+ if (this.abort_signal.aborted) {
388
+ throw new Error(`Aborted initial replication of ${this.slot_name}`);
389
+ }
390
+
391
+ for (let record of WalStream.getQueryData(rows)) {
392
+ // This auto-flushes when the batch reaches its size limit
393
+ await batch.save({
394
+ tag: 'insert',
395
+ sourceTable: table,
396
+ before: undefined,
397
+ beforeReplicaId: undefined,
398
+ after: record,
399
+ afterReplicaId: getUuidReplicaIdentityBson(record, table.replicaIdColumns)
400
+ });
401
+ }
402
+ at += rows.length;
403
+ Metrics.getInstance().rows_replicated_total.add(rows.length);
404
+
405
+ await touch();
406
+ }
407
+
408
+ await batch.flush();
409
+ }
410
+
411
+ async handleRelation(batch: storage.BucketStorageBatch, descriptor: SourceEntityDescriptor, snapshot: boolean) {
412
+ if (!descriptor.objectId && typeof descriptor.objectId != 'number') {
413
+ throw new Error('objectId expected');
414
+ }
415
+ const result = await this.storage.resolveTable({
416
+ group_id: this.group_id,
417
+ connection_id: this.connection_id,
418
+ connection_tag: this.connections.connectionTag,
419
+ entity_descriptor: descriptor,
420
+ sync_rules: this.sync_rules
421
+ });
422
+ this.relation_cache.set(descriptor.objectId, result.table);
423
+
424
+ // Drop conflicting tables. This includes for example renamed tables.
425
+ await batch.drop(result.dropTables);
426
+
427
+ // Snapshot if:
428
+ // 1. Snapshot is requested (false for initial snapshot, since that process handles it elsewhere)
429
+ // 2. Snapshot is not already done, AND:
430
+ // 3. The table is used in sync rules.
431
+ const shouldSnapshot = snapshot && !result.table.snapshotComplete && result.table.syncAny;
432
+
433
+ if (shouldSnapshot) {
434
+ // Truncate this table, in case a previous snapshot was interrupted.
435
+ await batch.truncate([result.table]);
436
+
437
+ let lsn: string = ZERO_LSN;
438
+ // Start the snapshot inside a transaction.
439
+ // We use a dedicated connection for this.
440
+ const db = await this.connections.snapshotConnection();
441
+ try {
442
+ await db.query('BEGIN');
443
+ try {
444
+ // Get the current LSN.
445
+ // The data will only be consistent once incremental replication
446
+ // has passed that point.
447
+ const rs = await db.query(`select pg_current_wal_lsn() as lsn`);
448
+ lsn = rs.rows[0][0];
449
+ await this.snapshotTable(batch, db, result.table);
450
+ await db.query('COMMIT');
451
+ } catch (e) {
452
+ await db.query('ROLLBACK');
453
+ throw e;
454
+ }
455
+ } finally {
456
+ await db.end();
457
+ }
458
+ const [table] = await batch.markSnapshotDone([result.table], lsn);
459
+ return table;
460
+ }
461
+
462
+ return result.table;
463
+ }
464
+
465
+ private getTable(relationId: number): storage.SourceTable {
466
+ const table = this.relation_cache.get(relationId);
467
+ if (table == null) {
468
+ // We should always receive a replication message before the relation is used.
469
+ // If we can't find it, it's a bug.
470
+ throw new Error(`Missing relation cache for ${relationId}`);
471
+ }
472
+ return table;
473
+ }
474
+
475
+ async writeChange(
476
+ batch: storage.BucketStorageBatch,
477
+ msg: pgwire.PgoutputMessage
478
+ ): Promise<storage.FlushedResult | null> {
479
+ if (msg.lsn == null) {
480
+ return null;
481
+ }
482
+ if (msg.tag == 'insert' || msg.tag == 'update' || msg.tag == 'delete') {
483
+ const table = this.getTable(getRelId(msg.relation));
484
+ if (!table.syncAny) {
485
+ logger.debug(`Table ${table.qualifiedName} not used in sync rules - skipping`);
486
+ return null;
487
+ }
488
+
489
+ if (msg.tag == 'insert') {
490
+ Metrics.getInstance().rows_replicated_total.add(1);
491
+ const baseRecord = util.constructAfterRecord(msg);
492
+ return await batch.save({
493
+ tag: 'insert',
494
+ sourceTable: table,
495
+ before: undefined,
496
+ beforeReplicaId: undefined,
497
+ after: baseRecord,
498
+ afterReplicaId: getUuidReplicaIdentityBson(baseRecord, table.replicaIdColumns)
499
+ });
500
+ } else if (msg.tag == 'update') {
501
+ Metrics.getInstance().rows_replicated_total.add(1);
502
+ // "before" may be null if the replica id columns are unchanged
503
+ // It's fine to treat that the same as an insert.
504
+ const before = util.constructBeforeRecord(msg);
505
+ const after = util.constructAfterRecord(msg);
506
+ return await batch.save({
507
+ tag: 'update',
508
+ sourceTable: table,
509
+ before: before,
510
+ beforeReplicaId: before ? getUuidReplicaIdentityBson(before, table.replicaIdColumns) : undefined,
511
+ after: after,
512
+ afterReplicaId: getUuidReplicaIdentityBson(after, table.replicaIdColumns)
513
+ });
514
+ } else if (msg.tag == 'delete') {
515
+ Metrics.getInstance().rows_replicated_total.add(1);
516
+ const before = util.constructBeforeRecord(msg)!;
517
+
518
+ return await batch.save({
519
+ tag: 'delete',
520
+ sourceTable: table,
521
+ before: before,
522
+ beforeReplicaId: getUuidReplicaIdentityBson(before, table.replicaIdColumns),
523
+ after: undefined,
524
+ afterReplicaId: undefined
525
+ });
526
+ }
527
+ } else if (msg.tag == 'truncate') {
528
+ let tables: storage.SourceTable[] = [];
529
+ for (let relation of msg.relations) {
530
+ const table = this.getTable(getRelId(relation));
531
+ tables.push(table);
532
+ }
533
+ return await batch.truncate(tables);
534
+ }
535
+ return null;
536
+ }
537
+
538
+ async replicate() {
539
+ try {
540
+ // If anything errors here, the entire replication process is halted, and
541
+ // all connections automatically closed, including this one.
542
+ const replicationConnection = await this.connections.replicationConnection();
543
+ await this.initReplication(replicationConnection);
544
+ await this.streamChanges(replicationConnection);
545
+ } catch (e) {
546
+ await this.storage.reportError(e);
547
+ throw e;
548
+ }
549
+ }
550
+
551
+ async initReplication(replicationConnection: pgwire.PgConnection) {
552
+ const result = await this.initSlot();
553
+ if (result.needsInitialSync) {
554
+ await this.startInitialReplication(replicationConnection);
555
+ }
556
+ }
557
+
558
+ async streamChanges(replicationConnection: pgwire.PgConnection) {
559
+ // When changing any logic here, check /docs/wal-lsns.md.
560
+
561
+ const replicationStream = replicationConnection.logicalReplication({
562
+ slot: this.slot_name,
563
+ options: {
564
+ proto_version: '1',
565
+ publication_names: PUBLICATION_NAME
566
+ }
567
+ });
568
+ this.startedStreaming = true;
569
+
570
+ // Auto-activate as soon as initial replication is done
571
+ await this.storage.autoActivate();
572
+
573
+ await this.storage.startBatch({ zeroLSN: ZERO_LSN, defaultSchema: POSTGRES_DEFAULT_SCHEMA }, async (batch) => {
574
+ // Replication never starts in the middle of a transaction
575
+ let inTx = false;
576
+ let count = 0;
577
+
578
+ for await (const chunk of replicationStream.pgoutputDecode()) {
579
+ await touch();
580
+
581
+ if (this.abort_signal.aborted) {
582
+ break;
583
+ }
584
+
585
+ // chunkLastLsn may come from normal messages in the chunk,
586
+ // or from a PrimaryKeepalive message.
587
+ const { messages, lastLsn: chunkLastLsn } = chunk;
588
+
589
+ for (const msg of messages) {
590
+ if (msg.tag == 'relation') {
591
+ await this.handleRelation(batch, getPgOutputRelation(msg), true);
592
+ } else if (msg.tag == 'begin') {
593
+ inTx = true;
594
+ } else if (msg.tag == 'commit') {
595
+ Metrics.getInstance().transactions_replicated_total.add(1);
596
+ inTx = false;
597
+ await batch.commit(msg.lsn!);
598
+ await this.ack(msg.lsn!, replicationStream);
599
+ } else {
600
+ if (count % 100 == 0) {
601
+ logger.info(`${this.slot_name} replicating op ${count} ${msg.lsn}`);
602
+ }
603
+
604
+ count += 1;
605
+ const result = await this.writeChange(batch, msg);
606
+ }
607
+ }
608
+
609
+ if (!inTx) {
610
+ // In a transaction, we ack and commit according to the transaction progress.
611
+ // Outside transactions, we use the PrimaryKeepalive messages to advance progress.
612
+ // Big caveat: This _must not_ be used to skip individual messages, since this LSN
613
+ // may be in the middle of the next transaction.
614
+ // It must only be used to associate checkpoints with LSNs.
615
+ if (await batch.keepalive(chunkLastLsn)) {
616
+ await this.ack(chunkLastLsn, replicationStream);
617
+ }
618
+ }
619
+
620
+ Metrics.getInstance().chunks_replicated_total.add(1);
621
+ }
622
+ });
623
+ }
624
+
625
+ async ack(lsn: string, replicationStream: pgwire.ReplicationStream) {
626
+ if (lsn == ZERO_LSN) {
627
+ return;
628
+ }
629
+
630
+ replicationStream.ack(lsn);
631
+ }
632
+ }
633
+
634
+ async function touch() {
635
+ // FIXME: The hosted Kubernetes probe does not actually check the timestamp on this.
636
+ // FIXME: We need a timeout of around 5+ minutes in Kubernetes if we do start checking the timestamp,
637
+ // or reduce PING_INTERVAL here.
638
+ return container.probes.touch();
639
+ }