@powersync/lib-service-postgres 0.5.2 → 0.5.3
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 +11 -0
- package/dist/db/connection/ConnectionSlot.d.ts +22 -8
- package/dist/db/connection/ConnectionSlot.js +129 -64
- package/dist/db/connection/ConnectionSlot.js.map +1 -1
- package/dist/db/connection/DatabaseClient.d.ts +7 -4
- package/dist/db/connection/DatabaseClient.js +34 -12
- package/dist/db/connection/DatabaseClient.js.map +1 -1
- package/package.json +4 -4
- package/src/db/connection/ConnectionSlot.ts +149 -69
- package/src/db/connection/DatabaseClient.ts +36 -12
- package/test/src/ConnectionSlot.test.ts +311 -0
- package/test/src/DatabaseClient.test.ts +43 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import * as framework from '@powersync/lib-services-framework';
|
|
2
2
|
import * as pgwire from '@powersync/service-jpgwire';
|
|
3
|
+
import * as timers from 'node:timers/promises';
|
|
4
|
+
|
|
5
|
+
export type NotificationEvent =
|
|
6
|
+
| { type: 'notification'; notification: pgwire.PgNotification }
|
|
7
|
+
| { type: 'channels-registered' }
|
|
8
|
+
| { type: 'connection-error'; error: unknown };
|
|
3
9
|
|
|
4
10
|
export interface NotificationListener {
|
|
5
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Reports notifications and notification-connection lifecycle events.
|
|
13
|
+
*/
|
|
14
|
+
notificationEvent?: (event: NotificationEvent) => void;
|
|
6
15
|
}
|
|
7
16
|
|
|
8
17
|
export interface ConnectionSlotListener extends NotificationListener {
|
|
9
18
|
connectionAvailable?: () => void;
|
|
19
|
+
/** Reports that this slot exhausted its connection attempts. */
|
|
10
20
|
connectionError?: (exception: any) => void;
|
|
11
21
|
connectionCreated?: (connection: pgwire.PgConnection) => Promise<void>;
|
|
12
22
|
}
|
|
@@ -23,22 +33,23 @@ export type ConnectionSlotOptions = {
|
|
|
23
33
|
};
|
|
24
34
|
|
|
25
35
|
export const MAX_CONNECTION_ATTEMPTS = 5;
|
|
36
|
+
const CONNECTION_RETRY_DELAY_MS = 100;
|
|
26
37
|
|
|
27
38
|
export class ConnectionSlot extends framework.BaseObserver<ConnectionSlotListener> {
|
|
28
|
-
isAvailable: boolean;
|
|
29
39
|
isPoking: boolean;
|
|
30
40
|
|
|
31
41
|
closed: boolean;
|
|
32
42
|
|
|
33
43
|
protected connection: pgwire.PgConnection | null;
|
|
34
44
|
protected connectingPromise: Promise<pgwire.PgConnection> | null;
|
|
45
|
+
protected activeLease: symbol | null;
|
|
35
46
|
|
|
36
47
|
constructor(protected options: ConnectionSlotOptions) {
|
|
37
48
|
super();
|
|
38
|
-
this.isAvailable = false;
|
|
39
49
|
this.connection = null;
|
|
40
50
|
this.isPoking = false;
|
|
41
51
|
this.connectingPromise = null;
|
|
52
|
+
this.activeLease = null;
|
|
42
53
|
this.closed = false;
|
|
43
54
|
}
|
|
44
55
|
|
|
@@ -46,21 +57,8 @@ export class ConnectionSlot extends framework.BaseObserver<ConnectionSlotListene
|
|
|
46
57
|
return !!this.connection;
|
|
47
58
|
}
|
|
48
59
|
|
|
49
|
-
|
|
50
|
-
this.
|
|
51
|
-
type: 'standard',
|
|
52
|
-
applicationName: this.options.applicationName
|
|
53
|
-
});
|
|
54
|
-
const connection = await this.connectingPromise;
|
|
55
|
-
this.connectingPromise = null;
|
|
56
|
-
await this.iterateAsyncListeners(async (l) => l.connectionCreated?.(connection));
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Configure the Postgres connection to listen to notifications.
|
|
60
|
-
* Subscribing to notifications, even without a registered listener, should not add much overhead.
|
|
61
|
-
*/
|
|
62
|
-
await this.configureConnectionNotifications(connection);
|
|
63
|
-
return connection;
|
|
60
|
+
get isAvailable() {
|
|
61
|
+
return this.connection != null && this.activeLease == null && !this.closed;
|
|
64
62
|
}
|
|
65
63
|
|
|
66
64
|
async [Symbol.asyncDispose]() {
|
|
@@ -70,84 +68,166 @@ export class ConnectionSlot extends framework.BaseObserver<ConnectionSlotListene
|
|
|
70
68
|
super.clearListeners();
|
|
71
69
|
}
|
|
72
70
|
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Ensure this slot has a connection and signal when it can be leased.
|
|
73
|
+
*/
|
|
74
|
+
async poke() {
|
|
75
|
+
if (this.closed || this.connection || this.isPoking) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
75
78
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
this.isPoking = true;
|
|
80
|
+
try {
|
|
81
|
+
for (let retryCounter = 0; retryCounter <= MAX_CONNECTION_ATTEMPTS; retryCounter++) {
|
|
82
|
+
try {
|
|
83
|
+
const connection = await this.connect();
|
|
84
|
+
|
|
85
|
+
if (this.closed) {
|
|
86
|
+
connection.destroy();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.connection = connection;
|
|
91
|
+
|
|
92
|
+
// Register this only after the slot owns the connection. If
|
|
93
|
+
// `whenDestroyed` has already settled, its callback runs in a later
|
|
94
|
+
// microtask and must see this exact connection assigned. Registering
|
|
95
|
+
// it before assignment could ignore the destruction and leave the
|
|
96
|
+
// slot holding an already-destroyed connection.
|
|
97
|
+
connection.whenDestroyed
|
|
98
|
+
.catch((error) => framework.logger.debug('Postgres connection destroyed with an error', error))
|
|
99
|
+
.then(() => this.handleConnectionDestroyed(connection));
|
|
100
|
+
|
|
101
|
+
if (this.activeLease == null) {
|
|
102
|
+
this.notifyConnectionAvailable();
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
} catch (ex) {
|
|
106
|
+
if (retryCounter >= MAX_CONNECTION_ATTEMPTS) {
|
|
107
|
+
this.iterateListeners((cb) => {
|
|
108
|
+
cb.connectionError?.(ex);
|
|
109
|
+
cb.notificationEvent?.({ type: 'connection-error', error: ex });
|
|
110
|
+
});
|
|
111
|
+
} else {
|
|
112
|
+
await timers.setTimeout(CONNECTION_RETRY_DELAY_MS);
|
|
113
|
+
if (this.closed) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} finally {
|
|
120
|
+
this.isPoking = false;
|
|
80
121
|
}
|
|
81
122
|
}
|
|
82
123
|
|
|
83
|
-
protected
|
|
84
|
-
|
|
85
|
-
|
|
124
|
+
protected async connect() {
|
|
125
|
+
// Only allow a single connect to run at-a-time
|
|
126
|
+
if (this.connectingPromise) {
|
|
127
|
+
return this.connectingPromise;
|
|
86
128
|
}
|
|
87
|
-
this.iterateListeners((l) => l.notification?.(payload));
|
|
88
|
-
};
|
|
89
129
|
|
|
90
|
-
|
|
91
|
-
|
|
130
|
+
const connectInternal = async () => {
|
|
131
|
+
let connection: pgwire.PgConnection | null = null;
|
|
132
|
+
try {
|
|
133
|
+
const connected = await pgwire.connectPgWire(this.options.config, {
|
|
134
|
+
type: 'standard',
|
|
135
|
+
applicationName: this.options.applicationName
|
|
136
|
+
});
|
|
137
|
+
connection = connected;
|
|
138
|
+
|
|
139
|
+
await this.iterateAsyncListeners(async (l) => l.connectionCreated?.(connected));
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Configure the Postgres connection to listen to notifications.
|
|
143
|
+
* Subscribing to notifications, even without a registered listener, should not add much overhead.
|
|
144
|
+
*/
|
|
145
|
+
await this.configureConnectionNotifications(connected);
|
|
146
|
+
|
|
147
|
+
return connected;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
connection?.destroy();
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
this.connectingPromise = connectInternal();
|
|
155
|
+
try {
|
|
156
|
+
return await this.connectingPromise;
|
|
157
|
+
} finally {
|
|
158
|
+
this.connectingPromise = null;
|
|
159
|
+
}
|
|
92
160
|
}
|
|
93
161
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
async poke() {
|
|
98
|
-
if (this.isPoking || (this.isConnected && this.isAvailable == false) || this.closed) {
|
|
162
|
+
protected handleConnectionDestroyed(connection: pgwire.PgConnection) {
|
|
163
|
+
// Guard that the closed connection is actually the one in use by the slot.
|
|
164
|
+
if (this.connection != connection) {
|
|
99
165
|
return;
|
|
100
166
|
}
|
|
101
|
-
this.isPoking = true;
|
|
102
|
-
for (let retryCounter = 0; retryCounter <= MAX_CONNECTION_ATTEMPTS; retryCounter++) {
|
|
103
|
-
try {
|
|
104
|
-
const connection = this.connection ?? (await this.connect());
|
|
105
167
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
});
|
|
168
|
+
// Clear the slot reference, marking the slot as unavailable
|
|
169
|
+
this.connection = null;
|
|
109
170
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
171
|
+
// Notification slots must restore their LISTEN subscriptions immediately.
|
|
172
|
+
// Other slots reconnect lazily when the next lease is requested.
|
|
173
|
+
if (!this.closed && this.options.notificationChannels?.length) {
|
|
174
|
+
this.poke().catch((error) => framework.logger.error('Failed to restore Postgres notification connection', error));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
116
177
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
}
|
|
178
|
+
protected async configureConnectionNotifications(connection: pgwire.PgConnection) {
|
|
179
|
+
connection.onnotification = this.handleNotification;
|
|
180
|
+
|
|
181
|
+
const notificationChannels = this.options.notificationChannels ?? [];
|
|
182
|
+
for (const channelName of notificationChannels) {
|
|
183
|
+
await connection.query({
|
|
184
|
+
statement: `LISTEN ${channelName}`
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (notificationChannels.length > 0) {
|
|
189
|
+
this.iterateListeners((l) => l.notificationEvent?.({ type: 'channels-registered' }));
|
|
131
190
|
}
|
|
132
|
-
this.isPoking = false;
|
|
133
191
|
}
|
|
134
192
|
|
|
135
|
-
protected
|
|
136
|
-
this.
|
|
193
|
+
protected handleNotification = (payload: pgwire.PgNotification) => {
|
|
194
|
+
if (!this.options.notificationChannels?.includes(payload.channel)) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.iterateListeners((l) => l.notificationEvent?.({ type: 'notification', notification: payload }));
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
protected notifyConnectionAvailable() {
|
|
137
201
|
this.iterateListeners((l) => l.connectionAvailable?.());
|
|
138
202
|
}
|
|
139
203
|
|
|
140
204
|
lock(): ConnectionLease | null {
|
|
141
|
-
if (!this.isAvailable || !this.connection || this.closed) {
|
|
205
|
+
if (!this.isAvailable || !this.connection || this.activeLease != null || this.closed) {
|
|
142
206
|
return null;
|
|
143
207
|
}
|
|
144
208
|
|
|
145
|
-
this
|
|
209
|
+
// Create a unique symbol to identify this lease
|
|
210
|
+
const lease = Symbol();
|
|
211
|
+
this.activeLease = lease;
|
|
146
212
|
|
|
147
213
|
return {
|
|
148
214
|
connection: this.connection,
|
|
149
215
|
release: () => {
|
|
150
|
-
this
|
|
216
|
+
// Only release if this lease is the current active lease
|
|
217
|
+
if (this.activeLease != lease) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
this.activeLease = null;
|
|
221
|
+
if (this.closed) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (this.connection) {
|
|
225
|
+
this.notifyConnectionAvailable();
|
|
226
|
+
} else {
|
|
227
|
+
// The leased connection was destroyed. Reconnect now in case a
|
|
228
|
+
// request was queued while this slot still held the lease.
|
|
229
|
+
this.poke().catch((error) => framework.logger.error('Failed to restore Postgres connection', error));
|
|
230
|
+
}
|
|
151
231
|
}
|
|
152
232
|
};
|
|
153
233
|
}
|
|
@@ -42,6 +42,8 @@ export class DatabaseClient extends AbstractPostgresConnection<DatabaseClientLis
|
|
|
42
42
|
|
|
43
43
|
protected initialized: Promise<void>;
|
|
44
44
|
protected queue: PromiseWithResolvers<ConnectionLease>[];
|
|
45
|
+
/** Latest exhausted connection attempt for each slot in the current attempt round. */
|
|
46
|
+
protected failedConnectionSlots: Map<ConnectionSlot, unknown>;
|
|
45
47
|
|
|
46
48
|
constructor(protected options: DatabaseClientOptions) {
|
|
47
49
|
super();
|
|
@@ -50,6 +52,7 @@ export class DatabaseClient extends AbstractPostgresConnection<DatabaseClientLis
|
|
|
50
52
|
maxSize: options.config.max_pool_size,
|
|
51
53
|
applicationName: options.applicationName
|
|
52
54
|
});
|
|
55
|
+
this.failedConnectionSlots = new Map();
|
|
53
56
|
this.connections = Array.from({ length: TRANSACTION_CONNECTION_COUNT }, (v, index) => {
|
|
54
57
|
// Only listen to notifications on a single (the first) connection
|
|
55
58
|
const notificationChannels = index == 0 ? options.notificationChannels : [];
|
|
@@ -59,8 +62,8 @@ export class DatabaseClient extends AbstractPostgresConnection<DatabaseClientLis
|
|
|
59
62
|
applicationName: options.applicationName
|
|
60
63
|
});
|
|
61
64
|
slot.registerListener({
|
|
62
|
-
connectionAvailable: () => this.
|
|
63
|
-
connectionError: (ex) => this.handleConnectionError(ex),
|
|
65
|
+
connectionAvailable: () => this.handleConnectionAvailable(slot),
|
|
66
|
+
connectionError: (ex) => this.handleConnectionError(slot, ex),
|
|
64
67
|
connectionCreated: (connection) => this.iterateAsyncListeners(async (l) => l.connectionCreated?.(connection))
|
|
65
68
|
});
|
|
66
69
|
return slot;
|
|
@@ -85,15 +88,13 @@ export class DatabaseClient extends AbstractPostgresConnection<DatabaseClientLis
|
|
|
85
88
|
|
|
86
89
|
registerListener(listener: Partial<DatabaseClientListener>): () => void {
|
|
87
90
|
let disposeNotification: (() => void) | null = null;
|
|
88
|
-
if ('
|
|
91
|
+
if ('notificationEvent' in listener) {
|
|
89
92
|
// Pass this on to the first connection slot
|
|
90
93
|
// It will only actively listen on the connection once a listener has been registered
|
|
91
|
-
disposeNotification = this.connections[0].registerListener({
|
|
92
|
-
notification: listener.notification
|
|
93
|
-
});
|
|
94
|
+
disposeNotification = this.connections[0].registerListener({ notificationEvent: listener.notificationEvent });
|
|
94
95
|
this.pokeSlots();
|
|
95
96
|
|
|
96
|
-
delete listener
|
|
97
|
+
delete listener.notificationEvent;
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
const superDispose = super.registerListener(listener);
|
|
@@ -202,14 +203,23 @@ export class DatabaseClient extends AbstractPostgresConnection<DatabaseClientLis
|
|
|
202
203
|
const deferred = Promise.withResolvers<ConnectionLease>();
|
|
203
204
|
this.queue.push(deferred);
|
|
204
205
|
|
|
206
|
+
// Try already-connected slots first. poke() only creates missing
|
|
207
|
+
// connections and does not emit another availability event for an existing
|
|
208
|
+
// connection, so skipping this could leave the request queued indefinitely.
|
|
209
|
+
this.processConnectionQueue();
|
|
205
210
|
this.pokeSlots();
|
|
206
211
|
|
|
207
212
|
return deferred.promise;
|
|
208
213
|
}
|
|
209
214
|
|
|
210
215
|
protected pokeSlots() {
|
|
211
|
-
//
|
|
216
|
+
// Ensure the slots have connections and notify any queued requests.
|
|
212
217
|
for (const slot of this.connections) {
|
|
218
|
+
if (!slot.isConnected && !slot.isPoking) {
|
|
219
|
+
// This slot is starting a fresh attempt, so a failure from an earlier
|
|
220
|
+
// attempt must not count against the current request.
|
|
221
|
+
this.failedConnectionSlots.delete(slot);
|
|
222
|
+
}
|
|
213
223
|
// No need to await this. Errors are reported asynchronously
|
|
214
224
|
slot.poke();
|
|
215
225
|
}
|
|
@@ -244,12 +254,26 @@ export class DatabaseClient extends AbstractPostgresConnection<DatabaseClientLis
|
|
|
244
254
|
}
|
|
245
255
|
}
|
|
246
256
|
|
|
257
|
+
protected handleConnectionAvailable(slot: ConnectionSlot) {
|
|
258
|
+
// This slot recovered, so its earlier failure must no longer contribute to
|
|
259
|
+
// deciding whether the shared lease queue is unreachable.
|
|
260
|
+
this.failedConnectionSlots.delete(slot);
|
|
261
|
+
this.processConnectionQueue();
|
|
262
|
+
}
|
|
263
|
+
|
|
247
264
|
/**
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
265
|
+
* Lease requests wait in one shared queue and are not assigned to a slot
|
|
266
|
+
* until that slot becomes available. A failure from one slot therefore
|
|
267
|
+
* cannot fail a particular request: another slot may still serve it.
|
|
251
268
|
*/
|
|
252
|
-
protected handleConnectionError(exception: any) {
|
|
269
|
+
protected handleConnectionError(slot: ConnectionSlot, exception: any) {
|
|
270
|
+
this.failedConnectionSlots.set(slot, exception);
|
|
271
|
+
if (this.failedConnectionSlots.size < this.connections.length) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Every slot has exhausted its attempts, so no slot can currently make
|
|
276
|
+
// progress on the shared queue.
|
|
253
277
|
for (const q of this.queue) {
|
|
254
278
|
q.reject(exception);
|
|
255
279
|
}
|