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
package/README.md CHANGED
@@ -26,6 +26,7 @@
26
26
  * Gap-less positional lists with automatic reordering via `actsAsList`, including models with numeric, string, or UUID primary keys (see [docs/acts-as-list.md](docs/acts-as-list.md))
27
27
  * Rails-style nested-attribute writes on frontend-model `save()` (see [docs/nested-attributes.md](docs/nested-attributes.md))
28
28
  * Async-aware test-data factories with inherited traits, graph-first native association autosave, metadata-aware override precedence, callbacks, sequences, linting, and a process-global reload-retention budget that bounds cache-busted re-import memory (see [docs/factories.md](docs/factories.md))
29
+ * Opt-in Benchmark-style test profiling with privacy-safe rich JSON and directly reusable duration-aware shard manifests (see [docs/test-profiling.md](docs/test-profiling.md))
29
30
  * Per-row association counts via `.withCount(...)`, including cohort-safe intersected filters, safe batching of structurally identical aggregates, and automatic IN-list chunking for large parent sets, on frontend and backend queries (see [docs/with-count.md](docs/with-count.md))
30
31
  * Consumer-defined per-row SQL aggregates/computations via `.queryData(...)`, with compatible projections sharing a roundtrip while preserving declared alias-overwrite order and automatic IN-list chunking for large parent sets, on frontend and backend queries (see [docs/query-data.md](docs/query-data.md))
31
32
  * Per-record ability checks via `.abilities(...)` on frontend queries + `record.can(action)` (see [docs/abilities.md](docs/abilities.md))
@@ -227,6 +228,22 @@ Slowest 10 tests:
227
228
 
228
229
  The report is skipped for single-test runs. See [docs/testing-guidelines.md](docs/testing-guidelines.md).
229
230
 
231
+ Add `--profile` for a compact Benchmark-style phase and pool summary. Use
232
+ `--profile-json <path>` for versioned, privacy-safe detail or
233
+ `--timing-manifest-output <path>` to generate a sorted per-file duration map for
234
+ the existing `--timing-manifest` shard input; either output flag implies
235
+ profiling.
236
+
237
+ ```bash
238
+ npx velocious test --profile-json tmp/test-profile.json \
239
+ --timing-manifest-output tmp/test-timings.json
240
+ npx velocious test --groups=4 --group-number=1 \
241
+ --timing-manifest tmp/test-timings.json
242
+ ```
243
+
244
+ See [test profiling](docs/test-profiling.md) for lifecycle accounting, custom
245
+ activity spans, schema, and privacy guarantees.
246
+
230
247
  Prefer waiting for a real signal or condition over sleeping a fixed duration. `waitForEvent(emitter, eventName, {timeoutMs, filter})` resolves the instant a matching event fires (a background job finishing, a model update, a websocket message) and rejects on timeout; for polling an arbitrary condition, use awaitery's `waitFor`.
231
248
 
