feat(hub): resume interrupted model downloads via HTTP Range (Node) - #1715
feat(hub): resume interrupted model downloads via HTTP Range (Node)#1715renezander030 wants to merge 5 commits into
Conversation
FileCache now streams large downloads to a deterministic <key>.incomplete file plus an etag/size sidecar, and keeps the partial on failure instead of deleting it. On the next attempt the Node streaming path (return_path) asks getResumeInfo for the offset and sends Range + If-Range: the server appends via 206, or restarts cleanly via 200 if the file changed upstream. A per-key lock preserves the previous concurrent-writer guarantee by falling back to the legacy unique-temp path when the partial is held. Adds FileCache unit tests for resume, truncation, and restart.
nico-martin
left a comment
There was a problem hiding this comment.
Hi @renezander030, thank you so much for looking into this! Resumable downloads would be really useful here. Before this is safe to merge, we need to fix the 206 lock-contention path, avoid stealing locks from slow active downloads, and fully validate ranges and validators. Could you also add concurrency and malformed-range tests? I'm holding off on merging because the current edge cases can corrupt a cached model.
Addresses review feedback on huggingface#1715. Reserve the per-key lock *before* the request is issued rather than in `put()`. A `Range` is now only ever sent by the writer that owns the partial it continues, so a 206 can no longer reach the unique-temp fallback and be renamed over the final path as a complete file. `put()` refuses an unreserved 206 outright, and the fallback path refuses any non-200 response. Validate the whole `Content-Range` before appending: the range must start exactly where the partial ends, its span must agree with `Content-Length`, and its total must match the sidecar. Unsatisfied (`bytes */N`) and suffix (`bytes -N/M`) forms are rejected. A mismatch discards the partial so the next attempt restarts clean instead of resuming onto data from another revision. Replace the blind 10-minute lock TTL with ownership and liveness. A held lock records its owner and is refreshed every 30s while downloading, so an untouched lock means an absent writer rather than a slow one. A lock is taken over only when it is stale *and* its owner is gone, or after an hour when the owner cannot be verified — a live slow download is never interrupted. Never resume without a validator: with no usable ETag to pin via `If-Range`, restart from byte 0 instead of splicing two revisions. `getResumeInfo` becomes `reserveResume`, paired with `releaseResume` for callers that abandon a download without writing. Tests cover concurrent writers, unreserved and contended 206s, ten malformed `Content-Range` forms, lock stealing and non-stealing, and resume-without-validator.
|
Thanks @nico-martin — all four are fair, and the 206 one is a genuine corruption bug rather than a sharp edge. You're right that a ranged response can reach the unique-temp path and get renamed over the final file. To answer the concurrency question directly: yes, concurrent use is supported. The random-suffix temp path predates this PR and exists for exactly that. My fallback preserved it for 200 responses and quietly broke it for 206. Rather than patch the fallback, I moved the coordination ahead of the request, which I think removes the class of bug instead of the instance:
Tests added as requested: concurrent writers (both finish, file complete exactly once, no temp/lock/sidecar left behind), a failing writer not stranding the key, unreserved and contended 206s, ten malformed One open item from the original description that I don't think got picked up: is the step-1 ( |
|
@nico-martin one small thing when you get a chance — the workflow runs on this branch are sitting in The full gate is green locally (jest including the new concurrency and malformed-range tests, |
`jsdoc` cannot parse an object literal nested inside a generic, so
`Promise<{size: number, ...}|undefined>` in the `CacheInterface` typedef
aborted `docs-api` with "Invalid type expression". This broke the Build
PR Documentation workflow.
Extract the shape into a named `ResumeInfo` typedef and reference it from
both the interface and `FileCache.reserveResume`.
Present since the original commit on this branch; it went unnoticed
because the workflow runs were awaiting approval and never executed.
|
Thanks for approving the runs. Results on The failure was in the workflow's @property {(request: string) => Promise<{size: number, etag: string|null, total: number}|undefined>}aborted the run with Worth flagging that this was not introduced by the review fixes — it has been present since the first commit on this branch under the old Local gate on the new commit: |
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
|
@renezander030 Thanks, the redesign addresses the four blocking issues from my first review, and rest looks good too. I found two cleanup issues remaining:
Once those are covered with failure/deletion tests, I’m happy to approve. |
`FileCache.delete()` removed only the completed file, leaving the `.incomplete` partial, its sidecar and the write lock behind. A later request for the same key could resume onto bytes the caller had asked to delete, and the stale lock sat in the way of the next writer. It now sweeps all four, and releases a reservation this instance holds so the lock is dropped by its owner rather than unlinked under a live heartbeat. The return value still reports only real entries — the completed file or a partial — so `clear_cache`'s fallback to the local key is unaffected by a stray lock. On the write paths, a reader, writer or progress-callback error left the output stream open over the partial while the reservation was released, handing the key to the next writer with a descriptor still on the file. Both paths now tear the stream down before releasing, and register an `error` listener so a write failure surfaces through the existing callbacks instead of reaching the process as an unhandled event. Completion now requires `loaded === total` rather than only rejecting `loaded < total`, so an oversized body cannot be promoted. A short body still keeps its partial for resuming; an oversized one is discarded, since it already runs past `total` and can never be resumed onto. Adds 13 tests: deletion of every artifact combination, the bare-lock return value, reservation release, stream teardown on reader, writer, callback and fallback-path errors, and oversized 200/206 bodies. Nine of them fail against the previous commit; the other four pin down behaviour that was already correct and is easy to break from here.
|
@nico-martin Both covered in
One deliberate choice on the return value: it still reports Stream teardown. Both write paths now destroy the stream and wait for the descriptor to be released before the reservation is dropped — reader errors, write errors and progress-callback errors alike. Previously the While there I added an Completion gate. Now Tests — 13 added, 42 total in
Nine of the thirteen fail against the previous commit; the other four pin down behaviour that was already correct and is easy to break from here. The teardown tests assert against Local gate green: I resolved the four threads from your first review, so only what you flag from here stays open. CI on |
nico-martin
left a comment
There was a problem hiding this comment.
Thanks, the cleanup and exact-size checks look good. I found one remaining concurrency issue: delete() currently removes the lock even if another writer is actively using it. That could let a second writer acquire the same key and write concurrently. Could you preserve live foreign locks and only remove locks we own or have confirmed are stale?
Also, stream.destroyed doesn’t necessarily mean the file descriptor is closed yet. destroyStream() should still wait for close unless stream.closed is already true.
…estroy `delete()` removed the lock unconditionally, so deleting a key another writer was actively downloading freed that key for a third writer to acquire and write concurrently — the same class of bug the reservation scheme exists to prevent. It now consults the same abandonment check `reserveResume` uses. A lock that is absent, stale with a provably dead owner, or stamped by this process is ours to clear; anything else belongs to a live writer and is left alone, together with the partial and sidecar that writer is streaming into. The completed file is still removed in that case: it is the entry the caller asked to delete, and it is not what the other writer holds. `destroyStream()` short-circuited on `stream.destroyed`, which is set synchronously when teardown begins rather than when the descriptor is released. A stream auto-destroyed by an error was therefore treated as finished while it could still be holding the partial open. It now short-circuits on `stream.closed`, which flips with the `close` event, and waits for that event otherwise — re-destroying only a stream that has not already begun tearing down. Adds 4 tests: a live foreign lock surviving `delete` while the completed file goes, a stale one being cleared with its artifacts, a lock this instance stamped but no longer tracks, and `put` not returning while a mid-teardown stream still holds its descriptor. The first and last fail against the previous commit.
|
@nico-martin Both fixed in Live foreign locks. One decision I'd like you to sanity-check, since it goes slightly past what you asked. When the lock is live I also leave the
Worth being precise about the impact, because it changes how the test had to be written: against a real Tests — 4 added, 46 total:
Two of the four fail against Local gate green: |
Follow-up to the discussion in #1220 (thanks @emojiiii for handing it over, and @sroussey for the Xet/CDC pointer).
This is step 1: HTTP Range-resume for the Node file-cache path, which is where large model weights are streamed to disk. It ships resume for how downloads work today, with no new transport dependency. Xet-native chunk caching (via the huggingface.js download path) is the natural follow-up and is intentionally out of scope here.
What changes
FileCachestreams large downloads to a deterministic<key>.incompletefile plus a small sidecar ({ etag, total }), and keeps the partial on failure instead of deleting it.getResumeInfo(request)reports{ size, etag, total }when a consistent partial exists.put()branches on the response status: 206 appends to the partial (offset/total parsed fromContent-Range); 200 restarts cleanly (truncates any stale partial). A truncated body (loaded < Content-Length) is rejected and the partial retained for the next attempt.put()falls back to the original unique-temp path (correct, non-resumable, never corrupts the shared partial). Stale locks are stolen after a TTL.hub.jsonly sendsRange/If-Rangeon the streaming path (IS_NODE_ENV && return_path), since a 206 on the buffered path would yield only the trailing bytes. It now accepts206alongside200and caches both.If-Range: <etag>makes the server fall back to a full 200 if the file changed upstream.CacheInterfacegains an optionalgetResumeInfoso custom caches can opt in.Scope / notes
Tests
New
tests/utils/file_cache.test.jscovers: no-partial, full 200 (no leftovers), truncated body (partial + sidecar retained,getResumeInfocorrect), 206 append to completion, and 200 restart over a stale partial. Existingcache/custom_cachesuites still pass; typecheck, prettier, and build are green.Closes #1220
Opening as a draft to get maintainer direction on the Xet question in #1220 before polishing. Happy to adjust.