velocious 1.0.694 → 1.0.696

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.
Files changed (57) hide show
  1. package/README.md +1 -1
  2. package/build/configuration-types.js +1 -0
  3. package/build/database/advisory-lock-runner.js +31 -0
  4. package/build/database/advisory-lock-telemetry.js +129 -0
  5. package/build/database/drivers/base.js +114 -2
  6. package/build/database/drivers/mssql/index.js +16 -0
  7. package/build/database/drivers/mysql/index.js +14 -0
  8. package/build/database/drivers/pgsql/index.js +17 -0
  9. package/build/database/drivers/sqlite/base.js +20 -0
  10. package/build/database/drivers/sqlite/index.js +33 -0
  11. package/build/database/migrations-ledger.js +96 -8
  12. package/build/database/record/index.js +28 -0
  13. package/build/src/configuration-types.d.ts +8 -0
  14. package/build/src/configuration-types.d.ts.map +1 -1
  15. package/build/src/configuration-types.js +2 -1
  16. package/build/src/database/advisory-lock-runner.d.ts +32 -0
  17. package/build/src/database/advisory-lock-runner.d.ts.map +1 -1
  18. package/build/src/database/advisory-lock-runner.js +29 -1
  19. package/build/src/database/advisory-lock-telemetry.d.ts +129 -0
  20. package/build/src/database/advisory-lock-telemetry.d.ts.map +1 -0
  21. package/build/src/database/advisory-lock-telemetry.js +122 -0
  22. package/build/src/database/drivers/base.d.ts +51 -0
  23. package/build/src/database/drivers/base.d.ts.map +1 -1
  24. package/build/src/database/drivers/base.js +104 -3
  25. package/build/src/database/drivers/mssql/index.d.ts +8 -0
  26. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  27. package/build/src/database/drivers/mssql/index.js +13 -1
  28. package/build/src/database/drivers/mysql/index.d.ts +8 -0
  29. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  30. package/build/src/database/drivers/mysql/index.js +13 -1
  31. package/build/src/database/drivers/pgsql/index.d.ts +8 -0
  32. package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
  33. package/build/src/database/drivers/pgsql/index.js +14 -1
  34. package/build/src/database/drivers/sqlite/base.d.ts +12 -0
  35. package/build/src/database/drivers/sqlite/base.d.ts.map +1 -1
  36. package/build/src/database/drivers/sqlite/base.js +18 -1
  37. package/build/src/database/drivers/sqlite/index.d.ts +15 -0
  38. package/build/src/database/drivers/sqlite/index.d.ts.map +1 -1
  39. package/build/src/database/drivers/sqlite/index.js +31 -1
  40. package/build/src/database/migrations-ledger.d.ts +34 -1
  41. package/build/src/database/migrations-ledger.d.ts.map +1 -1
  42. package/build/src/database/migrations-ledger.js +82 -9
  43. package/build/src/database/record/index.d.ts +54 -0
  44. package/build/src/database/record/index.d.ts.map +1 -1
  45. package/build/src/database/record/index.js +26 -1
  46. package/package.json +1 -1
  47. package/src/configuration-types.js +1 -0
  48. package/src/database/advisory-lock-runner.js +31 -0
  49. package/src/database/advisory-lock-telemetry.js +129 -0
  50. package/src/database/drivers/base.js +114 -2
  51. package/src/database/drivers/mssql/index.js +16 -0
  52. package/src/database/drivers/mysql/index.js +14 -0
  53. package/src/database/drivers/pgsql/index.js +17 -0
  54. package/src/database/drivers/sqlite/base.js +20 -0
  55. package/src/database/drivers/sqlite/index.js +33 -0
  56. package/src/database/migrations-ledger.js +96 -8
  57. package/src/database/record/index.js +28 -0
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  * Database framework with familiar MVC concepts
5
5
  * Database models with migrations and validations
6
6
  * Database models that work almost the same in frontend and backend, including online CRUD for [composite primary keys](docs/composite-primary-keys.md)
7
- * Connection-scoped advisory locks with automatic cleanup before pooled connections are reused or closed (see [docs/advisory-locks.md](docs/advisory-locks.md))
7
+ * Connection-scoped advisory locks with automatic cleanup before pooled connections are reused or closed, a non-throwing `withAdvisoryLockOrDefer` result for contended hot paths, and event-driven slow acquire/hold duration telemetry with holder attribution (see [docs/advisory-locks.md](docs/advisory-locks.md)) (see [docs/advisory-locks.md](docs/advisory-locks.md))
8
8
  * Built-in record auditing for model lifecycle changes (see [docs/auditing.md](docs/auditing.md))
9
9
  * Declarative state machines for models, with typed event methods generated into the base model (see [docs/state-machine.md](docs/state-machine.md))
10
10
  * Migrations for schema changes and UTC datetime storage, including caller-selected `pre-runtime` and `post-publication` execution sets and recorded `changeTable` batches that combine operations into one `ALTER` on bulk-capable drivers (see [docs/database-migrations.md](docs/database-migrations.md), [docs/migration-execution-phases.md](docs/migration-execution-phases.md), and [docs/change-table.md](docs/change-table.md))
