* Studio Hub: default downloads to Xet transport Model and dataset downloads defaulted to HTTP; flip the default to Xet for faster parallel chunked transfers. - Frontend: DEFAULT_TRANSPORT_MODE is now Xet, so a user with no saved preference starts on Xet. effectiveTransportMode() already downgrades to HTTP and warns when hf_xet is unavailable, so this degrades gracefully. - Backend: DownloadModelRequest.use_xet and DownloadDatasetRequest.use_xet default to True, keeping the API in step with the UI. Set use_xet=False for sequential HTTP Range-resume. - Align the internal _spawn_download_worker default so no caller silently falls back to HTTP. Inference and training model loads were already Xet-first with an HTTP stall fallback, so this brings explicit downloads in line with the rest of Studio. * Studio Hub: gracefully fall back to HTTP when Xet is unavailable With Xet now the default, an omitted or explicit use_xet=True from a non-UI API caller would 400 on installs without hf_xet, since resolve_transport raises when the transport is unavailable. Add resolve_effective_use_xet(), which downgrades a Xet request to HTTP (with a warning) when hf_xet is missing, mirroring the frontend's own downgrade. Both the model and dataset flows now derive a single effective use_xet and feed it to resolve_transport and spawn_worker, so the recorded transport and the worker env can never disagree. The UI is unaffected: it already resolves availability and passes use_xet explicitly. * Add tests for resolve_effective_use_xet Xet to HTTP fallback * Trim comments for PR #6433 --------- Co-authored-by: Daniel Han <danielhanchen@gmail.com>
269 lines
9.5 KiB
Python
269 lines
9.5 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
|
|
|
|
"""Start, cancel, and report progress for dataset downloads."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import threading
|
|
import time
|
|
from collections import OrderedDict
|
|
from typing import Optional
|
|
|
|
from fastapi import HTTPException
|
|
from loggers import get_logger
|
|
|
|
from hub.schemas.downloads import (
|
|
ActiveDownloadsResponse,
|
|
CancelDatasetDownloadRequest,
|
|
DatasetDownloadJobStatus,
|
|
DownloadDatasetRequest,
|
|
)
|
|
from hub.services import snapshot_progress
|
|
from hub.services import download_lifecycle
|
|
from hub.utils import download_manifest
|
|
from hub.utils import download_registry
|
|
from hub.utils import inventory_scan as hf_cache_scan
|
|
from hub.utils.hf_cache_state import has_active_incomplete_blobs
|
|
from hub.utils.paths import (
|
|
is_valid_repo_id as _is_valid_repo_id,
|
|
resolve_cached_repo_id_case,
|
|
)
|
|
from hub.utils.snapshot_filters import (
|
|
blob_hashes_for_siblings,
|
|
total_size_for_siblings,
|
|
)
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
_dataset_size_cache: "OrderedDict[str, tuple[int, frozenset[str], bool, str, float]]" = (
|
|
OrderedDict()
|
|
)
|
|
_dataset_size_neg_cache: "OrderedDict[tuple[str, str], float]" = OrderedDict()
|
|
_DATASET_SIZE_CACHE_MAX = 256
|
|
_DATASET_SIZE_POS_TTL = 60.0
|
|
_DATASET_SIZE_NEG_TTL = 60.0
|
|
_DATASET_SIZE_TIMEOUT_SECONDS = 5.0
|
|
_dataset_size_cache_lock = threading.Lock()
|
|
|
|
_registry = download_registry.get_datasets_registry()
|
|
|
|
|
|
def _download_job_key(repo_id: str) -> str:
|
|
return download_registry.normalize_repo_key(repo_id)
|
|
|
|
|
|
def get_dataset_snapshot_metadata_cached(
|
|
repo_id: str, hf_token: Optional[str] = None
|
|
) -> tuple[int, frozenset[str]]:
|
|
"""Raw snapshot size + expected blob hashes for a dataset repo.
|
|
|
|
The dataset worker downloads every sibling, so the denominator is the full
|
|
sibling-size sum and the hashes cover every file. Consumed by the shared
|
|
``snapshot_progress`` accounting."""
|
|
token_fp = hf_cache_scan.token_fingerprint(hf_token)
|
|
cache_key = (repo_id, token_fp)
|
|
with _dataset_size_cache_lock:
|
|
cached = _dataset_size_cache.get(repo_id)
|
|
if cached is not None:
|
|
size, hashes, restricted, cached_fp, ts = cached
|
|
if (time.monotonic() - ts) >= _DATASET_SIZE_POS_TTL:
|
|
del _dataset_size_cache[repo_id]
|
|
# A gated/private repo's metadata is only served back to the token
|
|
# that fetched it; another token may have no access at all.
|
|
elif not restricted or cached_fp == token_fp:
|
|
_dataset_size_cache.move_to_end(repo_id)
|
|
return size, hashes
|
|
neg_ts = _dataset_size_neg_cache.get(cache_key)
|
|
if neg_ts is not None and (time.monotonic() - neg_ts) < _DATASET_SIZE_NEG_TTL:
|
|
return 0, frozenset()
|
|
try:
|
|
from huggingface_hub import HfApi
|
|
|
|
info = HfApi(token = hf_token).dataset_info(
|
|
repo_id,
|
|
files_metadata = True,
|
|
timeout = _DATASET_SIZE_TIMEOUT_SECONDS,
|
|
)
|
|
total = total_size_for_siblings(info.siblings)
|
|
hashes = blob_hashes_for_siblings(info.siblings)
|
|
restricted = bool(getattr(info, "private", False) or getattr(info, "gated", False))
|
|
except Exception:
|
|
with _dataset_size_cache_lock:
|
|
_dataset_size_neg_cache[cache_key] = time.monotonic()
|
|
_dataset_size_neg_cache.move_to_end(cache_key)
|
|
while len(_dataset_size_neg_cache) > _DATASET_SIZE_CACHE_MAX:
|
|
_dataset_size_neg_cache.popitem(last = False)
|
|
return 0, frozenset()
|
|
with _dataset_size_cache_lock:
|
|
_dataset_size_cache[repo_id] = (
|
|
total,
|
|
hashes,
|
|
restricted,
|
|
token_fp,
|
|
time.monotonic(),
|
|
)
|
|
_dataset_size_cache.move_to_end(repo_id)
|
|
_dataset_size_neg_cache.pop(cache_key, None)
|
|
while len(_dataset_size_cache) > _DATASET_SIZE_CACHE_MAX:
|
|
_dataset_size_cache.popitem(last = False)
|
|
return total, hashes
|
|
|
|
|
|
async def get_dataset_download_progress_response(
|
|
repo_id: str,
|
|
expected_bytes: int = 0,
|
|
hf_token: Optional[str] = None,
|
|
) -> dict:
|
|
"""Return download progress for a HuggingFace dataset repo.
|
|
|
|
Scans the ``datasets--owner--name`` cache dir and shares the blob accounting
|
|
with the model path via ``snapshot_progress``. Returns ``cache_path`` for the
|
|
UI."""
|
|
return await snapshot_progress.snapshot_progress_response(
|
|
repo_type = "dataset",
|
|
repo_id = repo_id,
|
|
job_key = _download_job_key(repo_id),
|
|
expected_bytes = expected_bytes,
|
|
hf_token = hf_token,
|
|
registry = _registry,
|
|
metadata_resolver = get_dataset_snapshot_metadata_cached,
|
|
)
|
|
|
|
|
|
def _dataset_status(key: str, *, repo_id: Optional[str] = None) -> DatasetDownloadJobStatus:
|
|
state, error, generation = download_lifecycle.idle_status(
|
|
_registry,
|
|
key,
|
|
repo_type = "dataset",
|
|
repo_id = repo_id,
|
|
variant = None,
|
|
)
|
|
return DatasetDownloadJobStatus(state = state, error = error, generation = generation)
|
|
|
|
|
|
async def download_dataset_response(
|
|
body: DownloadDatasetRequest, hf_token: Optional[str] = None
|
|
) -> dict:
|
|
"""Start a background download for a HuggingFace dataset."""
|
|
repo_id = body.repo_id.strip()
|
|
if not _is_valid_repo_id(repo_id):
|
|
raise HTTPException(
|
|
status_code = 400,
|
|
detail = f"Invalid repo_id: {repo_id!r}",
|
|
)
|
|
# Canonicalize so two different-cased paste-ins share one job + cache dir.
|
|
repo_id = await asyncio.to_thread(resolve_cached_repo_id_case, repo_id, repo_type = "dataset")
|
|
key = _download_job_key(repo_id)
|
|
|
|
use_xet = download_lifecycle.resolve_effective_use_xet(body.use_xet)
|
|
transport = download_lifecycle.resolve_transport(use_xet)
|
|
|
|
claimed, claim_state = _registry.claim(
|
|
key,
|
|
transport,
|
|
repo_type = "dataset",
|
|
repo_id = repo_id,
|
|
)
|
|
generation = _registry.current_generation(key)
|
|
if not claimed:
|
|
# Pollable when rejected by this repo's own in-flight job; an
|
|
# in-progress delete leaves no job, so flag it via ``adoptable``.
|
|
return {
|
|
"repo_id": repo_id,
|
|
"state": claim_state,
|
|
"accepted": _registry.adoptable(key),
|
|
"generation": generation,
|
|
}
|
|
download_manifest.clear_cancel_marker("dataset", repo_id, None)
|
|
|
|
state = download_lifecycle.launch_worker(
|
|
_registry,
|
|
key,
|
|
spawn = lambda: download_lifecycle.spawn_worker(
|
|
["--repo-id", repo_id, "--dataset"],
|
|
hf_token,
|
|
use_xet = use_xet,
|
|
),
|
|
hf_token = hf_token,
|
|
label = repo_id,
|
|
log_prefix = "Dataset download",
|
|
logger = logger,
|
|
repo_type = "dataset",
|
|
repo_id = repo_id,
|
|
transport = transport,
|
|
watch_name = f"hf-dataset-download-watch-{repo_id}",
|
|
)
|
|
|
|
return {
|
|
"repo_id": repo_id,
|
|
"state": state,
|
|
"accepted": True,
|
|
"generation": generation,
|
|
}
|
|
|
|
|
|
async def cancel_dataset_download_response(body: CancelDatasetDownloadRequest) -> dict:
|
|
"""Cancel an in-flight dataset download (SIGKILL; HF cache resumes on next download)."""
|
|
repo_id = body.repo_id.strip()
|
|
if not _is_valid_repo_id(repo_id):
|
|
raise HTTPException(
|
|
status_code = 400,
|
|
detail = f"Invalid repo_id: {repo_id!r}",
|
|
)
|
|
repo_id = await asyncio.to_thread(resolve_cached_repo_id_case, repo_id, repo_type = "dataset")
|
|
key = _download_job_key(repo_id)
|
|
|
|
state = download_lifecycle.cancel_worker(
|
|
_registry,
|
|
key,
|
|
generation = body.generation,
|
|
label = f"dataset {repo_id}",
|
|
logger = logger,
|
|
)
|
|
return {"repo_id": repo_id, "state": state}
|
|
|
|
|
|
async def get_dataset_download_status_response(repo_id: str) -> DatasetDownloadJobStatus:
|
|
"""Return the latest state of a background dataset download job."""
|
|
repo_id = repo_id.strip()
|
|
if not _is_valid_repo_id(repo_id):
|
|
return DatasetDownloadJobStatus(state = "idle")
|
|
repo_id = await asyncio.to_thread(resolve_cached_repo_id_case, repo_id, repo_type = "dataset")
|
|
return _dataset_status(_download_job_key(repo_id), repo_id = repo_id)
|
|
|
|
|
|
async def get_active_dataset_downloads_response(repo_id: str = "") -> ActiveDownloadsResponse:
|
|
repo_id = repo_id.strip()
|
|
if repo_id and not _is_valid_repo_id(repo_id):
|
|
return ActiveDownloadsResponse(downloads = [])
|
|
canonical_repo_id = (
|
|
await asyncio.to_thread(resolve_cached_repo_id_case, repo_id, repo_type = "dataset")
|
|
if repo_id
|
|
else None
|
|
)
|
|
return ActiveDownloadsResponse(
|
|
downloads = download_lifecycle.active_download_refs(
|
|
_registry,
|
|
canonical_repo_id,
|
|
with_variant = False,
|
|
)
|
|
)
|
|
|
|
|
|
async def get_dataset_transport_status_response(repo_id: str) -> dict:
|
|
"""Last transport used, whether partial blobs exist, and whether they
|
|
support byte-level resume. XET partials show via ``has_partial`` but are not
|
|
byte-level resumable (see ``models.get_model_transport_status``)."""
|
|
repo_id = repo_id.strip()
|
|
if not _is_valid_repo_id(repo_id):
|
|
return {"has_partial": False, "last_transport": None, "resumable": False}
|
|
return {
|
|
"has_partial": has_active_incomplete_blobs("dataset", repo_id),
|
|
"last_transport": download_registry.read_active_transport_marker("dataset", repo_id),
|
|
"resumable": download_registry.is_resumable_partial("dataset", repo_id),
|
|
}
|
|
|
|
|
|
registry = _registry
|