diff --git a/packages/drivers/driver-sql/src/live-dialect-matrix.budget.test.ts b/packages/drivers/driver-sql/src/live-dialect-matrix.budget.test.ts new file mode 100644 index 0000000000..800b1d4f5e --- /dev/null +++ b/packages/drivers/driver-sql/src/live-dialect-matrix.budget.test.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16434] The live-cell test budget, pinned to the two bounds it was DERIVED + * from rather than to the literal it happens to be. + * + * `LIVE_CELL_TIMEOUT_MS`'s docblock states the derivation; this file makes it + * executable, in the shape #13691 used for `MAX_SPAN_MS` — "the derivation is + * pinned by arithmetic ... so the two halves cannot drift apart in silence". + * Three things could move it and none of them would touch this constant: + * + * - the driver's own connection bounds (`withConnectBound`), which the budget + * must stay ABOVE so a connect fault reports the driver's envelope and not + * vitest's stopwatch; + * - the live job's stall guard, which the budget must stay BELOW so a hung + * live test is NAMED instead of being swallowed as an unattributed stall; + * - vitest's own cascade rules, which are what makes one seam-level suite + * option reach 40 files' live cells while leaving their explicit per-`it` + * budgets alone. + * + * ⚠️ Every bound here is read from the thing it describes — a constructed knex + * config, the workflow file — never re-typed. A pin that copies both sides of + * an equality cannot fail. Each read carries a non-vacuity assertion for the + * same reason: a regex that silently matches nothing is a phantom check. + * + * Runs on every runner: it constructs a driver but never connects, so no live + * server is required and no cell of the matrix is involved. + */ + +import { describe, expect, it } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { SqlDriver } from './index.js'; +import { LIVE_CELL_TIMEOUT_MS } from './live-dialect-matrix.testkit.js'; + +/** The workspace root, found the way the testkit finds it — by its marker file. */ +function repoRoot(): string { + let dir = dirname(fileURLToPath(import.meta.url)); + for (;;) { + if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return dir; + const parent = dirname(dir); + if (parent === dir) throw new Error('no pnpm-workspace.yaml above this file'); + dir = parent; + } +} + +/** + * The connection bounds the driver ACTUALLY installs, read off a constructed + * pg config rather than copied from `SqlDriver`'s private constants. + * + * Constructing a driver opens no socket — knex builds its pool lazily — so this + * is a pure read of the config the driver would connect with. + */ +function installedConnectBounds(): { poolCreateMs: number; dialectConnectMs: number } { + const driver = new SqlDriver({ + client: 'pg', + connection: 'postgres://u:p@127.0.0.1:5432/never_connected', + } as any); + const config = (driver as any).knex.client.config; + return { + poolCreateMs: Number(config?.pool?.createTimeoutMillis), + dialectConnectMs: Number(config?.connection?.connectionTimeoutMillis), + }; +} + +describe('[#16434] the live-cell budget stays inside the corridor it was derived from', () => { + it('sits ABOVE the longest wait the driver is entitled to for one connection', () => { + const { poolCreateMs, dialectConnectMs } = installedConnectBounds(); + + // Non-vacuity: if the driver stopped installing these, both reads would be + // NaN and every comparison below would be vacuously false-y rather than red. + expect( + Number.isFinite(poolCreateMs), + 'the driver installed no `pool.createTimeoutMillis` — this pin read nothing, so it is ' + + 'measuring nothing (see `withConnectBound`)', + ).toBe(true); + expect( + Number.isFinite(dialectConnectMs), + 'the driver installed no per-dialect connect timeout — this pin read nothing (see ' + + '`DIALECT_CONNECT_TIMEOUT`)', + ).toBe(true); + + // The floor. At or below the pool's create backstop, vitest kills the test + // while the driver is still inside a wait it declares legal, and the + // accurate connect message never prints. + expect( + LIVE_CELL_TIMEOUT_MS, + `a live cell budget of ${LIVE_CELL_TIMEOUT_MS} ms does not clear the ${poolCreateMs} ms ` + + `pool create backstop the driver installs, so a connect fault would be reported as ` + + `"Test timed out" instead of by the driver's own envelope`, + ).toBeGreaterThan(poolCreateMs); + expect(LIVE_CELL_TIMEOUT_MS).toBeGreaterThan(dialectConnectMs); + + // ⭐ The status quo this card is about, asserted rather than recounted: + // vitest's own default is below even the dialect connect bound. + const VITEST_DEFAULT_TEST_TIMEOUT_MS = 5_000; + expect( + VITEST_DEFAULT_TEST_TIMEOUT_MS, + 'vitest’s default no longer sits below the driver’s connect bound — re-derive the ' + + 'floor above, because the reason an unbudgeted live cell could never report a connect ' + + 'fault has changed', + ).toBeLessThan(dialectConnectMs); + }); + + it('sits BELOW the stall guard the live job wraps this suite in', () => { + const ci = readFileSync(join(repoRoot(), '.github/workflows/ci.yml'), 'utf8'); + const guarded = /run-with-stall-guard\.mjs[^\n]*--stall-minutes\s+(\d+)[\s\S]{0,400}?driver-sql/; + const match = guarded.exec(ci); + + // Non-vacuity: no match means the workflow moved and this pin is measuring + // nothing — a louder failure than a green over a regex that matches nothing. + expect( + match, + 'no `run-with-stall-guard --stall-minutes N` step wrapping the driver-sql suite was found ' + + 'in .github/workflows/ci.yml — the ceiling half of this budget’s derivation now reads ' + + 'nothing, so re-derive it against wherever that guard moved to', + ).not.toBeNull(); + + const stallWindowMs = Number(match![1]) * 60_000; + expect(stallWindowMs).toBeGreaterThan(0); + expect( + LIVE_CELL_TIMEOUT_MS, + `a live cell budget of ${LIVE_CELL_TIMEOUT_MS} ms is not comfortably under the ` + + `${stallWindowMs} ms stall window: at that size a hung live test is killed as an ` + + `unattributed stall instead of being named by vitest`, + ).toBeLessThan(stallWindowMs / 2); + }); +}); + +describe('[#16434] the seam-level suite option behaves the way the seam assumes', () => { + const SUITE_BUDGET = 4_242; + const OWN_BUDGET = 1_337; + + describe('a suite option', { timeout: SUITE_BUDGET }, () => { + it('reaches a test declared directly in that suite', (ctx) => { + expect(ctx.task.timeout).toBe(SUITE_BUDGET); + }); + + describe('and a describe nested inside it — the shape every matrix consumer writes', () => { + it('reaches a test one level deeper too', (ctx) => { + expect(ctx.task.timeout).toBe(SUITE_BUDGET); + }); + + it( + 'but does NOT override a budget the test declared for itself', + (ctx) => { + // The 62 explicit budgets already in this package (60 x 60_000, one + // 40_000, one 120_000) keep the value their own site chose. + expect(ctx.task.timeout).toBe(OWN_BUDGET); + }, + OWN_BUDGET, + ); + }); + }); + + it('leaves a test OUTSIDE that suite on the runner default — the SQLite cells', (ctx) => { + expect( + ctx.task.timeout, + 'a suite option leaked out of its own suite — the whole "live cells only" claim rests on ' + + 'it not doing that', + ).not.toBe(SUITE_BUDGET); + + // ⛔ The fence, executable: this package must keep inheriting the runner + // default outside a live cell. It reds two ways, and both are the point — + // a package-wide `testTimeout` added to `vitest.config.ts` (which is the + // fix #16434 declined), or a vitest upgrade that moves the default out from + // under the FLOOR argument in `LIVE_CELL_TIMEOUT_MS`'s docblock. Either one + // needs a human to re-derive, not a number bumped here. + expect( + ctx.task.timeout, + 'a test outside every live cell no longer runs at vitest’s 5000 ms default — either this ' + + 'package grew a package-wide `testTimeout` (the fix #16434 declined, because it has no ' + + 'cell-level discrimination) or the runner default moved; re-derive LIVE_CELL_TIMEOUT_MS ' + + 'rather than editing this number', + ).toBe(5_000); + }); +}); diff --git a/packages/drivers/driver-sql/src/live-dialect-matrix.testkit.ts b/packages/drivers/driver-sql/src/live-dialect-matrix.testkit.ts index cd16016c97..2b69798553 100644 --- a/packages/drivers/driver-sql/src/live-dialect-matrix.testkit.ts +++ b/packages/drivers/driver-sql/src/live-dialect-matrix.testkit.ts @@ -382,6 +382,95 @@ export function declareUnprovisionedCell(cell: DialectCell, matrix: string): voi }); } +/** + * [#16434] The per-test budget a LIVE cell runs under — the one the matrix was + * missing, and the reason a merge-queue build dequeued an unrelated PR. + * + * ## Why a live cell needs its own budget at all + * + * This package sets no `testTimeout`, so every cell inherited vitest's default + * 5000 ms — the SQLite cell, which does no I/O, and the live cells, which talk + * to a separate server over a socket. Measured against a live Postgres 16.13 + * and a live MySQL 8.0.46 in ONE run, on the cell that actually timed out: + * `sql-driver-11224-update-stamp-precision.test.ts` §2 on live mysql (six + * rounds of create → read → update → a server-side cursor comparison, so 24 + * live round-trips in one test body): + * + * ``` + * §2 idle loop loop held by a re-scheduling 12 ms hog (8 on 4 CPUs) + * sqlite 21 ms 24 ms + * live postgres 50 ms 50 ms + * live mysql 64 ms 121 ms + * ``` + * + * ⚠️ Read what that does NOT license, in three directions. + * + * - It does not derive this number, and cannot. The queue build that dequeued + * PR #16430 spent MORE than 5000 ms in that same live-mysql body — 40x to + * 75x the figures above. A budget written as "measured cost times a margin" + * would have landed in the low hundreds of milliseconds and been wrong by + * two orders of magnitude. What the measurement establishes is the opposite: + * the cost of the WORK is not what sets this bound, so the bound is derived + * from what it has to sit BETWEEN instead. + * - It is not the cost of live cells in general. This file is one of the + * heavier ones; `sql-driver-12998-shadow-null-safe-key.test.ts`'s live cells + * were measured on this same container at 248 ms for the slowest of them. + * ⛔ Nothing here claims any live cell is normally near this ceiling. + * - The numbers above are ONE world. Earlier readings taken on this container + * with only `OS_TEST_POSTGRES_URL` set are not comparable with them: the box + * and the cell population both differ. Whole-row comparisons only. + * + * ## The two bounds it sits between, both read off the code it guards + * + * FLOOR — the driver's own longest LEGAL wait for one connection. `SqlDriver` + * bounds every live connection itself: a per-dialect connect timeout of + * 10_000 ms and a deliberately looser `pool.createTimeoutMillis` backstop of + * 15_000 ms ("The two bounds must not be equal. They race, and knex wins a + * tie", `withConnectBound`). Any live round-trip may have to acquire a pooled + * connection, so 15_000 ms is a wait the driver is ENTITLED to inside a test + * body. A budget at or below it pre-empts the driver's own envelope: vitest + * kills the test with `Test timed out in Nms` while the driver was still inside + * a legal wait, and the accurate message the black-hole test pins (`timeout + * expired` from pg, `connect ETIMEDOUT` from mysql2) never prints. + * ⇒ the budget must be strictly ABOVE 15_000 ms. + * ⭐ Note where that leaves the status quo: 5000 ms is below even the 10_000 ms + * dialect connect bound, so an unbudgeted live cell could never report a + * connect fault at all — vitest always won that race. + * + * CEILING — the stall guard the live job wraps this suite in + * (`run-with-stall-guard.mjs --stall-minutes 10`, ci.yml). A per-test budget at + * or above ten minutes of silence never fires first: the guard kills the + * process group and reports an unattributed stall, losing WHICH test hung. + * ⇒ the budget must be well BELOW 600_000 ms. + * + * ## The point inside that corridor, stated as a choice rather than a measurement + * + * Nothing in the corridor (15_000, 600_000) is distinguishable by measurement, + * so the value is fixed by this package's OWN existing answer for live-touching + * sites: 60 explicit `60_000` budgets across 22 files — #13688 and its sweep + * #13902 put them on live test BODIES, #14213 and #14628 on the hooks that pay + * a live connect. Adopting it leaves the live matrix with ONE live budget + * instead of two, so a red at 60_000 ms is unambiguous about which bound it hit. + * + * ⭐ That convention states its own reasoning, and states its own limit — + * `sql-driver-12998-shadow-null-safe-key.test.ts`, on the four budgets #13902 + * gave it: "Sized like this package's siblings — 60_000 is 7 of its 9 explicit + * budgets — and NOT an assertion that these tests are normally anywhere near + * that slow." So the precedent picked the value by convention and said so; what + * it never had is a CORRIDOR the value must lie in. That is what this constant + * adds, and it is the half that is derived. ⛔ It is NOT `driver-mongodb`'s 30_000 carried over + * by analogy — that is that package's number, and this one is this package's. + * + * It clears the derived floor by 4x — arithmetically, room for four + * full-length pool creations inside one test body before the budget could + * pre-empt the driver — and sits an order of magnitude under the derived + * ceiling. `live-dialect-matrix.budget.test.ts` pins both inequalities + * against the bound the driver ACTUALLY installs, read off a constructed + * connection rather than copied here, so the two halves cannot drift apart in + * silence. + */ +export const LIVE_CELL_TIMEOUT_MS = 60_000; + /** * Run a cell EITHER WAY — measured when it is provisioned, declared un-run when * it is not — with no third outcome available to the caller. @@ -423,7 +512,30 @@ export function declareDialectCell( declareUnprovisionedCell(cell, matrix); return; } - measure(cell); + // [#16434] LIVE cells only — the budget is applied HERE, at the one seam + // every matrix consumer already goes through, rather than at each `it` in the + // 40 files that call this. Same argument the rest of this module makes: a + // guard copy-pasted per suite is a guard that can weaken in one copy and + // nowhere else, and a new live file would arrive without it. + // + // ⛔ Deliberately NOT a package-wide `testTimeout` in `vitest.config.ts`. + // That is the one knob with no cell-level discrimination, so it would raise + // the ceiling for the SQLite cell too — measured at 21 ms idle / 24 ms hogged + // for the same test body the live-mysql cell spends 64-121 ms on — and this + // package's fast in-memory cells are where a 5 s guard is doing real work. + // + // A suite-level `timeout` cascades to the tests the consumer's own describes + // declare, and an explicit per-`it` third argument still wins over it — both + // asserted in `live-dialect-matrix.budget.test.ts`, so the 62 explicit + // budgets already in this package (60 x 60_000, one 40_000, one 120_000) + // keep the value their own site chose. + if (!cell.live) { + measure(cell); + return; + } + describe(`live cell budget (${LIVE_CELL_TIMEOUT_MS} ms)`, { timeout: LIVE_CELL_TIMEOUT_MS }, () => { + measure(cell); + }); } /** What a server reports about its own timezone. */