@@ -69,6 +69,7 @@
69
69
  /**
70
70
  * @typedef {object} DatabaseConfigurationType
71
71
  * @property {number | null} [advisoryLockAcquireTimeoutMs] - Bound (ms) a blocking advisory-lock acquisition waits for a contended lock before reporting failure. Default: `900000` (15 minutes). `null` opts out and restores the driver's indefinite wait. A per-call `timeoutMs` argument always overrides this.
72
+ * @property {{slowAcquireMs?: number | null, slowHoldMs?: number | null}} [advisoryLockTelemetry] - Event-driven advisory-lock duration telemetry thresholds. `slowAcquireMs` (default `5000`) reports an acquire wait that completed slower than the threshold, including the eventual outcome; `advisory-lock-acquire-timeout` always fires when a wait exceeds its bound and carries the holder identity available on the tier at that moment. `slowHoldMs` (default `60000`) reports a hold that outlives the threshold at release. `null` disables that report. Reports fire on the events themselves — no periodic scanner — on `getErrorEvents()` as `framework-error` with `context: "advisory-lock-telemetry"`. See [docs/advisory-locks.md](../docs/advisory-locks.md).
72
73
  * @property {string} [databaseCharset] - Default character set applied by `db:create` via mysql/mariadb `CREATE DATABASE ... CHARACTER SET`. Distinct from `charset`, which is the client connection charset forwarded to the mysql2 driver.
73
74
  * @property {string} [databaseCollation] - Default collation applied by `db:create` via mysql/mariadb `CREATE DATABASE ... COLLATE`.
74
75
  * @property {string} [database] - Database name for this connection.
@@ -118,6 +118,37 @@ export default class AdvisoryLockRunner {
118
118
  })
119
119
  }
120
120
 
121
+ /**
122
+ * Runs a callback if the advisory lock can be acquired without waiting;
123
+ * otherwise returns a `{acquired: false}` defer result instead of throwing.
124
+ *
125
+ * This is the non-throwing counterpart of `withAdvisoryLockOrFail` for hot
126
+ * paths: "holder busy, defer me" is a first-class outcome, so a contended
127
+ * lock produces zero exception traffic and — because the probe is a single
128
+ * immediate try-acquire — zero pinned connections. Callers reschedule via
129
+ * their own event-driven retry machinery. The result form is identical
130
+ * across tiers: the driver's `tryAcquireAdvisoryLock` maps per-tier (MySQL
131
+ * `GET_LOCK(name, 0)`, PostgreSQL `pg_try_advisory_lock`, SQL Server
132
+ * `sp_getapplock @LockTimeout = 0`, SQLite process-local queue, SQLite Node
133
+ * file lock), so a caller never branches on the backend.
134
+ * @template T
135
+ * @param {string} name - Lock name.
136
+ * @param {(args?: {control: import("awaitery/build/timeout.js").TimeoutControl}) => Promise<T>} callback - Callback to invoke while the lock is held.
137
+ * @param {{holdTimeoutMs?: number | null, dedicatedConnection?: boolean}} [args] - Hold timeout options.
138
+ * @returns {Promise<{acquired: true, result: T} | {acquired: false, reason: "contended", lockName: string}>} - Either the held-callback result or the defer outcome.
139
+ */
140
+ async withAdvisoryLockOrDefer(name, callback, args = {}) {
141
+ return await this.withLockConnection(args, async (connection) => {
142
+ const acquired = await connection.tryAcquireAdvisoryLock(name)
143
+
144
+ if (!acquired) return {acquired: false, reason: "contended", lockName: name}
145
+
146
+ const result = await this.runLockedCallback({callback, connection, holdTimeoutMs: args.holdTimeoutMs, name})
147
+
148
+ return {acquired: true, result}
149
+ })
150
+ }
151
+
121
152
  /**
122
153
  * Runs the lock holder callback and releases the lock from its owning connection.
123
154
  * @template T
@@ -0,0 +1,129 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Event-driven advisory-lock duration telemetry (STC-261, parent epic STC-209).
5
+ *
6
+ * A lock convoy is only fixable when it is *attributable*: which lock name was
7
+ * contended, on which backend tier, for how long, and who held it. These reports
8
+ * fire on the acquisition/release events themselves — never from a periodic
9
+ * scanner — so a contended lock in production surfaces on the framework-error
10
+ * channel with the holder identity available on that tier at that moment,
11
+ * without SSH forensics.
12
+ *
13
+ * Three events, each with its own threshold (all configurable per database via
14
+ * `advisoryLockTelemetry`, see `docs/advisory-locks.md`):
15
+ *
16
+ * - `advisory-lock-slow-acquire` — an acquire wait completed slower than
17
+ * `slowAcquireMs` (default 5000), including the eventual outcome (`acquired`
18
+ * or `timeout`).
19
+ * - `advisory-lock-acquire-timeout` — a wait exceeded its timeout. The report
20
+ * carries the holder identity available on the tier at that moment: the
21
+ * driver-native tiers attribute the holder connection (MariaDB
22
+ * `IS_USED_LOCK`, PostgreSQL `pg_locks` joined to `pg_stat_activity`, SQL
23
+ * Server `sys.dm_tran_locks` request session, SQLite Node file tier
24
+ * `owner.json` pid/hostname); the process-local tier reports the owning
25
+ * driver's `holderId`.
26
+ * - `advisory-lock-slow-hold` — a hold lasted longer than `slowHoldMs`
27
+ * (default 60000), emitted at release.
28
+ *
29
+ * Reports are emitted on `configuration.getErrorEvents()` as `framework-error`
30
+ * (plus the matching `all-error`) with `{context: "advisory-lock-telemetry",
31
+ * event, lockName, backendTier, ...}` payloads, matching the repo's "never
32
+ * swallow" rule so process-level bug reporters capture them.
33
+ *
34
+ * Holder lookups are strictly best-effort *after* the acquisition already
35
+ * settled: a failed lookup never changes the acquisition outcome and never
36
+ * throws to the caller.
37
+ */
38
+
39
+ /**
40
+ * One advisory-lock telemetry report.
41
+ * @typedef {object} AdvisoryLockTelemetryReport
42
+ * @property {"advisory-lock-slow-acquire" | "advisory-lock-acquire-timeout" | "advisory-lock-slow-hold"} event - Which event fired.
43
+ * @property {string} lockName - The contended advisory-lock name.
44
+ * @property {string} backendTier - Tier that served (or refused) the acquisition: `driver-native`, `process-local`, or `file`.
45
+ * @property {number} durationMs - Measured duration of the acquire wait (or the hold).
46
+ * @property {"acquired" | "timeout" | null} outcome - For acquire events: whether the wait eventually acquired the lock. Null for hold reports.
47
+ * @property {number | null} timeoutMs - The effective acquire bound for acquire events. Null for hold reports or an unbounded wait.
48
+ * @property {string | null} holderId - Process-local holder attribution when the tier can name its local holder.
49
+ * @property {string | null} holderConnectionId - Driver-native holder attribution captured at the event moment (MariaDB `IS_USED_LOCK` connection id, PostgreSQL pid, SQL Server session id, SQLite file `pid@hostname`).
50
+ * @property {string | null} holderLookupError - Set when the best-effort holder lookup itself failed; the lookup never affects the acquisition.
51
+ */
52
+
53
+ /**
54
+ * Resolved per-database telemetry thresholds.
55
+ * @typedef {object} AdvisoryLockTelemetryThresholds
56
+ * @property {number | null} slowAcquireMs - Report an acquire wait slower than this; `null` disables slow-acquire reports.
57
+ * @property {number | null} slowHoldMs - Report a hold longer than this at release; `null` disables hold reports.
58
+ */
59
+
60
+ /** Framework default: report an acquire wait that completed slower than this. */
61
+ export const DEFAULT_ADVISORY_LOCK_SLOW_ACQUIRE_MS = 5000
62
+
63
+ /** Framework default: report a hold that outlives this at release. */
64
+ export const DEFAULT_ADVISORY_LOCK_SLOW_HOLD_MS = 60 * 1000
65
+
66
+ /**
67
+ * Resolve the telemetry thresholds for one database configuration.
68
+ * An explicit per-database `advisoryLockTelemetry` value wins; `null` opts out
69
+ * of that report; only when neither is configured does the framework default
70
+ * apply.
71
+ * @param {import("../configuration-types.js").DatabaseConfigurationType} args - Database configuration args.
72
+ * @returns {AdvisoryLockTelemetryThresholds} - Resolved thresholds.
73
+ */
74
+ export function advisoryLockTelemetryThresholds(args) {
75
+ const configured = args?.advisoryLockTelemetry
76
+ /** @type {AdvisoryLockTelemetryThresholds} */
77
+ const thresholds = {
78
+ slowAcquireMs: DEFAULT_ADVISORY_LOCK_SLOW_ACQUIRE_MS,
79
+ slowHoldMs: DEFAULT_ADVISORY_LOCK_SLOW_HOLD_MS
80
+ }
81
+
82
+ if (configured?.slowAcquireMs === null) thresholds.slowAcquireMs = null
83
+ else if (typeof configured?.slowAcquireMs === "number" && configured.slowAcquireMs >= 0) thresholds.slowAcquireMs = configured.slowAcquireMs
84
+
85
+ if (configured?.slowHoldMs === null) thresholds.slowHoldMs = null
86
+ else if (typeof configured?.slowHoldMs === "number" && configured.slowHoldMs >= 0) thresholds.slowHoldMs = configured.slowHoldMs
87
+
88
+ return thresholds
89
+ }
90
+
91
+ /**
92
+ * Emit one telemetry report on the framework-error channel.
93
+ *
94
+ * Mirrors the repo's framework-error emission shape (see `configuration.js`
95
+ * `_reportBeaconError`): when no listener is attached to `framework-error` or
96
+ * `all-error`, a one-line summary goes to `stderr` so a convoy is never
97
+ * completely silent. A listener failure can never break lock handling, so
98
+ * emissions are guarded.
99
+ * @param {import("../configuration.js").default} configuration - Configuration instance owning the error channel.
100
+ * @param {AdvisoryLockTelemetryReport} report - The report payload.
101
+ * @returns {void} - No return value.
102
+ */
103
+ export function emitAdvisoryLockTelemetryReport(configuration, report) {
104
+ /** @type {import("eventemitter3").EventEmitter | undefined} */
105
+ let errorEvents
106
+
107
+ try {
108
+ errorEvents = configuration?.getErrorEvents()
109
+ } catch {
110
+ return // No configuration to report through — never break lock handling.
111
+ }
112
+
113
+ if (!errorEvents) return
114
+
115
+ const hasListener = errorEvents.listenerCount("framework-error") > 0
116
+ || errorEvents.listenerCount("all-error") > 0
117
+ const payload = {context: "advisory-lock-telemetry", ...report}
118
+
119
+ try {
120
+ errorEvents.emit("framework-error", payload)
121
+ errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
122
+ } catch {
123
+ // A broken listener must never surface as a lock failure.
124
+ }
125
+
126
+ if (!hasListener) {
127
+ console.error(`[velocious advisory-lock-telemetry ${report.event}] lock=${JSON.stringify(report.lockName)} tier=${report.backendTier} durationMs=${report.durationMs}${report.outcome ? ` outcome=${report.outcome}` : ""}${report.holderConnectionId ? ` holderConnectionId=${report.holderConnectionId}` : ""}${report.holderId ? ` holderId=${report.holderId}` : ""} — subscribe via configuration.getErrorEvents().on("framework-error", …) to route these reports to your bug reporter`)
128
+ }
129
+ }
@@ -161,6 +161,7 @@ import { getDatabaseAnnotations } from "../annotations.js"
161
161
  import { formatDateForDatabase } from "../datetime-storage.js"
