@powersync/service-module-postgres 0.16.10 → 0.16.11
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 +13 -0
- package/dist/replication/ConnectionManagerFactory.js +8 -4
- package/dist/replication/ConnectionManagerFactory.js.map +1 -1
- package/dist/replication/PgManager.d.ts +5 -1
- package/dist/replication/PgManager.js +15 -5
- 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 +64 -95
- 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/package.json +5 -5
- package/src/replication/ConnectionManagerFactory.ts +9 -4
- package/src/replication/PgManager.ts +19 -5
- package/src/replication/PostgresErrorRateLimiter.ts +5 -1
- package/src/replication/WalStream.ts +71 -116
- package/src/replication/WalStreamReplicationJob.ts +48 -68
- package/test/src/wal_stream.test.ts +15 -7
- package/test/src/wal_stream_utils.ts +23 -4
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
}
|
|
@@ -295,7 +295,7 @@ bucket_definitions:
|
|
|
295
295
|
`INSERT INTO test_data(id, description) VALUES('8133cd37-903b-4937-a022-7c8294015a3a', 'test1') returning id as test_id`
|
|
296
296
|
);
|
|
297
297
|
await context.replicateSnapshot();
|
|
298
|
-
|
|
298
|
+
context.startStreaming();
|
|
299
299
|
|
|
300
300
|
const data = await context.getBucketData('global[]');
|
|
301
301
|
|
|
@@ -320,17 +320,25 @@ bucket_definitions:
|
|
|
320
320
|
|
|
321
321
|
await context.loadActiveSyncRules();
|
|
322
322
|
|
|
323
|
+
// Previously, the `replicateSnapshot` call picked up on this error.
|
|
324
|
+
// Now, we have removed that check, this only comes up when we start actually streaming.
|
|
325
|
+
// We don't get the streaming response directly here, but getCheckpoint() checks for that.
|
|
326
|
+
await context.replicateSnapshot();
|
|
327
|
+
context.startStreaming();
|
|
328
|
+
|
|
323
329
|
if (serverVersion!.compareMain('18.0.0') >= 0) {
|
|
324
|
-
await context.replicateSnapshot();
|
|
325
330
|
// No error expected in Postres 18. Replication keeps on working depite the
|
|
326
331
|
// publication being re-created.
|
|
332
|
+
await context.getCheckpoint();
|
|
327
333
|
} else {
|
|
334
|
+
// await context.getCheckpoint();
|
|
328
335
|
// Postgres < 18 invalidates the replication slot when the publication is re-created.
|
|
329
|
-
//
|
|
336
|
+
// In the service, this error is handled in WalStreamReplicationJob,
|
|
330
337
|
// creating a new replication slot.
|
|
331
338
|
await expect(async () => {
|
|
332
|
-
await context.
|
|
339
|
+
await context.getCheckpoint();
|
|
333
340
|
}).rejects.toThrowError(MissingReplicationSlotError);
|
|
341
|
+
context.clearStreamError();
|
|
334
342
|
}
|
|
335
343
|
}
|
|
336
344
|
});
|
|
@@ -352,7 +360,7 @@ bucket_definitions:
|
|
|
352
360
|
`INSERT INTO test_data(id, description) VALUES('8133cd37-903b-4937-a022-7c8294015a3a', 'test1') returning id as test_id`
|
|
353
361
|
);
|
|
354
362
|
await context.replicateSnapshot();
|
|
355
|
-
|
|
363
|
+
context.startStreaming();
|
|
356
364
|
|
|
357
365
|
const data = await context.getBucketData('global[]');
|
|
358
366
|
|
|
@@ -415,7 +423,7 @@ bucket_definitions:
|
|
|
415
423
|
`INSERT INTO test_data(id, description) VALUES('8133cd37-903b-4937-a022-7c8294015a3a', 'test1') returning id as test_id`
|
|
416
424
|
);
|
|
417
425
|
await context.replicateSnapshot();
|
|
418
|
-
|
|
426
|
+
context.startStreaming();
|
|
419
427
|
|
|
420
428
|
const data = await context.getBucketData('global[]');
|
|
421
429
|
|
|
@@ -572,7 +580,7 @@ config:
|
|
|
572
580
|
);
|
|
573
581
|
|
|
574
582
|
await context.replicateSnapshot();
|
|
575
|
-
|
|
583
|
+
context.startStreaming();
|
|
576
584
|
|
|
577
585
|
await pool.query(`UPDATE test_data SET description = 'test2' WHERE id = '${test_id}'`);
|
|
578
586
|
|
|
@@ -55,12 +55,31 @@ export class WalStreamTestContext implements AsyncDisposable {
|
|
|
55
55
|
await this.dispose();
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Clear any errors from startStream, to allow for a graceful dispose when streaming errors
|
|
60
|
+
* were expected.
|
|
61
|
+
*/
|
|
62
|
+
async clearStreamError() {
|
|
63
|
+
if (this.streamPromise != null) {
|
|
64
|
+
this.streamPromise = this.streamPromise.catch((e) => {});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
async dispose() {
|
|
59
69
|
this.abortController.abort();
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
70
|
+
try {
|
|
71
|
+
await this.snapshotPromise;
|
|
72
|
+
await this.streamPromise;
|
|
73
|
+
await this.connectionManager.destroy();
|
|
74
|
+
await this.factory?.[Symbol.asyncDispose]();
|
|
75
|
+
} catch (e) {
|
|
76
|
+
// Throwing here may result in SuppressedError. The underlying errors often don't show up
|
|
77
|
+
// in the test output, so we log it here.
|
|
78
|
+
// If we could get vitest to log SuppressedError.error and SuppressedError.suppressed, we
|
|
79
|
+
// could remove this.
|
|
80
|
+
console.error('Error during WalStreamTestContext dispose', e);
|
|
81
|
+
throw e;
|
|
82
|
+
}
|
|
64
83
|
}
|
|
65
84
|
|
|
66
85
|
get pool() {
|