unsloth/studio/backend/hub/utils/inventory_scan.py
Daniel Han 3427e3fd62
Studio: fix Downloaded model list disappearing and order it by last download (#6247)
* Studio: fix Downloaded model list disappearing and order it by last download

The chat model picker scan for cached GGUF and safetensors models aborted
whenever an auxiliary Hugging Face cache dir (such as ~/.cache/huggingface/hub)
was unreadable, returning an empty list. That hid the Downloaded section and
let already downloaded models appear under Recommended. Isolate each cache
probe so an inaccessible directory is skipped instead of failing the scan.

Also order Downloaded newest-first using cached blob mtimes (multi-quant repos
group by their most recent quant), keep the section visible while searching,
and make the per-quant downloaded check per-snapshot and mmproj aware so a
Recommended quant is never falsely marked downloaded.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: harden gguf-variants scan and dedupe by newest timestamp

Guard f.stat() per file so a broken symlink or unreadable file in a
snapshot no longer aborts the downloaded check early, and match quant
labels case-insensitively. When the same repo is present in multiple
caches with equal size, keep the newest last_modified so Downloaded
ordering reflects the most recent copy.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: apply cache-scan guards to sibling endpoints found in review

Extend the inaccessible-cache guard and mmproj/stat hardening to the
parallel HF cache code paths flagged in review:

- list_local_models and the Hub inventory scan now skip an unreadable
  auxiliary cache instead of returning 500.
- The GGUF download-progress endpoint excludes mmproj adapters and
  guards f.stat() so one bad file does not zero a repo's progress.
- The offline snapshot scanner guards its is_dir() probes.
- The chat-only picker no longer renders a blank list when a search
  matches only cached non-GGUF models.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-12 05:27:34 -07:00

543 lines
18 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""HF cache inventory scanner.
Read-only walks of the HuggingFace hub cache plus legacy/default
cache locations. Builds the foundation that Hub inventory endpoints
and the DownloadRegistry both consume.
The worker spawn / transport-marker preparation / DownloadRegistry
layers built on top of these primitives live in download_registry.py.
"""
from __future__ import annotations
import hashlib
import re
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
from loggers import get_logger
logger = get_logger(__name__)
from hub.utils.gguf import (
extract_quant_label,
is_gguf_filename,
is_mmproj_filename,
is_mtp_drafter_path,
)
from hub.utils.state_dir import RepoType
from hub.utils.hf_cache_state import (
INCOMPLETE_SUFFIX,
has_incomplete_blobs,
hf_cache_root,
iter_repo_cache_dirs,
latest_snapshot_dir,
repo_cache_dir_has_incomplete_blobs,
)
# Inventory is invalidated explicitly on every app-driven cache mutation, so
# this TTL only bounds staleness from out-of-band edits while skipping re-walks
# on rapid UI navigation.
_HF_CACHE_SCANS_TTL_SECONDS = 15.0
_GGUF_SPLIT_RE = re.compile(r"-(\d{3,})-of-(\d{3,})(?=\.gguf$)", re.IGNORECASE)
_hf_cache_scans_lock = threading.Lock()
@dataclass
class _HfCacheScanFlight:
event: threading.Event
epoch: int
result: Optional[list] = None
error: Optional[BaseException] = None
_hf_cache_scans_flight: Optional[_HfCacheScanFlight] = None
_hf_cache_scans_result: Optional[list] = None
_hf_cache_scans_cached_at: float = 0.0
# Bumped on every invalidation. A scan tags itself with the epoch it began
# under; an invalidation mid-scan changes the epoch so the in-flight result is
# neither cached nor served to callers that arrived after the mutation.
_hf_cache_scans_epoch: int = 0
def invalidate_hf_cache_scans() -> None:
global _hf_cache_scans_result, _hf_cache_scans_cached_at, _hf_cache_scans_epoch
with _hf_cache_scans_lock:
_hf_cache_scans_result = None
_hf_cache_scans_cached_at = 0.0
_hf_cache_scans_epoch += 1
def all_hf_cache_scans() -> list:
global _hf_cache_scans_flight, _hf_cache_scans_result, _hf_cache_scans_cached_at
now = time.monotonic()
with _hf_cache_scans_lock:
if (
_hf_cache_scans_result is not None
and (now - _hf_cache_scans_cached_at) < _HF_CACHE_SCANS_TTL_SECONDS
):
return list(_hf_cache_scans_result)
start_epoch = _hf_cache_scans_epoch
flight = _hf_cache_scans_flight
# Only coalesce onto an in-flight scan from the current epoch; one that
# began before an intervening invalidation is superseded so
# post-mutation callers never receive pre-mutation data.
if flight is None or flight.epoch != start_epoch:
flight = _HfCacheScanFlight(event = threading.Event(), epoch = start_epoch)
_hf_cache_scans_flight = flight
owner = True
else:
owner = False
if not owner:
flight.event.wait()
if flight.error is not None:
raise flight.error
return list(flight.result or [])
try:
scans = _compute_all_hf_cache_scans()
with _hf_cache_scans_lock:
flight.result = scans
if _hf_cache_scans_epoch == flight.epoch:
_hf_cache_scans_result = scans
_hf_cache_scans_cached_at = time.monotonic()
return scans
except Exception as exc:
with _hf_cache_scans_lock:
if _hf_cache_scans_epoch == flight.epoch:
_hf_cache_scans_result = None
_hf_cache_scans_cached_at = 0.0
flight.error = exc
raise
finally:
with _hf_cache_scans_lock:
if _hf_cache_scans_flight is flight:
_hf_cache_scans_flight = None
flight.event.set()
def _compute_all_hf_cache_scans() -> list:
from huggingface_hub import scan_cache_dir
from hub.utils.paths import legacy_hf_cache_dir, hf_default_cache_dir
scans: list = []
seen: set[str] = set()
try:
from huggingface_hub.constants import HF_HUB_CACHE
active = Path(HF_HUB_CACHE).resolve()
seen.add(str(active))
if active.is_dir():
scans.append(scan_cache_dir())
except Exception as exc:
logger.warning("Could not scan active HF cache: %s", exc)
for extra_fn in (legacy_hf_cache_dir, hf_default_cache_dir):
try:
extra = extra_fn()
# is_dir()/resolve() can raise on an inaccessible path; skip it.
if not extra.is_dir():
continue
resolved = str(extra.resolve())
if resolved in seen:
continue
seen.add(resolved)
scans.append(scan_cache_dir(cache_dir = str(extra)))
except Exception as exc:
logger.warning("Could not scan HF cache %s: %s", extra_fn.__name__, exc)
return scans
def token_fingerprint(hf_token: Optional[str]) -> str:
"""16-char SHA256 prefix used as a cache-key qualifier for gated repos.
Lets per-token size/snapshot caches refuse to serve a previously
fetched value back to a different token (a private/gated repo's
metadata is only valid for the credential that fetched it).
"""
if not hf_token:
return ""
return hashlib.sha256(hf_token.encode()).hexdigest()[:16]
def resolve_hf_cache_realpath(repo_dir: Path) -> Optional[str]:
"""Pick the most useful on-disk path for a HF cache repo dir.
Prefers the most-recent snapshot dir (what ``from_pretrained``
actually points at). Falls back to the cache repo root. Returns the
resolved realpath so symlinks under ``snapshots/`` are followed back
to ``blobs/``.
"""
try:
latest = latest_snapshot_dir(repo_dir)
if latest is not None:
return str(latest.resolve())
return str(repo_dir.resolve())
except Exception:
return None
def resolve_snapshot_dir_for_scan(
repo_type: str,
repo_id: str,
repo_cache_dir: Optional[Path] = None,
) -> Optional[Path]:
"""Latest snapshot dir for a cache row, or the first populated HF cache root.
Scanner-side counterpart to snapshot_download()'s return value (which the
scanner cannot access). With a *repo_cache_dir*, returns its newest
snapshot. Otherwise scans roots in priority order (active, legacy, default)
and returns the newest snapshot in the first root that holds one; active is
where snapshot_download writes, so it is authoritative. Within a root,
picks by mtime (what from_pretrained resolves to) rather than refs/main,
since the user may have downloaded a non-main commit.
"""
if repo_cache_dir is not None:
latest = latest_snapshot_dir(repo_cache_dir)
if latest is None:
return None
try:
return latest.resolve()
except OSError:
return None
for repo_dir in iter_repo_cache_dirs(repo_type, repo_id):
latest = latest_snapshot_dir(repo_dir)
if latest is None:
continue
try:
return latest.resolve()
except OSError:
continue
return None
def _compose_partial(*signals: Callable[[], bool]) -> bool:
return any(signal() for signal in signals)
def _state_applies_to_repo_cache_dir(repo_cache_dir: Optional[Path]) -> bool:
if repo_cache_dir is None:
return True
root = hf_cache_root()
if root is None:
return False
try:
return repo_cache_dir.resolve().parent == root.resolve()
except OSError:
return False
def _legacy_partial(
repo_type: str,
repo_id: str,
repo_cache_dir: Optional[Path] = None,
) -> bool:
if repo_cache_dir is not None:
return repo_cache_dir_has_incomplete_blobs(repo_cache_dir)
return has_incomplete_blobs(repo_type, repo_id)
def _repo_cache_dir_incomplete_hashes(repo_cache_dir: Path) -> set[str]:
blobs_dir = repo_cache_dir / "blobs"
if not blobs_dir.is_dir():
return set()
hashes: set[str] = set()
try:
entries = list(blobs_dir.iterdir())
except OSError:
return hashes
for blob in entries:
try:
if blob.is_file() and blob.name.endswith(INCOMPLETE_SUFFIX):
hashes.add(blob.name[: -len(INCOMPLETE_SUFFIX)])
except OSError:
continue
return hashes
def _repo_cache_dir_has_non_gguf_broken_snapshot_symlinks(repo_cache_dir: Path) -> bool:
latest = latest_snapshot_dir(repo_cache_dir)
if latest is None:
return False
try:
entries = list(latest.rglob("*"))
except OSError:
return False
for entry in entries:
try:
if not entry.is_symlink() or entry.exists():
continue
rel = entry.relative_to(latest).as_posix()
if is_gguf_filename(rel):
continue
return True
except OSError:
continue
return False
def _gguf_variant_manifest_blob_hashes(repo_id: str) -> frozenset[str]:
from hub.utils import download_manifest
hashes: set[str] = set()
for variant, _path in download_manifest.iter_variant_manifests("model", repo_id):
manifest = download_manifest.read_manifest("model", repo_id, variant)
if manifest is None:
continue
for expected in manifest.expected_files:
if expected.sha256 and is_gguf_filename(expected.path):
hashes.add(expected.sha256)
return frozenset(hashes)
def _repo_cache_dir_has_snapshot_legacy_partial(
repo_cache_dir: Path, *, ignored_blob_hashes: frozenset[str]
) -> bool:
incomplete_hashes = _repo_cache_dir_incomplete_hashes(repo_cache_dir)
if any(blob_hash not in ignored_blob_hashes for blob_hash in incomplete_hashes):
return True
return _repo_cache_dir_has_non_gguf_broken_snapshot_symlinks(repo_cache_dir)
def _snapshot_legacy_partial(
repo_type: str,
repo_id: str,
repo_cache_dir: Optional[Path] = None,
) -> bool:
if repo_type != "model":
return _legacy_partial(repo_type, repo_id, repo_cache_dir)
ignored_hashes = _gguf_variant_manifest_blob_hashes(repo_id)
if repo_cache_dir is not None:
return _repo_cache_dir_has_snapshot_legacy_partial(
repo_cache_dir,
ignored_blob_hashes = ignored_hashes,
)
return any(
_repo_cache_dir_has_snapshot_legacy_partial(
entry,
ignored_blob_hashes = ignored_hashes,
)
for entry in iter_repo_cache_dirs(repo_type, repo_id)
)
def _completed_gguf_variants(snapshot_dir: Optional[Path]) -> set[str]:
if snapshot_dir is None:
return set()
complete: set[str] = set()
split_groups: dict[str, dict[int, set[int]]] = {}
try:
paths = list(snapshot_dir.rglob("*"))
except OSError:
return set()
for path in paths:
try:
if not path.is_file() or path.stat().st_size <= 0:
continue
except OSError:
continue
rel = path.relative_to(snapshot_dir).as_posix()
if not is_gguf_filename(rel) or is_mmproj_filename(rel) or is_mtp_drafter_path(rel):
continue
quant = extract_quant_label(rel)
split = _GGUF_SPLIT_RE.search(path.name)
if split is None:
complete.add(quant)
continue
index = int(split.group(1))
total = int(split.group(2))
if index <= 0 or total <= 0 or index > total:
continue
split_groups.setdefault(quant, {}).setdefault(total, set()).add(index)
for quant, groups in split_groups.items():
for total, indices in groups.items():
if indices == set(range(1, total + 1)):
complete.add(quant)
break
return complete
def _manifest_partial(
repo_type: RepoType,
repo_id: str,
variant: Optional[str] = None,
snapshot_dir: Optional[Path] = None,
repo_cache_dir: Optional[Path] = None,
) -> bool:
from hub.utils import download_manifest
if not _state_applies_to_repo_cache_dir(repo_cache_dir):
return False
manifest = download_manifest.read_manifest(repo_type, repo_id, variant)
if manifest is None:
return False
resolved = (
snapshot_dir
if snapshot_dir is not None
else resolve_snapshot_dir_for_scan(repo_type, repo_id, repo_cache_dir)
)
if resolved is None:
return True
return not download_manifest.verify_against_disk(manifest, resolved).ok
def is_snapshot_partial(
repo_type: RepoType,
repo_id: str,
repo_cache_dir: Optional[Path] = None,
) -> bool:
"""Repo-row partial flag for snapshot-style downloads (full-snapshot
models — safetensors/adapter/checkpoint — and all datasets).
Composes three signals, cheapest first:
1. Cancel marker (single stat).
2. Snapshot-attributed legacy .incomplete blob / broken-symlink check.
3. Manifest walk (stat per expected file under the latest snapshot).
A manifest without a resolvable snapshot is partial: the worker got
far enough to record expectations but did not leave a usable snapshot."""
from hub.utils import download_manifest
state_applies = _state_applies_to_repo_cache_dir(repo_cache_dir)
return _compose_partial(
lambda: state_applies and download_manifest.has_cancel_marker(repo_type, repo_id, None),
lambda: _snapshot_legacy_partial(repo_type, repo_id, repo_cache_dir),
lambda: _manifest_partial(
repo_type,
repo_id,
None,
None,
repo_cache_dir,
),
)
def is_variant_partial(
repo_id: str,
variant: str,
snapshot_dir: Optional[Path] = None,
*,
incomplete_blob_hashes: Optional[set[str]] = None,
variant_blob_hashes: Optional[frozenset[str]] = None,
repo_cache_dir: Optional[Path] = None,
) -> bool:
"""Per-variant partial detection. Owns its manifest, owns its marker.
Used by the GGUF variants endpoint to flag a specific quant as broken
without contaminating other quants in the same repo.
snapshot_dir is an optional hint to avoid re-walking the cache when a
caller is checking many variants of the same repo (see
is_gguf_repo_partial for that usage)."""
from hub.utils import download_manifest
state_applies = _state_applies_to_repo_cache_dir(repo_cache_dir)
return _compose_partial(
lambda: state_applies and download_manifest.has_cancel_marker("model", repo_id, variant),
lambda: bool(
incomplete_blob_hashes
and variant_blob_hashes
and incomplete_blob_hashes.intersection(variant_blob_hashes)
),
lambda: _manifest_partial(
"model",
repo_id,
variant,
snapshot_dir,
repo_cache_dir,
),
)
def is_gguf_repo_partial(repo_id: str, repo_cache_dir: Optional[Path] = None) -> bool:
"""Repo-row partial flag for a GGUF repo. The inventory shows ONE row per
GGUF repo (requires_variant=True); per-variant detail lives in
GET /api/models/gguf-variants and uses is_variant_partial.
*** DO NOT simplify this to "any variant partial -> repo partial" ***
Tripwire scenario: user downloads Q8_0 fully, then starts Q4_K_M and
cancels. Both variants share ONE inventory row. If row.partial flips True,
_capabilities_for_format flips can_chat=False, so the user can no longer
chat with the perfectly-good Q8_0 because of an unrelated cancelled Q4_K_M.
Correct semantics: partial=True only when at least one variant is broken
AND no other variant is clean. "Simplifying" to the obvious "any broken"
form re-introduces this Q8+Q4 mixed-state regression.
Composes signals:
1. Cheap legacy fast-path (.incomplete blobs / broken symlinks).
2. Per-variant manifest + marker enumeration, gated on "all broken".
"""
from hub.utils import download_manifest
has_legacy_partial = _legacy_partial("model", repo_id, repo_cache_dir)
state_applies = _state_applies_to_repo_cache_dir(repo_cache_dir)
snapshot_dir = resolve_snapshot_dir_for_scan(
"model",
repo_id,
repo_cache_dir,
)
variants: set[str] = set(_completed_gguf_variants(snapshot_dir))
if state_applies:
for variant, _path in download_manifest.iter_variant_manifests(
"model",
repo_id,
):
variants.add(variant)
for variant, _path in download_manifest.iter_variant_markers(
"model",
repo_id,
):
variants.add(variant)
if not variants:
return has_legacy_partial
has_clean = False
has_broken = has_legacy_partial
for variant in variants:
if is_variant_partial(
repo_id,
variant,
snapshot_dir,
repo_cache_dir = repo_cache_dir,
):
has_broken = True
else:
has_clean = True
return has_broken and not has_clean
def partial_transport_for(
repo_type: RepoType,
repo_id: str,
variant: Optional[str] = None,
repo_cache_dir: Optional[Path] = None,
) -> Optional[str]:
"""Transport to surface on a partial row's resume affordance.
Prefers the cancel marker's transport, then the manifest's. The fallback
matters for rows partial without a marker (an errored/interrupted download
leaves the manifest but no marker) so the UI can still show HTTP-resume vs
XET-redownload instead of the neutral retry label. ``None`` when neither is
available."""
from hub.utils import download_manifest
if not _state_applies_to_repo_cache_dir(repo_cache_dir):
return None
marker_transport = download_manifest.read_cancel_marker_transport(
repo_type,
repo_id,
variant,
)
if marker_transport is not None:
return marker_transport
manifest = download_manifest.read_manifest(repo_type, repo_id, variant)
return manifest.transport if manifest is not None else None