fix: one writer per cache_dir, and chunk files that are replaced, not truncated - #51
Merged
Merged
Conversation
… truncated Two processes sharing a cache_dir could corrupt each other silently (#42, part 1). The window is re-admission after eviction: B revives a cached chunk and holds its mapping, A misses the same chunk and re-admits it, and open_memmap(mode="w+") truncates the file under B -- which then reads zero pages as data. Right shape, right dtype, wrong numbers. - _alloc writes a temp file and renames it into place. POSIX keeps the old inode alive for anyone holding it, so the truncation window is gone independently of any locking. A crashed writer's temp files are swept by the next writer to hold the exclusive lock. - The pool takes an advisory flock on the cache dir for its lifetime, keyed on cache_dir being set rather than on persist -- _alloc writes the same filenames in spill mode, so scoping it to persist would leave the same hole. A second writer fails fast, naming the holder and what to do either way. - readonly_cache=True (InSituDataset and ChunkPool) takes the lock shared: many readers coexist, none with a writer, nothing is written, and a miss raises. That is what makes it a contract rather than a silent slow path. - Manifest entries go out as a single os.write on an O_APPEND fd, so the format is robust independently of the lock. - A network cache_dir and a platform without POSIX locking stay unarbitrated and now warn saying exactly that -- which forced the project's first platform-support statement (Linux supported and CI-proven, macOS untested, Windows untested and unarbitrated). Part 2 of the issue (per-variable cache identity) is unchanged: it needs the manifest bump, and this fixes the corruption without invalidating any cache. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011iQ36j3rLYeh4R5uzmB52j
close() released the flock before freeing the slots, so another process could take the write lock while this one still had cache files mapped. Atomic replace means the window cannot corrupt data -- POSIX keeps our inode alive -- but the arbitration's invariant is "hold the lock for as long as you hold a mapping", and a fix that leans on the other layer to cover it is one that stops working the day the other layer changes. Also pins the two properties that answer "what happens to active readers when a writer invalidates the cache", cross-process rather than by reasoning: - a writer cannot start while another process is reading (LOCK_SH excludes LOCK_EX), so invalidation never begins; - and deleting a cached file -- what reset_stale_cache does to every entry -- does not disturb a mapping already held on it, so the second layer stands on its own where no lock can be taken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011iQ36j3rLYeh4R5uzmB52j
The reader/writer rule is a 2x2, and four table rows say it better than three paragraphs of prose did. Drops the reproduced error message -- the loader prints it at runtime, so a copy in the docs is redundant and free to go stale -- keeping only what the message cannot tell you: how to confirm the holder, and why deleting the lockfile is the harmful thing to try. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011iQ36j3rLYeh4R5uzmB52j
_alloc opens the mapping on a temp name and renames it, then corrects memmap.filename because _record_completed names the manifest entry from it and _free unlinks through it. Both failures are silent -- a leaked spill file is just disk, and a log entry naming a vanished temp reads as a cold cache -- and until now the line was only covered incidentally by tests that happen to fail downstream of it. Removing it makes the new test fail with 't2m__0.npy.57947.insitu-tmp' != 't2m__0.npy', which says what broke. The comment now records why the assignment is sound, from numpy's source rather than inference: filename is a plain instance attribute set in __new__ and copied in __array_finalize__, and numpy never reads it for behavior (flush goes through _mmap/base). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011iQ36j3rLYeh4R5uzmB52j
…hanges Drops the "until now it was not stated at all", "what it replaces", "used to truncate" and "not a new restriction" framing. A reader arriving at a page wants the current contract; how it got there belongs in CHANGELOG and DESIGN.md's known-limitations record, which already carry it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011iQ36j3rLYeh4R5uzmB52j
…it true Drops "until this module's contract landed", "The bug:", "until the lock landed" and "silent corruption is what this replaces". A test docstring should say what must hold and why it is hard to observe; when it holds is the changelog's business. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011iQ36j3rLYeh4R5uzmB52j
This was referenced Sep 4, 2026
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.
Summary
Part 1 of #42 — the arbitration half. It does not touch the manifest, so it fixes the
silent corruption without invalidating anybody's cache. Part 2 (per-variable cache identity)
still needs the format bump and is unchanged; the issue stays open for it.
Two processes pointed at one
cache_dir— the obvious thing to do, and what thesupply-chain audit on NVIDIA/earth2studio#962
found
InSituForecastFeedinviting — could corrupt each other with nothing to show for it.The window is re-admission after eviction: B revives a cached chunk and holds its mapping,
A misses the same chunk and re-admits it, and
open_memmap(mode="w+")truncates the fileunder B, which then reads zero pages as data. Right shape, right dtype, wrong numbers.
The fix, in two independent layers.
_allocreplaces instead of truncating. A slot file is written under a temp name andrenamed into place; POSIX keeps the old inode alive for anyone holding it, so a reader'smapping stays intact. This closes the corruption window on its own, with no locking
involved. A writer killed between the two leaves a temp file, swept by the next writer to
hold the exclusive lock (the only moment it is safe).
flockon the cache dir, held for the pool's lifetime. Exclusive towrite, so a second writer fails fast with a message naming the holder, what to do in
either case, and the two commands that answer "who?". Non-blocking on purpose: contention
is a configuration fact to resolve, not a queue to join.
The lock keys on
cache_dirbeing set, not onpersist— a correction to the issue asfiled.
_allocwrites{array}__{cid}.npywhenever a backing dir is set, so two processessharing a spill dir with
persist=Falsecollide on identical filenames just as surely;scoping the lock to persist mode would have left that hole open. Test:
test_the_lock_keys_on_cache_dir_not_on_persist.New
readonly_cache=True(InSituDatasetandChunkPool) is the workload this isshaped around — one job warms a cache, several score against it. Shared lock, so many
coexist and none coexists with a writer; writes nothing; and a miss raises, naming the
array and chunk. That is what makes it a contract (this cache is complete for what I am
about to read) rather than a slow path that silently re-fetches whatever the warming run's
split or transforms left out.
reset_stale_cacheis rejected in this mode.Also: manifest entries now go out as a single
os.writeon anO_APPENDfd (sub-PIPE_BUFappends are atomic under POSIX), so the format is robust independently of the lock —
which is what keeps a log written on an unarbitrated platform readable.
What is still unarbitrated, said plainly where the user meets it. A network
cache_dir(
flockmay be emulated per client, so two hosts can each believe they hold it) and aplatform with no POSIX locking. Both warn at construction saying that this is the one
configuration where two writers can still corrupt each other. Detection is not a fix. That
forced the project's first platform-support statement (README, contributing,
classifiers): Linux supported and the only thing CI proves; macOS expected to work but
untested; Windows untested and unarbitrated — untested, not unsupported.
Two things the docs now have to say, because they are the first things anyone will get
wrong: there is no such thing as a stale
flock(the kernel releases it on processdeath,
SIGKILLand spot preemption included), so there is no cleanup procedure; anddeleting the lockfile is actively harmful — it releases nothing and makes the next two
processes lock different inodes, reintroducing exactly the corruption the check prevents.
Two deviations from the agreed plan, both deliberate
bench/run.pydid not get--readonly-cache. Its--cache-diris a per-configscratch root, not a shared cache, and nothing in the bench sets
persist=True— so theflag could only ever error. Making it usable means adding persist to
Cfg, which lands anew column in the JSONL results schema for a knob no benchmark measures. Benchmarking the
cross-run cache is its own piece of work. The other three CLIs got
--persistand--readonly-cache:--readonly-cachealone would have been equally dead there, since noexample could warm a cache for it to read.
examples/advection/data.pywas left alone. It has a--cache-dirtoo, but it was notin the agreed list, and the arbitration protects it regardless — the lock is on the
directory, not on a flag.
For reviewers
Worth a second look:
_alloc'sfilenamefix-up. Aftertmp.replace(path)the memmap still records thename it was opened under, so
_alloccorrectsbacking.filename._record_completednames the cache entry through it and
_freeunlinks through it; both would otherwise chasea temp path that no longer exists.
test_pool_spill_unlinks_without_persistcovers thesecond.
__init__, after the lock is taken, and__del__cannot clean that up —close()readsattributes a half-built pool does not have, and its own suppression would swallow the
error, leaving the directory locked until the process exits. Handled explicitly.
test_pool.pynow call a_die(pool)helperthat closes the lock fd without running
close(). That is what process death actuallydoes (the kernel drops the fds; nothing in
close()runs), and the tests' contract — onlywhat was appended at completion survives — is unchanged.
readonly_cachedeliberately does not requirepersist=True: it implies the samecross-run identity internally, so
persist=True, readonly_cache=Trueandreadonly_cache=Truealone behave identically, and forgetting the pair cannot produce a confusing all-misses run.
Confident in: the failure mode itself (reproduced by a subprocess test that fails on
main),and that nothing changes for a single-process run.
Active readers while a writer invalidates the cache
Asked in review, and worth stating as a contract rather than leaving to inference.
It cannot happen on the arbitrated path. Invalidation —
reset_stale_cachedeletingevery entry, or a run re-admitting chunks it evicted — is work a writer does, and
LOCK_SHexcludes
LOCK_EX, so a writer cannot open the directory while any reader holds it. Theexclusion lands at construction, before the writer can allocate, evict or delete anything.
Now tested cross-process in both directions
(
test_a_writer_cannot_start_while_another_process_is_reading), not just in-process.And the layer underneath does not depend on that — which is what matters on a network
cache_diror a platform without advisory locking. A reader holding a chunk's mapping keepsreading real data whether the file is replaced (new content goes to a new inode) or
deleted (POSIX keeps the inode alive until the last reference goes). So the worst a
bypassed lock can do to an active reader is cost it a future open — a miss, which
readonly_cacheturns into a loud error — never wrong numbers in a batch that looks fine.Pinned by
test_deleting_a_cached_file_does_not_disturb_a_held_mapping.Checking that turned up a real defect in this PR, now fixed (
732acc9):close()released the lock before freeing the slots, leaving a window where another process could
take the write lock while this one still had cache files mapped. Atomic replace meant the
window could not corrupt data, but the invariant the arbitration rests on is "hold the lock
for as long as you hold a mapping" — and a fix that leans on the other layer to cover it is
one that stops working the day the other layer changes. The lock is now released last, and
test_the_lock_outlives_every_mapping_the_pool_holdsfails on the previous ordering.Verified by running the CLIs, not just
--helpexamples/wb2_dataloader.py—--persistwarmed 6 chunks;--readonly-cacheover itreported
chunks 6/6 hit (100%)withinflight peak 0/32, i.e. it fetched nothing. Againsta deliberately incomplete cache (entries removed from the log and disk) it raised through
the prefetch producer onto the main thread with the intended message: "chunk 5 of 't2m' is
not in the cache … The usual cause is that the run that warmed it used a different split,
sample_range or transform set."
(
--train-step-ms 4000); writer B on the same--cache-dirfailed at construction with thecontention error naming A's live PID and host. The two commands that error prints were then
run against that lockfile and both named the same PID —
fuser -v→dstuebe 54294 F.... python3,lsof→10uW(write lock held). After A exited, a--readonly-cacherun overthe same directory succeeded immediately: no stale lock, no cleanup step.
examples/fit_scaler.py—--persistthen--readonly-cache; both complete, theread-only pass serving every chunk.
bench/run.py(unchanged, butcache=residentputs acache_dirunder the new lock) —a local sweep runs clean, including
--repeats 2, since each repeat already gets its ownscratch subdirectory.
examples/advection/train_torch.py(unchanged; also takes--cache-dir) — full rungreen, and the model-free check passes: persistence RMSE 0.884, which is the one signal
that would catch a cache returning different bytes.
examples/wb2_arraylake.pycould not be run here —arraylakeis not installed in thisenvironment and the local config carries no token. Argparse accepts both new flags (it gets
as far as the
arraylakeimport before failing), andmain()'srun(...)call was boundagainst the real signature to catch a kwarg-name typo that mypy cannot see through an
untyped argparse
Namespace. It deserves a run by someone with credentials.One incidental, unrelated finding:
train_torch --n-steps 24fails withValueError: need at least one array to concatenate(too few chunks leaves a split empty). It reproduces without--cache-dir, so it is pre-existing and untouched here.One small behavior change worth knowing: a spill (
persist=False)cache_dirused to beleft empty on close. It now retains the
.insitu.lockfile — deliberately, since that inodeis the lock, and removing it is what the docs tell users never to do.
Author attestation
have verified the claims made in this description.
(Left unchecked deliberately — that box is the human author's to tick.)
Checklist
tests/test_cache_concurrency.py(18 tests). The bug test camefirst and is a real subprocess: a child maps a chunk, the parent re-admits it, and the
child must still read real data. It fails on
main(
assert '38723a2e…' == '64cddc0c…'). Also covered: second writer raises with theguidance, reader+writer excluded, many readers coexist, a readonly miss raises naming the
chunk, readonly writes nothing, readonly rejects
reset_stale_cache,.tmplitter swept(and not swept by a reader),
O_APPENDentries survive a seek to 0, aSIGKILLedholder's lock is immediately re-acquirable, and the end-to-end warm-then-read flow through
InSituDataset.uv run ruff check src tests bench examples,uv run mypy src bench examplesanduv run pytest -qare green locally (416 passed, 6 skipped)cache_dir/readonly_cacheon bothInSituDatasetandChunkPool)docs/*.md—docs/tuning.md, "Sharing acache_dirbetween processes"; platform support indocs/contributing.mdand the README## UnreleasedinCHANGELOG.mdreshard; the hot path is unchanged (the lock is taken once, at construction)
ChunkPool→ free-threaded (3.13t) run passes:364 passed, 29 skippedwith
PYTHON_GIL=0and the GIL confirmed off_allocgains onerenameper chunk file creation (not pertile, not per batch), and construction gains one
open+flock.🤖 Generated with Claude Code
https://claude.ai/code/session_011iQ36j3rLYeh4R5uzmB52j