@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -1
- package/dist/lib/db/advisoryLock.js +172 -0
- package/dist/lib/db/advisoryLock.js.map +7 -0
- package/dist/lib/db/pg-errors.js +14 -0
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/integrations/types.js.map +2 -2
- package/package.json +9 -2
- package/src/lib/db/__tests__/advisoryLock.integration.test.ts +343 -0
- package/src/lib/db/__tests__/advisoryLock.test.ts +564 -0
- package/src/lib/db/__tests__/pg-errors.test.ts +68 -1
- package/src/lib/db/advisoryLock.ts +271 -0
- package/src/lib/db/pg-errors.ts +35 -0
- package/src/modules/integrations/types.ts +6 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { EntitySchema, MikroORM, type EventSubscriber } from '@mikro-orm/core'
|
|
3
|
+
import { PostgreSqlDriver, type EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import type { StartedTestContainer } from 'testcontainers'
|
|
5
|
+
import { withAdvisoryXactLock } from '../advisoryLock'
|
|
6
|
+
|
|
7
|
+
const describeWithPostgres = process.env.OM_PG_INTEGRATION === '1' ? describe : describe.skip
|
|
8
|
+
|
|
9
|
+
const DATABASE_NAME = 'advisory_lock_test'
|
|
10
|
+
const DATABASE_USER = 'advisory_lock'
|
|
11
|
+
const DATABASE_PASSWORD = 'advisory_lock'
|
|
12
|
+
const PROBE_TABLE = 'advisory_lock_probe_rows'
|
|
13
|
+
const LOCK_PROBE_SQL = 'select pg_try_advisory_xact_lock(hashtextextended(?, 0)) as locked'
|
|
14
|
+
const ACQUIRE_TIMEOUT_MS = 1000
|
|
15
|
+
const HOLD_MS = 3000
|
|
16
|
+
const PROBE_INTERVAL_MS = 500
|
|
17
|
+
const SHORT_HOLD_MS = 25
|
|
18
|
+
const WAITER_COUNT = 10
|
|
19
|
+
const CASE_TIMEOUT_MS = 20_000
|
|
20
|
+
const POOL_ACQUIRE_TIMEOUT_PATTERN = /timeout exceeded when trying to connect/i
|
|
21
|
+
|
|
22
|
+
type ProbeRow = { id: string; label: string }
|
|
23
|
+
|
|
24
|
+
const ProbeRowSchema = new EntitySchema<ProbeRow>({
|
|
25
|
+
name: 'AdvisoryLockProbeRow',
|
|
26
|
+
tableName: PROBE_TABLE,
|
|
27
|
+
properties: {
|
|
28
|
+
id: { type: 'string', primary: true },
|
|
29
|
+
label: { type: 'string' },
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
type TestOrm = MikroORM<PostgreSqlDriver, EntityManager<PostgreSqlDriver>>
|
|
34
|
+
|
|
35
|
+
type LockProbe = { locked: boolean; pid: number }
|
|
36
|
+
|
|
37
|
+
type SubscriberCall = { label: string; insideFn: boolean }
|
|
38
|
+
|
|
39
|
+
function sleep(delayMs: number): Promise<void> {
|
|
40
|
+
return new Promise((resolve) => setTimeout(resolve, delayMs))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createDeferred(): { promise: Promise<void>; resolve: () => void } {
|
|
44
|
+
let resolve: () => void = () => undefined
|
|
45
|
+
const promise = new Promise<void>((resolveDeferred) => {
|
|
46
|
+
resolve = resolveDeferred
|
|
47
|
+
})
|
|
48
|
+
return { promise, resolve }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describeWithPostgres('withAdvisoryXactLock against real PostgreSQL', () => {
|
|
52
|
+
let postgres: StartedTestContainer | null = null
|
|
53
|
+
let connection: { host: string; port: number } | null = null
|
|
54
|
+
const startedOrms = new Set<TestOrm>()
|
|
55
|
+
|
|
56
|
+
async function startOrm(options: { poolMax: number; acquireTimeoutMs?: number }): Promise<TestOrm> {
|
|
57
|
+
if (!connection) throw new Error('[internal] PostgreSQL container is not running')
|
|
58
|
+
const orm = await MikroORM.init<PostgreSqlDriver, EntityManager<PostgreSqlDriver>>({
|
|
59
|
+
driver: PostgreSqlDriver,
|
|
60
|
+
host: connection.host,
|
|
61
|
+
port: connection.port,
|
|
62
|
+
dbName: DATABASE_NAME,
|
|
63
|
+
user: DATABASE_USER,
|
|
64
|
+
password: DATABASE_PASSWORD,
|
|
65
|
+
entities: [ProbeRowSchema],
|
|
66
|
+
debug: false,
|
|
67
|
+
pool: { min: 0, max: options.poolMax },
|
|
68
|
+
driverOptions: { connectionTimeoutMillis: options.acquireTimeoutMs ?? ACQUIRE_TIMEOUT_MS },
|
|
69
|
+
})
|
|
70
|
+
startedOrms.add(orm)
|
|
71
|
+
return orm
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function forkRoot(orm: TestOrm): EntityManager {
|
|
75
|
+
return orm.em.fork({ disableContextResolution: true })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createRequestEm(orm: TestOrm): EntityManager {
|
|
79
|
+
return orm.em.fork({ clear: true, freshEventManager: true, useContext: true })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function readBackendPid(em: EntityManager): Promise<number> {
|
|
83
|
+
const rows = await em.execute<Array<{ pid: number }>>('select pg_backend_pid() as pid')
|
|
84
|
+
return rows[0].pid
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function insertProbeRow(em: EntityManager, label: string): Promise<void> {
|
|
88
|
+
em.persist(em.create(ProbeRowSchema, { id: randomUUID(), label }))
|
|
89
|
+
await em.flush()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function readLabels(orm: TestOrm, tag: string): Promise<string[]> {
|
|
93
|
+
const rows = await forkRoot(orm).execute<Array<{ label: string }>>(
|
|
94
|
+
`select label from ${PROBE_TABLE} where label like ? order by label`,
|
|
95
|
+
[`${tag}:%`],
|
|
96
|
+
)
|
|
97
|
+
return rows.map((row) => row.label)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function probeLock(orm: TestOrm, key: string): Promise<LockProbe> {
|
|
101
|
+
return forkRoot(orm).transactional(async (probeEm) => {
|
|
102
|
+
const rows = await probeEm.execute<Array<{ locked: boolean }>>(LOCK_PROBE_SQL, [key])
|
|
103
|
+
return { locked: rows[0].locked, pid: await readBackendPid(probeEm) }
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function registerCallRecorder(requestEm: EntityManager, calls: SubscriberCall[], isInsideFn: () => boolean): void {
|
|
108
|
+
const recorder: EventSubscriber<ProbeRow> = {
|
|
109
|
+
beforeCreate: (args) => {
|
|
110
|
+
calls.push({ label: args.entity.label, insideFn: isInsideFn() })
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
requestEm.getEventManager().registerSubscriber(recorder)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
beforeAll(async () => {
|
|
117
|
+
const { GenericContainer, Wait } = await import('testcontainers')
|
|
118
|
+
postgres = await new GenericContainer('postgres:16')
|
|
119
|
+
.withEnvironment({
|
|
120
|
+
POSTGRES_DB: DATABASE_NAME,
|
|
121
|
+
POSTGRES_USER: DATABASE_USER,
|
|
122
|
+
POSTGRES_PASSWORD: DATABASE_PASSWORD,
|
|
123
|
+
})
|
|
124
|
+
.withExposedPorts(5432)
|
|
125
|
+
.withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/, 2))
|
|
126
|
+
.start()
|
|
127
|
+
connection = { host: postgres.getHost(), port: postgres.getMappedPort(5432) }
|
|
128
|
+
const schemaOrm = await startOrm({ poolMax: 1 })
|
|
129
|
+
await schemaOrm.schema.create()
|
|
130
|
+
startedOrms.delete(schemaOrm)
|
|
131
|
+
await schemaOrm.close(true)
|
|
132
|
+
}, 120_000)
|
|
133
|
+
|
|
134
|
+
afterEach(async () => {
|
|
135
|
+
const orms = [...startedOrms]
|
|
136
|
+
startedOrms.clear()
|
|
137
|
+
await Promise.allSettled(orms.map((orm) => orm.close(true)))
|
|
138
|
+
}, 30_000)
|
|
139
|
+
|
|
140
|
+
afterAll(async () => {
|
|
141
|
+
const orms = [...startedOrms]
|
|
142
|
+
startedOrms.clear()
|
|
143
|
+
await Promise.allSettled(orms.map((orm) => orm.close(true)))
|
|
144
|
+
await Promise.allSettled([postgres?.stop()])
|
|
145
|
+
}, 30_000)
|
|
146
|
+
|
|
147
|
+
it('proves the harness: a pool of 4 times out the fifth concurrent connection after the acquire timeout', async () => {
|
|
148
|
+
const orm = await startOrm({ poolMax: 4 })
|
|
149
|
+
const release = createDeferred()
|
|
150
|
+
const allHeld = createDeferred()
|
|
151
|
+
let held = 0
|
|
152
|
+
const holders = Array.from({ length: 4 }, () =>
|
|
153
|
+
forkRoot(orm).transactional(async (holderEm) => {
|
|
154
|
+
await holderEm.execute('select 1')
|
|
155
|
+
held += 1
|
|
156
|
+
if (held === 4) allHeld.resolve()
|
|
157
|
+
await release.promise
|
|
158
|
+
}),
|
|
159
|
+
)
|
|
160
|
+
await allHeld.promise
|
|
161
|
+
|
|
162
|
+
const startedAt = Date.now()
|
|
163
|
+
const fifth = forkRoot(orm).execute('select 1')
|
|
164
|
+
await expect(fifth).rejects.toThrow(POOL_ACQUIRE_TIMEOUT_PATTERN)
|
|
165
|
+
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(ACQUIRE_TIMEOUT_MS - 100)
|
|
166
|
+
|
|
167
|
+
release.resolve()
|
|
168
|
+
await Promise.all(holders)
|
|
169
|
+
}, CASE_TIMEOUT_MS)
|
|
170
|
+
|
|
171
|
+
it('keeps a second connection out for the whole of fn and frees the key when the lock transaction commits', async () => {
|
|
172
|
+
const orm = await startOrm({ poolMax: 4 })
|
|
173
|
+
const key = `lock_suite:${randomUUID()}`
|
|
174
|
+
const otherKey = `lock_suite:${randomUUID()}`
|
|
175
|
+
const probes: LockProbe[] = []
|
|
176
|
+
let holderPid = 0
|
|
177
|
+
let otherKeyProbe: LockProbe | null = null
|
|
178
|
+
let heldForMs = 0
|
|
179
|
+
|
|
180
|
+
await withAdvisoryXactLock(createRequestEm(orm), key, async (txEm) => {
|
|
181
|
+
holderPid = await readBackendPid(txEm)
|
|
182
|
+
otherKeyProbe = await probeLock(orm, otherKey)
|
|
183
|
+
const enteredAt = Date.now()
|
|
184
|
+
while (Date.now() - enteredAt < HOLD_MS) {
|
|
185
|
+
probes.push(await probeLock(orm, key))
|
|
186
|
+
await sleep(PROBE_INTERVAL_MS)
|
|
187
|
+
}
|
|
188
|
+
probes.push(await probeLock(orm, key))
|
|
189
|
+
heldForMs = Date.now() - enteredAt
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
expect(heldForMs).toBeGreaterThanOrEqual(HOLD_MS)
|
|
193
|
+
expect(probes.length).toBeGreaterThanOrEqual(HOLD_MS / PROBE_INTERVAL_MS)
|
|
194
|
+
expect(probes.map((probe) => probe.locked)).toEqual(probes.map(() => false))
|
|
195
|
+
expect(probes.every((probe) => probe.pid !== holderPid)).toBe(true)
|
|
196
|
+
expect(otherKeyProbe).toMatchObject({ locked: true })
|
|
197
|
+
expect(await probeLock(orm, key)).toMatchObject({ locked: true })
|
|
198
|
+
}, CASE_TIMEOUT_MS)
|
|
199
|
+
|
|
200
|
+
it('holds the lock for the whole of fn when the request EntityManager has transactions disabled', async () => {
|
|
201
|
+
const orm = await startOrm({ poolMax: 4 })
|
|
202
|
+
const key = `lock_suite:${randomUUID()}`
|
|
203
|
+
const requestEm = orm.em.fork({ clear: true, freshEventManager: true, useContext: true, disableTransactions: true })
|
|
204
|
+
const probes: LockProbe[] = []
|
|
205
|
+
|
|
206
|
+
await withAdvisoryXactLock(requestEm, key, async () => {
|
|
207
|
+
probes.push(await probeLock(orm, key))
|
|
208
|
+
await sleep(PROBE_INTERVAL_MS)
|
|
209
|
+
probes.push(await probeLock(orm, key))
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
expect(probes.map((probe) => probe.locked)).toEqual([false, false])
|
|
213
|
+
expect(await probeLock(orm, key)).toMatchObject({ locked: true })
|
|
214
|
+
}, CASE_TIMEOUT_MS)
|
|
215
|
+
|
|
216
|
+
it('serves 10 concurrent waiters on one key from a pool of 4 without an acquire timeout', async () => {
|
|
217
|
+
const orm = await startOrm({ poolMax: 4 })
|
|
218
|
+
const key = `lock_suite:${randomUUID()}`
|
|
219
|
+
const tag = randomUUID()
|
|
220
|
+
let inFn = 0
|
|
221
|
+
let peakInFn = 0
|
|
222
|
+
let entered = 0
|
|
223
|
+
|
|
224
|
+
const settled = await Promise.allSettled(
|
|
225
|
+
Array.from({ length: WAITER_COUNT }, (_, index) =>
|
|
226
|
+
withAdvisoryXactLock(createRequestEm(orm), key, async (txEm) => {
|
|
227
|
+
const order = entered
|
|
228
|
+
entered += 1
|
|
229
|
+
inFn += 1
|
|
230
|
+
peakInFn = Math.max(peakInFn, inFn)
|
|
231
|
+
try {
|
|
232
|
+
await insertProbeRow(txEm, `${tag}:${index}`)
|
|
233
|
+
await sleep(order === 0 ? HOLD_MS : SHORT_HOLD_MS)
|
|
234
|
+
} finally {
|
|
235
|
+
inFn -= 1
|
|
236
|
+
}
|
|
237
|
+
return index
|
|
238
|
+
}),
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
const failures = settled.flatMap((outcome) => (outcome.status === 'rejected' ? [String(outcome.reason)] : []))
|
|
243
|
+
expect(failures.filter((failure) => POOL_ACQUIRE_TIMEOUT_PATTERN.test(failure))).toEqual([])
|
|
244
|
+
expect(failures).toEqual([])
|
|
245
|
+
expect(entered).toBe(WAITER_COUNT)
|
|
246
|
+
expect(peakInFn).toBe(1)
|
|
247
|
+
expect(await readLabels(orm, tag)).toHaveLength(WAITER_COUNT)
|
|
248
|
+
}, CASE_TIMEOUT_MS)
|
|
249
|
+
|
|
250
|
+
it('commits the lock transaction while the outer transaction on the request EntityManager rolls back', async () => {
|
|
251
|
+
const orm = await startOrm({ poolMax: 4 })
|
|
252
|
+
const requestEm = createRequestEm(orm)
|
|
253
|
+
const key = `lock_suite:${randomUUID()}`
|
|
254
|
+
const tag = randomUUID()
|
|
255
|
+
const outerFailure = new Error('[internal] roll back the outer transaction')
|
|
256
|
+
const pids = { outer: 0, lock: 0 }
|
|
257
|
+
|
|
258
|
+
await expect(
|
|
259
|
+
requestEm.transactional(async (outerEm) => {
|
|
260
|
+
await insertProbeRow(outerEm, `${tag}:outer`)
|
|
261
|
+
pids.outer = await readBackendPid(outerEm)
|
|
262
|
+
await withAdvisoryXactLock(requestEm, key, async (txEm) => {
|
|
263
|
+
pids.lock = await readBackendPid(txEm)
|
|
264
|
+
await insertProbeRow(txEm, `${tag}:lock`)
|
|
265
|
+
})
|
|
266
|
+
throw outerFailure
|
|
267
|
+
}),
|
|
268
|
+
).rejects.toBe(outerFailure)
|
|
269
|
+
|
|
270
|
+
expect(pids.lock).not.toBe(pids.outer)
|
|
271
|
+
expect(await readLabels(orm, tag)).toEqual([`${tag}:lock`])
|
|
272
|
+
}, CASE_TIMEOUT_MS)
|
|
273
|
+
|
|
274
|
+
it('fires a subscriber registered on the request EntityManager inside fn', async () => {
|
|
275
|
+
const orm = await startOrm({ poolMax: 4 })
|
|
276
|
+
const requestEm = createRequestEm(orm)
|
|
277
|
+
const key = `lock_suite:${randomUUID()}`
|
|
278
|
+
const label = `${randomUUID()}:inner`
|
|
279
|
+
const calls: SubscriberCall[] = []
|
|
280
|
+
let insideFn = false
|
|
281
|
+
registerCallRecorder(requestEm, calls, () => insideFn)
|
|
282
|
+
|
|
283
|
+
await withAdvisoryXactLock(requestEm, key, async (txEm) => {
|
|
284
|
+
insideFn = true
|
|
285
|
+
try {
|
|
286
|
+
await insertProbeRow(txEm, label)
|
|
287
|
+
} finally {
|
|
288
|
+
insideFn = false
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
expect(calls).toEqual([{ label, insideFn: true }])
|
|
293
|
+
}, CASE_TIMEOUT_MS)
|
|
294
|
+
|
|
295
|
+
it('fires the request subscriber inside fn under an outer transaction opened on the request EntityManager', async () => {
|
|
296
|
+
const orm = await startOrm({ poolMax: 4 })
|
|
297
|
+
const requestEm = createRequestEm(orm)
|
|
298
|
+
const key = `lock_suite:${randomUUID()}`
|
|
299
|
+
const label = `${randomUUID()}:inner`
|
|
300
|
+
const calls: SubscriberCall[] = []
|
|
301
|
+
let insideFn = false
|
|
302
|
+
registerCallRecorder(requestEm, calls, () => insideFn)
|
|
303
|
+
|
|
304
|
+
await requestEm.transactional(async (outerEm) => {
|
|
305
|
+
await readBackendPid(outerEm)
|
|
306
|
+
await withAdvisoryXactLock(requestEm, key, async (txEm) => {
|
|
307
|
+
insideFn = true
|
|
308
|
+
try {
|
|
309
|
+
await insertProbeRow(txEm, label)
|
|
310
|
+
} finally {
|
|
311
|
+
insideFn = false
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
expect(calls).toEqual([{ label, insideFn: true }])
|
|
317
|
+
}, CASE_TIMEOUT_MS)
|
|
318
|
+
|
|
319
|
+
it('fires the request subscriber inside fn under an outer transaction opened on an EntityManager with a fresh event manager', async () => {
|
|
320
|
+
const orm = await startOrm({ poolMax: 4 })
|
|
321
|
+
const requestEm = createRequestEm(orm)
|
|
322
|
+
const outerEm = createRequestEm(orm)
|
|
323
|
+
const key = `lock_suite:${randomUUID()}`
|
|
324
|
+
const label = `${randomUUID()}:inner`
|
|
325
|
+
const calls: SubscriberCall[] = []
|
|
326
|
+
let insideFn = false
|
|
327
|
+
registerCallRecorder(requestEm, calls, () => insideFn)
|
|
328
|
+
|
|
329
|
+
await outerEm.transactional(async (outerTxEm) => {
|
|
330
|
+
await readBackendPid(outerTxEm)
|
|
331
|
+
await withAdvisoryXactLock(requestEm, key, async (txEm) => {
|
|
332
|
+
insideFn = true
|
|
333
|
+
try {
|
|
334
|
+
await insertProbeRow(txEm, label)
|
|
335
|
+
} finally {
|
|
336
|
+
insideFn = false
|
|
337
|
+
}
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
expect(calls).toEqual([{ label, insideFn: true }])
|
|
342
|
+
}, CASE_TIMEOUT_MS)
|
|
343
|
+
})
|