Skip to content

test(driver-sql): give the live dialect cells a derived per-test budget, at the seam every matrix consumer already goes through - #16578

Merged
os-musk merged 6 commits into
mainfrom
claude/issue-16434-driver-sql-live-cell-timeout
Sep 7, 2026
Merged

test(driver-sql): give the live dialect cells a derived per-test budget, at the seam every matrix consumer already goes through#16578
os-musk merged 6 commits into
mainfrom
claude/issue-16434-driver-sql-live-cell-timeout

Conversation

@os-musk

@os-musk os-musk commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Fixes #16434

packages/drivers/driver-sql set no testTimeout, so its live Postgres and MySQL
cells 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/*.md and touched
zero files under driver-sql/.

declareDialectCell — the one seam all 40 matrix files already go through — now
wraps LIVE cells in a suite carrying LIVE_CELL_TIMEOUT_MS. SQLite cells are
untouched.

Files: src/live-dialect-matrix.testkit.ts (the budget and the seam) and a new
src/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 testTimeout on vitest.config.ts. Three separate readings say so:

  1. It is already this package's ratified convention. driver-sql: 37 more live-DDL it() blocks across 9 files inherit vitest's 5000ms default (same shape as #13688) #13902, filed out of driver-sql (live MySQL): the two #13056 orphan-shadow-cleanup tests run 3–4 connect→DDL→disconnect round trips under vitest's DEFAULT 5000ms, so Temporal Conformance reddens unrelated PRs #13688's
    sweep 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-budget
    the 150+ tests that legitimately want the fast default)."
    This PR does not reopen
    that; it applies it.
  2. Measured, in the failing file itself. Same test body, three cells, one run:
    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.ts is the one knob with no cell-level discrimination, so a
    package-wide value raises the ceiling for the 105 of this package's 166 test files
    that never reach a dialect cell at all.
  3. The seam already exists and is already load-bearing. DialectCell.live is what
    globalSetup, 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, one 40_000, one 120_000).
⭐ The file that dequeued #16430 calls declareDialectCell and 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-it third
argument still wins over a suite option — asserted, not assumed).

(b) What number, derived from what

⛔ Not driver-mongodb's 30_000 carried over by analogy — that is that package's
number.

⚠️ And the observed cost does NOT derive it either. That is the honest reading of
the measurement this card asked for. §2 issues ROUNDS = 6 x four live round-trips
(createreadAuditupdate → a server-side cursor comparison) = 24 live
round-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:

§2 idle loop loop held by 8 re-scheduling 12 ms hogs on 4 CPUs
sqlite 21 ms 24 ms
live postgres 50 ms 50 ms
live mysql 64 ms 121 ms

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.

⚠️ Two things these numbers are not. They are not the cost of live cells in general —
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 at
248 ms for the slowest. And they are one WORLD: earlier readings taken in this round
with only OS_TEST_POSTGRES_URL set (a live MySQL did not exist in the container until
mid-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. 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
(withConnectBound: "The two bounds must not be equal. They race, and knex wins a
tie"
). 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 Nms while the driver is still inside a legal wait, and the
accurate message the black-hole test pins (timeout expired from pg,
connect ETIMEDOUT from 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 10 in ci.yml. At or above ten minutes of
silence 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_000 by
this 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.ts pins
both 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

  • ⛔ No repo-wide default, and no package-wide testTimeout. vitest.config.ts is
    unchanged; the pin asserts a test outside a live cell still gets exactly 5000 ms, so
    adding one later turns this file red.
  • ⛔ §2 of sql-driver-11224-update-stamp-precision.test.ts is not touched. No
    sleep, 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.
  • ⛔ Nothing skipped, disabled or quarantined; no assertion changed anywhere.
  • plugin-email is 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

probe as filed re-derived verdict
testTimeout absent from driver-sql/vitest.config.ts, so 5000 ms applies absent (grep -c = 0) ✅ holds
ROUNDS = 6, four live round-trips per iteration, 24 in one test const ROUNDS = 6; create / readAudit / update / cursor select ✅ holds
globalSetup: ['./src/live-dialect-matrix.globalsetup.ts'] already in the config present ✅ holds
"explicit per-test timeouts in the failing file: 0" 0 ✅ holds
⛔ "explicit timeouts across 20 sibling driver-sql live-cell suites: 0" 62 explicit budgets across 22 files (60 x 60_000, one 40_000, one 120_000); independently re-measured on origin/main d1c86a745 at 22 files FALSE
⛔ "Exactly six packages set testTimeout — a complete non-node_modules grep" that grep was --include=vitest.config.ts. Widened to every tracked file, a seventh appears: packages/client/vitest.integration.config.ts: testTimeout: 30000 incomplete

Both 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 that
already 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-it budgets, 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
testTimeout grep fires 7 times against a defineConfig control that fires over 104
files; 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 -c in both directions plus a blob-hash comparison against HEAD), and restore
with git checkout HEAD -- path verified by hash equality and an empty git diff HEAD,
under an EXIT INT TERM trap. No mutated byte survives; the tree is clean.
The mutated symbol never reaches disttsup bundles entry: ['src/index.ts'] and
the testkit is not exported from index.ts, so nothing here resolves through a built
artifact and no rebuild can change either leg's colour.

Leg A — LIVE_CELL_TIMEOUT_MS forced to 5. Predicted: both live cells red with
Test timed out in 5ms, the SQLite cells stay green, and the floor pin reds. Run on the
final head with both servers live, so the live-mysql cell — the one that produced the
dequeue — is in the reading:

                                            red   green
live cell budget (5 ms) > (live mysql)       5     2      15 x "Test timed out in 5ms"
live cell budget (5 ms) > (live postgres)    5     2      (the 2 green are §4/§5)
#11224 ...              > (sqlite)           0     7
[#16434] ... > sits ABOVE the longest wait the driver is entitled to    red
[#16434] ... > sits BELOW the stall guard the live job wraps this in    green
[#16434] ... > the three cascade assertions                             green
Tests  11 failed | 18 passed (29)

⭐ 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 stays
green.

[#16434] ... > sits ABOVE the longest wait the driver is entitled to    OK
[#16434] ... > sits BELOW the stall guard the live job wraps this in    x
  a live cell budget of 400000 ms is not comfortably under the 600000 ms stall window:
  at that size a hung live test is killed as an unattributed stall instead of being
  named by vitest: expected 400000 to be less than 300000
Tests  1 failed | 5 passed (6)

The 600000 in that message is read out of ci.yml at run time, which is what makes the
ceiling half of the derivation drift-proof rather than decorative.

Verification

Both live servers, CI parity. Live Postgres 16.13 (timezone=Asia/Shanghai) was
provisioned in-container; live MySQL 8.0.46 (conformance, root/root) became available
part-way through this round and its @@global.time_zone was set to +08:00, the value
ci.yml sets. Process zone TZ=America/New_York throughout. So both live cells really
ran, including the live-mysql cell that produced the dequeue.

⚠️ Two worlds, stated rather than blended. The first half of this round had no live
MySQL in the container (docker pull mysql:8.0 is refused by the egress proxy —
production.cloudfront.docker.com policy denial — and no mysqld was installed then),
so readings taken before that point had OS_TEST_POSTGRES_URL set alone and the MySQL
cell 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, and tsc --listFiles
    confirms 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_URL set (PG-only world) — exit 0:
    163 passed | 3 skipped (166 files), 2980 passed | 73 skipped (3053 tests), 182 s,
    zero FAIL lines and zero Test timed out.

  • ⭐ The same command again with both OS_TEST_POSTGRES_URL and OS_TEST_MYSQL_URL
    set plus OS_EXPECT_LIVE_DIALECT_MATRIX=1 — the full CI configuration, in which an
    unprovisioned 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 FAIL lines and zero Test timed out. Every live cell in the package ran under
    the new budget and none came near it. The three worlds side by side, which is also the
    proof the live matrix really engaged:

    run files tests
    SQLite only 156 passed, 10 skipped 2395 passed, 141 skipped
    + live Postgres 163 passed, 3 skipped 2980 passed, 73 skipped
    + live Postgres and live MySQL, OS_EXPECT_LIVE_DIALECT_MATRIX=1 166 passed, 0 skipped 3591 passed, 1 skipped
  • The 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 the
    SQLite 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 code
    captured 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-measure half; the
    coverage 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) were
    re-run on the final head after the later comment-only commits: all exit 0.

  • Repo-wide pnpm lint is CI's run, not narrowed here.

Changeset

skip-changeset. Re-derived from the actual diff rather than inherited: two files, both
under src/, both test-only. package.json declares files: ['dist', ...] and exports
only ./dist/index.*; tsup bundles entry: ['src/index.ts']; the testkit is not
re-exported from index.ts. Nothing this PR touches is published from any package.
Clause-②: no likewise re-derived — no contract carrier in the diff.

Acceptance notes


Generated by Claude Code

…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
@github-actions github-actions Bot added the size/m label Sep 7, 2026
@os-musk os-musk added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 7, 2026 — with Claude
@github-actions github-actions Bot added the tests label Sep 7, 2026
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f2f6684cd5e93032ed5d82a1848784b6026a8a03packageMentionDocs.

Which tree this was computed on

This run read content/docs from cec8935e7729dd920243d1002f57944bb1a29b0a — the merge of head c4771e80f84974ade8c5cb67f2db33bb9fcae79f into base f2f6684cd5e93032ed5d82a1848784b6026a8a03, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

…epairs, not two

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
@os-musk
os-musk marked this pull request as ready for review September 7, 2026 11:12
@os-musk
os-musk enabled auto-merge September 7, 2026 11:12
@os-musk
os-musk added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit b4abb0a Sep 7, 2026
38 checks passed
@os-musk
os-musk deleted the claude/issue-16434-driver-sql-live-cell-timeout branch September 7, 2026 11:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

2 participants