Repository navigation
Description
Problem
cache.Put can delete the entry it just stored, then return success for a path that no longer exists.
In pkgs/mcpx/internal/cache/cache.go (main 674c4c6a):
Put(line 117) renames the filled temp directory into place, then runs_ = c.Evict(kind, lim)(line 141) before returning(dest, false, nil).Evict(line 272) callsSelectVictims(entries, lim, "")(line 277) — the keep argument is empty, so the just-stored entry is an ordinary eviction candidate.SelectVictims(line 305) only ever exemptskeepand leased entries.- By contrast
Prune(lines 284-289), the runner-facing form of the same mechanism, passes itskeepentry through toSelectVictims— the mechanism already has the exemption concept;Put/Evictdo not use it for the entry being stored.
So whenever the bounds cannot be satisfied by evicting older unleased entries — one entry larger than MaxBytes alone, or every older entry leased while the total is over MaxBytes — the newest victim is the entry Put just wrote. The caller receives a path, hit=false, and a nil error, and only discovers the loss later as a cache miss / missing directory at use time.
Evidence (verified this run, real module on the laptop)
Probe: laptop ~/git/fleet/probe-2026-10-29-laneA-cacheput/ — internal/cache/cache.go copied verbatim (md5 9f9ce95f29c82804fb56487e47a29cd7, matches main 674c4c6a), driven by a small program:
- Control,
PutwithLimits{}for a 100-byte entry:hit=false err=<nil> exists=true. - Scenario A, same entry with
Limits{MaxBytes: 10}:Putreturnshit=false err=<nil>, butstaton the returned path reports no such file or directory, and a followingGetfor the key misses — the entry is gone. - Scenario B, a 100-byte older entry held under
cache.Acquire, thenPutof a second 100-byte entry withLimits{MaxBytes: 150}(total 200):newExists=false oldExists=true— the leased entry correctly survives and the just-stored entry is the one evicted, again with a nil error fromPut.
No existing test covers Put under a binding limit: cache_test.go's writeEntry helper always passes cache.Limits{}, and the eviction tests call Evict directly.
Context: Put is the store path of the one content-addressed cache mechanism from #1908 (merged in PR #2555). Today it is exercised directly only by tests, but it is the documented API the remaining kinds (prepared dependencies, import graphs, flake resolutions — #1915/#1921) are being migrated onto; each new caller inherits a silent store-then-delete on exactly the oversized/pressured stores that most need a loud outcome.
Why it matters
A store API that reports success for data it has already deleted turns a configuration/size problem into a Heisenbug downstream: intermittent missing prepared deps and workdirs, retried work with no error anywhere in the log (the Evict result is discarded with _ =). It also makes the byte bound unenforceable as a diagnostic — an entry that can never fit should fail at Put, not vanish.
Acceptance criteria
-
Putnever returns(path, false, nil)for a path that does not exist when the call returns: either the just-stored entry is exempt from the eviction pass its own store triggers (keep semantics, asPrunealready has), orPutreturns an explicit error when the entry cannot be retained underlim. - The chosen semantics are pinned by tests in
internal/cache: (a) a single entry larger thanMaxBytes; (b) a store made while all older entries are leased and the total is overMaxBytes. - Existing cache tests still pass (
TestEvictByBytesKeepsNewest,TestLeasedEntrySurvivesEviction,TestPruneCountWithPatternAndKeep, round-trip/hit behaviour unchanged for unbounded stores). - Triage pins which of the two semantics (exempt-just-stored vs error) callers of the mcpx: one content-addressed cache for prepared dependencies, import graphs and flake resolutions #1908 migration should rely on, and the package doc comment for
Putstates it.
Activity
dezren39 commentedon Oct 30, 2026
🤖 Agent note · state: filed
dezren39 commentedon Nov 1, 2026
🤖 Agent note · state: claimed
dezren39 commentedon Nov 1, 2026
🤖 Agent note · state: done