232
249
  ```js
@@ -1769,7 +1786,12 @@ database: {
1769
1786
 
1770
1787
  `pool.max` caps live async-tracked connections for that pool and defaults to `10` when omitted. When the cap is reached, new checkouts wait until a matching checked-in connection can be handed over or capacity is freed. Set `pool.max` to `null` only when a process is deliberately allowed to open an unbounded number of database connections. The built-in debug endpoint reports each in-use connection's `checkedOutForMs`, each idle connection's `idleForMs`, and queued `pendingCheckouts[].waitingForMs` so production diagnostics can distinguish long-held checkouts from pool-capacity waits.
1771
1788
 
1772
- Debug snapshots also expose cumulative checkout-wait and idle-reaper disposal telemetry. The [MySQL idle-reaping benchmark and methodology](docs/mysql-pool-idle-reaping-research.md) compare the 5-second default with 60 seconds and disabled reaping under a fixed cap; absent representative measured evidence, retain the 5-second default.
1789
+ Debug snapshots also expose cumulative connection-creation, checkout-wait and
1790
+ timeout, idle-reap, and peak-live-connection telemetry. Opt-in test profiles can
1791
+ attribute safe aggregate deltas to their current spans. The [MySQL idle-reaping
1792
+ benchmark and methodology](docs/mysql-pool-idle-reaping-research.md) compare the
1793
+ 5-second default with 60 seconds and disabled reaping under a fixed cap; absent
1794
+ representative measured evidence, retain the 5-second default.
1773
1795
 
1774
1796
  # Websockets
1775
1797
 
@@ -1921,10 +1943,21 @@ Genuinely unexpected frontend-model command failures reach this bus too. The fro
1921
1943
 
1922
1944
  Unexpected inbound decoded WebSocket dispatch failures emit one `framework-error` and one matching `all-error`. Established expected client-flow errors remain excluded from both events.
1923
1945
 
1924
- MySQL/MariaDB transaction deadlock retries emit a structured `database-deadlock-retry` event (also
1925
- mirrored to `all-error`) with retry metadata, a redacted SQL fingerprint when available, and a
1926
- best-effort bounded/redacted InnoDB deadlock excerpt. Diagnostic capture never joins the retry
1927
- control flow. See [deadlock retry diagnostics](docs/logging.md#deadlock-retry-diagnostics).
1946
+ MySQL/MariaDB transaction contention that will run another outer attempt emits a structured
1947
+ `database-deadlock-retry` event through `configuration.getErrorEvents()` and mirrors it to `all-error` with
1948
+ `errorType: "database-deadlock-retry"`. Required context fields are `stage`, `driverType`,
1949
+ `contentionKind`, `attempt`, `maxAttempts`, `willRetry` (always `true` for this retry-only event), and
1950
+ `transactionAttemptDurationMs`. Exhausted/non-retried contention emits no retry event.
1951
+ Pool-owned connections optionally add logical `databaseIdentifier` and opaque
1952
+ `databaseIdentifierFingerprint`/`databaseIdentityFingerprint` fields; the logical identifier itself
1953
+ is always redacted. Named checkouts always redact `operationName` and add only its bounded opaque
1954
+ `operationNameFingerprint` for correlation. Query failures add
1955
+ `sqlOperation`/`sqlFingerprint`. True deadlocks may add `statusCapture` and the bounded structural
1956
+ `innodbDeadlockSummary`, including explicit bounded MariaDB counterparty conflict edges whose owner
1957
+ is intentionally not inferred; lock-wait timeouts report `statusCapture: "not-applicable"` and never attach a
1958
+ historical graph. Capture, parsing, and listeners remain detached from rollback and retry control
1959
+ flow. See the exact identity, redaction, structural bounds, failure-channel, and optional-field
1960
+ contract in [deadlock retry diagnostics](docs/logging.md#deadlock-retry-diagnostics).
1928
1961
 
1929
1962
  ## Use the Websocket client API (HTTP-like)
1930
1963
 
@@ -35,6 +35,7 @@ import {frontendModelApiManifest, frontendModelResourceClassFromDefinition, fron
35
35
  import {currentOfflineGrantSigningKey, normalizeOfflineGrantSigningKey} from "./sync/offline-grant.js"
36
36
  import PluginRoutes from "./routes/plugin-routes.js"
37
37
  import restArgsError from "./utils/rest-args-error.js"
38
+ import { validateTestActivityName } from "./testing/test-profile-activity.js"
38
39
  import {validateTimeZone} from "./time-zone.js"
39
40
  import {withTrackedStack} from "./utils/with-tracked-stack.js"
40
41
  import VelociousPackage from "./packages/velocious-package.js"
@@ -3150,6 +3151,24 @@ export default class VelociousConfiguration {
3150
3151
  return await this.getEnvironmentHandler().runWithRequestTiming(requestTiming, callback)
3151
3152
  }
3152
3153
 
3154
+ /**
3155
+ * Profiles an application-defined test activity when an opt-in test profile
3156
+ * context is active. The callback always runs, including outside profiling.
3157
+ * @template T
3158
+ * @param {string} name - Low-cardinality activity identifier.
3159
+ * @param {() => (T | Promise<T>)} callback - Activity callback.
3160
+ * @returns {Promise<T>} - Callback result.
3161
+ */
3162
+ async profileTestActivity(name, callback) {
3163
+ const validatedName = validateTestActivityName(name)
3164
+
3165
+ const context = this.getEnvironmentHandler().getCurrentTestProfileContext()
3166
+
3167
+ if (!context) return await callback()
3168
+
3169
+ return await context.profiler.profileActivity(context, validatedName, callback)
3170
+ }
3171
+
3153
3172
  /**
3154
3173
  * Runs run with timezone.
3155
3174
  * @param {string} timeZone - IANA timezone identifier.
@@ -54,6 +54,7 @@
54
54
  * @property {boolean} retry - Whether the error should be retried.
55
55
  * @property {boolean} reconnect - Whether to reconnect before retrying.
56
56
  * @property {boolean} [deadlock] - Whether the error is a transaction deadlock/lock-wait-timeout that should retry the whole transaction.
57
+ * @property {"deadlock" | "lock-wait-timeout"} [contentionKind] - Classified transaction contention kind.
57
58
  * @property {number} [maxTries] - Override the max retry attempts.
58
59
  * @property {number} [waitMs] - Wait time before retrying in milliseconds.
59
60
  */
@@ -71,6 +72,33 @@
71
72
  * @property {symbol} [operationOwner] - Opaque owner for an operation-leased connection.
72
73
  */
73
74
 
75
+ /**
76
+ * DeadlockRetryDiagnosticSnapshot type.
77
+ * @typedef {object} DeadlockRetryDiagnosticSnapshot
78
+ * @property {number} attempt - One-based transaction attempt.
79
+ * @property {"deadlock" | "lock-wait-timeout"} contentionKind - Classified contention kind.
80
+ * @property {string} [databaseIdentifier] - Redacted logical database pool identifier marker.
81
+ * @property {string} [databaseIdentifierFingerprint] - Opaque logical database pool identity.
82
+ * @property {string} [databaseIdentityFingerprint] - Opaque physical database identity.
83
+ * @property {string} driverType - Driver type.
84
+ * @property {number} maxAttempts - Configured transaction attempt budget.
85
+ * @property {string} [operationName] - Redacted operation-name marker.
86
+ * @property {string} [operationNameFingerprint] - Opaque operation-name identity.
87
+ * @property {string} [sqlFingerprint] - Normalized SQL-shape fingerprint.
88
+ * @property {string} [sqlOperation] - SQL verb.
89
+ * @property {string} stage - Error-event stage.
90
+ * @property {number} transactionAttemptDurationMs - Duration of the failed outer attempt.
91
+ * @property {boolean} willRetry - Whether another outer transaction attempt will run.
92
+ */
93
+
94
+ /**
95
+ * TestProfileQueryAttempt type.
96
+ * @typedef {object} TestProfileQueryAttempt
97
+ * @property {import("../../testing/test-profiler.js").TestProfileAsyncContext} context - Captured async attribution.
98
+ * @property {{sqlFingerprint: string, sqlOperation: string}} diagnostic - Redacted statement diagnostic.
99
+ * @property {number} startedAtMs - Physical attempt start time.
100
+ */
101
+
74
102
  /**
75
103
  * ActiveQueryDebugSnapshot type.
76
104
  * @typedef {object} ActiveQueryDebugSnapshot
@@ -144,19 +172,24 @@ import TableForeignKey from "../table-data/table-foreign-key.js"
144
172
  import wait from "awaitery/build/wait.js"
145
173
  import { optionalPositiveInteger } from "typanic"
146
174
  import { coordinateSharedTransactionConnection } from "../../testing/shared-transaction-connection-coordinator.js"
175
+ import { currentTestProfileContext } from "../../testing/test-profile-context.js"
176
+ import sha256Hex from "../../utils/sha256-hex.js"
147
177
 
148
178
  /** Maximum characters inspected when building the debug SQL preview. */
149
179
  const SQL_PREVIEW_SCAN_LIMIT = 4096
150
180
  /** Maximum characters inspected when deciding whether a statement invalidates schema metadata. */
151
181
  const SCHEMA_INVALIDATION_SCAN_LIMIT = 8192
182
+ /** Maximum checkout-name characters inspected by retry diagnostics. */
183
+ const OPERATION_NAME_SCAN_LIMIT = 1024
184
+ const REDACTED_DIAGNOSTIC_LABEL = "[REDACTED]"
152
185
 
153
186
  /**
154
187
  * Builds a non-reversible, stable SQL fingerprint without retaining SQL text. Literal spelling is
155
188
  * normalized first so the same statement shape produces the same fingerprint across values.
156
189
  * @param {string} sql - SQL to fingerprint.
157
- * @returns {string} - Bounded fingerprint.
190
+ * @returns {{sqlFingerprint: string, sqlOperation: string}} - Bounded query diagnostic.
158
191
  */
159
- function sqlFingerprint(sql) {
192
+ function sqlDiagnostic(sql) {
160
193
  let fingerprintInput = ""
161
194
 
162
195
  for (let index = 0; index < sql.length;) {
@@ -206,7 +239,12 @@ function sqlFingerprint(sql) {
206
239
  hash = BigInt.asUintN(64, hash * 0x100000001b3n)
207
240
  }
208
241
 
209
- return `fnv1a64:${hash.toString(16).padStart(16, "0")}`
242
+ const operationMatch = /^([a-z]+)/.exec(normalized)
243
+
244
+ return {
245
+ sqlFingerprint: `fnv1a64:${hash.toString(16).padStart(16, "0")}`,
246
+ sqlOperation: operationMatch ? operationMatch[1].toUpperCase() : "UNKNOWN"
247
+ }
210
248
  }
211
249
 
212
250
  /**
@@ -270,6 +308,10 @@ export default class VelociousDatabaseDriversBase {
270
308
  * Narrows the runtime value to the documented type.
271
309
  * @type {string | undefined} */
272
310
  _connectionCheckoutName
311
+ /** @type {string | undefined} */
312
+ _databaseIdentifier
313
+ /** @type {string | undefined} */
314
+ _databaseIdentityFingerprint
273
315
  /**
274
316
  * Active query.
275
317
  * @type {ActiveQueryState | null} */
@@ -465,6 +507,16 @@ export default class VelociousDatabaseDriversBase {
465
507
  this._connectionCheckedOutAtUnixMs = undefined
466
508
  }
467
509
 
510
+ /**
511
+ * Sets the pool-owned identity used by safe database diagnostics.
512
+ * @param {{databaseIdentifier: string, databaseIdentityFingerprint: string}} identity - Pool-stamped identity redacted at diagnostic snapshot time.
513
+ * @returns {void}
514
+ */
515
+ setPoolDiagnosticIdentity({databaseIdentifier, databaseIdentityFingerprint}) {
516
+ this._databaseIdentifier = databaseIdentifier
517
+ this._databaseIdentityFingerprint = databaseIdentityFingerprint
518
+ }
519
+
468
520
  /**
469
521
  * Runs reconnect.
470
522
  * @returns {Promise<void>} - Resolves when complete.
@@ -1304,6 +1356,7 @@ export default class VelociousDatabaseDriversBase {
1304
1356
 
1305
1357
  while (true) {
1306
1358
  attempt++
1359
+ const attemptStartedAtMs = this._nowMs()
1307
1360
 
1308
1361
  try {
1309
1362
  return await this._runTransactionAttempt(callback, options)
@@ -1312,9 +1365,17 @@ export default class VelociousDatabaseDriversBase {
1312
1365
  if (!(error instanceof Error)) throw error
1313
1366
 
1314
1367
  const retryInfo = this.retryableDatabaseError(error)
1315
-
1316
- if (retryInfo.deadlock && attempt < maxAttempts && this._transactionsCount == 0) {
1317
- this._reportDeadlockRetryDiagnostic({attempt, error, maxAttempts})
1368
+ const willRetry = Boolean(retryInfo.deadlock && attempt < maxAttempts && this._transactionsCount == 0)
1369
+
1370
+ if (willRetry) {
1371
+ this._reportDeadlockRetryDiagnostic({
1372
+ attempt,
1373
+ contentionKind: retryInfo.contentionKind || "deadlock",
1374
+ error,
1375
+ maxAttempts,
1376
+ transactionAttemptDurationMs: Math.max(0, this._nowMs() - attemptStartedAtMs),
1377
+ willRetry
1378
+ })
1318
1379
 
1319
1380
  // An explicitly-configured base wins so the tuning knob is effective even on drivers
1320
1381
  // whose classifier supplies its own `waitMs` (MySQL/MariaDB return a fixed 50ms for
@@ -1330,7 +1391,9 @@ export default class VelociousDatabaseDriversBase {
1330
1391
  const ceilingWaitMs = Math.min(baseWaitMs * (2 ** (attempt - 1)), deadlockMaxWaitMs)
1331
1392
  const jitteredWaitMs = Math.floor(Math.random() * (ceilingWaitMs + 1))
1332
1393
 
1333
- this.logger.warn(`Retrying transaction after deadlock (attempt ${attempt}/${maxAttempts})`)
1394
+ const loggedContentionKind = retryInfo.contentionKind || "transaction contention"
1395
+
1396
+ this.logger.warn(`Retrying transaction after ${loggedContentionKind} (attempt ${attempt}/${maxAttempts})`)
1334
1397
  await this._waitMs(jitteredWaitMs)
1335
1398
  continue
1336
1399
  }
@@ -1350,29 +1413,67 @@ export default class VelociousDatabaseDriversBase {
1350
1413
  await wait(ms)
1351
1414
  }
1352
1415
 
1416
+ /**
1417
+ * Returns the clock used for transaction-attempt diagnostics.
1418
+ * @returns {number} - Monotonic milliseconds where available.
1419
+ */
1420
+ _nowMs() {
1421
+ return nowMs()
1422
+ }
1423
+
1353
1424
  /**
1354
1425
  * Starts best-effort deadlock diagnostics without joining the retry control flow. Subclasses may
1355
1426
  * add bounded driver-specific context; capture and event-listener failures cannot affect retry.
1356
- * @param {{attempt: number, error: Error, maxAttempts: number}} args - Retry metadata.
1427
+ * @param {{attempt: number, contentionKind: "deadlock" | "lock-wait-timeout", error: Error, maxAttempts: number, transactionAttemptDurationMs: number, willRetry: boolean}} args - Retry metadata.
1357
1428
  * @returns {void}
1358
1429
  */
1359
- _reportDeadlockRetryDiagnostic({attempt, error, maxAttempts}) {
1360
- const queryDiagnostic = this._failedQueryDiagnostics.get(error)
1430
+ _reportDeadlockRetryDiagnostic({attempt, contentionKind, error, maxAttempts, transactionAttemptDurationMs, willRetry}) {
1431
+ let snapshot
1432
+
1433
+ try {
1434
+ const queryDiagnostic = this._failedQueryDiagnostics.get(error)
1435
+
1436
+ snapshot = Object.freeze({
1437
+ attempt,
1438
+ contentionKind,
1439
+ driverType: this.getType(),
1440
+ maxAttempts,
1441
+ stage: "database-deadlock-retry",
1442
+ transactionAttemptDurationMs,
1443
+ willRetry,
1444
+ ...this._poolDiagnosticIdentityContext(),
1445
+ ...this._operationDiagnosticContext(),
1446
+ ...queryDiagnostic
1447
+ })
1448
+ } catch (diagnosticError) {
1449
+ this._reportDeadlockDiagnosticPipelineFailure(diagnosticError)
1450
+ return
1451
+ }
1452
+
1453
+ let driverContextResult
1454
+
1455
+ try {
1456
+ driverContextResult = this._deadlockDiagnosticContext(snapshot)
1457
+ } catch (diagnosticError) {
1458
+ this._reportDeadlockDiagnosticPipelineFailure(diagnosticError)
1459
+ return
1460
+ }
1361
1461
 
1362
- void this._deadlockDiagnosticContext()
1462
+ const hasPromiseContract = driverContextResult instanceof Promise
1463
+
1464
+ void Promise.resolve(driverContextResult)
1363
1465
  .then((driverContext) => {
1466
+ if (!hasPromiseContract) throw new Error("Database deadlock diagnostic context must return a Promise")
1467
+
1364
1468
  const context = {
1365
- attempt,
1366
- driverType: this.getType(),
1367
- maxAttempts,
1368
- stage: "database-deadlock-retry",
1369
- willRetry: true,
1370
- ...queryDiagnostic,
1469
+ ...snapshot,
1371
1470
  ...driverContext
1372
1471
  }
1373
1472
  const payload = {
1374
1473
  context,
1375
- error: new Error("Database transaction deadlock will be retried")
1474
+ error: new Error(willRetry
1475
+ ? `Database transaction ${contentionKind} will be retried`
1476
+ : `Database transaction ${contentionKind} exhausted its retry budget`)
1376
1477
  }
1377
1478
  const errorEvents = this.configuration.getErrorEvents()
1378
1479
 
@@ -1388,30 +1489,93 @@ export default class VelociousDatabaseDriversBase {
1388
1489
  this.logger.warn("Database deadlock retry all-error listener failed", {error: eventError})
1389
1490
  }
1390
1491
  })
1391
- .catch((diagnosticError) => {
1392
- const normalizedError = diagnosticError instanceof Error
1393
- ? diagnosticError
1394
- : new Error("Database deadlock retry diagnostic failed", {cause: diagnosticError})
1395
- const payload = {
1396
- context: {stage: "database-deadlock-retry-diagnostic"},
1397
- error: normalizedError
1398
- }
1492
+ .catch((diagnosticError) => this._reportDeadlockDiagnosticPipelineFailure(diagnosticError))
1493
+ }
1399
1494
 
1400
- try {
1401
- const errorEvents = this.configuration.getErrorEvents()
1402
- errorEvents.emit("framework-error", payload)
1403
- errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
1404
- } catch (reportingError) {
1405
- this.logger.warn("Database deadlock retry diagnostic pipeline failed", {error: normalizedError, reportingError})
1406
- }
1407
- })
1495
+ /**
1496
+ * Returns pool identity only when this driver was stamped by a pool.
1497
+ * @returns {{databaseIdentifier?: string, databaseIdentifierFingerprint?: string, databaseIdentityFingerprint?: string}} - Safe pool identity.
1498
+ */
1499
+ _poolDiagnosticIdentityContext() {
1500
+ if (this._databaseIdentifier === undefined || !this._databaseIdentityFingerprint) return {}
1501
+
1502
+ const identifierFingerprintInput = typeof this._databaseIdentifier === "string"
1503
+ ? this._databaseIdentifier
1504
+ : `invalid:${typeof this._databaseIdentifier}`
1505
+ const databaseIdentifierFingerprint = `sha256:${sha256Hex(`database-logical-identifier:v1\0${identifierFingerprintInput}`)}`
1506
+
1507
+ return {
1508
+ databaseIdentifier: REDACTED_DIAGNOSTIC_LABEL,
1509
+ databaseIdentifierFingerprint,
1510
+ databaseIdentityFingerprint: this._databaseIdentityFingerprint
1511
+ }
1512
+ }
1513
+
1514
+ /**
1515
+ * Builds the bounded operation portion of an immutable retry snapshot.
1516
+ * @returns {{operationName?: string, operationNameFingerprint?: string}} - Safe operation fields.
1517
+ */
1518
+ _operationDiagnosticContext() {
1519
+ const rawOperationName = this._connectionCheckoutName
1520
+
1521
+ if (rawOperationName === undefined) return {}
1522
+ if (typeof rawOperationName !== "string") {
1523
+ return {
1524
+ operationName: REDACTED_DIAGNOSTIC_LABEL,
1525
+ operationNameFingerprint: `sha256:${sha256Hex(`database-operation:v1\0invalid:${typeof rawOperationName}`)}`
1526
+ }
1527
+ }
1528
+
1529
+ const scannedOperationName = rawOperationName.slice(0, OPERATION_NAME_SCAN_LIMIT)
1530
+ const operationNameFingerprint = `sha256:${sha256Hex(`database-operation:v1\0${scannedOperationName}\0length:${rawOperationName.length}`)}`
1531
+
1532
+ return {
1533
+ operationName: REDACTED_DIAGNOSTIC_LABEL,
1534
+ operationNameFingerprint
1535
+ }
1536
+ }
1537
+
1538
+ /**
1539
+ * Reports an unexpected detached diagnostics failure without changing transaction control flow.
1540
+ * @param {ReturnType<typeof JSON.parse>} diagnosticError - Diagnostics failure.
1541
+ * @returns {void}
1542
+ */
1543
+ _reportDeadlockDiagnosticPipelineFailure(diagnosticError) {
1544
+ const normalizedError = diagnosticError instanceof Error
1545
+ ? diagnosticError
1546
+ : new Error("Database deadlock retry diagnostic failed", {cause: diagnosticError})
1547
+ const payload = {
1548
+ context: {stage: "database-deadlock-retry-diagnostic"},
1549
+ error: normalizedError
1550
+ }
1551
+ let errorEvents
1552
+
1553
+ try {
1554
+ errorEvents = this.configuration.getErrorEvents()
1555
+ } catch (reportingError) {
1556
+ this.logger.warn("Database deadlock retry diagnostic pipeline reporting failed", {error: normalizedError, reportingError})
1557
+ return
1558
+ }
1559
+
1560
+ try {
1561
+ errorEvents.emit("framework-error", payload)
1562
+ } catch (reportingError) {
1563
+ this.logger.warn("Database deadlock retry framework-error listener failed", {error: normalizedError, reportingError})
1564
+ }
1565
+
1566
+ try {
1567
+ errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
1568
+ } catch (reportingError) {
1569
+ this.logger.warn("Database deadlock retry all-error listener failed", {error: normalizedError, reportingError})
1570
+ }
1408
1571
  }
1409
1572
 
1410
1573
  /**
1411
1574
  * Builds driver-specific deadlock context. The base driver has no server diagnostic source.
1575
+ * @param {DeadlockRetryDiagnosticSnapshot} _snapshot - Immutable retry snapshot.
1412
1576
  * @returns {Promise<Record<string, ReturnType<typeof JSON.parse>>>} - Safe context fields.
1413
1577
  */
1414
- async _deadlockDiagnosticContext() {
1578
+ async _deadlockDiagnosticContext(_snapshot) {
1415
1579
  return {}
1416
1580
  }
1417
1581
 
@@ -1585,7 +1749,9 @@ export default class VelociousDatabaseDriversBase {
1585
1749
  return
1586
1750
  }
1587
1751
 
1588
- await this._startTransactionAction(options)
1752
+ await this._runProfiledTransactionAction("start", async () => {
1753
+ await this._startTransactionAction(options)
1754
+ })
1589
1755
  this._transactionsCount++
1590
1756
 
1591
1757
  if (this._transactionsCount === 1) {
@@ -1617,7 +1783,9 @@ export default class VelociousDatabaseDriversBase {
1617
1783
  */
1618
1784
  async commitTransaction(options = {}) {
1619
1785
  await this._transactionsActionsMutex.sync(async () => {
1620
- await this._commitTransactionAction(options)
1786
+ await this._runProfiledTransactionAction("commit", async () => {
1787
+ await this._commitTransactionAction(options)
1788
+ })
1621
1789
  this._transactionsCount--
1622
1790
  this._resolveCompletedTransaction()
1623
1791
  })
@@ -1642,6 +1810,68 @@ export default class VelociousDatabaseDriversBase {
1642
1810
  await this.query("COMMIT", options)
1643
1811
  }
1644
1812
 
1813
+ /**
1814
+ * Times a physical transaction action only when test profiling is active.
1815
+ * @template T
1816
+ * @param {"start" | "commit" | "rollback"} action - Transaction action.
1817
+ * @param {() => Promise<T>} callback - Physical action callback.
1818
+ * @returns {Promise<T>} - Callback result.
1819
+ */
1820
+ async _runProfiledTransactionAction(action, callback) {
1821
+ const profileContext = currentTestProfileContext(this.configuration)
1822
+
1823
+ if (!profileContext) return await callback()
1824
+
1825
+ const startedAtMs = nowMs()
1826
+ let failed = true
1827
+
1828
+ try {
1829
+ const result = await callback()
1830
+
1831
+ failed = false
1832
+ return result
1833
+ } finally {
1834
+ profileContext.profiler.recordDatabaseTransaction(profileContext, {
1835
+ action,
1836
+ durationMs: nowMs() - startedAtMs,
1837
+ failed
1838
+ })
1839
+ }
1840
+ }
1841
+
1842
+ /**
1843
+ * Starts an optional physical-query profile attempt without retaining SQL.
1844
+ * @param {string} sql - Original SQL used only to derive its redacted diagnostic.
1845
+ * @returns {TestProfileQueryAttempt | undefined} - Active profile handle.
1846
+ */
1847
+ _startProfiledQueryAttempt(sql) {
1848
+ const context = currentTestProfileContext(this.configuration)
1849
+
1850
+ if (!context) return undefined
1851
+
1852
+ return {
1853
+ context,
1854
+ diagnostic: sqlDiagnostic(sql),
1855
+ startedAtMs: nowMs()
1856
+ }
1857
+ }
1858
+
1859
+ /**
1860
+ * Completes an optional physical-query profile attempt.
1861
+ * @param {TestProfileQueryAttempt | undefined} attempt - Profile handle.
1862
+ * @param {boolean} failed - Whether the physical driver call failed.
1863
+ * @returns {void}
1864
+ */
1865
+ _finishProfiledQueryAttempt(attempt, failed) {
1866
+ if (!attempt) return
1867
+
1868
+ attempt.context.profiler.recordDatabaseQuery(attempt.context, {
1869
+ durationMs: nowMs() - attempt.startedAtMs,
1870
+ failed,
1871
+ ...attempt.diagnostic
1872
+ })
1873
+ }
1874
+
1645
1875
  /**
1646
1876
  * Runs every guard registered to the transaction frame.
1647
1877
  * @param {TransactionCallbackFrame} callbackFrame - Frame whose guards are completing.
@@ -1716,10 +1946,7 @@ export default class VelociousDatabaseDriversBase {
1716
1946
  } catch (error) {
1717
1947
  if (!(error instanceof Error)) throw error
1718
1948
 
1719
- this._failedQueryDiagnostics.set(error, {
1720
- sqlFingerprint: sqlFingerprint(sql),
1721
- sqlOperation: sql.trim().split(/\s+/, 1)[0]?.toUpperCase() || "UNKNOWN"
1722
- })
1949
+ this._failedQueryDiagnostics.set(error, sqlDiagnostic(sql))
1723
1950
 
1724
1951
  // A deliberately-aborted query must never be silently re-run — its
1725
1952
  // connection was destroyed on purpose, so treat it as terminal.
@@ -1764,7 +1991,17 @@ export default class VelociousDatabaseDriversBase {
1764
1991
  await this.beforeQuery(sql, options)
1765
1992
 
1766
1993
  try {
1767
- return await this._affectedRowsActual(sql)
1994
+ const profileAttempt = this._startProfiledQueryAttempt(sql)
1995
+ let failed = true
1996
+
1997
+ try {
1998
+ const affectedRows = await this._affectedRowsActual(sql)
1999
+
2000
+ failed = false
2001
+ return affectedRows
2002
+ } finally {
2003
+ this._finishProfiledQueryAttempt(profileAttempt, failed)
2004
+ }
1768
2005
  } finally {
1769
2006
  await this.afterQuery(sql, options)
1770
2007
  }
@@ -1793,7 +2030,7 @@ export default class VelociousDatabaseDriversBase {
1793
2030
  let result
1794
2031
 
1795
2032
  try {
1796
- const runQueryActualWithHooks = async () => await this._queryActualWithHooks(querySql, options)
2033
+ const runQueryActualWithHooks = async () => await this._queryActualWithHooks(querySql, options, originalSql)
1797
2034
 
1798
2035
  if (requestTiming && tries === 1) {
1799
2036
  result = await requestTiming.measureDbQuery(runQueryActualWithHooks)
@@ -1828,14 +2065,25 @@ export default class VelociousDatabaseDriversBase {
1828
2065
  * Runs query actual with before/after hooks.
1829
2066
  * @param {string} sql - SQL string.
1830
2067
  * @param {QueryOptions} options - Query options.
2068
+ * @param {string} originalSql - SQL before process-list comments.
1831
2069
  * @returns {Promise<QueryResultType>} - Resolves with the query.
1832
2070
  */
1833
- async _queryActualWithHooks(sql, options) {
2071
+ async _queryActualWithHooks(sql, options, originalSql) {
1834
2072
  return await coordinateSharedTransactionConnection(this, async () => {
1835
2073
  await this.beforeQuery(sql, options)
1836
2074
 
1837
2075
  try {
1838
- return await this._queryActual(sql, options)
2076
+ const profileAttempt = this._startProfiledQueryAttempt(originalSql)
2077
+ let failed = true
2078
+
2079
+ try {
2080
+ const result = await this._queryActual(sql, options)
2081
+
2082
+ failed = false
2083
+ return result
2084
+ } finally {
2085
+ this._finishProfiledQueryAttempt(profileAttempt, failed)
2086
+ }
1839
2087
  } finally {
1840
2088
  await this.afterQuery(sql, options)
1841
2089
  }
@@ -2251,7 +2499,9 @@ export default class VelociousDatabaseDriversBase {
2251
2499
  async rollbackTransaction(options = {}) {
2252
2500
  await this._transactionsActionsMutex.sync(async () => {
2253
2501
  try {
2254
- await this._rollbackTransactionAction(options)
2502
+ await this._runProfiledTransactionAction("rollback", async () => {
2503
+ await this._rollbackTransactionAction(options)
2504
+ })
2255
2505
  } finally {
2256
2506
  this._transactionsCount--
2257
2507
  this._resolveCompletedTransaction()