fix(distributed): count staging verification as progress, not as a stall#11026
Merged
Conversation
Testing the progress-based cold-load deadline on the live cluster surfaced a false positive. The stall window observed UPLOAD bytes only, but the staging path has a phase that does real work while moving zero upload bytes: the resumable-upload verify phase. When a shard is already present on the worker from an earlier attempt, the frontend HEADs it, hashes the local copy to confirm it matches, and skips the transfer. Staging a 70 GB model with 56 GB already staged: 17:27:34 INFO Upload skipped (file already exists with matching hash) ... 17:28:20 INFO Upload skipped (file already exists with matching hash) ... 17:29:07 INFO Upload skipped (file already exists with matching hash) ... ... six-plus consecutive minutes, no bytes uploaded at all ~45s per skipped ~4 GB shard. That is correct and desirable - it is what makes resume work - but it was indistinguishable from a stall. At 45s per shard it sits inside the 5m window, so the run in flight was fine; the problem is the 600 GB scale this machinery exists to enable, where one shard can plausibly hash for longer than the window. The guard would then fire during verification of a transfer that is working perfectly. Verified mechanism: probeExisting() HEADs the worker and then calls downloader.CalculateSHA(). The staging progress callback is only consulted inside doUpload(), which the skip path never reaches, so observeLoadProgress was called zero times for the whole verify phase. Verification exposed a second, worse bug in the same path: CalculateSHA consults no context at all. An expired cold load kept hashing to completion, compared the hashes, and returned success - reporting a file as staged on a dead load. The failure only surfaced on the NEXT file, whose HEAD died immediately. That is exactly the shape of the red test here, which fails on shard 3. Fix: hash in 1 MiB chunks via hashFileWithActivity(), ticking the cold-load deadline per chunk and checking ctx per chunk. A successful HEAD also counts, since a 200 with a content hash proves the worker is serving right now. Counting hash progress does not make a dead transfer look alive: hashing is bounded, terminating work proportional to file size, in probeExisting it runs only after a HEAD proved the worker was up, and the 24h absolute cap still bounds the whole hold. The alternative of simply widening the window was rejected - it would reintroduce the size cliff this work removes. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]
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.
Follow-up to #11019 (merged as
b700a78ae). Testing that change on the live cluster surfaced a false positive in the stall window.The gap
The stall window observes upload bytes via
withStagingCallback. But the staging path has a phase that does real work while moving zero upload bytes: the resumable-upload verify phase. When a shard is already present on the worker from an earlier attempt, the frontend HEADs it, hashes the local copy to confirm it matches, and skips the transfer.Observed staging a 70 GB model with 56 GB already present from a previous attempt:
~45s per skipped ~4 GB shard, six-plus consecutive minutes with no bytes uploaded at all. That behaviour is correct and desirable — it is what makes resume work — but from the deadline's point of view it was indistinguishable from a stall.
At 45s per shard this sits comfortably inside the 5m window, so the run in flight was fine. The problem is the scenario #11019 exists for: at 600 GB, a single shard can plausibly take longer than 5 minutes to hash. The guard would then fire during verification of a transfer that is working perfectly — a false positive landing precisely in the case the work exists to enable.
Mechanism verified
probeExisting()HEADs the worker, then callsdownloader.CalculateSHA(). The staging progress callback is only consulted atStagingProgressFromContext(ctx)insidedoUpload(), which the skip path never reaches. SoobserveLoadProgresswas called zero times for the entire verify phase. The gap is real.A second, worse bug in the same path
Writing the test exposed something beyond the reported issue:
CalculateSHAconsults no context at all. An expired cold load kept hashing to completion, compared the hashes, and returned success — reporting a file as staged on a dead load. The failure surfaced only on the next file, whose HEAD died immediately.This is exactly what the red test shows: it fails on shard 3, not shard 1, which also explains the production error shape (a run of successful skips, then an abrupt
context deadline exceededon a later file).Fix
Hash in 1 MiB chunks via
hashFileWithActivity(), which:ctx.Err()per chunk, so an expired load aborts instead of silently succeeding.A successful HEAD also ticks the deadline: a 200 carrying a content hash is proof the worker is serving right now.
Why this does not make a dead transfer look alive
Considered and rejected: simply widening the window (reintroduces the size cliff #11019 removes) and a coarser per-file "staging is doing something" signal (a single 600 GB shard would still look stalled for hours — too coarse to distinguish the two cases, which is the whole point).
Hash progress is a safe signal because:
probeExistingit runs only after a successful HEAD proved the worker was serving;EnsureRemote, outside the retry loop, so it can mask at most one file-hash duration; andA genuinely wedged worker produces no HEAD response and no hashing, so it is still caught by the stall window — covered by a spec.
Tests
TDD, Ginkgo/Gomega, in
core/services/nodes/file_stager_verify_deadline_test.go, exercising the realHTTPFileStageragainst anhttptestworker. Red first, on behaviour:Specs: a run of verified-and-skipped shards uploading zero bytes must survive; the verify path must not silently succeed after the deadline expired; a silent worker must still be killed promptly.
Note on test methodology: my first attempt at this test was a false green — the window was larger than the hash time, so it passed without any fix. The committed version was recalibrated until it failed for the right reason, and the shard sizes were then reduced to stay friendly to
/tmp.Verification
go build ./core/... ./pkg/...go test ./core/services/nodes/(full suite, 406 specs)make lint0 issues.)Not verified: the 600 GB slow-hash scenario was not reproduced — the specs simulate it with scaled-down budgets (20ms window vs 5m) against real hashing of small shards. The production log above is from the cluster, not from this branch.
Docs updated in
docs/content/features/distributed-mode.mdper the docs-with-code rule.