@open-mercato/shared 0.9.1-develop.7573.1.d0ccc51d5b → 0.9.1-develop.7574.1.837288eec1

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.
@@ -0,0 +1,564 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import {
3
+ AdvisoryLockUnavailableError,
4
+ withAdvisoryXactLock,
5
+ type AdvisoryLockWaitResult,
6
+ } from '../advisoryLock'
7
+
8
+ type AdvisoryLockModule = typeof import('../advisoryLock')
9
+
10
+ const GLOBAL_SLOTS_KEY = '__openMercatoAdvisoryLockSlots__'
11
+ const LOCK_SQL = 'select pg_try_advisory_xact_lock(hashtextextended(?, 0)) as locked'
12
+ const LOCK_FORK_OPTIONS = {
13
+ disableContextResolution: true,
14
+ clear: true,
15
+ useContext: false,
16
+ disableTransactions: false,
17
+ cloneEventManager: true,
18
+ }
19
+
20
+ type TransactionFaults = {
21
+ begin?: unknown
22
+ lock?: unknown
23
+ commit?: unknown
24
+ }
25
+
26
+ type LockQuery = { sql: string; params: unknown[]; at: number }
27
+
28
+ type FakeDatabase = {
29
+ em: EntityManager
30
+ fork: jest.Mock
31
+ transactional: jest.Mock
32
+ heldKeys: Set<string>
33
+ lockQueries: LockQuery[]
34
+ outcomes: Array<'commit' | 'rollback'>
35
+ transactionEms: EntityManager[]
36
+ queueFaults: (faults: TransactionFaults) => void
37
+ openTransactions: () => number
38
+ }
39
+
40
+ function createFakeDatabase(): FakeDatabase {
41
+ const heldKeys = new Set<string>()
42
+ const lockQueries: LockQuery[] = []
43
+ const outcomes: Array<'commit' | 'rollback'> = []
44
+ const transactionEms: EntityManager[] = []
45
+ const pendingFaults: TransactionFaults[] = []
46
+ let open = 0
47
+
48
+ const transactional = jest.fn(async (callback: (txEm: EntityManager) => Promise<unknown>) => {
49
+ const faults = pendingFaults.shift() ?? {}
50
+ if (faults.begin !== undefined) throw faults.begin
51
+ open += 1
52
+ const ownedKeys: string[] = []
53
+ const finish = (outcome: 'commit' | 'rollback') => {
54
+ for (const ownedKey of ownedKeys) heldKeys.delete(ownedKey)
55
+ open -= 1
56
+ outcomes.push(outcome)
57
+ }
58
+ const txEm = {
59
+ execute: async (sql: string, params: unknown[]) => {
60
+ lockQueries.push({ sql, params, at: Date.now() })
61
+ if (faults.lock !== undefined) throw faults.lock
62
+ const lockKey = String(params[0])
63
+ if (heldKeys.has(lockKey)) return [{ locked: false }]
64
+ heldKeys.add(lockKey)
65
+ ownedKeys.push(lockKey)
66
+ return [{ locked: true }]
67
+ },
68
+ } as unknown as EntityManager
69
+ transactionEms.push(txEm)
70
+ let result: unknown
71
+ try {
72
+ result = await callback(txEm)
73
+ } catch (error) {
74
+ finish('rollback')
75
+ throw error
76
+ }
77
+ if (faults.commit !== undefined) {
78
+ finish('rollback')
79
+ throw faults.commit
80
+ }
81
+ finish('commit')
82
+ return result
83
+ })
84
+ const lockEm = { transactional }
85
+ const fork = jest.fn(() => lockEm)
86
+
87
+ return {
88
+ em: { fork } as unknown as EntityManager,
89
+ fork,
90
+ transactional,
91
+ heldKeys,
92
+ lockQueries,
93
+ outcomes,
94
+ transactionEms,
95
+ queueFaults: (faults) => {
96
+ pendingFaults.push(faults)
97
+ },
98
+ openTransactions: () => open,
99
+ }
100
+ }
101
+
102
+ function createDeferred(): { promise: Promise<void>; resolve: () => void } {
103
+ let resolve: () => void = () => undefined
104
+ const promise = new Promise<void>((settle) => {
105
+ resolve = settle
106
+ })
107
+ return { promise, resolve }
108
+ }
109
+
110
+ type Settled<T> = { value?: T; error?: unknown; settledAt: number }
111
+
112
+ function track<T>(promise: Promise<T>): { result: () => Settled<T> | null; done: Promise<Settled<T>> } {
113
+ let settled: Settled<T> | null = null
114
+ const done = promise.then(
115
+ (value) => {
116
+ settled = { value, settledAt: Date.now() }
117
+ return settled
118
+ },
119
+ (error: unknown) => {
120
+ settled = { error, settledAt: Date.now() }
121
+ return settled
122
+ },
123
+ )
124
+ return { result: () => settled, done }
125
+ }
126
+
127
+ let namespaceCounter = 0
128
+
129
+ function freshNamespace(): string {
130
+ namespaceCounter += 1
131
+ return `lock_case_${namespaceCounter}`
132
+ }
133
+
134
+ function loadIsolatedAdvisoryLockModule(): AdvisoryLockModule {
135
+ let loaded: AdvisoryLockModule | undefined
136
+ jest.isolateModules(() => {
137
+ loaded = jest.requireActual<AdvisoryLockModule>('../advisoryLock')
138
+ })
139
+ if (!loaded) throw new Error('[internal] advisoryLock module did not load')
140
+ return loaded
141
+ }
142
+
143
+ describe('withAdvisoryXactLock', () => {
144
+ beforeEach(() => {
145
+ jest.useFakeTimers({ now: 1_000_000 })
146
+ })
147
+
148
+ afterEach(() => {
149
+ jest.useRealTimers()
150
+ jest.restoreAllMocks()
151
+ })
152
+
153
+ it('forks the request EntityManager with the detached options and runs the lock query and fn on the transaction', async () => {
154
+ const db = createFakeDatabase()
155
+ const key = `${freshNamespace()}:tenant:org`
156
+ const fn = jest.fn(async (txEm: EntityManager) => {
157
+ expect(txEm).toBe(db.transactionEms[0])
158
+ expect(db.heldKeys.has(key)).toBe(true)
159
+ return 'value'
160
+ })
161
+
162
+ await expect(withAdvisoryXactLock(db.em, key, fn)).resolves.toBe('value')
163
+
164
+ expect(db.fork).toHaveBeenCalledTimes(1)
165
+ expect(db.fork.mock.calls[0]).toStrictEqual([LOCK_FORK_OPTIONS])
166
+ expect(db.lockQueries).toEqual([{ sql: LOCK_SQL, params: [key], at: expect.any(Number) }])
167
+ expect(fn).toHaveBeenCalledTimes(1)
168
+ expect(db.outcomes).toEqual(['commit'])
169
+ expect(db.openTransactions()).toBe(0)
170
+ expect(db.heldKeys.size).toBe(0)
171
+ })
172
+
173
+ it('ends the empty transaction when the lock is taken elsewhere and fails with deadline once the budget is gone', async () => {
174
+ const db = createFakeDatabase()
175
+ const key = `${freshNamespace()}:owner`
176
+ db.heldKeys.add(key)
177
+ const fn = jest.fn(async () => 'never')
178
+
179
+ const error = await withAdvisoryXactLock(db.em, key, fn, { waitDeadlineMs: 0 }).catch((caught: unknown) => caught)
180
+
181
+ expect(error).toBeInstanceOf(AdvisoryLockUnavailableError)
182
+ expect(error).toBeInstanceOf(Error)
183
+ expect(error).toMatchObject({ name: 'AdvisoryLockUnavailableError', reason: 'deadline', key })
184
+ expect((error as Error).message.startsWith('[internal]')).toBe(true)
185
+ expect(fn).not.toHaveBeenCalled()
186
+ expect(db.outcomes).toEqual(['commit'])
187
+ expect(db.openTransactions()).toBe(0)
188
+ })
189
+
190
+ it('retries after the back-off and runs fn once the other holder releases', async () => {
191
+ jest.spyOn(Math, 'random').mockReturnValue(0)
192
+ const db = createFakeDatabase()
193
+ const key = `${freshNamespace()}:owner`
194
+ db.heldKeys.add(key)
195
+ const start = Date.now()
196
+ const fn = jest.fn(async () => 'acquired')
197
+ const call = track(withAdvisoryXactLock(db.em, key, fn))
198
+
199
+ await jest.advanceTimersByTimeAsync(100)
200
+ db.heldKeys.delete(key)
201
+ await jest.advanceTimersByTimeAsync(50)
202
+ const settled = await call.done
203
+
204
+ expect(settled.value).toBe('acquired')
205
+ expect(db.lockQueries.map((query) => query.at - start)).toEqual([0, 50, 150])
206
+ expect(fn).toHaveBeenCalledTimes(1)
207
+ expect(db.outcomes).toEqual(['commit', 'commit', 'commit'])
208
+ })
209
+
210
+ it('doubles the back-off from 50 ms up to attempt 5 and never waits past the deadline', async () => {
211
+ jest.spyOn(Math, 'random').mockReturnValue(0)
212
+ const db = createFakeDatabase()
213
+ const key = `${freshNamespace()}:owner`
214
+ db.heldKeys.add(key)
215
+ const start = Date.now()
216
+ const call = track(withAdvisoryXactLock(db.em, key, async () => 'never', { waitDeadlineMs: 3_000 }))
217
+
218
+ await jest.advanceTimersByTimeAsync(2_999)
219
+ expect(call.result()).toBeNull()
220
+ await jest.advanceTimersByTimeAsync(1)
221
+ const settled = await call.done
222
+
223
+ expect(db.lockQueries.map((query) => query.at - start)).toEqual([0, 50, 150, 350, 750, 1_550, 2_350])
224
+ expect(settled.error).toMatchObject({ reason: 'deadline', key })
225
+ expect(settled.settledAt - start).toBe(3_000)
226
+ expect(db.openTransactions()).toBe(0)
227
+ })
228
+
229
+ it('keeps every back-off at or below 824 ms with the largest jitter', async () => {
230
+ jest.spyOn(Math, 'random').mockReturnValue(0.999)
231
+ const db = createFakeDatabase()
232
+ const key = `${freshNamespace()}:owner`
233
+ db.heldKeys.add(key)
234
+ const start = Date.now()
235
+ const call = track(withAdvisoryXactLock(db.em, key, async () => 'never', { waitDeadlineMs: 5_000 }))
236
+
237
+ await jest.advanceTimersByTimeAsync(5_000)
238
+ await call.done
239
+
240
+ const attemptTimes = db.lockQueries.map((query) => query.at - start)
241
+ const gaps = attemptTimes.slice(1).map((time, index) => time - attemptTimes[index])
242
+ expect(gaps.slice(0, 6)).toEqual([74, 124, 224, 424, 824, 824])
243
+ expect(Math.max(...gaps)).toBe(824)
244
+ })
245
+
246
+ it('measures the default 15 s deadline from the call start', async () => {
247
+ const db = createFakeDatabase()
248
+ const key = `${freshNamespace()}:owner`
249
+ db.heldKeys.add(key)
250
+ const start = Date.now()
251
+ const call = track(withAdvisoryXactLock(db.em, key, async () => 'never'))
252
+
253
+ await jest.advanceTimersByTimeAsync(14_999)
254
+ expect(call.result()).toBeNull()
255
+ await jest.advanceTimersByTimeAsync(1)
256
+ const settled = await call.done
257
+
258
+ expect(settled.error).toBeInstanceOf(AdvisoryLockUnavailableError)
259
+ expect(settled.error).toMatchObject({ reason: 'deadline', key })
260
+ expect(settled.settledAt - start).toBe(15_000)
261
+ })
262
+
263
+ it('calls onWait after each failed attempt and back-off with no connection held, and short-circuits on done', async () => {
264
+ jest.spyOn(Math, 'random').mockReturnValue(0)
265
+ const db = createFakeDatabase()
266
+ const key = `${freshNamespace()}:owner`
267
+ db.heldKeys.add(key)
268
+ const start = Date.now()
269
+ const observed: Array<{ at: number; open: number }> = []
270
+ const onWait = jest.fn(async (): Promise<AdvisoryLockWaitResult<string>> => {
271
+ observed.push({ at: Date.now() - start, open: db.openTransactions() })
272
+ return observed.length === 2 ? { done: true, value: 'from-wait' } : { done: false }
273
+ })
274
+ const fn = jest.fn(async () => 'from-lock')
275
+ const call = track(withAdvisoryXactLock(db.em, key, fn, { onWait }))
276
+
277
+ await jest.advanceTimersByTimeAsync(1_000)
278
+ const settled = await call.done
279
+
280
+ expect(settled.value).toBe('from-wait')
281
+ expect(observed).toEqual([{ at: 50, open: 0 }, { at: 150, open: 0 }])
282
+ expect(db.lockQueries).toHaveLength(2)
283
+ expect(fn).not.toHaveBeenCalled()
284
+ })
285
+
286
+ it('propagates an onWait error unchanged, even one that looks transient', async () => {
287
+ const db = createFakeDatabase()
288
+ const key = `${freshNamespace()}:owner`
289
+ db.heldKeys.add(key)
290
+ const failure = Object.assign(new Error('canceling statement due to statement timeout'), { code: '57014' })
291
+ const call = track(withAdvisoryXactLock(db.em, key, async () => 'never', {
292
+ onWait: async () => {
293
+ throw failure
294
+ },
295
+ }))
296
+
297
+ await jest.advanceTimersByTimeAsync(1_000)
298
+ const settled = await call.done
299
+
300
+ expect(settled.error).toBe(failure)
301
+ expect(db.lockQueries).toHaveLength(1)
302
+ })
303
+
304
+ it('rolls back, releases the lock and its slot, and propagates an fn error unchanged', async () => {
305
+ const db = createFakeDatabase()
306
+ const key = `${freshNamespace()}:owner`
307
+ const failure = Object.assign(new Error('could not obtain lock on row'), { code: '55P03' })
308
+
309
+ await expect(
310
+ withAdvisoryXactLock(db.em, key, async () => {
311
+ throw failure
312
+ }, { maxConcurrentHolders: 1 }),
313
+ ).rejects.toBe(failure)
314
+
315
+ expect(db.outcomes).toEqual(['rollback'])
316
+ expect(db.openTransactions()).toBe(0)
317
+ expect(db.heldKeys.size).toBe(0)
318
+ await expect(
319
+ withAdvisoryXactLock(db.em, key, async () => 'next', { maxConcurrentHolders: 1, waitDeadlineMs: 0 }),
320
+ ).resolves.toBe('next')
321
+ })
322
+
323
+ it.each([
324
+ ['begin', 'transient_db', { begin: new Error('timeout exceeded when trying to connect') }],
325
+ ['lock query', 'transient_db', { lock: Object.assign(new Error('wrapped by the ORM'), { previous: { code: '57P01' } }) }],
326
+ ['commit', 'commit_unknown', { commit: new Error('Client has encountered a connection error and is not queryable') }],
327
+ ])('maps a transient %s failure to %s with the original error as cause', async (_stage, reason, faults: TransactionFaults) => {
328
+ const db = createFakeDatabase()
329
+ const key = `${freshNamespace()}:owner`
330
+ db.queueFaults(faults)
331
+ const original = faults.begin ?? faults.lock ?? faults.commit
332
+
333
+ const error = await withAdvisoryXactLock(db.em, key, async () => 'value').catch((caught: unknown) => caught)
334
+
335
+ expect(error).toBeInstanceOf(AdvisoryLockUnavailableError)
336
+ expect(error).toMatchObject({ reason, key })
337
+ expect((error as Error).cause).toBe(original)
338
+ expect((error as Error).message.startsWith('[internal]')).toBe(true)
339
+ expect(db.openTransactions()).toBe(0)
340
+ })
341
+
342
+ it.each([
343
+ ['begin', { begin: new Error('permission denied for database') }],
344
+ ['lock query', { lock: Object.assign(new Error('function hashtextextended does not exist'), { code: '42883' }) }],
345
+ ['commit', { commit: new Error('[internal] tenant data encryption failed') }],
346
+ ])('propagates a non-transient %s failure unchanged', async (_stage, faults: TransactionFaults) => {
347
+ const db = createFakeDatabase()
348
+ const key = `${freshNamespace()}:owner`
349
+ db.queueFaults(faults)
350
+ const original = faults.begin ?? faults.lock ?? faults.commit
351
+
352
+ await expect(withAdvisoryXactLock(db.em, key, async () => 'value')).rejects.toBe(original)
353
+ })
354
+
355
+ it.each(['', 'no_separator', 'Upper:x', '1abc:x', 'a-b:x', ':x', 'abc:', ' abc:x'])(
356
+ 'rejects the key %p with a TypeError before forking',
357
+ async (key) => {
358
+ const db = createFakeDatabase()
359
+
360
+ const error = await withAdvisoryXactLock(db.em, key, async () => 'value').catch((caught: unknown) => caught)
361
+
362
+ expect(error).toBeInstanceOf(TypeError)
363
+ expect((error as Error).message.startsWith('[internal]')).toBe(true)
364
+ expect(db.fork).not.toHaveBeenCalled()
365
+ },
366
+ )
367
+
368
+ it('accepts a key whose parts contain further separators', async () => {
369
+ const db = createFakeDatabase()
370
+
371
+ await expect(withAdvisoryXactLock(db.em, 'oauth_grant:id:tenant:org:-', async () => 'value')).resolves.toBe('value')
372
+ })
373
+
374
+ it.each([
375
+ [{ maxConcurrentHolders: 0 }],
376
+ [{ maxConcurrentHolders: 1.5 }],
377
+ [{ waitDeadlineMs: -1 }],
378
+ [{ waitDeadlineMs: Number.NaN }],
379
+ [{ waitDeadlineMs: Number.POSITIVE_INFINITY }],
380
+ ])('rejects the invalid options %p with a TypeError before forking', async (options) => {
381
+ const db = createFakeDatabase()
382
+
383
+ const error = await withAdvisoryXactLock(db.em, `${freshNamespace()}:owner`, async () => 'value', options)
384
+ .catch((caught: unknown) => caught)
385
+
386
+ expect(error).toBeInstanceOf(TypeError)
387
+ expect((error as Error).message.startsWith('[internal]')).toBe(true)
388
+ expect(db.fork).not.toHaveBeenCalled()
389
+ })
390
+
391
+ it('recognises an AdvisoryLockUnavailableError from another module copy with instanceof', () => {
392
+ const otherInstance = loadIsolatedAdvisoryLockModule()
393
+ expect(otherInstance.AdvisoryLockUnavailableError).not.toBe(AdvisoryLockUnavailableError)
394
+ const error = new otherInstance.AdvisoryLockUnavailableError('deadline', 'lock_case:key')
395
+ expect(error).toBeInstanceOf(AdvisoryLockUnavailableError)
396
+ expect(new Error('plain')).not.toBeInstanceOf(AdvisoryLockUnavailableError)
397
+ expect(Object.assign(new Error('look-alike'), { name: 'AdvisoryLockUnavailableError', reason: 'deadline' })).not.toBeInstanceOf(AdvisoryLockUnavailableError)
398
+ })
399
+
400
+ describe('maxConcurrentHolders', () => {
401
+ it('makes a third attempt wait for a slot without a connection and fail with deadline', async () => {
402
+ const db = createFakeDatabase()
403
+ const namespace = freshNamespace()
404
+ const release = createDeferred()
405
+ const holderFn = jest.fn(async () => {
406
+ await release.promise
407
+ return 'held'
408
+ })
409
+ const first = track(withAdvisoryXactLock(db.em, `${namespace}:a`, holderFn, { maxConcurrentHolders: 2 }))
410
+ const second = track(withAdvisoryXactLock(db.em, `${namespace}:b`, holderFn, { maxConcurrentHolders: 2 }))
411
+ await jest.advanceTimersByTimeAsync(0)
412
+ expect(db.openTransactions()).toBe(2)
413
+
414
+ const start = Date.now()
415
+ const thirdFn = jest.fn(async () => 'third')
416
+ const third = track(withAdvisoryXactLock(db.em, `${namespace}:c`, thirdFn, { maxConcurrentHolders: 2, waitDeadlineMs: 1_000 }))
417
+ await jest.advanceTimersByTimeAsync(999)
418
+ expect(third.result()).toBeNull()
419
+ expect(db.transactional).toHaveBeenCalledTimes(2)
420
+ expect(db.openTransactions()).toBe(2)
421
+ await jest.advanceTimersByTimeAsync(1)
422
+ const thirdSettled = await third.done
423
+
424
+ expect(thirdSettled.error).toBeInstanceOf(AdvisoryLockUnavailableError)
425
+ expect(thirdSettled.error).toMatchObject({ reason: 'deadline', key: `${namespace}:c` })
426
+ expect(thirdSettled.settledAt - start).toBe(1_000)
427
+ expect(thirdFn).not.toHaveBeenCalled()
428
+ expect(db.lockQueries.map((query) => query.params[0])).toEqual([`${namespace}:a`, `${namespace}:b`])
429
+
430
+ await expect(withAdvisoryXactLock(db.em, `${namespace}:d`, async () => 'no slot taken')).resolves.toBe('no slot taken')
431
+ await expect(
432
+ withAdvisoryXactLock(db.em, `${namespace}:e`, async () => 'own limit', { maxConcurrentHolders: 3, waitDeadlineMs: 0 }),
433
+ ).resolves.toBe('own limit')
434
+ await expect(
435
+ withAdvisoryXactLock(db.em, `${freshNamespace()}:a`, async () => 'other namespace', { maxConcurrentHolders: 2, waitDeadlineMs: 0 }),
436
+ ).resolves.toBe('other namespace')
437
+
438
+ release.resolve()
439
+ expect((await first.done).value).toBe('held')
440
+ expect((await second.done).value).toBe('held')
441
+ await expect(
442
+ withAdvisoryXactLock(db.em, `${namespace}:c`, async () => 'slot free', { maxConcurrentHolders: 2, waitDeadlineMs: 0 }),
443
+ ).resolves.toBe('slot free')
444
+ })
445
+
446
+ it('hands a released slot to the next live waiter', async () => {
447
+ const db = createFakeDatabase()
448
+ const namespace = freshNamespace()
449
+ const release = createDeferred()
450
+ const holder = track(withAdvisoryXactLock(db.em, `${namespace}:holder`, async () => {
451
+ await release.promise
452
+ return 'holder'
453
+ }, { maxConcurrentHolders: 1 }))
454
+ await jest.advanceTimersByTimeAsync(0)
455
+ const start = Date.now()
456
+ const waiter = track(withAdvisoryXactLock(db.em, `${namespace}:waiter`, async () => 'waiter', { maxConcurrentHolders: 1 }))
457
+
458
+ await jest.advanceTimersByTimeAsync(200)
459
+ expect(waiter.result()).toBeNull()
460
+ expect(db.transactional).toHaveBeenCalledTimes(1)
461
+ release.resolve()
462
+ await jest.advanceTimersByTimeAsync(0)
463
+
464
+ expect((await holder.done).value).toBe('holder')
465
+ const settled = await waiter.done
466
+ expect(settled.value).toBe('waiter')
467
+ expect(settled.settledAt - start).toBe(200)
468
+ })
469
+
470
+ it('gives the slot back while an attempt that lost the lock backs off', async () => {
471
+ const db = createFakeDatabase()
472
+ const namespace = freshNamespace()
473
+ const contendedKey = `${namespace}:contended`
474
+ db.heldKeys.add(contendedKey)
475
+ const contender = track(withAdvisoryXactLock(db.em, contendedKey, async () => 'contender', { maxConcurrentHolders: 1 }))
476
+ await jest.advanceTimersByTimeAsync(0)
477
+ expect(db.lockQueries).toHaveLength(1)
478
+ expect(db.openTransactions()).toBe(0)
479
+
480
+ await expect(
481
+ withAdvisoryXactLock(db.em, `${namespace}:other`, async () => 'other', { maxConcurrentHolders: 1, waitDeadlineMs: 0 }),
482
+ ).resolves.toBe('other')
483
+ expect(contender.result()).toBeNull()
484
+
485
+ db.heldKeys.delete(contendedKey)
486
+ await jest.advanceTimersByTimeAsync(100)
487
+ expect((await contender.done).value).toBe('contender')
488
+ })
489
+
490
+ it('gives the slot back when an attempt fails with transient_db', async () => {
491
+ const db = createFakeDatabase()
492
+ const namespace = freshNamespace()
493
+ db.queueFaults({ begin: new Error('timeout exceeded when trying to connect') })
494
+
495
+ await expect(
496
+ withAdvisoryXactLock(db.em, `${namespace}:first`, async () => 'first', { maxConcurrentHolders: 1 }),
497
+ ).rejects.toMatchObject({ reason: 'transient_db' })
498
+ await expect(
499
+ withAdvisoryXactLock(db.em, `${namespace}:second`, async () => 'second', { maxConcurrentHolders: 1, waitDeadlineMs: 0 }),
500
+ ).resolves.toBe('second')
501
+ })
502
+
503
+ it('never hands a slot to a waiter that already reached its deadline', async () => {
504
+ const db = createFakeDatabase()
505
+ const namespace = freshNamespace()
506
+ const release = createDeferred()
507
+ const holder = track(withAdvisoryXactLock(db.em, `${namespace}:holder`, async () => {
508
+ await release.promise
509
+ return 'holder'
510
+ }, { maxConcurrentHolders: 1 }))
511
+ await jest.advanceTimersByTimeAsync(0)
512
+ const expiredFn = jest.fn(async () => 'expired')
513
+ const liveFn = jest.fn(async () => 'live')
514
+ const expired = track(withAdvisoryXactLock(db.em, `${namespace}:expired`, expiredFn, { maxConcurrentHolders: 1, waitDeadlineMs: 100 }))
515
+ const live = track(withAdvisoryXactLock(db.em, `${namespace}:live`, liveFn, { maxConcurrentHolders: 1, waitDeadlineMs: 10_000 }))
516
+
517
+ await jest.advanceTimersByTimeAsync(100)
518
+ expect((await expired.done).error).toMatchObject({ reason: 'deadline', key: `${namespace}:expired` })
519
+ release.resolve()
520
+ await jest.advanceTimersByTimeAsync(0)
521
+
522
+ expect((await holder.done).value).toBe('holder')
523
+ expect((await live.done).value).toBe('live')
524
+ expect(expiredFn).not.toHaveBeenCalled()
525
+ expect(db.lockQueries.map((query) => query.params[0])).toEqual([`${namespace}:holder`, `${namespace}:live`])
526
+ await expect(
527
+ withAdvisoryXactLock(db.em, `${namespace}:after`, async () => 'after', { maxConcurrentHolders: 1, waitDeadlineMs: 0 }),
528
+ ).resolves.toBe('after')
529
+ })
530
+
531
+ it('shares the slot count between isolated module instances through globalThis', async () => {
532
+ const firstInstance = loadIsolatedAdvisoryLockModule()
533
+ const secondInstance = loadIsolatedAdvisoryLockModule()
534
+ expect(firstInstance.withAdvisoryXactLock).not.toBe(secondInstance.withAdvisoryXactLock)
535
+ const db = createFakeDatabase()
536
+ const namespace = freshNamespace()
537
+ const release = createDeferred()
538
+ const holderFn = async () => {
539
+ await release.promise
540
+ return 'held'
541
+ }
542
+ const first = track(firstInstance.withAdvisoryXactLock(db.em, `${namespace}:a`, holderFn, { maxConcurrentHolders: 2 }))
543
+ const second = track(firstInstance.withAdvisoryXactLock(db.em, `${namespace}:b`, holderFn, { maxConcurrentHolders: 2 }))
544
+ await jest.advanceTimersByTimeAsync(0)
545
+
546
+ const thirdFn = jest.fn(async () => 'third')
547
+ const third = track(secondInstance.withAdvisoryXactLock(db.em, `${namespace}:c`, thirdFn, { maxConcurrentHolders: 2, waitDeadlineMs: 500 }))
548
+ await jest.advanceTimersByTimeAsync(500)
549
+ const thirdSettled = await third.done
550
+
551
+ expect(thirdSettled.error).toBeInstanceOf(secondInstance.AdvisoryLockUnavailableError)
552
+ expect(thirdSettled.error).toMatchObject({ reason: 'deadline' })
553
+ expect(thirdFn).not.toHaveBeenCalled()
554
+ expect(db.openTransactions()).toBe(2)
555
+ const slots = (globalThis as Record<string, unknown>)[GLOBAL_SLOTS_KEY]
556
+ expect(slots).toBeInstanceOf(Map)
557
+ expect((slots as Map<string, unknown>).has(namespace)).toBe(true)
558
+
559
+ release.resolve()
560
+ expect((await first.done).value).toBe('held')
561
+ expect((await second.done).value).toBe('held')
562
+ })
563
+ })
564
+ })
@@ -1,4 +1,11 @@
1
- import { getForeignKeyViolationConstraint, isForeignKeyViolation, isTransientDbError, isUniqueViolation, readPgSqlState } from '../pg-errors'
1
+ import {
2
+ getForeignKeyViolationConstraint,
3
+ isForeignKeyViolation,
4
+ isTransientDbError,
5
+ isTransientLockDbError,
6
+ isUniqueViolation,
7
+ readPgSqlState,
8
+ } from '../pg-errors'
2
9
 