162
162
  import isDate from "../../utils/is-date.js"
163
163
  import {DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS} from "../advisory-lock-bound.js"
164
+ import {advisoryLockTelemetryThresholds, emitAdvisoryLockTelemetryReport} from "../advisory-lock-telemetry.js"
164
165
  import Logger from "../../logger.js"
165
166
  import Query from "../query/index.js"
166
167
  import QueryAbortedError from "../query-aborted-error.js"
@@ -328,6 +329,18 @@ export default class VelociousDatabaseDriversBase {
328
329
  _failedQueryDiagnostics = new WeakMap()
329
330
  /** @type {Map<string, number>} */
330
331
  _heldAdvisoryLocks = new Map()
332
+ /**
333
+ * When each tracked advisory lock was first acquired, so the final release can
334
+ * report a hold that outlived `advisoryLockTelemetry.slowHoldMs`.
335
+ * @type {Map<string, number>}
336
+ */
337
+ _advisoryLockAcquiredAtByName = new Map()
338
+ /**
339
+ * Stable per-connection holder identity used by advisory-lock telemetry when
340
+ * the tier can only name local holders.
341
+ * @type {string | undefined}
342
+ */
343
+ _advisoryLockHolderId = undefined
331
344
  /**
332
345
  * Exclusive operation lease installed by a single-multi-use pool.
333
346
  * @type {import("../operation-lease.js").default | undefined}
@@ -2933,9 +2946,24 @@ export default class VelociousDatabaseDriversBase {
2933
2946
  * @returns {Promise<boolean>} - Resolves to true when the lock has been acquired, false if the timeout elapsed.
2934
2947
  */
2935
2948
  async acquireAdvisoryLock(name, args = {}) {
2936
- const acquired = await this._acquireAdvisoryLock(name, {timeoutMs: this.advisoryLockAcquireTimeoutMs(args)})
2949
+ const timeoutMs = this.advisoryLockAcquireTimeoutMs(args)
2950
+ const startedAt = Date.now()
2951
+ const acquired = await this._acquireAdvisoryLock(name, {timeoutMs})
2952
+ const durationMs = Date.now() - startedAt
2937
2953
 
2938
- if (acquired) this._trackAdvisoryLock(name)
2954
+ if (acquired) {
2955
+ this._trackAdvisoryLock(name)
2956
+
2957
+ const slowAcquireMs = advisoryLockTelemetryThresholds(this.getArgs()).slowAcquireMs
2958
+
2959
+ if (slowAcquireMs !== null && durationMs > slowAcquireMs) {
2960
+ await this._reportAdvisoryLockTelemetry({event: "advisory-lock-slow-acquire", lockName: name, durationMs, outcome: "acquired", timeoutMs})
2961
+ }
2962
+ } else {
2963
+ // The wait exceeded its bound: attribute the lock to the holder that
2964
+ // blocked it, captured at this moment on this tier.
2965
+ await this._reportAdvisoryLockTelemetry({event: "advisory-lock-acquire-timeout", lockName: name, durationMs, outcome: "timeout", timeoutMs, captureHolder: true})
2966
+ }
2939
2967
 
2940
2968
  return acquired
2941
2969
  }
@@ -3008,6 +3036,21 @@ export default class VelociousDatabaseDriversBase {
3008
3036
  this._heldAdvisoryLocks.delete(name)
3009
3037
  }
3010
3038
 
3039
+ if (!this._heldAdvisoryLocks.has(name)) {
3040
+ const acquiredAt = this._advisoryLockAcquiredAtByName.get(name)
3041
+
3042
+ this._advisoryLockAcquiredAtByName.delete(name)
3043
+
3044
+ if (acquiredAt !== undefined) {
3045
+ const holdDurationMs = Date.now() - acquiredAt
3046
+ const slowHoldMs = advisoryLockTelemetryThresholds(this.getArgs()).slowHoldMs
3047
+
3048
+ if (slowHoldMs !== null && holdDurationMs > slowHoldMs) {
3049
+ await this._reportAdvisoryLockTelemetry({event: "advisory-lock-slow-hold", lockName: name, durationMs: holdDurationMs, outcome: null, timeoutMs: null})
3050
+ }
3051
+ }
3052
+ }
3053
+
3011
3054
  return released
3012
3055
  }
