From f62aff0809a2f74da1703076a54c46c3e5dcc178 Mon Sep 17 00:00:00 2001 From: Alejandro Gaston Alvarez Date: Mon, 27 Jul 2026 11:58:06 +0200 Subject: [PATCH] fix(cache): don't treat orphaned .incomplete blobs as an in-progress download is_model_cached() marked a model as not-cached whenever any .incomplete blob existed in its cache dir, even when a completed blob with the same hash already sat next to it. A retried/concurrent download can leave this orphan behind after the real transfer already finished, which made the model appear perpetually "downloading" and re-trigger a full re-download on every load. Only .incomplete files with no matching completed blob now count as a genuinely in-progress download. --- backend/backends/base.py | 15 ++++-- backend/tests/test_is_model_cached.py | 74 +++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_is_model_cached.py diff --git a/backend/backends/base.py b/backend/backends/base.py index 83acf2c3..9360be79 100644 --- a/backend/backends/base.py +++ b/backend/backends/base.py @@ -48,11 +48,18 @@ def is_model_cached( if not repo_cache.exists(): return False - # Incomplete blobs mean a download is still in progress + # An .incomplete blob means a download is still in progress -- unless + # a completed blob with the same hash already sits next to it, which + # happens when a retried/concurrent download leaves a stale .incomplete + # behind after the real transfer already finished. Only orphaned + # .incomplete files (no matching completed blob) count as "in progress". blobs_dir = repo_cache / "blobs" - if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")): - logger.debug(f"Found .incomplete files for {hf_repo}") - return False + if blobs_dir.exists(): + for incomplete in blobs_dir.glob("*.incomplete"): + completed = incomplete.with_name(incomplete.name.removesuffix(".incomplete")) + if not completed.exists(): + logger.debug(f"Found in-progress .incomplete file for {hf_repo}") + return False snapshots_dir = repo_cache / "snapshots" if not snapshots_dir.exists(): diff --git a/backend/tests/test_is_model_cached.py b/backend/tests/test_is_model_cached.py new file mode 100644 index 00000000..6b9d883f --- /dev/null +++ b/backend/tests/test_is_model_cached.py @@ -0,0 +1,74 @@ +""" +Unit tests for ``is_model_cached``'s handling of stale ``.incomplete`` blobs. + +A retried or concurrent download can leave an orphaned ``.incomplete`` file +next to its now-completed counterpart (same blob hash, no suffix). Only a +genuinely in-progress download -- an ``.incomplete`` with no completed blob +alongside it -- should mark the model as not cached. + +``is_model_cached`` is extracted and exec'd standalone (instead of importing +``backend.backends.base``) so this test doesn't pull in the module's sibling +imports (audio/progress/hf_progress/tasks), which in turn require the full +ML stack (torch/transformers/librosa/fastapi/...) this pure filesystem check +never touches. +""" + +import ast +import logging +from pathlib import Path +from typing import Optional + +_SOURCE = (Path(__file__).parent.parent / "backends" / "base.py").read_text() +_MODULE = ast.parse(_SOURCE) +_FUNC_SRC = next( + ast.get_source_segment(_SOURCE, node) + for node in _MODULE.body + if isinstance(node, ast.FunctionDef) and node.name == "is_model_cached" +) + +_namespace = { + "Path": Path, + "Optional": Optional, + "logger": logging.getLogger("test_is_model_cached"), +} +exec(_FUNC_SRC, _namespace) # noqa: S102 +is_model_cached = _namespace["is_model_cached"] + + +def _make_repo_cache(tmp_path, repo="org/model"): + repo_dir = tmp_path / ("models--" + repo.replace("/", "--")) + blobs_dir = repo_dir / "blobs" + snapshots_dir = repo_dir / "snapshots" / "abc123" + blobs_dir.mkdir(parents=True) + snapshots_dir.mkdir(parents=True) + return repo_dir, blobs_dir, snapshots_dir + + +def test_orphaned_incomplete_blob_does_not_block_cache_hit(tmp_path, monkeypatch): + import huggingface_hub.constants as hf_constants + + monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path)) + + repo = "org/model" + _, blobs_dir, snapshots_dir = _make_repo_cache(tmp_path, repo) + + completed_blob = blobs_dir / "deadbeef" + completed_blob.write_bytes(b"weights") + (blobs_dir / "deadbeef.incomplete").write_bytes(b"stale partial") + (snapshots_dir / "model.safetensors").symlink_to(completed_blob) + + assert is_model_cached(repo) is True + + +def test_genuinely_in_progress_download_is_not_cached(tmp_path, monkeypatch): + import huggingface_hub.constants as hf_constants + + monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path)) + + repo = "org/model" + _, blobs_dir, snapshots_dir = _make_repo_cache(tmp_path, repo) + + (blobs_dir / "feedface.incomplete").write_bytes(b"partial") + (snapshots_dir / "config.json").write_text("{}") + + assert is_model_cached(repo) is False