Skip to content

Commit 3b3f67d

Browse files
os-steveclaude
andauthored
fix(driver-sql): report an un-run MySQL widening ALTER at error, naming the fix (#9665)
* fix(driver-sql): report an un-run MySQL widening ALTER at `error`, naming 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 * test(driver-sql): type the fixture log sinks so `error` stays OPTIONAL (#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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f6c71ea commit 3b3f67d

4 files changed

Lines changed: 256 additions & 26 deletions

File tree

.changeset/olive-donkeys-brake.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
Report an un-run MySQL widening ALTER at `error`, naming the fix
6+
7+
Boot schema-sync widens legacy MySQL `TIMESTAMP` columns to `DATETIME(3)` and
8+
zero-precision `TIME` columns to `TIME(3)`. When that DDL cannot run — most
9+
often another session holding the table's metadata lock — the failure is
10+
swallowed on purpose so a migration never takes boot down. It was reported at
11+
`warn`.
12+
13+
That is the case AGENTS.md's degradation rule names for `error` by name: after
14+
the swallow the platform boots, serves traffic and looks entirely normal, while
15+
the DDL that was supposed to run did not. An un-widened `TIMESTAMP` keeps
16+
truncating milliseconds and an un-widened `TIME` keeps rounding fractional
17+
seconds to whole ones, against a canonical storage form that promises the
18+
milliseconds are kept, and nothing else reports the column as outstanding.
19+
20+
Both lines now report at `error` and say what to do about it — identify the
21+
metadata-lock holder, end it, then re-run `os migrate apply` or restart, the
22+
widening being idempotent. Control flow is unchanged: the swallow stays, and
23+
the deferred-DDL flush keeps its loud refusal.
24+
25+
`scripts/check-durability-degradation-log-level.mjs` gains `runWideningAlters`
26+
in its durability vocabulary, so the class stays fixed rather than these two
27+
sites.

packages/drivers/driver-sql/src/sql-driver-deferred-ddl-lock-wait.test.ts

Lines changed: 146 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,22 @@ const WIDGET = {
5555
/** One statement, tagged with the pinned session it was issued on. */
5656
interface Issued { session: number; sql: string; bindings: unknown[] }
5757

58+
/**
59+
* [#9609] The driver's logger shape, with `error` OPTIONAL exactly as
60+
* `SqlDriver` declares it.
61+
*
62+
* Spelled out rather than inferred from each fixture's object literal: an
63+
* inferred type makes `error` REQUIRED on whichever fixture happens to supply
64+
* it, and then the no-error-sink twin below cannot extend it — which reads as a
65+
* TypeScript puzzle when it is really the contract under test. `error?` is the
66+
* whole reason `logDurabilityFailure` needs a fallback.
67+
*/
68+
type FakeLogSink = {
69+
warn: (msg: string, meta?: any) => void;
70+
info?: (msg: string, meta?: any) => void;
71+
error?: (msg: string, meta?: any) => void;
72+
};
73+
5874
/**
5975
* MySQL's lock-wait timeout as mysql2 raises it, wrapped the way knex re-throws
6076
* it — the wrapper is the point: a recognizer reading only the top-level error
@@ -94,17 +110,27 @@ class FakeMysqlDriver extends SqlDriver {
94110

95111
issued: Issued[] = [];
96112
/**
97-
* [#9542] Every `logger.warn` the driver emitted.
113+
* [#9542/#9609] Every log line the driver emitted, **with its level**.
98114
*
99115
* On the boot path this is the ONLY output a blocked widening produces — the
100116
* swallow eats the error itself — so "the bound fires and the operator is
101117
* told" and "the bound fires and nothing at all is printed" are the same
102118
* green suite without a sink to assert on.
119+
*
120+
* ⭐ #9609: the LEVEL is recorded, not just the text. The pins below assert
121+
* `error`, because that is the observable that regresses: a future edit that
122+
* puts this line back on `warn` keeps every message assertion green while
123+
* removing it from the operator's alerting, which is the only signal there
124+
* is. Recording the text alone cannot tell those two apart. The sink
125+
* therefore also HAS an `error` channel — the previous fixture had only
126+
* `warn`, which would have made an `error` call land nowhere and read as a
127+
* missing line rather than as a level change.
103128
*/
104-
warnings: Array<{ msg: string; meta?: any }> = [];
129+
logs: Array<{ level: 'warn' | 'error'; msg: string; meta?: any }> = [];
105130

106-
protected override logger = {
107-
warn: (msg: string, meta?: any) => { this.warnings.push({ msg, meta }); },
131+
protected override logger: FakeLogSink = {
132+
warn: (msg: string, meta?: any) => { this.logs.push({ level: 'warn', msg, meta }); },
133+
error: (msg: string, meta?: any) => { this.logs.push({ level: 'error', msg, meta }); },
108134
info: () => {},
109135
};
110136

@@ -174,6 +200,32 @@ function makeDriver(): FakeMysqlDriver {
174200
});
175201
}
176202

203+
/**
204+
* [#9609] The same driver behind a sink that has NO `error` channel.
205+
*
206+
* `SqlDriver.logger.error` is optional by declaration, and a host that injects
207+
* `{ warn }` is a supported composition. This twin exists so the fallback in
208+
* `logDurabilityFailure` is pinned by a test rather than by a comment: the
209+
* obvious way to make the durability gate see this call site is
210+
* `this.logger.error?.(…)`, which against THIS sink prints nothing at all —
211+
* strictly worse than the `warn` it replaced, and invisible to every assertion
212+
* that only looks at the driver with a full sink.
213+
*/
214+
class NoErrorSinkDriver extends FakeMysqlDriver {
215+
protected override logger: FakeLogSink = {
216+
warn: (msg: string, meta?: any) => { this.logs.push({ level: 'warn', msg, meta }); },
217+
info: () => {},
218+
};
219+
}
220+
221+
function makeNoErrorSinkDriver(): NoErrorSinkDriver {
222+
return new NoErrorSinkDriver({
223+
client: 'better-sqlite3',
224+
connection: { filename: ':memory:' },
225+
useNullAsDefault: true,
226+
});
227+
}
228+
177229
/** Create the table, then arm the deferral over the same metadata. */
178230
async function armedFlush(driver: FakeMysqlDriver): Promise<void> {
179231
await driver.initObjects([WIDGET]); // table now EXISTS — widening applies
@@ -361,27 +413,109 @@ describe('[#9354/#9542] a blocked widening ALTER — bounded on both paths, refu
361413
// boot, and correctness never depended on the widening having run.
362414
});
363415

364-
it('finally reaches the boot `logger.warn` — a bound that printed nothing would deliver nothing', async () => {
416+
it('finally reaches the boot durability log — a bound that printed nothing would deliver nothing', async () => {
365417
driver = makeDriver();
366418
await driver.initObjects([WIDGET]);
367419
driver.issued.length = 0;
368-
driver.warnings.length = 0;
420+
driver.logs.length = 0;
369421

370422
await expect(driver.initObjects([WIDGET])).resolves.toBeUndefined();
371423

372-
// ⭐ The card's whole claim. This warn was already written and was
424+
// ⭐ The card's whole claim. This line was already written and was
373425
// UNREACHABLE in this scenario: the unbounded ALTER never returned, so the
374426
// catch that logs it never ran. A bound whose only effect is a quieter hang
375427
// delivers nothing and looks identical in a green suite, so the delivery is
376428
// asserted on the sink rather than inferred from the bound being armed.
377-
const warn = driver.warnings.find((w) => /could not widen MySQL datetime columns/.test(w.msg));
378-
expect(warn).toBeDefined();
379-
expect(warn!.msg).toContain(WIDGET.name);
429+
const line = driver.logs.find((w) => /widen MySQL datetime columns/.test(w.msg));
430+
expect(line).toBeDefined();
431+
expect(line!.msg).toContain(WIDGET.name);
380432
// Carrying the server's own diagnosis, not a swallowed blank.
381-
expect(String(warn!.meta?.error)).toMatch(/Lock wait timeout exceeded/);
433+
expect(String(line!.meta?.error)).toMatch(/Lock wait timeout exceeded/);
382434
// And it is the SERVER error that was swallowed, not the ADR-0112 refusal:
383435
// that envelope stays flush-only, so its operator sentence is absent here.
384-
expect(String(warn!.meta?.error)).not.toMatch(/PROCESSLIST|No schema change was made/);
436+
expect(String(line!.meta?.error)).not.toMatch(/PROCESSLIST|No schema change was made/);
437+
});
438+
439+
// ───────────────────────────────────────────────────────────────
440+
// #9609 — the LEVEL of that line, which is a separate question
441+
// ───────────────────────────────────────────────────────────────
442+
443+
it('reports the un-run datetime widening at `error`, not `warn`', async () => {
444+
driver = makeDriver();
445+
await driver.initObjects([WIDGET]);
446+
driver.logs.length = 0;
447+
448+
await expect(driver.initObjects([WIDGET])).resolves.toBeUndefined();
449+
450+
// AGENTS.md → "Degradation log levels" decides this with one question:
451+
// after the degradation, does the system still look normal from the outside
452+
// while something it claims is persisted has not landed? Here: yes. Boot
453+
// completed, traffic is served, and the `error` limb names this exact case
454+
// — "DDL that was supposed to run did not". The swallow is unchanged and
455+
// deliberately so (#9542); only the level moved.
456+
const line = driver.logs.find((w) => /widen MySQL datetime columns/.test(w.msg));
457+
expect(line?.level).toBe('error');
458+
// ⭐ Asserted as an ABSENCE too, because `find` above would happily return
459+
// an `error` line while a second `warn` copy of the same degradation kept
460+
// being emitted somewhere else on the path.
461+
expect(driver.logs.filter((w) => w.level === 'warn')).toHaveLength(0);
462+
});
463+
464+
it('reports the un-run TIME widening at `error` too — the twins do not diverge', async () => {
465+
driver = makeDriver();
466+
driver.legacyDatetimeColumns = [];
467+
driver.legacyTimeColumns = [{ name: 'at', nullable: true }];
468+
await driver.initObjects([WIDGET]);
469+
driver.logs.length = 0;
470+
471+
await expect(driver.initObjects([WIDGET])).resolves.toBeUndefined();
472+
473+
// The `Field.time` twin loses fractional seconds by ROUNDING, which changes
474+
// the wall clock that was asked for — if anything the louder of the two. It
475+
// is pinned separately because the two catches are separate code: fixing one
476+
// and not the other is the likeliest way this half-regresses.
477+
const line = driver.logs.find((w) => /widen MySQL time columns/.test(w.msg));
478+
expect(line?.level).toBe('error');
479+
expect(line!.msg).toContain(WIDGET.name);
480+
});
481+
482+
it('names the FIX, not only the consequence — the second thing an `error` owes', async () => {
483+
driver = makeDriver();
484+
await driver.initObjects([WIDGET]);
485+
driver.logs.length = 0;
486+
487+
await expect(driver.initObjects([WIDGET])).resolves.toBeUndefined();
488+
489+
const line = driver.logs.find((w) => /widen MySQL datetime columns/.test(w.msg));
490+
// AGENTS.md: an `error` owes BOTH the consequence and the fix, in the first
491+
// line it prints. An operator woken at `error` with no next step is a worse
492+
// outcome than the `warn` this replaced, so the actionable half is pinned as
493+
// hard as the level. Same three moves the flush's refusal names, because it
494+
// is the same blocker: find the lock holder, end it, re-run.
495+
expect(line!.msg).toMatch(/PROCESSLIST|metadata_locks/);
496+
expect(line!.msg).toMatch(/os migrate apply|restart/);
497+
expect(line!.msg).toMatch(/idempotent/);
498+
// And the consequence stays concrete rather than becoming "degraded".
499+
expect(line!.msg).toMatch(/millisecond/i);
500+
});
501+
502+
it('still delivers the line at `warn` when the injected sink has no `error`', async () => {
503+
const noErrorSink = makeNoErrorSinkDriver();
504+
driver = noErrorSink;
505+
await noErrorSink.initObjects([WIDGET]);
506+
noErrorSink.logs.length = 0;
507+
508+
await expect(noErrorSink.initObjects([WIDGET])).resolves.toBeUndefined();
509+
510+
// ⛔ The regression this guards is `this.logger.error?.(…)`: it satisfies the
511+
// durability gate's matcher and prints NOTHING against this sink, converting
512+
// a loud degradation into a silent one to please a checker. `SqlDriver`
513+
// declares `logger.error` optional, so this composition is supported and the
514+
// fallback is part of the contract, not a nicety.
515+
const line = noErrorSink.logs.find((w) => /widen MySQL datetime columns/.test(w.msg));
516+
expect(line).toBeDefined();
517+
expect(line!.level).toBe('warn');
518+
expect(line!.msg).toMatch(/PROCESSLIST|metadata_locks/);
385519
});
386520

387521
it('clears the flush flag after a refusal, so a later boot sync is unaffected', async () => {

0 commit comments

Comments
 (0)