fix(modelartifacts): stage each writer's artifact in its own partial tree#10995
Merged
Conversation
…tree Every writer used to stage into the same `.artifacts/.partial/<cacheKey>`. That was safe only because the artifact lock held: two writers that both believed they had it opened the same blob with O_APPEND and interleaved their bytes into one file, while the resume probe read the other writer's in-flight size. SHA verification caught the damage only after both had burned the entire download. #10986 restored the lock's precondition on CIFS but left the dependency in place. Suffix the staging tree with a writer identity drawn once per process run, so concurrent writers cannot corrupt each other whatever the lock does. The lock stops being a correctness dependency and becomes a pure efficiency optimisation: a lock failure now costs a duplicated download, not a corrupted one. Commit stays an atomic rename. The loser of a commit race reconciles onto the winner's tree instead of surfacing a bare ENOTEMPTY for work that actually succeeded, since the artifact is content-addressed and both trees hold the same verified bytes. Writer-unique staging means a crashed writer's tree is no longer overwritten by its successor, so two things are added to keep it from becoming a disk leak and a resume regression: - A sweep reclaims trees whose contents have been untouched for 24h, matching the window the startup reaper already uses for stray *.partial files. It reads the newest mtime anywhere inside the tree, because writing a blob never touches an ancestor, and refuses any name this package did not write. A live download writes continuously, and the downloader's stall watchdog aborts a silent one long before it could look abandoned. - Adoption lets a restarted process claim a dead predecessor's tree for the same artifact and resume from its bytes, which a tens-of-gigabytes repo depends on. The claim is an atomic rename, so racing adopters cannot both win. It runs only under the artifact lock - which is released exactly when the owning process dies - and only on a tree idle for 5 minutes as a second line of defence for when the lock does not exclude. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code]
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.
Problem
.artifacts/.partial/<cacheKey>was shared mutable state with no second line of defence — safe only because the artifact lock held.Two writers that both believed they held it opened the same blob with
O_APPEND(pkg/downloader/uri.go:706) and interleaved into one file, while the resume probe (uri.go:634) read the other writer's in-flight size. SHA verification caught it only after both had burned the entire download — 57 GB, in the incident that surfaced this.#10986 restored the lock's precondition on CIFS. It did not remove the dependency on it.
Change
Stage into
.partial/<cacheKey>.<writerID>, wherewriterIDis 64 bits ofcrypto/randdrawn once per process run.Concurrent writers now cannot corrupt each other regardless of what the lock does. The lock stops being a correctness dependency and becomes a pure efficiency optimisation — a lock failure costs a duplicated download, not a corrupted one.
The lock is retained and still has that job: it stops N replicas each pulling the same 57 GB, and it is what proves a peer is not writing (see adoption below).
Commit stays an atomic rename. The loser of a commit race now reconciles onto
committedResultand discards its own tree, rather than surfacing a bareENOTEMPTYfor work that actually succeeded.writerIDiscrypto/randrather than PID or hostname: PIDs repeat freely across containers sharing a NAS mount, and a restarted pod can inherit a hostname while the dead pod's tree is still on disk. A collision needs two replicas to draw the same 64 bits within the same window, and even then degrades to the previous shared-tree behaviour rather than anything worse.Consequences handled
Disk leak. A crashed writer's tree is no longer overwritten by its successor, so it would leak on a shared NAS forever. A 24h sweep (matching the existing startup reaper's window) runs at startup and on the materialization path under the lock. Three independent guards make deleting a live tree impossible:
<64-hex cacheKey>.<16-hex writerID>— a shape only this package writesThat third guard matters: the directory's own mtime is useless, because writing
.downloads/<blob>.partialnever touches an ancestor. A running download writes continuously, so its newest mtime is seconds old; a silently stalled one is killed by the downloader'sDownloadStallTimeoutwatchdog after 60s. An unreadable entry counts as "now", so a tree that cannot be fully inspected is never judged stale.Resume. A new process would not find its predecessor's partial and would restart from zero — a real regression on a 57 GB repo. Adoption lets it claim an idle same-cacheKey tree by
os.Renameinto its own writer path. The rename is atomic, so racing adopters cannot both win and the loser simply starts fresh. Adoption runs only while holding the artifact lock — flock releases exactly when the owning process dies, which is the liveness signal — and is additionally gated on 5 minutes of idleness.The cost, stated deliberately: a restart that retries within 5 minutes re-downloads from zero. A longer window means more wasted re-downloads after a crash; a shorter one means a broken lock could let us claim a live peer's tree. Both directions cost exactly one wasted download, because every adopted blob is still SHA-verified before promotion — a wrongly-adopted tree fails verification rather than committing corruption. Symmetric costs, so 5 minutes is a middle, not a derived optimum, and is easy to revisit.
Tests
The headline spec runs two writers concurrently with the lock bypassed via
WithLocker, using a rendezvous server to force overlap. Onmasterit fails with interleaved bytes:That is the corruption, reproduced — not a missing symbol.
Plus specs for sweep safety (live tree, own tree, foreign directory, missing root, and the "old directory, fresh blob inside" case that a naive directory-mtime check would get wrong), adoption with a real Range-resume assertion, and writer-identity validation.
go build ./core/... ./pkg/...OK;go test ./pkg/modelartifacts/ ./pkg/downloader/ ./core/gallery/ ./core/config/all ok;-raceclean over 5 consecutive runs;make lint0 issues. Repo-wide grep includingtests/e2e/and docs: nothing outside the package asserts the.partiallayout. DroppedLayout.PartialSnapshot, which was set but never read.Follow-up, not done here
Writer-unique staging makes the previously-sketched design cheap to add: a brief advisory-lock election plus an expiring on-disk claim, with no lock held across the download. The writer identity and the atomic-rename claim are exactly the pieces that needed. Deliberately out of scope.
🤖 Generated with Claude Code