3
10
  describe('isTransientDbError', () => {
4
11
  it('is true for the max_connections SQLSTATE', () => {
@@ -47,6 +54,66 @@ describe('isTransientDbError', () => {
47
54
  })
48
55
  })
49
56
 
57
+ describe('isTransientLockDbError', () => {
58
+ it('matches lock, statement and idle-in-transaction timeouts', () => {
59
+ for (const code of ['55P03', '57014', '25P03']) {
60
+ expect(isTransientLockDbError({ code })).toBe(true)
61
+ }
62
+ })
63
+
64
+ it('matches the pg-pool acquire timeout and the node-postgres not-queryable error', () => {
65
+ expect(isTransientLockDbError(new Error('timeout exceeded when trying to connect'))).toBe(true)
66
+ expect(isTransientLockDbError(new Error('Client has encountered a connection error and is not queryable'))).toBe(true)
67
+ })
68
+
69
+ it('matches everything isTransientDbError matches', () => {
70
+ for (const code of ['53300', '53400', '57P01', '57P02', '57P03', '08000', '08001', '08003', '08006']) {
71
+ expect(isTransientLockDbError({ code })).toBe(true)
72
+ }
73
+ expect(isTransientLockDbError(new Error('sorry, too many clients already'))).toBe(true)
74
+ expect(isTransientLockDbError(new Error('Connection terminated unexpectedly'))).toBe(true)
75
+ })
76
+
77
+ it('looks through cause and previous on every layer of the wrapper chain', () => {
78
+ expect(isTransientLockDbError({ message: 'wrapped', cause: { code: '55P03' } })).toBe(true)
79
+ expect(isTransientLockDbError({ message: 'wrapped', previous: { code: '57014' } })).toBe(true)
80
+ expect(isTransientLockDbError({ message: 'outer', cause: { message: 'inner', previous: { code: '25P03' } } })).toBe(true)
81
+ expect(isTransientLockDbError(new Error('commit failed', { cause: new Error('timeout exceeded when trying to connect') }))).toBe(true)
82
+ expect(
83
+ isTransientLockDbError({ message: 'wrapped', cause: new Error('Client has encountered a connection error and is not queryable') }),
84
+ ).toBe(true)
85
+ expect(isTransientLockDbError({ message: 'wrapped', previous: { code: '53300' } })).toBe(true)
86
+ })
87
+
88
+ it('is false for query-level conflicts, socket codes and non-DB errors', () => {
89
+ expect(isTransientLockDbError({ code: '23505' })).toBe(false)
90
+ expect(isTransientLockDbError({ code: '40P01' })).toBe(false)
91
+ expect(isTransientLockDbError({ code: '40001' })).toBe(false)
92
+ expect(isTransientLockDbError({ code: 'ECONNREFUSED' })).toBe(false)
93
+ expect(isTransientLockDbError(new Error('something unrelated broke'))).toBe(false)
94
+ expect(isTransientLockDbError(null)).toBe(false)
95
+ expect(isTransientLockDbError(undefined)).toBe(false)
96
+ expect(isTransientLockDbError('timeout exceeded when trying to connect')).toBe(false)
97
+ })
98
+
99
+ it('stops on cyclic or very deep wrapper chains', () => {
100
+ const cyclic: Record<string, unknown> = { message: 'loop' }
101
+ cyclic.cause = cyclic
102
+ expect(isTransientLockDbError(cyclic)).toBe(false)
103
+ const deep = { cause: { cause: { cause: { cause: { cause: { code: '55P03' } } } } } }
104
+ expect(isTransientLockDbError(deep)).toBe(false)
105
+ })
106
+
107
+ it('leaves isTransientDbError unchanged for the lock-only signals', () => {
108
+ for (const code of ['55P03', '57014', '25P03']) {
109
+ expect(isTransientDbError({ code })).toBe(false)
110
+ }
111
+ expect(isTransientDbError(new Error('timeout exceeded when trying to connect'))).toBe(false)
112
+ expect(isTransientDbError(new Error('Client has encountered a connection error and is not queryable'))).toBe(false)
113
+ expect(isTransientDbError({ message: 'wrapped', cause: { code: '53300' } })).toBe(false)
114
+ })
115
+ })
116
+
50
117
  describe('isForeignKeyViolation', () => {
51
118
  it('is true for the foreign_key_violation SQLSTATE', () => {
52
119
  expect(isForeignKeyViolation({ code: '23503' })).toBe(true)