Files
ttulttul-ComfyUI-Modal/sync_engine.py
T

911 lines
33 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,
_format_indexed_asset_status,
)
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,
_format_indexed_asset_status,
)
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)
@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_indexed_asset_status(
source_path.name,
action="Uploading asset",
location=f"to {self._destination_label()}",
item_index=item_index,
total_items=total_items,
),
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_indexed_asset_status(
spec.local_path.name,
action="Downloading asset",
location="from Hugging Face on Vast.ai",
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_indexed_asset_status(
local_path.name,
action="Identifying Hugging Face source",
location="for Vast.ai",
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