Uh oh!
There was an error while loading. Please reload this page.
fix(driver-sql): report an un-run MySQL widening ALTER at error, naming the fix - #9665
Conversation
…ming the fix (#9609) Boot schema-sync's MySQL widening swallows a failed `ALTER … MODIFY COLUMN` on purpose — correctness never depended on the widening having run, and a migration must not take boot down (#9542 adjudicated exactly that and it is unchanged here). It reported the swallow at `warn`. AGENTS.md's degradation rule decides the level with one question: after the degradation, does the system still look normal from the outside while something it claims is persisted has not landed? Both halves hold — boot completes and serves traffic, and the rule's `error` limb names this case verbatim, "DDL that was supposed to run did not". An un-widened `TIMESTAMP` keeps truncating milliseconds and an un-widened `TIME` keeps ROUNDING fractional seconds, against a canonical storage form that promises the milliseconds are kept, and nothing else reports the column as outstanding. Newly reachable, too: before #9542 the boot ALTER waited MySQL's one-year default and never returned, so this catch could not fire on a metadata-lock block at all. Both messages now report at `error` and carry the second thing an `error` owes — the FIX: identify the metadata-lock holder with `SHOW PROCESSLIST` or `performance_schema.metadata_locks`, end it, then re-run `os migrate apply` or restart, the widening being idempotent. Control flow is untouched. The gate could not see these sites: `check-durability-degradation-log-level.mjs` scans all of `packages/` and its baseline is empty, but its durability vocabulary had no entry for the widening's DDL path. `runWideningAlters` is declared there now — measured to light up exactly these two catches and nothing else — so the class stays fixed rather than the two sites. The emission goes through a named `logDurabilityFailure` helper rather than the file's inline `(this.logger.error ?? this.logger.warn)(…)`: the gate's matcher cannot see that parenthesized shape and reports it as a silent swallow, and the spelling it CAN see, `this.logger.error?.(…)`, prints nothing at all against a sink that has no `error` — worse than the `warn` it replaces. Pinned by a test against such a sink. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
#9609) The level-recording fixture supplies `error`, so its inferred logger type made `error` REQUIRED — and the no-error-sink twin, whose entire job is to be a sink without one, then could not extend it (TS2416/TS2322). Both fixtures now annotate `FakeLogSink`, which spells `error?` exactly as `SqlDriver` declares it. That is the contract under test, not a workaround: the optional `error` is the whole reason `logDurabilityFailure` needs a fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not seeCoarse fallback — 9 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 4dd62bcb78098bc615fb73c0e972035df94de7bd && git checkout 4dd62bcb78098bc615fb73c0e972035df94de7bd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 40fb55eead85ed46f4b5eee8562a624f3ef3dc28 042f406faceb7672e43594b49aad9a8247a8add2 && git checkout -B drift-repro 40fb55eead85ed46f4b5eee8562a624f3ef3dc28 && git merge --no-ff 042f406faceb7672e43594b49aad9a8247a8add2
node scripts/docs-audit/affected-docs.mjs --json 40fb55eead85ed46f4b5eee8562a624f3ef3dc28
|
✅ PM ACCEPT — #9609 / PR #9665Verified independently: 4 files H1 resolved to (a) — by running the gate, which is the only way it could have been resolved
And the vocabulary extension was measured for blast radius rather than hoped: declaring Had it come back (b) — the sites baselined — I asked you to stop, and I meant it. Good that it did not, but the fork was live. H2 confirmed with a refinement that corrects the cardThe card says the catch "could not fire at all" before #9542. From That distinction matters for how urgent the level is, which is exactly why I asked. A dev who reports "H2 confirmed" without noticing the scope is narrower than the card claimed leaves the next reader with a slightly false model. H3 confirmed, and the fix clause is concreteThe messages named the consequence and not the fix. They now carry the same three moves the flush's refusal already names — find the lock holder via ⭐ The unplanned finding is the best thing here, and it changed the implementationThe file's own idiom Put together: the gate's cheapest satisfaction is actively harmful. A dev under time pressure, told by the gate that their loud site is a silent swallow, fixes it by switching to the spelling the gate recognises — and silently loses the log line on any sink lacking Routing both sites through a named ⭐ Ablation 2 is the load-bearing measurement and you called its direction in advance: helper body → The typecheck detail
That is the same family this lane keeps meeting: a pipeline's exit status is the last command's, so Also correct: the downstream check was targeted with a stated reason rather than a 48-package sweep — one new protected member, no public surface, #9657 and the #8897 commentThe matcher defect is filed as a sub-issue of #8897 with the full picture: 7 sites use the idiom (4 pre-existing in And you noticed that #8897's own restart-trigger — "any PR touches Verdict: ACCEPT. Arming once the gates converge. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9609
Boot schema-sync's MySQL widening swallows a failed
ALTER … MODIFY COLUMNon purpose, and that stays exactly as it was. Only the LEVEL and the message change.The judgment, applied rather than re-litigated
AGENTS.md's degradation rule decides the level with one question: after the degradation, does the system still look normal from the outside while something it claims is persisted has not landed? Both halves hold here — boot completes, serves traffic and looks entirely healthy, and the rule's
errorlimb names this case verbatim: "DDL that was supposed to run did not". An un-widenedTIMESTAMPkeeps truncating milliseconds; an un-widened zero-precisionTIMEkeeps ROUNDING fractional seconds, so the stored wall clock is not the one that was asked for. Nothing else reports the column as outstanding, which makes the level the only signal there is.Swallow-vs-throw was adjudicated on #9542 and is untouched: no control flow moved, the deferred-DDL flush keeps its loud
DATABASE_ERROR/500 refusal, and boot still carries on.H2 — the catch really is newly reachable, with one refinement
Confirmed from the code, not from the card. Before #9542 (commit
8bbf45947) the guard readif (!this.flushingDeferredDdl || !this.isMysql), so boot took the early return and ran the ALTER through the pool at MySQL's defaultlock_wait_timeoutof 31,536,000 seconds. A metadata-lock-blocked boot ALTER never returned, so its catch never ran.The refinement, since the card's wording is slightly stronger than what holds: the catch was always reachable by NON-lock failures (a permission error, a dialect refusal, a failed
information_schemaprobe). What #9542 made newly reachable is the metadata-lock-blocked path specifically — which is the path this level change matters most for.H1 resolved to (a), measured
The previous comment on the card established that
sql-driver.tsIS in the log-level rule's scan population (a walk of all ofpackages/) and is NOT indurability-degradation.baseline.json(which is empty), but could not determine why the gate stayed silent. Running it settles it: the durability vocabulary had no entry reaching the widening's DDL path. Not (b) — the baseline is empty. Not (c) — the matcher resolvesrunWideningAlters()fine once it is declared.So
DURABILITY_CRITICAL_CALLEESgainsrunWideningAlters, which is what AGENTS.md asks for ("found a new one, add it toDURABILITY_CRITICAL_CALLEESin the same PR that fixes it"). Measured to be exactly contained — with the vocabulary entry added and the level stillwarn, the gate went red on precisely the two sites the card names and nothing else:Zero collateral sites, so no widening decision was deferred. After the fix:
29 durability-critical catch seam(s), all loud— 27 before, plus these two.H3 — the messages named the consequence but not the fix
Correct, they did not. Both now carry the second thing an
errorowes, using the same three moves the flush's refusal already names because it is the same blocker: identify the metadata-lock holder withSHOW PROCESSLISTorperformance_schema.metadata_locks, end it, then re-runos migrate applyor restart — the widening re-readsinformation_schemaand is idempotent, so re-running is safe.The emission goes through a named helper, and that is load-bearing
The file's existing durability idiom is
(this.logger.error ?? this.logger.warn)(…), becauseSqlDriver.logger.erroris optional by declaration. The gate'sloggerLevel()matcher cannot see that shape — it requires a property-access callee, and a parenthesized??is not one. With the fallback inline, both catches reportedcatch swallows the failure with no log at all: a false silent-swallow on code that is loud at runtime.The spelling the matcher DOES accept,
this.logger.error?.(…), is the trap: it clears the gate and prints nothing at all against a sink that has noerror. Measured — with the helper body replaced by the optional call, the gate stays green while the delivery disappears. That converts a loud degradation into a genuinely silent one to satisfy a checker, which is the failure the whole rule exists to prevent.So both sites call a named
logDurabilityFailure, whose body branches explicitly. The gate follows same-file helpers transitively (documented in its header), so this is correctly classified rather than routed around:loud (error@4021 via logDurabilityFailure()).The matcher blind spot itself is not patched here — choosing among the options is a policy call on a gate's classification surface. Filed as #9657, a sub-issue of #8897 (the receiver-name half of the same function's narrowness), whose restart-when trigger this PR fires. The 4 pre-existing inline uses in this file are left alone for the same reason.
Tests
Four new pins in
sql-driver-deferred-ddl-lock-wait.test.ts, asserting the LEVEL rather than the text, per the card. The fixture now records{ level, msg, meta }and carries anerrorchannel — the old one had onlywarn, which would have made anerrorcall land nowhere and read as a missing line rather than as a level change.error, with zerowarnlines left on the pathPROCESSLIST/metadata_locks, re-run, idempotent), not only the consequenceerrorstill receives the line atwarn— the pin that makes the?.regression impossible to land quietlyReverse verification, all three predicted before running and all three landing as predicted:
warnreports the un-run datetime widening at error, not warncatch logs warn@8264 and does not rethrowthis.logger.error?.(…)still delivers the line at warn when the injected sink has no errornames the FIX(and the no-error-sink pin, which asserts the clause too)The middle row is the one worth reading: the gate alone would have accepted the harmful spelling. Only the test discriminates it. Each ablation was run from the committed state and restored with
git checkout branch -- path, verified byte-identical by an emptygit status. These are vitest runs againstsrc/, so nodist/rebuild is involved.Verification at
042f406fa(the head this PR points at):pnpm --filter '@objectstack/driver-sql' test→Test Files 101 passed | 5 skipped,Tests 1761 passed | 62 skippedpnpm --filter '@objectstack/driver-sql' typecheck→ cleanTests 15 passed (15), including all four new onesscripts/pm/dispatch-gates.mjs, all 15 PASS:check:durability-log-level,check:changeset-gate-self-tests,check:objectui-changeset,check:cross-package-test-inputs,check:test-source-alias,check:type-source-resolution,check:nul-bytes,check:engine-double-contract,check:where-matcher,check:query-options-erasure,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-cross-package-test-inputs,docs-audit/check-affected-docsnode scripts/check-durability-degradation-log-level.mjs --self-test→ 35 cases passedSqlDriversubclasses typecheck clean against the new protected member (driver-sqlite-wasm,driver-turso), andlogDurabilityFailurecollides with no existing name repo-widecontent/docs/releases/**untouched; a.changesetcarries the operator-visible change. No governed surface edited — where AGENTS.md is quoted it is quoted, never amended.Generated by Claude Code