Skip to content

fix: validate multi-file cache staging on shared volumes - #2185

Merged
Defilan merged 4 commits into
defilantech:mainfrom
aditya-rai-5:fix/multifile-cache-size
Oct 26, 2026
Merged

Defilan merged 4 commits into
defilantech:mainfrom
aditya-rai-5:fix/multifile-cache-size

Conversation

@aditya-rai-5

@aditya-rai-5 aditya-rai-5 commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

What

Validate unpinned cached files in multi-file models against their recorded staged size, keeping entries without a record and backfilling their local size where safe, and check the published file size after staging. Prevent serving pods from staging into FUSE-backed caches where cross-node locking cannot be trusted.

Why

Concurrent staging into a shared ReadWriteMany cache can leave a truncated weight file that later pods accept based only on its existence.

Fixes #2158

How

  • Use recorded staged sizes to validate IfNotPresent cache hits locally, while keeping entries without a record without contacting the origin and backfilling their local size where safe. A file that mismatches a valid record is downloaded again. Checksum-pinned files retain local verification.
  • For OnChange, check the recorded staged size when revalidation fails and the origin size is unavailable, so a known truncated file is not accepted.
  • Detect FUSE using its numeric filesystem identifier, which works with the downloader image’s BusyBox stat.
  • Behavior change: serving pods refuse actual multi-file staging on FUSE mounts, including virtiofs/Kata volumes. Warm caches remain usable.
  • Allow the prefetch Job to stage on FUSE. Its pod retries wait for failed pods, and Job replacement uses foreground deletion so an old writer finishes before the new Job starts.
  • Add regression coverage for cache sizes, FUSE detection, and Job replacement; update generated command goldens and cache documentation.
  • For OnChange, a failed refresh compares the cached file with its own size record, even if the origin now reports a different size. A file without a valid record is kept with a warning.
  • Write staged size records using a temporary file and rename so concurrent readers cannot observe a partially written number.

Focused regression and golden tests, make test-affected, and make gate passed locally. Azure Blob CSI validation remains pending.

Assisted-by: Codex (some part of implementation).

Checklist

  • Tests added/updated
  • make test passes locally
  • make lint passes locally
  • Commit messages follow conventional commits
  • All commits are signed off (git commit -s) per DCO
  • AI assistance (if any) is disclosed above, per CONTRIBUTING.md
  • Documentation updated (if user-facing change)

@aditya-rai-5
aditya-rai-5 requested a review from Defilan as a code owner October 23, 2026 15:41

@Defilan Defilan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for picking up #2158. This is a tricky corner of the downloader, and the PR shows real care: the post-publish size check is a good idea, the HF probe correctly follows the CDN redirect and reads the final Content-Length, the token stays out of the logs, and the FUSE magic check is right for BusyBox (I confirmed stat -f -c %t prints 65735546 in curlimages/curl:8.18.0). Lint passes on darwin and linux.

I ran the golden scripts in the real init image (curlimages/curl:8.18.0) against a local origin, comparing this branch with its base. A few behavior changes need addressing before this can merge.

1. A warm cache can no longer start offline. With IfNotPresent (the default), a complete, correct cached file now needs a successful HEAD on every pod start. If the origin is unreachable, returns an error, or is rate limited, the script treats the file as unverified, tries to re-download it, fails, and exits 1. Before this change, the same pod started from cache without touching the network.

complete cached file, origin... before this PR
unreachable (http, s3) starts from cache init fails
503 (http, hf with token) starts from cache init fails

That breaks the air-gapped workflow in docs/MODEL-CACHE.md and the IfNotPresent contract in api/v1alpha1/model_types.go ("download only if the cached file is missing ... an operator upgrade triggers no surprise re-pulls"). TestModelDownloadLock_ReadOnlyWarmCache pointed at an unreachable origin on purpose to guard this case. Please keep that test as it was rather than giving it a live origin.

GPU Deployments use the Recreate strategy, and this PR changes the init command, so every multi-file cached Deployment rolls on upgrade. The old pods would stop first, and the new ones could not start.

Suggested direction: re-stage only on a definite mismatch (the origin answered with a numeric Content-Length that differs from the local size). Keep the cached file, with a warning, when no size is available. Even better would be a comparison that needs no network at start: the controller already gets per-file sizes from the HF tree listing (hf_listing.go), and those could be passed in as env the same way MODEL_FILE_SHA256 is. A local size record written after a verified publish would also work.

2. OnChange lost its keep-cached fallback. An unreachable origin used to log "Revalidation unreachable ... kept cached copy" and start. Now it fails (http and s3). That also diverges from the single-file revalidation path, which still keeps the cached copy.