3013
3056
 
@@ -3051,6 +3094,8 @@ export default class VelociousDatabaseDriversBase {
3051
3094
  */
3052
3095
  _trackAdvisoryLock(name) {
3053
3096
  this._heldAdvisoryLocks.set(name, (this._heldAdvisoryLocks.get(name) || 0) + 1)
3097
+
3098
+ if (!this._advisoryLockAcquiredAtByName.has(name)) this._advisoryLockAcquiredAtByName.set(name, Date.now())
3054
3099
  }
3055
3100
 
3056
3101
  /**
@@ -3079,4 +3124,71 @@ export default class VelociousDatabaseDriversBase {
3079
3124
  isAdvisoryLockHeld(name) { // eslint-disable-line no-unused-vars
3080
3125
  throw new Error(`'isAdvisoryLockHeld' not implemented for ${this.constructor.name}`)
3081
3126
  }
3127
+
3128
+ /**
3129
+ * Which advisory-lock backend tier this connection serves (STC-210 chain
3130
+ * naming). The base driver is the driver-native tier; the SQLite drivers
3131
+ * override to `process-local` / `file`.
3132
+ * @returns {string} - Tier name reported on telemetry payloads.
3133
+ */
3134
+ advisoryLockBackendTier() {
3135
+ return "driver-native"
3136
+ }
3137
+
3138
+ /**
3139
+ * Stable identity of this connection as a lock holder, reported on
3140
+ * telemetry payloads so a slow acquire/hold is attributable to the reporting
3141
+ * connection itself.
3142
+ * @returns {string} - Holder id.
3143
+ */
3144
+ advisoryLockHolderId() {
3145
+ if (!this._advisoryLockHolderId) this._advisoryLockHolderId = `advisory-lock-holder:${new UUID(4).format()}`
3146
+
3147
+ return this._advisoryLockHolderId
3148
+ }
3149
+
3150
+ /**
3151
+ * Best-effort identity of the connection currently holding the named lock,
3152
+ * captured on the tier that served the acquisition. The base driver has no
3153
+ * portable server-side lookup; MySQL/PostgreSQL/SQL Server/Node-SQLite
3154
+ * override with their tier's holder attribution.
3155
+ * @param {string} name - Lock name.
3156
+ * @returns {Promise<string | null>} - Holder connection identity, or null when the tier cannot attribute it.
3157
+ */
3158
+ async advisoryLockHolderConnectionId(name) { // eslint-disable-line no-unused-vars
3159
+ return null
3160
+ }
3161
+
3162
+ /**
3163
+ * Emit one event-driven advisory-lock telemetry report (slow acquire,
3164
+ * acquire timeout with holder attribution, slow hold). The holder lookup is
3165
+ * best-effort: a failed lookup is recorded on the report and never affects
3166
+ * the acquisition that already settled.
3167
+ * @param {{event: "advisory-lock-slow-acquire" | "advisory-lock-acquire-timeout" | "advisory-lock-slow-hold", lockName: string, durationMs: number, outcome: "acquired" | "timeout" | null, timeoutMs: number | null, captureHolder?: boolean}} args - Report args.
3168
+ * @returns {Promise<void>} - Resolves after the report is emitted.
3169
+ */
3170
+ async _reportAdvisoryLockTelemetry(args) {
3171
+ /** @type {import("../advisory-lock-telemetry.js").AdvisoryLockTelemetryReport} */
3172
+ const report = {
3173
+ event: args.event,
3174
+ lockName: args.lockName,
3175
+ backendTier: this.advisoryLockBackendTier(),
3176
+ durationMs: args.durationMs,
3177
+ outcome: args.outcome,
3178
+ timeoutMs: args.timeoutMs,
3179
+ holderId: this.advisoryLockHolderId(),
3180
+ holderConnectionId: null,
3181
+ holderLookupError: null
3182
+ }
3183
+
3184
+ if (args.captureHolder) {
3185
+ try {
3186
+ report.holderConnectionId = await this.advisoryLockHolderConnectionId(args.lockName)
3187
+ } catch (error) {
3188
+ report.holderLookupError = error instanceof Error ? error.message : String(error)
3189
+ }
3190
+ }
3191
+
3192
+ emitAdvisoryLockTelemetryReport(this.configuration, report)
3193
+ }
3082
3194
  }
