@powersync/lib-service-postgres 0.5.1 → 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 +18 -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
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import * as pgwire from '@powersync/service-jpgwire';
|
|
2
|
+
import { expect, test, vi } from 'vitest';
|
|
3
|
+
import { ConnectionSlot, MAX_CONNECTION_ATTEMPTS } from '../../src/db/connection/ConnectionSlot.js';
|
|
4
|
+
|
|
5
|
+
test('reports an error when the notification connection cannot be restored', async () => {
|
|
6
|
+
const connectionError = new Error('connection unavailable');
|
|
7
|
+
const notificationEvent = vi.fn();
|
|
8
|
+
|
|
9
|
+
class FailingConnectionSlot extends ConnectionSlot {
|
|
10
|
+
attempts = 0;
|
|
11
|
+
|
|
12
|
+
protected override async connect(): Promise<pgwire.PgConnection> {
|
|
13
|
+
this.attempts++;
|
|
14
|
+
throw connectionError;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const slot = new FailingConnectionSlot({
|
|
19
|
+
config: {} as pgwire.NormalizedConnectionConfig,
|
|
20
|
+
notificationChannels: ['checkpoints'],
|
|
21
|
+
applicationName: 'test'
|
|
22
|
+
});
|
|
23
|
+
slot.registerListener({ notificationEvent });
|
|
24
|
+
|
|
25
|
+
await slot.poke();
|
|
26
|
+
|
|
27
|
+
expect(slot.attempts).toBe(MAX_CONNECTION_ATTEMPTS + 1);
|
|
28
|
+
expect(notificationEvent).toHaveBeenCalledOnce();
|
|
29
|
+
expect(notificationEvent).toHaveBeenCalledWith({ type: 'connection-error', error: connectionError });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('reports a slot connection error when background notification restoration fails', async () => {
|
|
33
|
+
const originalDestroyed = Promise.withResolvers<void>();
|
|
34
|
+
const originalConnection = {
|
|
35
|
+
whenDestroyed: originalDestroyed.promise
|
|
36
|
+
} as pgwire.PgConnection;
|
|
37
|
+
const connectionError = new Error('connection unavailable');
|
|
38
|
+
const generalConnectionError = vi.fn();
|
|
39
|
+
const notificationEvent = vi.fn();
|
|
40
|
+
|
|
41
|
+
class FailingNotificationSlot extends ConnectionSlot {
|
|
42
|
+
attempts = 0;
|
|
43
|
+
|
|
44
|
+
protected override async connect(): Promise<pgwire.PgConnection> {
|
|
45
|
+
if (this.attempts++ === 0) {
|
|
46
|
+
return originalConnection;
|
|
47
|
+
}
|
|
48
|
+
throw connectionError;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const slot = new FailingNotificationSlot({
|
|
53
|
+
config: {} as pgwire.NormalizedConnectionConfig,
|
|
54
|
+
notificationChannels: ['checkpoints'],
|
|
55
|
+
applicationName: 'test'
|
|
56
|
+
});
|
|
57
|
+
slot.registerListener({ connectionError: generalConnectionError, notificationEvent });
|
|
58
|
+
|
|
59
|
+
await slot.poke();
|
|
60
|
+
originalDestroyed.resolve();
|
|
61
|
+
|
|
62
|
+
await vi.waitFor(() => expect(notificationEvent).toHaveBeenCalledOnce());
|
|
63
|
+
expect(slot.attempts).toBe(MAX_CONNECTION_ATTEMPTS + 2);
|
|
64
|
+
expect(notificationEvent).toHaveBeenCalledWith({ type: 'connection-error', error: connectionError });
|
|
65
|
+
expect(generalConnectionError).toHaveBeenCalledOnce();
|
|
66
|
+
expect(generalConnectionError).toHaveBeenCalledWith(connectionError);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('keeps a replacement connection unavailable while the slot has an outstanding lease', async () => {
|
|
70
|
+
const originalDestroyed = Promise.withResolvers<void>();
|
|
71
|
+
const originalConnection = {
|
|
72
|
+
whenDestroyed: originalDestroyed.promise
|
|
73
|
+
} as pgwire.PgConnection;
|
|
74
|
+
const replacementConnection = {
|
|
75
|
+
whenDestroyed: new Promise<void>(() => {})
|
|
76
|
+
} as pgwire.PgConnection;
|
|
77
|
+
|
|
78
|
+
class TestConnectionSlot extends ConnectionSlot {
|
|
79
|
+
attempts = 0;
|
|
80
|
+
|
|
81
|
+
protected override async connect(): Promise<pgwire.PgConnection> {
|
|
82
|
+
return [originalConnection, replacementConnection][this.attempts++];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const slot = new TestConnectionSlot({
|
|
87
|
+
config: {} as pgwire.NormalizedConnectionConfig,
|
|
88
|
+
notificationChannels: ['checkpoints'],
|
|
89
|
+
applicationName: 'test'
|
|
90
|
+
});
|
|
91
|
+
await slot.poke();
|
|
92
|
+
const originalLease = slot.lock()!;
|
|
93
|
+
|
|
94
|
+
originalDestroyed.resolve();
|
|
95
|
+
await vi.waitFor(() => expect(slot.attempts).toBe(2));
|
|
96
|
+
await vi.waitFor(() => expect(slot.isConnected).toBe(true));
|
|
97
|
+
|
|
98
|
+
// Should not be able to get the lock, since it's in use.
|
|
99
|
+
expect(slot.lock()).toBeNull();
|
|
100
|
+
|
|
101
|
+
originalLease.release();
|
|
102
|
+
expect(slot.isAvailable).toBe(true);
|
|
103
|
+
|
|
104
|
+
const replacementLease = slot.lock()!;
|
|
105
|
+
expect(replacementLease.connection).toBe(replacementConnection);
|
|
106
|
+
replacementLease.release();
|
|
107
|
+
expect(slot.isAvailable).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('releasing a lease twice does not release a later lease', async () => {
|
|
111
|
+
const connection = {
|
|
112
|
+
whenDestroyed: new Promise<void>(() => {})
|
|
113
|
+
} as pgwire.PgConnection;
|
|
114
|
+
|
|
115
|
+
class TestConnectionSlot extends ConnectionSlot {
|
|
116
|
+
protected override async connect(): Promise<pgwire.PgConnection> {
|
|
117
|
+
return connection;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const slot = new TestConnectionSlot({
|
|
122
|
+
config: {} as pgwire.NormalizedConnectionConfig,
|
|
123
|
+
applicationName: 'test'
|
|
124
|
+
});
|
|
125
|
+
await slot.poke();
|
|
126
|
+
|
|
127
|
+
const firstLease = slot.lock()!;
|
|
128
|
+
firstLease.release();
|
|
129
|
+
const secondLease = slot.lock()!;
|
|
130
|
+
|
|
131
|
+
firstLease.release();
|
|
132
|
+
expect(slot.isAvailable).toBe(false);
|
|
133
|
+
|
|
134
|
+
secondLease.release();
|
|
135
|
+
expect(slot.isAvailable).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('resets the poking state after failed connection attempts', async () => {
|
|
139
|
+
class FailingConnectionSlot extends ConnectionSlot {
|
|
140
|
+
protected override async connect(): Promise<pgwire.PgConnection> {
|
|
141
|
+
throw new Error('connection failed');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const slot = new FailingConnectionSlot({
|
|
146
|
+
config: {} as pgwire.NormalizedConnectionConfig,
|
|
147
|
+
applicationName: 'test'
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await slot.poke();
|
|
151
|
+
|
|
152
|
+
expect(slot.isPoking).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('restores a destroyed connection without probing it', async () => {
|
|
156
|
+
const originalDestroyed = Promise.withResolvers<void>();
|
|
157
|
+
const originalConnection = {
|
|
158
|
+
whenDestroyed: originalDestroyed.promise
|
|
159
|
+
} as pgwire.PgConnection;
|
|
160
|
+
const replacementConnection = {
|
|
161
|
+
whenDestroyed: new Promise<void>(() => {})
|
|
162
|
+
} as pgwire.PgConnection;
|
|
163
|
+
|
|
164
|
+
class RestoringConnectionSlot extends ConnectionSlot {
|
|
165
|
+
attempts = 0;
|
|
166
|
+
|
|
167
|
+
protected override async connect(): Promise<pgwire.PgConnection> {
|
|
168
|
+
return [originalConnection, replacementConnection][this.attempts++];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const slot = new RestoringConnectionSlot({
|
|
173
|
+
config: {} as pgwire.NormalizedConnectionConfig,
|
|
174
|
+
notificationChannels: ['checkpoints'],
|
|
175
|
+
applicationName: 'test'
|
|
176
|
+
});
|
|
177
|
+
await slot.poke();
|
|
178
|
+
originalDestroyed.resolve();
|
|
179
|
+
|
|
180
|
+
await vi.waitFor(() => expect(slot.attempts).toBe(2));
|
|
181
|
+
await vi.waitFor(() => expect(slot.isAvailable).toBe(true));
|
|
182
|
+
const lease = slot.lock()!;
|
|
183
|
+
expect(lease.connection).toBe(replacementConnection);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('restores a transaction connection lazily after it is destroyed', async () => {
|
|
187
|
+
const originalDestroyed = Promise.withResolvers<void>();
|
|
188
|
+
const originalConnection = {
|
|
189
|
+
whenDestroyed: originalDestroyed.promise
|
|
190
|
+
} as pgwire.PgConnection;
|
|
191
|
+
const replacementConnection = {
|
|
192
|
+
whenDestroyed: new Promise<void>(() => {})
|
|
193
|
+
} as pgwire.PgConnection;
|
|
194
|
+
|
|
195
|
+
class LazyConnectionSlot extends ConnectionSlot {
|
|
196
|
+
attempts = 0;
|
|
197
|
+
|
|
198
|
+
protected override async connect(): Promise<pgwire.PgConnection> {
|
|
199
|
+
return [originalConnection, replacementConnection][this.attempts++];
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const slot = new LazyConnectionSlot({
|
|
204
|
+
config: {} as pgwire.NormalizedConnectionConfig,
|
|
205
|
+
applicationName: 'test'
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await slot.poke();
|
|
209
|
+
originalDestroyed.resolve();
|
|
210
|
+
await vi.waitFor(() => expect(slot.isConnected).toBe(false));
|
|
211
|
+
|
|
212
|
+
expect(slot.attempts).toBe(1);
|
|
213
|
+
|
|
214
|
+
await slot.poke();
|
|
215
|
+
expect(slot.attempts).toBe(2);
|
|
216
|
+
expect(slot.lock()?.connection).toBe(replacementConnection);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('restores a destroyed transaction connection when its lease is released', async () => {
|
|
220
|
+
const originalDestroyed = Promise.withResolvers<void>();
|
|
221
|
+
const originalConnection = {
|
|
222
|
+
whenDestroyed: originalDestroyed.promise
|
|
223
|
+
} as pgwire.PgConnection;
|
|
224
|
+
const replacementConnection = {
|
|
225
|
+
whenDestroyed: new Promise<void>(() => {})
|
|
226
|
+
} as pgwire.PgConnection;
|
|
227
|
+
|
|
228
|
+
class LazyConnectionSlot extends ConnectionSlot {
|
|
229
|
+
attempts = 0;
|
|
230
|
+
|
|
231
|
+
protected override async connect(): Promise<pgwire.PgConnection> {
|
|
232
|
+
return [originalConnection, replacementConnection][this.attempts++];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const slot = new LazyConnectionSlot({
|
|
237
|
+
config: {} as pgwire.NormalizedConnectionConfig,
|
|
238
|
+
applicationName: 'test'
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await slot.poke();
|
|
242
|
+
const lease = slot.lock()!;
|
|
243
|
+
originalDestroyed.resolve();
|
|
244
|
+
await vi.waitFor(() => expect(slot.isConnected).toBe(false));
|
|
245
|
+
|
|
246
|
+
lease.release();
|
|
247
|
+
|
|
248
|
+
await vi.waitFor(() => expect(slot.attempts).toBe(2));
|
|
249
|
+
await vi.waitFor(() => expect(slot.isAvailable).toBe(true));
|
|
250
|
+
expect(slot.lock()?.connection).toBe(replacementConnection);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('does not start another connection setup while poke is in progress', async () => {
|
|
254
|
+
const setupStarted = Promise.withResolvers<void>();
|
|
255
|
+
const allowSetup = Promise.withResolvers<void>();
|
|
256
|
+
const connection = {
|
|
257
|
+
whenDestroyed: new Promise<void>(() => {})
|
|
258
|
+
} as pgwire.PgConnection;
|
|
259
|
+
const connectPgWire = vi.spyOn(pgwire, 'connectPgWire').mockImplementation(async () => {
|
|
260
|
+
setupStarted.resolve();
|
|
261
|
+
await allowSetup.promise;
|
|
262
|
+
return connection;
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const slot = new ConnectionSlot({
|
|
266
|
+
config: {} as pgwire.NormalizedConnectionConfig,
|
|
267
|
+
applicationName: 'test'
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const first = slot.poke();
|
|
272
|
+
await setupStarted.promise;
|
|
273
|
+
const second = slot.poke();
|
|
274
|
+
allowSetup.resolve();
|
|
275
|
+
|
|
276
|
+
await Promise.all([first, second]);
|
|
277
|
+
expect(connectPgWire).toHaveBeenCalledOnce();
|
|
278
|
+
} finally {
|
|
279
|
+
connectPgWire.mockRestore();
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('destroys a connection that finishes connecting after the slot is disposed', async () => {
|
|
284
|
+
const setupStarted = Promise.withResolvers<void>();
|
|
285
|
+
const allowSetup = Promise.withResolvers<void>();
|
|
286
|
+
const connection = {
|
|
287
|
+
destroy: vi.fn()
|
|
288
|
+
} as unknown as pgwire.PgConnection;
|
|
289
|
+
|
|
290
|
+
class DisposedConnectionSlot extends ConnectionSlot {
|
|
291
|
+
protected override async connect(): Promise<pgwire.PgConnection> {
|
|
292
|
+
setupStarted.resolve();
|
|
293
|
+
await allowSetup.promise;
|
|
294
|
+
return connection;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const slot = new DisposedConnectionSlot({
|
|
299
|
+
config: {} as pgwire.NormalizedConnectionConfig,
|
|
300
|
+
applicationName: 'test'
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const poke = slot.poke();
|
|
304
|
+
await setupStarted.promise;
|
|
305
|
+
await slot[Symbol.asyncDispose]();
|
|
306
|
+
allowSetup.resolve();
|
|
307
|
+
await poke;
|
|
308
|
+
|
|
309
|
+
expect(connection.destroy).toHaveBeenCalledOnce();
|
|
310
|
+
expect(slot.isConnected).toBe(false);
|
|
311
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as lib_postgres from '@powersync/lib-service-postgres';
|
|
2
|
+
import { expect, test } from 'vitest';
|
|
3
|
+
import { ConnectionLease, ConnectionSlot } from '../../src/db/connection/ConnectionSlot.js';
|
|
4
|
+
import { DatabaseClient } from '../../src/db/connection/DatabaseClient.js';
|
|
5
|
+
|
|
6
|
+
test('rejects queued leases only after every connection slot fails', async () => {
|
|
7
|
+
class TestDatabaseClient extends DatabaseClient {
|
|
8
|
+
get slots() {
|
|
9
|
+
return this.connections;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
get queuedRequests() {
|
|
13
|
+
return this.queue.length;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
queueLease() {
|
|
17
|
+
const deferred = Promise.withResolvers<ConnectionLease>();
|
|
18
|
+
this.queue.push(deferred);
|
|
19
|
+
return deferred.promise;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
reportConnectionError(slot: ConnectionSlot, error: unknown) {
|
|
23
|
+
this.handleConnectionError(slot, error);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
await using client = new TestDatabaseClient({
|
|
28
|
+
config: {} as lib_postgres.NormalizedBasePostgresConnectionConfig,
|
|
29
|
+
applicationName: 'test'
|
|
30
|
+
});
|
|
31
|
+
const error = new Error('connection unavailable');
|
|
32
|
+
const pendingLease = client.queueLease();
|
|
33
|
+
const rejectedLease = expect(pendingLease).rejects.toBe(error);
|
|
34
|
+
|
|
35
|
+
for (const slot of client.slots.slice(0, -1)) {
|
|
36
|
+
client.reportConnectionError(slot, error);
|
|
37
|
+
}
|
|
38
|
+
expect(client.queuedRequests).toBe(1);
|
|
39
|
+
|
|
40
|
+
client.reportConnectionError(client.slots.at(-1)!, error);
|
|
41
|
+
await rejectedLease;
|
|
42
|
+
expect(client.queuedRequests).toBe(0);
|
|
43
|
+
});
|