velocious 1.0.719 → 1.0.720

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,5 +1,6 @@
1
1
  // @ts-check
2
2
 
3
+ import {AsyncLocalStorage} from "node:async_hooks"
3
4
  import timeout, {TimeoutError} from "awaitery/build/timeout.js"
4
5
  import {DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS} from "./advisory-lock-bound.js"
5
6
  import {
@@ -59,6 +60,27 @@ class AdvisoryLockBusyError extends Error {
59
60
  }
60
61
  }
61
62
 
63
+ /**
64
+ * Advisory locks held by one async context, keyed by
65
+ * `<databaseIdentifier>\u0000<lock name>` and carrying the re-entry depth plus
66
+ * the provenance of the tier that actually holds the lock.
67
+ *
68
+ * Re-entry is scoped to the async context, not to the process: a nested call
69
+ * made inside a held callback belongs to the same logical operation and must
70
+ * not contend with itself, while an unrelated concurrent request in the same
71
+ * process is a different context and must still wait. That is Ruby's `Monitor`
72
+ * ("re-entrant for the holding thread") expressed with the async-context
73
+ * analogue of a thread.
74
+ *
75
+ * Without this, the four tiers disagreed on re-entry: a nested acquisition on
76
+ * a `dedicatedConnection` opened a second connection and self-deadlocked, the
77
+ * file tier refused it as held, and the lock-server tier received
78
+ * `already-held` and treated it as contention. Applications worked around it
79
+ * with hand-passed `*LockHeld` booleans, which is exactly the parameter a
80
+ * monitor makes redundant.
81
+ */
82
+ const heldAdvisoryLocks = new AsyncLocalStorage()
83
+
62
84
  /**
63
85
  * Thrown when a callback holds an advisory lock longer than `holdTimeoutMs`.
64
86
  */
@@ -107,6 +129,10 @@ export default class AdvisoryLockRunner {
107
129
  * @returns {Promise<T>} - Resolves with the callback result.
108
130
  */
109
131
  async withAdvisoryLock(name, callback, args = {}) {
132
+ const heldEntry = this.heldEntryFor(name)
133
+
134
+ if (heldEntry) return await this.runReentrantCallback({callback, entry: heldEntry, name})
135
+
110
136
  const lockServerResult = await this.attemptLockServer({args, callback, mode: "wait", name})
111
137
 
112
138
  // The wait mode never returns a deferred outcome, so the value member is present.
@@ -159,6 +185,10 @@ export default class AdvisoryLockRunner {
159
185
  * @returns {Promise<T>} - Resolves with the callback result.
160
186
  */
161
187
  async withAdvisoryLockOrFail(name, callback, args = {}) {
188
+ const heldEntry = this.heldEntryFor(name)
189
+
190
+ if (heldEntry) return await this.runReentrantCallback({callback, entry: heldEntry, name})
191
+
162
192
  const lockServerResult = await this.attemptLockServer({args, callback, mode: "or-fail", name})
163
193
 
164
194
  // The or-fail mode throws on contention, so the value member is present.
@@ -218,6 +248,10 @@ export default class AdvisoryLockRunner {
218
248
  * @returns {Promise<{acquired: true, result: T} | {acquired: false, reason: "contended", lockName: string}>} - Either the held-callback result or the defer outcome.
219
249
  */
220
250
  async withAdvisoryLockOrDefer(name, callback, args = {}) {
251
+ const heldEntry = this.heldEntryFor(name)
252
+
253
+ if (heldEntry) return {acquired: true, result: await this.runReentrantCallback({callback, entry: heldEntry, name})}
254
+
221
255
  const lockServerResult = await this.attemptLockServer({args, callback, mode: "defer", name})
222
256
 
223
257
  if (lockServerResult) {
@@ -266,13 +300,69 @@ export default class AdvisoryLockRunner {
266
300
  * @returns {Promise<T>} - Resolves with the callback result.
267
301
  */
268
302
  async runLockedCallback({callback, holdTimeoutMs, lockBackend, name, release}) {
303
+ const store = new Map(heldAdvisoryLocks.getStore() ?? [])
304
+
305
+ store.set(this.lockStorageKey(name), {depth: 1, lockBackend})
306
+
269
307
  try {
270
- return await AdvisoryLockRunner.runWithAdvisoryLockHoldTimeout(name, callback, holdTimeoutMs, lockBackend)
308
+ // The lock is released outside the context scope: a release path that
309
+ // touches the same name (a cleanup hook, an error reporter) must not look
310
+ // like a re-entry of a hold that is already over.
311
+ return await heldAdvisoryLocks.run(store, async () => await AdvisoryLockRunner.runWithAdvisoryLockHoldTimeout(name, callback, holdTimeoutMs, lockBackend))
271
312
  } finally {
272
313
  await release()
273
314
  }
274
315
  }
275
316
 
317
+ /**
318
+ * Returns the held entry when this async context already holds the lock.
319
+ * @param {string} name - Lock name.
320
+ * @returns {{depth: number, lockBackend: AdvisoryLockBackendProvenance} | null} - The held entry, or null when this context does not hold the lock.
321
+ */
322
+ heldEntryFor(name) {
323
+ const store = heldAdvisoryLocks.getStore()
324
+
325
+ if (!store) return null
326
+
327
+ return store.get(this.lockStorageKey(name)) ?? null
328
+ }
329
+
330
+ /**
331
+ * Runs a callback as a re-entry of a lock this async context already holds.
332
+ *
333
+ * The re-entry adds no backend round trip and cannot fail on contention: the
334
+ * outer hold already owns the lock, so mutual exclusion is unchanged. The
335
+ * outer hold owns the hold timeout and the lease; a re-entry does not start a
336
+ * second one, and the callback therefore receives the outer provenance without
337
+ * a `control`.
338
+ * @template T
339
+ * @param {{callback: (args?: {control?: import("awaitery/build/timeout.js").TimeoutControl, lockBackend: AdvisoryLockBackendProvenance}) => Promise<T>, entry: {depth: number, lockBackend: AdvisoryLockBackendProvenance}, name: string}} args - Re-entry request.
340
+ * @returns {Promise<T>} - Callback result.
341
+ */
342
+ async runReentrantCallback({callback, entry, name}) {
343
+ entry.depth += 1
344
+ this.configuration.recordAdvisoryLockReentry({lockName: name})
345
+
346
+ try {
347
+ return await callback({lockBackend: entry.lockBackend})
348
+ } finally {
349
+ entry.depth -= 1
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Builds the async-context storage key for one lock name.
355
+ *
356
+ * The database identifier is part of the key because it is part of the lock's
357
+ * identity: two tenant databases reusing one name hold two different locks,
358
+ * and a nested call in the other database is not a re-entry.
359
+ * @param {string} name - Lock name.
360
+ * @returns {string} - Storage key for the held-entry map.
361
+ */
362
+ lockStorageKey(name) {
363
+ return `${this.databaseIdentifier}\u0000${name}`
364
+ }
365
+
276
366
  /**
277
367
  * Serves one acquisition on the lock-server tier when the lock name prefers
278
368
  * it (design §1/§9), falling *upward* to the driver path when the server is