SimpleChatbot / scripts /save_to_dataset_atomic.py
Amin
Add retry-with-backoff for dataset backup on transient 502s.
a1f20e5
Raw
History Blame Contribute Delete
15.1 kB
#!/usr/bin/env python3
"""Atomic Hugging Face Dataset persistence for HermesFace state.
The public helpers retain the legacy single-file API, while the production
sync path commits one validated secure-backup archive plus metadata in a single
Dataset commit.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import shutil
import sys
import tempfile
import time
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from typing import Any, Callable, Dict, List, Optional, TypeVar
from huggingface_hub import CommitOperationAdd, HfApi, hf_hub_download
from huggingface_hub.utils import HfHubHTTPError, RepositoryNotFoundError
T = TypeVar("T")
_RETRYABLE_STATUS = {408, 425, 429, 500, 502, 503, 504}
logging.basicConfig(
level=logging.INFO,
format='{"timestamp": "%(asctime)s", "level": "%(levelname)s", "module": "atomic-save", "message": "%(message)s"}',
)
logger = logging.getLogger(__name__)
def _is_retryable_hf_error(exc: Exception) -> bool:
"""True for transient Hugging Face Hub / network failures."""
if isinstance(exc, HfHubHTTPError):
response = getattr(exc, "response", None)
status = getattr(response, "status_code", None)
if status in _RETRYABLE_STATUS:
return True
message = str(exc).lower()
return any(token in message for token in ("502", "503", "504", "bad gateway", "gateway timeout"))
def _safe_dataset_path(value: str) -> str:
pure = PurePosixPath(str(value).replace("\\", "/"))
if pure.is_absolute() or not pure.parts or any(part in {"", ".", ".."} for part in pure.parts):
raise ValueError(f"unsafe dataset path: {value!r}")
return pure.as_posix()
def validate_backup_source_paths(source_paths: List[str], state_dir: Optional[str] = None) -> List[Path]:
"""Resolve and validate explicitly allowlisted backup source files."""
try:
from backup_allowlist import BackupAllowlistError, is_path_allowed, scan_file_for_secrets
except ImportError:
from scripts.backup_allowlist import ( # type: ignore
BackupAllowlistError,
is_path_allowed,
scan_file_for_secrets,
)
root = Path(state_dir or os.environ.get("HERMES_HOME", "/opt/data")).resolve()
validated: List[Path] = []
for raw in source_paths:
unresolved = Path(raw)
if unresolved.is_symlink():
raise BackupAllowlistError(f"Refusing symlink backup source: {unresolved.name}")
path = unresolved.resolve()
try:
relative = path.relative_to(root)
except ValueError as exc:
raise BackupAllowlistError(f"Refusing backup outside HERMES_HOME: {path.name}") from exc
if not path.is_file():
raise BackupAllowlistError(f"Refusing non-file backup source: {relative.as_posix()}")
if not is_path_allowed(relative.as_posix()):
raise BackupAllowlistError(f"Refusing non-allowlisted backup source: {relative.as_posix()}")
reason = scan_file_for_secrets(path)
if reason:
raise BackupAllowlistError(
f"Refusing backup: secret-like content in {relative.as_posix()} ({reason})"
)
validated.append(path)
return validated
class AtomicDatasetSaver:
def __init__(
self,
repo_id: str,
dataset_path: str = "state",
*,
api: Optional[HfApi] = None,
token: Optional[str] = None,
):
self.repo_id = repo_id
self.dataset_path = _safe_dataset_path(dataset_path)
self.token = token
self.api = api or HfApi(token=token)
self.max_retries = 3
self.base_delay = 1.0
self.max_backups = 3
logger.info(f"init repo_id={repo_id} dataset_path={self.dataset_path}")
def _call_with_retry(self, label: str, fn: Callable[[], T]) -> T:
"""Retry transient Hub API failures with bounded exponential backoff."""
attempt = 0
while True:
try:
return fn()
except Exception as exc:
attempt += 1
if attempt > self.max_retries or not _is_retryable_hf_error(exc):
raise
delay = min(30.0, self.base_delay * (2 ** (attempt - 1)))
logger.warning(
f"{label}_retry attempt={attempt}/{self.max_retries} "
f"delay_s={delay:.1f} error={type(exc).__name__}"
)
time.sleep(delay)
def calculate_checksum(self, file_path: Path) -> str:
digest = hashlib.sha256()
with file_path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _repo_info(self):
try:
return self._call_with_retry(
"repo_info",
lambda: self.api.repo_info(repo_id=self.repo_id, repo_type="dataset"),
)
except RepositoryNotFoundError:
return None
def create_backup(self, current_commit: Optional[str]) -> Optional[str]:
"""Copy the current authoritative state files to a timestamped remote backup."""
if not current_commit:
return None
try:
files = self._call_with_retry(
"list_repo_files",
lambda: self.api.list_repo_files(
repo_id=self.repo_id, repo_type="dataset", revision=current_commit
),
)
prefix = f"{self.dataset_path}/"
state_files = [path for path in files if path.startswith(prefix)]
if not state_files:
return None
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
backup_path = f"backups/state_{timestamp}"
logger.info(f"creating_backup path={backup_path} files={len(state_files)}")
with tempfile.TemporaryDirectory(prefix="hermes-hf-backup-") as tmpdir:
root = Path(tmpdir)
operations = []
for remote_path in state_files:
local = self._call_with_retry(
"hf_hub_download",
lambda remote_path=remote_path: hf_hub_download(
repo_id=self.repo_id,
repo_type="dataset",
filename=remote_path,
revision=current_commit,
token=self.token,
),
)
relative = PurePosixPath(remote_path).relative_to(PurePosixPath(self.dataset_path))
destination = root.joinpath(*relative.parts)
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(local, destination)
operations.append(
CommitOperationAdd(
path_in_repo=f"{backup_path}/{relative.as_posix()}",
path_or_fileobj=str(destination),
)
)
if not operations:
return None
info = self._call_with_retry(
"create_backup_commit",
lambda: self.api.create_commit(
repo_id=self.repo_id,
repo_type="dataset",
operations=operations,
commit_message=f"Backup state before update - {timestamp}",
parent_commit=current_commit,
),
)
logger.info(f"backup_created commit={info.oid}")
return info.oid
except Exception as exc:
# A failed remote backup must prevent replacing the current remote state.
logger.error(f"backup_failed error={exc}")
raise
def save_secure_archive_atomic(
self,
archive_path: Path,
state_data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Validate and atomically commit one secure backup archive and metadata."""
try:
from secure_backup import extract_validated
except ImportError:
from scripts.secure_backup import extract_validated # type: ignore
archive = Path(archive_path)
if archive.is_symlink() or not archive.is_file():
raise ValueError("secure backup archive must be a regular file")
# Validate member paths, member types, manifest, hashes, and SQLite before
# any Hugging Face API call is permitted.
with tempfile.TemporaryDirectory(prefix="hermes-archive-validate-") as tmpdir:
manifest = extract_validated(archive, Path(tmpdir) / "validated")
archive_sha256 = self.calculate_checksum(archive)
operation_id = f"save_{int(time.time())}"
repo_info = self._repo_info()
current_commit = getattr(repo_info, "sha", None) if repo_info else None
backup_commit = self.create_backup(current_commit)
parent_commit = backup_commit or current_commit
metadata = {
"format": "hermes-secure-backup",
"format_version": 1,
"timestamp": datetime.now(timezone.utc).isoformat(),
"operation_id": operation_id,
"archive": "secure_state.tar.gz",
"archive_sha256": archive_sha256,
"manifest_sha256": hashlib.sha256(
json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest(),
"manifest": manifest,
"backup_commit": backup_commit,
"state_data": state_data or {},
}
with tempfile.TemporaryDirectory(prefix="hermes-hf-save-") as tmpdir:
metadata_path = Path(tmpdir) / "metadata.json"
metadata_path.write_text(
json.dumps(metadata, sort_keys=True, separators=(",", ":")),
encoding="utf-8",
)
operations = [
CommitOperationAdd(
path_in_repo=f"{self.dataset_path}/secure_state.tar.gz",
path_or_fileobj=str(archive),
),
CommitOperationAdd(
path_in_repo=f"{self.dataset_path}/metadata.json",
path_or_fileobj=str(metadata_path),
),
]
info = self._call_with_retry(
"create_commit",
lambda: self.api.create_commit(
repo_id=self.repo_id,
repo_type="dataset",
operations=operations,
commit_message=f"Atomic secure state update - {operation_id}",
parent_commit=parent_commit,
),
)
result = {
"success": True,
"operation_id": operation_id,
"commit_id": info.oid,
"backup_commit": backup_commit,
"timestamp": metadata["timestamp"],
"archive_sha256": archive_sha256,
"files_count": len(manifest.get("files", [])),
}
logger.info(f"atomic_secure_save_completed {result}")
return result
def save_state_atomic(
self, state_data: Dict[str, Any], source_paths: List[str]
) -> Dict[str, Any]:
"""Legacy allowlisted individual-file commit retained for compatibility."""
operation_id = f"save_{int(time.time())}"
logger.info(f"starting_atomic_save op={operation_id} sources={source_paths}")
validated_sources = validate_backup_source_paths(source_paths)
repo_info = self._repo_info()
current_commit = getattr(repo_info, "sha", None) if repo_info else None
backup_commit = self.create_backup(current_commit)
parent_commit = backup_commit or current_commit
with tempfile.TemporaryDirectory(prefix="hermes-state-save-") as tmpdir:
state_dir = Path(tmpdir) / "state"
state_dir.mkdir(parents=True, exist_ok=True)
metadata = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"operation_id": operation_id,
"checksum": hashlib.sha256(
json.dumps(state_data, sort_keys=True).encode("utf-8")
).hexdigest(),
"backup_commit": backup_commit,
"state_data": state_data,
}
metadata_path = state_dir / "metadata.json"
metadata_path.write_text(json.dumps(metadata, indent=2), encoding="utf-8")
operations = [
CommitOperationAdd(
path_in_repo=f"{self.dataset_path}/metadata.json",
path_or_fileobj=str(metadata_path),
)
]
for source in validated_sources:
destination = state_dir / source.name
shutil.copy2(source, destination)
operations.append(
CommitOperationAdd(
path_in_repo=f"{self.dataset_path}/{source.name}",
path_or_fileobj=str(destination),
)
)
info = self._call_with_retry(
"create_commit",
lambda: self.api.create_commit(
repo_id=self.repo_id,
repo_type="dataset",
operations=operations,
commit_message=f"Atomic state update - {operation_id}",
parent_commit=parent_commit,
),
)
return {
"success": True,
"operation_id": operation_id,
"commit_id": info.oid,
"backup_commit": backup_commit,
"timestamp": metadata["timestamp"],
"files_count": len(validated_sources),
}
def main() -> None:
if len(sys.argv) < 3:
print(json.dumps({
"error": "Usage: python save_to_dataset_atomic.py <repo_id> <source_path1> [source_path2...]",
"status": "error",
}, indent=2))
sys.exit(1)
repo_id = sys.argv[1]
source_paths = sys.argv[2:]
for path in source_paths:
if not os.path.exists(path):
print(json.dumps({"error": f"Source path does not exist: {path}", "status": "error"}, indent=2))
sys.exit(1)
state_data = {
"environment": "production",
"version": "1.0.0",
"platform": "huggingface-spaces",
"app": "hermesface",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
try:
result = AtomicDatasetSaver(repo_id).save_state_atomic(state_data, source_paths)
print(json.dumps(result, indent=2))
except Exception as exc:
print(json.dumps({"error": str(exc), "status": "error"}, indent=2))
sys.exit(1)
if __name__ == "__main__":
main()