@@ -954,4 +954,20 @@ export default class VelociousDatabaseDriversMssql extends Base{
954
954
 
955
955
  return testResult === 0
956
956
  }
957
+
958
+ /**
959
+ * Attributes the current holder of the named application lock to its
960
+ * requesting session from `sys.dm_tran_locks`, captured at the telemetry
961
+ * event moment (best-effort; the caller guards failures).
962
+ * @param {string} name - Lock name.
963
+ * @returns {Promise<string | null>} - SQL Server session id of the holder, or null when no session holds the lock.
964
+ */
965
+ async advisoryLockHolderConnectionId(name) {
966
+ const rows = await this.query(
967
+ `SELECT TOP 1 request_session_id AS velocious_advisory_lock_holder FROM sys.dm_tran_locks WHERE resource_type = 'APPLICATIONLOCK' AND resource_description = ${this.quote(name)}`
968
+ )
969
+ const holder = rows?.[0]?.velocious_advisory_lock_holder
970
+
971
+ return holder === null || holder === undefined ? null : String(holder)
972
+ }
957
973
  }
@@ -882,6 +882,20 @@ export default class VelociousDatabaseDriversMysql extends Base{
882
882
  return Number(result) === 1
883
883
  }
884
884
 
885
+ /**
886
+ * Attributes the current holder of the named lock to its physical session
887
+ * connection id via `IS_USED_LOCK(name)`, captured at the telemetry event
888
+ * moment (best-effort; the caller guards failures).
889
+ * @param {string} name - Lock name.
890
+ * @returns {Promise<string | null>} - MariaDB/MySQL connection id of the holder, or null when no session holds the lock.
891
+ */
892
+ async advisoryLockHolderConnectionId(name) {
893
+ const rows = await this.query(`SELECT IS_USED_LOCK(${this.quote(name)}) AS velocious_advisory_lock_holder`)
894
+ const holder = rows?.[0]?.velocious_advisory_lock_holder
895
+
896
+ return holder === null || holder === undefined ? null : String(holder)
897
+ }
898
+
885
899
  /**
886
900
  * Runs is advisory lock held.
887
901
  * @param {string} name - Lock name.
@@ -505,6 +505,23 @@ export default class VelociousDatabaseDriversPgsql extends Base{
505
505
  return result === true || result === "t" || result === 1 || result === "1"
506
506
  }
507
507
 
508
+ /**
509
+ * Attributes the current holder of the named advisory lock to its backend
510
+ * process id from `pg_locks`, captured at the telemetry event moment
511
+ * (best-effort; the caller guards failures).
512
+ * @param {string} name - Lock name.
513
+ * @returns {Promise<string | null>} - PostgreSQL backend pid of the holder, or null when no session holds the lock.
514
+ */
515
+ async advisoryLockHolderConnectionId(name) {
516
+ const key = this.advisoryLockKey(name)
517
+ const rows = await this.query(
518
+ `SELECT pid FROM pg_locks WHERE locktype = 'advisory' AND ((classid::bigint << 32) | (objid::bigint & 4294967295)) = ${key} LIMIT 1`
519
+ )
520
+ const holder = rows?.[0]?.pid
521
+
522
+ return holder === null || holder === undefined ? null : String(holder)
523
+ }
524
+
508
525
  /**
509
526
  * Runs is advisory lock held.
510
527
  * @param {string} name - Lock name.
@@ -524,4 +524,24 @@ export default class VelociousDatabaseDriversSqliteBase extends Base {
524
524
  async isAdvisoryLockHeld(name) {
525
525
  return VelociousDatabaseDriversSqliteBase._advisoryLockState.ownersByName.has(name)
526
526
  }
527
+
528
+ /**
529
+ * The SQLite emulation is the process-local tier of the STC-210 chain.
530
+ * @returns {string} - Tier name reported on telemetry payloads.
531
+ */
532
+ advisoryLockBackendTier() {
533
+ return "process-local"
534
+ }
535
+
536
+ /**
537
+ * Attribute the named lock to the driver instance that owns it in the
538
+ * shared in-process registry (best-effort; the caller guards failures).
539
+ * @param {string} name - Lock name.
540
+ * @returns {Promise<string | null>} - Owning driver's holder id, or null when no instance holds the lock.
541
+ */
542
+ async advisoryLockHolderConnectionId(name) {
543
+ const owner = VelociousDatabaseDriversSqliteBase._advisoryLockState.ownersByName.get(name)
544
+
545
+ return owner ? owner.advisoryLockHolderId() : null
546
+ }
527
547
  }
