from __future__ import annotations
import logging
import os
import shutil
import tempfile
import uuid
from contextlib import contextmanager
from fcntl import LOCK_EX, LOCK_UN, flock
from pathlib import Path
from typing import Iterator, Mapping
from huggingface_hub.constants import HF_HUB_CACHE
from . import envs
logger = logging.getLogger("modelexpress.model_snapshot")
WEIGHT_FILE_SUFFIXES = (
".bin",
".safetensors",
".h5",
".msgpack",
".ckpt.index",
".iop",
".gas",
)
MAIN_REF = "main"
_LOCK_FILE = ".modelexpress.lock"
_STAGING_PREFIX = ".modelexpress-staging-"
_STALE_PREFIX = ".modelexpress-stale-"
_TEMP_PREFIX = ".modelexpress-tmp-"
_BACKUP_PREFIX = ".modelexpress-backup-"
class ModelSnapshotError(RuntimeError):
def is_weight_file(relative_path: str) -> bool:
return relative_path.endswith(WEIGHT_FILE_SUFFIXES)
def split_by_weight(paths) -> tuple[list[str], list[str]]:
metadata: list[str] = []
weights: list[str] = []
for path in paths:
(weights if is_weight_file(path) else metadata).append(path)
return metadata, weights
def safe_relative_path(relative_path: str) -> Path:
if (
not relative_path
or "\x00" in relative_path
or "\\" in relative_path
or relative_path.startswith("/")
):
raise ModelSnapshotError(f"Unsafe model file path: {relative_path!r}")
parts = relative_path.split("/")
if any(part in ("", ".", "..") for part in parts):
raise ModelSnapshotError(f"Unsafe model file path: {relative_path!r}")
return Path(*parts)
def safe_commit_hash(commit_hash: str) -> str:
if (
not commit_hash
or commit_hash in (".", "..")
or "\x00" in commit_hash
or "/" in commit_hash
or "\\" in commit_hash
):
raise ModelSnapshotError(f"Unsafe commit hash: {commit_hash!r}")
return commit_hash
def repo_dir_name(model_name: str) -> str:
if (
not model_name
or "\x00" in model_name
or "\\" in model_name
or model_name.startswith("/")
):
raise ValueError(f"Invalid Hugging Face model name: {model_name!r}")
parts = model_name.split("/")
if any(part in ("", ".", "..") for part in parts):
raise ValueError(f"Invalid Hugging Face model name: {model_name!r}")
return f"models--{'--'.join(parts)}"
def resolve_cache_root(explicit: str | os.PathLike[str] | None = None) -> Path:
if explicit is not None:
return Path(explicit).expanduser()
configured = envs.MODEL_EXPRESS_CACHE_DIRECTORY
if configured:
return Path(configured).expanduser()
return Path(HF_HUB_CACHE).expanduser()
def _fsync_directory(directory: Path) -> None:
descriptor = os.open(directory, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _is_contained(path: Path, root: Path) -> bool:
try:
return path.resolve().is_relative_to(root)
except OSError:
return False
def _ensure_directory(directory: Path, cache_root: Path) -> None:
if directory.is_symlink():
raise ModelSnapshotError(f"Refusing to use symlinked cache directory: {directory}")
directory.mkdir(parents=True, exist_ok=True)
if not _is_contained(directory, cache_root):
raise ModelSnapshotError(f"Cache directory resolves outside the cache root: {directory}")
class SnapshotSink:
def __init__(self, root: Path, cache_root: Path):
self._root = root
self._cache_root = cache_root
self._handle = None
self._target: Path | None = None
self._relative_path: str | None = None
@property
def current_file(self) -> str | None:
return self._relative_path
def begin_file(self, relative_path: str) -> None:
if self._handle is not None:
raise ModelSnapshotError(
f"Cannot start {relative_path!r} while {self._relative_path!r} is open"
)
target = self._root / safe_relative_path(relative_path)
target.parent.mkdir(parents=True, exist_ok=True)
if not _is_contained(target.parent, self._cache_root):
raise ModelSnapshotError(f"File path resolves outside the cache root: {target}")
self._target = target
self._relative_path = relative_path
self._handle = self._open(target)
def write(self, data: bytes) -> None:
if self._handle is None:
raise ModelSnapshotError("No model file is open for writing")
written = self._handle.write(data)
if written != len(data):
raise ModelSnapshotError(
f"Short local write for {self._relative_path!r}: "
f"wrote {written} of {len(data)} bytes"
)
def end_file(self) -> None:
if self._handle is None or self._target is None:
raise ModelSnapshotError("No model file is open for writing")
self._handle.flush()
os.fsync(self._handle.fileno())
self._handle.close()
self._handle = None
self._finalize(self._target)
self._target = None
self._relative_path = None
def close(self) -> None:
if self._handle is None:
return
self._handle.close()
self._handle = None
if self._target is not None:
self._discard(self._target)
self._target = None
self._relative_path = None
def _open(self, target: Path):
raise NotImplementedError
def _finalize(self, target: Path) -> None:
raise NotImplementedError
def _discard(self, target: Path) -> None:
raise NotImplementedError
class SnapshotStaging(SnapshotSink):
def __init__(self, cache: "ModelSnapshotCache"):
_ensure_directory(cache.repo_root, cache.cache_root)
staging_path = Path(
tempfile.mkdtemp(prefix=_STAGING_PREFIX, dir=cache.repo_root)
)
super().__init__(staging_path, cache.cache_root)
self._cache = cache
self._staging_path: Path | None = staging_path
@property
def path(self) -> Path:
if self._staging_path is None:
raise ModelSnapshotError("Staging directory has already been consumed")
return self._staging_path
def publish(
self,
commit_hash: str,
expected_files: Mapping[str, int],
requested_revision: str | None = None,
) -> Path:
staging_path = self.path
commit_hash = safe_commit_hash(commit_hash)
snapshots_root = self._cache.repo_root / "snapshots"
_ensure_directory(snapshots_root, self._cache.cache_root)
snapshot_path = snapshots_root / commit_hash
if self._cache.has_files(snapshot_path, expected_files):
logger.info(
"Snapshot %s already complete, discarding staged copy", snapshot_path
)
shutil.rmtree(staging_path, ignore_errors=True)
self._staging_path = None
self._cache.write_revision_ref(commit_hash, requested_revision)
return snapshot_path
if snapshot_path.is_dir() and not snapshot_path.is_symlink():
self._merge_into(snapshot_path)
self._cache.write_revision_ref(commit_hash, requested_revision)
self._staging_path = None
return snapshot_path
stale_path: Path | None = None
if snapshot_path.exists() or snapshot_path.is_symlink():
stale_path = self._cache.repo_root / f"{_STALE_PREFIX}{uuid.uuid4().hex}"
os.replace(snapshot_path, stale_path)
try:
os.replace(staging_path, snapshot_path)
_fsync_directory(snapshots_root)
self._cache.write_revision_ref(commit_hash, requested_revision)
except BaseException:
if stale_path is not None and not snapshot_path.exists():
os.replace(stale_path, snapshot_path)
raise
self._staging_path = None
if stale_path is not None:
try:
shutil.rmtree(stale_path)
except OSError:
logger.warning("Failed to clean up stale snapshot %s", stale_path)
return snapshot_path
def _merge_into(self, snapshot_path: Path) -> None:
staging_path = self.path
touched_dirs: set[Path] = set()
for source in sorted(staging_path.rglob("*")):
if source.is_dir():
continue
target = snapshot_path / source.relative_to(staging_path)
target.parent.mkdir(parents=True, exist_ok=True)
if not _is_contained(target.parent, self._cache.cache_root):
raise ModelSnapshotError(
f"Staged file resolves outside the cache root: {target}"
)
os.replace(source, target)
touched_dirs.add(target.parent)
for directory in touched_dirs:
_fsync_directory(directory)
shutil.rmtree(staging_path, ignore_errors=True)
def discard(self) -> None:
self.close()
if self._staging_path is not None:
shutil.rmtree(self._staging_path, ignore_errors=True)
self._staging_path = None
def _open(self, target: Path):
return target.open("xb")
def _finalize(self, target: Path) -> None:
return None
def _discard(self, target: Path) -> None:
target.unlink(missing_ok=True)
class SnapshotPatch(SnapshotSink):
def __init__(self, cache: "ModelSnapshotCache", snapshot_path: Path):
if not snapshot_path.is_dir():
raise ModelSnapshotError(f"Snapshot directory does not exist: {snapshot_path}")
if not _is_contained(snapshot_path, cache.cache_root):
raise ModelSnapshotError(
f"Snapshot resolves outside the cache root: {snapshot_path}"
)
super().__init__(snapshot_path, cache.cache_root)
self._temp_paths: dict[Path, Path] = {}
self._published: list[Path] = []
self._backups: dict[Path, Path] = {}
def commit(self) -> None:
touched: set[Path] = set()
for backup in self._backups.values():
backup.unlink(missing_ok=True)
touched.add(backup.parent)
self._backups.clear()
self._published.clear()
for directory in touched:
_fsync_directory(directory)
def rollback(self) -> None:
self.close()
touched: set[Path] = set()
while self._published:
target = self._published.pop()
target.unlink(missing_ok=True)
backup = self._backups.pop(target, None)
if backup is not None:
os.replace(backup, target)
touched.add(target.parent)
for target, backup in self._backups.items():
if target.exists():
backup.unlink(missing_ok=True)
else:
os.replace(backup, target)
touched.add(target.parent)
self._backups.clear()
for directory in touched:
_fsync_directory(directory)
def _open(self, target: Path):
temp_path = target.parent / f"{_TEMP_PREFIX}{uuid.uuid4().hex}-{target.name}"
self._temp_paths[target] = temp_path
return temp_path.open("xb")
def _finalize(self, target: Path) -> None:
temp_path = self._temp_paths.pop(target)
if target.exists() or target.is_symlink():
backup = target.parent / f"{_BACKUP_PREFIX}{uuid.uuid4().hex}-{target.name}"
os.replace(target, backup)
self._backups[target] = backup
os.replace(temp_path, target)
_fsync_directory(target.parent)
self._published.append(target)
def _discard(self, target: Path) -> None:
temp_path = self._temp_paths.pop(target, None)
if temp_path is not None:
temp_path.unlink(missing_ok=True)
class ModelSnapshotCache:
def __init__(
self,
model_name: str,
cache_root: str | os.PathLike[str] | None = None,
):
self.model_name = model_name
root = resolve_cache_root(cache_root)
root.mkdir(parents=True, exist_ok=True)
self.cache_root = root.resolve()
self.repo_root = self.cache_root / repo_dir_name(model_name)
@contextmanager
def lock(self) -> Iterator[None]:
_ensure_directory(self.repo_root, self.cache_root)
lock_path = self.repo_root / _LOCK_FILE
with lock_path.open("a", encoding="utf-8") as handle:
flock(handle.fileno(), LOCK_EX)
try:
yield
finally:
flock(handle.fileno(), LOCK_UN)
def snapshot_path(self, commit_hash: str) -> Path:
return self.repo_root / "snapshots" / safe_commit_hash(commit_hash)
def read_main_ref(self) -> str | None:
return self.read_ref(MAIN_REF)
def read_ref(self, ref_name: str) -> str | None:
try:
ref_path = self.repo_root / "refs" / safe_relative_path(ref_name)
except ModelSnapshotError:
return None
return self._read_ref_path(ref_path)
@staticmethod
def _read_ref_path(ref_path: Path) -> str | None:
if not ref_path.is_file() or ref_path.is_symlink():
return None
try:
return safe_commit_hash(ref_path.read_text(encoding="utf-8").strip())
except (OSError, UnicodeError, ModelSnapshotError):
return None
def write_main_ref(self, commit_hash: str) -> None:
self.write_ref(MAIN_REF, commit_hash)
def write_ref(self, ref_name: str, commit_hash: str) -> None:
commit_hash = safe_commit_hash(commit_hash)
refs_root = self.repo_root / "refs"
relative = safe_relative_path(ref_name)
ref_path = refs_root / relative
self._reject_ref_collision(refs_root, relative)
if self._read_ref_path(ref_path) == commit_hash:
return
_ensure_directory(ref_path.parent, self.cache_root)
temp_ref = ref_path.parent / f"{_TEMP_PREFIX}{uuid.uuid4().hex}"
try:
with temp_ref.open("x", encoding="utf-8") as ref_file:
ref_file.write(commit_hash)
ref_file.flush()
os.fsync(ref_file.fileno())
os.replace(temp_ref, ref_path)
_fsync_directory(ref_path.parent)
finally:
temp_ref.unlink(missing_ok=True)
@staticmethod
def _reject_ref_collision(refs_root: Path, relative: Path) -> None:
ancestor = refs_root
for part in relative.parts[:-1]:
ancestor = ancestor / part
if ancestor.exists() and not ancestor.is_dir():
raise ModelSnapshotError(
f"Ref name {str(relative)!r} needs {ancestor} to be a directory, "
"but another ref already holds that name"
)
target = refs_root / relative
if target.is_dir() and not target.is_symlink():
raise ModelSnapshotError(
f"Ref name {str(relative)!r} is already a directory holding other refs"
)
def write_revision_ref(self, commit_hash: str, requested_revision: str | None) -> None:
if requested_revision is None:
self.write_main_ref(commit_hash)
return
if requested_revision == commit_hash:
return
self.write_ref(requested_revision, commit_hash)
def has_files(self, snapshot_path: Path, expected_files: Mapping[str, int]) -> bool:
if not snapshot_path.is_dir():
return False
try:
if not _is_contained(snapshot_path, self.cache_root):
return False
for relative_path, expected_size in expected_files.items():
file_path = snapshot_path / safe_relative_path(relative_path)
if not file_path.is_file():
return False
if not _is_contained(file_path, self.cache_root):
return False
if file_path.stat().st_size != expected_size:
return False
except (OSError, ModelSnapshotError):
return False
return True
def resolve_pinned_snapshot(
self,
expected_files: Mapping[str, int],
commit_hash: str,
) -> Path | None:
snapshot_path = self.snapshot_path(commit_hash)
if self.has_files(snapshot_path, expected_files):
return snapshot_path
return None
def resolve_snapshot(
self,
expected_files: Mapping[str, int],
expected_commit: str | None,
) -> Path | None:
if expected_commit is None:
return None
commit_hash = self.read_main_ref()
if commit_hash is None or commit_hash != expected_commit:
return None
snapshot_path = self.snapshot_path(commit_hash)
if self.has_files(snapshot_path, expected_files):
return snapshot_path
return None
def staging(self) -> SnapshotStaging:
return SnapshotStaging(self)
def patch(self, snapshot_path: Path) -> SnapshotPatch:
return SnapshotPatch(self, snapshot_path)