fix(cache): adopt the on-disk state read during Store, not just its mtime - #3928
fix(cache): adopt the on-disk state read during Store, not just its mtime#3928dwin-gharibi wants to merge 4 commits into
Store, not just its mtime#3928Conversation
aheritier
left a comment
There was a problem hiding this comment.
Review — fix(cache): adopt the on-disk state read during Store
Reviewed at head 0eb7102. The diagnosis in #3927 is right, the fix is the minimal correct one, and the tests genuinely discriminate. I'd approve on the code — I'm leaving this as a comment only because CI has not run for this SHA: the ci workflow run is conclusion=action_required (fork PR awaiting maintainer approval), there are zero check-runs and the commit status is {"state":"pending","statuses":[]}. The windows-tests job matters here specifically — it's the one that exercises LockFileEx and the MkdirAll-failure trick the new tests reuse.
The fix
adopt (pkg/cache/cache.go:209-212) binds the two facts that had drifted apart — the merged map read under the lock, and c.mtime — into one call, and both call sites (:190 shortcut, :198 write path) go through it. That's the right shape: the bug was an asymmetry between two branches, and a single helper is what makes it non-reintroducible. Locking is sound: adopt runs with c.mu held (via Store) and inside the flock window, so the mtime it stats cannot belong to content it didn't read. maps.Copy copies rather than aliases the local map, so no post-adopt mutation of entries can leak into c.entries. No exported API changes; go build ./... clean and the pkg/cache consumers (pkg/runtime, pkg/agent, pkg/teamloader) pass untouched.
Test adequacy — mutation-tested
I re-ran the suite against five mutants of the production code, keeping the PR's tests. All five were killed, and each by the specific assertion that should catch it:
| Mutant | Result |
|---|---|
pkg/cache/cache.go reverted to main wholesale |
FAIL — both storeMakesSiblingEntriesVisible subtests (cache_test.go:424, :447); everything else passed |
write path only reverted to c.mtime = mtimeOf(c.path) (:198) |
FAIL — after merging into an existing file only |
shortcut only reverted (:190) |
FAIL — after the redundant-write shortcut only |
adopt replaces instead of merging (c.entries = entries) |
FAIL — successfulStoreKeepsUnpersistedInMemoryEntry: "a later successful Store must not drop an in-memory-only entry" |
adopt merges but drops the c.mtime refresh |
FAIL — successfulStoreKeepsUnpersistedInMemoryEntry |
The per-branch isolation (mutants 2 and 3 each failing exactly one subtest) is what I'd want from a two-branch fix, and the embedded on-disk require.Equal in the first subtest does its job: a failure reads as "stale in-memory view", not "lost write". -race -count=3 ./pkg/cache/ is stable, and neither new test leans on mtime granularity — they turn on exact equality with the mtime Store recorded, so no os.Chtimes workaround is needed.
[optional] adopt's doc comment claims a bit more than the merge delivers
cache.go:207-208 says replacing c.entries "would discard entries an earlier failed Store kept in memory on purpose". True, but the merge only protects such entries in two narrow senses, and the comment reads as a general guarantee:
- Only until the next reload.
maybeReloadstill doesc.entries = fresh(cache.go:245), so the first sibling write after the failedStorewipes the unpersisted entry anyway. Probe (extends the new test withsib.Store("sibling", "value-3")before the finalLookup):kept-in-memoryis gone. Same effect as mutant 5, which is why dropping the mtime refresh also breakssuccessfulStoreKeepsUnpersistedInMemoryEntry. - Only for keys absent from disk. If the failed
Store's key is on disk with an older value,adoptoverwrites the newer in-memory value with the stale on-disk one. This is a behavior change vs.main— same probe (sibling writesk → old-from-sibling; localStore("k","new-local")fails via a0o500cache dir; then a successfulStore("other","v");Lookup("k")):main→"new-local", this PR →"old-from-sibling".
I don't think (2) needs a code change — converging on the state read under the lock is defensible for a cache, and Store overwrites entries by design — but the comment should be narrowed to what holds, e.g. "preserves in-memory entries that the on-disk state does not mention; a later reload may still drop them." Leaving the current wording risks the next reader treating unpersisted entries as durable.
[optional] Package doc still describes visibility as a Lookup-only property
The package doc (cache.go:19-24) — the text #3927 quoted as the contract — still attributes cross-process visibility solely to Lookup's mtime reload. One half-sentence noting that Store adopts the state it read under the lock would close the loop for the next reader.
[optional] Commit hygiene before merge
main's convention scopes by package (fix(cache):, exactly as your PR title does) rather than by file (fix(pkg/cache/cache.go):), and style(pkg/cache/cache.go): linting to fix the lint problems is a fixup that AGENTS.md's "logical and atomic commits" would have squashed into the first commit. Cosmetic; only worth doing if you're pushing again anyway.
Verification I ran (go1.26.5, darwin/arm64)
go build ./... clean · go test -race -count=3 ./pkg/cache/ ok · go test ./pkg/runtime ./pkg/agent ok · go test -run Cache ./pkg/teamloader ok · golangci-lint run ./pkg/cache/... (fresh cache) 0 issues · gofmt -l pkg/cache/ empty · go vet ./pkg/cache/ clean. Windows, task test-binary, check-plan-cross and the license check remain unverified — they need the gated CI run.
Happy to flip to approve once ci is green.
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
The fix is correct and well-designed. The adopt() helper properly solves the original bug by atomically binding the mtime refresh and the in-memory merge, and the merge strategy (rather than replace) correctly preserves in-memory entries from failed persists.
Lower-confidence findings (not posted inline)
- [low]
pkg/cache/cache.go:211— TOCTOU betweenwriteJSONandmtimeOfcould in theory causec.mtimeto reflect an interloper's write, suppressing a futuremaybeReload(confidence: 🟠 weak 52/100). Practically a non-issue:mtimeOfis called while the advisory file lock is still held (thedefer unlock()fires afteradoptreturns), so all cooperating Cache processes are locked out. Only a process bypassing the advisory lock protocol (e.g., a manual file edit) could race this window. The scenario is outside the documented cross-process contract and the window is microseconds wide.
adopt's comment said replacing c.entries "would discard entries an earlier failed Store kept in memory on purpose", which reads as a general guarantee. It is not one: maybeReload still replaces c.entries wholesale, so the first sibling write drops an unpersisted entry anyway, and for a key that is on disk the value read under the lock wins over the newer in-memory one. Both behaviours are the right call for a cache — converging on the state the lock protected, and overwriting by key as Store does — but the comment should not let a reader treat unpersisted entries as durable. Also notes on the package doc that Store adopts the state it read under the lock, since the doc previously attributed cross-process visibility to Lookup alone.
|
Done. @aheritier |
persistToDiskread the on-disk map, merged its own key, wrote it back — and then advancedc.mtimewithout keeping the entries it had just read. SincemaybeReloadonly reloads when themtime differs, the instance was marked up to date against a file it never fully loaded, and a
sibling process's entries stayed invisible to
Lookupindefinitely.Closes #3927 .
What was happening
The file was always correct —
TestFileCache_crossProcessConcurrentStoresPreserveAllEntriesalready covered that. But it asserts only the on-disk result, which is precisely the half
that worked. The in-memory half was never asserted:
Remove A's intervening
Storeand the documented mtime reload works exactly as advertised. Thatcontrol is what pinned the cause to
persistToDiskrather than to the reload path.Two branches had the bug, not one: the write path (
:193-198) and the redundant-write shortcut(
:188-191), which also refreshedc.mtimeafter reading the on-disk map without adopting it.The fix
The merged map is already in hand under both the mutex and the cross-process file lock, so adopt
it. Extracted as one helper used by both branches, so the asymmetry cannot reappear:
Binding the mtime refresh and the adoption into a single call is the actual point: the bug was
that those two facts could drift apart.
Why it merges instead of replacing
This is the one design decision worth reviewing, and the repo's own tests forced it.
TestFileCache_persistenceFailureKeepsInMemory(cache_test.go:186) pins that aStorewhosewrite fails deliberately keeps its entry in memory. The obvious one-liner —
c.entries = entries— would satisfy the cross-process bug but silently undo that contract onthe next successful
Store:The existing test does not catch this (it only performs one, failing,
Store), so I addedTestFileCache_successfulStoreKeepsUnpersistedInMemoryEntryto cover it. Merging is also safeagainst resurrecting deleted keys:
Cacheexposes noDelete, so entries are never removed fromthe file.
Adoption happens only on the success paths. A failed
persistToDiskleavesc.entriesandc.mtimeuntouched, soStore's documented in-memory fallback at:166still applies.Tests
pkg/cache/cache_test.go:TestFileCache_storeMakesSiblingEntriesVisible— the regression, one subtest per branch:after merging into an existing file(the write path)after the redundant-write shortcut(theexisting == responsepath)Both first assert the file on disk contains both keys, so a failure reads unambiguously as "the
in-memory view is stale", not "the write was lost".
TestFileCache_successfulStoreKeepsUnpersistedInMemoryEntry— the merge-vs-replace guarddescribed above. It reuses the existing suite's trick for forcing a write failure (a plain file
where the cache directory belongs, making
MkdirAllfail), then removes it so the nextStoresucceeds.
Written test-first. Both
storeMakesSiblingEntriesVisiblesubtests failed on unpatched code withthe sibling's entry is in the cache file and must be visible/entries read during the shortcut must also become visible, while the embedded on-disk assertions passed — which is whatlocalises the defect.
successfulStoreKeepsUnpersistedInMemoryEntrypassed before the change aswell as after; it exists to fail if the fix is ever simplified into a replacement.
Neither new test depends on filesystem mtime granularity: they turn on an exact equality with
the mtime
Storeitself recorded, so there is no timing flake of the kindTestFileCache_lookupReloadsAfterExternalWritehas to work around withos.Chtimes.Verification
Toolchain
go1.26.5, darwin/arm64.go test ./pkg/cache/go test -race -count=1 ./pkg/cache/go test -race -count=1 ./pkg/runtime/ ./pkg/agent/pkg/cacheconsumers)go test ./pkg/teamloader/ -run CachebuildAgentCachetests)go build ./...go vet ./pkg/cache/gofmt -l pkg/cache/go test ./...(full suite,.env.testloaded)pkg/teamloaderfails — pre-existingThe pre-existing tests that matter most here all still pass:
persistenceFailureKeepsInMemory,crossProcessConcurrentStoresPreserveAllEntries,dedupSkipsRedundantWrite,lookupReloadsAfterExternalWrite,atomicWriteLeavesNoTempFiles,concurrentStoreNeverYieldsTornFile,lockFileNeverDeleted.