@@ -204,6 +204,39 @@ export default class VelociousDatabaseDriversSqliteNode extends Base {
204
204
  return await this._isAdvisoryLockFileHeld(name)
205
205
  }
206
206
 
207
+ /**
208
+ * The Node SQLite driver layers the opt-in file tier on top of the
209
+ * process-local pre-filter, so telemetry reports it as `file`.
210
+ * @returns {string} - Tier name reported on telemetry payloads.
211
+ */
212
+ advisoryLockBackendTier() {
213
+ return "file"
214
+ }
215
+
216
+ /**
217
+ * Attribute the named lock to the file-tier owner recorded in
218
+ * `owner.json` (`pid@hostname`), captured at the telemetry event moment
219
+ * (best-effort; the caller guards failures). Falls back to the
220
+ * process-local owner when the filesystem lock is not the holder.
221
+ * @param {string} name - Lock name.
222
+ * @returns {Promise<string | null>} - File lock owner identity, or null when no process holds the lock.
223
+ */
224
+ async advisoryLockHolderConnectionId(name) {
225
+ const lockPath = this._advisoryLockPath(name)
226
+
227
+ try {
228
+ const content = await fs.readFile(path.join(lockPath, "owner.json"), "utf8")
229
+ const owner = JSON.parse(content)
230
+
231
+ if (owner && typeof owner.pid === "number" && typeof owner.hostname === "string") return `${owner.pid}@${owner.hostname}`
232
+ } catch {
233
+ // No file lock directory or unreadable metadata: fall through to the
234
+ // in-process owner below.
235
+ }
236
+
237
+ return await super.advisoryLockHolderConnectionId(name)
238
+ }
239
+
207
240
  /**
208
241
  * Runs resolve advisory lock directory.
209
242
  * @returns {string} - The advisory-lock directory.
@@ -4,6 +4,11 @@ import {digg} from "diggerize"
4
4
  import TableData from "./table-data/index.js"
5
5
 
6
6
  const TABLE_NAME = "schema_migrations"
7
+ /** SQL Server "There is already an object named ..." — raised when two sessions race to create the same table. */
8
+ const MSSQL_OBJECT_ALREADY_EXISTS_ERROR_NUMBER = 2714
9
+ /** SQL Server deadlock victim — raised when concurrent sessions deadlock on the same catalog reads. */
10
+ const MSSQL_DEADLOCK_VICTIM_ERROR_NUMBER = 1205
11
+
7
12
 
