@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,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
+ })