Files
ttulttul-ComfyUI-Modal/remote/modal_app.py
T

624 lines
24 KiB
Python

"""Remote Modal runtime and local execution fallback."""
from __future__ import annotations
import copy
import importlib.util
import logging
import os
import sys
import tempfile
import time
import zipfile
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager, nullcontext
from pathlib import Path
from typing import Any, Iterator
from ..serialization import (
deserialize_node_inputs,
serialize_node_outputs,
)
from ..settings import get_settings
logger = logging.getLogger(__name__)
_REMOTE_MODAL_CALL_EXECUTOR = ThreadPoolExecutor(max_workers=1)
_MODAL_CLOUD_MODULE_NAME = "comfyui_modal_sync_cloud"
try:
import modal # type: ignore
except ModuleNotFoundError: # pragma: no cover - exercised by local fallback tests.
modal = None
class RemoteSubgraphExecutionError(RuntimeError):
"""Raised when remote subgraph execution fails."""
class ModalRemoteInvocationError(RuntimeError):
"""Raised when the Modal client cannot invoke the remote runtime."""
class _NullPromptServer:
"""Minimal PromptExecutor server stub for headless subgraph execution."""
def __init__(self) -> None:
"""Initialize the no-op prompt server state."""
self.client_id: str | None = None
self.last_node_id: str | None = None
def send_sync(self, event: str, data: dict[str, Any], client_id: str | None) -> None:
"""Discard PromptExecutor progress and status events."""
logger.debug("Suppressed remote prompt event %s for client %s.", event, client_id)
def _extract_custom_nodes_bundle(bundle_path: str | None) -> None:
"""Extract a mirrored custom_nodes archive into a temporary import path."""
if not bundle_path:
return
settings = get_settings()
if settings.execution_mode == "local":
logger.debug("Skipping custom_nodes bundle extraction in local execution mode.")
return
local_bundle = settings.local_storage_root / bundle_path.lstrip("/")
if not local_bundle.exists():
logger.warning("Custom nodes bundle %s was not found in local storage.", local_bundle)
return
extraction_root = Path(tempfile.gettempdir()) / "comfy-modal-sync-custom-nodes"
extraction_root.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(local_bundle, "r") as archive:
archive.extractall(extraction_root)
if str(extraction_root) not in sys.path:
sys.path.insert(0, str(extraction_root))
logger.info("Extracted remote custom_nodes bundle to %s", extraction_root)
def _load_nodes_module() -> Any:
"""Import the ComfyUI nodes module lazily."""
import nodes
return nodes
def _load_execution_module() -> Any:
"""Import the ComfyUI execution module lazily."""
import execution
return execution
@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
nodes_module = _load_nodes_module()
original_mappings = dict(nodes_module.NODE_CLASS_MAPPINGS)
original_display_mappings = dict(getattr(nodes_module, "NODE_DISPLAY_NAME_MAPPINGS", {}))
try:
nodes_module.NODE_CLASS_MAPPINGS.update(node_mapping)
for class_type in node_mapping:
nodes_module.NODE_DISPLAY_NAME_MAPPINGS.setdefault(class_type, class_type)
yield
finally:
nodes_module.NODE_CLASS_MAPPINGS.clear()
nodes_module.NODE_CLASS_MAPPINGS.update(original_mappings)
if hasattr(nodes_module, "NODE_DISPLAY_NAME_MAPPINGS"):
nodes_module.NODE_DISPLAY_NAME_MAPPINGS.clear()
nodes_module.NODE_DISPLAY_NAME_MAPPINGS.update(original_display_mappings)
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,
) -> bytes:
"""Execute a single target node in-process and return serialized outputs."""
_extract_custom_nodes_bundle(node_data.get("custom_nodes_bundle"))
kwargs = deserialize_node_inputs(kwargs_payload)
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.")
outputs = _invoke_original_node(node_mapping[class_type], node_data, kwargs)
return serialize_node_outputs(outputs)
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.")
outputs = _invoke_original_node(resolved_node_mapping[class_type], node_data, kwargs)
return serialize_node_outputs(outputs)
def _apply_boundary_inputs(
prompt: dict[str, Any],
boundary_input_specs: list[dict[str, Any]],
hydrated_inputs: dict[str, Any],
) -> None:
"""Inject hydrated local boundary inputs into a remote subgraph prompt."""
logger.info("Applying %d hydrated boundary inputs to remote subgraph prompt.", len(boundary_input_specs))
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]
logger.info(
"Applying boundary input %s to %d targets.",
proxy_input_name,
len(boundary_input.get("targets", [])),
)
for target in boundary_input.get("targets", []):
node_id = str(target["node_id"])
input_name = str(target["input_name"])
prompt[node_id]["inputs"][input_name] = value
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 str(data.get("exception_message") or "Remote subgraph execution failed.")
if event == "execution_interrupted":
return "Remote subgraph execution was interrupted."
return "Remote subgraph execution failed."
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 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 _execute_subgraph_with_mapping(
payload: dict[str, Any],
hydrated_inputs: dict[str, Any],
node_mapping: dict[str, type[Any]],
) -> tuple[Any, ...]:
"""Execute a rewritten remote component using an explicit node mapping."""
prompt = copy.deepcopy(payload["subgraph_prompt"])
logger.info(
"Executing remote subgraph %s via test mapping with %d prompt nodes.",
payload.get("component_id"),
len(prompt),
)
_apply_boundary_inputs(
prompt=prompt,
boundary_input_specs=list(payload.get("boundary_inputs", [])),
hydrated_inputs=hydrated_inputs,
)
required_node_ids = _resolve_required_subgraph_nodes(
prompt=prompt,
execute_node_ids=list(payload.get("execute_node_ids", [])),
)
executed_outputs: dict[str, tuple[Any, ...]] = {}
pending = set(required_node_ids)
while pending:
progressed = False
logger.info("Mapped remote subgraph pending nodes: %s", sorted(pending))
for node_id in list(sorted(pending)):
prompt_node = prompt[node_id]
kwargs: dict[str, Any] = {}
unresolved_dependency = False
for input_name, input_value in (prompt_node.get("inputs") or {}).items():
if _is_link(input_value):
upstream_node_id = str(input_value[0])
if upstream_node_id not in executed_outputs:
unresolved_dependency = True
break
kwargs[str(input_name)] = executed_outputs[upstream_node_id][int(input_value[1])]
else:
kwargs[str(input_name)] = input_value
if unresolved_dependency:
continue
class_type = str(prompt_node["class_type"])
if class_type not in node_mapping:
raise KeyError(f"Remote node class {class_type!r} is not registered.")
logger.info(
"Executing mapped remote node %s (%s) with %d inputs.",
node_id,
class_type,
len(kwargs),
)
executed_outputs[node_id] = _invoke_original_node(
node_mapping[class_type],
prompt_node,
kwargs,
)
pending.remove(node_id)
progressed = True
if not progressed:
raise RemoteSubgraphExecutionError(
"Unable to resolve execution order for remote subgraph payload."
)
outputs: list[Any] = []
for boundary_output in payload.get("boundary_outputs", []):
node_id = str(boundary_output["node_id"])
output_index = int(boundary_output["output_index"])
node_outputs = executed_outputs.get(node_id)
if node_outputs is None:
raise RemoteSubgraphExecutionError(
f"Remote subgraph did not execute boundary output node {node_id}."
)
outputs.append(node_outputs[output_index])
logger.info(
"Mapped remote subgraph %s produced %d exported outputs.",
payload.get("component_id"),
len(outputs),
)
return tuple(outputs)
def _execute_subgraph_prompt(
payload: dict[str, Any],
hydrated_inputs: dict[str, Any],
node_mapping: dict[str, type[Any]] | None = None,
) -> tuple[Any, ...]:
"""Execute a remote component prompt and return its exported outputs."""
if node_mapping is not None:
return _execute_subgraph_with_mapping(payload, hydrated_inputs, node_mapping)
prompt = copy.deepcopy(payload["subgraph_prompt"])
logger.info(
"Executing remote subgraph %s through PromptExecutor with %d prompt nodes, %d boundary inputs, and %d exported outputs.",
payload.get("component_id"),
len(prompt),
len(payload.get("boundary_inputs", [])),
len(payload.get("boundary_outputs", [])),
)
_apply_boundary_inputs(
prompt=prompt,
boundary_input_specs=list(payload.get("boundary_inputs", [])),
hydrated_inputs=hydrated_inputs,
)
execution = _load_execution_module()
with _temporary_node_mapping(node_mapping):
executor = execution.PromptExecutor(_NullPromptServer())
execution_started_at = time.perf_counter()
logger.info(
"Starting PromptExecutor for remote subgraph %s with execute targets %s.",
payload.get("component_id"),
payload.get("execute_node_ids", []),
)
executor.execute(
prompt=prompt,
prompt_id=str(payload.get("component_id", "modal-subgraph")),
extra_data=copy.deepcopy(payload.get("extra_data") or {}),
execute_outputs=list(payload.get("execute_node_ids", [])),
)
logger.info(
"PromptExecutor finished for remote subgraph %s in %.3fs with success=%s and %d status messages.",
payload.get("component_id"),
time.perf_counter() - execution_started_at,
executor.success,
len(executor.status_messages),
)
if executor.status_messages:
logger.info(
"Remote subgraph %s status events: %s",
payload.get("component_id"),
[event for event, _data in executor.status_messages],
)
if not executor.success:
raise RemoteSubgraphExecutionError(_extract_prompt_executor_error(executor))
outputs: list[Any] = []
for boundary_output in payload.get("boundary_outputs", []):
node_id = str(boundary_output["node_id"])
output_index = int(boundary_output["output_index"])
cache_entry = executor.caches.outputs.get(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}."
)
outputs.append(
_collapse_cache_slot(
slot_values=cache_entry.outputs[output_index],
is_list=bool(boundary_output.get("is_list", False)),
)
)
logger.info(
"Collected exported output %s from node %s output %d.",
boundary_output.get("proxy_output_name"),
node_id,
output_index,
)
return tuple(outputs)
def execute_subgraph_locally(
payload: dict[str, Any],
kwargs_payload: bytes | bytearray | str | dict[str, Any],
node_mapping: dict[str, type[Any]] | None = None,
) -> bytes:
"""Execute a rewritten remote component in-process and return serialized outputs."""
_extract_custom_nodes_bundle(payload.get("custom_nodes_bundle"))
hydrated_inputs = deserialize_node_inputs(kwargs_payload)
logger.info(
"Executing local fallback subgraph %s with %d hydrated inputs.",
payload.get("component_id"),
len(hydrated_inputs),
)
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(_execute_subgraph_prompt, payload, hydrated_inputs, node_mapping)
try:
outputs = future.result()
except Exception:
logger.exception(
"Local fallback subgraph %s raised while running in worker thread.",
payload.get("component_id"),
)
raise
logger.info(
"Local fallback subgraph %s completed with %d outputs.",
payload.get("component_id"),
len(outputs),
)
return serialize_node_outputs(outputs)
def _modal_lookup_error_types() -> tuple[type[BaseException], ...]:
"""Return Modal exception types that indicate lookup or hydration failure."""
if modal is None:
return tuple()
exception_module = getattr(modal, "exception", None)
if exception_module is None:
return tuple()
error_types: list[type[BaseException]] = []
for error_name in ("NotFoundError", "ExecutionError", "InvalidError"):
error_type = getattr(exception_module, error_name, None)
if isinstance(error_type, type) and issubclass(error_type, BaseException):
error_types.append(error_type)
return tuple(error_types)
def _load_modal_cloud_module() -> Any:
"""Load the stable Modal cloud entry module under a valid Python name."""
if _MODAL_CLOUD_MODULE_NAME in sys.modules:
return sys.modules[_MODAL_CLOUD_MODULE_NAME]
cloud_module_path = Path(__file__).resolve().parents[1] / f"{_MODAL_CLOUD_MODULE_NAME}.py"
module_spec = importlib.util.spec_from_file_location(
_MODAL_CLOUD_MODULE_NAME,
cloud_module_path,
)
if module_spec is None or module_spec.loader is None:
raise ModalRemoteInvocationError(
f"Unable to create module spec for Modal cloud entrypoint at {cloud_module_path}."
)
cloud_module = importlib.util.module_from_spec(module_spec)
sys.modules[_MODAL_CLOUD_MODULE_NAME] = cloud_module
module_spec.loader.exec_module(cloud_module)
return cloud_module
def _lookup_deployed_modal_method(payload: dict[str, Any]) -> Any:
"""Look up the deployed Modal method used to execute the remote runtime."""
if modal is None:
raise ModalRemoteInvocationError("Modal SDK is unavailable.")
settings = get_settings()
logger.info(
"Attempting deployed Modal invocation for app=%s class=%s component=%s.",
settings.app_name,
"RemoteEngine",
payload.get("component_id"),
)
remote_cls = modal.Cls.from_name(settings.app_name, "RemoteEngine")
remote_engine = remote_cls()
return remote_engine.execute_payload
def _invoke_modal_payload_blocking(payload: dict[str, Any], kwargs_payload: bytes) -> bytes:
"""Invoke the Modal runtime from a worker thread using deployed or ephemeral app state."""
if modal is None:
raise ModalRemoteInvocationError("Modal SDK is unavailable.")
lookup_error_types = _modal_lookup_error_types()
settings = get_settings()
if lookup_error_types:
try:
remote_method = _lookup_deployed_modal_method(payload)
logger.info(
"Using deployed Modal app %s for component %s.",
settings.app_name,
payload.get("component_id"),
)
return remote_method.remote(payload, kwargs_payload)
except lookup_error_types as exc:
logger.warning(
"Deployed Modal app lookup failed for app=%s component=%s: %s. Falling back to ephemeral app.run(); this creates a temporary Modal app session, not a persistent deployment or endpoint.",
settings.app_name,
payload.get("component_id"),
exc,
)
else:
remote_method = _lookup_deployed_modal_method(payload)
logger.info(
"Using deployed Modal app %s for component %s.",
settings.app_name,
payload.get("component_id"),
)
return remote_method.remote(payload, kwargs_payload)
if "app" not in globals() or "RemoteEngine" not in globals():
logger.debug("Local module Modal runtime objects are unavailable; loading stable cloud entry module.")
cloud_module = _load_modal_cloud_module()
cloud_app = getattr(cloud_module, "app", None)
cloud_remote_engine = getattr(cloud_module, "RemoteEngine", None)
if cloud_app is None or cloud_remote_engine is None:
raise ModalRemoteInvocationError(
"Stable Modal cloud entry module did not expose app and RemoteEngine."
)
logger.info(
"Starting ephemeral Modal app.run() for component %s. This does not create a persistent deployed app or web endpoint.",
payload.get("component_id"),
)
run_context = cloud_app.run() if hasattr(cloud_app, "run") else nullcontext()
with run_context:
remote_engine = cloud_remote_engine()
result = remote_engine.execute_payload.remote(payload, kwargs_payload)
logger.info(
"Ephemeral Modal app.run() invocation completed for component %s.",
payload.get("component_id"),
)
return result
def invoke_remote_engine(payload: dict[str, Any], kwargs_payload: bytes) -> bytes:
"""Invoke Modal when configured, or fall back to local in-process execution."""
execution_mode = os.getenv("COMFY_MODAL_EXECUTION_MODE", "local")
if execution_mode == "local" or modal is None:
if payload.get("payload_kind") == "subgraph":
return execute_subgraph_locally(payload, kwargs_payload)
return execute_node_locally(payload, kwargs_payload)
logger.info(
"Dispatching Modal remote invocation for component=%s payload_kind=%s.",
payload.get("component_id"),
payload.get("payload_kind"),
)
future = _REMOTE_MODAL_CALL_EXECUTOR.submit(
_invoke_modal_payload_blocking,
dict(payload),
kwargs_payload,
)
try:
response = future.result()
except Exception:
logger.exception(
"Modal remote invocation failed for component=%s.",
payload.get("component_id"),
)
raise
logger.info(
"Modal remote invocation completed for component=%s.",
payload.get("component_id"),
)
return response
if modal is not None: # pragma: no branch - simple import-time configuration.
settings = get_settings()
app = modal.App(settings.app_name)
vol = modal.Volume.from_name(settings.volume_name, create_if_missing=True)
image = modal.Image.debian_slim().pip_install("torch", "safetensors", "pillow", "numpy")
@app.cls(
gpu="A100",
volumes={settings.remote_storage_root: vol},
scaledown_window=60,
image=image,
)
class RemoteEngine:
"""Modal runtime class that executes proxied ComfyUI payloads."""
@modal.enter()
def setup(self) -> None:
"""Prepare the container process for headless node execution."""
logger.info("RemoteEngine setup complete.")
@modal.method()
def execute_payload(self, payload: dict[str, Any], kwargs_payload: bytes) -> bytes:
"""Execute a proxied node or subgraph inside the Modal container."""
if payload.get("payload_kind") == "subgraph":
return execute_subgraph_locally(payload, kwargs_payload)
return execute_node_locally(payload, kwargs_payload)
else:
class RemoteEngine:
"""Local fallback runtime used when the Modal SDK is unavailable."""
def setup(self) -> None:
"""No-op setup for local fallback execution."""
def execute_payload(self, payload: dict[str, Any], kwargs_payload: bytes) -> bytes:
"""Execute the proxied node or subgraph locally."""
if payload.get("payload_kind") == "subgraph":
return execute_subgraph_locally(payload, kwargs_payload)
return execute_node_locally(payload, kwargs_payload)