velocious 1.0.602 → 1.0.604

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 (78) hide show
  1. package/README.md +38 -5
  2. package/build/configuration.js +19 -0
  3. package/build/database/drivers/base.js +296 -46
  4. package/build/database/drivers/mysql/deadlock-diagnostic-parser.js +227 -0
  5. package/build/database/drivers/mysql/index.js +42 -36
  6. package/build/database/pool/async-tracked-multi-connection.js +145 -42
  7. package/build/database/pool/base.js +20 -3
  8. package/build/environment-handlers/base.js +16 -0
  9. package/build/environment-handlers/node/cli/commands/test.js +220 -84
  10. package/build/environment-handlers/node.js +40 -1
  11. package/build/src/configuration.d.ts +9 -0
  12. package/build/src/configuration.d.ts.map +1 -1
  13. package/build/src/configuration.js +17 -1
  14. package/build/src/database/drivers/base.d.ts +150 -4
  15. package/build/src/database/drivers/base.d.ts.map +1 -1
  16. package/build/src/database/drivers/base.js +271 -44
  17. package/build/src/database/drivers/mysql/deadlock-diagnostic-parser.d.ts +80 -0
  18. package/build/src/database/drivers/mysql/deadlock-diagnostic-parser.d.ts.map +1 -0
  19. package/build/src/database/drivers/mysql/deadlock-diagnostic-parser.js +196 -0
  20. package/build/src/database/drivers/mysql/index.d.ts +21 -2
  21. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  22. package/build/src/database/drivers/mysql/index.js +43 -36
  23. package/build/src/database/pool/async-tracked-multi-connection.d.ts +42 -2
  24. package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
  25. package/build/src/database/pool/async-tracked-multi-connection.js +127 -43
  26. package/build/src/database/pool/base.d.ts +18 -1
  27. package/build/src/database/pool/base.d.ts.map +1 -1
  28. package/build/src/database/pool/base.js +18 -3
  29. package/build/src/environment-handlers/base.d.ts +14 -0
  30. package/build/src/environment-handlers/base.d.ts.map +1 -1
  31. package/build/src/environment-handlers/base.js +15 -1
  32. package/build/src/environment-handlers/node/cli/commands/test.d.ts +22 -0
  33. package/build/src/environment-handlers/node/cli/commands/test.d.ts.map +1 -1
  34. package/build/src/environment-handlers/node/cli/commands/test.js +200 -82
  35. package/build/src/environment-handlers/node.d.ts +14 -0
  36. package/build/src/environment-handlers/node.d.ts.map +1 -1
  37. package/build/src/environment-handlers/node.js +38 -2
  38. package/build/src/testing/test-filter-parser.d.ts +15 -0
  39. package/build/src/testing/test-filter-parser.d.ts.map +1 -1
  40. package/build/src/testing/test-filter-parser.js +57 -6
  41. package/build/src/testing/test-profile-activity.d.ts +7 -0
  42. package/build/src/testing/test-profile-activity.d.ts.map +1 -0
  43. package/build/src/testing/test-profile-activity.js +13 -0
  44. package/build/src/testing/test-profile-context.d.ts +15 -0
  45. package/build/src/testing/test-profile-context.d.ts.map +1 -0
  46. package/build/src/testing/test-profile-context.js +25 -0
  47. package/build/src/testing/test-profile-output.d.ts +30 -0
  48. package/build/src/testing/test-profile-output.d.ts.map +1 -0
  49. package/build/src/testing/test-profile-output.js +204 -0
  50. package/build/src/testing/test-profiler.d.ts +668 -0
  51. package/build/src/testing/test-profiler.d.ts.map +1 -0
  52. package/build/src/testing/test-profiler.js +847 -0
  53. package/build/src/testing/test-runner.d.ts +85 -2
  54. package/build/src/testing/test-runner.d.ts.map +1 -1
  55. package/build/src/testing/test-runner.js +182 -33
  56. package/build/testing/test-filter-parser.js +57 -5
  57. package/build/testing/test-profile-activity.js +14 -0
  58. package/build/testing/test-profile-context.js +28 -0
  59. package/build/testing/test-profile-output.js +240 -0
  60. package/build/testing/test-profiler.js +917 -0
  61. package/build/testing/test-runner.js +196 -33
  62. package/build/tsconfig.tsbuildinfo +1 -1
  63. package/package.json +1 -1
  64. package/src/configuration.js +19 -0
  65. package/src/database/drivers/base.js +296 -46
  66. package/src/database/drivers/mysql/deadlock-diagnostic-parser.js +227 -0
  67. package/src/database/drivers/mysql/index.js +42 -36
  68. package/src/database/pool/async-tracked-multi-connection.js +145 -42
  69. package/src/database/pool/base.js +20 -3
  70. package/src/environment-handlers/base.js +16 -0
  71. package/src/environment-handlers/node/cli/commands/test.js +220 -84
  72. package/src/environment-handlers/node.js +40 -1
  73. package/src/testing/test-filter-parser.js +57 -5
  74. package/src/testing/test-profile-activity.js +14 -0
  75. package/src/testing/test-profile-context.js +28 -0
  76. package/src/testing/test-profile-output.js +240 -0
  77. package/src/testing/test-profiler.js +917 -0
  78. package/src/testing/test-runner.js +196 -33
