Skip to content

verdict.accept:[404] on a cached stitch stores the absence, and the write side has no working invalidate spelling #704

Description

@rejifald

Scenario: read-after-write
Proofs: docs/scenarios/proofs/read-after-write/ (8 scripts, 217 checks)

Found while measuring what a client can do about read-after-write lag — a POST followed by a GET
that hits a replica which has not caught up.

First, what works

  • A failed run stores nothing. A bare 404 throws and is never written to the cache; the
    invisibility window after the vendor caught up is 0 reads. The naive fear is wrong by default.
  • paginate is the only body-aware bounded wait, and it dispatches above the cache branch, so a
    poll cannot poison an entry. 4 requests inside the window, 1 when the record is already there.
  • Three cross-declaration invalidations genuinely workread.invalidate(exactInput),
    seam.invalidate(readStitch) and seam.invalidate().
  • cacheStitchId falling back to path is a sensible default for a named-by-path stitch.

1. Accepting a not-found status makes the absence cacheable, and nothing says so

verdict: { accept: [404] } is the documented way to turn "not found" into data. It makes the run
succeed — and runCached stores on out.ok. So the absence becomes a cache entry.

read shape stored? invisibility window after the vendor caught up requests in window
bare 404 no 0 reads 1
verdict: { accept: [404] } yes unbounded (12 probed) 0
200 with [] yes unbounded (12 probed) 0

Three things make it worse than a normal stale entry:

  • The stored 404 replays to a stitch that never accepted one. A second declaration sharing the
    name and store was served cache: 'hit', status 404, 0 requests — a status its own
    verdict would have rejected. The accept list is a property of the writer of the entry, not of
    its reader.
  • .report() cannot reproduce it. It bypasses the cache by default, so the probe a developer
    reaches for shows a healthy 200 while every real call is served the stale 404. (Cross-checked with
    a plain await: 0 requests, same cached error body — the instrument is not creating the effect.)
  • The two config blocks are independently reasonable. cache is right; accept: [404] is
    right; the interaction is the whole bug, and it produces no type error, no warning and no finding.

Ask: at minimum, document the interaction on both cache and verdict.accept. Better: do not
store a response whose status is in verdict.accept but outside 2xx unless the caller opts in — or
emit a cache event saying an accepted non-2xx was stored, which would at least make it greppable.
The status is known at the store site.

2. The write side has no invalidation spelling of its own

Eleven spellings measured; four work, seven are silent no-ops that resolve.

create.invalidate(input) can never work: cache.methods defaults to ['GET','HEAD']
(cache.ts:382), so exact eviction returns before deriving a key. Adding a cache block to the
write does not help. The only write-side spelling that works is create.cache.invalidate(), and
only when the write shares the read's name, shares its store, and declares a cache block it
never otherwise uses — three couplings for one eviction.

Ask: invalidate() on a non-cacheable-method stitch is a caller error and currently resolves
silently. Either reject it, or return the number of entries removed so a near-miss is
distinguishable from a hit. Nothing currently reports how many entries an invalidation removed.

3. Two url:-only stitches share one generation id

cacheStitchId is cfg.name ?? cfg.path ?? 'stitch' (cache.ts:294). A stitch declared with a
bare url: has neither name nor path, so every such stitch answers to the literal id
'stitch'. Measured: seam.invalidate() evicted an unrelated warm stitch, and a targeted
seam.invalidate(a) reaches b's entries.

Ask: derive the fallback from the url when name and path are both absent, or warn once when
two cached stitches in a seam resolve to the same id. The current fallback silently makes bulk
invalidation cross-declaration.

4. Smaller, all measured

  • hooks.onRetry's return value is discarded. Only a throw stops the loop, and it replaces the
    real status and body with the hook's error (status: 0). There is no "stop retrying, keep the
    response" outcome.
  • Retry cannot be scoped to a call. A call passing retry: { attempts: 1 } spent 6 attempts
    the unit is the declaration, so "wait only just after a write" means two stitches for one endpoint.
  • retry.on's predicate is handed only the status number, so it can never see an empty body.
  • RunReport has no headers field, so reading a consistency token from a response header needs
    a hooks.onResponse closure over a per-stitch mutable — racy under concurrency.
  • NEVER_VARY silently drops six header names from cache.vary, including x-request-id. A
    header-borne consistency token is invisible to the cache key unless vary names it, so the read
    carrying the fix is answered from the stale entry and never leaves the process.
  • pick on a field the response lacks returns undefined with 0 findings and a successful run.

Not filed here: the coalescer rejection

C1 independently reproduced #670 — a lone
failing cached call killing the process — which is already fixed on origin/main by
038a57b fix(core): a lone failing cached call no longer kills the process (#676). Recorded only so
the proofs' catchUnhandled() guard and coalesce: false workaround are explicable; no action
needed.

Reproduction

npx tsx docs/scenarios/proofs/read-after-write/c1-cache-the-absence.ts
npx tsx docs/scenarios/proofs/read-after-write/c2-cross-declaration-invalidation.ts

A real node:http vendor on 127.0.0.1 modelling a primary and a lagging replica. The replica
catches up after N reads, never after a duration
— nothing depends on a clock or a sleep, so the
invisibility window is expressed in reads. Each cell allocates a fresh record, because an earlier
draft shared one and the cells silently spent each other's lag.

Which engine

Citations verified against origin/main: engine.ts:635, :1584, :1627, :468 exact;
cacheStitchId at cache.ts:294; cache.methods default at cache.ts:382. cacheInvalidateExact
is at engine.ts:1779 on main and is longer than in this worktree because #648 added input
validation. The ADR 0004 cache vocabulary differs between the trees
(cache.version/cache.onUnfingerprintable here vs a cache.fingerprint envelope on main); C4
probes this behaviourally and prints which tree it ran on. Nothing was executed on origin/main.

Zod: installed 3.25.76, declared ^4.4.3. Only C4 touches Zod and passes under both; no assertion
depends on Zod wording.


Found while writing scenario 50 ("the record you just created isn't there yet") for the docs. Every claim is backed by a runnable offline proof in docs/scenarios/proofs/read-after-write/.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Correctness bug, live on mainbugSomething isn't workingenhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions