Skip to content

Make the Model the single owner of local model weights - #765

Merged
weilei0120 merged 20 commits into
mainfrom
feat/model-local-lifecycle
Oct 12, 2026
Merged

weilei0120 merged 20 commits into
mainfrom
feat/model-local-lifecycle

Conversation

@zoroyihan7

@zoroyihan7 zoroyihan7 commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Why

A local model 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

  1. No S3 needed for local models. With S3 disabled, handlePending goes straight to Downloading. The per-workspace download job fetches from HuggingFace directly into the workspace storage path (model.downloader_image, huggingface-cli download, with a fallback to hf download). The S3-staged flow is unchanged when S3 is enabled.
  2. SECRET is 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.ids annotation) so the job can mount it.
  3. The download only fetches what is needed to load the model: *.safetensors, *.json, tokenizer*, *.model, *.tiktoken, *.txt, *.py, *.jinja, excluding original/*. 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 prints MODEL_SIZE_BYTES=<n>, which ends up in status.localPaths[].sizeBytes.
  4. Deleting a model removes its files, and never releases early:
    • Downloads are stopped first: the S3 upload Job, the download OpsJobs and their workloads must be gone before cleanup starts.
    • Each status.localPaths entry is removed by a new model-cleanup OpsJob. 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 leaves status.localPaths only after its directory is gone.
    • The three "cleanup failed, release anyway" paths now keep the finalizer and retry (failed job deleted, status.message says why, retry after 30s). The same applies to the S3 cleanup Job, which now fails when aws s3 rm fails.
    • Guards:
      • Only an absolute, clean path with a segment below a models directory, 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.
      • A directory that another live model still points at is kept.
      • The webhook refuses a cleanup while a download or cleanup on the same path is still running.
  5. No new model on a path that is still being cleaned up:
    • The API rejects such a create with 409 / ResourceProcessing.
    • The controller re-checks before starting: a pending model waits while another model's deletion holds its directory, and fails if a live model already owns the directory.
  6. Jobs run as the model owner (user id and name labels) instead of the system user. Storage that maps users to their own uid then writes and removes files with the owner's identity.
  7. Failover stays on the same cluster. It only moves to a workspace on the same cluster that mounts the same volume root as the path. An equal mount path on another cluster is a different filesystem.
  8. The list API can show models still being deleted: GET /models?includeDeleting=true adds models whose CR still exists with a deletion timestamp (isDeleted=true, deletionTime, remaining localPaths).
  9. Global download concurrency limit: 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 stay Pending with "Waiting for a download slot (n/m in use)". The download timeout is now model.download_timeout_second (default 10800).

Also:

  • HuggingFace models are stored as <owner>--<name>. HuggingFace forbids -- inside a segment, so two repositories can no longer map to the same directory (a-b/c and a/b-c both used to become a-b-c).
  • A target.subpath that already contains a models segment is used as the parent directory as is: <volume>/<subpath>/<owner>--<name>. Otherwise the layout is unchanged: <volume>/[subpath/]models/<dir>.
  • The download always targets the absolute path recorded in status, so download and cleanup cannot disagree about the directory.
  • cleanup_image in the resource-manager config template was rendered under cd: and never read. It is now under model:.

Review follow-up

A review round found three defects, fixed in the third commit:

  • A private workspace without a volume got a download target on the container's own filesystem. It now gets none, and the model fails.
  • A public model deduplicated workspaces by path alone across clusters. It is now downloaded once per cluster and path.
  • A recorded path that failed the cleanup guard released the model. It now keeps the finalizer.

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}, and includeDeleting on the list API are enough for an external system to tell "available", "failed", and "still occupying storage".

Deployment notes

  • The download and cleanup jobs are kind: Job workloads in the target workspace. Clusters with admission that only trusts approved workload creators must allow the Job controller to create their pods.
  • The job identity is the model owner. On storage that maps users to their own uid, the owner needs write access to the parent of the model directory. Choose target.subpath so that the parent is writable by that user. The directory itself is created by the job.
  • model.cleanup_image (default alpine:3.18) must be pullable in the workspace cluster.

Testing

  • Unit tests (fake client): no-S3 download end to end, from create through Ready (path and size) to deletion and release. Also: token mounting, cleanup failure / create failure / S3 cleanup failure keep the finalizer, downloads stopped before cleanup, shared path kept, unsafe paths refused, pending model waits for a deletion on its path, concurrency limit, same-cluster failover, SECRET optional (controller + webhook), entry point only for Model-owned jobs, cleanup webhook validation, API create rejection and includeDeleting, directory naming and layout.
  • TestModelLifecycleEnvtest (-tags integration, needs KUBEBUILDER_ASSETS) runs the controller against a real API server with the chart CRDs. It covers the same lifecycle, that sizeBytes survives the CRD schema, and that a failed cleanup keeps the model until a later cleanup succeeds.
  • Mutation check: for each key rule (empty storage root, per-cluster dedup, refused path, failure keeps finalizer, shared path, stop before cleanup, concurrency cap, same-cluster failover, optional secret in controller and webhook, wait for deletion in controller and API, no-S3 path, path guard in controller and webhook, CRD field), the rule was broken by hand. The corresponding test went red each time, and the change was reverted.
  • The download and cleanup scripts were run as written, with huggingface_hub==0.24.0 (the version in the model-downloader image), against a small public repository:
    • only the listed file types were fetched (no .bin / .h5);
    • the size line was printed;
    • a re-run was a no-op;
    • an unknown repository exits non-zero;
    • cleanup removes the directory and refuses a non-model path.

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

zoroyihan7 and others added 2 commits October 9, 2026 03:40
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>
@zoroyihan7
zoroyihan7 requested a review from a team as a code owner October 9, 2026 03:45
@zoroyihan7 zoroyihan7 added the enhancement New feature or request label Oct 9, 2026
zoroyihan7 and others added 18 commits October 9, 2026 03:54
- 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
weilei0120 merged commit 5c53e14 into main Oct 12, 2026
15 checks passed
@weilei0120
weilei0120 deleted the feat/model-local-lifecycle branch October 12, 2026 05:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants