947 lines
34 KiB
Python
947 lines
34 KiB
Python
"""Asset synchronization helpers for Modal-backed execution."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from contextlib import AbstractContextManager
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Iterable
|
|
|
|
if __package__:
|
|
from . import sync_backends as _sync_backends
|
|
from .huggingface_assets import HuggingFaceAssetRegistry, HuggingFaceAssetSource
|
|
from .huggingface_discovery import HuggingFaceAssetDiscovery
|
|
from .r2_cache import (
|
|
R2CacheClient,
|
|
R2CacheError,
|
|
R2DownloadRequest,
|
|
R2UploadPlan,
|
|
R2UploadResult,
|
|
R2WorkerPreflightRequest,
|
|
)
|
|
from .settings import ModalSyncSettings, get_settings
|
|
from .sync_backends import (
|
|
LocalFileSyncIndex,
|
|
LocalMirrorVolume,
|
|
ModalDictSyncIndex,
|
|
ModalVolumeBackend,
|
|
_ModalSdkCaller,
|
|
_modal_volume_worker_count,
|
|
)
|
|
from .sync_custom_nodes import CustomNodesSynchronizer, MODEL_FILE_EXTENSIONS
|
|
from .sync_hashing import SyncHasher, _SKIP_DIRS, _SKIP_FILE_SUFFIXES
|
|
from .sync_protocols import (
|
|
AssetSyncRequestCache,
|
|
CancellationCheck,
|
|
CancellableHuggingFaceMaterializingBackend,
|
|
CancellableR2WriteBackBackend,
|
|
CancellableVolumeBackend,
|
|
HuggingFaceMaterializingBackend,
|
|
R2MaterializingBackend,
|
|
R2WorkerPreflightBackend,
|
|
SyncCancelledError,
|
|
SyncIndexBackend,
|
|
SyncedAsset,
|
|
SyncStatusCallback,
|
|
VolumeBackend,
|
|
_ContentAddressedSyncResult,
|
|
_ContentAddressedSyncSpec,
|
|
_CustomNodeAssetSpec,
|
|
_CustomNodeAssetSyncResult,
|
|
_CustomNodesArchiveSpec,
|
|
_CustomNodesArchiveSyncResult,
|
|
_R2MaterializationOutcome,
|
|
)
|
|
from .sync_r2_transfer import (
|
|
R2TransferManager,
|
|
R2WriteBackCancelled,
|
|
R2WriteBackCoordinator,
|
|
_R2_WRITE_BACK_COORDINATOR,
|
|
begin_r2_writeback_prompt,
|
|
finish_r2_writeback_prompt,
|
|
)
|
|
else: # pragma: no cover - flat import inside the Modal container.
|
|
import sync_backends as _sync_backends
|
|
from huggingface_assets import HuggingFaceAssetRegistry, HuggingFaceAssetSource
|
|
from huggingface_discovery import HuggingFaceAssetDiscovery
|
|
from r2_cache import (
|
|
R2CacheClient,
|
|
R2CacheError,
|
|
R2DownloadRequest,
|
|
R2UploadPlan,
|
|
R2UploadResult,
|
|
R2WorkerPreflightRequest,
|
|
)
|
|
from settings import ModalSyncSettings, get_settings
|
|
from sync_backends import (
|
|
LocalFileSyncIndex,
|
|
LocalMirrorVolume,
|
|
ModalDictSyncIndex,
|
|
ModalVolumeBackend,
|
|
_ModalSdkCaller,
|
|
_modal_volume_worker_count,
|
|
)
|
|
from sync_custom_nodes import CustomNodesSynchronizer, MODEL_FILE_EXTENSIONS
|
|
from sync_hashing import SyncHasher, _SKIP_DIRS, _SKIP_FILE_SUFFIXES
|
|
from sync_protocols import (
|
|
AssetSyncRequestCache,
|
|
CancellationCheck,
|
|
CancellableHuggingFaceMaterializingBackend,
|
|
CancellableR2WriteBackBackend,
|
|
CancellableVolumeBackend,
|
|
HuggingFaceMaterializingBackend,
|
|
R2MaterializingBackend,
|
|
R2WorkerPreflightBackend,
|
|
SyncCancelledError,
|
|
SyncIndexBackend,
|
|
SyncedAsset,
|
|
SyncStatusCallback,
|
|
VolumeBackend,
|
|
_ContentAddressedSyncResult,
|
|
_ContentAddressedSyncSpec,
|
|
_CustomNodeAssetSpec,
|
|
_CustomNodeAssetSyncResult,
|
|
_CustomNodesArchiveSpec,
|
|
_CustomNodesArchiveSyncResult,
|
|
_R2MaterializationOutcome,
|
|
)
|
|
from sync_r2_transfer import (
|
|
R2TransferManager,
|
|
R2WriteBackCancelled,
|
|
R2WriteBackCoordinator,
|
|
_R2_WRITE_BACK_COORDINATOR,
|
|
begin_r2_writeback_prompt,
|
|
finish_r2_writeback_prompt,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
_SYNC_EXTENSIONS = frozenset({".safetensors", ".ckpt", ".gguf", ".pt", ".vae"})
|
|
def resolve_model_path(
|
|
value: str,
|
|
*,
|
|
comfyui_root: Path | None = None,
|
|
extensions: frozenset[str] = _SYNC_EXTENSIONS,
|
|
) -> Path | None:
|
|
"""Resolve one prompt string to a local model file when possible."""
|
|
path = Path(value).expanduser()
|
|
if path.suffix.lower() not in extensions:
|
|
return None
|
|
if path.is_file():
|
|
return path.absolute()
|
|
if os.path.isabs(value):
|
|
return None
|
|
|
|
try:
|
|
import folder_paths
|
|
except ModuleNotFoundError:
|
|
folder_paths = None
|
|
|
|
if folder_paths is not None:
|
|
for folder_name in folder_paths.folder_names_and_paths:
|
|
full_path = folder_paths.get_full_path(folder_name, value)
|
|
if full_path is not None:
|
|
return Path(full_path).absolute()
|
|
|
|
if comfyui_root is not None:
|
|
candidate = comfyui_root / value
|
|
if candidate.is_file():
|
|
return candidate.absolute()
|
|
return None
|
|
|
|
|
|
def _emit_sync_status(
|
|
status_callback: SyncStatusCallback | None,
|
|
message: str,
|
|
current: int | None = None,
|
|
total: int | None = None,
|
|
) -> None:
|
|
"""Emit one human-readable sync status update when a callback is available."""
|
|
if status_callback is None:
|
|
return
|
|
status_callback(message, current, total)
|
|
|
|
|
|
def _format_asset_upload_status(
|
|
asset_name: str,
|
|
*,
|
|
item_index: int | None,
|
|
total_items: int | None,
|
|
destination: str = "Modal",
|
|
) -> str:
|
|
"""Return the global-status message for one asset upload."""
|
|
if item_index is not None and total_items is not None and total_items > 1:
|
|
return f"Uploading asset {item_index}/{total_items} to {destination}: {asset_name}"
|
|
return f"Uploading asset to {destination}: {asset_name}"
|
|
|
|
|
|
def _format_huggingface_download_status(
|
|
asset_name: str,
|
|
*,
|
|
item_index: int | None,
|
|
total_items: int | None,
|
|
) -> str:
|
|
"""Return one queue-time status for direct Hugging Face acquisition on Vast."""
|
|
if item_index is not None and total_items is not None and total_items > 1:
|
|
return (
|
|
f"Downloading asset {item_index}/{total_items} from Hugging Face on "
|
|
f"Vast.ai: {asset_name}"
|
|
)
|
|
return f"Downloading asset from Hugging Face on Vast.ai: {asset_name}"
|
|
|
|
|
|
def _format_huggingface_discovery_status(
|
|
asset_name: str,
|
|
*,
|
|
item_index: int | None,
|
|
total_items: int | None,
|
|
) -> str:
|
|
"""Return one queue-time status for automatic Hugging Face source discovery."""
|
|
if item_index is not None and total_items is not None and total_items > 1:
|
|
return (
|
|
f"Identifying Hugging Face source {item_index}/{total_items} for "
|
|
f"Vast.ai: {asset_name}"
|
|
)
|
|
return f"Identifying Hugging Face source for Vast.ai: {asset_name}"
|
|
|
|
|
|
@dataclass
|
|
class ModalAssetSyncEngine:
|
|
"""Content-addressable storage sync engine for files and custom nodes."""
|
|
|
|
volume: VolumeBackend
|
|
settings: ModalSyncSettings
|
|
sync_index: SyncIndexBackend | None = None
|
|
huggingface_asset_registry: HuggingFaceAssetRegistry | None = None
|
|
huggingface_asset_discovery: HuggingFaceAssetDiscovery | None = None
|
|
r2_cache: R2CacheClient | None = None
|
|
cancellation_check: CancellationCheck | None = None
|
|
r2_writeback_activity: Callable[[], AbstractContextManager[None]] | None = None
|
|
_hasher: SyncHasher = field(init=False)
|
|
_custom_nodes: CustomNodesSynchronizer = field(init=False)
|
|
_r2_transfer: R2TransferManager = field(init=False)
|
|
_path_resolution_cache: dict[str, str | None] = field(init=False, default_factory=dict)
|
|
_sync_scope_prefix_cache: str | None = field(init=False, default=None)
|
|
_sync_scope_prefix_lock: threading.Lock = field(init=False, default_factory=threading.Lock)
|
|
|
|
def __post_init__(self) -> None:
|
|
"""Load persistent metadata caches used to avoid repeated hashing work."""
|
|
if self.sync_index is None:
|
|
self.sync_index = LocalFileSyncIndex(self.settings.local_storage_root)
|
|
self._hasher = SyncHasher(
|
|
settings=self.settings,
|
|
cancellation_check=self.cancellation_check,
|
|
)
|
|
self._custom_nodes = CustomNodesSynchronizer(self)
|
|
self._r2_transfer = R2TransferManager(
|
|
self,
|
|
volume=self.volume,
|
|
r2_cache=self.r2_cache,
|
|
cancellation_check=self.cancellation_check,
|
|
r2_writeback_activity=self.r2_writeback_activity,
|
|
)
|
|
|
|
def _hash_file(self, path: Path) -> str:
|
|
"""Return the cached content digest for one file."""
|
|
return self._hasher._hash_file(path)
|
|
|
|
def _hash_directory(self, path: Path) -> str:
|
|
"""Return the cached stable digest for one directory tree."""
|
|
return self._hasher._hash_directory(path)
|
|
|
|
def _hash_file_group(self, root: Path, files: list[Path]) -> str:
|
|
"""Return a stable digest for a selected file group."""
|
|
return self._hasher._hash_file_group(root, files)
|
|
|
|
def _iter_files(self, path: Path) -> list[Path]:
|
|
"""Return syncable files below a directory."""
|
|
return self._hasher._iter_files(path)
|
|
|
|
def _raise_if_cancelled(self) -> None:
|
|
"""Raise when queue-time synchronization has been cancelled."""
|
|
self._hasher._raise_if_cancelled()
|
|
|
|
def _destination_label(self) -> str:
|
|
"""Return a user-facing destination name for sync progress messages."""
|
|
if self.settings.execution_mode == "ssh_docker":
|
|
return "the self-hosted worker"
|
|
if self.settings.execution_mode == "vast":
|
|
return "the Vast.ai instance"
|
|
return "Modal"
|
|
|
|
def preflight_r2_access(
|
|
self,
|
|
*,
|
|
status_callback: SyncStatusCallback | None = None,
|
|
) -> None:
|
|
"""Validate effective worker-side R2 access before transfer."""
|
|
self._r2_transfer.preflight_r2_access(status_callback=status_callback)
|
|
|
|
def _materialize_r2_source(
|
|
self,
|
|
spec: _ContentAddressedSyncSpec,
|
|
size_bytes: int,
|
|
) -> _R2MaterializationOutcome:
|
|
"""Delegate worker-side R2 read-through materialization."""
|
|
return self._r2_transfer._materialize_r2_source(spec, size_bytes)
|
|
|
|
def _schedule_r2_writeback(
|
|
self,
|
|
*,
|
|
sha256: str,
|
|
size_bytes: int,
|
|
remote_path: str,
|
|
force: bool = False,
|
|
) -> None:
|
|
"""Schedule one idle-gated R2 cache population job."""
|
|
self._r2_transfer._schedule_r2_writeback(
|
|
sha256=sha256,
|
|
size_bytes=size_bytes,
|
|
remote_path=remote_path,
|
|
force=force,
|
|
)
|
|
|
|
def _r2_writeback_job_prefix(self) -> tuple[str, str, str]:
|
|
"""Return the write-back deduplication namespace."""
|
|
return self._r2_transfer._r2_writeback_job_prefix()
|
|
|
|
def _r2_writeback_enabled(self) -> bool:
|
|
"""Return whether R2 cache population is configured."""
|
|
return self._r2_transfer._r2_writeback_enabled()
|
|
|
|
def wait_for_r2_writebacks(self) -> None:
|
|
"""Wait for this process's queued R2 write-backs to finish."""
|
|
self._r2_transfer.wait_for_r2_writebacks()
|
|
|
|
|
|
@classmethod
|
|
def from_environment(cls, settings: ModalSyncSettings | None = None) -> "ModalAssetSyncEngine":
|
|
"""Create a sync engine using the local mirror backend by default."""
|
|
resolved_settings = settings or get_settings()
|
|
backend: VolumeBackend
|
|
sync_index: SyncIndexBackend
|
|
if resolved_settings.execution_mode == "remote" and _sync_backends.modal is not None:
|
|
logger.info(
|
|
"Using Modal volume backend %s and Modal sync index %s for remote asset sync.",
|
|
resolved_settings.volume_name,
|
|
resolved_settings.sync_index_dict_name,
|
|
)
|
|
backend = ModalVolumeBackend(resolved_settings.volume_name)
|
|
sync_index = ModalDictSyncIndex(resolved_settings.sync_index_dict_name)
|
|
else:
|
|
if resolved_settings.execution_mode == "remote" and _sync_backends.modal is None:
|
|
logger.warning(
|
|
"Modal SDK is unavailable in remote execution mode; falling back to local mirror storage."
|
|
)
|
|
backend = LocalMirrorVolume(resolved_settings.local_storage_root)
|
|
sync_index = LocalFileSyncIndex(resolved_settings.local_storage_root)
|
|
return cls(volume=backend, settings=resolved_settings, sync_index=sync_index)
|
|
|
|
def sync_file(
|
|
self,
|
|
local_path: Path,
|
|
remote_folder: str = "/assets",
|
|
*,
|
|
status_callback: SyncStatusCallback | None = None,
|
|
item_index: int | None = None,
|
|
total_items: int | None = None,
|
|
) -> SyncedAsset:
|
|
"""Sync a file into content-addressable remote storage."""
|
|
self._raise_if_cancelled()
|
|
source_path = local_path.expanduser().absolute()
|
|
if not source_path.is_file():
|
|
raise FileNotFoundError(f"Asset not found: {source_path}")
|
|
resolved_path = source_path.resolve()
|
|
|
|
sha256 = self._hash_file(resolved_path)
|
|
proposed_remote_path = f"{remote_folder.rstrip('/')}/{sha256}_{source_path.name}"
|
|
sync_key = self._asset_sync_index_key(sha256)
|
|
huggingface_source = (
|
|
self._huggingface_source_for_asset(
|
|
source_path,
|
|
sha256=sha256,
|
|
status_callback=status_callback,
|
|
item_index=item_index,
|
|
total_items=total_items,
|
|
)
|
|
if self._lookup_sync_record(sync_key) is None
|
|
else None
|
|
)
|
|
sync_result = self._sync_content_addressed_file(
|
|
local_path=source_path,
|
|
remote_path=proposed_remote_path,
|
|
sha256=sha256,
|
|
sync_key=sync_key,
|
|
source_description=str(source_path),
|
|
status_callback=status_callback,
|
|
upload_status_message=_format_asset_upload_status(
|
|
source_path.name,
|
|
item_index=item_index,
|
|
total_items=total_items,
|
|
destination=self._destination_label(),
|
|
),
|
|
status_current=item_index,
|
|
status_total=total_items,
|
|
huggingface_source=huggingface_source,
|
|
)
|
|
|
|
return SyncedAsset(
|
|
local_path=source_path,
|
|
remote_path=sync_result.remote_path,
|
|
sha256=sha256,
|
|
uploaded=sync_result.uploaded,
|
|
)
|
|
|
|
def sync_prompt_inputs(
|
|
self,
|
|
inputs: dict[str, Any],
|
|
*,
|
|
status_callback: SyncStatusCallback | None = None,
|
|
request_cache: AssetSyncRequestCache | None = None,
|
|
) -> tuple[dict[str, Any], list[SyncedAsset]]:
|
|
"""Rewrite file-like prompt inputs to mirrored storage paths."""
|
|
self._raise_if_cancelled()
|
|
synced_assets: list[SyncedAsset] = []
|
|
sync_started_at = time.perf_counter()
|
|
logger.info("Scanning prompt inputs for syncable assets.")
|
|
syncable_asset_paths = self._collect_syncable_asset_paths(inputs)
|
|
syncable_asset_index = 0
|
|
|
|
def rewrite(value: Any) -> Any:
|
|
nonlocal syncable_asset_index
|
|
self._raise_if_cancelled()
|
|
if isinstance(value, str):
|
|
maybe_path = self._resolve_model_path(value)
|
|
if maybe_path is not None:
|
|
cached_asset = request_cache.get(maybe_path) if request_cache is not None else None
|
|
if cached_asset is not None:
|
|
synced_assets.append(cached_asset)
|
|
return cached_asset.remote_path
|
|
if request_cache is None:
|
|
syncable_asset_index += 1
|
|
item_index = syncable_asset_index
|
|
total_items = len(syncable_asset_paths)
|
|
else:
|
|
item_index, total_items = request_cache.progress(maybe_path)
|
|
synced_asset = self.sync_file(
|
|
maybe_path,
|
|
status_callback=status_callback,
|
|
item_index=item_index,
|
|
total_items=total_items,
|
|
)
|
|
if request_cache is not None:
|
|
request_cache.put(synced_asset)
|
|
synced_assets.append(synced_asset)
|
|
return synced_asset.remote_path
|
|
return value
|
|
if isinstance(value, list):
|
|
return [rewrite(item) for item in value]
|
|
if isinstance(value, dict):
|
|
return {str(key): rewrite(item) for key, item in value.items()}
|
|
return value
|
|
|
|
rewritten_inputs = rewrite(inputs)
|
|
logger.info(
|
|
"Finished scanning prompt inputs in %.3fs with %d synced assets.",
|
|
time.perf_counter() - sync_started_at,
|
|
len(synced_assets),
|
|
)
|
|
return rewritten_inputs, synced_assets
|
|
|
|
def create_request_asset_cache(
|
|
self,
|
|
prompt_input_values: Iterable[Any],
|
|
) -> AssetSyncRequestCache:
|
|
"""Plan unique syncable assets for one queued prompt in stable encounter order."""
|
|
unique_paths: dict[Path, Path] = {}
|
|
for prompt_input_value in prompt_input_values:
|
|
for local_path in self._collect_syncable_asset_paths(prompt_input_value):
|
|
unique_paths.setdefault(local_path.resolve(), local_path.absolute())
|
|
return AssetSyncRequestCache(planned_paths=tuple(unique_paths.values()))
|
|
|
|
|
|
|
|
|
|
|
|
def sync_custom_nodes_directory(
|
|
self,
|
|
*,
|
|
status_callback: SyncStatusCallback | None = None,
|
|
) -> SyncedAsset | None:
|
|
"""Synchronize custom nodes through the dedicated collaborator."""
|
|
return self._custom_nodes.sync_custom_nodes_directory(
|
|
status_callback=status_callback
|
|
)
|
|
|
|
def _sync_custom_nodes_directory_uncached(
|
|
self,
|
|
*,
|
|
status_callback: SyncStatusCallback | None = None,
|
|
revisit_indexed_for_r2: bool = False,
|
|
writeback_cancellation_check: CancellationCheck | None = None,
|
|
) -> SyncedAsset | None:
|
|
"""Delegate an uncached custom-node synchronization pass."""
|
|
return self._custom_nodes._sync_custom_nodes_directory_uncached(
|
|
status_callback=status_callback,
|
|
revisit_indexed_for_r2=revisit_indexed_for_r2,
|
|
writeback_cancellation_check=writeback_cancellation_check,
|
|
)
|
|
|
|
def _custom_nodes_archive_specs(
|
|
self,
|
|
custom_nodes_dir: Path,
|
|
) -> list[_CustomNodesArchiveSpec]:
|
|
"""Return deterministic custom-node archive specifications."""
|
|
return self._custom_nodes._custom_nodes_archive_specs(custom_nodes_dir)
|
|
|
|
def _cached_custom_nodes_archive_path(
|
|
self,
|
|
entry_name: str,
|
|
entry_hash: str,
|
|
) -> Path:
|
|
"""Return the cached archive path for one custom-node entry."""
|
|
return self._custom_nodes._cached_custom_nodes_archive_path(
|
|
entry_name,
|
|
entry_hash,
|
|
)
|
|
|
|
def _custom_nodes_manifest_remote_path(self, directory_hash: str) -> str:
|
|
"""Return the remote custom-node manifest path."""
|
|
return self._custom_nodes._custom_nodes_manifest_remote_path(directory_hash)
|
|
|
|
def _custom_nodes_manifest_sync_index_key(self, directory_hash: str) -> str:
|
|
"""Return the scoped custom-node manifest index key."""
|
|
return self._custom_nodes._custom_nodes_manifest_sync_index_key(directory_hash)
|
|
|
|
def _custom_nodes_archive_remote_path(
|
|
self,
|
|
entry_name: str,
|
|
entry_hash: str,
|
|
) -> str:
|
|
"""Return the remote path for one custom-node archive."""
|
|
return self._custom_nodes._custom_nodes_archive_remote_path(
|
|
entry_name,
|
|
entry_hash,
|
|
)
|
|
|
|
def _create_archive_from_files(
|
|
self,
|
|
root_path: Path,
|
|
files: list[Path],
|
|
archive_path: Path,
|
|
) -> Path:
|
|
"""Create one deterministic custom-node archive."""
|
|
return self._custom_nodes._create_archive_from_files(
|
|
root_path,
|
|
files,
|
|
archive_path,
|
|
)
|
|
|
|
def _sync_custom_nodes_archive_spec(
|
|
self,
|
|
custom_nodes_dir: Path,
|
|
spec: _CustomNodesArchiveSpec,
|
|
) -> _CustomNodesArchiveSyncResult:
|
|
"""Synchronize one custom-node archive specification."""
|
|
return self._custom_nodes._sync_custom_nodes_archive_spec(
|
|
custom_nodes_dir,
|
|
spec,
|
|
)
|
|
|
|
def _sync_content_addressed_file(
|
|
self,
|
|
*,
|
|
local_path: Path,
|
|
remote_path: str,
|
|
sha256: str,
|
|
sync_key: str,
|
|
source_description: str,
|
|
status_callback: SyncStatusCallback | None = None,
|
|
upload_status_message: str | None = None,
|
|
status_current: int | None = None,
|
|
status_total: int | None = None,
|
|
huggingface_source: HuggingFaceAssetSource | None = None,
|
|
) -> _ContentAddressedSyncResult:
|
|
"""Materialize one deterministic file from the fastest available source."""
|
|
self._raise_if_cancelled()
|
|
spec = _ContentAddressedSyncSpec(
|
|
local_path=local_path,
|
|
remote_path=remote_path,
|
|
sha256=sha256,
|
|
sync_key=sync_key,
|
|
source_description=source_description,
|
|
status_callback=status_callback,
|
|
upload_status_message=upload_status_message,
|
|
status_current=status_current,
|
|
status_total=status_total,
|
|
huggingface_source=huggingface_source,
|
|
)
|
|
size_bytes = spec.local_path.stat().st_size
|
|
existing_record = self._lookup_sync_record(spec.sync_key)
|
|
if existing_record is not None:
|
|
indexed_remote_path = str(existing_record["remote_path"])
|
|
self._schedule_r2_writeback(
|
|
sha256=spec.sha256,
|
|
size_bytes=size_bytes,
|
|
remote_path=indexed_remote_path,
|
|
)
|
|
logger.info(
|
|
"Reusing mirrored asset at %s because sync index key %s already exists.",
|
|
indexed_remote_path,
|
|
spec.sync_key,
|
|
)
|
|
return _ContentAddressedSyncResult(
|
|
remote_path=indexed_remote_path,
|
|
uploaded=False,
|
|
)
|
|
|
|
adopted = self._adopt_existing_remote(spec, size_bytes)
|
|
if adopted is not None:
|
|
return adopted
|
|
logger.info("Syncing %s to %s", spec.source_description, spec.remote_path)
|
|
huggingface_result = self._materialize_huggingface_source(spec, size_bytes)
|
|
if huggingface_result is not None:
|
|
return huggingface_result
|
|
r2_outcome = self._materialize_r2_source(spec, size_bytes)
|
|
if r2_outcome.result is not None:
|
|
return r2_outcome.result
|
|
return self._upload_content_addressed_file(
|
|
spec,
|
|
size_bytes,
|
|
force_r2_writeback=r2_outcome.refresh_required,
|
|
)
|
|
|
|
def _adopt_existing_remote(
|
|
self,
|
|
spec: _ContentAddressedSyncSpec,
|
|
size_bytes: int,
|
|
) -> _ContentAddressedSyncResult | None:
|
|
"""Repair a lost local index from an existing persistent remote path."""
|
|
if not self.volume.exists(spec.remote_path):
|
|
return None
|
|
self._store_sync_record(
|
|
sync_key=spec.sync_key,
|
|
remote_path=spec.remote_path,
|
|
source_description=spec.source_description,
|
|
)
|
|
self._schedule_r2_writeback(
|
|
sha256=spec.sha256,
|
|
size_bytes=size_bytes,
|
|
remote_path=spec.remote_path,
|
|
)
|
|
logger.info("Adopted existing content-addressed remote file at %s.", spec.remote_path)
|
|
return _ContentAddressedSyncResult(remote_path=spec.remote_path, uploaded=False)
|
|
|
|
def _materialize_huggingface_source(
|
|
self,
|
|
spec: _ContentAddressedSyncSpec,
|
|
size_bytes: int,
|
|
) -> _ContentAddressedSyncResult | None:
|
|
"""Try an authoritative Hugging Face source before shared cache lookup."""
|
|
source = spec.huggingface_source
|
|
if source is None or not isinstance(self.volume, HuggingFaceMaterializingBackend):
|
|
return None
|
|
_emit_sync_status(
|
|
spec.status_callback,
|
|
_format_huggingface_download_status(
|
|
spec.local_path.name,
|
|
item_index=spec.status_current,
|
|
total_items=spec.status_total,
|
|
),
|
|
spec.status_current,
|
|
spec.status_total,
|
|
)
|
|
if not self._invoke_huggingface_materializer(source, spec.remote_path):
|
|
return None
|
|
self._store_sync_record(
|
|
sync_key=spec.sync_key,
|
|
remote_path=spec.remote_path,
|
|
source_description=source.display_reference,
|
|
)
|
|
self._schedule_r2_writeback(
|
|
sha256=spec.sha256,
|
|
size_bytes=size_bytes,
|
|
remote_path=spec.remote_path,
|
|
)
|
|
logger.info(
|
|
"Materialized %s directly from Hugging Face at %s.",
|
|
source.display_reference,
|
|
spec.remote_path,
|
|
)
|
|
return _ContentAddressedSyncResult(remote_path=spec.remote_path, uploaded=True)
|
|
|
|
def _invoke_huggingface_materializer(
|
|
self,
|
|
source: HuggingFaceAssetSource,
|
|
remote_path: str,
|
|
) -> bool:
|
|
"""Invoke the available Hub materializer and normalize cancellation."""
|
|
token = os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN")
|
|
if self.cancellation_check is None or not isinstance(
|
|
self.volume,
|
|
CancellableHuggingFaceMaterializingBackend,
|
|
):
|
|
assert isinstance(self.volume, HuggingFaceMaterializingBackend)
|
|
return self.volume.materialize_huggingface_file(
|
|
source,
|
|
remote_path,
|
|
token=token,
|
|
)
|
|
try:
|
|
return self.volume.materialize_huggingface_file_cancellable(
|
|
source,
|
|
remote_path,
|
|
token=token,
|
|
cancellation_check=self.cancellation_check,
|
|
)
|
|
except InterruptedError as exc:
|
|
raise SyncCancelledError("Remote workflow preparation was cancelled.") from exc
|
|
|
|
|
|
def _upload_content_addressed_file(
|
|
self,
|
|
spec: _ContentAddressedSyncSpec,
|
|
size_bytes: int,
|
|
*,
|
|
force_r2_writeback: bool = False,
|
|
) -> _ContentAddressedSyncResult:
|
|
"""Use the established controller-to-worker upload and schedule write-back."""
|
|
_emit_sync_status(
|
|
spec.status_callback,
|
|
spec.upload_status_message
|
|
or (
|
|
f"Uploading {Path(spec.source_description).name} to "
|
|
f"{self._destination_label()}"
|
|
),
|
|
spec.status_current,
|
|
spec.status_total,
|
|
)
|
|
if self.cancellation_check is not None and isinstance(
|
|
self.volume,
|
|
CancellableVolumeBackend,
|
|
):
|
|
try:
|
|
self.volume.put_file_cancellable(
|
|
spec.local_path,
|
|
spec.remote_path,
|
|
cancellation_check=self.cancellation_check,
|
|
)
|
|
except InterruptedError as exc:
|
|
raise SyncCancelledError(
|
|
"Remote workflow preparation was cancelled."
|
|
) from exc
|
|
else:
|
|
self.volume.put_file(spec.local_path, spec.remote_path)
|
|
self._store_sync_record(
|
|
sync_key=spec.sync_key,
|
|
remote_path=spec.remote_path,
|
|
source_description=spec.source_description,
|
|
)
|
|
self._schedule_r2_writeback(
|
|
sha256=spec.sha256,
|
|
size_bytes=size_bytes,
|
|
remote_path=spec.remote_path,
|
|
force=force_r2_writeback,
|
|
)
|
|
return _ContentAddressedSyncResult(remote_path=spec.remote_path, uploaded=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _huggingface_source_for_asset(
|
|
self,
|
|
local_path: Path,
|
|
*,
|
|
sha256: str,
|
|
status_callback: SyncStatusCallback | None,
|
|
item_index: int | None,
|
|
total_items: int | None,
|
|
) -> HuggingFaceAssetSource | None:
|
|
"""Return registered or automatically discovered immutable provenance."""
|
|
if not isinstance(self.volume, HuggingFaceMaterializingBackend):
|
|
return None
|
|
source = (
|
|
self.huggingface_asset_registry.get(sha256)
|
|
if self.huggingface_asset_registry is not None
|
|
else None
|
|
)
|
|
if source is None and self.huggingface_asset_discovery is not None:
|
|
_emit_sync_status(
|
|
status_callback,
|
|
_format_huggingface_discovery_status(
|
|
local_path.name,
|
|
item_index=item_index,
|
|
total_items=total_items,
|
|
),
|
|
item_index,
|
|
total_items,
|
|
)
|
|
source = self.huggingface_asset_discovery.discover(
|
|
local_path,
|
|
sha256=sha256,
|
|
)
|
|
if source is None:
|
|
return None
|
|
actual_size = local_path.stat().st_size
|
|
if actual_size != source.size_bytes:
|
|
logger.warning(
|
|
"Ignoring Hugging Face provenance for %s because registered size %d "
|
|
"does not match local size %d.",
|
|
local_path,
|
|
source.size_bytes,
|
|
actual_size,
|
|
)
|
|
return None
|
|
return source
|
|
|
|
|
|
|
|
|
|
|
|
def _lookup_sync_record(self, sync_key: str) -> dict[str, Any] | None:
|
|
"""Return one normalized sync-index record when the key is present."""
|
|
assert self.sync_index is not None
|
|
payload = self.sync_index.get(sync_key)
|
|
if payload is None:
|
|
return None
|
|
remote_path = payload.get("remote_path")
|
|
if not isinstance(remote_path, str) or not remote_path:
|
|
logger.warning("Ignoring malformed sync-index record for key=%s payload=%s.", sync_key, payload)
|
|
return None
|
|
return dict(payload)
|
|
|
|
def _store_sync_record(
|
|
self,
|
|
*,
|
|
sync_key: str,
|
|
remote_path: str,
|
|
source_description: str,
|
|
) -> None:
|
|
"""Persist one normalized sync-index record."""
|
|
assert self.sync_index is not None
|
|
self.sync_index.put(
|
|
sync_key,
|
|
{
|
|
"remote_path": remote_path,
|
|
"source": source_description,
|
|
},
|
|
)
|
|
|
|
def _asset_sync_index_key(self, sha256: str) -> str:
|
|
"""Return the sync-index key for one content-addressed asset digest."""
|
|
return f"{self._sync_index_scope_prefix()}:asset:{sha256}"
|
|
|
|
|
|
|
|
|
|
def _sync_index_scope_prefix(self) -> str:
|
|
"""Return the active sync-index scope prefix for this storage backend."""
|
|
cached_prefix = self._sync_scope_prefix_cache
|
|
if cached_prefix is not None:
|
|
return cached_prefix
|
|
with self._sync_scope_prefix_lock:
|
|
cached_prefix = self._sync_scope_prefix_cache
|
|
if cached_prefix is not None:
|
|
return cached_prefix
|
|
if isinstance(self.volume, ModalVolumeBackend) or bool(
|
|
getattr(self.volume, "remote_volume_epoch_scoped", False)
|
|
):
|
|
cached_prefix = self._ensure_remote_volume_epoch_scope()
|
|
else:
|
|
cached_prefix = f"local:{self.settings.local_storage_root.resolve()}"
|
|
self._sync_scope_prefix_cache = cached_prefix
|
|
return cached_prefix
|
|
|
|
def _ensure_remote_volume_epoch_scope(self) -> str:
|
|
"""Return a sync-index prefix tied to the active remote volume contents."""
|
|
fixed_key = f"{self.settings.volume_name}:current_volume_epoch"
|
|
current_record = self._lookup_sync_record(fixed_key)
|
|
if current_record is not None:
|
|
epoch = current_record.get("epoch")
|
|
sentinel_path = current_record.get("sentinel_path")
|
|
if (
|
|
isinstance(epoch, str)
|
|
and epoch
|
|
and isinstance(sentinel_path, str)
|
|
and sentinel_path
|
|
and self.volume.exists(sentinel_path)
|
|
):
|
|
return f"{self.settings.volume_name}:epoch:{epoch}"
|
|
logger.warning(
|
|
"Discarding stale remote sync-index volume epoch for volume=%s because sentinel %s is missing.",
|
|
self.settings.volume_name,
|
|
sentinel_path,
|
|
)
|
|
|
|
epoch = uuid.uuid4().hex
|
|
sentinel_path = f"/sync_index_epochs/{epoch}.json"
|
|
self.volume.put_bytes(
|
|
json.dumps(
|
|
{
|
|
"volume_name": self.settings.volume_name,
|
|
"epoch": epoch,
|
|
},
|
|
sort_keys=True,
|
|
).encode("utf-8"),
|
|
sentinel_path,
|
|
)
|
|
assert self.sync_index is not None
|
|
self.sync_index.put(
|
|
fixed_key,
|
|
{
|
|
"epoch": epoch,
|
|
"remote_path": sentinel_path,
|
|
"sentinel_path": sentinel_path,
|
|
},
|
|
)
|
|
logger.info(
|
|
"Initialized remote sync-index volume epoch %s for volume=%s sentinel=%s.",
|
|
epoch,
|
|
self.settings.volume_name,
|
|
sentinel_path,
|
|
)
|
|
return f"{self.settings.volume_name}:epoch:{epoch}"
|
|
|
|
|
|
def _resolve_model_path(self, value: str) -> Path | None:
|
|
"""Resolve a prompt string into a local model file path when possible."""
|
|
if value in self._path_resolution_cache:
|
|
cached = self._path_resolution_cache[value]
|
|
return Path(cached) if cached is not None else None
|
|
resolved = resolve_model_path(
|
|
value,
|
|
comfyui_root=self.settings.comfyui_root,
|
|
)
|
|
self._path_resolution_cache[value] = (
|
|
str(resolved) if resolved is not None else None
|
|
)
|
|
return resolved
|
|
|
|
def _collect_syncable_asset_paths(self, value: Any) -> list[Path]:
|
|
"""Return all prompt asset paths that resolve to syncable local files."""
|
|
collected_paths: list[Path] = []
|
|
|
|
def visit(item: Any) -> None:
|
|
if isinstance(item, str):
|
|
resolved_path = self._resolve_model_path(item)
|
|
if resolved_path is not None:
|
|
collected_paths.append(resolved_path)
|
|
return
|
|
if isinstance(item, list):
|
|
for child in item:
|
|
visit(child)
|
|
return
|
|
if isinstance(item, dict):
|
|
for child in item.values():
|
|
visit(child)
|
|
|
|
visit(value)
|
|
return collected_paths
|