Files
ttulttul-ComfyUI-Modal/cloud_prompt_execution.py

1392 lines
51 KiB
Python

"""ComfyUI prompt validation, executor management, and subgraph execution."""
from __future__ import annotations
import asyncio
from concurrent.futures import ThreadPoolExecutor
from contextlib import AbstractContextManager, contextmanager
import copy
from dataclasses import dataclass
import importlib
import inspect
import json
import logging
from pathlib import Path
import threading
from typing import Any, Callable, Iterator, Mapping
try:
from .cloud_comfy_bootstrap import (
_ensure_comfy_runtime_initialized,
_ensure_prompt_node_classes_registered,
_extract_custom_nodes_bundle,
_load_execution_module,
_load_nodes_module,
_loader_cache_metric_snapshot,
_patched_folder_paths_absolute_lookup,
_rewrite_modal_asset_references,
)
from .cloud_node_output_cache import (
_PersistedNodeCacheRestoreState,
_boundary_output_node_ids,
_emit_restored_node_cache_events,
_install_prompt_executor_persisted_cache_restore,
_node_output_cache_store,
_persist_node_output_cache_entries,
_prompt_executor_cache_get_sync,
)
from .cloud_prompt_server_shims import _NullPromptServer, _TracingPromptServer
from .cloud_prompt_validation import (
_coerce_primitive_prompt_input_value,
_coerce_prompt_primitive_input_values,
_extract_prompt_executor_error,
_extract_prompt_executor_error_payload,
_format_prompt_executor_error_payload,
_is_link,
_log_prompt_executor_failure_details,
_node_input_type_map,
_node_input_types,
_node_required_input_names,
_normalize_link_output_index,
_normalize_prompt_input_value,
_normalize_subgraph_payload,
_resolve_required_subgraph_nodes,
_summarize_suspicious_prompt_inputs,
_trim_subgraph_payload_to_required_nodes,
_unwrap_wrapped_prompt_link,
_validate_prompt_input_shapes,
_validate_required_prompt_inputs,
)
from .cloud_session_bridge import (
_RemoteSessionBridgeResolutionStats,
_build_remote_session_bridge_record,
_log_remote_session_resolution_summary,
_payload_remote_session_handle,
_resolve_remote_session_inputs,
_store_remote_session_bridge_record,
_store_remote_session_bridge_value,
remote_session_store,
)
from .remote_protocol import (
BOUNDARY_INPUT_SIGNATURES_KEY as _BOUNDARY_INPUT_SIGNATURES_KEY,
)
from .serialization import (
coerce_serialized_node_outputs,
deserialize_node_inputs,
serialize_mapping,
serialize_node_inputs,
serialize_node_outputs,
)
from .session_state import (
RemoteSessionBridgeRef,
RemoteSessionHandle,
RemoteSessionStateError,
RemoteSessionValueRef,
is_remote_session_bridge_ref_payload,
is_remote_session_value_ref_payload,
)
from .settings import get_settings
except ImportError: # pragma: no cover - flat Modal-container import.
from cloud_comfy_bootstrap import (
_ensure_comfy_runtime_initialized,
_ensure_prompt_node_classes_registered,
_extract_custom_nodes_bundle,
_load_execution_module,
_load_nodes_module,
_loader_cache_metric_snapshot,
_patched_folder_paths_absolute_lookup,
_rewrite_modal_asset_references,
)
from cloud_node_output_cache import (
_PersistedNodeCacheRestoreState,
_boundary_output_node_ids,
_emit_restored_node_cache_events,
_install_prompt_executor_persisted_cache_restore,
_node_output_cache_store,
_persist_node_output_cache_entries,
_prompt_executor_cache_get_sync,
)
from cloud_prompt_server_shims import _NullPromptServer, _TracingPromptServer
from cloud_prompt_validation import (
_coerce_primitive_prompt_input_value,
_coerce_prompt_primitive_input_values,
_extract_prompt_executor_error,
_extract_prompt_executor_error_payload,
_format_prompt_executor_error_payload,
_is_link,
_log_prompt_executor_failure_details,
_node_input_type_map,
_node_input_types,
_node_required_input_names,
_normalize_link_output_index,
_normalize_prompt_input_value,
_normalize_subgraph_payload,
_resolve_required_subgraph_nodes,
_summarize_suspicious_prompt_inputs,
_trim_subgraph_payload_to_required_nodes,
_unwrap_wrapped_prompt_link,
_validate_prompt_input_shapes,
_validate_required_prompt_inputs,
)
from cloud_session_bridge import (
_RemoteSessionBridgeResolutionStats,
_build_remote_session_bridge_record,
_log_remote_session_resolution_summary,
_payload_remote_session_handle,
_resolve_remote_session_inputs,
_store_remote_session_bridge_record,
_store_remote_session_bridge_value,
remote_session_store,
)
from remote_protocol import (
BOUNDARY_INPUT_SIGNATURES_KEY as _BOUNDARY_INPUT_SIGNATURES_KEY,
)
from serialization import (
coerce_serialized_node_outputs,
deserialize_node_inputs,
serialize_mapping,
serialize_node_inputs,
serialize_node_outputs,
)
from session_state import (
RemoteSessionBridgeRef,
RemoteSessionHandle,
RemoteSessionStateError,
RemoteSessionValueRef,
is_remote_session_bridge_ref_payload,
is_remote_session_value_ref_payload,
)
from settings import get_settings
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class CloudPromptExecutionHooks:
"""Callbacks and stable errors supplied by the cloud entrypoint."""
emit_cloud_info: Callable[..., None]
timed_phase: Callable[..., AbstractContextManager[None]]
schedule_remote_cancel_restart: Callable[..., bool]
remote_subgraph_error: type[RuntimeError]
_PROMPT_EXECUTION_HOOKS: CloudPromptExecutionHooks | None = None
RemoteSubgraphExecutionError: type[RuntimeError] = RuntimeError
def configure_cloud_prompt_execution_hooks(
hooks: CloudPromptExecutionHooks,
) -> None:
"""Install prompt-execution callbacks without importing upward."""
global _PROMPT_EXECUTION_HOOKS
global RemoteSubgraphExecutionError
_PROMPT_EXECUTION_HOOKS = hooks
RemoteSubgraphExecutionError = hooks.remote_subgraph_error
def _prompt_execution_hooks() -> CloudPromptExecutionHooks:
"""Return configured callbacks or fail with a clear import-order error."""
if _PROMPT_EXECUTION_HOOKS is None:
raise RuntimeError("Cloud prompt-execution hooks have not been configured.")
return _PROMPT_EXECUTION_HOOKS
def _emit_cloud_info(message: str, *args: Any) -> None:
"""Delegate timestamped cloud logging to the stable entrypoint."""
_prompt_execution_hooks().emit_cloud_info(message, *args)
def _timed_phase(phase: str, **fields: Any) -> AbstractContextManager[None]:
"""Delegate phase timing to the stable entrypoint."""
return _prompt_execution_hooks().timed_phase(phase, **fields)
def _schedule_remote_cancel_restart(**kwargs: Any) -> bool:
"""Delegate poisoned-container retirement scheduling to the entrypoint."""
return _prompt_execution_hooks().schedule_remote_cancel_restart(**kwargs)
@dataclass
class _ReusablePromptExecutorState:
"""Hold one warm PromptExecutor and serialize access to its mutable caches."""
executor: Any
lock: threading.Lock
_PROMPT_EXECUTOR_STATES_LOCK = threading.Lock()
_PROMPT_EXECUTOR_STATES: dict[str, _ReusablePromptExecutorState] = {}
_DYNAMIC_PROMPT_WRAPPER_LOCK = threading.Lock()
_PROMPT_METADATA_STATE = threading.local()
def clear_warm_caches() -> None:
"""Clear reusable PromptExecutor state owned by this module."""
with _PROMPT_EXECUTOR_STATES_LOCK:
_PROMPT_EXECUTOR_STATES.clear()
@contextmanager
def _temporary_node_mapping(
node_mapping: dict[str, type[Any]] | None
) -> Iterator[None]:
"""Temporarily overlay node mappings for tests or custom runtimes."""
if node_mapping is None:
yield
return
import nodes
original_mappings = dict(nodes.NODE_CLASS_MAPPINGS)
original_display_mappings = dict(getattr(nodes, "NODE_DISPLAY_NAME_MAPPINGS", {}))
try:
nodes.NODE_CLASS_MAPPINGS.update(node_mapping)
for class_type in node_mapping:
nodes.NODE_DISPLAY_NAME_MAPPINGS.setdefault(class_type, class_type)
yield
finally:
nodes.NODE_CLASS_MAPPINGS.clear()
nodes.NODE_CLASS_MAPPINGS.update(original_mappings)
if hasattr(nodes, "NODE_DISPLAY_NAME_MAPPINGS"):
nodes.NODE_DISPLAY_NAME_MAPPINGS.clear()
nodes.NODE_DISPLAY_NAME_MAPPINGS.update(original_display_mappings)
@contextmanager
def _temporary_progress_hook(prompt_server: _NullPromptServer) -> Iterator[None]:
"""Install a ComfyUI progress hook so remote samplers emit numeric progress updates."""
import comfy.utils
import comfy.model_management
from comfy_execution.progress import get_progress_state
from comfy_execution.utils import get_executing_context
previous_hook = comfy.utils.PROGRESS_BAR_HOOK
def hook(
value: float,
total: float,
preview_image: Any,
prompt_id: str | None = None,
node_id: str | None = None,
) -> None:
"""Mirror ComfyUI progress-bar updates into the headless progress registry."""
executing_context = get_executing_context()
if prompt_id is None and executing_context is not None:
prompt_id = executing_context.prompt_id
if node_id is None and executing_context is not None:
node_id = executing_context.node_id
comfy.model_management.throw_exception_if_processing_interrupted()
if prompt_id is None:
prompt_id = prompt_server.last_prompt_id
if node_id is None:
node_id = prompt_server.last_node_id
if node_id is None:
return
resolved_node_id = str(node_id)
get_progress_state().update_progress(
resolved_node_id, value, total, preview_image
)
preview_emitter = getattr(prompt_server, "emit_preview_update", None)
if preview_image is not None and callable(preview_emitter):
preview_emitter(node_id=resolved_node_id, preview_image=preview_image)
comfy.utils.set_progress_bar_global_hook(hook)
try:
yield
finally:
comfy.utils.set_progress_bar_global_hook(previous_hook)
@contextmanager
def _temporary_remote_interrupt_monitor(
component_id: str,
cancellation_event: threading.Event | None,
interrupt_store: Any | None = None,
interrupt_flag_key: str | None = None,
) -> Iterator[None]:
"""Mirror shared cancellation requests into ComfyUI's interrupt flag inside Modal."""
if cancellation_event is None and (
interrupt_store is None or interrupt_flag_key is None
):
yield
return
import nodes
stop_event = threading.Event()
execution_completed_event = threading.Event()
restart_scheduled = False
try:
modal_exception_module = importlib.import_module("modal.exception")
except ModuleNotFoundError:
modal_exception_module = None
modal_client_closed_error = getattr(modal_exception_module, "ClientClosed", None)
def shared_cancel_flag_exists() -> bool:
"""Return whether the shared interrupt flag is present, tolerating Modal shutdown races."""
try:
return bool(interrupt_store.contains(interrupt_flag_key))
except Exception as exc:
if modal_client_closed_error is not None and isinstance(
exc, modal_client_closed_error
):
logger.info(
"Remote interrupt monitor stopped after Modal client shutdown for component=%s.",
component_id,
)
stop_event.set()
return False
raise
def consume_shared_cancel_flag() -> None:
"""Remove the shared interrupt flag if the Modal client is still alive."""
try:
interrupt_store.pop(interrupt_flag_key, None)
except Exception as exc:
if modal_client_closed_error is not None and isinstance(
exc, modal_client_closed_error
):
logger.info(
"Remote interrupt monitor skipped flag cleanup after Modal client shutdown for component=%s.",
component_id,
)
stop_event.set()
return
raise
def monitor_interrupts() -> None:
"""Set ComfyUI's interrupt flag once the caller requests cancellation."""
nonlocal restart_scheduled
while not stop_event.is_set():
if cancellation_event is not None and cancellation_event.wait(timeout=0.1):
logger.info(
"Remote interrupt monitor tripped local event for component=%s.",
component_id,
)
if not restart_scheduled:
restart_scheduled = _schedule_remote_cancel_restart(
component_id=component_id,
completion_event=execution_completed_event,
)
nodes.interrupt_processing()
return
if interrupt_store is None or interrupt_flag_key is None:
continue
if not shared_cancel_flag_exists():
continue
logger.info(
"Remote interrupt monitor observed shared cancel flag for component=%s.",
component_id,
)
consume_shared_cancel_flag()
if stop_event.is_set():
return
if cancellation_event is not None:
cancellation_event.set()
if not restart_scheduled:
restart_scheduled = _schedule_remote_cancel_restart(
component_id=component_id,
completion_event=execution_completed_event,
)
nodes.interrupt_processing()
return
interrupt_thread = threading.Thread(
target=monitor_interrupts,
name=f"modal-interrupt-{component_id}",
daemon=True,
)
interrupt_thread.start()
try:
yield
finally:
execution_completed_event.set()
stop_event.set()
interrupt_thread.join(timeout=1.0)
def _prompt_executor_ram_thresholds(
cache_ram_values: list[float],
) -> tuple[float, float]:
"""Return current ComfyUI active and inactive RAM-cache thresholds in GiB."""
model_management = importlib.import_module("comfy.model_management")
active_threshold = min(10.0, max(2.0, model_management.total_ram * 0.10 / 1024.0))
inactive_threshold = min(128.0, model_management.total_ram / 1024.0)
if cache_ram_values:
active_threshold = cache_ram_values[0]
if len(cache_ram_values) > 1:
inactive_threshold = cache_ram_values[1]
return active_threshold, inactive_threshold
def _prompt_executor_cache_config(execution: Any) -> tuple[Any, dict[str, float]]:
"""Return cache settings compatible with legacy and current ComfyUI CLI shapes."""
from comfy.cli_args import args
cache_ram = args.cache_ram
if isinstance(cache_ram, list):
active_threshold = 0.0
inactive_threshold = 0.0
if not args.cache_classic and not args.cache_none and args.cache_lru <= 0:
active_threshold, inactive_threshold = _prompt_executor_ram_thresholds(
cache_ram
)
cache_type = execution.CacheType.RAM_PRESSURE
if args.cache_classic:
cache_type = execution.CacheType.CLASSIC
elif args.cache_lru > 0:
cache_type = execution.CacheType.LRU
elif args.cache_none:
cache_type = execution.CacheType.NONE
return cache_type, {
"lru": args.cache_lru,
"ram": active_threshold,
"ram_inactive": inactive_threshold,
}
cache_type = execution.CacheType.CLASSIC
if args.cache_lru > 0:
cache_type = execution.CacheType.LRU
elif cache_ram > 0:
cache_type = execution.CacheType.RAM_PRESSURE
elif args.cache_none:
cache_type = execution.CacheType.NONE
return cache_type, {"lru": args.cache_lru, "ram": cache_ram}
def _serialize_prompt_executor_cache_scope(
cache_type: Any,
cache_args: dict[str, Any],
custom_nodes_root: Path | None,
) -> str:
"""Return a stable cache scope key for reusable PromptExecutor instances."""
return json.dumps(
{
"cache_type": str(cache_type),
"cache_args": cache_args,
"custom_nodes_root": str(custom_nodes_root.resolve())
if custom_nodes_root is not None
else None,
},
sort_keys=True,
default=str,
)
def _reset_prompt_executor_request_state(executor: Any, prompt_server: Any) -> None:
"""Prepare a reusable PromptExecutor for a fresh request without discarding its caches."""
executor.server = prompt_server
executor.status_messages = []
executor.success = True
executor.history_result = {}
prompt_server.client_id = None
prompt_server.last_node_id = None
def _get_or_create_prompt_executor_state(
execution: Any,
prompt_server: Any,
cache_type: Any,
cache_args: dict[str, Any],
custom_nodes_root: Path | None,
) -> _ReusablePromptExecutorState:
"""Return the warm-container PromptExecutor state for a cache scope, creating it once."""
state_key = _serialize_prompt_executor_cache_scope(
cache_type, cache_args, custom_nodes_root
)
with _PROMPT_EXECUTOR_STATES_LOCK:
existing_state = _PROMPT_EXECUTOR_STATES.get(state_key)
if existing_state is not None:
_emit_cloud_info("Prompt executor cache hit scope=%s", state_key)
return existing_state
_emit_cloud_info("Prompt executor cache miss scope=%s", state_key)
executor = execution.PromptExecutor(
prompt_server,
cache_type=cache_type,
cache_args=cache_args,
)
state = _ReusablePromptExecutorState(executor=executor, lock=threading.Lock())
_PROMPT_EXECUTOR_STATES[state_key] = state
return state
def _execute_prompt_executor_compat(
executor: Any,
*,
prompt: dict[str, Any],
prompt_id: str,
extra_data: dict[str, Any],
execute_outputs: list[str],
) -> None:
"""Execute a ComfyUI prompt across synchronous and asynchronous executor APIs."""
execution_result = executor.execute(
prompt=prompt,
prompt_id=prompt_id,
extra_data=extra_data,
execute_outputs=execute_outputs,
)
if inspect.isawaitable(execution_result):
asyncio.run(execution_result)
def _install_metadata_safe_dynamic_prompt_wrapper(execution: Any) -> None:
"""Let hidden PROMPT inputs see metadata-safe graphs instead of hydrated tensors."""
with _DYNAMIC_PROMPT_WRAPPER_LOCK:
dynamic_prompt_class = getattr(execution, "DynamicPrompt", None)
if dynamic_prompt_class is None:
raise RemoteSubgraphExecutionError(
"The ComfyUI execution module does not expose DynamicPrompt."
)
if bool(getattr(dynamic_prompt_class, "_comfy_modal_metadata_safe", False)):
return
class MetadataSafeDynamicPrompt(dynamic_prompt_class):
"""DynamicPrompt variant with a separate hidden-input metadata graph."""
_comfy_modal_metadata_safe = True
def __init__(self, original_prompt: dict[str, Any]) -> None:
"""Capture the calling thread's metadata prompt before execution starts."""
super().__init__(original_prompt)
self._comfy_modal_metadata_prompt = getattr(
_PROMPT_METADATA_STATE,
"prompt",
None,
)
def get_original_prompt(self) -> dict[str, Any]:
"""Return the JSON-safe graph exposed through ComfyUI's hidden PROMPT input."""
if self._comfy_modal_metadata_prompt is not None:
return self._comfy_modal_metadata_prompt
return super().get_original_prompt()
execution.DynamicPrompt = MetadataSafeDynamicPrompt
logger.info(
"Installed metadata-safe DynamicPrompt wrapper for hydrated remote boundary inputs."
)
@contextmanager
def _temporary_prompt_metadata(prompt: dict[str, Any]) -> Iterator[None]:
"""Expose one JSON-safe hidden PROMPT graph to this execution thread."""
had_previous_prompt = hasattr(_PROMPT_METADATA_STATE, "prompt")
previous_prompt = getattr(_PROMPT_METADATA_STATE, "prompt", None)
_PROMPT_METADATA_STATE.prompt = prompt
try:
yield
finally:
if had_previous_prompt:
_PROMPT_METADATA_STATE.prompt = previous_prompt
else:
delattr(_PROMPT_METADATA_STATE, "prompt")
def _copy_json_safe_prompt_metadata(prompt: dict[str, Any]) -> dict[str, Any]:
"""Copy and validate the graph exposed to metadata-writing custom nodes."""
metadata_prompt = copy.deepcopy(prompt)
try:
json.dumps(metadata_prompt)
except (TypeError, ValueError) as exc:
raise RemoteSubgraphExecutionError(
"Remote subgraph prompt metadata was not JSON-compatible before boundary hydration."
) from exc
return metadata_prompt
def _invoke_original_node(
node_class: type[Any],
node_data: dict[str, Any],
kwargs: dict[str, Any],
) -> tuple[Any, ...]:
"""Execute an original V1 or V3 node class and normalize its outputs."""
class_type = node_data["class_type"]
logger.info("Executing remote node %s", class_type)
if hasattr(node_class, "GET_SCHEMA"):
node_output = node_class.execute(**kwargs)
if hasattr(node_output, "result"):
result = node_output.result
return tuple(result) if result is not None else tuple()
return tuple(node_output)
instance = node_class()
function_name = getattr(node_class, "FUNCTION", "execute")
function = getattr(instance, function_name)
result = function(**kwargs)
if result is None:
return tuple()
if isinstance(result, tuple):
return result
if isinstance(result, list):
return tuple(result)
return (result,)
def execute_node_locally(
node_data: dict[str, Any],
kwargs_payload: bytes | bytearray | str | dict[str, Any],
node_mapping: dict[str, type[Any]] | None = None,
cancellation_event: threading.Event | None = None,
interrupt_store: Any | None = None,
interrupt_flag_key: str | None = None,
) -> bytes:
"""Execute a single target node in-process and return serialized outputs."""
outputs = _execute_node_locally_raw(
node_data,
kwargs_payload,
node_mapping=node_mapping,
cancellation_event=cancellation_event,
interrupt_store=interrupt_store,
interrupt_flag_key=interrupt_flag_key,
)
return serialize_node_outputs(outputs)
def _execute_node_locally_raw(
node_data: dict[str, Any],
kwargs_payload: bytes | bytearray | str | dict[str, Any],
node_mapping: dict[str, type[Any]] | None = None,
cancellation_event: threading.Event | None = None,
interrupt_store: Any | None = None,
interrupt_flag_key: str | None = None,
) -> tuple[Any, ...]:
"""Execute a single target node in-process and return raw node outputs."""
custom_nodes_root = _extract_custom_nodes_bundle(
node_data.get("custom_nodes_bundle")
)
_ensure_comfy_runtime_initialized(custom_nodes_root)
kwargs = _rewrite_modal_asset_references(deserialize_node_inputs(kwargs_payload))
component_id = str(
node_data.get("component_id") or node_data.get("class_type") or "single-node"
)
if node_mapping is not None:
class_type = node_data["class_type"]
if class_type not in node_mapping:
raise KeyError(f"Remote node class {class_type!r} is not registered.")
with (
_patched_folder_paths_absolute_lookup(),
_temporary_remote_interrupt_monitor(
component_id,
cancellation_event,
interrupt_store=interrupt_store,
interrupt_flag_key=interrupt_flag_key,
),
):
return _invoke_original_node(node_mapping[class_type], node_data, kwargs)
with _temporary_node_mapping(node_mapping):
resolved_node_mapping = _load_nodes_module().NODE_CLASS_MAPPINGS
class_type = node_data["class_type"]
if class_type not in resolved_node_mapping:
raise KeyError(f"Remote node class {class_type!r} is not registered.")
with (
_patched_folder_paths_absolute_lookup(),
_temporary_remote_interrupt_monitor(
component_id,
cancellation_event,
interrupt_store=interrupt_store,
interrupt_flag_key=interrupt_flag_key,
),
):
return _invoke_original_node(
resolved_node_mapping[class_type], node_data, kwargs
)
def _remote_session_ref_cache_signature(value: Any) -> Any | None:
"""Return cache-key metadata for any remote-session refs nested in `value`."""
if is_remote_session_bridge_ref_payload(value):
return {
"kind": "remote_session_bridge_ref",
"bridge_key": value.get("bridge_key"),
"node_id": value.get("node_id"),
"output_index": value.get("output_index"),
}
if is_remote_session_value_ref_payload(value):
return {
"kind": "remote_session_value_ref",
"session_id": value.get("session_id"),
"node_id": value.get("node_id"),
"output_index": value.get("output_index"),
}
if isinstance(value, list):
items = [_remote_session_ref_cache_signature(item) for item in value]
if any(item is not None for item in items):
return {"kind": "list", "items": items}
return None
if isinstance(value, tuple):
items = [_remote_session_ref_cache_signature(item) for item in value]
if any(item is not None for item in items):
return {"kind": "tuple", "items": items}
return None
if isinstance(value, Mapping):
items = {
str(key): _remote_session_ref_cache_signature(item)
for key, item in value.items()
}
filtered_items = {key: item for key, item in items.items() if item is not None}
if filtered_items:
return {"kind": "mapping", "items": filtered_items}
return None
def _boundary_input_cache_signature(
*,
source_signature: Any,
cache_value: Any,
) -> Any | None:
"""Return a cache signature for one boundary input and its unresolved source value."""
ref_signature = _remote_session_ref_cache_signature(cache_value)
if ref_signature is None:
if isinstance(source_signature, str) and source_signature:
return source_signature
return None
return {
"source_signature": source_signature
if isinstance(source_signature, str)
else None,
"remote_session_refs": ref_signature,
}
def _apply_boundary_inputs(
prompt: dict[str, Any],
boundary_input_specs: list[dict[str, Any]],
hydrated_inputs: dict[str, Any],
cache_signature_inputs: dict[str, Any] | None = None,
) -> None:
"""Inject hydrated local boundary inputs into a remote subgraph prompt."""
for boundary_input in boundary_input_specs:
proxy_input_name = str(boundary_input["proxy_input_name"])
if proxy_input_name not in hydrated_inputs:
raise KeyError(f"Missing hydrated boundary input {proxy_input_name!r}.")
value = hydrated_inputs[proxy_input_name]
io_type = (
str(boundary_input["io_type"])
if boundary_input.get("io_type") is not None
else None
)
for target in boundary_input.get("targets", []):
node_id = str(target["node_id"])
input_name = str(target["input_name"])
prompt_node = prompt[node_id]
prompt_node["inputs"][input_name] = _normalize_prompt_input_value(
value,
io_type=io_type,
)
cache_signature = _boundary_input_cache_signature(
source_signature=boundary_input.get("source_signature"),
cache_value=(
cache_signature_inputs.get(proxy_input_name)
if cache_signature_inputs is not None
and proxy_input_name in cache_signature_inputs
else value
),
)
if cache_signature is not None:
boundary_signatures = prompt_node.setdefault(
_BOUNDARY_INPUT_SIGNATURES_KEY, {}
)
if isinstance(boundary_signatures, dict):
boundary_signatures[input_name] = cache_signature
def _collapse_cache_slot(slot_values: Any, is_list: bool) -> Any:
"""Convert a PromptExecutor cache slot back into a node-style output value."""
if is_list:
return slot_values
if not isinstance(slot_values, list):
return slot_values
if len(slot_values) == 1:
return slot_values[0]
return slot_values
@dataclass(frozen=True)
class _PreparedSubgraphExecution:
"""Hold validated prompt state required by one PromptExecutor invocation."""
component_id: str
payload: dict[str, Any]
prompt: dict[str, Any]
metadata_prompt: dict[str, Any]
execution: Any
cache_type: Any
cache_args: dict[str, float]
session_handle: RemoteSessionHandle | None
def _resolve_subgraph_session_inputs(
*,
payload: dict[str, Any],
hydrated_inputs: dict[str, Any],
custom_nodes_root: Path | None,
cancellation_event: threading.Event | None,
interrupt_store: Any | None,
interrupt_flag_key: str | None,
) -> tuple[
dict[str, Any],
RemoteSessionHandle | None,
dict[str, Any],
tuple[Any, ...] | None,
]:
"""Normalize a payload and resolve its session-backed boundary inputs."""
component_id = str(payload.get("component_id", "modal-subgraph"))
normalized_payload = _trim_subgraph_payload_to_required_nodes(
_normalize_subgraph_payload(payload)
)
session_handle = _payload_remote_session_handle(normalized_payload)
resolution_stats = _RemoteSessionBridgeResolutionStats()
loader_cache_before = _loader_cache_metric_snapshot()
resolved_inputs = _resolve_remote_session_inputs(
dict(hydrated_inputs),
component_id=component_id,
target_session_handle=session_handle,
custom_nodes_root=custom_nodes_root,
cancellation_event=cancellation_event,
interrupt_store=interrupt_store,
interrupt_flag_key=interrupt_flag_key,
resolution_stats=resolution_stats,
)
_log_remote_session_resolution_summary(
component_id=component_id,
resolution_stats=resolution_stats,
loader_cache_before=loader_cache_before,
loader_cache_after=_loader_cache_metric_snapshot(),
)
short_circuit_outputs = _short_circuit_restored_session_output_subgraph(
payload=normalized_payload,
hydrated_inputs=hydrated_inputs,
session_handle=session_handle,
resolution_stats=resolution_stats,
)
return normalized_payload, session_handle, resolved_inputs, short_circuit_outputs
def _load_subgraph_execution_runtime(
*,
component_id: str,
normalized_payload: dict[str, Any],
prompt: dict[str, Any],
custom_nodes_root: Path | None,
) -> tuple[Any, Any, dict[str, float], Mapping[str, type[Any]]]:
"""Load ComfyUI execution APIs and resolve every prompt node class."""
with _timed_phase("load_execution_module", component=component_id):
execution = _load_execution_module()
_install_metadata_safe_dynamic_prompt_wrapper(execution)
cache_type, cache_args = _prompt_executor_cache_config(execution)
custom_nodes_bundle_path = normalized_payload.get("custom_nodes_bundle")
resolved_node_mapping = _ensure_prompt_node_classes_registered(
component_id=component_id,
prompt=prompt,
custom_nodes_root=custom_nodes_root,
custom_nodes_bundle_path=(
custom_nodes_bundle_path
if isinstance(custom_nodes_bundle_path, str)
else None
),
)
return execution, cache_type, cache_args, resolved_node_mapping
def _prepare_subgraph_execution(
*,
source_payload: dict[str, Any],
normalized_payload: dict[str, Any],
hydrated_inputs: dict[str, Any],
resolved_inputs: dict[str, Any],
custom_nodes_root: Path | None,
session_handle: RemoteSessionHandle | None,
) -> _PreparedSubgraphExecution:
"""Build, hydrate, load, coerce, and validate one remote ComfyUI prompt."""
component_id = str(source_payload.get("component_id", "modal-subgraph"))
if session_handle is not None:
logger.info(
"Executing cloud subgraph component=%s with remote_session session_id=%s prompt_id=%s owner_component_id=%s.",
component_id,
session_handle.session_id,
session_handle.prompt_id,
session_handle.owner_component_id,
)
with _timed_phase("prepare_subgraph_prompt", component=component_id):
prompt = _rewrite_modal_asset_references(
copy.deepcopy(normalized_payload["subgraph_prompt"])
)
metadata_prompt = _copy_json_safe_prompt_metadata(prompt)
_apply_boundary_inputs(
prompt=prompt,
boundary_input_specs=list(normalized_payload.get("boundary_inputs", [])),
hydrated_inputs=resolved_inputs,
cache_signature_inputs=hydrated_inputs,
)
execution, cache_type, cache_args, resolved_node_mapping = (
_load_subgraph_execution_runtime(
component_id=component_id,
normalized_payload=normalized_payload,
prompt=prompt,
custom_nodes_root=custom_nodes_root,
)
)
_coerce_prompt_primitive_input_values(prompt, resolved_node_mapping)
_validate_prompt_input_shapes(
prompt,
resolved_node_mapping,
list(normalized_payload.get("boundary_inputs", [])),
)
_validate_required_prompt_inputs(prompt, resolved_node_mapping)
return _PreparedSubgraphExecution(
component_id=component_id,
payload=normalized_payload,
prompt=prompt,
metadata_prompt=metadata_prompt,
execution=execution,
cache_type=cache_type,
cache_args=cache_args,
session_handle=session_handle,
)
def _install_subgraph_node_cache_restore(
*,
prepared: _PreparedSubgraphExecution,
executor: Any,
cache_store: Any | None,
) -> _PersistedNodeCacheRestoreState | None:
"""Install persisted-cache hydration when distributed caching is enabled."""
if cache_store is None or get_settings().node_output_cache_max_bytes <= 0:
return None
return _install_prompt_executor_persisted_cache_restore(
prepared.execution,
executor,
component_id=prepared.component_id,
prompt=prepared.prompt,
required_materialized_node_ids=_boundary_output_node_ids(
prepared.payload.get("boundary_outputs", [])
),
cache_store=cache_store,
)
def _emit_restored_subgraph_cache_events(
component_id: str,
restore_state: _PersistedNodeCacheRestoreState | None,
status_callback: Callable[[dict[str, Any]], None] | None,
) -> None:
"""Log and publish persisted-cache hits from one prompt execution."""
restored_node_ids = (
restore_state.restored_node_ids if restore_state is not None else []
)
if not restored_node_ids:
return
_emit_cloud_info(
"Restored %d persisted node cache entries for component=%s: %s",
len(restored_node_ids),
component_id,
restored_node_ids,
)
_emit_restored_node_cache_events(status_callback, restored_node_ids)
def _execute_prepared_prompt(
*,
source_payload: dict[str, Any],
prepared: _PreparedSubgraphExecution,
executor: Any,
prompt_server: _TracingPromptServer,
cache_store: Any | None,
status_callback: Callable[[dict[str, Any]], None] | None,
) -> _PersistedNodeCacheRestoreState | None:
"""Execute one prepared prompt while protecting its reusable executor state."""
_reset_prompt_executor_request_state(executor, prompt_server)
restore_state = _install_subgraph_node_cache_restore(
prepared=prepared,
executor=executor,
cache_store=cache_store,
)
prompt_server.configure_boundary_output_stream(
boundary_outputs=list(prepared.payload.get("boundary_outputs", [])),
lookup_cache_entry=lambda node_id: _prompt_executor_cache_get_sync(
executor.caches.outputs,
node_id,
),
)
try:
with _timed_phase(
"prompt_executor_execute",
component=prepared.component_id,
execute_nodes=list(prepared.payload.get("execute_node_ids", [])),
):
with _temporary_prompt_metadata(prepared.metadata_prompt):
_execute_prompt_executor_compat(
executor,
prompt=prepared.prompt,
prompt_id=str(
source_payload.get("prompt_id") or prepared.component_id
),
extra_data=copy.deepcopy(prepared.payload.get("extra_data") or {}),
execute_outputs=list(prepared.payload.get("execute_node_ids", [])),
)
finally:
if restore_state is not None:
restore_state.restore_original_method()
_emit_restored_subgraph_cache_events(
prepared.component_id,
restore_state,
status_callback,
)
return restore_state
def _persist_prepared_prompt_cache(
*,
prepared: _PreparedSubgraphExecution,
executor: Any,
cache_store: Any | None,
restore_state: _PersistedNodeCacheRestoreState | None,
) -> None:
"""Write eligible prompt outputs into the distributed node cache."""
if cache_store is None or get_settings().node_output_cache_max_bytes <= 0:
return
with _timed_phase("persist_node_cache", component=prepared.component_id):
persisted_node_ids = asyncio.run(
_persist_node_output_cache_entries(
executor,
prompt=prepared.prompt,
cache_store=cache_store,
restored_cache_keys_by_node_id=(
restore_state.restored_cache_keys_by_node_id
if restore_state is not None
else None
),
),
)
if persisted_node_ids:
_emit_cloud_info(
"Persisted %d node cache entries for component=%s: %s",
len(persisted_node_ids),
prepared.component_id,
persisted_node_ids,
)
def _store_session_boundary_output(
*,
prepared: _PreparedSubgraphExecution,
hydrated_inputs: dict[str, Any],
boundary_output: Mapping[str, Any],
output_value: Any,
) -> Any:
"""Store one session-backed boundary value and return its durable reference."""
session_handle = prepared.session_handle
if session_handle is None:
raise RemoteSessionStateError(
"Session-backed boundary outputs require payload.remote_session."
)
node_id = str(boundary_output["node_id"])
output_index = int(boundary_output["output_index"])
bridge_record = _build_remote_session_bridge_record(
payload=prepared.payload,
hydrated_inputs=hydrated_inputs,
node_id=node_id,
output_index=output_index,
io_type=str(boundary_output.get("io_type") or "*"),
output_value=output_value,
)
_store_remote_session_bridge_record(bridge_record)
_store_remote_session_bridge_value(bridge_record.bridge_key, output_value)
live_ref = remote_session_store().put_bridge_output(
session_handle,
bridge_key=bridge_record.bridge_key,
node_id=node_id,
output_index=output_index,
value=output_value,
)
return RemoteSessionBridgeRef(
bridge_key=bridge_record.bridge_key,
node_id=node_id,
output_index=output_index,
session_id=live_ref.session_id,
).to_payload()
def _collect_subgraph_boundary_outputs(
*,
prepared: _PreparedSubgraphExecution,
executor: Any,
hydrated_inputs: dict[str, Any],
) -> tuple[Any, ...]:
"""Collect and optionally persist every declared subgraph boundary output."""
boundary_outputs = list(prepared.payload.get("boundary_outputs", []))
outputs: list[Any] = []
with _timed_phase(
"collect_boundary_outputs",
component=prepared.component_id,
output_count=len(boundary_outputs),
):
for boundary_output in boundary_outputs:
node_id = str(boundary_output["node_id"])
output_index = int(boundary_output["output_index"])
cache_entry = _prompt_executor_cache_get_sync(
executor.caches.outputs,
node_id,
)
if cache_entry is None:
raise RemoteSubgraphExecutionError(
f"Remote subgraph did not produce cache entry for node {node_id}."
)
if output_index >= len(cache_entry.outputs):
raise RemoteSubgraphExecutionError(
f"Remote subgraph output index {output_index} is missing for node {node_id}."
)
output_value = _collapse_cache_slot(
slot_values=cache_entry.outputs[output_index],
is_list=bool(boundary_output.get("is_list", False)),
)
if bool(boundary_output.get("session_output")):
output_value = _store_session_boundary_output(
prepared=prepared,
hydrated_inputs=hydrated_inputs,
boundary_output=boundary_output,
output_value=output_value,
)
outputs.append(output_value)
return tuple(outputs)
def _execute_prepared_subgraph(
*,
source_payload: dict[str, Any],
prepared: _PreparedSubgraphExecution,
hydrated_inputs: dict[str, Any],
custom_nodes_root: Path | None,
status_callback: Callable[[dict[str, Any]], None] | None,
cancellation_event: threading.Event | None,
interrupt_store: Any | None,
interrupt_flag_key: str | None,
) -> tuple[Any, ...]:
"""Run a prepared subgraph under ComfyUI's temporary execution adapters."""
prompt_server = _TracingPromptServer(
prepared.component_id,
prepared.prompt,
status_callback=status_callback,
)
with (
_temporary_node_mapping(None),
_patched_folder_paths_absolute_lookup(),
_temporary_remote_interrupt_monitor(
prepared.component_id,
cancellation_event,
interrupt_store=interrupt_store,
interrupt_flag_key=interrupt_flag_key,
),
_temporary_progress_hook(prompt_server),
):
with _timed_phase("create_prompt_executor", component=prepared.component_id):
executor_state = _get_or_create_prompt_executor_state(
execution=prepared.execution,
prompt_server=prompt_server,
cache_type=prepared.cache_type,
cache_args=prepared.cache_args,
custom_nodes_root=custom_nodes_root,
)
cache_store = _node_output_cache_store()
with executor_state.lock:
restore_state = _execute_prepared_prompt(
source_payload=source_payload,
prepared=prepared,
executor=executor_state.executor,
prompt_server=prompt_server,
cache_store=cache_store,
status_callback=status_callback,
)
executor = executor_state.executor
if not executor.success:
_log_prompt_executor_failure_details(
component_id=prepared.component_id,
prompt=prepared.prompt,
normalized_payload=prepared.payload,
executor=executor,
)
raise RemoteSubgraphExecutionError(_extract_prompt_executor_error(executor))
_persist_prepared_prompt_cache(
prepared=prepared,
executor=executor,
cache_store=cache_store,
restore_state=restore_state,
)
return _collect_subgraph_boundary_outputs(
prepared=prepared,
executor=executor,
hydrated_inputs=hydrated_inputs,
)
def _execute_subgraph_prompt(
payload: dict[str, Any],
hydrated_inputs: dict[str, Any],
custom_nodes_root: Path | None,
status_callback: Callable[[dict[str, Any]], None] | None = None,
cancellation_event: threading.Event | None = None,
interrupt_store: Any | None = None,
interrupt_flag_key: str | None = None,
) -> tuple[Any, ...]:
"""Execute a remote component prompt and return its exported outputs."""
normalized_payload, session_handle, resolved_inputs, short_circuit_outputs = (
_resolve_subgraph_session_inputs(
payload=payload,
hydrated_inputs=hydrated_inputs,
custom_nodes_root=custom_nodes_root,
cancellation_event=cancellation_event,
interrupt_store=interrupt_store,
interrupt_flag_key=interrupt_flag_key,
)
)
if short_circuit_outputs is not None:
logger.info(
"Skipping prompt_executor_execute for component=%s because all %d session-backed outputs were restored into session_id=%s.",
payload.get("component_id", "modal-subgraph"),
len(short_circuit_outputs),
session_handle.session_id if session_handle is not None else None,
)
return short_circuit_outputs
prepared = _prepare_subgraph_execution(
source_payload=payload,
normalized_payload=normalized_payload,
hydrated_inputs=hydrated_inputs,
resolved_inputs=resolved_inputs,
custom_nodes_root=custom_nodes_root,
session_handle=session_handle,
)
return _execute_prepared_subgraph(
source_payload=payload,
prepared=prepared,
hydrated_inputs=hydrated_inputs,
custom_nodes_root=custom_nodes_root,
status_callback=status_callback,
cancellation_event=cancellation_event,
interrupt_store=interrupt_store,
interrupt_flag_key=interrupt_flag_key,
)
def _short_circuit_restored_session_output_subgraph(
*,
payload: dict[str, Any],
hydrated_inputs: dict[str, Any],
session_handle: RemoteSessionHandle | None,
resolution_stats: _RemoteSessionBridgeResolutionStats,
) -> tuple[Any, ...] | None:
"""Return session-backed outputs directly when bridge restoration already satisfied them all."""
boundary_outputs = list(payload.get("boundary_outputs", []))
if session_handle is None or not boundary_outputs:
return None
if resolution_stats.input_ref_count <= 0 or resolution_stats.replay_count > 0:
return None
if any(
not bool(boundary_output.get("session_output"))
for boundary_output in boundary_outputs
):
return None
restored_outputs: list[Any] = []
for boundary_output in boundary_outputs:
node_id = str(boundary_output["node_id"])
output_index = int(boundary_output["output_index"])
try:
output_value = remote_session_store().get_output(
RemoteSessionValueRef(
session_id=session_handle.session_id,
node_id=node_id,
output_index=output_index,
)
)
except RemoteSessionStateError:
return None
bridge_record = _build_remote_session_bridge_record(
payload=payload,
hydrated_inputs=hydrated_inputs,
node_id=node_id,
output_index=output_index,
io_type=str(boundary_output.get("io_type") or "*"),
output_value=output_value,
)
_store_remote_session_bridge_record(bridge_record)
_store_remote_session_bridge_value(bridge_record.bridge_key, output_value)
live_ref = remote_session_store().put_bridge_output(
session_handle,
bridge_key=bridge_record.bridge_key,
node_id=node_id,
output_index=output_index,
value=output_value,
)
restored_outputs.append(
RemoteSessionBridgeRef(
bridge_key=bridge_record.bridge_key,
node_id=node_id,
output_index=output_index,
session_id=live_ref.session_id,
).to_payload()
)
return tuple(restored_outputs)
def execute_subgraph_locally(
payload: dict[str, Any],
kwargs_payload: bytes | bytearray | str | dict[str, Any],
status_callback: Callable[[dict[str, Any]], None] | None = None,
cancellation_event: threading.Event | None = None,
interrupt_store: Any | None = None,
interrupt_flag_key: str | None = None,
) -> bytes:
"""Execute a rewritten remote component in-process and return serialized outputs."""
component_id = str(payload.get("component_id", "modal-subgraph"))
session_handle = _payload_remote_session_handle(payload)
with _timed_phase("execute_subgraph_locally", component=component_id):
custom_nodes_root = _extract_custom_nodes_bundle(
payload.get("custom_nodes_bundle")
)
_ensure_comfy_runtime_initialized(custom_nodes_root)
with _timed_phase("deserialize_boundary_inputs", component=component_id):
hydrated_inputs = deserialize_node_inputs(kwargs_payload)
logger.info(
"Executing cloud-local subgraph component=%s hydrated_inputs=%d session_id=%s clear_remote_session=%s.",
component_id,
len(hydrated_inputs),
session_handle.session_id if session_handle is not None else None,
bool(payload.get("clear_remote_session")),
)
try:
with _timed_phase("subgraph_worker_roundtrip", component=component_id):
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(
_execute_subgraph_prompt,
payload,
hydrated_inputs,
custom_nodes_root,
status_callback,
cancellation_event,
interrupt_store,
interrupt_flag_key,
)
outputs = future.result()
finally:
if bool(payload.get("clear_remote_session")) and session_handle is not None:
logger.info(
"Clearing remote session after cloud component=%s session_id=%s.",
component_id,
session_handle.session_id,
)
remote_session_store().clear_session(session_handle)
with _timed_phase("serialize_boundary_outputs", component=component_id):
return serialize_node_outputs(outputs)