test(driver-sql): give the live dialect cells a derived per-test budget, at the seam every matrix consumer already goes through - #16578
Conversation
…et, at the seam every matrix consumer already goes through The package sets no `testTimeout`, so its live PG + MySQL cells ran under vitest's default 5000 ms — the only live-database driver in the repo with no budget. A queue build spent more than that in one live cell and dequeued an unrelated PR. `declareDialectCell` now wraps LIVE cells only in a suite carrying `LIVE_CELL_TIMEOUT_MS`. SQLite cells are untouched and keep the 5 s guard, and there is deliberately no package-wide `testTimeout` — that knob has no cell-level discrimination. The value is derived from the corridor it has to sit in, not copied by analogy: above the driver's own longest legal connection wait (so a connect fault reports the driver's envelope rather than vitest's stopwatch), below the live job's stall guard (so a hung live test is named rather than swallowed). `live-dialect-matrix.budget.test.ts` pins both inequalities against the bounds read off the code they describe, plus the three vitest cascade rules the seam relies on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…iver-sql-live-cell-timeout
…dget Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cec8935e7729dd920243d1002f57944bb1a29b0a && git checkout cec8935e7729dd920243d1002f57944bb1a29b0a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f2f6684cd5e93032ed5d82a1848784b6026a8a03 c4771e80f84974ade8c5cb67f2db33bb9fcae79f && git checkout -B drift-repro f2f6684cd5e93032ed5d82a1848784b6026a8a03 && git merge --no-ff c4771e80f84974ade8c5cb67f2db33bb9fcae79f
node scripts/docs-audit/affected-docs.mjs --json f2f6684cd5e93032ed5d82a1848784b6026a8a03 |
…epairs, not two Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
… live MySQL, and cite the convention's own stated limit The earlier table was taken with only a Postgres URL set, so it never touched the cell that actually timed out. Re-measured in one run against live Postgres 16.13 and live MySQL 8.0.46: live-mysql §2 costs 64 ms idle and 121 ms with the loop held, against the >5000 ms the queue build spent in that same body. Also records what the numbers do not license: this file is one of the heavier ones (12998's live cells peak at 248 ms), and #13902's own comment says it sized 60_000 by sibling convention and NOT as a claim that these tests run near it — which is precisely the half this constant adds a derived corridor to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
Fixes #16434
packages/drivers/driver-sqlset notestTimeout, so its live Postgres and MySQLcells ran under vitest's default 5000 ms — the only live-database driver in the repo
with no budget of its own. A merge-queue build spent more than that inside one live
cell and dequeued PR #16430, whose diff was a single
.changeset/*.mdand touchedzero files under
driver-sql/.declareDialectCell— the one seam all 40 matrix files already go through — nowwraps LIVE cells in a suite carrying
LIVE_CELL_TIMEOUT_MS. SQLite cells areuntouched.
Files:
src/live-dialect-matrix.testkit.ts(the budget and the seam) and a newsrc/live-dialect-matrix.budget.test.ts(the derivation, made executable).No production file, no config file, no published surface.
The two blocking questions, answered
(a) One budget or two — TWO, and the repo already ruled this way
⛔ Not a package-wide
testTimeoutonvitest.config.ts. Three separate readings say so:Temporal Conformancereddens unrelated PRs #13688'ssweep and closed, states it in as many words: "The repo's existing convention is a
per-test budget (never a package-wide
testTimeout, which would silently re-budgetthe 150+ tests that legitimately want the fast default)." This PR does not reopen
that; it applies it.
SQLite §2 costs 21 ms idle and 24 ms with the event loop held; live Postgres 50 ms
and 50 ms; live MySQL — the cell that actually timed out — 64 ms and 121 ms.
vitest.config.tsis the one knob with no cell-level discrimination, so apackage-wide value raises the ceiling for the 105 of this package's 166 test files
that never reach a dialect cell at all.
DialectCell.liveis whatglobalSetup, the isolation test and the timezone axis already key off.But NOT at each
it, which is where this differs from the four prior repairs.#13688, #13902, #14213 and #14628 each budgeted the sites that existed when they ran —
62 explicit budgets across 22 files today (60 x
60_000, one40_000, one120_000).⭐ The file that dequeued #16430 calls
declareDialectCelland still carried none,because a per-site sweep can only cover the sites present at sweep time. Applying the
budget at the seam makes it structural: a new live file cannot arrive without one, and
the 62 budgets already written keep the value their own site chose (a per-
itthirdargument still wins over a suite option — asserted, not assumed).
(b) What number, derived from what
⛔ Not
driver-mongodb's30_000carried over by analogy — that is that package'snumber.
the measurement this card asked for. §2 issues
ROUNDS = 6x four live round-trips(
create→readAudit→update→ a server-side cursor comparison) = 24 liveround-trips in one test body. Measured in ONE run against live Postgres 16.13 and
live MySQL 8.0.46 — so the row that matters, live mysql, is the cell that actually
timed out rather than a stand-in:
The queue build spent over 5000 ms in that same live-mysql body — 40x to 75x these
figures. 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. So the measurement
establishes the opposite of what a cost-times-margin derivation would claim: the cost
of the work is not what sets this bound.
this file is one of the heavier ones, and the sibling round measured
sql-driver-12998-shadow-null-safe-key.test.ts's live cells on this same container at248 ms for the slowest. And they are one WORLD: earlier readings taken in this round
with only
OS_TEST_POSTGRES_URLset (a live MySQL did not exist in the container untilmid-round) are not comparable with them, so the table above is a single run and rows are
only compared with each other.
The bound is therefore derived from what it has to sit between, both read off the code
it guards:
FLOOR — the driver's own longest LEGAL wait for one connection.
SqlDriverbounds every live connection itself: a per-dialect connect timeout of
10_000ms and adeliberately looser
pool.createTimeoutMillisbackstop of15_000ms(
withConnectBound: "The two bounds must not be equal. They race, and knex wins atie"). Any live round-trip may have to acquire a pooled connection, so 15 s is a wait
the driver is entitled to inside a test body. At or below it, vitest kills the test
with
Test timed out in Nmswhile the driver is still inside a legal wait, and theaccurate message the black-hole test pins (
timeout expiredfrom pg,connect ETIMEDOUTfrom mysql2) never prints.⇒ 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.
CEILING — the stall guard the live job wraps this suite in.
run-with-stall-guard.mjs --stall-minutes 10in ci.yml. At or above ten minutes ofsilence the per-test budget never fires first: the guard kills the process group and
reports an unattributed stall, losing WHICH test hung.
⇒ well below 600_000 ms.
The point inside the corridor is a choice, and is labelled as one. Nothing in
(15_000, 600_000) is distinguishable by measurement. The value is fixed at
60_000bythis package's own existing answer for live-touching sites (the 60 budgets above), so
the live matrix ends with ONE live budget instead of two and a red at 60_000 ms is
unambiguous about which bound it hit. It clears the derived floor by 4x and sits an
order of magnitude under the derived ceiling.
⭐ And that convention states its own reasoning — and 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 its value by convention and said so plainly. What it never had is a corridor
the value must lie in — and that is exactly the half this PR adds and the half that is
derived. Reading it the other way round (corridor picked, value derived) would be the
error the card forbids.
The derivation is executable, not prose.
live-dialect-matrix.budget.test.tspinsboth inequalities against the bounds read off the thing they describe — the pool and
connect timeouts off a constructed knex config, the stall window off ci.yml — never
re-typed, each with a non-vacuity assertion so a pin that reads nothing fails loudly.
Same shape #13691 gave
MAX_SPAN_MS.Ruling compliance
testTimeout.vitest.config.tsisunchanged; the pin asserts a test outside a live cell still gets exactly 5000 ms, so
adding one later turns this file red.
sql-driver-11224-update-stamp-precision.test.tsis not touched. Nosleep, no backdating, no driven clock — the unslept same-millisecond path is §2's
subject and stays measured. The file is not in this diff at all.
plugin-emailis out of scope and is not in this diff — see the acceptance notes.The card's own probes, re-derived on this tree — two of them are FALSE
testTimeoutabsent fromdriver-sql/vitest.config.ts, so 5000 ms appliesgrep -c= 0)ROUNDS = 6, four live round-trips per iteration, 24 in one testconst ROUNDS = 6;create/readAudit/update/ cursorselectglobalSetup: ['./src/live-dialect-matrix.globalsetup.ts']already in the configdriver-sqllive-cell suites: 0"60_000, one40_000, one120_000); independently re-measured onorigin/maind1c86a745at 22 filestestTimeout— a complete non-node_modulesgrep"--include=vitest.config.ts. Widened to every tracked file, a seventh appears:packages/client/vitest.integration.config.ts: testTimeout: 30000Both falsifications matter to the fix rather than being pedantry: the first is the
whole reason the answer to (a) is "at the seam, not at each
it" — a package thatalready carries 62 per-site budgets and still dequeued a PR is a package the per-site
route does not cover. The second is a live precedent for a separate config for a
slower tier, which was worth weighing before choosing the per-cell route.
⭐ So the question was not "one budget or two" but "one budget, two, or the third one
already in production". The third option — explicit per-
itbudgets, 22 files deep —is the one this PR measures itself against, and it is rejected on evidence rather than
taste: it has been applied four times (#13688, #13902, #14213, #14628) and the file that
dequeued #16430 still had none, because each pass can only reach the sites that exist
when it runs. The seam keeps that option's value and its per-cell precision while
removing the property that let this recur.
Positive controls, so a zero is a reading rather than a silence: the widened
testTimeoutgrep fires 7 times against adefineConfigcontrol that fires over 104files; the dedup search that found #16506 fired its control (#16434, first hit).
Reverse verification — direction predicted before running, and it held
Both legs mutate the committed tree, prove the mutation reached disk (anchored
grep -cin both directions plus a blob-hash comparison againstHEAD), and restorewith
git checkout HEAD -- pathverified by hash equality and an emptygit diff HEAD,under an
EXIT INT TERMtrap. No mutated byte survives; the tree is clean.The mutated symbol never reaches
dist—tsupbundlesentry: ['src/index.ts']andthe testkit is not exported from
index.ts, so nothing here resolves through a builtartifact and no rebuild can change either leg's colour.
Leg A —
LIVE_CELL_TIMEOUT_MSforced to5. Predicted: both live cells red withTest timed out in 5ms, the SQLite cells stay green, and the floor pin reds. Run on thefinal head with both servers live, so the live-mysql cell — the one that produced the
dequeue — is in the reading:
⭐ The asymmetry is the point, and it is now symmetric across BOTH live dialects: the
same mutation reds five of seven tests in each live cell and leaves all seven SQLite
tests green — "live cells only" stated as a measurement rather than as a claim. The two
green live tests are §4 and §5, which assert emitted SQL strings and issue no
round-trip: a correct discrimination, not a hole.
Leg B — forced to
400_000. Predicted: the ceiling pin reds, the floor pin staysgreen.
The
600000in that message is read out of ci.yml at run time, which is what makes theceiling half of the derivation drift-proof rather than decorative.
Verification
Both live servers, CI parity. Live Postgres 16.13 (
timezone=Asia/Shanghai) wasprovisioned in-container; live MySQL 8.0.46 (
conformance, root/root) became availablepart-way through this round and its
@@global.time_zonewas set to+08:00, the valueci.yml sets. Process zone
TZ=America/New_Yorkthroughout. So both live cells reallyran, including the live-mysql cell that produced the dequeue.
MySQL in the container (
docker pull mysql:8.0is refused by the egress proxy —production.cloudfront.docker.compolicy denial — and nomysqldwas installed then),so readings taken before that point had
OS_TEST_POSTGRES_URLset alone and the MySQLcell reported itself as a named skip. Every number in the (b) table above comes from the
LATER world, in a single run with both URLs set; no figure in this PR mixes the two.
pnpm --filter @objectstack/driver-sql typecheck— exit 0, andtsc --listFilesconfirms the program really reaches all 166 test files including the new one, so the
green is about this diff rather than around it.
pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2(SQLite only,Test Core's shape) — exit 0: 156 passed | 10 skipped (166 files), 2395 passed |
141 skipped (2536 tests), 198 s.
The same command with
OS_TEST_POSTGRES_URLset (PG-only world) — exit 0:163 passed | 3 skipped (166 files), 2980 passed | 73 skipped (3053 tests), 182 s,
zero
FAILlines and zeroTest timed out.⭐ The same command again with both
OS_TEST_POSTGRES_URLandOS_TEST_MYSQL_URLset plus
OS_EXPECT_LIVE_DIALECT_MATRIX=1— the full CI configuration, in which anunprovisioned cell is a named red rather than a skip — exit 0:
166 passed | 0 skipped (166 files), 3591 passed | 1 skipped (3592 tests), 171 s,
zero
FAILlines and zeroTest timed out. Every live cell in the package ran underthe new budget and none came near it. The three worlds side by side, which is also the
proof the live matrix really engaged:
OS_EXPECT_LIVE_DIALECT_MATRIX=1The failing file across all three cells with both servers live: 23 passed, 0
skipped; the two live cells report under
live cell budget (60000 ms)and theSQLite suite paths are byte-identical to before the change.
live-dialect-matrix.budget.test.ts— 6 passed.node scripts/check-live-db-isolation.mjs— PASS, and it scans the edited testkit.Gates: derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(2 paths, 46 families), each run with its exit codecaptured to disk before any pipe, reconciled with
--ran:46 derived, 46 run, 0 NOT-MEASURED, 0 UNRUN. 44 exit 0. Two exit 3 =
PREREQUISITE NOT MET, both because the whole workspace is not built here(
check:dual-build-cjs-loads,check:type-check-debt— its--re-measurehalf; thecoverage half passed). Per those gates' own wording that is "NOT a pass: nothing was
measured" — read as NOT MEASURED, not as red. CI builds first and runs both.
The comment-reading families (
check:nul-bytes,check-comment-mask-adoption,check-comment-mask-corpus,check:doc-authoring,check:driver-conformance) werere-run on the final head after the later comment-only commits: all exit 0.
Repo-wide
pnpm lintis CI's run, not narrowed here.Changeset
skip-changeset. Re-derived from the actual diff rather than inherited: two files, bothunder
src/, both test-only.package.jsondeclaresfiles: ['dist', ...]and exportsonly
./dist/index.*;tsupbundlesentry: ['src/index.ts']; the testkit is notre-exported from
index.ts. Nothing this PR touches is published from any package.Clause-②: nolikewise re-derived — no contract carrier in the diff.Acceptance notes
plugin-email's second instance is not fixed here and was not filed as a new card.Ruled out of scope (it is
domain:services, and why an in-process test with no liveserver needs more than 5 s is a different question). The dedup search — control fired —
returned Queue-flake anchor: src/email-service.queue-delivery.test.ts #16506, already open: the merge-queue-triage workflow's anchor for
src/email-service.queue-delivery.test.ts, naming both ejections (docs(skills): drop the duplicated ASCII field-type decision tree from objectstack-data rules #16369, fix(core): remove ResolvedAuthzContext.authRefusal, a published member nothing ever read #16442).Opening a second card for the same file would have been a duplicate, so the finding was
added to Queue-flake anchor: src/email-service.queue-delivery.test.ts #16506 instead. Flagged in the report as a declared deviation.
hookTimeout, which this seamdoes not reach (a suite
timeoutoption is a test timeout). Noted, not filed: thatdefault is
10_000ms — not measured by me, quoted from this package's own measuredcomment ("A hook inherits
hookTimeout, NOTtestTimeout... an unbudgeted hook diesat 10000ms") — exactly equal to the driver's own dialect connect bound, so a
live
beforeAllwhose connect hits its bound races vitest's hook timeout at the samevalue and which message you get is undefined. driver-sql: 7 unbudgeted LIVE-cell hooks outside #14100, and a measured correction — hooks inherit hookTimeout (10000ms), not testTimeout (5000ms) #14213 and [finding] driver-sql: an 8th unbudgeted live-cell hook, reached through the
rawDriver()helper rather than an inline argument (found by the #14213 walk, deliberately not fixed there) #14628 budgeted such hooksone site at a time in the direct-cell files; the same structural argument would apply,
but it needs its own derivation and no instance has been observed.
MYSQL_CELL/PG_CELL/LIVE_DIALECT_CELLSwiththeir own
describe.skipIf) rather than throughdeclareDialectCell, so this seam doesnot cover them — 11 that run against a server, plus
live-dialect-matrix.isolation.test.ts,which only derives from the cell list and opens nothing. Their shapes are heterogeneous,
so there is no single mechanical edit; several already carry driver-sql: 7 unbudgeted LIVE-cell hooks outside #14100, and a measured correction — hooks inherit hookTimeout (10000ms), not testTimeout (5000ms) #14213/[finding] driver-sql: an 8th unbudgeted live-cell hook, reached through the
rawDriver()helper rather than an inline argument (found by the #14213 walk, deliberately not fixed there) #14628 budgets.Noted, not filed.
p3) is notaddressed here and remains open.
in this container had
@@global.time_zone = SYSTEM(UTC). ci.yml runsSET GLOBAL time_zone = '+08:00'before this suite, and the matrix's own non-vacuityguard fails a UTC server on purpose ("on UTC the D-B2 divergence is invisible and this
cell proves nothing"). It was set to
+08:00so the live runs above are valid ratherthan vacuous. No repository file is involved; it is recorded here because the server is
shared with another round.
Generated by Claude Code