Repository navigation
Make the Model the single owner of local model weights - #765
Merged
Merged
Conversation
A local model is now downloaded, recorded and removed by the Model resource alone, with or without S3: - Without S3, a local model is downloaded from HuggingFace straight into the workspace storage by the per-workspace download job, instead of failing. Only the files needed to load the model are fetched, the HuggingFace cache sits inside the model directory (no ephemeral-storage pressure, a retry resumes), and the job reports the size it left on disk. - The download job's SECRET is optional (public models need no token); a model token is shared with the workspace so the job can mount it. - Deleting a model removes its files: downloads are stopped first, then a cleanup job runs in the workspace that owns the directory, with its volume mounted, as the model owner. The three places that released the finalizer on a failed cleanup now keep it and retry. A directory another live model points at is kept, and only a path below a "models" segment inside a workspace volume (never the volume root) is ever removed. - A model cannot be created, or start downloading, into a directory that another model's deletion is still cleaning up. - Download and cleanup jobs run as the model owner, not the system user. - Failover only moves to a workspace on the same cluster that mounts the same volume root. - Model downloads are capped globally (model.max_concurrent_downloads, default 4); the download timeout is configurable. - status.localPaths[].sizeBytes reports the downloaded size, and the list API can include models whose files are still being removed (includeDeleting). - HuggingFace models are stored as "<owner>--<name>", which cannot collide between repositories, and a subpath that already contains a "models" segment is used as the parent directory as is. Co-Authored-By: Claude <noreply@anthropic.com>
… CRDs Co-Authored-By: Claude <noreply@anthropic.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
- A private workspace without a volume gets no download target, instead of a path on the container's own filesystem. - A public model is downloaded once per cluster and path: the same mount path on another cluster is a different filesystem. Cleanup job names include the workspace so two such entries do not share a job. - A recorded path that fails the cleanup guard keeps the finalizer instead of releasing the model with its files on disk. Co-Authored-By: Claude <noreply@anthropic.com>
- HuggingFace download: fetch safetensors when the repository has them, otherwise PyTorch weights (*.bin, *.pth, *.pt); GGUF and TF/Flax weights are never fetched. A download that leaves no weights exits non-zero with an [ERROR] line, so the path fails with the reason instead of going Ready without weights. Failure reasons of model OpsJobs are now read from the JobCompleted/JobFailed condition the OpsJob controller actually sets. - Single-segment canonical repositories (gpt2, bert-base-uncased) are HuggingFace repositories; their directory is the bare name, which cannot collide with an "<owner>--<name>" one. Reserved top-level paths (datasets, spaces, ...) are not. - The download job prints its size on a [SUCCESS] line, the only kind of line besides [ERROR] that the job-manager keeps in the job outputs. The log filter moves to common/pkg/ops_job so the controller tests run the real script output through the same filter into sizeBytes. - Target directories are decided per path: a directory a live model holds fails only that entry, the rest are downloaded; same path on another cluster is another directory. Waiting for another model's deletion to free a directory ends after 30 minutes with a reason. The apiserver checks directories with the controller's rule (repository + cluster + root), not the per-workspace URL check. - Deletion never hangs silently: a directory no workspace can reach (the workspace is gone, or no workspace of its cluster mounts it) is left on disk with a warning event and the model is released; a same-cluster workspace that still mounts it runs the cleanup instead. Failed cleanups back off (30s doubling to 10m) and from the fifth failure the model status says that an administrator is needed and how to release it: the model.amd.com/abandon-cleanup=true annotation releases the model and leaves its files. - Download workloads carry the model id; the deletion waits for every download workload of the model in any workspace, so a workload left terminating in the workspace before a failover is not raced by the cleanup. Workloads matched by name only are waited for, never deleted. Co-Authored-By: Claude <noreply@anthropic.com>
… directory - A model turns Ready once one directory is; directories still waiting for a download slot were never downloaded because Ready was not reconciled further. A Ready local model with Pending or Downloading entries is now driven on. - Deleting a model kept its directory when another model recorded the same path on another cluster, or recorded it only in a Failed entry. Holders are now matched by path and cluster, and a live model's Failed entry no longer holds a directory, in the controller and in the apiserver's target check; a deleting model's Failed entry is still waited for. - The S3 cleanup removed by key prefix without a trailing slash, matching the objects of models whose S3 path extends this one, and removed a copy another live model with the same display name uses. It now removes below "<path>/" only and leaves a shared copy in place. - The local cleanup script reported failures on stderr without the [ERROR] marker, so the job-manager log filter dropped them and the model showed "unknown". Every failure is now an [ERROR] line; tests run the real script through the real filter, and the failed-job fixture uses the platform's condition shape. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
A live model that never uploaded to S3 has no status.s3Path, but GetS3Path derived one from its display name, so two models of one HuggingFace repository made the deleted model skip its S3 cleanup and forget its path, leaving models/<name> in the bucket for good. The other model now holds the copy only when its status records the path. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
A model that is not a HuggingFace repository (an S3 import) is stored under its display name, which only had '/', ':', ' ' and '\' replaced: ".." put the files at the volume root, where the cleanup refuses to remove them. The directory name now has to be one segment of letters, digits, '.', '-' and '_' that is neither "." nor ".."; otherwise the model has none. - The apiserver refuses such a display name when an import is created or a local model is renamed, with the reason. - An S3 import now goes through the same directory check as a HuggingFace model; it skipped it, so a held directory only surfaced in the controller. - The controller fails a model without a directory name instead of planning a path for it. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
The cleanup script only accepted /*/models/?*, stricter than the controller's check: on a volume mounted at / the controller accepted /models/<dir>, the script refused it every time, and the model kept its finalizer for good. The controller now passes the volume root it validated the path against, and the script repeats that rule: an absolute, clean path strictly inside that volume with something below a models segment. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
Any error creating a download OpsJob failed the directory for good, while this branch made the OpsJob webhook refuse a download as long as a cleanup of the same directory runs (ResourceProcessing, a 409). Conflicts, existing jobs, timeouts, throttling, server errors and unanswered requests now leave the directory Pending with the reason and are retried with the controller's backoff; only a refusal that cannot pass (invalid, forbidden, ...) fails it. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
…ecret The direct HuggingFace download shared the model owner's token secret with every workspace it downloaded into, for good: the secret was mirrored into each workspace namespace and stayed there after the download, and after the model, since that secret has no finalizer to remove the mirrors. A download now mounts a copy made for it: the token only, shared with that one workspace, with no user (only an administrator can attach it to a workload), owned by the model, and with the secret finalizer so the secret controller removes the mirrored copy first. The copy is deleted once the download has finished, failed or moved to another workspace, and when the model is deleted. A token secret an earlier version shared is taken back. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
The getter treats 0 or below as no limit, but the chart said only a negative value is, and its default replaced an explicit 0 with 4. The chart now passes any value an operator sets, and both say: unset is 4, 0 or negative is no limit. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
tryFailover records the workspaces it tried in an annotation, but the download handler only updated the status, so the record was never saved: a download failing in every workspace of a volume moved between them for ever. The annotation is now patched onto the model when it changed. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
- Keep a finished upload job for longer than a succeeded model may wait for another model's cleanup, so the wait reads the result instead of losing the job to its TTL and failing. - Retry a failure to read the workspaces instead of taking it for no storage; fail a model with no storage volume at once, saying so. - Refuse a workload config for a model with no Ready directory the workspace reaches, instead of guessing a path from the display name. - Give a download job of a one-character model a display name the OpsJob webhook accepts. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
They moved a model to Downloading with no directory to download into, which then fails as "All local downloads failed"; such a model now fails at once. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
- getWorkloadConfig: a Ready public model with no status.localPaths (created before they were recorded) falls back to the path earlier releases derived from its display name, instead of being refused after an upgrade. - downloadJobName / cleanupJobName: a readable prefix shortened to fit, then a 16-digit hash of the workspace (and directory), so long HuggingFace model names no longer truncate the distinguishing part away. Unlabelled workloads under the old download name are still recognised. - hfDownloadScript: has_files also counts symbolic links, so a CLI that lays out --local-dir as links into its cache is not reported as missing weights. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: zoroyihan7 <Yihan.Wang@amd.com>
handleDownloading deletes a finished download OpsJob in the pass that sees it finish, so the directory's Ready state lives only in the status. An error from countActiveDownloads or pruneDownloadTokens later in that pass returned before the status update, leaving the directory Downloading with its job gone, and the next pass downloaded the whole model again. Both errors are now returned after the status is saved, like the retryable create error. Co-Authored-By: Claude <noreply@anthropic.com>
weilei0120
approved these changes
Oct 12, 2026
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.
Why
A
localmodel today depends on S3: without it the model fails within seconds ("S3 storage is not enabled"), and deleting a model never removes its files. The cleanup job runs in the control-plane namespace without the workspace volume mounted, and three code paths release the finalizer even when cleanup fails. So the Model resource cannot be the single owner of model weights.This PR makes it one: the Model downloads, records and removes its weights, with or without S3.
What changes
localmodels. With S3 disabled,handlePendinggoes straight toDownloading. The per-workspace download job fetches from HuggingFace directly into the workspace storage path (model.downloader_image,huggingface-cli download, with a fallback tohf download). The S3-staged flow is unchanged when S3 is enabled.SECRETis optional in the download OpsJob webhook and in the download controller, so public models need no token. When the model has a token, the token Secret is shared with the target workspace (workspace.idsannotation) so the job can mount it.*.safetensors,*.json,tokenizer*,*.model,*.tiktoken,*.txt,*.py,*.jinja, excludingoriginal/*. The HuggingFace cache sits under the model directory, so a large model does not fill the container's ephemeral storage and a retry resumes. Nothing is deleted on failure; deletion is the model's job. The script printsMODEL_SIZE_BYTES=<n>, which ends up instatus.localPaths[].sizeBytes.status.localPathsentry is removed by a newmodel-cleanupOpsJob. It runs in the entry's workspace, so the volume is mounted, and it runs as the model owner. It is handled by the existing download controller and is not exposed through the OpsJob API. The entry leavesstatus.localPathsonly after its directory is gone.status.messagesays why, retry after 30s). The same applies to the S3 cleanup Job, which now fails whenaws s3 rmfails.modelsdirectory, inside a workspace volume and not the volume root, is ever removed. This is checked in the controller, in the cleanup script, and in the OpsJob webhook.ResourceProcessing.GET /models?includeDeleting=trueadds models whose CR still exists with a deletion timestamp (isDeleted=true,deletionTime, remaininglocalPaths).model.max_concurrent_downloads(default 4, negative = unlimited) counts unfinished model download jobs across all workspaces, read from the API server (not the cache). Paths over the limit stayPendingwith "Waiting for a download slot (n/m in use)". The download timeout is nowmodel.download_timeout_second(default 10800).Also:
<owner>--<name>. HuggingFace forbids--inside a segment, so two repositories can no longer map to the same directory (a-b/canda/b-cboth used to becomea-b-c).target.subpaththat already contains amodelssegment is used as the parent directory as is:<volume>/<subpath>/<owner>--<name>. Otherwise the layout is unchanged:<volume>/[subpath/]models/<dir>.cleanup_imagein the resource-manager config template was rendered undercd:and never read. It is now undermodel:.Review follow-up
A review round found three defects, fixed in the third commit:
Known limitation, not addressed: the "another model points at this path" checks compare path strings only. On two clusters with the same mount path they are therefore conservative: files are kept or a create waits, but nothing is deleted wrongly.
Status for consumers
status.phase(Ready/Failed),status.localPaths[].{workspace,path,status,sizeBytes}, andincludeDeletingon the list API are enough for an external system to tell "available", "failed", and "still occupying storage".Deployment notes
kind: Jobworkloads in the target workspace. Clusters with admission that only trusts approved workload creators must allow the Job controller to create their pods.target.subpathso that the parent is writable by that user. The directory itself is created by the job.model.cleanup_image(defaultalpine:3.18) must be pullable in the workspace cluster.Testing
SECREToptional (controller + webhook), entry point only for Model-owned jobs, cleanup webhook validation, API create rejection andincludeDeleting, directory naming and layout.TestModelLifecycleEnvtest(-tags integration, needsKUBEBUILDER_ASSETS) runs the controller against a real API server with the chart CRDs. It covers the same lifecycle, thatsizeBytessurvives the CRD schema, and that a failed cleanup keeps the model until a later cleanup succeeds.huggingface_hub==0.24.0(the version in the model-downloader image), against a small public repository:.bin/.h5);Not verified on a live cluster: the new controller code has not been deployed. In particular, the following are untested: the launcher entry point in the download image, size reporting via pod logs, and secret mirroring into the workspace.
🤖 Generated with Claude Code