Uh oh!
There was an error while loading. Please reload this page.
fix(runtime,objectql): /api/v1/ready drains only on the PRIMARY datasource's failure; a secondary is reported, not drained - #13763
Conversation
…ady-primary-datasource-drain
📓 Docs Drift CheckThis PR changes 2 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 32 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 bfce312ca0056a032356dc9bfe4f40f6216eadd4 && git checkout bfce312ca0056a032356dc9bfe4f40f6216eadd4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c42bc8ee68833297f0273f14b0f6f6e9357ab293 2aa73e83e4f01d0a1a196e27f30cf4efd09d98ed && git checkout -B drift-repro c42bc8ee68833297f0273f14b0f6f6e9357ab293 && git merge --no-ff 2aa73e83e4f01d0a1a196e27f30cf4efd09d98ed
node scripts/docs-audit/affected-docs.mjs --json c42bc8ee68833297f0273f14b0f6f6e9357ab293
|
…ady-primary-datasource-drain
…he hidden test layer type-checks (#13408)
os-steve
commented
Aug 31, 2026
⛔ Correction to this PR's own body — the "58 of 58" line is false, and the dev measured that itself
The Verification section above states:
⛔ That is not what happened, and the correction comes from the implementer, read off its own captured output after I asked it to confirm from logs rather than memory:
⇒ 57 of 58 ran. The missing one is How a false number passed as a reconciled one
The numbers that are true, at the current head |
| reading | value |
|---|---|
| gate union | named 69, ran 69, unreconciled 0 — empty comm -23, one log per command |
| exit 0 | 68 |
| NOT MEASURED | 1 — check-test-completeness, exit 3, in the gate's own words, ⛔ never in the pass list |
check:type-check-debt | exit 0 — "29 ledger entr(ies) re-measured … none above its recorded number", surplus: none |
| objectql TEST_DEBT | 266 → 252 |
| runtime TEST_DEBT | 236 → 206 (ledger lowered 217 → 206 via the gate's prescribed --lower) |
5477743466).
⭐ Recording it here rather than only on the card because a merged PR body is the audit record, and #13774 exists precisely because coverage claims of this shape read as complete. Leaving a measured-false number in the record of the PR that discovered the defect would be the same failure one level up.
⛔ Nothing about the change itself is affected. The 33 raw tsc errors were fixed at source, the ruled Option B is implemented as ruled, and CI is green on every check.
Generated by Claude Code
Fixes#13408
Implements the maintainer ruling of 2026-08-31 (第 6 场总监席决裁批 #12, verbatim 「同意」) — comments
5474190573and5474567457. Option B. Not re-adjudicated here.The defect
On a multi-datasource deployment one datasource whose driver could not start pinned
/api/v1/readyto 503 on every replica, so a readiness-checked load balancer drained every upstream and took the whole deployment offline — while Postgres and the app itself were healthy. One tenant's misconfiguration became a total outage. Observed on a live 3-replica EE deployment and recovered only by restarting every process.What changed
When a driver reports itself unhealthy,
/readynow asks which datasource is the deployment's primary one before choosing a status:degradedkey)degraded: { drivers, primaryDatasource }The failed driver is never hidden: the rejected fourth option — filtering a bad driver out of the response so it becomes invisible — stays rejected, and
degraded.driversis what alerting consumes.The primary criterion is only resolved on the path that already answered 503, so a deployment with no unhealthy driver takes the identical path it took before. The only transition this change can produce is 503 to 200, on the one branch the ruling opened.
The criterion is a readable fact, single-point
ObjectQL.resolvePrimaryDatasource()(packages/objectql/src/engine.ts) is the one implementation. It answers where this deployment's platform system objects actually live, resolved throughresolveDatasourceBinding— the same five-step order every query routes by. The voters are the curatedPLATFORM_PROVIDED_OBJECT_NAMESregistry from@objectstack/spec, intersected with what the registry actually holds.⛔ Not registration order, and ⛔ not
getDefaultDriverName(). That second one matters: the default flag is a configuration input to step 5, not evidence about where anything is stored, and a deployment routingsys_*elsewhere would make it confidently wrong in the direction that keeps a dead replica in rotation. The pin⛔ NOT the first-registered driver, and ⛔ NOT the one flagged defaultbuilds exactly that deployment and asserts both heuristics are live and both disagree with the verdict.The ADR-0057 §3.6 system ledgers (
audit/telemetry/event) are excluded from the vote because they are deliberately routed off the primary; including them would report a split for every deployment that adopted lifecycle separation.transientis deliberately not excluded, matching step 3.The dispatcher probes the method structurally and never re-derives it — a second implementation of "which datasource carries
sys_*" is the drift the single-point requirement forbids.Fail toward draining, pinned
Every way of not knowing lands on the 503: an engine that predates the probe, a probe that throws, a
resolved: falseverdict (all four reasons), and five malformed-verdict shapes (undefined,null, a bare truthy object, an empty name, a non-string name). Staying in rotation requires a positive reading; the absence of a negative one is not permission.Ablation. Inverting that one condition to the forbidden direction (
kind !== 'resolved' ||) turns 12 tests red, including framework#3756's own pre-existing pin. Mutation confirmed on disk by anchored text counts in both directions plus a blob-hash change; restored withgit checkout HEAD --, proven by a blob hash equal to the HEAD blob and an emptygit diff HEAD; re-run green afterwards. No rebuild leg is involved — the suite imports./http-dispatcher.js, a same-package relative specifier vitest resolves tosrc/, never todist/.Non-vacuity: the same fixture that drains under every unresolved verdict returns 200 the moment the criterion resolves, so none of the drain assertions would survive a handler that had simply stopped serving 200.
framework#3756 is not overturned
Its reasoning was a quantified claim — "a replica that would fail 100% of its requests" — and that antecedent still holds where it was measured: in a single-datasource deployment the primary is the only source, so this handler still answers 503 for exactly the case #3756 fixed, down to the response body. What #3756's reasoning never covered is the multi-datasource shape. B carves out that branch and revisits nothing else. The implementation comment is rewritten in place to say so and cites this ruling.
No contract key
No declared
requiredmember is added to the datasource shape. Option C stays archived as the upgrade path. Thedegradedreporting rides the existing 200 body.H17 — decision #7898 checked, not acted on
packages/runtime/src/http-dispatcher.tsis a trigger file of the on-hold decision #7898 (isAuthGateAllowlisted's "no path implies exempt" default; ruled defer 2026-08-12). Its wording makes this a check obligation, and the result is reported either way.http-dispatcher.ts. That is why the check is owed.packages/core/src/security/auth-gate.ts: does not fire — untouched.packages/adapters/: does not fire — none added.isAuthGateAllowlisted, and does not touchenforceAuthGateor thecleanPath = path.replace(/\/$/, '')normalization that the hold prices as item A2./readyis already exempt from project-membership gating and the pre-change 503 body already named driver names to the same audience; the same names now appear underdegraded.driverson a 200. No new disclosure class.⛔ Nothing here is acted on: the hold is not edited, widened, or resolved.
Census re-anchored by regeneration
content/docs/permissions/system-context.mdx— one added import line inengine.tsshifted every anchor after it. Attributed by measurement rather than assumed: withengine.tsreverted toorigin/main's blob the census gate is green, with this change it is red, so the rot is this PR's. Repaired with the gate's own--fix(15 anchors rewritten). Totals are identical toorigin/main— 109 elevation read sites, 145 anchors, 27 declared non-read — so this is a re-anchor, ⛔ not a softened assertion.packages/qa/dogfood/test/authz-probe-blind-spot.test.ts— thePROBE_FILE_CENSUSrow for this file is unmoved:async handle*(is 15 before and after, and its controls (async handleMetadata(1,async handleMcp(1,HttpDispatcherResult18) are unchanged.Verification
Gate union derived after the last edit with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack— re-derived once when the census repair added a documentation file to the change set, which pulled in 11 further families. Full run at6a07781896:node scripts/check-test-completeness.mjs, exit 3 — the script's own text says it needs a saved test log, that the derived family names it with no argument, and that the correct local record is NOT MEASURED, explicitly "not a red". Reported as a refusal, not folded into the green list.mainstaled their build inputs (spec check:docs,spec check:skill-examples,check:dual-build-cjs-loads); after rebuilding@objectstack/spec,@objectstack/client,@objectstack/client-reactand@objectstack/studioall three were re-run and exit 0.gen:schemaleft the tracked tree byte-identical, so no baseline rewrite rode in.pnpm lintrepo-wide — exit 0, whole population, no narrowing claimed.pnpm --filter @objectstack/runtime exec vitest run— 202 files, 3011 passed.pnpm --filter @objectstack/objectql exec vitest run— 250 files, 4322 passed.Test Files 43 passed (43) · Tests 313 passed (313); shard 2/3Test Files 43 passed (43) · Tests 288 passed | 1 skipped (289); shard 3/3Test Files 42 passed | 1 skipped (43) · Tests 379 passed | 2 skipped (381). The census pin was additionally re-run alone on the final head: 29 passed. The merge touched none of the census-cited sources.pnpm --filter @objectstack/runtime typecheckandpnpm --filter @objectstack/objectql typecheck— both exit 0.typecheckexcludes*.test.ts:tsc --listFilesreports 0 hits for either new test file, so "typecheck green" says nothing about them. This is the repo's recorded state, not something this card introduces —check:type-check-coverageis green and reports "17 package(s) still hide their own tests from tsc", and neither package carries atest-typecheck-debt.json. The test files are executed by vitest and pass; onboarding these packages toscripts/check-test-typecheck.mtsis a separate piece of wiring, not a rider here.Grade proposed, not chosen silently
minorfor both@objectstack/objectqland@objectstack/runtime. This changes when a published operational probe drains a node, so an operator whose alerting keys on/readyreturning 503 for any driver failure now sees 200 plusdegraded, anddegradedis a new response field. Nothing is removed or renamed, no declared contract key is added, single-datasource behaviour is bit-identical, and no migration is required — somajoroverstates it andpatchunderstates a deliberate change to an availability control surface. Happy to be overridden either way.Out of scope
⛔ #13578 is not addressed here —
DELETEof a datasource still does not evict the stuck driver from the in-memory engine registry, so the datasource keeps appearing in this report until the process restarts. It is queued independently and is a defect under either answer to this card. The rejected fourth option (making a bad driver invisible to/ready) also remains rejected.Generated by Claude Code