@@ -0,0 +1,227 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * InnodbDeadlockLockNode type.
5
+ * @typedef {object} InnodbDeadlockLockNode
6
+ * @property {string} indexFingerprint - Opaque index identity.
7
+ * @property {string} lockMode - Allowlisted lock mode.
8
+ * @property {"conflicting" | "held" | "waiting"} state - Lock relationship to its transaction.
9
+ * @property {string} tableFingerprint - Opaque table identity.
10
+ */
11
+
12
+ /**
13
+ * InnodbDeadlockTransactionNode type.
14
+ * @typedef {object} InnodbDeadlockTransactionNode
15
+ * @property {InnodbDeadlockLockNode[]} conflictingLocks - Bounded counterparty conflict edges whose owner is unavailable.
16
+ * @property {InnodbDeadlockLockNode[]} locks - Bounded locks owned or awaited by this transaction.
17
+ * @property {number} ordinal - Report-local transaction ordinal.
18
+ */
19
+
20
+ /**
21
+ * InnodbDeadlockParserState type.
22
+ * @typedef {object} InnodbDeadlockParserState
23
+ * @property {InnodbDeadlockTransactionNode | undefined} currentTransaction - Current bounded transaction node.
24
+ * @property {"conflicting" | "held" | "waiting" | undefined} currentLockState - Current lock section state.
25
+ * @property {number} lockRecordCount - Total emitted lock nodes.
26
+ * @property {boolean} lockRecordsTruncated - Whether a lock-node bound was reached.
27
+ * @property {InnodbDeadlockTransactionNode[]} transactionNodes - Bounded transaction nodes.
28
+ * @property {boolean} transactionNodesTruncated - Whether the transaction-node bound was reached.
29
+ * @property {number} transactions - Transaction headers observed in the bounded section.
30
+ * @property {number | null} victimTransaction - Victim ordinal.
31
+ */
32
+
33
+ import sha256Hex from "../../../utils/sha256-hex.js"
34
+
35
+ const INNODB_STATUS_SCAN_MAX_CHARS = 65536
36
+ const INNODB_DEADLOCK_SECTION_MAX_CHARS = 16384
37
+ const INNODB_DEADLOCK_TRANSACTION_MAX = 8
38
+ const INNODB_DEADLOCK_LOCKS_PER_TRANSACTION_MAX = 8
39
+ const INNODB_DEADLOCK_LOCK_RECORD_MAX = 32
40
+ const INNODB_DEADLOCK_LINE_MAX_CHARS = 1024
41
+
42
+ /**
43
+ * Parses a bounded InnoDB latest-deadlock section into safe structural context.
44
+ * @param {string} status - SHOW ENGINE INNODB STATUS text.
45
+ * @returns {{lockRecordsTruncated: boolean, sectionTruncated: boolean, transactionNodes: InnodbDeadlockTransactionNode[], transactionNodesTruncated: boolean, transactions: number, victimTransaction: number | null}} - Structural deadlock summary.
46
+ */
47
+ export default function parseInnodbDeadlockSummary(status) {
48
+ const {candidate, sectionTruncated} = boundedDeadlockSection(status)
49
+ /** @type {InnodbDeadlockParserState} */
50
+ const state = {
51
+ currentTransaction: undefined,
52
+ currentLockState: undefined,
53
+ lockRecordCount: 0,
54
+ lockRecordsTruncated: false,
55
+ transactionNodes: [],
56
+ transactionNodesTruncated: false,
57
+ transactions: 0,
58
+ victimTransaction: null
59
+ }
60
+
61
+ for (const line of candidate.split(/\r?\n/)) {
62
+ const trimmed = line.slice(0, INNODB_DEADLOCK_LINE_MAX_CHARS).trim()
63
+ const transactionOrdinal = transactionHeaderOrdinal(trimmed)
64
+
65
+ if (transactionOrdinal !== undefined) {
66
+ startTransactionNode(state, transactionOrdinal)
67
+ continue
68
+ }
69
+
70
+ const lockStateMarker = transactionLockStateMarker(trimmed, state.currentTransaction)
71
+
72
+ if (lockStateMarker.matched) {
73
+ state.currentLockState = lockStateMarker.state
74
+ continue
75
+ }
76
+
77
+ const victimOrdinal = victimTransactionOrdinal(trimmed)
78
+
79
+ if (victimOrdinal !== undefined) state.victimTransaction = victimOrdinal
80
+ appendLockNode(state, trimmed)
81
+ }
82
+
83
+ return {
84
+ lockRecordsTruncated: state.lockRecordsTruncated,
85
+ sectionTruncated,
86
+ transactionNodes: state.transactionNodes,
87
+ transactionNodesTruncated: state.transactionNodesTruncated,
88
+ transactions: state.transactions,
89
+ victimTransaction: state.victimTransaction
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Extracts and caps the latest-deadlock section.
95
+ * @param {string} status - Raw server status.
96
+ * @returns {{candidate: string, sectionTruncated: boolean}} - Bounded candidate and truncation state.
97
+ */
98
+ function boundedDeadlockSection(status) {
99
+ const scannedStatus = status.slice(0, INNODB_STATUS_SCAN_MAX_CHARS)
100
+ const deadlockStart = scannedStatus.indexOf("LATEST DETECTED DEADLOCK")
101
+ const availableSection = deadlockStart >= 0 ? scannedStatus.slice(deadlockStart) : ""
102
+ const boundedSection = availableSection.slice(0, INNODB_DEADLOCK_SECTION_MAX_CHARS)
103
+ const sectionEndMatch = /\n-{10,}\r?\nTRANSACTIONS\r?\n-{10,}/.exec(boundedSection)
104
+ const scannedStatusTruncated = status.length > scannedStatus.length
105
+
106
+ return {
107
+ candidate: sectionEndMatch ? boundedSection.slice(0, sectionEndMatch.index) : boundedSection,
108
+ sectionTruncated: !sectionEndMatch && deadlockStart >= 0 && (
109
+ availableSection.length > boundedSection.length || scannedStatusTruncated
110
+ )
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Returns a fixed-format transaction header ordinal.
116
+ * @param {string} line - Bounded status line.
117
+ * @returns {number | undefined} - Transaction ordinal.
118
+ */
119
+ function transactionHeaderOrdinal(line) {
120
+ const match = /^\*\*\* \((\d{1,6})\) TRANSACTION:$/.exec(line)
121
+
122
+ return match ? Number(match[1]) : undefined
123
+ }
124
+
125
+ /**
126
+ * Starts a bounded transaction node.
127
+ * @param {InnodbDeadlockParserState} state - Parser state.
128
+ * @param {number} ordinal - Transaction ordinal.
129
+ * @returns {void}
130
+ */
131
+ function startTransactionNode(state, ordinal) {
132
+ state.transactions++
133
+ state.currentLockState = undefined
134
+
135
+ if (state.transactionNodes.length >= INNODB_DEADLOCK_TRANSACTION_MAX) {
136
+ state.currentTransaction = undefined
137
+ state.transactionNodesTruncated = true
138
+ return
139
+ }
140
+
141
+ state.currentTransaction = {conflictingLocks: [], locks: [], ordinal}
142
+ state.transactionNodes.push(state.currentTransaction)
143
+ }
144
+
145
+ /**
146
+ * Parses a fixed-format held/waiting marker for the current transaction.
147
+ * @param {string} line - Bounded status line.
148
+ * @param {InnodbDeadlockTransactionNode | undefined} currentTransaction - Current transaction.
149
+ * @returns {{matched: boolean, state: "conflicting" | "held" | "waiting" | undefined}} - Marker result.
150
+ */
151
+ function transactionLockStateMarker(line, currentTransaction) {
152
+ const numberedMatch = /^\*\*\* \((\d{1,6})\) (HOLDS THE LOCK\(S\)|WAITING FOR THIS LOCK TO BE GRANTED):$/.exec(line)
153
+
154
+ if (numberedMatch) {
155
+ if (!currentTransaction || currentTransaction.ordinal != Number(numberedMatch[1])) return {matched: true, state: undefined}
156
+
157
+ return {matched: true, state: numberedMatch[2] == "HOLDS THE LOCK(S)" ? "held" : "waiting"}
158
+ }
159
+
160
+ const unnumberedMatch = /^\*\*\* (WAITING FOR THIS LOCK TO BE GRANTED|CONFLICTING WITH):$/.exec(line)
161
+
162
+ if (!unnumberedMatch) return {matched: false, state: undefined}
163
+ if (!currentTransaction) return {matched: true, state: undefined}
164
+
165
+ return {matched: true, state: unnumberedMatch[1] == "CONFLICTING WITH" ? "conflicting" : "waiting"}
166
+ }
167
+
168
+ /**
169
+ * Returns a fixed-format victim ordinal.
170
+ * @param {string} line - Bounded status line.
171
+ * @returns {number | undefined} - Victim ordinal.
172
+ */
173
+ function victimTransactionOrdinal(line) {
174
+ const match = /^\*\*\* WE ROLL BACK TRANSACTION \((\d{1,6})\)$/.exec(line)
175
+
176
+ return match ? Number(match[1]) : undefined
177
+ }
178
+
179
+ /**
180
+ * Appends one bounded, fixed-format lock node when the line is eligible.
181
+ * @param {InnodbDeadlockParserState} state - Parser state.
182
+ * @param {string} line - Bounded status line.
183
+ * @returns {void}
184
+ */
185
+ function appendLockNode(state, line) {
186
+ if (!state.currentTransaction || !state.currentLockState || !line.startsWith("RECORD LOCKS ")) return
187
+
188
+ if (
189
+ state.lockRecordCount >= INNODB_DEADLOCK_LOCK_RECORD_MAX ||
190
+ state.currentTransaction.locks.length + state.currentTransaction.conflictingLocks.length >= INNODB_DEADLOCK_LOCKS_PER_TRANSACTION_MAX
191
+ ) {
192
+ state.lockRecordsTruncated = true
193
+ return
194
+ }
195
+
196
+ const lock = deadlockLockNode(line, state.currentLockState)
197
+
198
+ if (!lock) return
199
+
200
+ if (state.currentLockState == "conflicting") {
201
+ state.currentTransaction.conflictingLocks.push(lock)
202
+ } else {
203
+ state.currentTransaction.locks.push(lock)
204
+ }
205
+ state.lockRecordCount++
206
+ }
207
+
208
+ /**
209
+ * Parses one fixed-format RECORD LOCKS line into safe structural fields.
210
+ * @param {string} line - One bounded InnoDB status line.
211
+ * @param {"conflicting" | "held" | "waiting"} state - Lock relationship to its transaction.
212
+ * @returns {InnodbDeadlockLockNode | undefined} - Safe lock node.
213
+ */
214
+ function deadlockLockNode(line, state) {
215
+ const identifier = "(?:`(?:``|[^`\\r\\n]){1,128}`|[A-Za-z0-9_$-]{1,128})"
216
+ const tableIdentifier = `(?:${identifier}\\.)?${identifier}`
217
+ const lockMatch = new RegExp(`^RECORD LOCKS .{1,512}? index (${identifier}) of table (${tableIdentifier}) trx id \\S{1,64}(?: \\S{1,64})? lock_mode (X|S|IX|IS|AUTO_INC)(?:\\s|$)`).exec(line)
218
+
219
+ if (!lockMatch) return undefined
220
+
221
+ return {
222
+ indexFingerprint: `sha256:${sha256Hex(`innodb-index:v1\0${lockMatch[1]}`)}`,
223
+ lockMode: lockMatch[3],
224
+ state,
225
+ tableFingerprint: `sha256:${sha256Hex(`innodb-table:v1\0${lockMatch[2]}`)}`
226
+ }
227
+ }
@@ -21,6 +21,7 @@ import Table from "./table.js"
21
21
  import StructureSql from "./structure-sql.js"
22
22
  import Upsert from "./sql/upsert.js"
23
23
  import Update from "./sql/update.js"
24
+ import parseInnodbDeadlockSummary from "./deadlock-diagnostic-parser.js"
24
25
 
25
26
  /**
26
27
  * Sentinel timeout (in seconds) used as the "block forever" value when a
@@ -365,14 +366,12 @@ export default class VelociousDatabaseDriversMysql extends Base{
365
366
  // A deadlock or lock-wait-timeout aborts the whole transaction; it must be retried at the
366
367
  // transaction level (re-running the callback), not the query level, so flag it as such and
367
368
  // keep `retry` false so an in-transaction query does not retry against the dead transaction.
368
- if (
369
- errorCode == "ER_LOCK_DEADLOCK" ||
370
- errorCode == "ER_LOCK_WAIT_TIMEOUT" ||
371
- message.includes("ER_LOCK_DEADLOCK") ||
372
- message.includes("Deadlock found") ||
373
- message.includes("Lock wait timeout exceeded")
374
- ) {
375
- return {retry: false, reconnect: false, deadlock: true, waitMs: 50}
369
+ if (errorCode == "ER_LOCK_DEADLOCK" || message.includes("ER_LOCK_DEADLOCK") || message.includes("Deadlock found")) {
370
+ return {retry: false, reconnect: false, deadlock: true, contentionKind: "deadlock", waitMs: 50}
371
+ }
372
+
373
+ if (errorCode == "ER_LOCK_WAIT_TIMEOUT" || message.includes("Lock wait timeout exceeded")) {
374
+ return {retry: false, reconnect: false, deadlock: true, contentionKind: "lock-wait-timeout", waitMs: 50}
376
375
  }
377
376
 
378
377
  shouldReconnect ||= (
@@ -397,19 +396,24 @@ export default class VelociousDatabaseDriversMysql extends Base{
397
396
  * Adds a redacted, bounded excerpt from MySQL's latest InnoDB deadlock report. Capture uses a
398
397
  * separate short-lived connection so it cannot queue ahead of rollback or the next retry on this
399
398
  * driver's single-connection pool.
399
+ * @param {import("../base.js").DeadlockRetryDiagnosticSnapshot} snapshot - Immutable retry snapshot.
400
400
  * @returns {Promise<Record<string, ReturnType<typeof JSON.parse>>>} - Safe diagnostic context.
401
401
  */
402
- async _deadlockDiagnosticContext() {
403
- try {
404
- const status = await this._captureInnodbDeadlockStatus()
402
+ async _deadlockDiagnosticContext(snapshot) {
403
+ if (snapshot.contentionKind == "lock-wait-timeout") return {statusCapture: "not-applicable"}
405
404
 
406
- return {
407
- innodbDeadlockSummary: this._innodbDeadlockSummary(status),
408
- statusCapture: "captured"
409
- }
405
+ let status
406
+
407
+ try {
408
+ status = await this._captureInnodbDeadlockStatus()
410
409
  } catch {
411
410
  return {statusCapture: "failed"}
412
411
  }
412
+
413
+ return {
414
+ innodbDeadlockSummary: this._innodbDeadlockSummary(status),
415
+ statusCapture: "captured"
416
+ }
413
417
  }
414
418
 
415
419
  /**
@@ -465,23 +469,10 @@ export default class VelociousDatabaseDriversMysql extends Base{
465
469
  * Extracts only fixed-format deadlock counters. The server report contains raw SQL, identifiers,
466
470
  * and physical record data, so no source text is ever included in an application diagnostic.
467
471
  * @param {string} status - SHOW ENGINE INNODB STATUS text.
468
- * @returns {{transactions: number, victimTransaction: number | null}} - Structural deadlock summary.
472
+ * @returns {{lockRecordsTruncated: boolean, sectionTruncated: boolean, transactionNodes: Array<{conflictingLocks: Array<{indexFingerprint: string, lockMode: string, state: string, tableFingerprint: string}>, locks: Array<{indexFingerprint: string, lockMode: string, state: string, tableFingerprint: string}>, ordinal: number}>, transactionNodesTruncated: boolean, transactions: number, victimTransaction: number | null}} - Structural deadlock summary.
469
473
  */
470
474
  _innodbDeadlockSummary(status) {
471
- const deadlockStart = status.indexOf("LATEST DETECTED DEADLOCK")
472
- const candidate = deadlockStart >= 0 ? status.slice(deadlockStart) : status
473
- let transactions = 0
474
- /** @type {number | null} */
475
- let victimTransaction = null
476
-
477
- for (const line of candidate.split(/\r?\n/)) {
478
- const trimmed = line.trim()
479
- if (/^\*\*\* \(\d+\) TRANSACTION:$/.test(trimmed)) transactions++
480
- const victimMatch = /^\*\*\* WE ROLL BACK TRANSACTION \((\d+)\)$/.exec(trimmed)
481
- if (victimMatch) victimTransaction = Number(victimMatch[1])
482
- }
483
-
484
- return {transactions, victimTransaction}
475
+ return parseInnodbDeadlockSummary(status)
485
476
  }
486
477
 
487
478
  /**
@@ -527,7 +518,15 @@ export default class VelociousDatabaseDriversMysql extends Base{
527
518
  if (!this.pool) await this.connect()
528
519
  if (!this.pool) throw new Error("MySQL pool failed to initialize")
529
520
 
530
- yield* streamQuery(this.pool, sql)
521
+ const profileAttempt = this._startProfiledQueryAttempt(sql)
522
+ let failed = true
523
+
524
+ try {
525
+ yield* streamQuery(this.pool, sql)
526
+ failed = false
527
+ } finally {
528
+ this._finishProfiledQueryAttempt(profileAttempt, failed)
529
+ }
531
530
  }
532
531
 
533
532
  /**
@@ -567,13 +566,20 @@ export default class VelociousDatabaseDriversMysql extends Base{
567
566
  if (!this.pool) throw new Error("MySQL pool failed to initialize")
568
567
 
569
568
  const pool = this.pool
569
+ const profileAttempt = this._startProfiledQueryAttempt(structureSql)
570
+ let failed = true
570
571
 
571
- await new Promise((resolve, reject) => {
572
- pool.query(structureSql, (error) => {
573
- if (error) reject(error)
574
- else resolve(undefined)
572
+ try {
573
+ await new Promise((resolve, reject) => {
574
+ pool.query(structureSql, (error) => {
575
+ if (error) reject(error)
576
+ else resolve(undefined)
577
+ })
575
578
  })
576
- })
579
+ failed = false
580
+ } finally {
581
+ this._finishProfiledQueryAttempt(profileAttempt, failed)
582
+ }
577
583
 
578
584
  return true
579
585
  }
@@ -1,7 +1,8 @@
1
1
  // @ts-check
2
2
 
3
- import {AsyncLocalStorage} from "async_hooks"
4
- import BasePool, {POOL_CONFIGURATION_KEY} from "./base.js"
3
+ import { AsyncLocalStorage } from "async_hooks"
4
+ import BasePool, { POOL_CONFIGURATION_KEY } from "./base.js"
5
+ import { currentTestProfileContext } from "../../testing/test-profile-context.js"
5
6
 
6
7
  /**
7
8
  * PendingCheckout type.
@@ -15,6 +16,7 @@ import BasePool, {POOL_CONFIGURATION_KEY} from "./base.js"
15
16
  * @property {number | null} timeoutAt - Timestamp when the checkout will time out, or null when disabled.
16
17
  * @property {number | null} timeoutMillis - Milliseconds to wait before rejecting, or null when disabled.
17
18
  * @property {ReturnType<typeof setTimeout> | undefined} timeoutTimer - Timer that rejects the pending checkout.
19
+ * @property {import("../../testing/test-profiler.js").TestProfileAsyncContext | undefined} [testProfileContext] - Async-safe profile attribution captured at enqueue.
18
20
  */
19
21
  export const CLOSED_CONNECTION = Symbol("velociousClosedConnection")
20
22
  const IDLE_CONNECTION_CHECKED_IN_AT = Symbol("velociousIdleConnectionCheckedInAt")
@@ -115,10 +117,20 @@ export default class VelociousDatabasePoolAsyncTrackedMultiConnection extends Ba
115
117
 
116
118
  /** Cumulative low-cardinality pool telemetry. */
117
119
  telemetry = {
120
+ connectionCreationCount: 0,
121
+ connectionCreationFailureCount: 0,
122
+ connectionCreationMaxMs: 0,
123
+ connectionCreationTotalMs: 0,
124
+ checkoutTimeoutCount: 0,
118
125
  checkoutWaitCount: 0,
119
126
  checkoutWaitMaxMs: 0,
120
127
  checkoutWaitTotalMs: 0,
121
- idleReapDisposalCount: 0
128
+ idleReapCount: 0,
129
+ idleReapDisposalCount: 0,
130
+ idleReapFailureCount: 0,
131
+ idleReapMaxMs: 0,
132
+ idleReapTotalMs: 0,
133
+ peakLiveConnections: 0
122
134
  }
123
135
 
124
136
  idSeq = 0
@@ -139,6 +151,58 @@ export default class VelociousDatabasePoolAsyncTrackedMultiConnection extends Ba
139
151
  this._withoutCurrentConnectionContext = withoutCurrentConnectionContext
140
152
  }
141
153
 
154
+ /**
155
+ * Returns the pool telemetry clock.
156
+ * @returns {number} - Current time in milliseconds.
157
+ */
158
+ nowMs() { return Date.now() }
159
+
160
+ /**
161
+ * Records a pool metric in the active async-safe test profile context.
162
+ * @param {import("../../testing/test-profiler.js").TestProfileAsyncContext | undefined} context - Captured profile context.
163
+ * @param {"connectionCreation" | "checkoutWait" | "checkoutTimeout" | "idleReap" | "idleReapDisposal" | "peakLiveConnections"} metric - Metric name.
164
+ * @param {{durationMs?: number, failed?: boolean, value?: number}} [values] - Aggregate values.
165
+ * @returns {void}
166
+ */
167
+ recordTestProfilePoolMetric(context, metric, values = {}) {
168
+ if (!context) return
169
+
170
+ context.profiler.recordPoolMetric(context, this.identifier, metric, values)
171
+ }
172
+
173
+ /**
174
+ * Spawns and times a physical connection without retaining its configuration.
175
+ * @param {import("../../configuration-types.js").DatabaseConfigurationType} config - Resolved database configuration.
176
+ * @returns {Promise<import("../drivers/base.js").default>} - Connected driver.
177
+ */
178
+ async spawnConnectionWithConfiguration(config) {
179
+ const startedAt = this.nowMs()
180
+ const profileContext = currentTestProfileContext(this.configuration)
181
+ let failed = true
182
+
183
+ try {
184
+ const connection = await super.spawnConnectionWithConfiguration(config)
185
+
186
+ failed = false
187
+ const liveConnectionCount = this.liveConnectionCount() - this.connectionsBeingSpawned + 1
188
+
189
+ if (liveConnectionCount > this.telemetry.peakLiveConnections) {
190
+ this.telemetry.peakLiveConnections = liveConnectionCount
191
+ this.recordTestProfilePoolMetric(profileContext, "peakLiveConnections", {value: liveConnectionCount})
192
+ }
193
+
194
+ return connection
195
+ } finally {
196
+ const durationMs = Math.max(0, this.nowMs() - startedAt)
197
+
198
+ this.telemetry.connectionCreationCount++
199
+ if (failed) this.telemetry.connectionCreationFailureCount++
200
+ this.telemetry.connectionCreationTotalMs += durationMs
201
+ this.telemetry.connectionCreationMaxMs = Math.max(this.telemetry.connectionCreationMaxMs, durationMs)
202
+ this.recordTestProfilePoolMetric(profileContext, "connectionCreation", {durationMs, failed})
203
+ }
204
+ }
205
+
142
206
  /**
143
207
  * Runs checkin.
144
208
  * @param {import("../drivers/base.js").default} connection - Database connection instance.
@@ -241,24 +305,28 @@ export default class VelociousDatabasePoolAsyncTrackedMultiConnection extends Ba
241
305
  * @returns {Promise<import("../drivers/base.js").default>} - Resolves with the checkout.
242
306
  */
243
307
  async checkout(options = {}) {
244
- const databaseConfig = this.getConfiguration()
245
- const reuseKey = this.getConfigurationReuseKey()
308
+ let databaseConfig = this.getConfiguration()
309
+ let reuseKey = this.getConfigurationReuseKey(databaseConfig)
246
310
  let connection = this.takeIdleConnectionForReuseKey(reuseKey)
247
311
 
248
312
  if (connection) return await this.activateConnection(connection, options)
249
313
 
250
314
  await this.reapIdleConnections()
315
+ databaseConfig = this.getConfiguration()
316
+ reuseKey = this.getConfigurationReuseKey(databaseConfig)
251
317
  connection = this.takeIdleConnectionForReuseKey(reuseKey)
252
318
 
253
319
  if (connection) return await this.activateConnection(connection, options)
254
320
 
255
- if (this.canSpawnConnection()) {
256
- // Spawn via spawnConnection() so the tenant-aware configuration is resolved FRESH at
257
- // spawn time for the current caller. Reusing the databaseConfig captured at the top of
258
- // checkout() could bind the connection to a stale tenant/database, which breaks
259
- // per-request isolation (e.g. test truncation appearing not to take effect). The queued
260
- // path below keeps the waiting caller's captured config via waitForCheckout().
261
- connection = await this.spawnConnectionForCheckout(this.getConfiguration(), this.getConfigurationReuseKey())
321
+ if (this.canSpawnConnection(databaseConfig)) {
322
+ // The post-reap configuration is fresh for the current caller, and its reuse key is
323
+ // derived from this exact captured object so the connection cannot open one tenant while
324
+ // being stamped for another. The queued path retains the same captured pair.
325
+ connection = await this.spawnConnectionForCheckout(
326
+ databaseConfig,
327
+ reuseKey,
328
+ currentTestProfileContext(this.configuration)
329
+ )
262
330
 
263
331
  return await this.activateConnection(connection, options)
264
332
  }
@@ -291,7 +359,11 @@ export default class VelociousDatabasePoolAsyncTrackedMultiConnection extends Ba
291
359
  if (connection) return await this.activateConnection(connection, options)
292
360
 
293
361
  if (this.canSpawnConnection(databaseConfig)) {
294
- connection = await this.spawnConnectionForCheckout(databaseConfig, reuseKey)
362
+ connection = await this.spawnConnectionForCheckout(
363
+ databaseConfig,
364
+ reuseKey,
365
+ currentTestProfileContext(this.configuration)
366
+ )
295
367
 
296
368
  return await this.activateConnection(connection, options)
297
369
  }
@@ -436,21 +508,20 @@ export default class VelociousDatabasePoolAsyncTrackedMultiConnection extends Ba
436
508
  * Runs spawn connection for checkout.
437
509
  * @param {import("../../configuration-types.js").DatabaseConfigurationType} databaseConfig - Resolved database config for the checkout.
438
510
  * @param {string} reuseKey - Database configuration reuse key for the checkout.
511
+ * @param {import("../../testing/test-profiler.js").TestProfileAsyncContext | undefined} profileContext - Profile context captured when checkout began.
439
512
  * @returns {Promise<import("../drivers/base.js").default>} - Spawned connection.
440
513
  */
441
- async spawnConnectionForCheckout(databaseConfig, reuseKey) {
514
+ async spawnConnectionForCheckout(databaseConfig, reuseKey, profileContext) {
442
515
  this.connectionsBeingSpawned++
443
516
 
444
517
  try {
445
- const connection = await this.spawnConnectionWithConfiguration(databaseConfig)
446
- const connectionWithPoolKey = /** @type {import("../drivers/base.js").default & {[POOL_CONFIGURATION_KEY]?: string}} */ (connection)
447
-
448
- connectionWithPoolKey[POOL_CONFIGURATION_KEY] = reuseKey
449
- connection.setSchemaCacheInvalidator(() => {
450
- this.clearSchemaCache()
451
- this.configuration.clearSchemaCachesForReuseKey(reuseKey)
518
+ const environmentHandler = this.configuration.getEnvironmentHandler()
519
+ const connection = await environmentHandler.runWithTestProfileContext(profileContext, async () => {
520
+ return await this.spawnConnectionWithConfiguration(databaseConfig)
452
521
  })
453
522
 
523
+ this.stampConnectionForConfigurationReuseKey(connection, reuseKey)
524
+
454
525
  return connection
455
526
  } finally {
456
527
  this.connectionsBeingSpawned--
@@ -478,7 +549,8 @@ export default class VelociousDatabasePoolAsyncTrackedMultiConnection extends Ba
478
549
  reuseKey,
479
550
  timeoutAt: timeoutMillis === null ? null : enqueuedAt + timeoutMillis,
480
551
  timeoutMillis,
481
- timeoutTimer: undefined
552
+ timeoutTimer: undefined,
553
+ testProfileContext: currentTestProfileContext(this.configuration)
482
554
  }
483
555
 
484
556
  checkout.timeoutTimer = this.startPendingCheckoutTimeout(checkout)
@@ -578,11 +650,12 @@ export default class VelociousDatabasePoolAsyncTrackedMultiConnection extends Ba
578
650
  * @returns {void}
579
651
  */
580
652
  recordCheckoutWait(checkout) {
581
- const waitedForMs = Math.max(0, Date.now() - checkout.enqueuedAt)
653
+ const waitedForMs = Math.max(0, this.nowMs() - checkout.enqueuedAt)
582
654
 
583
655
  this.telemetry.checkoutWaitCount++
584
656
  this.telemetry.checkoutWaitTotalMs += waitedForMs
585
657
  this.telemetry.checkoutWaitMaxMs = Math.max(this.telemetry.checkoutWaitMaxMs, waitedForMs)
658
+ this.recordTestProfilePoolMetric(checkout.testProfileContext, "checkoutWait", {durationMs: waitedForMs})
586
659
  }
587
660
 
588
661
  /**
@@ -611,6 +684,8 @@ export default class VelociousDatabasePoolAsyncTrackedMultiConnection extends Ba
611
684
  if (index === -1) return
612
685
 
613
686
  this.removePendingCheckoutAt(index)
687
+ this.telemetry.checkoutTimeoutCount++
688
+ this.recordTestProfilePoolMetric(checkout.testProfileContext, "checkoutTimeout")
614
689
  checkout.reject(this.pendingCheckoutTimeoutError(checkout))
615
690
  }
616
691
 
@@ -744,16 +819,24 @@ export default class VelociousDatabasePoolAsyncTrackedMultiConnection extends Ba
744
819
  * @returns {Promise<void>} - Resolves when the checkout has been handled.
745
820
  */
746
821
  async spawnAndResolvePendingCheckout(checkout) {
747
- let connection
822
+ const environmentHandler = this.configuration.getEnvironmentHandler()
748
823
 
749
- try {
750
- connection = await this.spawnConnectionForCheckout(checkout.databaseConfig, checkout.reuseKey)
751
- } catch (error) {
752
- checkout.reject(error instanceof Error ? error : new Error("Failed to spawn database connection.", {cause: error}))
753
- return
754
- }
824
+ return await environmentHandler.runWithTestProfileContext(checkout.testProfileContext, async () => {
825
+ let connection
826
+
827
+ try {
828
+ connection = await this.spawnConnectionForCheckout(
829
+ checkout.databaseConfig,
830
+ checkout.reuseKey,
831
+ checkout.testProfileContext
832
+ )
833
+ } catch (error) {
834
+ checkout.reject(error instanceof Error ? error : new Error("Failed to spawn database connection.", {cause: error}))
835
+ return
836
+ }
755
837
 
756
- await this.resolvePendingCheckout(checkout, connection)
838
+ await this.resolvePendingCheckout(checkout, connection)
839
+ })
757
840
  }
758
841
 
759
842
  /**
@@ -763,11 +846,15 @@ export default class VelociousDatabasePoolAsyncTrackedMultiConnection extends Ba
763
846
  * @returns {Promise<void>} - Resolves when the checkout has been handled.
764
847
  */
765
848
  async resolvePendingCheckout(checkout, connection) {
766
- try {
767
- checkout.resolve(await this.activateConnection(connection, checkout.options))
768
- } catch (error) {
769
- checkout.reject(error instanceof Error ? error : new Error("Failed to activate database connection.", {cause: error}))
770
- }
849
+ const environmentHandler = this.configuration.getEnvironmentHandler()
850
+
851
+ return await environmentHandler.runWithTestProfileContext(checkout.testProfileContext, async () => {
852
+ try {
853
+ checkout.resolve(await this.activateConnection(connection, checkout.options))
854
+ } catch (error) {
855
+ checkout.reject(error instanceof Error ? error : new Error("Failed to activate database connection.", {cause: error}))
856
+ }
857
+ })
771
858
  }
772
859
 
773
860
  /**
@@ -1313,24 +1400,40 @@ export default class VelociousDatabasePoolAsyncTrackedMultiConnection extends Ba
1313
1400
  const idleTimeoutMillis = this.idleTimeoutMillis()
1314
1401
 
1315
1402
  if (idleTimeoutMillis === null) return
1403
+ const startedAt = this.nowMs()
1404
+ const profileContext = currentTestProfileContext(this.configuration)
1405
+ let failed = true
1406
+
1407
+ try {
1408
+ const {expiredConnections, keptConnections} = this.classifyIdleConnectionsForReaping({idleTimeoutMillis, now: this.nowMs()})
1316
1409
 
1317
- const {expiredConnections, keptConnections} = this.classifyIdleConnectionsForReaping({idleTimeoutMillis, now: Date.now()})
1410
+ this.connections = keptConnections
1411
+ await this.closeExpiredIdleConnections(expiredConnections, profileContext)
1412
+ await this.awaitInflightConnectionCloses()
1413
+ if (this.connections.length > 0) this.scheduleIdleConnectionReaper()
1414
+ failed = false
1415
+ } finally {
1416
+ const durationMs = Math.max(0, this.nowMs() - startedAt)
1318
1417
 
1319
- this.connections = keptConnections
1320
- await this.closeExpiredIdleConnections(expiredConnections)
1321
- await this.awaitInflightConnectionCloses()
1322
- if (this.connections.length > 0) this.scheduleIdleConnectionReaper()
1418
+ this.telemetry.idleReapCount++
1419
+ if (failed) this.telemetry.idleReapFailureCount++
1420
+ this.telemetry.idleReapTotalMs += durationMs
1421
+ this.telemetry.idleReapMaxMs = Math.max(this.telemetry.idleReapMaxMs, durationMs)
1422
+ this.recordTestProfilePoolMetric(profileContext, "idleReap", {durationMs, failed})
1423
+ }
1323
1424
  }
1324
1425
 
1325
1426
  /**
1326
1427
  * Runs close expired idle connections.
1327
1428
  * @param {import("../drivers/base.js").default[]} expiredConnections - Connections to close.
1429
+ * @param {import("../../testing/test-profiler.js").TestProfileAsyncContext | undefined} [profileContext] - Reaper profile context.
1328
1430
  * @returns {Promise<void>} - Resolves when closed.
1329
1431
  */
1330
- async closeExpiredIdleConnections(expiredConnections) {
1432
+ async closeExpiredIdleConnections(expiredConnections, profileContext) {
1331
1433
  for (const connection of expiredConnections) {
1332
1434
  await this.closeConnection(connection)
1333
1435
  this.telemetry.idleReapDisposalCount++
1436
+ this.recordTestProfilePoolMetric(profileContext, "idleReapDisposal")
1334
1437
  }
1335
1438
  }
1336
1439