@powersync/service-module-postgres 0.16.10 → 0.16.12
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.
- package/CHANGELOG.md +25 -0
- package/dist/api/PostgresRouteAPIAdapter.js +1 -2
- package/dist/api/PostgresRouteAPIAdapter.js.map +1 -1
- package/dist/module/PostgresModule.d.ts +0 -1
- package/dist/module/PostgresModule.js +4 -9
- package/dist/module/PostgresModule.js.map +1 -1
- package/dist/replication/ConnectionManagerFactory.d.ts +3 -5
- package/dist/replication/ConnectionManagerFactory.js +11 -9
- package/dist/replication/ConnectionManagerFactory.js.map +1 -1
- package/dist/replication/PgManager.d.ts +6 -4
- package/dist/replication/PgManager.js +17 -7
- package/dist/replication/PgManager.js.map +1 -1
- package/dist/replication/PostgresErrorRateLimiter.js +6 -1
- package/dist/replication/PostgresErrorRateLimiter.js.map +1 -1
- package/dist/replication/WalStream.d.ts +2 -5
- package/dist/replication/WalStream.js +69 -96
- package/dist/replication/WalStream.js.map +1 -1
- package/dist/replication/WalStreamReplicationJob.d.ts +1 -2
- package/dist/replication/WalStreamReplicationJob.js +47 -70
- package/dist/replication/WalStreamReplicationJob.js.map +1 -1
- package/dist/types/resolver.d.ts +9 -3
- package/dist/types/resolver.js +26 -5
- package/dist/types/resolver.js.map +1 -1
- package/dist/types/types.d.ts +1 -4
- package/dist/types/types.js.map +1 -1
- package/package.json +10 -10
- package/src/api/PostgresRouteAPIAdapter.ts +1 -1
- package/src/module/PostgresModule.ts +4 -9
- package/src/replication/ConnectionManagerFactory.ts +14 -13
- package/src/replication/PgManager.ts +22 -11
- package/src/replication/PostgresErrorRateLimiter.ts +5 -1
- package/src/replication/WalStream.ts +76 -117
- package/src/replication/WalStreamReplicationJob.ts +48 -68
- package/src/types/resolver.ts +27 -6
- package/src/types/types.ts +1 -5
- package/test/src/pg_test.test.ts +2 -2
- package/test/src/slow_tests.test.ts +2 -2
- package/test/src/wal_stream.test.ts +29 -10
- package/test/src/wal_stream_utils.ts +24 -5
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,30 +1,31 @@
|
|
|
1
|
-
import { PgManager } from './PgManager.js';
|
|
2
|
-
import { NormalizedPostgresConnectionConfig } from '../types/types.js';
|
|
3
|
-
import { PgPoolOptions } from '@powersync/service-jpgwire';
|
|
4
1
|
import { logger } from '@powersync/lib-services-framework';
|
|
5
|
-
import {
|
|
2
|
+
import { PgPoolOptions } from '@powersync/service-jpgwire';
|
|
3
|
+
import { NormalizedPostgresConnectionConfig } from '../types/types.js';
|
|
4
|
+
import { PgManager } from './PgManager.js';
|
|
6
5
|
|
|
7
6
|
export class ConnectionManagerFactory {
|
|
8
|
-
private readonly connectionManagers
|
|
7
|
+
private readonly connectionManagers = new Set<PgManager>();
|
|
9
8
|
public readonly dbConnectionConfig: NormalizedPostgresConnectionConfig;
|
|
10
9
|
|
|
11
|
-
constructor(
|
|
12
|
-
dbConnectionConfig: NormalizedPostgresConnectionConfig,
|
|
13
|
-
private readonly registry: CustomTypeRegistry
|
|
14
|
-
) {
|
|
10
|
+
constructor(dbConnectionConfig: NormalizedPostgresConnectionConfig) {
|
|
15
11
|
this.dbConnectionConfig = dbConnectionConfig;
|
|
16
|
-
this.connectionManagers = [];
|
|
17
12
|
}
|
|
18
13
|
|
|
19
14
|
create(poolOptions: PgPoolOptions) {
|
|
20
|
-
const manager = new PgManager(this.dbConnectionConfig, { ...poolOptions
|
|
21
|
-
this.connectionManagers.
|
|
15
|
+
const manager = new PgManager(this.dbConnectionConfig, { ...poolOptions });
|
|
16
|
+
this.connectionManagers.add(manager);
|
|
17
|
+
|
|
18
|
+
manager.registerListener({
|
|
19
|
+
onEnded: () => {
|
|
20
|
+
this.connectionManagers.delete(manager);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
22
23
|
return manager;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
async shutdown() {
|
|
26
27
|
logger.info('Shutting down Postgres connection Managers...');
|
|
27
|
-
for (const manager of this.connectionManagers) {
|
|
28
|
+
for (const manager of [...this.connectionManagers]) {
|
|
28
29
|
await manager.end();
|
|
29
30
|
}
|
|
30
31
|
logger.info('Postgres connection Managers shutdown completed.');
|
|
@@ -1,21 +1,23 @@
|
|
|
1
|
+
import { BaseObserver } from '@powersync/lib-services-framework';
|
|
1
2
|
import * as pgwire from '@powersync/service-jpgwire';
|
|
2
3
|
import semver from 'semver';
|
|
4
|
+
import { PostgresTypeResolver } from '../types/resolver.js';
|
|
3
5
|
import { NormalizedPostgresConnectionConfig } from '../types/types.js';
|
|
4
6
|
import { getApplicationName } from '../utils/application-name.js';
|
|
5
|
-
import { PostgresTypeResolver } from '../types/resolver.js';
|
|
6
7
|
import { getServerVersion } from '../utils/postgres_version.js';
|
|
7
|
-
import { CustomTypeRegistry } from '../types/registry.js';
|
|
8
8
|
|
|
9
|
-
export interface PgManagerOptions extends pgwire.PgPoolOptions {
|
|
10
|
-
registry: CustomTypeRegistry;
|
|
11
|
-
}
|
|
9
|
+
export interface PgManagerOptions extends pgwire.PgPoolOptions {}
|
|
12
10
|
|
|
13
11
|
/**
|
|
14
12
|
* Shorter timeout for snapshot connections than for replication connections.
|
|
15
13
|
*/
|
|
16
14
|
const SNAPSHOT_SOCKET_TIMEOUT = 30_000;
|
|
17
15
|
|
|
18
|
-
export
|
|
16
|
+
export interface PgManagerListener {
|
|
17
|
+
onEnded(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class PgManager extends BaseObserver<PgManagerListener> {
|
|
19
21
|
/**
|
|
20
22
|
* Do not use this for any transactions.
|
|
21
23
|
*/
|
|
@@ -29,9 +31,10 @@ export class PgManager {
|
|
|
29
31
|
public options: NormalizedPostgresConnectionConfig,
|
|
30
32
|
public poolOptions: PgManagerOptions
|
|
31
33
|
) {
|
|
34
|
+
super();
|
|
32
35
|
// The pool is lazy - no connections are opened until a query is performed.
|
|
33
36
|
this.pool = pgwire.connectPgWirePool(this.options, poolOptions);
|
|
34
|
-
this.types = new PostgresTypeResolver(
|
|
37
|
+
this.types = new PostgresTypeResolver(this.pool);
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
public get connectionTag() {
|
|
@@ -83,8 +86,9 @@ export class PgManager {
|
|
|
83
86
|
for (let result of await Promise.allSettled([
|
|
84
87
|
this.pool.end(),
|
|
85
88
|
...this.connectionPromises.map(async (promise) => {
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
// Wait for connection attempts to finish, but do not throw connection errors here
|
|
90
|
+
const connection = await promise.catch((_) => {});
|
|
91
|
+
return await connection?.end();
|
|
88
92
|
})
|
|
89
93
|
])) {
|
|
90
94
|
// Throw the first error, if any
|
|
@@ -92,14 +96,18 @@ export class PgManager {
|
|
|
92
96
|
throw result.reason;
|
|
93
97
|
}
|
|
94
98
|
}
|
|
99
|
+
this.iterateListeners((listener) => {
|
|
100
|
+
listener.onEnded?.();
|
|
101
|
+
});
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
async destroy() {
|
|
98
105
|
this.pool.destroy();
|
|
99
106
|
for (let result of await Promise.allSettled([
|
|
100
107
|
...this.connectionPromises.map(async (promise) => {
|
|
101
|
-
|
|
102
|
-
|
|
108
|
+
// Wait for connection attempts to finish, but do not throw connection errors here
|
|
109
|
+
const connection = await promise.catch((_) => {});
|
|
110
|
+
return connection?.destroy();
|
|
103
111
|
})
|
|
104
112
|
])) {
|
|
105
113
|
// Throw the first error, if any
|
|
@@ -107,5 +115,8 @@ export class PgManager {
|
|
|
107
115
|
throw result.reason;
|
|
108
116
|
}
|
|
109
117
|
}
|
|
118
|
+
this.iterateListeners((listener) => {
|
|
119
|
+
listener.onEnded?.();
|
|
120
|
+
});
|
|
110
121
|
}
|
|
111
122
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { setTimeout } from 'timers/promises';
|
|
2
2
|
import { ErrorRateLimiter } from '@powersync/service-core';
|
|
3
|
+
import { MissingReplicationSlotError } from './WalStream.js';
|
|
3
4
|
|
|
4
5
|
export class PostgresErrorRateLimiter implements ErrorRateLimiter {
|
|
5
6
|
nextAllowed: number = Date.now();
|
|
@@ -17,7 +18,10 @@ export class PostgresErrorRateLimiter implements ErrorRateLimiter {
|
|
|
17
18
|
|
|
18
19
|
reportError(e: any): void {
|
|
19
20
|
const message = (e.message as string) ?? '';
|
|
20
|
-
if (
|
|
21
|
+
if (e instanceof MissingReplicationSlotError) {
|
|
22
|
+
// Short delay for a retrying (re-creating the slot)
|
|
23
|
+
this.setDelay(2_000);
|
|
24
|
+
} else if (message.includes('password authentication failed')) {
|
|
21
25
|
// Wait 15 minutes, to avoid triggering Supabase's fail2ban
|
|
22
26
|
this.setDelay(900_000);
|
|
23
27
|
} else if (message.includes('ENOTFOUND')) {
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
DatabaseConnectionError,
|
|
5
5
|
logger as defaultLogger,
|
|
6
6
|
ErrorCode,
|
|
7
|
-
errors,
|
|
8
7
|
Logger,
|
|
9
8
|
ReplicationAbortedError,
|
|
10
9
|
ReplicationAssertionError
|
|
@@ -100,8 +99,10 @@ export const sendKeepAlive = async (db: pgwire.PgClient) => {
|
|
|
100
99
|
};
|
|
101
100
|
|
|
102
101
|
export class MissingReplicationSlotError extends Error {
|
|
103
|
-
constructor(message: string) {
|
|
102
|
+
constructor(message: string, cause?: any) {
|
|
104
103
|
super(message);
|
|
104
|
+
|
|
105
|
+
this.cause = cause;
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
|
|
@@ -304,135 +305,54 @@ export class WalStream {
|
|
|
304
305
|
})
|
|
305
306
|
)[0];
|
|
306
307
|
|
|
308
|
+
// Previously we also used pg_catalog.pg_logical_slot_peek_binary_changes to confirm that we can query the slot.
|
|
309
|
+
// However, there were some edge cases where the query times out, repeating the query, ultimately
|
|
310
|
+
// causing high load on the source database and never recovering automatically.
|
|
311
|
+
// We now instead jump straight to replication if the wal_status is not "lost", rather detecting those
|
|
312
|
+
// errors during streaming replication, which is a little more robust.
|
|
313
|
+
|
|
314
|
+
// We can have:
|
|
315
|
+
// 1. needsInitialSync: true, lost slot -> MissingReplicationSlotError (starts new sync rules version).
|
|
316
|
+
// Theoretically we could handle this the same as (2).
|
|
317
|
+
// 2. needsInitialSync: true, no slot -> create new slot
|
|
318
|
+
// 3. needsInitialSync: true, valid slot -> resume initial sync
|
|
319
|
+
// 4. needsInitialSync: false, lost slot -> MissingReplicationSlotError (starts new sync rules version)
|
|
320
|
+
// 5. needsInitialSync: false, no slot -> MissingReplicationSlotError (starts new sync rules version)
|
|
321
|
+
// 6. needsInitialSync: false, valid slot -> resume streaming replication
|
|
322
|
+
// The main advantage of MissingReplicationSlotError are:
|
|
323
|
+
// 1. If there was a complete snapshot already (cases 4/5), users can still sync from that snapshot while
|
|
324
|
+
// we do the reprocessing under a new slot name.
|
|
325
|
+
// 2. If there was a partial snapshot (case 1), we can start with the new slot faster by not waiting for
|
|
326
|
+
// the partial data to be cleared.
|
|
307
327
|
if (slot != null) {
|
|
308
328
|
// This checks that the slot is still valid
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
329
|
+
|
|
330
|
+
// wal_status is present in postgres 13+
|
|
331
|
+
// invalidation_reason is present in postgres 17+
|
|
332
|
+
const lost = slot.wal_status == 'lost';
|
|
333
|
+
if (lost) {
|
|
334
|
+
// Case 1 / 4
|
|
335
|
+
throw new MissingReplicationSlotError(
|
|
336
|
+
`Replication slot ${slotName} is not valid anymore. invalidation_reason: ${slot.invalidation_reason ?? 'unknown'}`
|
|
337
|
+
);
|
|
313
338
|
}
|
|
314
|
-
//
|
|
315
|
-
// needsInitialSync: true, needsNewSlot: true -> initial sync from scratch
|
|
316
|
-
// needsInitialSync: true, needsNewSlot: false -> resume initial sync
|
|
317
|
-
// needsInitialSync: false, needsNewSlot: true -> handled above
|
|
318
|
-
// needsInitialSync: false, needsNewSlot: false -> resume streaming replication
|
|
339
|
+
// Case 3 / 6
|
|
319
340
|
return {
|
|
320
341
|
needsInitialSync: !snapshotDone,
|
|
321
|
-
needsNewSlot:
|
|
342
|
+
needsNewSlot: false
|
|
322
343
|
};
|
|
323
344
|
} else {
|
|
324
345
|
if (snapshotDone) {
|
|
346
|
+
// Case 5
|
|
325
347
|
// This will create a new slot, while keeping the current sync rules active
|
|
326
348
|
throw new MissingReplicationSlotError(`Replication slot ${slotName} is missing`);
|
|
327
349
|
}
|
|
328
|
-
//
|
|
350
|
+
// Case 2
|
|
351
|
+
// This will clear data (if any) and re-create the same slot
|
|
329
352
|
return { needsInitialSync: true, needsNewSlot: true };
|
|
330
353
|
}
|
|
331
354
|
}
|
|
332
355
|
|
|
333
|
-
/**
|
|
334
|
-
* If a replication slot exists, check that it is healthy.
|
|
335
|
-
*/
|
|
336
|
-
private async checkReplicationSlot(slot: {
|
|
337
|
-
// postgres 13+
|
|
338
|
-
wal_status?: string;
|
|
339
|
-
// postgres 17+
|
|
340
|
-
invalidation_reason?: string | null;
|
|
341
|
-
}): Promise<{ needsNewSlot: boolean }> {
|
|
342
|
-
// Start with a placeholder error, should be replaced if there is an actual issue.
|
|
343
|
-
let last_error = new ReplicationAssertionError(`Slot health check failed to execute`);
|
|
344
|
-
|
|
345
|
-
const slotName = this.slot_name;
|
|
346
|
-
|
|
347
|
-
const lost = slot.wal_status == 'lost';
|
|
348
|
-
if (lost) {
|
|
349
|
-
this.logger.warn(
|
|
350
|
-
`Replication slot ${slotName} is invalidated. invalidation_reason: ${slot.invalidation_reason ?? 'unknown'}`
|
|
351
|
-
);
|
|
352
|
-
return {
|
|
353
|
-
needsNewSlot: true
|
|
354
|
-
};
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// Check that replication slot exists, trying for up to 2 minutes.
|
|
358
|
-
const startAt = performance.now();
|
|
359
|
-
while (performance.now() - startAt < 120_000) {
|
|
360
|
-
this.touch();
|
|
361
|
-
|
|
362
|
-
try {
|
|
363
|
-
// We peek a large number of changes here, to make it more likely to pick up replication slot errors.
|
|
364
|
-
// For example, "publication does not exist" only occurs here if the peek actually includes changes related
|
|
365
|
-
// to the slot.
|
|
366
|
-
this.logger.info(`Checking ${slotName}`);
|
|
367
|
-
|
|
368
|
-
// The actual results can be quite large, so we don't actually return everything
|
|
369
|
-
// due to memory and processing overhead that would create.
|
|
370
|
-
const cursor = await this.connections.pool.stream({
|
|
371
|
-
statement: `SELECT 1 FROM pg_catalog.pg_logical_slot_peek_binary_changes($1, NULL, 1000, 'proto_version', '1', 'publication_names', $2)`,
|
|
372
|
-
params: [
|
|
373
|
-
{ type: 'varchar', value: slotName },
|
|
374
|
-
{ type: 'varchar', value: PUBLICATION_NAME }
|
|
375
|
-
]
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
for await (let _chunk of cursor) {
|
|
379
|
-
// No-op, just exhaust the cursor
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// Success
|
|
383
|
-
this.logger.info(`Slot ${slotName} appears healthy`);
|
|
384
|
-
return { needsNewSlot: false };
|
|
385
|
-
} catch (e) {
|
|
386
|
-
last_error = e;
|
|
387
|
-
this.logger.warn(`Replication slot error`, e);
|
|
388
|
-
|
|
389
|
-
if (this.stopped) {
|
|
390
|
-
throw e;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
if (
|
|
394
|
-
/incorrect prev-link/.test(e.message) ||
|
|
395
|
-
/replication slot.*does not exist/.test(e.message) ||
|
|
396
|
-
/publication.*does not exist/.test(e.message) ||
|
|
397
|
-
// Postgres 18 - exceeded max_slot_wal_keep_size
|
|
398
|
-
/can no longer access replication slot/.test(e.message) ||
|
|
399
|
-
// Postgres 17 - exceeded max_slot_wal_keep_size
|
|
400
|
-
/can no longer get changes from replication slot/.test(e.message)
|
|
401
|
-
) {
|
|
402
|
-
// Fatal error. In most cases since Postgres 13+, the `wal_status == 'lost'` check should pick this up, but this
|
|
403
|
-
// works as a fallback.
|
|
404
|
-
|
|
405
|
-
container.reporter.captureException(e, {
|
|
406
|
-
level: errors.ErrorSeverity.WARNING,
|
|
407
|
-
metadata: {
|
|
408
|
-
replication_slot: slotName
|
|
409
|
-
}
|
|
410
|
-
});
|
|
411
|
-
// Sample: record with incorrect prev-link 10000/10000 at 0/18AB778
|
|
412
|
-
// Seen during development. Some internal error, fixed by re-creating slot.
|
|
413
|
-
//
|
|
414
|
-
// Sample: publication "powersync" does not exist
|
|
415
|
-
// Happens when publication deleted or never created.
|
|
416
|
-
// Slot must be re-created in this case.
|
|
417
|
-
this.logger.info(`${slotName} is not valid anymore`);
|
|
418
|
-
|
|
419
|
-
return { needsNewSlot: true };
|
|
420
|
-
}
|
|
421
|
-
// Try again after a pause
|
|
422
|
-
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
container.reporter.captureException(last_error, {
|
|
427
|
-
level: errors.ErrorSeverity.ERROR,
|
|
428
|
-
metadata: {
|
|
429
|
-
replication_slot: slotName
|
|
430
|
-
}
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
throw last_error;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
356
|
async estimatedCountNumber(db: pgwire.PgConnection, table: storage.SourceTable): Promise<number> {
|
|
437
357
|
const results = await db.query({
|
|
438
358
|
statement: `SELECT reltuples::bigint AS estimate
|
|
@@ -626,6 +546,7 @@ WHERE oid = $1::regclass`,
|
|
|
626
546
|
await q.initialize();
|
|
627
547
|
|
|
628
548
|
let columns: { i: number; name: string }[] = [];
|
|
549
|
+
let columnMap: Record<string, number> = {};
|
|
629
550
|
let hasRemainingData = true;
|
|
630
551
|
while (hasRemainingData) {
|
|
631
552
|
// Fetch 10k at a time.
|
|
@@ -645,6 +566,9 @@ WHERE oid = $1::regclass`,
|
|
|
645
566
|
columns = chunk.payload.map((c) => {
|
|
646
567
|
return { i: i++, name: c.name };
|
|
647
568
|
});
|
|
569
|
+
for (let column of chunk.payload) {
|
|
570
|
+
columnMap[column.name] = column.typeOid;
|
|
571
|
+
}
|
|
648
572
|
continue;
|
|
649
573
|
}
|
|
650
574
|
|
|
@@ -660,7 +584,7 @@ WHERE oid = $1::regclass`,
|
|
|
660
584
|
}
|
|
661
585
|
|
|
662
586
|
for (const inputRecord of WalStream.getQueryData(rows)) {
|
|
663
|
-
const record = this.syncRulesRecord(inputRecord);
|
|
587
|
+
const record = this.syncRulesRecord(this.connections.types.constructRowRecord(columnMap, inputRecord));
|
|
664
588
|
// This auto-flushes when the batch reaches its size limit
|
|
665
589
|
await batch.save({
|
|
666
590
|
tag: storage.SaveOperationTag.INSERT,
|
|
@@ -915,6 +839,17 @@ WHERE oid = $1::regclass`,
|
|
|
915
839
|
}
|
|
916
840
|
|
|
917
841
|
async streamChanges(replicationConnection: pgwire.PgConnection) {
|
|
842
|
+
try {
|
|
843
|
+
await this.streamChangesInternal(replicationConnection);
|
|
844
|
+
} catch (e) {
|
|
845
|
+
if (isReplicationSlotInvalidError(e)) {
|
|
846
|
+
throw new MissingReplicationSlotError(e.message, e);
|
|
847
|
+
}
|
|
848
|
+
throw e;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
private async streamChangesInternal(replicationConnection: pgwire.PgConnection) {
|
|
918
853
|
// When changing any logic here, check /docs/wal-lsns.md.
|
|
919
854
|
const { createEmptyCheckpoints } = await this.ensureStorageCompatibility();
|
|
920
855
|
|
|
@@ -1179,3 +1114,27 @@ WHERE oid = $1::regclass`,
|
|
|
1179
1114
|
});
|
|
1180
1115
|
}
|
|
1181
1116
|
}
|
|
1117
|
+
|
|
1118
|
+
function isReplicationSlotInvalidError(e: any) {
|
|
1119
|
+
// We could access the error code from pgwire using this:
|
|
1120
|
+
// e[Symbol.for('pg.ErrorCode')]
|
|
1121
|
+
// However, we typically get a generic code such as 42704 (undefined_object), which does not
|
|
1122
|
+
// help much. So we check the actual error message.
|
|
1123
|
+
const message = e.message ?? '';
|
|
1124
|
+
|
|
1125
|
+
// Sample: record with incorrect prev-link 10000/10000 at 0/18AB778
|
|
1126
|
+
// Seen during development. Some internal error, fixed by re-creating slot.
|
|
1127
|
+
//
|
|
1128
|
+
// Sample: publication "powersync" does not exist
|
|
1129
|
+
// Happens when publication deleted or never created.
|
|
1130
|
+
// Slot must be re-created in this case.
|
|
1131
|
+
return (
|
|
1132
|
+
/incorrect prev-link/.test(message) ||
|
|
1133
|
+
/replication slot.*does not exist/.test(message) ||
|
|
1134
|
+
/publication.*does not exist/.test(message) ||
|
|
1135
|
+
// Postgres 18 - exceeded max_slot_wal_keep_size
|
|
1136
|
+
/can no longer access replication slot/.test(message) ||
|
|
1137
|
+
// Postgres 17 - exceeded max_slot_wal_keep_size
|
|
1138
|
+
/can no longer get changes from replication slot/.test(message)
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
@@ -12,19 +12,13 @@ export interface WalStreamReplicationJobOptions extends replication.AbstractRepl
|
|
|
12
12
|
|
|
13
13
|
export class WalStreamReplicationJob extends replication.AbstractReplicationJob {
|
|
14
14
|
private connectionFactory: ConnectionManagerFactory;
|
|
15
|
-
private
|
|
15
|
+
private connectionManager: PgManager | null = null;
|
|
16
16
|
private lastStream: WalStream | null = null;
|
|
17
17
|
|
|
18
18
|
constructor(options: WalStreamReplicationJobOptions) {
|
|
19
19
|
super(options);
|
|
20
20
|
this.logger = logger.child({ prefix: `[${this.slotName}] ` });
|
|
21
21
|
this.connectionFactory = options.connectionFactory;
|
|
22
|
-
this.connectionManager = this.connectionFactory.create({
|
|
23
|
-
// Pool connections are only used intermittently.
|
|
24
|
-
idleTimeout: 30_000,
|
|
25
|
-
maxSize: 2,
|
|
26
|
-
applicationName: getApplicationName()
|
|
27
|
-
});
|
|
28
22
|
}
|
|
29
23
|
|
|
30
24
|
/**
|
|
@@ -40,10 +34,12 @@ export class WalStreamReplicationJob extends replication.AbstractReplicationJob
|
|
|
40
34
|
* **This may be a bug in pgwire or how we're using it.
|
|
41
35
|
*/
|
|
42
36
|
async keepAlive() {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
37
|
+
if (this.connectionManager) {
|
|
38
|
+
try {
|
|
39
|
+
await sendKeepAlive(this.connectionManager.pool);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
this.logger.warn(`KeepAlive failed, unable to post to WAL`, e);
|
|
42
|
+
}
|
|
47
43
|
}
|
|
48
44
|
}
|
|
49
45
|
|
|
@@ -53,35 +49,58 @@ export class WalStreamReplicationJob extends replication.AbstractReplicationJob
|
|
|
53
49
|
|
|
54
50
|
async replicate() {
|
|
55
51
|
try {
|
|
56
|
-
await this.
|
|
52
|
+
await this.replicateOnce();
|
|
57
53
|
} catch (e) {
|
|
58
54
|
// Fatal exception
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
55
|
+
|
|
56
|
+
if (!this.isStopped) {
|
|
57
|
+
// Ignore aborted errors
|
|
58
|
+
|
|
59
|
+
this.logger.error(`Replication error`, e);
|
|
60
|
+
if (e.cause != null) {
|
|
61
|
+
// Example:
|
|
62
|
+
// PgError.conn_ended: Unable to do postgres query on ended connection
|
|
63
|
+
// at PgConnection.stream (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:315:13)
|
|
64
|
+
// at stream.next (<anonymous>)
|
|
65
|
+
// at PgResult.fromStream (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:1174:22)
|
|
66
|
+
// at PgConnection.query (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:311:21)
|
|
67
|
+
// at WalStream.startInitialReplication (file:///.../powersync/powersync-service/lib/replication/WalStream.js:266:22)
|
|
68
|
+
// ...
|
|
69
|
+
// cause: TypeError: match is not iterable
|
|
70
|
+
// at timestamptzToSqlite (file:///.../powersync/packages/jpgwire/dist/util.js:140:50)
|
|
71
|
+
// at PgType.decode (file:///.../powersync/packages/jpgwire/dist/pgwire_types.js:25:24)
|
|
72
|
+
// at PgConnection._recvDataRow (file:///.../powersync/packages/jpgwire/dist/util.js:88:22)
|
|
73
|
+
// at PgConnection._recvMessages (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:656:30)
|
|
74
|
+
// at PgConnection._ioloopAttempt (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:563:20)
|
|
75
|
+
// at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
|
|
76
|
+
// at async PgConnection._ioloop (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:517:14),
|
|
77
|
+
// [Symbol(pg.ErrorCode)]: 'conn_ended',
|
|
78
|
+
// [Symbol(pg.ErrorResponse)]: undefined
|
|
79
|
+
// }
|
|
80
|
+
// Without this additional log, the cause would not be visible in the logs.
|
|
81
|
+
this.logger.error(`cause`, e.cause);
|
|
62
82
|
}
|
|
63
|
-
|
|
64
|
-
|
|
83
|
+
// Report the error if relevant, before retrying
|
|
84
|
+
container.reporter.captureException(e, {
|
|
85
|
+
metadata: {
|
|
86
|
+
replication_slot: this.slotName
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
// This sets the retry delay
|
|
90
|
+
this.rateLimiter.reportError(e);
|
|
91
|
+
}
|
|
65
92
|
|
|
66
93
|
if (e instanceof MissingReplicationSlotError) {
|
|
67
94
|
// This stops replication on this slot and restarts with a new slot
|
|
68
95
|
await this.options.storage.factory.restartReplication(this.storage.group_id);
|
|
69
96
|
}
|
|
97
|
+
|
|
98
|
+
// No need to rethrow - the error is already logged, and retry behavior is the same on error
|
|
70
99
|
} finally {
|
|
71
100
|
this.abortController.abort();
|
|
72
101
|
}
|
|
73
102
|
}
|
|
74
103
|
|
|
75
|
-
async replicateLoop() {
|
|
76
|
-
while (!this.isStopped) {
|
|
77
|
-
await this.replicateOnce();
|
|
78
|
-
|
|
79
|
-
if (!this.isStopped) {
|
|
80
|
-
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
104
|
async replicateOnce() {
|
|
86
105
|
// New connections on every iteration (every error with retry),
|
|
87
106
|
// otherwise we risk repeating errors related to the connection,
|
|
@@ -92,6 +111,7 @@ export class WalStreamReplicationJob extends replication.AbstractReplicationJob
|
|
|
92
111
|
maxSize: 2,
|
|
93
112
|
applicationName: getApplicationName()
|
|
94
113
|
});
|
|
114
|
+
this.connectionManager = connectionManager;
|
|
95
115
|
try {
|
|
96
116
|
await this.rateLimiter?.waitUntilAllowed({ signal: this.abortController.signal });
|
|
97
117
|
if (this.isStopped) {
|
|
@@ -106,48 +126,8 @@ export class WalStreamReplicationJob extends replication.AbstractReplicationJob
|
|
|
106
126
|
});
|
|
107
127
|
this.lastStream = stream;
|
|
108
128
|
await stream.replicate();
|
|
109
|
-
} catch (e) {
|
|
110
|
-
if (this.isStopped && e instanceof ReplicationAbortedError) {
|
|
111
|
-
// Ignore aborted errors
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
this.logger.error(`Replication error`, e);
|
|
115
|
-
if (e.cause != null) {
|
|
116
|
-
// Example:
|
|
117
|
-
// PgError.conn_ended: Unable to do postgres query on ended connection
|
|
118
|
-
// at PgConnection.stream (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:315:13)
|
|
119
|
-
// at stream.next (<anonymous>)
|
|
120
|
-
// at PgResult.fromStream (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:1174:22)
|
|
121
|
-
// at PgConnection.query (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:311:21)
|
|
122
|
-
// at WalStream.startInitialReplication (file:///.../powersync/powersync-service/lib/replication/WalStream.js:266:22)
|
|
123
|
-
// ...
|
|
124
|
-
// cause: TypeError: match is not iterable
|
|
125
|
-
// at timestamptzToSqlite (file:///.../powersync/packages/jpgwire/dist/util.js:140:50)
|
|
126
|
-
// at PgType.decode (file:///.../powersync/packages/jpgwire/dist/pgwire_types.js:25:24)
|
|
127
|
-
// at PgConnection._recvDataRow (file:///.../powersync/packages/jpgwire/dist/util.js:88:22)
|
|
128
|
-
// at PgConnection._recvMessages (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:656:30)
|
|
129
|
-
// at PgConnection._ioloopAttempt (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:563:20)
|
|
130
|
-
// at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
|
|
131
|
-
// at async PgConnection._ioloop (file:///.../powersync/node_modules/.pnpm/github.com+kagis+pgwire@f1cb95f9a0f42a612bb5a6b67bb2eb793fc5fc87/node_modules/pgwire/mod.js:517:14),
|
|
132
|
-
// [Symbol(pg.ErrorCode)]: 'conn_ended',
|
|
133
|
-
// [Symbol(pg.ErrorResponse)]: undefined
|
|
134
|
-
// }
|
|
135
|
-
// Without this additional log, the cause would not be visible in the logs.
|
|
136
|
-
this.logger.error(`cause`, e.cause);
|
|
137
|
-
}
|
|
138
|
-
if (e instanceof MissingReplicationSlotError) {
|
|
139
|
-
throw e;
|
|
140
|
-
} else {
|
|
141
|
-
// Report the error if relevant, before retrying
|
|
142
|
-
container.reporter.captureException(e, {
|
|
143
|
-
metadata: {
|
|
144
|
-
replication_slot: this.slotName
|
|
145
|
-
}
|
|
146
|
-
});
|
|
147
|
-
// This sets the retry delay
|
|
148
|
-
this.rateLimiter?.reportError(e);
|
|
149
|
-
}
|
|
150
129
|
} finally {
|
|
130
|
+
this.connectionManager = null;
|
|
151
131
|
await connectionManager.end();
|
|
152
132
|
}
|
|
153
133
|
}
|
package/src/types/resolver.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { DatabaseInputRow, SqliteInputRow, toSyncRulesRow } from '@powersync/service-sync-rules';
|
|
2
1
|
import * as pgwire from '@powersync/service-jpgwire';
|
|
3
|
-
import {
|
|
2
|
+
import { DatabaseInputRow, SqliteInputRow, toSyncRulesRow } from '@powersync/service-sync-rules';
|
|
4
3
|
import semver from 'semver';
|
|
5
4
|
import { getServerVersion } from '../utils/postgres_version.js';
|
|
5
|
+
import { CustomTypeRegistry } from './registry.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Resolves descriptions used to decode values for custom postgres types.
|
|
@@ -11,11 +11,9 @@ import { getServerVersion } from '../utils/postgres_version.js';
|
|
|
11
11
|
*/
|
|
12
12
|
export class PostgresTypeResolver {
|
|
13
13
|
private cachedVersion: semver.SemVer | null = null;
|
|
14
|
+
readonly registry: CustomTypeRegistry;
|
|
14
15
|
|
|
15
|
-
constructor(
|
|
16
|
-
readonly registry: CustomTypeRegistry,
|
|
17
|
-
private readonly pool: pgwire.PgClient
|
|
18
|
-
) {
|
|
16
|
+
constructor(private readonly pool: pgwire.PgClient) {
|
|
19
17
|
this.registry = new CustomTypeRegistry();
|
|
20
18
|
}
|
|
21
19
|
|
|
@@ -188,6 +186,11 @@ WHERE a.attnum > 0
|
|
|
188
186
|
return toSyncRulesRow(record);
|
|
189
187
|
}
|
|
190
188
|
|
|
189
|
+
constructRowRecord(columnMap: Record<string, number>, tupleRaw: Record<string, any>): SqliteInputRow {
|
|
190
|
+
const record = this.decodeTupleForTable(columnMap, tupleRaw);
|
|
191
|
+
return toSyncRulesRow(record);
|
|
192
|
+
}
|
|
193
|
+
|
|
191
194
|
/**
|
|
192
195
|
* We need a high level of control over how values are decoded, to make sure there is no loss
|
|
193
196
|
* of precision in the process.
|
|
@@ -206,5 +209,23 @@ WHERE a.attnum > 0
|
|
|
206
209
|
return result;
|
|
207
210
|
}
|
|
208
211
|
|
|
212
|
+
/**
|
|
213
|
+
* We need a high level of control over how values are decoded, to make sure there is no loss
|
|
214
|
+
* of precision in the process.
|
|
215
|
+
*/
|
|
216
|
+
private decodeTupleForTable(columnMap: Record<string, number>, tupleRaw: Record<string, any>): DatabaseInputRow {
|
|
217
|
+
let result: Record<string, any> = {};
|
|
218
|
+
for (let columnName in tupleRaw) {
|
|
219
|
+
const rawval = tupleRaw[columnName];
|
|
220
|
+
const typeOid = columnMap[columnName];
|
|
221
|
+
if (typeof rawval == 'string' && typeOid) {
|
|
222
|
+
result[columnName] = this.registry.decodeDatabaseValue(rawval, typeOid);
|
|
223
|
+
} else {
|
|
224
|
+
result[columnName] = rawval;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
|
|
209
230
|
private static minVersionForMultirange: semver.SemVer = semver.parse('14.0.0')!;
|
|
210
231
|
}
|
package/src/types/types.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import * as lib_postgres from '@powersync/lib-service-postgres';
|
|
2
2
|
import * as service_types from '@powersync/service-types';
|
|
3
3
|
import * as t from 'ts-codec';
|
|
4
|
-
import { CustomTypeRegistry } from './registry.js';
|
|
5
4
|
|
|
6
5
|
// Maintain backwards compatibility by exporting these
|
|
7
6
|
export const validatePort = lib_postgres.validatePort;
|
|
@@ -25,10 +24,7 @@ export type PostgresConnectionConfig = t.Decoded<typeof PostgresConnectionConfig
|
|
|
25
24
|
/**
|
|
26
25
|
* Resolved version of {@link PostgresConnectionConfig}
|
|
27
26
|
*/
|
|
28
|
-
export type ResolvedConnectionConfig = PostgresConnectionConfig &
|
|
29
|
-
NormalizedPostgresConnectionConfig & {
|
|
30
|
-
typeRegistry: CustomTypeRegistry;
|
|
31
|
-
};
|
|
27
|
+
export type ResolvedConnectionConfig = PostgresConnectionConfig & NormalizedPostgresConnectionConfig;
|
|
32
28
|
|
|
33
29
|
export function isPostgresConfig(
|
|
34
30
|
config: service_types.configFile.DataSourceConfig
|