8
13
  /**
9
14
  * Single owner of the `schema_migrations` ledger shape and the only place that reads
@@ -35,24 +40,107 @@ export default class MigrationsLedger {
35
40
  return Boolean(table)
36
41
  }
37
42
 
43
+ /**
44
+ * Whether the error means a concurrent session already created the ledger table.
45
+ *
46
+ * Several processes bootstrap the same database at the same time (for example every
47
+ * CI test shard), and the MSSQL `IF NOT EXISTS (...) BEGIN CREATE TABLE ... END` guard
48
+ * is NOT atomic: two sessions can both evaluate the check as false and then both
49
+ * create, so the loser is rejected. Walks the wrapped-error cause chain because
50
+ * `db.query` re-wraps the driver's `RequestError` in a plain `Error`, and matches the
51
+ * stable numeric error number instead of the localized message text.
52
+ * @param {unknown} error - Error thrown while creating the ledger table.
53
+ * @returns {boolean} - True if the table was created by a concurrent session.
54
+ */
55
+ static isConcurrentCreationError(error) {
56
+ let current = error
57
+
58
+ while (current instanceof Error) {
59
+ const errorNumber = /** @type {{number?: unknown}} */ (current).number
60
+
61
+ if (typeof errorNumber === "number" && errorNumber === MSSQL_OBJECT_ALREADY_EXISTS_ERROR_NUMBER) return true
62
+
63
+ current = current.cause
64
+ }
65
+
66
+ return false
67
+ }
68
+
69
+ /**
70
+ * Whether the error is a transient deadlock that should be retried.
71
+ *
72
+ * Concurrent boots also deadlock on the `INFORMATION_SCHEMA` metadata scan that
73
+ * `getTableByName` performs on SQL Server; the victim transaction rolled back cleanly,
74
+ * so retrying it is safe. Matched by numeric error number, never by message text.
75
+ * @param {unknown} error - Error thrown while inspecting or creating the ledger table.
76
+ * @returns {boolean} - True if the error is a retryable deadlock.
77
+ */
78
+ static isDeadlockError(error) {
79
+ let current = error
80
+
81
+ while (current instanceof Error) {
82
+ const errorNumber = /** @type {{number?: unknown}} */ (current).number
83
+
84
+ if (typeof errorNumber === "number" && errorNumber === MSSQL_DEADLOCK_VICTIM_ERROR_NUMBER) return true
85
+
86
+ current = current.cause
87
+ }
88
+
89
+ return false
90
+ }
91
+
38
92
  /**
39
93
  * Creates the ledger table if it does not exist. This is the single definition of
40
94
  * the `schema_migrations` table shape.
95
+ *
96
+ * Safe under concurrency: a session that loses the create race (or deadlocks on the
97
+ * catalog read) converges on the existing table instead of failing its caller. That
98
+ * matters because this runs on every boot of a database configured for migrations
99
+ * (see `Migrator#createMigrationsTableForDatabase` and
100
+ * `migrateRequireContextForDatabase`), so a crash here takes down a whole test shard
101
+ * or deployment over a table that provably already exists.
41
102
  * @param {import("./drivers/base.js").default} db - Database that should contain the ledger table.
103
+ * @param {{maxAttempts?: number}} [args] - Retry budget for transient catalog deadlocks.
42
104
  * @returns {Promise<void>}
43
105
  */
