624 lines
24 KiB
Python
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)
|