3. No Content-Length means a re-download on every start. If the origin, or a mirror or proxy in front of it, answers HEAD without Content-Length, every pod start re-downloads the full file (I saw 2 starts produce 2 full GETs). A 429 from Hugging Face does the same. HF counts each /resolve/ request against the Resolvers limit (3,000 per 5 minutes per IP for anonymous users, https://huggingface.co/docs/hub/rate-limits), so a large rollout could hit that limit and turn warm starts into full re-pulls. Fixing item 1 fixes this too.

4. The FUSE gate. Detection works, but a few things need changing:

  • It runs before anything else, so it also blocks a FUSE cache that is already fully staged and needs no writes. The issue asks that staging not proceed without a working lock. Could the check run only when a file actually needs downloading? As written, the reporter's existing blobfuse deployment would go down on upgrade unless they set prefetch first.
  • The error tells users to set spec.modelCache.prefetch: true, but s3:// sources are not prefetchable (prefetchableSource in prefetch.go). An s3:// multi-file Model on a FUSE cache would have no way to start.
  • The message does not match any of the transfer-failure signatures in model_transfer_diag.go, so the InferenceService gets no condition. Users would only see Init:CrashLoopBackOff. A signature with a clear reason and remedy would help a lot.
  • 0x65735546 also matches virtiofs, so volumes under Kata Containers (and virtiofs bind mounts generally) would be refused too. Worth a line in the docs.
  • Please call this out as a behavior change in the PR description so it lands in the release notes.

5. The controller suite fails. With envtest, go test ./internal/controller/... has 4 failing specs in inferenceservice_storage_test.go: the three "uses/generates ... HEAD revalidation for OnChange" specs (they assert kept cached copy) and "generates download loop for IfNotPresent policy" (its expected string predates the publish wrapper). Also, the new shell tests skip on macOS, because requireInitShellEnvironment needs a stat with -c. If you ran them on a Mac, they were skipped rather than run. Running them in a Linux container (for example golang:1.26) exercises them for real. They do pass there on this branch.

Smaller things:

  • TestMultiFileOnChangeRejectsUnverifiedCache lists config.json but never caches it, so the script fails on the missing config.json, not on the truncated shard. Restoring the old keep-cached line still passes the test. Caching config.json as well would make it test what it says.
  • When the post-publish size check fails, $dest is left in place and only the .sha256 stamp is removed. Removing $dest too would stop an existence-only reader (for example, the prefetched-pod verify step) from picking up the short file.
  • For an unpinned revision, an upstream re-upload that changes one shard's size now re-fetches only that shard, so the cache can end up mixing revisions. Comparing against the size recorded at staging time avoids that.
  • The foreground deletion change looks reasonable. One note: if the old Job's pod is on a node that went NotReady, the replacement waits until that pod is force-deleted. A hint in the status message or docs would help users unblock it.
  • TestMultiFileCachedSize (offline, head-failed) and TestMultiFileOnChangeRejectsUnverifiedCache currently assert the fail-closed behavior, so they will need to flip along with items 1 and 2. An offline warm-cache case that must pass would be a good addition.
  • llmkube_cached_size_matches is redefined on every loop iteration. Defining it once in the prefix is enough.

The post-publish size check and "re-stage when the origin definitely reports a different size" are worth keeping. If the fail-closed parts are dropped and the FUSE gate is limited to actual staging, I think this gets close. Happy to talk through the size-from-listing approach if you want to go that way.

@aditya-rai-5
aditya-rai-5 requested a review from Defilan October 24, 2026 09:16

@Defilan Defilan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the second round. This addresses almost everything from the first review, and the size record is a better design than what I suggested: a warm cache with a record now validates with no network at all. I re-ran the same harness as last time, with the generated scripts in the real init image (curlimages/curl:8.18.0) against a local origin, plus a Docker Desktop bind mount, which reports the FUSE magic (virtiofs).

What's fixed, confirmed in the image:

  • Warm caches start offline again (items 1 and 3). With IfNotPresent, a complete cached file starts when the origin is unreachable, returns 503, or sends no Content-Length. It logs a warning and makes no re-download, for http, s3 and HF with a token. A truncated file is re-downloaded when the origin answers, and the size record is written. With a record, the check is local: a complete file starts offline with zero requests, and a second start after a fresh download makes no request.
  • OnChange keeps the cached copy when revalidation is unreachable (item 2), for http, s3 and HF.
  • The FUSE gate (item 4). A warm FUSE cache starts, with no lock and no writes. Staging is refused with ModelCacheLockUnsupported, the prefetch stager may stage, s3:// gets its own remedy, and the new transfer signature turns the refusal into a condition. The docs cover the behavior change, virtiofs and Kata, and the NotReady recovery.
  • The smaller items: $dest is removed on a failed size check, config.json is now cached in the test, the size helper is defined once, and the tests that asserted the old fail-closed behavior have flipped.

Two things before merge:

1. The branch no longer builds against main. #2206 added a prep parameter to buildModelStorageConfig, so the new model_storage_fuse_test.go:59 call is one argument short. CI tests the merge with main, and this one error is all six failures (Lint, Tests and four e2e jobs): not enough arguments in call to buildModelStorageConfig. The fix is to pass ModelCachePrepAlways, as the other test call sites do:

storage := buildModelStorageConfig(model, nil, "default", true, ModelCacheModeShared, "", defaultPrefetchImage, 0, ModelCachePrepAlways, nil, "")

With that one change, rebased onto main: make test passes (53 packages), golangci-lint reports 0 issues on darwin and linux, and the shell-behavior tests pass in a Linux container (golang:1.27.3), where they run rather than skip.

2. OnChange can still serve a file that its own size record proves is truncated. With the origin unreachable, a 5-byte file whose .llmkube-size says 23 is kept: "Revalidation unreachable for model.safetensors; kept cached copy". IfNotPresent correctly fails in the same case. The cause is that keepCachedTail calls llmkube_cached_size_matches "$remote_size" with an empty argument, and any argument skips the record. This is #2158's failure, a truncated shard accepted on existence, and the record you added can catch it. Passing the argument only when it's set fixes it:

llmkube_cached_size_matches ${remote_size:+"$remote_size"}

I ran that change in the image. Offline, a truncated file with a record now fails, a complete file with a record is kept with no warning, and a file without a record is still kept with the warning. With the origin up, the truncated file is repaired. A test for "OnChange, origin unreachable, file shorter than its record, init fails" would pin it.

Optional, but worth considering:

  • Pre-existing caches send a HEAD on every start, indefinitely. The record is only written when a file is published, so a cache staged before this change (every current user's) never gets one and probes the origin on each pod start. Writing the record after a successful size match would let those caches go offline-clean after one start, and keeps HF's Resolvers limit out of large rollouts.
  • The PR description still describes the first commit, for example "A mismatched or unverified unpinned file is downloaded again." Updating it to the current behavior, with the behavior-change note that's already in the docs, would keep the release notes accurate.

Once 1 and 2 are in, I think this is ready.

@aditya-rai-5

Copy link
Copy Markdown
Contributor Author

Addressed both: merged upstream main and updated the storage test call with ModelCachePrepAlways; fixed both OnChange fallback paths to consult the staged size record when the remote size is unavailable. Added regression coverage across HTTP, HF, S3, and partially pinned manifests, and updated the command goldens. make test-affected and make gate pass locally.

@aditya-rai-5
aditya-rai-5 requested a review from Defilan October 25, 2026 11:42

@Defilan Defilan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick turnaround. Both items from the last round are in: the branch builds against main, and the OnChange fallback now consults the size record.

Another pass on fff210a4 found two problems in how caches staged before this change behave after the upgrade. Sorry these are landing after I said it was close; they're in code the earlier rounds didn't catch. These two need fixing before merge:

  1. IfNotPresent can mix revisions in a pre-existing cache (inline at model_storage.go:1224).
  2. OnChange fails, instead of keeping the cached copy, when upstream changed size and the download fails (inline at model_storage.go:1180).

Both come from treating a cache without a size record as untrusted. One shape that fixes both, and the HEAD-every-start note from last round: under IfNotPresent, trust an existing file that has no record (as main does) and backfill the record, so the next start is offline-clean. Under OnChange, keep the pre-PR "kept cached copy" behavior when the download fails and the only reason for the mismatch is that upstream changed size.

Everything else below is non-blocking. Fix it here if it's cheap, or I'm happy to take follow-up issues:

  • Inline: FUSE prefetch stagers of two Models sharing a source and claim (:1157), Content-Length: 0 treated as authoritative (:1220), HEAD with no timeout and no backfill (:1219), the size record written non-atomically (:1206), duplicated remedy text (:1151), the foreground-delete decision depending on a feature-gated field (prefetch.go:235), and the stat -f requirement labelled as a lock problem (model_transfer_diag.go:125).
  • Not in the diff: the single-file GGUF cached path (mkcache around model_storage.go:504) still takes cacheDirLock on FUSE, where flock doesn't exclude across nodes, so #2158's truncated-partial race is still reachable for single-file models. Detection next to cacheDirLock would cover both builders. A follow-up issue is fine.

`[ "$_llmkube_local_size" = "$_llmkube_size" ]; }; `
prefix += sizeCheck
downloadOrCached := func(download, cachedEcho string) string {
accept := `llmkube_cached_size_matches`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs fixing before merge. For a file with no .llmkube-size (every cache staged before this PR), llmkube_cached_size_matches HEADs the origin and re-downloads the file if the size differs, per file.

Scenario: a Model on unpinned hf://org/repo (tracking main), cache staged by the previous operator. Upstream pushes a new model-00002-of-00004.safetensors or tokenizer.json with a different size. After the upgrade, the next pod start re-downloads only that file. The cache now holds shards and tokenizer from two revisions, and the server loads mismatched weights or crashes. On main, [ ! -f "$dest" ] never touched warm files.

The comment above says the record exists to avoid "refreshing just one shard when a mutable upstream changes", but the no-record fallback does exactly that for every existing cache. Suggest: under IfNotPresent, accept an existing file with no record (as before) and write the record from its local size, or fail closed for the whole manifest rather than refreshing a subset. A regression test for "no record, origin size differs, IfNotPresent: file not re-downloaded" would pin it.

Comment thread internal/controller/model_storage.go Outdated
sizeHit := ""
keepCachedTail := `elif [ -f "$dest" ]; then echo "Revalidation unreachable for $rel; kept cached copy"; ` +
`else echo "ERROR: model artifact $rel missing and revalidation failed"; exit 1; fi; `
keepCachedTail := `elif [ -f "$dest" ] && llmkube_cached_size_matches ${remote_size:+"$remote_size"}; then echo "Revalidation unreachable for $rel; kept cached copy"; ` +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs fixing before merge. When the origin is reachable and upstream changed a file's size, remote_size holds the new size. If the download then fails (network blip, 5xx, disk pressure), this fallback compares the cached file to the new size, which can never match, so the init exit 1s. Before this PR it logged "kept cached copy" and served.

On FUSE it's worse: llmkube_prepare_download always refuses for non-stager pods, so any upstream change to an OnChange model makes every serving pod's init fail. That contradicts the docs' "Warm caches remain usable".

Suggest: when the download fails, judge the cached copy against its own record (or keep it with the warning when there is none), not against the new origin size. The new origin size proves the cache is stale, not that it's corrupt. A test for "OnChange, origin reachable with a changed size, download fails: cached copy kept" would pin it.

}
prefix := `cache_fs=$(stat -f -c %t "$CACHE_DIR" 2>/dev/null || stat -f -c %t "$(dirname "$CACHE_DIR")") || { echo "ERROR: ModelCacheLockUnsupported: cannot determine cache filesystem; use an init image with stat -f support" >&2; exit 1; }; ` +
`if [ "$cache_fs" != 65735546 ]; then mkdir -p "$CACHE_DIR" || exit 1; ` + cacheDirLock +
`elif [ "${LLMKUBE_PREFETCH_STAGER:-false}" = true ]; then mkdir -p "$CACHE_DIR" || exit 1; fi; ` +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking. On FUSE every prefetch stager skips cacheDirLock. The cache dir is keyed on source alone, so two Models on the same hf://org/repo with prefetch: true on one BlobFuse RWX claim run two prefetch Jobs that curl -C - into the same $MODEL_PATH.<key>.tmp partials with no lock. Foreground deletion and PodReplacementPolicy serialize pods within one Job, not across Jobs. The docs say to "avoid" this setup, but nothing enforces it. A follow-up could have the controller refuse, or serialize, a second stager for the same cache key.

Comment thread internal/controller/model_storage.go Outdated
`if [ "$#" -eq 0 ] && [ -f "$dest.llmkube-size" ]; then _llmkube_size=$(cat "$dest.llmkube-size" 2>/dev/null); ` +
`case "$_llmkube_size" in ''|*[!0-9]*) ;; *) [ "$_llmkube_local_size" = "$_llmkube_size" ]; return $?;; esac; fi; ` +
`if [ "$#" -gt 0 ]; then _llmkube_size=$1; else _llmkube_size=$(` + sizeCurl + ` -fsSL -I "$url" -o /dev/null -w '%header{content-length}' 2>/dev/null) || _llmkube_size=; fi; ` +
`case "$_llmkube_size" in ''|*[!0-9]*) echo "WARNING: origin size unavailable for $rel; keeping cached copy without size validation" >&2; return 0;; esac; ` +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking. An origin answering HEAD with Content-Length: 0 passes the numeric case, so a non-empty cached file never matches. IfNotPresent then re-downloads every warm file on every start, and a FUSE serving pod fails with ModelCacheLockUnsupported even though the cache is good. The OnChange probe at the same site already guards with [ "$remote_size" != "0" ]; this helper should treat 0 as "unavailable" too.

Comment thread internal/controller/model_storage.go Outdated
`_llmkube_local_size=$(stat -c %s "$dest" 2>/dev/null) || return 1; ` +
`if [ "$#" -eq 0 ] && [ -f "$dest.llmkube-size" ]; then _llmkube_size=$(cat "$dest.llmkube-size" 2>/dev/null); ` +
`case "$_llmkube_size" in ''|*[!0-9]*) ;; *) [ "$_llmkube_local_size" = "$_llmkube_size" ]; return $?;; esac; fi; ` +
`if [ "$#" -gt 0 ]; then _llmkube_size=$1; else _llmkube_size=$(` + sizeCurl + ` -fsSL -I "$url" -o /dev/null -w '%header{content-length}' 2>/dev/null) || _llmkube_size=; fi; ` +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (the follow-up to last round's optional note). The HEAD here has no --connect-timeout/--max-time, and a matching probe never writes .llmkube-size, so a pre-existing cache probes on every start indefinitely. On an air-gapped cluster where the origin is blackholed rather than refused, each probe waits for the TCP connect timeout (about 2 minutes), so a 40-shard model can stall startup for over an hour. A short timeout plus backfilling the record after a successful match would fix both.

Comment thread internal/controller/model_storage.go Outdated
return `{ published_size=$(stat -c %s "` + partial + `") && ` + publish + ` && ` +
`{ [ "$(stat -c %s "$dest")" = "$published_size" ] || { ` +
`rm -f "$dest" "$dest.sha256" "$dest.llmkube-size"; echo "ERROR: published artifact $rel has the wrong size" >&2; false; }; } && ` +
`{ printf '%s\n' "$published_size" > "$dest.llmkube-size" || echo "WARNING: could not record staged size for $rel" >&2; }; }`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking. The record is written with printf > "$dest.llmkube-size" while serving pods on a shared cache may read it concurrently. A reader can see an empty or truncated number (123 of 12345678). The empty case is handled, but a truncated digit string passes the numeric case, mismatches, and triggers a download, or a ModelCacheLockUnsupported failure on FUSE, for a correct file. llmkube_atomic_write (mktemp + rename) exists for this, but only in the withSHA256 helpers; the unpinned path could use it too.

// Warm FUSE caches need no lock or writes. Only the prefetch stager may
// download there; ordinary filesystems retain the directory-wide lock.
// Use Linux FUSE_SUPER_MAGIC: BusyBox stat reports its name as UNKNOWN.
remedy := "set spec.modelCache.prefetch: true on every InferenceService using this claim, or use storage with cross-node flock support"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. The ModelCacheLockUnsupported remedy text lives here and again, with different wording, in the transfer-diag signature. When prefetch support changes (e.g. s3 prefetch), the pod log and the ModelTransferHealthy condition will give conflicting instructions unless both are updated. A shared constant, or letting the diag pass through the script's message, would avoid the drift.

func deletePrefetchJob(ctx context.Context, c client.Client, job *batchv1.Job) error {
if err := c.Delete(ctx, job, client.PropagationPolicy(metav1.DeletePropagationBackground)); err != nil && !apierrors.IsNotFound(err) {
propagation := metav1.DeletePropagationBackground
if job.Spec.PodReplacementPolicy != nil && *job.Spec.PodReplacementPolicy == batchv1.Failed {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking. This picks foreground deletion only when the Job read back from the API server has PodReplacementPolicy=Failed. With the JobPodReplacementPolicy gate off (alpha and off by default on 1.28, or disabled on 1.29+), the API server drops the field, so FUSE stager Jobs get background deletion and immediate pod replacement, and old and new stagers overlap with no flock. Keying the decision off something the operator owns (a label or annotation set in buildPrefetchJob) and documenting the gate requirement would make the protection hold on any cluster.

reason: ReasonModelCacheLockUnsupported,
remedy: "for HTTP/HF sources, set spec.modelCache.prefetch: true on every service using the claim; " +
"s3:// sources cannot prefetch, so use storage with cross-node flock support or pre-stage files with a pvc:// source",
matches: []string{"modelcachelockunsupported"},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking. The script's stat -f failure ("cannot determine cache filesystem") also carries the ModelCacheLockUnsupported token, so it matches this signature with the remedy "set spec.modelCache.prefetch: true", but prefetch would hit the same stat failure. It also fails on every filesystem, not just FUSE, for a custom init image whose stat lacks -f (which worked before on NFSv4/CephFS). Suggest a distinct token for that error, and falling back to cacheDirLock (the previous behavior) when the filesystem type can't be determined.

Signed-off-by: aditya-rai-5 <aditya5748rai@gmail.com>
Signed-off-by: aditya-rai-5 <aditya5748rai@gmail.com>
Signed-off-by: aditya-rai-5 <aditya5748rai@gmail.com>
@aditya-rai-5
aditya-rai-5 force-pushed the fix/multifile-cache-size branch from fff210a to 6bd0b71 Compare October 26, 2026 08:16
Signed-off-by: aditya-rai-5 <aditya5748rai@gmail.com>
@aditya-rai-5

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review. This update addresses both blocking findings and three related non-blocking comments:

  1. IfNotPresent preserves existing files without a size record without contacting the origin, preventing mixed revisions after an upgrade. It backfills the local size where safe; warm FUSE caches remain write-free.
  2. After a failed OnChange refresh, cached files are checked against their own size record, not the changed origin size. Files without a valid record retain the keep-cached fallback; known truncated files still fail.
  3. Content-Length: 0 no longer triggers warm-cache downloads under IfNotPresent because that origin probe has been removed.
  4. Removing the same probe also eliminates repeated HEAD requests and their startup delays for legacy IfNotPresent caches.
  5. Size records now use temporary-file-and-rename writes, reusing the existing atomic writer.

Regression tests reproduced the blocking failures before the fix and pass afterward. Coverage includes HTTP, HF, S3, partially pinned manifests, failed refreshes, known truncation, atomic records, and simulated warm FUSE caches.

I have left these reviewer-designated non-blocking items for follow-up:

  1. Cross-Model FUSE prefetch coordination.
  2. Consolidating the duplicated remedy text.
  3. Job replacement safety when PodReplacementPolicy is dropped.
  4. Filesystem-probe failure diagnostics and fallback.

The separate single-file GGUF/FUSE staging observation also remains for follow-up.

Validation:

  • Focused behavioral, checksum, wiring, and golden tests passed.
  • Controller-package lint: 0 issues.
  • GOTOOLCHAIN=go1.27.3 make test-affected: passed.
  • GOTOOLCHAIN=go1.27.3 make gate: all steps passed.

Real Azure Blob CSI/BlobFuse validation remains pending; the FUSE tests simulate filesystem reporting.

@aditya-rai-5
aditya-rai-5 requested a review from Defilan October 26, 2026 11:53

@Defilan Defilan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this round fixes both blockers, and the approach (no origin probe for a file with no record, backfill from the local size) is simpler than what I suggested. I re-ran the generated scripts in the real init image (curlimages/curl:8.18.0) against a local origin, and on a real FUSE mount (fuse-overlayfs, magic 65735546) in Alpine's BusyBox.

Both blockers, confirmed:

  • IfNotPresent, cache with no record, upstream changed size: the file is kept, its record is backfilled, and the origin gets zero requests. The next start runs with no warning. No more mixed revisions.
  • OnChange, origin reachable with a changed size, download fails (500): the cached copy is kept, with or without a record. A file shorter than its own record still fails, both offline and when the download fails.

Also checked:

  • IfNotPresent with a record and a short file: repaired when the origin is up, init fails when it is down.
  • FUSE: a warm cache with no record starts and nothing is written. A serving pod refuses to stage a missing file, and the prefetch stager stages it.
  • The size record is written atomically.
  • Shell tests run (not skipped) in golang:1.27.3 as a non-root user, including TestModelDownloadLock_ReadOnlyWarmCache. The full internal/controller suite passes, and golangci-lint reports 0 issues.

Non-blocking, fine as follow-ups:

  1. OnChange on FUSE, serving pod, upstream changed: it logs ERROR: ModelCacheLockUnsupported: ... and then Revalidation unreachable for model.safetensors; kept cached copy, then exits 0. The behavior is right: the init exits 0, so the transfer diagnostics stay quiet. But the log reads as two failures, and "unreachable" is wrong, because the origin answered. The pod also keeps serving the old revision until a stager refreshes it. A message like "upstream changed; a serving pod does not stage on FUSE; kept the cached copy" would be clearer.
  2. The FUSE prefetch stager does not backfill the record either, so a FUSE cache staged before this change warns on every start, forever. The stager is the one pod allowed to write there, so it could backfill.
  3. The backfill records a legacy file's size as it is, including a file #2158 truncated before the upgrade. The docs say so ("cannot prove that a legacy file was complete"). A line in the release notes would help.
  4. The temp files from the record writer (model.safetensors.llmkube-size.XXXXXX) are not swept by the *.tmp cleanup if the pod dies between mktemp and mv. The sha256 stamps have the same gap today, so this is not new.
  5. The PR description's second "How" bullet ("check the recorded staged size when revalidation fails and the origin size is unavailable") contradicts the later bullet and the code: a failed refresh now checks the record whatever the origin says. Dropping it would keep the release notes accurate.

The deferred items (cross-Model FUSE stagers, the shared remedy text, the PodReplacementPolicy gate, the stat -f fallback, single-file GGUF on FUSE) are fine as follow-up issues; I can open them.

CI has not run on this push yet, so this merges once it's green.

@codecov

codecov Bot commented Oct 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Defilan
Defilan merged commit e6eab85 into defilantech:main Oct 26, 2026
31 checks passed
@github-actions github-actions Bot mentioned this pull request Oct 26, 2026
hbjydev pushed a commit to hbjydev/phoebe that referenced this pull request Oct 27, 2026
…→ 0.10.12) (#1018)

This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [ghcr.io/defilantech/charts/llmkube](https://github.com/defilantech/LLMKube) | HelmChart | patch | `0.10.11` → `0.10.12` |

---

### Release Notes

<details>
<summary>defilantech/LLMKube (ghcr.io/defilantech/charts/llmkube)</summary>

### [`v0.10.12`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#01012-2026-10-27)

[Compare Source](defilantech/LLMKube@v0.10.11...v0.10.12)

##### Features

- **api:** add MetalNode, verified by a controller dial-back, with the agent reporting through its Lease ([#&#8203;2235](defilantech/LLMKube#2235)) ([fccc1bc](defilantech/LLMKube@fccc1bc))
- **cli:** add llmkube metal join and leave, with agent token renewal ([#&#8203;2246](defilantech/LLMKube#2246)) ([b1f6602](defilantech/LLMKube@b1f6602))
- **cli:** add llmkube metal token create and metal remove ([#&#8203;2236](defilantech/LLMKube#2236)) ([f54414a](defilantech/LLMKube@f54414a))

##### Bug Fixes

- **controller:** keep a ModelPool activation while its member is held at zero ([#&#8203;2241](defilantech/LLMKube#2241)) ([8fc4143](defilantech/LLMKube@8fc4143))
- **controller:** map sglangConfig.enablePrefixCaching onto --disable-radix-cache ([#&#8203;2259](defilantech/LLMKube#2259)) ([d499aa4](defilantech/LLMKube@d499aa4))
- **controller:** stage under the cache lock when the init image's stat has no -f ([#&#8203;2260](defilantech/LLMKube#2260)) ([52241de](defilantech/LLMKube@52241de))
- **router:** list each backend on /v1/models under the id BackendNameMatch routes ([#&#8203;2257](defilantech/LLMKube#2257)) ([4627cfc](defilantech/LLMKube@4627cfc))
- **router:** report moves off a quarantined pod as reason="quarantined" instead of reason="not_ready" ([faa7a5f](defilantech/LLMKube@faa7a5f))
- **router:** rewrite vLLM and SGLang backends to the name they serve ([#&#8203;2258](defilantech/LLMKube#2258)) ([3d39111](defilantech/LLMKube@3d39111))
- validate multi-file cache staging on shared volumes ([#&#8203;2185](defilantech/LLMKube#2185)) ([e6eab85](defilantech/LLMKube@e6eab85))

</details>

---

### Configuration

📅 **Schedule**: (in timezone Europe/London)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xODEuNyIsInVwZGF0ZWRJblZlciI6IjQ0LjE4MS43IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJyZW5vdmF0ZS9jb250YWluZXIiLCJ0eXBlL3BhdGNoIl19-->

Reviewed-on: https://git.hayden.moe/hayden/phoebe/pulls/1018
Defilan added a commit to Defilan/LLMKube that referenced this pull request Oct 27, 2026
…SE cache

defilantech#2185 stopped serving pods from staging multi-file models on a FUSE cache,
where flock does not exclude across nodes, but the single-file path still
took cacheDirLock on any filesystem. On a BlobFuse RWX claim two pods on
different nodes could both hold it and curl -C - into the same partial,
the truncated-file race of defilantech#2158.

FUSE detection now sits next to cacheDirLock in one prologue that both
builders open with. A warm cache stays usable, and a serving pod on FUSE
takes no lock, sweeps nothing and refuses any transfer with the same
ModelCacheLockUnsupported message and condition, with a remedy for its
source kind (local sources get their own). The single-file downloader now
carries LLMKUBE_PREFETCH_STAGER=false like the multi-file one, so a
single-file prefetch Job is a stager: it may stage on FUSE, waits for a
failed pod before replacing it and is deleted in the foreground.

Fixes defilantech#2254

Signed-off-by: Christopher Maher <chris@mahercode.io>
Defilan added a commit to Defilan/LLMKube that referenced this pull request Oct 27, 2026
…e record

A FUSE cache staged before defilantech#2185 has no .llmkube-size record, and only pods
off FUSE backfilled one, so such a cache warned "no valid size record" on
every start for good. The prefetch stager is the one pod allowed to write a
FUSE cache, so its IfNotPresent pass now records the cached file's size as
pods on other filesystems do. Serving pods on FUSE still write nothing.

Refs defilantech#2255

Signed-off-by: Christopher Maher <chris@mahercode.io>
eleboucher pushed a commit to eleboucher/homelab that referenced this pull request Oct 31, 2026
…0.10.15) (#2331)

This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [ghcr.io/defilantech/charts/llmkube](https://github.com/defilantech/LLMKube) | HelmChart | patch | `0.10.2` → `0.10.15` |

---

> ⚠️ **Warning**
>
> Some dependencies could not be looked up. Check the [Dependency Dashboard](issues/567) for more information.

---

### Release Notes

<details>
<summary>defilantech/LLMKube (ghcr.io/defilantech/charts/llmkube)</summary>

### [`v0.10.15`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#01015-2026-10-31)

[Compare Source](defilantech/LLMKube@v0.10.14...v0.10.15)

##### Features

- **agent:** add --engine-env-passthrough to pass named variables to Metal engines ([6d4fba0](defilantech/LLMKube@6d4fba0))
- **agent:** give Metal engines an allowlisted environment (fixes [#&#8203;2314](defilantech/LLMKube#2314)) ([6d4fba0](defilantech/LLMKube@6d4fba0))
- **foreman:** select the forge provider by name, remote host, or default ([#&#8203;2292](defilantech/LLMKube#2292)) ([239411a](defilantech/LLMKube@239411a))
- **foreman:** stream in-turn generation progress ([#&#8203;2313](defilantech/LLMKube#2313)) ([3d57743](defilantech/LLMKube@3d57743))
- **metal-agent:** read only marked Secrets through sourceSecretRef (fixes [#&#8203;1956](defilantech/LLMKube#1956)) ([eaecad3](defilantech/LLMKube@eaecad3))
- **metal:** limit the metal-agent's Service and EndpointSlice writes by admission policy ([#&#8203;2319](defilantech/LLMKube#2319)) ([a8ea5bf](defilantech/LLMKube@a8ea5bf))
- **release:** publish a Homebrew cask for the metal-agent ([#&#8203;2234](defilantech/LLMKube#2234)) ([1b29497](defilantech/LLMKube@1b29497))
- **router:** mirror requests to the other backends of a shadow rule (fixes [#&#8203;2327](defilantech/LLMKube#2327)) ([40266ab](defilantech/LLMKube@40266ab))

##### Bug Fixes

- **agent:** pin the exact powermetrics argument vector in sudoers ([d28c55f](defilantech/LLMKube@d28c55f))
- **controller:** treat a ModelPool resident removed from spec.members as gone ([#&#8203;2310](defilantech/LLMKube#2310)) ([87464ad](defilantech/LLMKube@87464ad))
- **foreman:** bound the Metal engine port read from the agent's EndpointSlice ([#&#8203;2321](defilantech/LLMKube#2321)) ([75837e3](defilantech/LLMKube@75837e3))
- **metal-agent:** re-check a refused InferenceService on a backoff (fixes [#&#8203;2329](defilantech/LLMKube#2329)) ([6f65b30](defilantech/LLMKube@6f65b30))
- **metal-agent:** re-issue an ingress certificate the Mac's clock no longer accepts ([7e7fbaa](defilantech/LLMKube@7e7fbaa))
- **metal-agent:** read sourceSecretRef from the Model's namespace ([eaecad3](defilantech/LLMKube@eaecad3))
- **metal-agent:** retry a start that hit a transient source Secret read error ([5e13d5c](defilantech/LLMKube@5e13d5c))
- **metal-agent:** retry a transient remote size probe failure without refusing (fixes [#&#8203;2326](defilantech/LLMKube#2326)) ([5e13d5c](defilantech/LLMKube@5e13d5c))
- **metal:** bind MetalNode Reachable to the Mac with a UID-named certificate (refs [#&#8203;2265](defilantech/LLMKube#2265)) ([7e7fbaa](defilantech/LLMKube@7e7fbaa))
- **metal:** expire a MetalNode Reachable result no check has renewed (refs [#&#8203;2265](defilantech/LLMKube#2265)) ([7e7fbaa](defilantech/LLMKube@7e7fbaa))
- **metal:** read MetalNode verifier finish times on the controller's clock (refs [#&#8203;2265](defilantech/LLMKube#2265)) ([7e7fbaa](defilantech/LLMKube@7e7fbaa))
- **metal:** trust only the reported certificate in the MetalNode dial-back ([7e7fbaa](defilantech/LLMKube@7e7fbaa))
- preserve Foreman audit and transcript records per attempt ([#&#8203;2311](defilantech/LLMKube#2311)) ([3e838f9](defilantech/LLMKube@3e838f9))
- **router:** dispatch only to backends with a rule's required capabilities (fixes [#&#8203;2328](defilantech/LLMKube#2328)) ([e533192](defilantech/LLMKube@e533192))
- **router:** honor the weighted rule strategy on the Proxy data plane (fixes [#&#8203;2325](defilantech/LLMKube#2325)) ([7b34a43](defilantech/LLMKube@7b34a43))

##### Documentation

- **macos:** explain why Login Items lists the agent as Defilan Technologies LLC ([#&#8203;2307](defilantech/LLMKube#2307)) ([5b5e17e](defilantech/LLMKube@5b5e17e))
- move Foreman stream guide before references ([#&#8203;2322](defilantech/LLMKube#2322)) ([d856cb1](defilantech/LLMKube@d856cb1))
- **upgrade:** a MetalNode is Reachable only when the Mac's metal-agent reports an ingress certificate naming its MetalNode. After upgrading the controller, every Mac shows Reachable=False with reason AgentUpgradeRequired, and its hostIP is cleared, until its metal-agent is upgraded too; upgrade the agents promptly. Nothing routes to hostIP yet, so serving is unaffected. The dial-back verifier image's curl must be 7.57 or later with a TLS library that accepts a self-signed certificate as its own trust anchor; the default curlimages/curl image does. ([7e7fbaa](defilantech/LLMKube@7e7fbaa))
- **upgrade:** Metal engines now inherit only PATH, HOME, USER, LOGNAME, TMPDIR, LANG, TZ, LC\_\*, HF_HOME and HF_HUB_CACHE from the agent's environment, plus the variables the agent sets for each runtime. If an engine relies on another variable from the agent's launchd environment (for example a GGML or MLX tuning variable, or proxy settings for llama-server's own `-hf` download), list it in `--engine-env-passthrough`. Engines adopted from a previous agent process keep their old environment until they restart; run `llmkube-metal-agent stop-engines` after the bootout to apply it at once. ([6d4fba0](defilantech/LLMKube@6d4fba0))
- **upgrade:** on the Proxy data plane, a ModelRouter rule with match.requiredCapabilities now sends requests only to the backends that advertise all of them, under every strategy. A primary-fallback rule no longer sends to, or falls back to, a backend that lacks them; when every capable backend fails, the request fails instead of landing on an incapable one. ([e533192](defilantech/LLMKube@e533192))
- **upgrade:** on the Proxy data plane, ModelRouter rules with strategy: shadow now send a copy of each request to the rule's other backends, including external ones, which bill those requests. A shadow rule with one backend now fails validation, and one that mirrors to a ModelPool member fails the compile and keeps the previous config. ([40266ab](defilantech/LLMKube@40266ab))
- **upgrade:** on the Proxy data plane, ModelRouter rules with strategy: weighted now split requests by backend weight instead of sending all of them to the first backend. weight: 0 drains a backend, matching the Gateway data plane. A weighted rule whose backends cannot take traffic now fails validation, and a weighted rule across two members of one ModelPool fails the compile and keeps the previous config. ([7b34a43](defilantech/LLMKube@7b34a43))
- **upgrade:** the Metal agent now reads a Model's spec.sourceSecretRef only when the Secret is labeled inference.llmkube.dev/model-source-credentials=true; otherwise the InferenceService is refused with SourceSecretNotAllowed. Before upgrading the agent, label each credentials Secret your Metal Models use: `kubectl label secret <name> -n <namespace> inference.llmkube.dev/model-source-credentials=true`. For one release, `--allow-unlabeled-source-secrets` keeps the old behavior with a warning; it is deprecated. A Hugging Face source whose Secret cannot be read is now refused instead of downloading anonymously. ([eaecad3](defilantech/LLMKube@eaecad3))
- **upgrade:** the powermetrics sudoers rule now names the exact command line the agent runs, with the sampling interval rendered in. Upgrading the agent does not change an installed /etc/sudoers.d/llmkube-powermetrics: on hosts that installed it, re-run `make install-powermetrics-sudo` (with `APPLE_POWER_INTERVAL_MS=<ms>` if the agent uses a non-default `--apple-power-interval`), then check with `sudo -ln | grep powermetrics` that the rule ends in `-i` followed by a number. ([d28c55f](defilantech/LLMKube@d28c55f))

### [`v0.10.14`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#01014-2026-10-29)

[Compare Source](defilantech/LLMKube@v0.10.13...v0.10.14)

##### Features

- **cli:** send the endpoint API key from llmkube benchmark ([#&#8203;2297](defilantech/LLMKube#2297)) ([df2e008](defilantech/LLMKube@df2e008))
- **router:** add spec.proxy.drainTimeout for the router-proxy shutdown ([#&#8203;2304](defilantech/LLMKube#2304)) ([113e009](defilantech/LLMKube@113e009))
- **router:** opt-in client address and caller header on the audit log ([#&#8203;2288](defilantech/LLMKube#2288)) ([4d1faac](defilantech/LLMKube@4d1faac))

##### Bug Fixes

- **controller:** keep a ModelPool resident the router re-activates during a reclaim ([#&#8203;2290](defilantech/LLMKube#2290)) ([857daf2](defilantech/LLMKube@857daf2))
- **controller:** remove a key dropped from podLabels or podAnnotations from the pods ([#&#8203;2289](defilantech/LLMKube#2289)) ([ca5df08](defilantech/LLMKube@ca5df08))
- **foreman:** route the remaining GitHub-only paths through the forge seams ([#&#8203;2291](defilantech/LLMKube#2291)) ([a333270](defilantech/LLMKube@a333270))
- **metrics:** each binary serves only the metrics it records ([#&#8203;2296](defilantech/LLMKube#2296)) ([de8ca21](defilantech/LLMKube@de8ca21))

##### Documentation

- **modelpool:** leave member replicas out of GitOps manifests ([#&#8203;2300](defilantech/LLMKube#2300)) ([bcbc357](defilantech/LLMKube@bcbc357))

### [`v0.10.13`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#01013-2026-10-28)

[Compare Source](defilantech/LLMKube@v0.10.12...v0.10.13)

##### Features

- **agent:** add the llmkube-metal-agent stop-engines command ([2cd2ec9](defilantech/LLMKube@2cd2ec9))
- **agent:** keep Metal engines serving across metal-agent restarts and adopt them on startup (refs [#&#8203;2079](defilantech/LLMKube#2079)) ([2cd2ec9](defilantech/LLMKube@2cd2ec9))
- **release:** sign and notarize the macOS release binaries ([#&#8203;2233](defilantech/LLMKube#2233)) ([f5ff962](defilantech/LLMKube@f5ff962))
- **router:** podLabels and podAnnotations for router-proxy pods ([#&#8203;2285](defilantech/LLMKube#2285)) ([a736e18](defilantech/LLMKube@a736e18))
- **router:** publish per-backend modalities and context length on /v1/models ([#&#8203;2249](defilantech/LLMKube#2249)) ([2887f54](defilantech/LLMKube@2887f54))

##### Bug Fixes

- apply CRDs server-side in make install and make deploy ([#&#8203;2275](defilantech/LLMKube#2275)) ([edaa4f7](defilantech/LLMKube@edaa4f7))
- **chart:** leave --init-container-user off when its value is unset ([#&#8203;2286](defilantech/LLMKube#2286)) ([ac541db](defilantech/LLMKube@ac541db))
- **cli:** pin only single-writer claims and make the cache pod image configurable ([#&#8203;2178](defilantech/LLMKube#2178)) ([2fe250b](defilantech/LLMKube@2fe250b))
- **controller:** annotate router-proxy pods for scraping under --emit-scrape-annotations ([#&#8203;2268](defilantech/LLMKube#2268)) ([07fb7f4](defilantech/LLMKube@07fb7f4))
- **controller:** chown the cache root to the init container's uid when fsGroup is off ([#&#8203;2273](defilantech/LLMKube#2273)) ([c9d86ad](defilantech/LLMKube@c9d86ad))
- **controller:** create no HPA for multiNode services ([#&#8203;2272](defilantech/LLMKube#2272)) ([c8d6f76](defilantech/LLMKube@c8d6f76))
- **controller:** delete FUSE stager Jobs in the foreground without the feature-gated field ([#&#8203;2269](defilantech/LLMKube#2269)) ([e439f60](defilantech/LLMKube@e439f60))
- **controller:** make FUSE model-cache staging safe across stagers and serving pods ([#&#8203;2276](defilantech/LLMKube#2276)) ([6c7c1fe](defilantech/LLMKube@6c7c1fe))
- **controller:** point the router-proxy Service at the port the proxy listens on ([#&#8203;2280](defilantech/LLMKube#2280)) ([1c69118](defilantech/LLMKube@1c69118))
- **controller:** run the stall-probe tests on a fake clock ([#&#8203;2267](defilantech/LLMKube#2267)) ([661856a](defilantech/LLMKube@661856a))
- **router:** tell client cancellations, upstream errors and timeouts apart ([#&#8203;2284](defilantech/LLMKube#2284)) ([3a2ea74](defilantech/LLMKube@3a2ea74))
- use vLLM's real TurboQuant KV cache types in the sample and docs ([#&#8203;2270](defilantech/LLMKube#2270)) ([3b4ffb6](defilantech/LLMKube@3b4ffb6))

##### Documentation

- **upgrade:** metal-agent engines now keep running when the agent stops and the next agent adopts them. To roll back to an older agent, run `launchctl bootout gui/$(id -u)/com.llmkube.metal-agent` and then `llmkube-metal-agent stop-engines` with the current binary before installing the older one, or every model is loaded twice. `make uninstall-metal-agent` stops the engines for you. Adopted engines keep the flags they started with; run stop-engines after the bootout to apply new defaults. ([2cd2ec9](defilantech/LLMKube@2cd2ec9))

### [`v0.10.12`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#01012-2026-10-27)

[Compare Source](defilantech/LLMKube@v0.10.11...v0.10.12)

##### Features

- **api:** add MetalNode, verified by a controller dial-back, with the agent reporting through its Lease ([#&#8203;2235](defilantech/LLMKube#2235)) ([fccc1bc](defilantech/LLMKube@fccc1bc))
- **cli:** add llmkube metal join and leave, with agent token renewal ([#&#8203;2246](defilantech/LLMKube#2246)) ([b1f6602](defilantech/LLMKube@b1f6602))
- **cli:** add llmkube metal token create and metal remove ([#&#8203;2236](defilantech/LLMKube#2236)) ([f54414a](defilantech/LLMKube@f54414a))

##### Bug Fixes

- **controller:** keep a ModelPool activation while its member is held at zero ([#&#8203;2241](defilantech/LLMKube#2241)) ([8fc4143](defilantech/LLMKube@8fc4143))
- **controller:** map sglangConfig.enablePrefixCaching onto --disable-radix-cache ([#&#8203;2259](defilantech/LLMKube#2259)) ([d499aa4](defilantech/LLMKube@d499aa4))
- **controller:** stage under the cache lock when the init image's stat has no -f ([#&#8203;2260](defilantech/LLMKube#2260)) ([52241de](defilantech/LLMKube@52241de))
- **router:** list each backend on /v1/models under the id BackendNameMatch routes ([#&#8203;2257](defilantech/LLMKube#2257)) ([4627cfc](defilantech/LLMKube@4627cfc))
- **router:** report moves off a quarantined pod as reason="quarantined" instead of reason="not_ready" ([faa7a5f](defilantech/LLMKube@faa7a5f))
- **router:** rewrite vLLM and SGLang backends to the name they serve ([#&#8203;2258](defilantech/LLMKube#2258)) ([3d39111](defilantech/LLMKube@3d39111))
- validate multi-file cache staging on shared volumes ([#&#8203;2185](defilantech/LLMKube#2185)) ([e6eab85](defilantech/LLMKube@e6eab85))

### [`v0.10.11`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#01011-2026-10-25)

[Compare Source](defilantech/LLMKube@v0.10.10...v0.10.11)

##### Features

- **api:** accept fp8, float16 and bfloat16 in vllmConfig.kvCacheDtype ([#&#8203;2227](defilantech/LLMKube#2227)) ([31eb943](defilantech/LLMKube@31eb943))
- **api:** add spec.imagePullPolicy to InferenceService ([#&#8203;2208](defilantech/LLMKube#2208)) ([ec54d28](defilantech/LLMKube@ec54d28))
- **controller:** move the SGLang defaults to v0.5.22 and report removed attention backends ([#&#8203;2221](defilantech/LLMKube#2221)) ([33f9bb1](defilantech/LLMKube@33f9bb1))
- **controller:** move the vLLM default to v0.32.0 and check every flag against it ([#&#8203;2223](defilantech/LLMKube#2223)) ([0144a4c](defilantech/LLMKube@0144a4c))
- **controller:** run every llama.cpp default from one pinned build, b11854 ([#&#8203;2220](defilantech/LLMKube#2220)) ([052840e](defilantech/LLMKube@052840e))
- **router:** weigh LoadAware placement by estimated prefill tokens in flight ([#&#8203;2193](defilantech/LLMKube#2193)) ([a33735c](defilantech/LLMKube@a33735c))

##### Bug Fixes

- **controller:** render vllmConfig.speculative as --speculative-config ([#&#8203;2210](defilantech/LLMKube#2210)) ([191dcbd](defilantech/LLMKube@191dcbd))
- **controller:** report sglangConfig tool-call parsers SGLang does not register ([#&#8203;2228](defilantech/LLMKube#2228)) ([90fed2e](defilantech/LLMKube@90fed2e))

### [`v0.10.10`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#01010-2026-10-24)

[Compare Source](defilantech/LLMKube@v0.10.9...v0.10.10)

##### Features

- **controller:** add --model-cache-prep to skip the root cache prep ([#&#8203;2206](defilantech/LLMKube#2206)) ([8bcb973](defilantech/LLMKube@8bcb973))
- **router:** compile dataPlane: AgentGateway to AgentgatewayModel ([#&#8203;2162](defilantech/LLMKube#2162)) ([26b1ef4](defilantech/LLMKube@26b1ef4))

##### Bug Fixes

- **controller:** report a prefetch as downloading while its downloader runs ([#&#8203;2207](defilantech/LLMKube#2207)) ([7699514](defilantech/LLMKube@7699514))
- **router:** let every waiter of a failed ModelPool swap see its outcome ([#&#8203;2203](defilantech/LLMKube#2203)) ([b95db62](defilantech/LLMKube@b95db62))

### [`v0.10.9`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#0109-2026-10-23)

[Compare Source](defilantech/LLMKube@v0.10.8...v0.10.9)

##### Features

- **api:** affinity and topology spread for the router proxy, rolling without surge under hard placement ([#&#8203;2200](defilantech/LLMKube#2200)) ([3a61700](defilantech/LLMKube@3a61700))
- **router:** proxy the Anthropic Messages API like chat completions ([#&#8203;2201](defilantech/LLMKube#2201)) ([86223aa](defilantech/LLMKube@86223aa))

##### Bug Fixes

- **router:** retry stale keep-alive connections and quarantine a pod only after two failures ([#&#8203;2197](defilantech/LLMKube#2197)) ([b279053](defilantech/LLMKube@b279053))

### [`v0.10.8`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#0108-2026-10-23)

[Compare Source](defilantech/LLMKube@v0.10.7...v0.10.8)

##### Features

- **api:** type the vLLM tool-call and reasoning parsers and admit GLM on SGLang ([#&#8203;2168](defilantech/LLMKube#2168)) ([fd64c72](defilantech/LLMKube@fd64c72))
- **controller:** add InferenceService spec.hubOffline for serving pods ([#&#8203;2188](defilantech/LLMKube#2188)) ([a911cb2](defilantech/LLMKube@a911cb2))
- **controller:** keep 3 old ReplicaSets for LLMKube-managed Deployments by default ([#&#8203;2182](defilantech/LLMKube#2182)) ([5e1722f](defilantech/LLMKube@5e1722f))
- **router:** honour endpoint nodePort and externalTrafficPolicy, add proxy runtimeClassName ([#&#8203;2183](defilantech/LLMKube#2183)) ([a8f9e57](defilantech/LLMKube@a8f9e57))
- **router:** on pools under 3 pods, move a session only when its pod is KV-full ([#&#8203;2192](defilantech/LLMKube#2192)) ([f9b934c](defilantech/LLMKube@f9b934c))
- **router:** remember LoadAware sessions and move them only under real pressure ([#&#8203;2180](defilantech/LLMKube#2180)) ([1e8ce5a](defilantech/LLMKube@1e8ce5a))

##### Bug Fixes

- **router:** answer a ModelPool swap past swapBudget with 503 and Retry-After ([#&#8203;2181](defilantech/LLMKube#2181)) ([7c0d84e](defilantech/LLMKube@7c0d84e))
- **router:** release request bodies once sent and raise the proxy's default memory limit ([#&#8203;2196](defilantech/LLMKube#2196)) ([140fd6b](defilantech/LLMKube@140fd6b))

### [`v0.10.7`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#0107-2026-10-23)

[Compare Source](defilantech/LLMKube@v0.10.6...v0.10.7)

##### Features

- **chart:** install the metal-agent RBAC from the Helm chart ([#&#8203;2165](defilantech/LLMKube#2165)) ([521e502](defilantech/LLMKube@521e502))
- **controller:** roll a multi-replica GPU service one pod at a time ([#&#8203;2164](defilantech/LLMKube#2164)) ([c4073cf](defilantech/LLMKube@c4073cf))
- **router:** load-aware, session-sticky placement for endpoint backends ([#&#8203;2166](defilantech/LLMKube#2166)) ([74cfb0e](defilantech/LLMKube@74cfb0e))
- **router:** the router proxy tracks endpoint backends' pods itself ([#&#8203;2159](defilantech/LLMKube#2159)) ([57070e1](defilantech/LLMKube@57070e1))

### [`v0.10.6`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#0106-2026-10-22)

[Compare Source](defilantech/LLMKube@v0.10.5...v0.10.6)

##### Features

- **controller:** prefetch per cache claim, gated by the InferenceService ([#&#8203;2138](defilantech/LLMKube#2138)) ([d76f398](defilantech/LLMKube@d76f398))
- **router:** per-backend Endpoint Picker for dataPlane: AgentGateway ([#&#8203;2157](defilantech/LLMKube#2157)) ([c1bbe28](defilantech/LLMKube@c1bbe28))

##### Bug Fixes

- **cli:** read the model cache from its own pod in cache list ([#&#8203;2154](defilantech/LLMKube#2154)) ([996abe9](defilantech/LLMKube@996abe9))
- **controller:** refuse to repoint a same-name Service ([#&#8203;2149](defilantech/LLMKube#2149)) ([7965a1b](defilantech/LLMKube@7965a1b))
- **hf-listing:** read a tree size only when it is a whole number of bytes ([#&#8203;2153](defilantech/LLMKube#2153)) ([6e6723d](defilantech/LLMKube@6e6723d))
- **model:** report the staged total instead of status.size "0" ([#&#8203;2150](defilantech/LLMKube#2150)) ([f6710e7](defilantech/LLMKube@f6710e7))

### [`v0.10.5`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#0105-2026-10-21)

[Compare Source](defilantech/LLMKube@v0.10.4...v0.10.5)

##### Features

- add make gate and make test-affected, a cached parallel round gate ([#&#8203;2106](defilantech/LLMKube#2106)) ([e971675](defilantech/LLMKube@e971675))
- **agent:** admit Metal models against memory already committed to running engines (closes [#&#8203;2080](defilantech/LLMKube#2080)) ([d2d2d43](defilantech/LLMKube@d2d2d43))
- **agent:** record each engine the metal-agent starts in the state directory (refs [#&#8203;2079](defilantech/LLMKube#2079)) ([0224d51](defilantech/LLMKube@0224d51))
- **controller:** add spec.startupTimeout for a bigger startup probe budget ([#&#8203;2129](defilantech/LLMKube#2129)) ([df0e9cd](defilantech/LLMKube@df0e9cd))
- **controller:** set fsGroupChangePolicy OnRootMismatch with the default fsGroup (closes [#&#8203;2058](defilantech/LLMKube#2058)) ([2bd4538](defilantech/LLMKube@2bd4538))

##### Bug Fixes

- **agent:** escalate to SIGKILL when stopping an engine the agent did not spawn ([0224d51](defilantech/LLMKube@0224d51))
- **agent:** keep the pressure block when an eviction teardown completed ([#&#8203;2107](defilantech/LLMKube#2107)) ([fd9ab77](defilantech/LLMKube@fd9ab77))
- **agent:** replace a hung oMLX daemon instead of starting a second one ([#&#8203;2105](defilantech/LLMKube#2105)) ([a16c348](defilantech/LLMKube@a16c348))
- **agent:** restart services evicted under memory pressure once memory recovers ([#&#8203;2093](defilantech/LLMKube#2093)) ([3db0ecc](defilantech/LLMKube@3db0ecc))
- answer 400 for request bodies that are not valid JSON ([#&#8203;2075](defilantech/LLMKube#2075)) ([d9826ff](defilantech/LLMKube@d9826ff))
- **chart:** fail fast on shared cache without default storageclass and support existingClaim ([#&#8203;2016](defilantech/LLMKube#2016)) ([#&#8203;2114](defilantech/LLMKube#2114)) ([c4f7f3d](defilantech/LLMKube@c4f7f3d))
- **chart:** gate llama.cpp InferenceAdmissionStalled on decode progress ([#&#8203;2065](defilantech/LLMKube#2065)) ([5b2f45d](defilantech/LLMKube@5b2f45d))
- **cli:** make cache clear and delete --purge-cache remove entries ([#&#8203;2134](defilantech/LLMKube#2134)) ([2b668e2](defilantech/LLMKube@2b668e2))
- **controller:** keep downloader credentials off curl's command line ([#&#8203;2098](defilantech/LLMKube#2098)) ([2207c2e](defilantech/LLMKube@2207c2e))
- **controller:** mount a memory-backed /dev/shm for multi-process vLLM, SGLang and TGI (closes [#&#8203;2099](defilantech/LLMKube#2099)) ([a68c98f](defilantech/LLMKube@a68c98f))
- **controller:** refuse to overwrite or recreate a same-name Deployment ([#&#8203;2096](defilantech/LLMKube#2096)) ([b90c8aa](defilantech/LLMKube@b90c8aa))
- **model:** canonicalize HF repo IDs so a trailing slash stages ([#&#8203;2128](defilantech/LLMKube#2128)) ([9289ac6](defilantech/LLMKube@9289ac6))
- **modelpool:** stop swap thrash on reversals, idle-probe blindness, and abandoned loads ([#&#8203;2130](defilantech/LLMKube#2130)) ([#&#8203;2135](defilantech/LLMKube#2135)) ([e7dee43](defilantech/LLMKube@e7dee43))
- **model:** serialize downloaders that share a cache directory ([#&#8203;2115](defilantech/LLMKube#2115)) ([13eca01](defilantech/LLMKube@13eca01))
- **sglang:** keep endpoint API keys out of process arguments ([#&#8203;2122](defilantech/LLMKube#2122)) ([7fdae50](defilantech/LLMKube@7fdae50))
- size metal-agent KV cache estimate from GQA attention shape ([#&#8203;2102](defilantech/LLMKube#2102)) ([a119234](defilantech/LLMKube@a119234))

##### Documentation

- cover per-request balancing in the ModelRouter page ([#&#8203;2095](defilantech/LLMKube#2095)) ([9ec4766](defilantech/LLMKube@9ec4766))
- **model-cache:** correct the CephFS fsGroupPolicy claim and the PSA advice ([2bd4538](defilantech/LLMKube@2bd4538))
- state differentiation, audience, and project status up front ([#&#8203;2100](defilantech/LLMKube#2100)) ([ee27e20](defilantech/LLMKube@ee27e20))
- **upgrade:** every InferenceService that uses the default pod security context rolls once on upgrade, because the generated pod template gains `fsGroupChangePolicy: OnRootMismatch` (a full model reload for large models) ([2bd4538](defilantech/LLMKube@2bd4538))
- **upgrade:** single-node vLLM, SGLang and TGI services with more than one GPU (or a tensor, pipeline or data-parallel size above 1) that do not mount /dev/shm themselves roll once on upgrade, because the generated pod template gains a memory-backed /dev/shm volume ([a68c98f](defilantech/LLMKube@a68c98f))
- **upgrade:** the metal-agent now admits a model only if it fits in the Mac's budget minus the memory committed to models already running, so a Mac whose models add up to more than its budget refuses the extra ones; a Model memoryBudget or memoryFraction above the Mac's budget is clamped to it unless the agent runs with --allow-model-memory-budget-override ([d2d2d43](defilantech/LLMKube@d2d2d43))
- **upgrade:** the metal-agent now writes a record per engine under --state-dir/engines (both relay and legacy mode) and fails an engine start when it cannot write it; make sure the state directory (default \~/Library/Application Support/llmkube/metal-agent) is writable by the agent's user ([0224d51](defilantech/LLMKube@0224d51))

### [`v0.10.4`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#0104-2026-10-16)

[Compare Source](defilantech/LLMKube@v0.10.3...v0.10.4)

##### Features

- **agent:** cap the bytes a single Model download may write ([#&#8203;2088](defilantech/LLMKube#2088)) ([3852411](defilantech/LLMKube@3852411))
- **api:** endpoint API key and externalTrafficPolicy for InferenceService ([#&#8203;2043](defilantech/LLMKube#2043)) ([27a9214](defilantech/LLMKube@27a9214))
- **controller:** detect a stalled watch path with a lease canary, bounded reconciles and stall alerts ([#&#8203;2072](defilantech/LLMKube#2072)) ([d00e0cf](defilantech/LLMKube@d00e0cf))
- **controller:** detect keyed pool siblings and vLLM env-var keys in ModelRouter ([#&#8203;2068](defilantech/LLMKube#2068)) ([78889f0](defilantech/LLMKube@78889f0))
- **controller:** detect ModelRouter backends keyed through --api-key on the command line ([#&#8203;2063](defilantech/LLMKube#2063)) ([3674acc](defilantech/LLMKube@3674acc))
- **controller:** stage HF mirror repos with spec.files and expand globs from the repo listing ([#&#8203;2071](defilantech/LLMKube#2071)) ([9e5d7d3](defilantech/LLMKube@9e5d7d3))

##### Bug Fixes

- **agent:** health restarts stop the unhealthy engine first, wait for repeated failures, and back off ([#&#8203;2090](defilantech/LLMKube#2090)) ([407e02e](defilantech/LLMKube@407e02e))
- **agent:** keep a Metal service running when its Model lookup fails transiently ([#&#8203;2089](defilantech/LLMKube#2089)) ([e22eeab](defilantech/LLMKube@e22eeab))
- **controller:** merge DRA pod scheduling fields and reject multiNode with resourceClaims ([#&#8203;2062](defilantech/LLMKube#2062)) ([9319745](defilantech/LLMKube@9319745))
- no device-plugin GPU limit when a Model uses DRA resourceClaims ([#&#8203;2060](defilantech/LLMKube#2060)) ([8c3e434](defilantech/LLMKube@8c3e434))

##### Documentation

- APISIX in front of LLMKube ([#&#8203;2053](defilantech/LLMKube#2053)) ([16286c6](defilantech/LLMKube@16286c6))
- **metal:** fix install commands and update the quickstart for relay mode ([#&#8203;2087](defilantech/LLMKube#2087)) ([8bc7c6f](defilantech/LLMKube@8bc7c6f))
- use an extension-less link in the GPU fault guide ([#&#8203;2056](defilantech/LLMKube#2056)) ([780e710](defilantech/LLMKube@780e710))

### [`v0.10.3`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#0103-2026-10-13)

[Compare Source](defilantech/LLMKube@v0.10.2...v0.10.3)

##### Features

- **api:** accept SGLang's Blackwell attention backends; flag flash_attn ([#&#8203;2033](defilantech/LLMKube#2033)) ([2385fd4](defilantech/LLMKube@2385fd4))
- **chart:** alert on GPU faults the device plugin ignores ([#&#8203;2031](defilantech/LLMKube#2031)) ([e4697ef](defilantech/LLMKube@e4697ef))
- **controller:** add opt-in vLLM engine-stall liveness probe ([#&#8203;2022](defilantech/LLMKube#2022)) ([429434a](defilantech/LLMKube@429434a))
- **controller:** apply the Model's HF_ENDPOINT mirror to runtime downloads ([#&#8203;2039](defilantech/LLMKube#2039)) ([cbececa](defilantech/LLMKube@cbececa))
- **controller:** stall-aware liveness for SGLang pods; share the detector ([#&#8203;2029](defilantech/LLMKube#2029)) ([212b014](defilantech/LLMKube@212b014))
- **router:** serve POST /v1/completions on the router-proxy ([#&#8203;2019](defilantech/LLMKube#2019)) ([1724a02](defilantech/LLMKube@1724a02))

##### Bug Fixes

- **controller:** bind SGLang dual-stack so it can become Ready on IPv4 clusters ([#&#8203;2028](defilantech/LLMKube#2028)) ([7d87c7e](defilantech/LLMKube@7d87c7e))
- **controller:** bound multiNode recreates and keep the failure evidence ([#&#8203;1996](defilantech/LLMKube#1996)) ([3331610](defilantech/LLMKube@3331610))
- **controller:** refuse to overwrite or delete a same-name HPA ([#&#8203;2010](defilantech/LLMKube#2010)) ([09affe5](defilantech/LLMKube@09affe5))
- **controller:** report a missing CA ConfigMap instead of wedging pods ([#&#8203;1995](defilantech/LLMKube#1995)) ([c624e02](defilantech/LLMKube@c624e02))
- **controller:** report AutoscalingReady=True when the HPA is managed ([#&#8203;2012](defilantech/LLMKube#2012)) ([b877ee5](defilantech/LLMKube@b877ee5))
- **controller:** split reconcileMultiNodeGroup under the gocyclo limit ([#&#8203;2000](defilantech/LLMKube#2000)) ([023a5c4](defilantech/LLMKube@023a5c4))
- **controller:** stop the prefetch status loop while the CA ConfigMap is missing ([#&#8203;2037](defilantech/LLMKube#2037)) ([b0b3cc6](defilantech/LLMKube@b0b3cc6))
- **foreman-agent:** make the repo package's git env hermetic and forward GIT_CONFIG\_\* ([#&#8203;2014](defilantech/LLMKube#2014)) ([62837d9](defilantech/LLMKube@62837d9))
- **router:** compile AgentGateway mode against Gateway API Inference Extension v1 ([#&#8203;1990](defilantech/LLMKube#1990)) ([ce817ba](defilantech/LLMKube@ce817ba))
- **router:** refuse to adopt same-name router-proxy objects ([#&#8203;2008](defilantech/LLMKube#2008)) ([e8f190a](defilantech/LLMKube@e8f190a))
- **router:** remove router-proxy objects for routers not in Proxy mode ([#&#8203;2006](defilantech/LLMKube#2006)) ([c04f824](defilantech/LLMKube@c04f824))
- **router:** stop truncating streams at the header timeout; quarantine stalled streams ([#&#8203;2026](defilantech/LLMKube#2026)) ([c4335c9](defilantech/LLMKube@c4335c9))
- **router:** time only upstream silence in the stream idle guard ([#&#8203;2035](defilantech/LLMKube#2035)) ([933426c](defilantech/LLMKube@933426c))

##### Documentation

- **air-gapped:** cover vLLM and SGLang safetensors checkpoints ([#&#8203;1993](defilantech/LLMKube#1993)) ([4d3fa70](defilantech/LLMKube@4d3fa70))
- replace deprecated Strix Halo GTT sizing with TTM parameters ([#&#8203;2003](defilantech/LLMKube#2003)) ([09d8d73](defilantech/LLMKube@09d8d73))

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xNTEuMiIsInVwZGF0ZWRJblZlciI6IjQ0LjE4MS45IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: https://git.erwanleboucher.dev/eleboucher/homelab/pulls/2331
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Concurrent multi-file staging into a shared ReadWriteMany cache can publish a truncated file that later pods accept

2 participants