"""Persisted ComfyUI node-output cache keys, records, restore, and write-back.""" from __future__ import annotations import asyncio import base64 from contextlib import AbstractContextManager from dataclasses import dataclass import hashlib import inspect import json import logging import math from typing import Any, Callable, Iterable, Mapping, Sequence import zlib try: from .cloud_comfy_bootstrap import _load_nodes_module from .cloud_runtime_context import node_output_cache_store from .remote_protocol import ( BOUNDARY_INPUT_SIGNATURES_KEY as _BOUNDARY_INPUT_SIGNATURES_KEY, ) from .serialization import ( deserialize_node_outputs, deserialize_value, serialize_node_outputs, serialize_value, ) from .settings import get_settings except ImportError: # pragma: no cover - flat Modal-container import. from cloud_comfy_bootstrap import _load_nodes_module from cloud_runtime_context import node_output_cache_store from remote_protocol import ( BOUNDARY_INPUT_SIGNATURES_KEY as _BOUNDARY_INPUT_SIGNATURES_KEY, ) from serialization import ( deserialize_node_outputs, deserialize_value, serialize_node_outputs, serialize_value, ) from settings import get_settings logger = logging.getLogger(__name__) _NODE_OUTPUT_CACHE_KEY_PREFIX = "NC_" _NODE_OUTPUT_CACHE_RECORD_VERSION = 1 @dataclass(frozen=True) class CloudNodeOutputCacheHooks: """Callbacks supplied by the stable cloud entrypoint.""" emit_cloud_info: Callable[..., None] timed_phase: Callable[..., AbstractContextManager[None]] _NODE_OUTPUT_CACHE_HOOKS: CloudNodeOutputCacheHooks | None = None def configure_cloud_node_output_cache_hooks( hooks: CloudNodeOutputCacheHooks, ) -> None: """Install cloud logging callbacks without importing upward into the entrypoint.""" global _NODE_OUTPUT_CACHE_HOOKS _NODE_OUTPUT_CACHE_HOOKS = hooks def _node_output_cache_hooks() -> CloudNodeOutputCacheHooks: """Return configured callbacks or fail with a clear import-order error.""" if _NODE_OUTPUT_CACHE_HOOKS is None: raise RuntimeError("Cloud node-output cache hooks have not been configured.") return _NODE_OUTPUT_CACHE_HOOKS def _emit_cloud_info(message: str, *args: Any) -> None: """Delegate timestamped cloud logging to the stable entrypoint.""" _node_output_cache_hooks().emit_cloud_info(message, *args) def _timed_phase(phase: str, **fields: Any) -> AbstractContextManager[None]: """Delegate phase timing to the stable entrypoint.""" return _node_output_cache_hooks().timed_phase(phase, **fields) @dataclass class _PersistedNodeCacheRestoreState: """Track distributed cache entries restored into one prompt execution.""" restored_node_ids: list[str] restored_cache_keys_by_node_id: dict[str, str] restore_original_method: Callable[[], None] @dataclass(frozen=True) class _NodeOutputCacheLookupResult: """Hold one distributed cache lookup result before live-cache hydration.""" node_id: str cache_key: str | None raw_record: Any | None cache_entry: Any | None def _is_link(value: Any) -> bool: """Return whether a prompt input value is a ComfyUI link.""" return ( isinstance(value, list) and len(value) == 2 and all(not isinstance(item, dict) for item in value) ) def _normalize_link_output_index(value: Any) -> Any: """Unwrap a singleton list around a prompt-link output index when present.""" while isinstance(value, list) and len(value) == 1: value = value[0] return value def _node_output_cache_store() -> Any | None: """Return the shared Modal Dict used for persisted transport-safe node outputs.""" return node_output_cache_store() def _node_output_cache_key_preview( cache_key: str | None, *, max_chars: int = 32 ) -> str: """Return a short human-readable prefix of one persisted node-cache key.""" if cache_key is None: return "" return cache_key[:max_chars] def _node_output_cache_value_preview(value: Any, *, max_chars: int = 160) -> str: """Return a truncated repr for node-cache debug logging.""" try: rendered = repr(value) except Exception as exc: # pragma: no cover - defensive logging path. rendered = f"" if len(rendered) <= max_chars: return rendered return f"{rendered[:max_chars]}..." def _tensor_cache_key_digest(value: Any) -> dict[str, Any]: """Return a stable digest payload for one tensor used inside a cache key.""" from safetensors.torch import save tensor = value.detach().contiguous().cpu() tensor_bytes = save({"value": tensor}) return { "kind": "tensor", "dtype": str(tensor.dtype), "shape": list(tensor.shape), "sha256": hashlib.sha256(tensor_bytes).hexdigest(), } async def _node_output_cache_store_get(cache_store: Any, cache_key: str) -> Any: """Return one persisted node-cache record, preferring Modal's async Dict interface.""" aio_get = getattr(getattr(cache_store, "get", None), "aio", None) if callable(aio_get): return await aio_get(cache_key) return cache_store.get(cache_key) async def _node_output_cache_store_put( cache_store: Any, cache_key: str, record: dict[str, Any], ) -> None: """Persist one node-cache record without blocking an active event loop.""" aio_put = getattr(getattr(cache_store, "put", None), "aio", None) if callable(aio_put): await aio_put(cache_key, record) return cache_store[cache_key] = record def _is_input_signature_cache_key_set(cache_key_set: Any) -> bool: """Return whether one cache-key set uses ComfyUI input-signature semantics.""" return all( hasattr(cache_key_set, attribute) for attribute in ( "dynprompt", "is_changed_cache", "get_ordered_ancestry", "include_node_id_in_input", ) ) def _include_unique_id_in_input_signature(class_type: str) -> bool: """Return whether ComfyUI includes the unique node id in this input signature.""" from comfy_execution.caching import include_unique_id_in_input return bool(include_unique_id_in_input(class_type)) def _build_node_output_cache_immediate_signature( cache_key_set: Any, *, dynprompt: Any, node_id: str, ancestor_order_mapping: dict[str, int], is_changed_value: Any, ) -> list[Any]: """Return one raw ComfyUI input-signature fragment before `to_hashable()` runs.""" if not dynprompt.has_node(node_id): return [float("NaN")] node = dynprompt.get_node(node_id) class_type = node["class_type"] class_def = _load_nodes_module().NODE_CLASS_MAPPINGS[class_type] signature: list[Any] = [class_type, is_changed_value] if ( cache_key_set.include_node_id_in_input() or (hasattr(class_def, "NOT_IDEMPOTENT") and class_def.NOT_IDEMPOTENT) or _include_unique_id_in_input_signature(class_type) ): signature.append(node_id) inputs = node["inputs"] boundary_input_signatures = node.get(_BOUNDARY_INPUT_SIGNATURES_KEY) for key in sorted(inputs.keys()): input_value = inputs[key] ancestor_socket = _cache_signature_link_output_index(input_value) ancestor_id = str(input_value[0]) if ancestor_socket is not None else None if ancestor_socket is not None and ancestor_id in ancestor_order_mapping: ancestor_index = int(ancestor_order_mapping[ancestor_id]) signature.append((key, ("ANCESTOR", ancestor_index, ancestor_socket))) else: boundary_signature = None if isinstance(boundary_input_signatures, dict): candidate_signature = boundary_input_signatures.get(str(key)) if candidate_signature is not None: boundary_signature = candidate_signature if boundary_signature is not None: signature.append((key, ("BOUNDARY_SOURCE", boundary_signature))) else: signature.append((key, input_value)) return signature def _cache_signature_link_output_index(value: Any) -> int | None: """Return a prompt-link output index when `value` is safe to treat as graph wiring.""" if not _is_link(value): return None output_index = _normalize_link_output_index(value[1]) if isinstance(output_index, bool): return None try: return int(output_index) except (TypeError, ValueError): return None async def _build_node_output_cache_signature_from_key_set_async( cache_key_set: Any, node_id: str, ) -> Any: """Return one distributed cache signature derived from a live ComfyUI cache-key set.""" if not _is_input_signature_cache_key_set(cache_key_set): return cache_key_set.get_data_key(node_id) dynprompt = cache_key_set.dynprompt ancestors, order_mapping = cache_key_set.get_ordered_ancestry(dynprompt, node_id) signature = [ _build_node_output_cache_immediate_signature( cache_key_set, dynprompt=dynprompt, node_id=node_id, ancestor_order_mapping=order_mapping, is_changed_value=await cache_key_set.is_changed_cache.get(node_id), ) ] for ancestor_id in ancestors: signature.append( _build_node_output_cache_immediate_signature( cache_key_set, dynprompt=dynprompt, node_id=str(ancestor_id), ancestor_order_mapping=order_mapping, is_changed_value=await cache_key_set.is_changed_cache.get(ancestor_id), ) ) return signature def _build_node_output_cache_signature_from_key_set_sync( cache_key_set: Any, node_id: str, ) -> Any | None: """Return one distributed cache signature using cached `is_changed` values only.""" if not _is_input_signature_cache_key_set(cache_key_set): return cache_key_set.get_data_key(node_id) cached_is_changed = getattr(cache_key_set.is_changed_cache, "is_changed", None) if not isinstance(cached_is_changed, dict): return None dynprompt = cache_key_set.dynprompt ancestors, order_mapping = cache_key_set.get_ordered_ancestry(dynprompt, node_id) all_node_ids = [str(node_id), *[str(ancestor_id) for ancestor_id in ancestors]] missing_node_ids = [ candidate for candidate in all_node_ids if candidate not in cached_is_changed ] if missing_node_ids: _emit_cloud_info( "Node output cache signature rebuild node=%s result=skip reason=missing-is-changed values=%s", node_id, missing_node_ids, ) return None return [ _build_node_output_cache_immediate_signature( cache_key_set, dynprompt=dynprompt, node_id=candidate, ancestor_order_mapping=order_mapping, is_changed_value=cached_is_changed[candidate], ) for candidate in all_node_ids ] def _canonicalize_node_output_cache_key_part( value: Any, *, path: str = "root", ) -> Any | None: """Return a JSON-stable representation of one CacheKeySetInputSignature fragment.""" value_type_name = type(value).__name__ if value_type_name == "Unhashable": _emit_cloud_info( "Node output cache canonicalization path=%s result=unhashable reason=comfy-unhashable-marker type=%s value=%s", path, value_type_name, _node_output_cache_value_preview(value), ) return None if value is None or isinstance(value, bool | int | str): return value if isinstance(value, float): if math.isnan(value): return {"kind": "float", "value": "nan"} if math.isinf(value): return {"kind": "float", "value": "inf" if value > 0 else "-inf"} return value if isinstance(value, bytes): return { "kind": "bytes", "value": base64.b64encode(value).decode("ascii"), } try: import torch except ModuleNotFoundError: torch = None if torch is not None and isinstance(value, torch.Tensor): return _tensor_cache_key_digest(value) if isinstance(value, tuple): items = [] for index, item in enumerate(value): child_path = f"{path}[{index}]" canonical_item = _canonicalize_node_output_cache_key_part( item, path=child_path, ) if canonical_item is None: _emit_cloud_info( "Node output cache canonicalization path=%s result=unhashable reason=tuple-child child_path=%s parent_type=%s parent_value=%s", path, child_path, value_type_name, _node_output_cache_value_preview(value), ) return None items.append(canonical_item) return {"kind": "tuple", "items": items} if isinstance(value, list): items = [] for index, item in enumerate(value): child_path = f"{path}[{index}]" canonical_item = _canonicalize_node_output_cache_key_part( item, path=child_path, ) if canonical_item is None: _emit_cloud_info( "Node output cache canonicalization path=%s result=unhashable reason=list-child child_path=%s parent_type=%s parent_value=%s", path, child_path, value_type_name, _node_output_cache_value_preview(value), ) return None items.append(canonical_item) return {"kind": "list", "items": items} if isinstance(value, dict): items: list[dict[str, Any]] = [] for key in sorted(value): rendered_key = _node_output_cache_value_preview(key, max_chars=48) key_path = f"{path}.key[{rendered_key}]" value_path = f"{path}[{rendered_key}]" canonical_key = _canonicalize_node_output_cache_key_part( key, path=key_path, ) canonical_value = _canonicalize_node_output_cache_key_part( value[key], path=value_path, ) if canonical_key is None: _emit_cloud_info( "Node output cache canonicalization path=%s result=unhashable reason=dict-key child_path=%s parent_type=%s parent_value=%s", path, key_path, value_type_name, _node_output_cache_value_preview(value), ) return None if canonical_value is None: _emit_cloud_info( "Node output cache canonicalization path=%s result=unhashable reason=dict-value key=%s child_path=%s parent_type=%s parent_value=%s", path, rendered_key, value_path, value_type_name, _node_output_cache_value_preview(value), ) return None items.append({"key": canonical_key, "value": canonical_value}) return {"kind": "dict", "items": items} if isinstance(value, frozenset): canonical_items: list[Any] = [] for index, item in enumerate( sorted( value, key=lambda item: _node_output_cache_value_preview(item, max_chars=120), ) ): child_path = f"{path}{{{index}}}" canonical_item = _canonicalize_node_output_cache_key_part( item, path=child_path, ) if canonical_item is None: _emit_cloud_info( "Node output cache canonicalization path=%s result=unhashable reason=frozenset-child child_path=%s parent_type=%s parent_value=%s", path, child_path, value_type_name, _node_output_cache_value_preview(value), ) return None canonical_items.append(canonical_item) canonical_items.sort( key=lambda item: json.dumps( item, sort_keys=True, separators=(",", ":"), ) ) return {"kind": "frozenset", "items": canonical_items} _emit_cloud_info( "Node output cache canonicalization path=%s result=unhashable reason=unsupported-type type=%s value=%s", path, value_type_name, _node_output_cache_value_preview(value), ) return None def _node_output_cache_key(signature: Any) -> str | None: """Return the persisted Modal Dict key for one ComfyUI cache signature.""" canonical_signature = _canonicalize_node_output_cache_key_part(signature) if canonical_signature is None: return None signature_payload = json.dumps( canonical_signature, sort_keys=True, separators=(",", ":"), ).encode("utf-8") signature_digest = hashlib.sha256(signature_payload).hexdigest() return f"{_NODE_OUTPUT_CACHE_KEY_PREFIX}{signature_digest}" async def _node_output_cache_key_from_key_set_async( cache_key_set: Any, node_id: str, ) -> str | None: """Return the persisted Modal Dict key for one live ComfyUI cache-key-set node.""" return _node_output_cache_key( await _build_node_output_cache_signature_from_key_set_async( cache_key_set, node_id, ) ) def _node_output_cache_key_from_key_set_sync( cache_key_set: Any, node_id: str, ) -> str | None: """Return the persisted Modal Dict key for one executed ComfyUI cache-key-set node.""" signature = _build_node_output_cache_signature_from_key_set_sync( cache_key_set, node_id ) if signature is None: return None return _node_output_cache_key(signature) def _estimate_node_output_cache_value_size_bytes( value: Any, *, byte_limit: int, ) -> int | None: """Return a best-effort raw-size estimate for one transport-safe value.""" if byte_limit < 0: return None if value is None or isinstance(value, bool): return 1 if isinstance(value, int): return 8 if isinstance(value, float): return 8 if isinstance(value, str): return len(value.encode("utf-8")) if isinstance(value, bytes): return len(value) try: import torch except ModuleNotFoundError: torch = None if torch is not None and isinstance(value, torch.Tensor): return int(value.numel()) * int(value.element_size()) if isinstance(value, tuple | list): total_size = 0 for item in value: item_size = _estimate_node_output_cache_value_size_bytes( item, byte_limit=byte_limit ) if item_size is None: return None total_size += item_size if total_size > byte_limit: return total_size return total_size if isinstance(value, dict): total_size = 0 for key, item in value.items(): total_size += len(str(key).encode("utf-8")) if total_size > byte_limit: return total_size item_size = _estimate_node_output_cache_value_size_bytes( item, byte_limit=byte_limit ) if item_size is None: return None total_size += item_size if total_size > byte_limit: return total_size return total_size return None def _serialize_node_output_cache_entry( cache_entry: Any, *, max_bytes: int, ) -> dict[str, Any] | None: """Return a persisted node-cache record when the outputs are safe and small enough.""" if max_bytes <= 0: return None outputs_size = _estimate_node_output_cache_value_size_bytes( list(getattr(cache_entry, "outputs", [])), byte_limit=max_bytes, ) if outputs_size is None or outputs_size > max_bytes: return None try: serialized_outputs = serialize_node_outputs( tuple(getattr(cache_entry, "outputs", [])) ) except TypeError: return None ui_payload: Any | None = None ui_value = getattr(cache_entry, "ui", None) if ui_value is not None: ui_size = _estimate_node_output_cache_value_size_bytes( ui_value, byte_limit=max_bytes ) if ui_size is not None and ui_size <= max_bytes: try: ui_payload = serialize_value(ui_value) except TypeError: ui_payload = None return { "version": _NODE_OUTPUT_CACHE_RECORD_VERSION, "outputs_zlib": zlib.compress(serialized_outputs), "outputs_size_bytes": outputs_size, "ui": ui_payload, } def _deserialize_node_output_cache_entry( execution: Any, record: Any, ) -> Any | None: """Return a ComfyUI CacheEntry reconstructed from one persisted Modal Dict record.""" if not isinstance(record, dict): return None if int(record.get("version", -1)) != _NODE_OUTPUT_CACHE_RECORD_VERSION: return None compressed_outputs = record.get("outputs_zlib") if not isinstance(compressed_outputs, (bytes, bytearray)): return None try: outputs = list( deserialize_node_outputs(zlib.decompress(bytes(compressed_outputs))) ) except (TypeError, ValueError, zlib.error): return None ui_payload = record.get("ui") try: ui_value = deserialize_value(ui_payload) if ui_payload is not None else None except TypeError: ui_value = None return execution.CacheEntry(ui=ui_value, outputs=outputs) async def _await_maybe(value: Any) -> Any: """Await an asynchronous compatibility result or return a synchronous value.""" if inspect.isawaitable(value): return await value return value def _prompt_executor_cache_get_sync(outputs_cache: Any, node_id: str) -> Any | None: """Read one prepared cache entry without leaking a coroutine into synchronous code.""" local_getter = getattr(outputs_cache, "get_local", None) if callable(local_getter): return local_getter(node_id) cache_entry = outputs_cache.get(node_id) if not inspect.isawaitable(cache_entry): return cache_entry try: asyncio.get_running_loop() except RuntimeError: return asyncio.run(cache_entry) if inspect.iscoroutine(cache_entry): cache_entry.close() raise RuntimeError( "ComfyUI exposed an asynchronous outputs cache without the synchronous get_local API." ) async def _restore_persisted_node_output_cache_entries( execution: Any, executor: Any, *, prompt_id: str, prompt: dict[str, Any], cache_store: Any, required_materialized_node_ids: Iterable[str] | None = None, restored_cache_keys_by_node_id: dict[str, str] | None = None, ) -> list[str]: """Hydrate PromptExecutor output-cache misses from the shared Modal Dict.""" outputs_cache = executor.caches.outputs dynamic_prompt = execution.DynamicPrompt(prompt) is_changed_cache = execution.IsChangedCache( prompt_id, dynamic_prompt, outputs_cache ) await _await_maybe( outputs_cache.set_prompt(dynamic_prompt, prompt.keys(), is_changed_cache) ) outputs_cache.clean_unused() return await _restore_persisted_node_output_cache_entries_into_prepared_cache( execution, outputs_cache, prompt=prompt, cache_store=cache_store, required_materialized_node_ids=required_materialized_node_ids, restored_cache_keys_by_node_id=restored_cache_keys_by_node_id, ) def _node_output_cache_ancestor_ids(cache_key_set: Any, node_id: str) -> set[str]: """Return the prompt ancestor ids used by one ComfyUI input-signature cache key.""" if not _is_input_signature_cache_key_set(cache_key_set): return set() dynprompt = cache_key_set.dynprompt if not dynprompt.has_node(node_id): return set() ancestors, _order_mapping = cache_key_set.get_ordered_ancestry(dynprompt, node_id) return {str(ancestor_id) for ancestor_id in ancestors} async def _restore_persisted_node_output_cache_entries_into_prepared_cache( execution: Any, outputs_cache: Any, *, prompt: dict[str, Any], cache_store: Any, required_materialized_node_ids: Iterable[str] | None = None, restored_cache_keys_by_node_id: dict[str, str] | None = None, ) -> list[str]: """Hydrate one already-prepared PromptExecutor outputs cache from the shared Modal Dict.""" restored_node_ids: list[str] = [] pending_lookup_tasks: list[asyncio.Task[_NodeOutputCacheLookupResult]] = [] required_node_ids = { str(node_id) for node_id in (required_materialized_node_ids or []) if str(node_id) in prompt } local_node_ids: set[str] = set() async def lookup_node(node_id: str) -> _NodeOutputCacheLookupResult: """Resolve one distributed cache candidate without mutating the live outputs cache.""" cache_key = await _node_output_cache_key_from_key_set_async( outputs_cache.cache_key_set, node_id ) if cache_key is None: return _NodeOutputCacheLookupResult( node_id=node_id, cache_key=None, raw_record=None, cache_entry=None, ) raw_record = await _node_output_cache_store_get(cache_store, cache_key) return _NodeOutputCacheLookupResult( node_id=node_id, cache_key=cache_key, raw_record=raw_record, cache_entry=_deserialize_node_output_cache_entry(execution, raw_record), ) for node_id in prompt: if await _await_maybe(outputs_cache.get(node_id)) is not None: _emit_cloud_info( "Node output cache lookup node=%s result=local-hit", node_id, ) local_node_ids.add(str(node_id)) continue pending_lookup_tasks.append(asyncio.create_task(lookup_node(str(node_id)))) if not pending_lookup_tasks: return restored_node_ids lookup_results = await asyncio.gather(*pending_lookup_tasks) restorable_node_ids = { lookup_result.node_id for lookup_result in lookup_results if lookup_result.cache_key is not None and lookup_result.cache_entry is not None } available_node_ids = local_node_ids | restorable_node_ids for lookup_result in lookup_results: node_id = lookup_result.node_id cache_key = lookup_result.cache_key if cache_key is None: _emit_cloud_info( "Node output cache lookup node=%s key_prefix=%s result=skip reason=key-unhashable", node_id, _node_output_cache_key_preview(cache_key), ) continue raw_record = lookup_result.raw_record cache_entry = lookup_result.cache_entry if cache_entry is None: result = "miss" if raw_record is not None: result = "miss-invalid" _emit_cloud_info( "Node output cache lookup node=%s key_prefix=%s result=%s", node_id, _node_output_cache_key_preview(cache_key), result, ) continue missing_required_ancestors = sorted( required_node_ids & _node_output_cache_ancestor_ids(outputs_cache.cache_key_set, node_id) - available_node_ids ) if missing_required_ancestors: _emit_cloud_info( "Node output cache lookup node=%s key_prefix=%s result=skip reason=missing-required-boundary-ancestors ancestors=%s", node_id, _node_output_cache_key_preview(cache_key), missing_required_ancestors, ) continue await _await_maybe(outputs_cache.set(node_id, cache_entry)) _emit_cloud_info( "Node output cache lookup node=%s key_prefix=%s result=hit", node_id, _node_output_cache_key_preview(cache_key), ) if restored_cache_keys_by_node_id is not None: restored_cache_keys_by_node_id[str(node_id)] = cache_key restored_node_ids.append(str(node_id)) return restored_node_ids def _install_prompt_executor_persisted_cache_restore( execution: Any, executor: Any, *, component_id: str, prompt: dict[str, Any], cache_store: Any, required_materialized_node_ids: Iterable[str] | None = None, ) -> _PersistedNodeCacheRestoreState: """Patch one executor so persisted-cache restore runs after its live `set_prompt()` call.""" restored_node_ids: list[str] = [] restored_cache_keys_by_node_id: dict[str, str] = {} outputs_cache = executor.caches.outputs original_set_prompt = outputs_cache.set_prompt async def wrapped_set_prompt( dynprompt: Any, node_ids: Any, is_changed_cache: Any ) -> None: await original_set_prompt(dynprompt, node_ids, is_changed_cache) with _timed_phase("restore_persisted_node_cache", component=component_id): restored_cache_keys_by_node_id.clear() restored_node_ids[ : ] = await _restore_persisted_node_output_cache_entries_into_prepared_cache( execution, outputs_cache, prompt=prompt, cache_store=cache_store, required_materialized_node_ids=required_materialized_node_ids, restored_cache_keys_by_node_id=restored_cache_keys_by_node_id, ) outputs_cache.set_prompt = wrapped_set_prompt def restore_original_method() -> None: outputs_cache.set_prompt = original_set_prompt return _PersistedNodeCacheRestoreState( restored_node_ids=restored_node_ids, restored_cache_keys_by_node_id=restored_cache_keys_by_node_id, restore_original_method=restore_original_method, ) def _emit_restored_node_cache_events( status_callback: Callable[[dict[str, Any]], None] | None, restored_node_ids: Sequence[str], ) -> None: """Publish one status event per node whose outputs were restored from the persisted cache.""" if status_callback is None: return for node_id in restored_node_ids: safe_node_id = str(node_id) status_callback( { "event_type": "node_cached", "node_id": safe_node_id, "display_node_id": safe_node_id, "real_node_id": safe_node_id, } ) def _boundary_output_node_ids( boundary_outputs: Iterable[Mapping[str, Any]] ) -> set[str]: """Return node ids whose local cache entries must exist for boundary collection.""" return { str(boundary_output.get("node_id")) for boundary_output in boundary_outputs if boundary_output.get("node_id") is not None } async def _persist_node_output_cache_entries( executor: Any, *, prompt: dict[str, Any], cache_store: Any, restored_cache_keys_by_node_id: dict[str, str] | None = None, ) -> list[str]: """Persist eligible PromptExecutor cache entries into the shared Modal Dict.""" max_bytes = get_settings().node_output_cache_max_bytes if max_bytes <= 0: return [] outputs_cache = executor.caches.outputs cache_key_set = getattr(outputs_cache, "cache_key_set", None) if cache_key_set is None: return [] persisted_node_ids: list[str] = [] for node_id in prompt: cache_entry = await _await_maybe(outputs_cache.get(node_id)) if cache_entry is None: _emit_cloud_info( "Node output cache write node=%s result=skip reason=no-local-cache-entry", node_id, ) continue cache_key = _node_output_cache_key_from_key_set_sync( cache_key_set, str(node_id) ) if cache_key is None: _emit_cloud_info( "Node output cache write node=%s key_prefix=%s result=skip reason=key-unhashable", node_id, _node_output_cache_key_preview(cache_key), ) continue restored_cache_key = None if restored_cache_keys_by_node_id is not None: restored_cache_key = restored_cache_keys_by_node_id.get(str(node_id)) if restored_cache_key == cache_key: _emit_cloud_info( "Node output cache write node=%s key_prefix=%s result=skip reason=restored-hit", node_id, _node_output_cache_key_preview(cache_key), ) continue record = _serialize_node_output_cache_entry(cache_entry, max_bytes=max_bytes) if record is None: _emit_cloud_info( "Node output cache write node=%s key_prefix=%s result=skip reason=ineligible-or-oversize", node_id, _node_output_cache_key_preview(cache_key), ) continue await _node_output_cache_store_put(cache_store, cache_key, record) _emit_cloud_info( "Node output cache write node=%s key_prefix=%s result=write outputs_size_bytes=%s", node_id, _node_output_cache_key_preview(cache_key), record.get("outputs_size_bytes"), ) persisted_node_ids.append(str(node_id)) return persisted_node_ids