@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.
@@ -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
- notification?: (payload: pgwire.PgNotification) => void;
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
- protected async connect() {
50
- this.connectingPromise = pgwire.connectPgWire(this.options.config, {
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
- protected async configureConnectionNotifications(connection: pgwire.PgConnection) {
74
- connection.onnotification = this.handleNotification;
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
- for (const channelName of this.options.notificationChannels ?? []) {
77
- await connection.query({
78
- statement: `LISTEN ${channelName}`
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 handleNotification = (payload: pgwire.PgNotification) => {
84
- if (!this.options.notificationChannels?.includes(payload.channel)) {
85
- return;
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
- protected hasNotificationListener() {
91
- return !!Object.values(this.listeners).find((l) => !!l.notification);
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
- * Test the connection if it can be reached.
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
- await connection.query({
107
- statement: 'SELECT 1'
108
- });
168
+ // Clear the slot reference, marking the slot as unavailable
169
+ this.connection = null;
109
170
 
110
- if (!this.connection) {
111
- this.connection = connection;
112
- this.setAvailable();
113
- } else if (this.isAvailable) {
114
- this.iterateListeners((cb) => cb.connectionAvailable?.());
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
- // Connection is alive and healthy
118
- break;
119
- } catch (ex) {
120
- // Should be valid for all cases
121
- this.isAvailable = false;
122
- if (this.connection) {
123
- this.connection.onnotification = () => {};
124
- this.connection.destroy();
125
- this.connection = null;
126
- }
127
- if (retryCounter >= MAX_CONNECTION_ATTEMPTS) {
128
- this.iterateListeners((cb) => cb.connectionError?.(ex));
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 setAvailable() {
136
- this.isAvailable = true;
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.isAvailable = false;
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.setAvailable();
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.processConnectionQueue(),
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 ('notification' in listener) {
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['notification'];
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
- // Poke the slots to check if they are alive
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
- * Reports connection errors which might occur from bad configuration or
249
- * a server which is no longer available.
250
- * This fails all pending requests.
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
  }