fix(core): an accepted non-2xx no longer becomes a cached absence (#704) - #717
Open
rejifald wants to merge 1 commit into
Open
fix(core): an accepted non-2xx no longer becomes a cached absence (#704)#717rejifald wants to merge 1 commit into
rejifald wants to merge 1 commit into
Conversation
`verdict: { accept: [404] }` makes a declared non-2xx a successful
outcome, and the cache's store gate was `out.ok` and nothing else — so
the ABSENCE behind the 404 was written to the store. A record created a
second later stayed masked for the whole TTL, on a stitch whose only sin
was declaring that a 404 is normal.
The second consequence is worse, and it is what settles the shape of the
fix. A hit is replayed as `resultEvt(found.data, found.status, 0)`
without running the reader's `interpret`/`verdict` at all, so a stored
404 could be served as a success to a second stitch whose own verdict
rejects that status — the entry's accept list belonging to its WRITER
rather than its reader. Two stitches reach one key easily: the cache id
falls back to `cfg.name ?? cfg.path ?? 'stitch'`, so two unnamed
stitches over one URL already share a namespace (that collision is #704
§3 and stays open).
The gate now reads the status, and reads it accept-BLIND: `< 400` is
`classifyStatus`'s own threshold minus the accept term, which is exactly
the set every reader counts as a success on its own merits. Nothing a
`verdict.accept` had to rescue is durable, so the cross-stitch replay is
unreachable through this path regardless of §3.
Coalescing is deliberately untouched. `claim.settle` stays ungated,
because the coalescer belongs to the stitch that owns it and every
follower therefore shares the leader's verdict — sharing one concurrent
accepted 404 among identical callers is the live behaviour each would
have got anyway. Only the durable write, which outlives the burst and
can be read back by someone else, is held back.
Semver: a `fix`, not a break. A caller deliberately caching an accepted
404 does lose that caching and pays a live call per call — the one
visible consequence, and the CHANGELOG says so. But the entry it loses
was never sound: ADR 0003 decision 2 has it that a stored entry is
always a known-good value, and this one could be handed to a reader that
rejects it. The success VALUE a caller sees is unchanged; only staleness
and the origin-call count move, and both move toward correct.
Written inline rather than as a `ctl.storableStatus` predicate with a
`bypass:` trace event, which is what this started as. `runCached` is in
the core entry and #676 left it 14 B under budget; the predicate plus
the event measured +30 B and went red, and even the bare predicate with
no event was +21 B and still red. The inline form is +6 B on the whole
entry and +7 B on `import { stitch }`, so both budgets hold unchanged at
24.10 / 21.55 KB with 8 B / 34 B of headroom, and the advertised 24 / 22
kB figures do not move. The cost is that the skipped write is silent;
the reasoning is a comment at the gate, which costs nothing at runtime.
Sizing a budget step is the maintainer's call, not a bug fix's — #676
says so in that file, so this fix fits under the ceiling instead.
Tests: an accepted 404 no longer masks a record created after it; the
same key still misses and then caches the 200 that follows; a second
stitch whose verdict rejects 404 reaches the origin instead of being
handed the writer's entry; and a plain 200 caches and replays as before.
The first three fail on `main`.
Refs #704
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
verdict: { accept: [404] }makes a declared non-2xx a successful outcome. The cache's storegate was
out.okand nothing else, so the absence behind that 404 was written to the store.packages/core/src/engine.ts:1668-1670(on
origin/main):Two consequences, both verified live on
origin/main:1. A record created after the 404 is masked for the whole TTL. The absence is cached, so the
stitch keeps answering "not found" long after the thing exists.
2. The cached 404 crosses into a stitch that never accepted it.
engine.ts:1658-1662replays a hit as
resultEvt(found.data, found.status, 0)— without running the reader'sinterpret/verdictat all:So an entry's accept list ends up belonging to its writer rather than its reader. Two
stitches reach one key easily:
cacheStitchIdfalls back tocfg.name ?? cfg.path ?? 'stitch'(
cache.ts:294-296),so two unnamed stitches over one URL already share a namespace. That collision is #704 §3 and
stays open — this fix makes the replay unreachable through an accepted non-2xx regardless of it.
The fix
The gate now reads the status, accept-blind:
< 400isclassifyStatus's own threshold minus the accept term(
surface.ts:147) —exactly the set every reader counts as a success on its own merits. Nothing a
verdict.accepthad to rescue becomes durable, so consequence 2 is closed by construction: there is no stored
non-2xx left to replay.
It is the narrowest gate that does the job. 2xx/3xx still cache exactly as before (a
301underredirect: 'manual'is canonically cacheable and keeps working); only the accepted-≥400 set, whichis precisely the buggy set, stops being written.
Coalescing is deliberately untouched.
claim.settlestays ungated: the coalescer belongs to thestitch that owns it, so every follower shares the leader's verdict, and sharing one concurrent
accepted 404 among identical in-flight callers is the live behaviour each would have got anyway.
Only the durable write — which outlives the burst and can be read back by someone else — is held
back.
Why inline, and not a predicate plus a trace event
This started as
ctl.storableStatus(status)on the cache controller with acacheEvt('bypass: non-storable status 404')alongside it, mirroring the nearbybypass: non-storable responseType. It did not fit the bundle budget.runCachedis in the coreentry, and #676 left it 14 B of headroom:
mainbypass:eventout.status < 400That file says sizing a budget step is the maintainer's call, not a bug fix's, so no budget is
raised here — the fix fits under the existing ceiling instead.
import { stitch }is +7 B(22026 → 22033 B) against a 22067 B budget, 34 B left. The advertised 24 / 22 kB figures do not
move, verified via
bundle-size.mjs --jsonagainst thebundle-advertised-sizetether.The cost is that the skipped write is silent. The reasoning lives in a comment at the gate,
which costs nothing at runtime.
Semver: a
fix, not a breaking changeFiled under
### Fixed.A caller who today deliberately caches an accepted 404 does lose that caching and pays a live call
per call — the one visible consequence, and the CHANGELOG entry says so. It is still a fix, not a
break:
known-good value; this one could be handed to a reader whose verdict rejects it.
and both move toward correct: where the old behaviour returned a stale absence, the new one
returns the record that now exists.
success — is the bug itself. Restoring the reader's declared verdict is a correction, not a
removal.
What I tested
Four tests in
packages/core/test/cache.spec.ts, matching the existing counting-adapter style:serves the record; the second call reaches the origin and returns it.
misses and runs the chain, and the 200that follows caches and replays normally (so the skip is on the write only, not a full bypass).
memoryStorelanding on onederived key; the writer accepts 404, the reader does not. The reader reaches the origin and fails
on its own verdict instead of inheriting the writer's entry.
accept: [404]list, which simplynever fires.
Tests 1-3 fail on
main(verified by reverting the guard toif (out.ok)); test 4 passes bothways, which is the point of it.
Gates
All green from the repo root:
prettier --write,check:lint,check:types,test(139 files, 1495 passed),
check-changelog.mjs,check-contract.mjs,check-unknown-keys.mjs, andcheck:size(added after noticing CI runs it as its own job).check-contract.mjsis worth a note: the originalcacheableStatusspelling tripped R8/P24 —cacheableMethod+cacheableStatusshare a leading-word prefix and would have had to fold into anenvelope. Moot now that the check is inline, but it is a second, independent reason the controller
predicate was the wrong shape here.
Scope
§2 (no working
invalidatespelling), §3 (cacheStitchIdcollision) and §4 are untouched — henceRefs, notFixes.Refs #704
🤖 Generated with Claude Code