2190 lines
81 KiB
Python
2190 lines
81 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_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,
|
|
PRIMITIVE_WIDGET_INPUT_TYPES as _PRIMITIVE_WIDGET_INPUT_TYPES,
|
|
)
|
|
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_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,
|
|
PRIMITIVE_WIDGET_INPUT_TYPES as _PRIMITIVE_WIDGET_INPUT_TYPES,
|
|
)
|
|
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
|
|
|
|
|
|
def _extract_prompt_executor_error(executor: Any) -> str:
|
|
"""Extract a useful failure message from a PromptExecutor run."""
|
|
for event, data in reversed(executor.status_messages):
|
|
if event == "execution_error":
|
|
return _format_prompt_executor_error_payload(data)
|
|
if event == "execution_interrupted":
|
|
return "Remote subgraph execution was interrupted."
|
|
return "Remote subgraph execution failed."
|
|
|
|
|
|
def _format_prompt_executor_error_payload(data: Any) -> str:
|
|
"""Return a richer human-readable PromptExecutor failure message when available."""
|
|
if not isinstance(data, dict):
|
|
return "Remote subgraph execution failed."
|
|
|
|
message = str(data.get("exception_message") or "Remote subgraph execution failed.")
|
|
node_id = data.get("node_id")
|
|
node_type = data.get("node_type")
|
|
current_inputs = data.get("current_inputs")
|
|
if node_id is None and node_type is None and not current_inputs:
|
|
return message
|
|
|
|
details: list[str] = [message]
|
|
if node_id is not None or node_type is not None:
|
|
details.append(f"node_id={node_id!r} node_type={node_type!r}")
|
|
if current_inputs:
|
|
details.append(f"current_inputs={current_inputs!r}")
|
|
return " | ".join(details)
|
|
|
|
|
|
def _extract_prompt_executor_error_payload(executor: Any) -> dict[str, Any] | None:
|
|
"""Return the most recent PromptExecutor execution_error payload when present."""
|
|
for event, data in reversed(executor.status_messages):
|
|
if event == "execution_error" and isinstance(data, dict):
|
|
return data
|
|
return None
|
|
|
|
|
|
def _summarize_suspicious_prompt_inputs(prompt: dict[str, Any]) -> list[str]:
|
|
"""Return compact descriptions of prompt inputs that still look list-wrapped."""
|
|
findings: list[str] = []
|
|
for node_id, node_info in sorted(prompt.items()):
|
|
inputs = node_info.get("inputs") or {}
|
|
for input_name, input_value in inputs.items():
|
|
if isinstance(input_value, list) and len(input_value) == 1:
|
|
findings.append(f"{node_id}.{input_name}={input_value!r}")
|
|
continue
|
|
if (
|
|
isinstance(input_value, list)
|
|
and len(input_value) == 2
|
|
and isinstance(input_value[0], str)
|
|
and isinstance(input_value[1], list)
|
|
):
|
|
findings.append(f"{node_id}.{input_name}={input_value!r}")
|
|
return findings
|
|
|
|
|
|
def _node_input_types(
|
|
node_class: type[Any],
|
|
live_inputs: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Return one node class's input schema finalized for the live prompt inputs."""
|
|
input_types_callable = getattr(node_class, "INPUT_TYPES", None)
|
|
if not callable(input_types_callable):
|
|
return {}
|
|
|
|
raw_input_types = input_types_callable()
|
|
if not isinstance(raw_input_types, dict):
|
|
return {}
|
|
|
|
has_v3_dynamic_inputs = any(
|
|
isinstance(input_config, tuple)
|
|
and bool(input_config)
|
|
and isinstance(input_config[0], str)
|
|
and input_config[0].startswith("COMFY_")
|
|
and input_config[0].endswith("_V3")
|
|
for section_name in ("required", "optional")
|
|
for input_config in (
|
|
raw_input_types.get(section_name, {}).values()
|
|
if isinstance(raw_input_types.get(section_name), dict)
|
|
else ()
|
|
)
|
|
)
|
|
if not has_v3_dynamic_inputs:
|
|
return raw_input_types
|
|
|
|
from comfy_api.latest import _io
|
|
|
|
finalized_input_types, _, _ = _io.get_finalized_class_inputs(
|
|
raw_input_types,
|
|
live_inputs or {},
|
|
)
|
|
logger.debug(
|
|
"Finalized V3 input schema for %s against prompt inputs %s.",
|
|
node_class.__name__,
|
|
sorted(str(input_name) for input_name in (live_inputs or {})),
|
|
)
|
|
return finalized_input_types
|
|
|
|
|
|
def _node_input_type_map(
|
|
node_class: type[Any],
|
|
live_inputs: dict[str, Any] | None = None,
|
|
) -> dict[str, str]:
|
|
"""Return one node class's finalized input types keyed by prompt input name."""
|
|
input_types = _node_input_types(node_class, live_inputs)
|
|
|
|
input_type_map: dict[str, str] = {}
|
|
for section_name in ("required", "optional", "hidden"):
|
|
section = input_types.get(section_name)
|
|
if not isinstance(section, dict):
|
|
continue
|
|
for input_name, input_config in section.items():
|
|
if not isinstance(input_config, tuple) or not input_config:
|
|
continue
|
|
declared_type = input_config[0]
|
|
if isinstance(declared_type, str):
|
|
input_type_map[str(input_name)] = declared_type
|
|
return input_type_map
|
|
|
|
|
|
def _node_required_input_names(
|
|
node_class: type[Any],
|
|
live_inputs: dict[str, Any] | None = None,
|
|
) -> set[str]:
|
|
"""Return required input names after expanding V3 dynamic prompt inputs."""
|
|
required_inputs = _node_input_types(node_class, live_inputs).get("required")
|
|
if not isinstance(required_inputs, dict):
|
|
return set()
|
|
return {str(input_name) for input_name in required_inputs}
|
|
|
|
|
|
def _coerce_primitive_prompt_input_value(
|
|
*,
|
|
node_id: str,
|
|
class_type: str,
|
|
input_name: str,
|
|
declared_type: str,
|
|
input_value: Any,
|
|
) -> Any:
|
|
"""Coerce one primitive prompt literal using ComfyUI's `validate_inputs` semantics."""
|
|
literal_value = (
|
|
input_value.get("__value__")
|
|
if isinstance(input_value, dict) and "__value__" in input_value
|
|
else input_value
|
|
)
|
|
if isinstance(literal_value, list):
|
|
return input_value
|
|
|
|
try:
|
|
if declared_type == "INT":
|
|
return int(literal_value)
|
|
if declared_type == "FLOAT":
|
|
return float(literal_value)
|
|
if declared_type == "STRING":
|
|
return str(literal_value)
|
|
if declared_type == "BOOLEAN":
|
|
return bool(literal_value)
|
|
except (OverflowError, TypeError, ValueError) as exc:
|
|
raise RemoteSubgraphExecutionError(
|
|
"Remote subgraph input could not be coerced to the declared primitive socket type."
|
|
f" node_id={node_id!r} node_type={class_type!r}"
|
|
f" input_name={input_name!r} declared_type={declared_type!r}"
|
|
f" received_value={literal_value!r}"
|
|
) from exc
|
|
|
|
return input_value
|
|
|
|
|
|
def _coerce_prompt_primitive_input_values(
|
|
prompt: dict[str, Any],
|
|
node_mapping: dict[str, type[Any]],
|
|
) -> None:
|
|
"""Mutate prompt literals in-place to match ComfyUI's primitive widget coercion."""
|
|
for node_id, prompt_node in sorted(prompt.items()):
|
|
class_type = str(prompt_node.get("class_type"))
|
|
node_class = node_mapping.get(class_type)
|
|
if node_class is None:
|
|
continue
|
|
inputs = prompt_node.get("inputs") or {}
|
|
input_type_map = _node_input_type_map(node_class, inputs)
|
|
if not input_type_map:
|
|
continue
|
|
for input_name, input_value in list(inputs.items()):
|
|
declared_type = input_type_map.get(str(input_name))
|
|
if declared_type not in _PRIMITIVE_WIDGET_INPUT_TYPES:
|
|
continue
|
|
if (
|
|
isinstance(input_value, list)
|
|
and len(input_value) == 2
|
|
and isinstance(input_value[0], str)
|
|
):
|
|
continue
|
|
coerced_value = _coerce_primitive_prompt_input_value(
|
|
node_id=str(node_id),
|
|
class_type=class_type,
|
|
input_name=str(input_name),
|
|
declared_type=declared_type,
|
|
input_value=input_value,
|
|
)
|
|
if coerced_value is not input_value:
|
|
logger.debug(
|
|
"Coerced remote primitive input %s.%s from %r to %r for type %s.",
|
|
node_id,
|
|
input_name,
|
|
input_value,
|
|
coerced_value,
|
|
declared_type,
|
|
)
|
|
inputs[input_name] = coerced_value
|
|
|
|
|
|
def _validate_prompt_input_shapes(
|
|
prompt: dict[str, Any],
|
|
node_mapping: dict[str, type[Any]],
|
|
boundary_input_specs: list[dict[str, Any]] | None = None,
|
|
) -> None:
|
|
"""Reject prompt inputs that still look invalid for primitive widget sockets."""
|
|
boundary_targets = {
|
|
(str(target.get("node_id")), str(target.get("input_name")))
|
|
for boundary_input in (boundary_input_specs or [])
|
|
for target in boundary_input.get("targets", [])
|
|
if target.get("node_id") is not None and target.get("input_name") is not None
|
|
}
|
|
for node_id, prompt_node in sorted(prompt.items()):
|
|
class_type = str(prompt_node.get("class_type"))
|
|
node_class = node_mapping.get(class_type)
|
|
if node_class is None:
|
|
continue
|
|
inputs = prompt_node.get("inputs") or {}
|
|
input_type_map = _node_input_type_map(node_class, inputs)
|
|
if not input_type_map:
|
|
continue
|
|
for input_name, input_value in inputs.items():
|
|
declared_type = input_type_map.get(str(input_name))
|
|
if declared_type not in _PRIMITIVE_WIDGET_INPUT_TYPES:
|
|
continue
|
|
if (
|
|
isinstance(input_value, list)
|
|
and len(input_value) == 2
|
|
and isinstance(input_value[0], str)
|
|
):
|
|
continue
|
|
if (str(node_id), str(input_name)) in boundary_targets:
|
|
continue
|
|
literal_value = (
|
|
input_value.get("__value__")
|
|
if isinstance(input_value, dict) and "__value__" in input_value
|
|
else input_value
|
|
)
|
|
if isinstance(literal_value, list):
|
|
raise RemoteSubgraphExecutionError(
|
|
"Remote subgraph input has an invalid list value for a primitive socket."
|
|
f" node_id={node_id!r} node_type={class_type!r}"
|
|
f" input_name={input_name!r} declared_type={declared_type!r}"
|
|
f" received_value={literal_value!r}"
|
|
)
|
|
|
|
|
|
def _validate_required_prompt_inputs(
|
|
prompt: dict[str, Any],
|
|
node_mapping: dict[str, type[Any]],
|
|
) -> None:
|
|
"""Reject remote prompt nodes that are missing declared required inputs."""
|
|
for node_id, prompt_node in sorted(prompt.items()):
|
|
class_type = str(prompt_node.get("class_type"))
|
|
node_class = node_mapping.get(class_type)
|
|
if node_class is None:
|
|
continue
|
|
inputs = prompt_node.get("inputs") or {}
|
|
missing_inputs = sorted(
|
|
_node_required_input_names(node_class, inputs) - set(inputs.keys())
|
|
)
|
|
if not missing_inputs:
|
|
continue
|
|
raise RemoteSubgraphExecutionError(
|
|
"Remote subgraph prompt is missing required node inputs before execution."
|
|
f" node_id={node_id!r} node_type={class_type!r}"
|
|
f" missing_inputs={missing_inputs!r} available_inputs={sorted(str(key) for key in inputs)!r}"
|
|
)
|
|
|
|
|
|
def _log_prompt_executor_failure_details(
|
|
*,
|
|
component_id: str,
|
|
prompt: dict[str, Any],
|
|
normalized_payload: dict[str, Any],
|
|
executor: Any,
|
|
) -> None:
|
|
"""Emit high-signal diagnostics for one remote PromptExecutor failure."""
|
|
error_payload = _extract_prompt_executor_error_payload(executor)
|
|
suspicious_inputs = _summarize_suspicious_prompt_inputs(prompt)
|
|
logger.error(
|
|
"Remote PromptExecutor failed for component=%s execute_node_ids=%s boundary_outputs=%s suspicious_inputs=%s error_payload=%s",
|
|
component_id,
|
|
normalized_payload.get("execute_node_ids", []),
|
|
[
|
|
{
|
|
"node_id": boundary_output.get("node_id"),
|
|
"output_index": boundary_output.get("output_index"),
|
|
}
|
|
for boundary_output in normalized_payload.get("boundary_outputs", [])
|
|
],
|
|
suspicious_inputs,
|
|
error_payload,
|
|
)
|
|
|
|
|
|
def _resolve_required_subgraph_nodes(
|
|
prompt: dict[str, Any],
|
|
execute_node_ids: list[str],
|
|
) -> list[str]:
|
|
"""Return the dependency closure needed to execute the requested subgraph nodes."""
|
|
required: set[str] = set()
|
|
pending = list(execute_node_ids)
|
|
logger.info(
|
|
"Resolving dependency closure for remote execute targets: %s", execute_node_ids
|
|
)
|
|
while pending:
|
|
node_id = str(pending.pop())
|
|
if node_id not in prompt:
|
|
logger.warning(
|
|
"Skipping missing remote execute target %s while resolving dependency closure.",
|
|
node_id,
|
|
)
|
|
continue
|
|
if node_id in required:
|
|
continue
|
|
required.add(node_id)
|
|
for input_value in (prompt[node_id].get("inputs") or {}).values():
|
|
if _is_link(input_value):
|
|
pending.append(str(input_value[0]))
|
|
resolved = sorted(required)
|
|
logger.info("Resolved remote dependency closure: %s", resolved)
|
|
return resolved
|
|
|
|
|
|
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 _unwrap_wrapped_prompt_link(value: Any) -> Any:
|
|
"""Collapse nested singleton wrappers around one serialized prompt link when present."""
|
|
candidate = value
|
|
while isinstance(candidate, list) and len(candidate) == 1:
|
|
candidate = candidate[0]
|
|
if _is_link(candidate):
|
|
return [candidate[0], _normalize_link_output_index(candidate[1])]
|
|
return value
|
|
|
|
|
|
def _normalize_prompt_input_value(value: Any, io_type: str | None = None) -> Any:
|
|
"""Unwrap transport-added singleton wrappers only for scalar-like prompt input values."""
|
|
wrapped_link = _unwrap_wrapped_prompt_link(value)
|
|
if wrapped_link is not value:
|
|
return wrapped_link
|
|
while (
|
|
isinstance(value, list)
|
|
and len(value) == 1
|
|
and (
|
|
io_type in _PRIMITIVE_WIDGET_INPUT_TYPES
|
|
or value[0] is None
|
|
or isinstance(value[0], bool | int | float | str)
|
|
)
|
|
):
|
|
value = value[0]
|
|
if isinstance(value, list) and len(value) == 2 and isinstance(value[0], str):
|
|
return [value[0], _normalize_link_output_index(value[1])]
|
|
if value is None or isinstance(value, bool | int | float | str):
|
|
return value
|
|
return value
|
|
|
|
|
|
def _normalize_subgraph_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return a subgraph payload with canonical prompt-link and output-index shapes."""
|
|
normalized_payload = copy.deepcopy(payload)
|
|
|
|
for node_info in normalized_payload.get("subgraph_prompt", {}).values():
|
|
inputs = node_info.get("inputs") or {}
|
|
for input_name, input_value in list(inputs.items()):
|
|
inputs[input_name] = _normalize_prompt_input_value(input_value)
|
|
|
|
for boundary_output in normalized_payload.get("boundary_outputs", []):
|
|
if "node_id" in boundary_output and isinstance(
|
|
boundary_output["node_id"], list
|
|
):
|
|
boundary_output["node_id"] = _normalize_prompt_input_value(
|
|
boundary_output["node_id"]
|
|
)
|
|
if "output_index" in boundary_output:
|
|
boundary_output["output_index"] = _normalize_link_output_index(
|
|
boundary_output["output_index"]
|
|
)
|
|
|
|
normalized_payload["execute_node_ids"] = [
|
|
_normalize_prompt_input_value(node_id)
|
|
for node_id in normalized_payload.get("execute_node_ids", [])
|
|
]
|
|
|
|
return normalized_payload
|
|
|
|
|
|
def _trim_subgraph_payload_to_required_nodes(payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""Trim a subgraph payload down to the dependency closure of its execute targets."""
|
|
trimmed_payload = copy.deepcopy(payload)
|
|
prompt = trimmed_payload.get("subgraph_prompt", {})
|
|
if not isinstance(prompt, dict):
|
|
return trimmed_payload
|
|
|
|
prompt_node_ids = {str(node_id) for node_id in prompt}
|
|
requested_execute_node_ids = [
|
|
str(node_id) for node_id in trimmed_payload.get("execute_node_ids", [])
|
|
]
|
|
available_execute_node_ids = [
|
|
node_id for node_id in requested_execute_node_ids if node_id in prompt_node_ids
|
|
]
|
|
dropped_execute_node_ids = [
|
|
node_id
|
|
for node_id in requested_execute_node_ids
|
|
if node_id not in prompt_node_ids
|
|
]
|
|
if dropped_execute_node_ids:
|
|
logger.warning(
|
|
"Dropping remote execute targets absent from subgraph prompt for component=%s: %s",
|
|
payload.get("component_id"),
|
|
dropped_execute_node_ids,
|
|
)
|
|
|
|
required_node_ids = set(
|
|
_resolve_required_subgraph_nodes(
|
|
prompt=prompt,
|
|
execute_node_ids=available_execute_node_ids,
|
|
)
|
|
)
|
|
if not required_node_ids:
|
|
return trimmed_payload
|
|
|
|
original_node_ids = list(prompt.keys())
|
|
trimmed_payload["subgraph_prompt"] = {
|
|
str(node_id): prompt[node_id]
|
|
for node_id in original_node_ids
|
|
if str(node_id) in required_node_ids
|
|
}
|
|
trimmed_payload["boundary_inputs"] = [
|
|
{
|
|
**copy.deepcopy(boundary_input),
|
|
"targets": [
|
|
copy.deepcopy(target)
|
|
for target in boundary_input.get("targets", [])
|
|
if str(target.get("node_id")) in required_node_ids
|
|
],
|
|
}
|
|
for boundary_input in trimmed_payload.get("boundary_inputs", [])
|
|
if any(
|
|
str(target.get("node_id")) in required_node_ids
|
|
for target in boundary_input.get("targets", [])
|
|
)
|
|
]
|
|
trimmed_payload["boundary_outputs"] = [
|
|
copy.deepcopy(boundary_output)
|
|
for boundary_output in trimmed_payload.get("boundary_outputs", [])
|
|
if str(boundary_output.get("node_id")) in required_node_ids
|
|
]
|
|
trimmed_payload["component_node_ids"] = [
|
|
str(node_id)
|
|
for node_id in trimmed_payload.get("component_node_ids", [])
|
|
if str(node_id) in required_node_ids
|
|
]
|
|
trimmed_payload["execute_node_ids"] = [
|
|
str(node_id)
|
|
for node_id in trimmed_payload.get("execute_node_ids", [])
|
|
if str(node_id) in required_node_ids
|
|
]
|
|
trimmed_payload["mapped_execute_node_ids"] = [
|
|
str(node_id)
|
|
for node_id in trimmed_payload.get("mapped_execute_node_ids", [])
|
|
if str(node_id) in required_node_ids
|
|
]
|
|
trimmed_payload["static_execute_node_ids"] = [
|
|
str(node_id)
|
|
for node_id in trimmed_payload.get("static_execute_node_ids", [])
|
|
if str(node_id) in required_node_ids
|
|
]
|
|
logger.info(
|
|
"Trimmed remote subgraph payload %s from %d prompt nodes to %d required nodes.",
|
|
payload.get("component_id"),
|
|
len(original_node_ids),
|
|
len(trimmed_payload["subgraph_prompt"]),
|
|
)
|
|
return trimmed_payload
|
|
|
|
|
|
@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)
|
|
|
|
|
|
def _mapped_phase_definition(
|
|
payload: dict[str, Any], phase_key: str
|
|
) -> dict[str, Any] | None:
|
|
"""Return one explicit mapped phase definition when queue-time planning provided it."""
|
|
phase_payload = payload.get(phase_key)
|
|
if isinstance(phase_payload, dict):
|
|
return phase_payload
|
|
return None
|
|
|
|
|
|
def _shared_subgraph_payload_fields(payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return the payload fields shared by every explicit mapped phase."""
|
|
shared_fields = {
|
|
"prompt_id": payload.get("prompt_id"),
|
|
"extra_data": copy.deepcopy(payload.get("extra_data") or {}),
|
|
"requires_volume_reload": bool(payload.get("requires_volume_reload", True)),
|
|
"volume_reload_marker": payload.get("volume_reload_marker"),
|
|
"uploaded_volume_paths": list(payload.get("uploaded_volume_paths", [])),
|
|
"terminate_container_on_error": bool(
|
|
payload.get("terminate_container_on_error", True)
|
|
),
|
|
"custom_nodes_bundle": payload.get("custom_nodes_bundle"),
|
|
}
|
|
snapshot_profile_key = payload.get("snapshot_profile_key")
|
|
if isinstance(snapshot_profile_key, str) and snapshot_profile_key.strip():
|
|
shared_fields["snapshot_profile_key"] = snapshot_profile_key.strip()
|
|
remote_session = payload.get("remote_session")
|
|
if remote_session is not None:
|
|
shared_fields["remote_session"] = copy.deepcopy(remote_session)
|
|
if bool(payload.get("clear_remote_session")):
|
|
shared_fields["clear_remote_session"] = True
|
|
return shared_fields
|
|
|
|
|
|
def _build_phase_subgraph_payload(
|
|
payload: dict[str, Any],
|
|
phase_key: str,
|
|
component_id: str,
|
|
) -> dict[str, Any]:
|
|
"""Return one explicit static or mapped subgraph payload."""
|
|
phase_definition = _mapped_phase_definition(payload, phase_key)
|
|
if phase_definition is None:
|
|
raise KeyError(f"Mapped payload is missing phase definition {phase_key!r}.")
|
|
|
|
return {
|
|
"payload_kind": "subgraph",
|
|
"component_id": component_id,
|
|
**_shared_subgraph_payload_fields(payload),
|
|
"component_node_ids": [
|
|
str(node_id)
|
|
for node_id in phase_definition.get("component_node_ids", [])
|
|
if str(node_id)
|
|
],
|
|
"subgraph_prompt": copy.deepcopy(phase_definition.get("subgraph_prompt", {})),
|
|
"boundary_inputs": copy.deepcopy(phase_definition.get("boundary_inputs", [])),
|
|
"boundary_outputs": copy.deepcopy(phase_definition.get("boundary_outputs", [])),
|
|
"execute_node_ids": [
|
|
str(node_id)
|
|
for node_id in phase_definition.get("execute_node_ids", [])
|
|
if str(node_id)
|
|
],
|
|
}
|
|
|
|
|
|
def _split_phase_outputs(
|
|
phase_outputs: tuple[Any, ...],
|
|
boundary_outputs: list[dict[str, Any]],
|
|
internal_output_names: set[str],
|
|
) -> tuple[dict[str, Any], tuple[Any, ...]]:
|
|
"""Split one phase result tuple into bridge values and external outputs."""
|
|
internal_outputs: dict[str, Any] = {}
|
|
external_outputs: list[Any] = []
|
|
for boundary_output, output_value in zip(
|
|
boundary_outputs, phase_outputs, strict=True
|
|
):
|
|
output_name = str(boundary_output.get("proxy_output_name") or "")
|
|
if output_name in internal_output_names:
|
|
internal_outputs[output_name] = output_value
|
|
continue
|
|
external_outputs.append(output_value)
|
|
return internal_outputs, tuple(external_outputs)
|
|
|
|
|
|
def _aggregate_mapped_phase_outputs(
|
|
per_item_outputs: list[tuple[Any, ...]],
|
|
payload: dict[str, Any],
|
|
) -> tuple[Any, ...]:
|
|
"""Join ordered mapped-phase outputs back into one proxy result tuple."""
|
|
if not per_item_outputs:
|
|
raise ValueError("Mapped execution produced no per-item outputs to aggregate.")
|
|
|
|
output_count = len(per_item_outputs[0])
|
|
if any(len(item_outputs) != output_count for item_outputs in per_item_outputs):
|
|
raise RemoteSubgraphExecutionError(
|
|
"Mapped remote execution produced inconsistent output arity."
|
|
)
|
|
|
|
aggregated_outputs: list[Any] = []
|
|
boundary_outputs = list(payload.get("boundary_outputs", []))
|
|
for output_index in range(output_count):
|
|
boundary_output = (
|
|
boundary_outputs[output_index]
|
|
if output_index < len(boundary_outputs)
|
|
else {}
|
|
)
|
|
aggregated_outputs.append(
|
|
_merge_static_or_mapped_values(
|
|
[item_outputs[output_index] for item_outputs in per_item_outputs],
|
|
io_type=str(boundary_output.get("io_type", "*")),
|
|
is_list=bool(boundary_output.get("is_list", False)),
|
|
scheduler_is_list=bool(boundary_output.get("scheduler_is_list", False)),
|
|
)
|
|
)
|
|
return tuple(aggregated_outputs)
|
|
|
|
|
|
def _merge_static_or_mapped_values(
|
|
values: list[Any],
|
|
*,
|
|
io_type: str,
|
|
is_list: bool,
|
|
scheduler_is_list: bool,
|
|
) -> Any:
|
|
"""Join mapped per-item outputs using the shared transport serializer rules."""
|
|
from serialization import join_mapped_values_for_scheduler
|
|
|
|
return join_mapped_values_for_scheduler(
|
|
values,
|
|
io_type=io_type,
|
|
is_list=is_list,
|
|
scheduler_is_list=scheduler_is_list,
|
|
)
|
|
|
|
|
|
def _merge_static_and_mapped_outputs(
|
|
*,
|
|
static_outputs: tuple[Any, ...],
|
|
mapped_outputs: tuple[Any, ...],
|
|
payload: dict[str, Any],
|
|
) -> tuple[Any, ...]:
|
|
"""Reassemble one mapped component's static and mapped outputs in proxy order."""
|
|
combined_outputs: list[Any] = []
|
|
static_output_index = 0
|
|
mapped_output_index = 0
|
|
for boundary_output in payload.get("boundary_outputs", []):
|
|
if bool(boundary_output.get("mapped_output")):
|
|
if mapped_output_index >= len(mapped_outputs):
|
|
raise RemoteSubgraphExecutionError(
|
|
"Mapped remote execution returned fewer mapped outputs than expected."
|
|
)
|
|
combined_outputs.append(mapped_outputs[mapped_output_index])
|
|
mapped_output_index += 1
|
|
continue
|
|
if static_output_index >= len(static_outputs):
|
|
raise RemoteSubgraphExecutionError(
|
|
"Mapped remote execution returned fewer static outputs than expected."
|
|
)
|
|
combined_outputs.append(static_outputs[static_output_index])
|
|
static_output_index += 1
|
|
|
|
if static_output_index != len(static_outputs) or mapped_output_index != len(
|
|
mapped_outputs
|
|
):
|
|
raise RemoteSubgraphExecutionError(
|
|
"Mapped remote execution produced extra outputs that did not match the declared boundary outputs."
|
|
)
|
|
return tuple(combined_outputs)
|
|
|
|
|
|
def _execute_mapped_subgraph_payload(
|
|
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 one mapped payload inside a single remote runtime process."""
|
|
mapped_input = payload.get("mapped_input") or {}
|
|
mapped_input_name = str(mapped_input.get("proxy_input_name") or "")
|
|
if not mapped_input_name:
|
|
raise RemoteSubgraphExecutionError(
|
|
"Mapped remote payloads must define mapped_input.proxy_input_name."
|
|
)
|
|
if mapped_input_name not in hydrated_inputs:
|
|
raise KeyError(
|
|
f"Mapped remote payload input {mapped_input_name!r} was not provided."
|
|
)
|
|
|
|
from serialization import split_mapped_value
|
|
|
|
mapped_items = split_mapped_value(
|
|
hydrated_inputs[mapped_input_name],
|
|
str(mapped_input.get("io_type", "*")),
|
|
)
|
|
if not mapped_items:
|
|
raise ValueError("Mapped remote execution requires at least one input item.")
|
|
|
|
broadcast_inputs = dict(hydrated_inputs)
|
|
broadcast_inputs.pop(mapped_input_name, None)
|
|
static_to_mapped_boundaries = list(payload.get("static_to_mapped_boundaries", []))
|
|
bridge_output_names = {
|
|
str(boundary_spec.get("proxy_name") or "")
|
|
for boundary_spec in static_to_mapped_boundaries
|
|
if str(boundary_spec.get("proxy_name") or "")
|
|
}
|
|
|
|
static_outputs: tuple[Any, ...] = ()
|
|
if payload.get("static_phase") is not None:
|
|
static_phase_payload = _build_phase_subgraph_payload(
|
|
payload,
|
|
"static_phase",
|
|
f"{payload.get('component_id', 'modal-subgraph')}::static",
|
|
)
|
|
if static_phase_payload.get("execute_node_ids"):
|
|
static_phase_outputs = _execute_subgraph_prompt(
|
|
static_phase_payload,
|
|
dict(broadcast_inputs),
|
|
custom_nodes_root,
|
|
status_callback=status_callback,
|
|
cancellation_event=cancellation_event,
|
|
interrupt_store=interrupt_store,
|
|
interrupt_flag_key=interrupt_flag_key,
|
|
)
|
|
bridge_inputs, static_outputs = _split_phase_outputs(
|
|
static_phase_outputs,
|
|
list(static_phase_payload.get("boundary_outputs", [])),
|
|
bridge_output_names,
|
|
)
|
|
broadcast_inputs.update(bridge_inputs)
|
|
|
|
if status_callback is not None:
|
|
status_callback(
|
|
{
|
|
"event_type": "node_progress",
|
|
"node_id": str(payload.get("component_id") or "modal-subgraph"),
|
|
"display_node_id": str(payload.get("component_id") or "modal-subgraph"),
|
|
"value": 0.0,
|
|
"max": float(len(mapped_items)),
|
|
"aggregate_only": True,
|
|
}
|
|
)
|
|
|
|
per_item_outputs: list[tuple[Any, ...]] = []
|
|
for item_index, item_value in enumerate(mapped_items):
|
|
last_lane_node_id: str | None = None
|
|
lane_id = str(payload.get("mapped_progress_lane_id") or item_index)
|
|
|
|
def publish_item_status(progress_state: dict[str, Any]) -> None:
|
|
"""Attach mapped-lane metadata to one per-item progress event."""
|
|
nonlocal last_lane_node_id
|
|
if status_callback is None:
|
|
return
|
|
event_type = str(progress_state.get("event_type", ""))
|
|
if event_type == "node_progress":
|
|
reported_node_id = progress_state.get(
|
|
"real_node_id"
|
|
) or progress_state.get("node_id")
|
|
if reported_node_id is not None:
|
|
last_lane_node_id = str(reported_node_id)
|
|
status_callback(
|
|
{
|
|
**progress_state,
|
|
"lane_id": lane_id,
|
|
"item_index": item_index,
|
|
}
|
|
)
|
|
return
|
|
if event_type in {"executed", "preview", "boundary_output"}:
|
|
status_callback({**progress_state, "item_index": item_index})
|
|
|
|
item_payload = _build_phase_subgraph_payload(
|
|
payload,
|
|
"mapped_phase",
|
|
f"{payload.get('component_id', 'modal-subgraph')}::item:{item_index}",
|
|
)
|
|
item_inputs = dict(broadcast_inputs)
|
|
item_inputs[mapped_input_name] = item_value
|
|
per_item_outputs.append(
|
|
_execute_subgraph_prompt(
|
|
item_payload,
|
|
item_inputs,
|
|
custom_nodes_root,
|
|
status_callback=publish_item_status,
|
|
cancellation_event=cancellation_event,
|
|
interrupt_store=interrupt_store,
|
|
interrupt_flag_key=interrupt_flag_key,
|
|
)
|
|
)
|
|
if status_callback is not None:
|
|
status_callback(
|
|
{
|
|
"event_type": "node_progress",
|
|
"node_id": last_lane_node_id
|
|
or str(payload.get("component_id") or "modal-subgraph"),
|
|
"display_node_id": last_lane_node_id
|
|
or str(payload.get("component_id") or "modal-subgraph"),
|
|
"value": 0.0,
|
|
"max": 1.0,
|
|
"lane_id": lane_id,
|
|
"item_index": item_index,
|
|
"clear": True,
|
|
}
|
|
)
|
|
status_callback(
|
|
{
|
|
"event_type": "node_progress",
|
|
"node_id": str(payload.get("component_id") or "modal-subgraph"),
|
|
"display_node_id": str(
|
|
payload.get("component_id") or "modal-subgraph"
|
|
),
|
|
"value": float(item_index + 1),
|
|
"max": float(len(mapped_items)),
|
|
"aggregate_only": True,
|
|
}
|
|
)
|
|
|
|
mapped_phase_payload = _build_phase_subgraph_payload(
|
|
payload,
|
|
"mapped_phase",
|
|
f"{payload.get('component_id', 'modal-subgraph')}::mapped",
|
|
)
|
|
mapped_outputs = _aggregate_mapped_phase_outputs(
|
|
per_item_outputs,
|
|
{"boundary_outputs": list(mapped_phase_payload.get("boundary_outputs", []))},
|
|
)
|
|
return _merge_static_and_mapped_outputs(
|
|
static_outputs=static_outputs,
|
|
mapped_outputs=mapped_outputs,
|
|
payload=payload,
|
|
)
|