44
- static async ensureTable(db) {
45
- if (await MigrationsLedger.tableExists(db)) return
106
+ static async ensureTable(db, {maxAttempts = 3} = {}) {
107
+ let attempt = 0
46
108
 
47
- const tableData = new TableData(TABLE_NAME, {ifNotExists: true})
109
+ while (true) {
110
+ attempt++
48
111
 
49
- tableData.string("version", {null: false, primaryKey: true})
112
+ try {
113
+ if (await MigrationsLedger.tableExists(db)) return
50
114
 
51
- for (const sql of await db.createTableSql(tableData)) {
52
- await db.query(sql)
53
- }
115
+ const tableData = new TableData(TABLE_NAME, {ifNotExists: true})
116
+
117
+ tableData.string("version", {null: false, primaryKey: true})
54
118
 
55
- db.clearSchemaCache()
119
+ for (const sql of await db.createTableSql(tableData)) {
120
+ await db.query(sql)
121
+ }
122
+
123
+ db.clearSchemaCache()
124
+
125
+ return
126
+ } catch (error) {
127
+ if (MigrationsLedger.isConcurrentCreationError(error)) {
128
+ // Another session created the ledger between our check and our create. The
129
+ // table exists with exactly this shape, so the goal state is already reached.
130
+ db.clearSchemaCache()
131
+
132
+ return
133
+ }
134
+
135
+ if (MigrationsLedger.isDeadlockError(error) && attempt < maxAttempts) {
136
+ db.clearSchemaCache()
137
+
138
+ continue
139
+ }
140
+
141
+ throw error
142
+ }
143
+ }
56
144
  }
57
145
 
58
146
  /**
@@ -3033,6 +3033,34 @@ class VelociousDatabaseRecord {
3033
3033
  return await runner.withAdvisoryLockOrFail(name, callback, args)
3034
3034
  }
3035
3035
 
3036
+ /**
3037
+ * Runs `callback` if the named advisory lock can be acquired immediately;
3038
+ * otherwise resolves with a `{acquired: false, reason: "contended"}` defer
3039
+ * result instead of throwing. This is the non-throwing counterpart of
3040
+ * `withAdvisoryLockOrFail` for hot paths: contention is a normal outcome,
3041
+ * so callers reschedule via their own event-driven retry machinery with zero
3042
+ * exception traffic and zero pinned connections (the probe is a single
3043
+ * immediate try-acquire on the tier's native try primitive). The result form
3044
+ * is identical across tiers.
3045
+ * @template T
3046
+ * @param {string} name - Lock name.
3047
+ * @param {() => Promise<T>} callback - Callback to invoke while the lock is held.
3048
+ * @param {{holdTimeoutMs?: number | null, dedicatedConnection?: boolean}} [args] - `holdTimeoutMs` caps how long the callback may hold the lock; `dedicatedConnection` spawns a separate lock session without enabling a hold timeout.
3049
+ * @returns {Promise<{acquired: true, result: T} | {acquired: false, reason: "contended", lockName: string}>} - Either the held-callback result or the defer outcome.
3050
+ * @throws {AdvisoryLockHoldTimeoutError} - If `holdTimeoutMs` elapses while the callback holds the lock.
3051
+ */
3052
+ static async withAdvisoryLockOrDefer(name, callback, args = {}) {
3053
+ await this.ensureInitialized()
3054
+
3055
+ const runner = new AdvisoryLockRunner({
3056
+ configuration: this._getConfiguration(),
3057
+ connectionProvider: () => this.connection(),
3058
+ databaseIdentifier: this.getDatabaseIdentifier()
3059
+ })
3060
+
3061
+ return await runner.withAdvisoryLockOrDefer(name, callback, args)
3062
+ }
3063
+
3036
3064
  /**
3037
3065
  * Runs `callback`, rejecting with `AdvisoryLockHoldTimeoutError` if it has
3038
3066
  * not settled within `holdTimeoutMs`. The callback is not cancelled — this is