fix(modelartifacts): treat CIFS EACCES as lock contention, not failure#10986
Conversation
flock(2) on CIFS/SMB returns EACCES when another client holds the lock: the kernel maps STATUS_LOCK_NOT_GRANTED and STATUS_FILE_LOCK_CONFLICT to -EACCES and never produces EWOULDBLOCK on that path. gofrs/flock only recognises EWOULDBLOCK as contention, so TryLockContext returned a bare "permission denied" and Ensure aborted. Both replicas then fell back to legacy loading, which makes the worker download the whole repo in-band inside LoadModel and blow the remote-load deadline. Replace TryLockContext with an explicit wait loop over a new Locker interface, classifying EWOULDBLOCK/EAGAIN/EACCES/EBUSY as contention. EACCES is ambiguous at the syscall boundary but not here: the lock file is already open O_CREATE|O_RDWR, so a real permission problem would have failed the open with an *fs.PathError, and flock(2) documents no EACCES on Linux at all. The wait is bounded (DefaultLockWait, overridable via WithLockWait), so even a misclassification degrades to a delay. On timeout the committed result is re-checked before reporting the new ErrLockContended, so a peer that finished the work still wins. Locker also exists so the contention path is testable without a network filesystem: nothing in CI can make flock(2) return EACCES on demand. Raise the fallback to error for a managedArtifactBackends backend, via a shared config.LogArtifactFallback used by both call sites. For those backends the legacy path is not graceful degradation, and the operator otherwise sees only a timeout with no causal link. The fallback stays non-fatal. Drop the os.Chmod(layout.Lock, 0o600) after acquisition: flock.New already creates the file 0600, and the chmod was gratuitous risk on a nounix mount that ignores modes. Fixes #10981 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]
|
Correcting the rationale in the description above, because it states the right conclusion for the wrong reason and would invite the same challenge from the next reviewer. The description says the Postgres advisory lock was not used because "it only works where Postgres is present, so the flock stays as the single-node path either way, leaving two mechanisms to keep in sync." That is wrong on the facts. The real reasons are lock ownership and duration. Ownership. Postgres advisory locks have no fencing token. If the holder's connection drops, the lock is released silently and the original holder is not notified — it keeps writing to flock has no such gap: the kernel ties the lock to the process that owns the files. It is released exactly when that process dies, because it is that process's lock. An advisory lock is held by a connection with no causal relationship to the download — the connection can drop while the download continues, and the download can die while the connection lingers. Any liveness protocol added on top is an attempt to re-establish a coupling flock gets for free. Duration. Every existing advisory-lock use in this tree is a short critical section or a ticker-guarded leader election; Wiring, independently. Known limitation this PR does not address
A follow-up worth considering, deliberately not done here: make the partial path writer-unique ( |
…tree (#10995) 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. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Problem
On a CIFS/SMB-backed
/models, two controller replicas starting together both fail to materialize the same model and both silently fall back to legacy loading — which makes the worker download the entire repo in-band insideLoadModeland exceed the remote-load deadline.flock()on CIFS returnsEACCES, notEWOULDBLOCK, when another client holds the lock: the kernel mapsSTATUS_LOCK_NOT_GRANTEDandSTATUS_FILE_LOCK_CONFLICTto-EACCES, and there is noEWOULDBLOCKtranslation anywhere in the cifs lock path.gofrs/flockonly recognisesEWOULDBLOCKas contention, soTryLockContextreturned a hard error andEnsureaborted.The
permission deniedin the logs was the lock working correctly and being misread as a failure. Root and0777were irrelevant — no permission check ever ran.Confirmed experimentally on the affected cluster, two pods against the shared mount:
flock(1)exits 1 silently onEWOULDBLOCKand only prints for other errnos, so this provesEACCES.Fixes #10981.
Approach: disambiguate EACCES rather than replace flock
EACCESfromflockon SMB is genuinely ambiguous at the syscall boundary, so treating it as contention naively would risk retrying forever on a real permission error. It is not ambiguous at this call site:gofrs/flockopens the lock fileO_CREATE|O_RDWR(flock.go:184) before callingunix.Flock. A real permission problem therefore fails the open and returns an*fs.PathErrornaming the path. The production error was a bare, path-lesspermission denied— the diagnostic fingerprint that identified this bug in the first place.flock(2)on Linux documents noEACCESat all (EBADF,EINTR,EINVAL,ENOLCK,EWOULDBLOCK).So a bare
EACCESfrom the lock call, on an fd already held read-write, can only come from a network filesystem's lock-conflict translation. And the wait is bounded regardless, so a misclassification degrades to a delay rather than a hang — that asymmetry is what makes the cheap option defensible rather than merely cheap.The
O_CREAT|O_EXCLsentinel alternative, and why it was rejectedEEXISTsurvives SMB translation unambiguously, which is genuinely attractive. But a crashed holder leaves a stale sentinel, and the grace period would have to exceed the longest legitimate materialization — hours, for the 57 GB repo in this issue. Too short silently permits two replicas materializing concurrently, which is the same outcome as thenobrlbooby trap. Too long blocks every replica for hours after a crash. Doing it correctly needs an mtime heartbeat refreshing the sentinel across replicas — replacing crash-safety the kernel gives away for free with crash-safety we would have to maintain.The Postgres advisory lock (the "architecturally correct" option in the issue) was not pursued: it only works where Postgres is present, so the flock stays as the single-node path either way, leaving two mechanisms to keep in sync. The new
Lockerinterface is the seam it would plug into later.Changes
acquireLock: explicit bounded wait loop replacingTryLockContext, treatingEWOULDBLOCK/EAGAIN/EACCES/EBUSYas contention. Logs when it starts and stops waiting.ErrLockContended, returned only when the wait window expires.Ensurere-checks the committed result first, so a peer that finished the work wins and contention never reaches the fallback — losing the race is a success signal, not a failure.Lockerinterface +WithLocker/WithLockWait. Exported rather than test-only: it is both the seam that makes the contention path testable without a network filesystem, and the plug point a database-backed lock would use.config.LogArtifactFallback, shared bybindPrimaryArtifactandpreloadOne. Logs at error for amanagedArtifactBackendsbackend and names the in-band download, since for those backends "legacy loading" is a guaranteed slow path rather than graceful degradation. Still non-fatal. The gate is always true today, but is checked explicitly so wideningPrimaryArtifactSpeclater cannot silently promote an ordinary fallback to an error.os.Chmod(layout.Lock, 0o600)—flock.Newalready creates the file0600withO_CREATE, so the chmod was a syscall that could only fail on anounixmount withfile_mode=0777forced.Testing
CIFS cannot be mounted in CI, so the errno is injected rather than the filesystem. The seam landed first as a faithful port of the old semantics, so the new specs failed on the real
EACCESrather than on a missing symbol:The
EWOULDBLOCKandENOLCKspecs passed throughout — that is the control, proving the seam preserved behaviour and only the classification changed. Severity specs capture atslog.LevelErrorso a warn emission is filtered out and the test fails on severity rather than wording.Grepped the repo including
tests/e2e/for the touched contracts — no other pins.Notes for review
preloadOneruns sequentially per model, soDefaultLockWait(30m) can stall startup behind a peer's large download. Waiting is still strictly cheaper than the fallback, which is guaranteed-slow and duplicates the same download — but this is a behavioural change worth a second opinion. The alternative would be deferring materialization on contention so the losing replica finishes startup and serves traffic sooner.WithLockWaitis not yet wired to a CLI flag or env var; exposing it would touch the runtime-settings registry, so it is left as a follow-up.os.Rename(layout.Partial, layout.Final)still has no SMBEBUSYfallback. It is correct while the lock holds, and this PR restores that precondition on CIFS rather than weakening it — previously, contention silently sent both replicas down a path where the lock's guarantee no longer held.🤖 Generated with Claude Code