5036 lines
200 KiB
Python
5036 lines
200 KiB
Python
"""Prompt interception and graph rewriting for Modal-backed execution."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import math
|
|
import os
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from collections import defaultdict, deque
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from dataclasses import replace
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Iterable, Iterator, Mapping, Sequence
|
|
|
|
from aiohttp import web
|
|
|
|
if __package__:
|
|
from .modal_executor_node import (
|
|
MODAL_ARTIFACT_FINALIZER_MAX_COMPONENTS,
|
|
MODAL_ARTIFACT_FINALIZER_NODE_ID,
|
|
MODAL_COMPONENT_COMPLETION_OUTPUT_NAME,
|
|
MODAL_LOCAL_BRIDGE_MATERIALIZER_NODE_ID,
|
|
MODAL_MAP_INPUT_NODE_ID,
|
|
MODAL_PARALLEL_LOCAL_PASSTHROUGH_NODE_ID,
|
|
MODAL_PROMPT_ID_EXTRA_PNGINFO_KEY,
|
|
ensure_modal_artifact_finalizer_registered,
|
|
ensure_modal_component_proxy_node_registered,
|
|
ensure_modal_local_bridge_materializer_registered,
|
|
ensure_modal_parallel_local_passthrough_registered,
|
|
register_cache_friendly_proxy_payload,
|
|
register_modal_map_input_warmup_context,
|
|
registered_proxy_execution_payload,
|
|
update_registered_proxy_payload_fields,
|
|
)
|
|
from .session_state import RemoteSessionHandle
|
|
from .settings import (
|
|
ModalSyncSettings,
|
|
discover_comfyui_user_directory,
|
|
get_settings,
|
|
modal_gpu_from_workflow,
|
|
settings_for_modal_gpu,
|
|
)
|
|
from .execution_environments import (
|
|
ComponentResourceRequirements,
|
|
CostAwareEnvironmentScheduler,
|
|
EnvironmentCapabilities,
|
|
EnvironmentHealth,
|
|
EnvironmentSchedulingState,
|
|
ExecutionAssignment,
|
|
ExecutionPolicy,
|
|
ExecutionProvider,
|
|
GpuCapability,
|
|
NoCompatibleExecutionEnvironmentError,
|
|
WorkflowExecutionPreferences,
|
|
)
|
|
from .execution_history import ExecutionHistory
|
|
from .sync_engine import (
|
|
AssetSyncRequestCache,
|
|
MODEL_FILE_EXTENSIONS,
|
|
ModalAssetSyncEngine,
|
|
ModalVolumeBackend,
|
|
SyncCancelledError,
|
|
SyncedAsset,
|
|
begin_r2_writeback_prompt,
|
|
finish_r2_writeback_prompt,
|
|
resolve_model_path,
|
|
)
|
|
from .remote_hosts import RemoteExecutionConfig, RemoteHostRegistry, SshHostConfig
|
|
from .r2_cache import R2CacheClient, R2CacheError, R2StorageUsage
|
|
from .r2_credentials import (
|
|
R2CredentialError,
|
|
R2CredentialStore,
|
|
R2_KEYCHAIN_UNLOCK_REQUIRED_CODE,
|
|
request_macos_keychain_unlock,
|
|
)
|
|
from .cloudflare_oauth import setup_r2_oauth_routes
|
|
from .remote_configuration_nodes import (
|
|
REMOTE_CONFIGURATION_NODE_IDS,
|
|
REMOTE_EXECUTION_CONFIGURATOR_NODE_ID,
|
|
compile_remote_configuration_set,
|
|
)
|
|
from .remote_configurations import (
|
|
ModalRemoteConfiguration,
|
|
RemoteConfiguration,
|
|
RemoteConfigurationSet,
|
|
R2StorageBackingConfiguration,
|
|
SshRemoteConfiguration,
|
|
VastRemoteConfiguration,
|
|
)
|
|
from .ssh_docker import SshDockerController, SshDockerVolumeBackend
|
|
from .ssh_runtime import SshRuntimeManager
|
|
from .vast_config_node import extract_vast_profiles
|
|
from .vast_service import VastProfileQuote, VastSearchRequirements, VastService
|
|
from .vast_api import VastApiClient
|
|
from .vast_leases import VastLeaseManager, VastLeaseRegistry
|
|
from .remote_plan_types import (
|
|
BoundaryInputSpec,
|
|
BoundaryOutputSpec,
|
|
ComponentExecutionPlan,
|
|
ComponentMemoryEstimate,
|
|
InputTarget,
|
|
LinkedOutputRef,
|
|
ModalPromptValidationError,
|
|
ProducedPhaseOutputSpec,
|
|
PromptGraphLink,
|
|
RemoteComponentPlan,
|
|
RemoteExpansionReason,
|
|
RemoteNodeAnalysis,
|
|
RewriteSummary,
|
|
StaticToMappedBoundarySpec,
|
|
_EnvironmentAssetPreparationResult,
|
|
)
|
|
from .modal_hardware import (
|
|
_HBM_GPU_NAME_MARKERS,
|
|
_MODAL_GPU_COST_USD_PER_SECOND,
|
|
_MODAL_GPU_VRAM_GB,
|
|
_capabilities_hardware_payload,
|
|
_gpu_memory_kind,
|
|
_hardware_payload,
|
|
_modal_hardware_payload,
|
|
_vast_hardware_payload,
|
|
)
|
|
from .intercept_route_paths import (
|
|
_analysis_route_path,
|
|
_cancel_preparation_route_path,
|
|
_container_status_route_path,
|
|
_delete_modal_caches_route_path,
|
|
_delete_modal_volume_route_path,
|
|
_progress_state_route_path,
|
|
_remote_environment_bootstrap_route_path,
|
|
_remote_environment_probe_route_path,
|
|
_remote_environment_status_route_path,
|
|
_remote_environment_stop_route_path,
|
|
_remote_environments_route_path,
|
|
)
|
|
from .modal_admin_ops import (
|
|
_call_modal_sdk,
|
|
_delete_modal_named_object,
|
|
_modal_cache_dict_names,
|
|
_modal_not_found_error_types,
|
|
delete_modal_cache_dicts,
|
|
delete_modal_volume,
|
|
)
|
|
from .modal_ui_events import (
|
|
_emit_modal_status,
|
|
modal_ui_events_for_client,
|
|
record_modal_ui_event,
|
|
)
|
|
from .remote_graph_analysis import (
|
|
_prompt_node_required_provider,
|
|
_iter_payload_input_strings,
|
|
_looks_like_workflow_node,
|
|
_iter_workflow_nodes,
|
|
_workflow_subgraph_definitions,
|
|
_iter_workflow_nodes_with_ancestors,
|
|
_resolve_prompt_node_ids_for_workflow_node,
|
|
_workflow_node_path,
|
|
_extract_marked_workflow_node_paths,
|
|
_build_workflow_prompt_resolution_maps,
|
|
_resolve_requested_prompt_node_ids,
|
|
_best_workflow_path_for_prompt_node,
|
|
_resolve_workflow_node_paths_for_prompt_nodes,
|
|
extract_remote_node_ids,
|
|
requested_remote_node_ids,
|
|
_workflow_node_remote_enabled,
|
|
_normalize_output_metadata,
|
|
_is_transportable_output_type,
|
|
_is_inexpensive_remote_boundary_type,
|
|
_build_consumer_map,
|
|
_sandwiched_local_node_ids,
|
|
_node_output_refs,
|
|
_downstream_node_ids_from_targets,
|
|
_is_non_returning_tap_terminal_node,
|
|
_non_returning_local_tap_node_ids,
|
|
_non_returning_local_output_consumers,
|
|
_output_supports_parallel_local_materialization,
|
|
_expand_component_for_non_transportable_local_outputs,
|
|
_remote_output_io_type,
|
|
_remote_output_is_list,
|
|
_remote_group_dependency_edges,
|
|
_has_alternate_group_path_through_protected_group,
|
|
_remote_component_partition_groups,
|
|
_component_topological_order,
|
|
_component_dependency_graph,
|
|
_component_execution_stages,
|
|
_estimated_stage_parallelism,
|
|
_merge_cyclic_component_groups,
|
|
_build_remote_components,
|
|
_expand_remote_node_ids_for_non_transportable_inputs,
|
|
_terminal_remote_video_source,
|
|
_expand_remote_node_ids_for_terminal_video_sinks,
|
|
analyze_remote_node_selection,
|
|
)
|
|
else: # pragma: no cover - flat import inside the Modal container.
|
|
from modal_executor_node import (
|
|
MODAL_ARTIFACT_FINALIZER_MAX_COMPONENTS,
|
|
MODAL_ARTIFACT_FINALIZER_NODE_ID,
|
|
MODAL_COMPONENT_COMPLETION_OUTPUT_NAME,
|
|
MODAL_LOCAL_BRIDGE_MATERIALIZER_NODE_ID,
|
|
MODAL_MAP_INPUT_NODE_ID,
|
|
MODAL_PARALLEL_LOCAL_PASSTHROUGH_NODE_ID,
|
|
MODAL_PROMPT_ID_EXTRA_PNGINFO_KEY,
|
|
ensure_modal_artifact_finalizer_registered,
|
|
ensure_modal_component_proxy_node_registered,
|
|
ensure_modal_local_bridge_materializer_registered,
|
|
ensure_modal_parallel_local_passthrough_registered,
|
|
register_cache_friendly_proxy_payload,
|
|
register_modal_map_input_warmup_context,
|
|
registered_proxy_execution_payload,
|
|
update_registered_proxy_payload_fields,
|
|
)
|
|
from session_state import RemoteSessionHandle
|
|
from settings import (
|
|
ModalSyncSettings,
|
|
discover_comfyui_user_directory,
|
|
get_settings,
|
|
modal_gpu_from_workflow,
|
|
settings_for_modal_gpu,
|
|
)
|
|
from execution_environments import (
|
|
ComponentResourceRequirements,
|
|
CostAwareEnvironmentScheduler,
|
|
EnvironmentCapabilities,
|
|
EnvironmentHealth,
|
|
EnvironmentSchedulingState,
|
|
ExecutionAssignment,
|
|
ExecutionPolicy,
|
|
ExecutionProvider,
|
|
GpuCapability,
|
|
NoCompatibleExecutionEnvironmentError,
|
|
WorkflowExecutionPreferences,
|
|
)
|
|
from execution_history import ExecutionHistory
|
|
from sync_engine import (
|
|
AssetSyncRequestCache,
|
|
MODEL_FILE_EXTENSIONS,
|
|
ModalAssetSyncEngine,
|
|
ModalVolumeBackend,
|
|
SyncCancelledError,
|
|
SyncedAsset,
|
|
begin_r2_writeback_prompt,
|
|
finish_r2_writeback_prompt,
|
|
resolve_model_path,
|
|
)
|
|
from remote_hosts import RemoteExecutionConfig, RemoteHostRegistry, SshHostConfig
|
|
from r2_cache import R2CacheClient, R2CacheError, R2StorageUsage
|
|
from r2_credentials import (
|
|
R2CredentialError,
|
|
R2CredentialStore,
|
|
R2_KEYCHAIN_UNLOCK_REQUIRED_CODE,
|
|
request_macos_keychain_unlock,
|
|
)
|
|
from cloudflare_oauth import setup_r2_oauth_routes
|
|
from remote_configuration_nodes import (
|
|
REMOTE_CONFIGURATION_NODE_IDS,
|
|
REMOTE_EXECUTION_CONFIGURATOR_NODE_ID,
|
|
compile_remote_configuration_set,
|
|
)
|
|
from remote_configurations import (
|
|
ModalRemoteConfiguration,
|
|
RemoteConfiguration,
|
|
RemoteConfigurationSet,
|
|
R2StorageBackingConfiguration,
|
|
SshRemoteConfiguration,
|
|
VastRemoteConfiguration,
|
|
)
|
|
from ssh_docker import SshDockerController, SshDockerVolumeBackend
|
|
from ssh_runtime import SshRuntimeManager
|
|
from vast_config_node import extract_vast_profiles
|
|
from vast_service import VastProfileQuote, VastSearchRequirements, VastService
|
|
from vast_api import VastApiClient
|
|
from vast_leases import VastLeaseManager, VastLeaseRegistry
|
|
from remote_plan_types import (
|
|
BoundaryInputSpec,
|
|
BoundaryOutputSpec,
|
|
ComponentExecutionPlan,
|
|
ComponentMemoryEstimate,
|
|
InputTarget,
|
|
LinkedOutputRef,
|
|
ModalPromptValidationError,
|
|
ProducedPhaseOutputSpec,
|
|
PromptGraphLink,
|
|
RemoteComponentPlan,
|
|
RemoteExpansionReason,
|
|
RemoteNodeAnalysis,
|
|
RewriteSummary,
|
|
StaticToMappedBoundarySpec,
|
|
_EnvironmentAssetPreparationResult,
|
|
)
|
|
from modal_hardware import (
|
|
_HBM_GPU_NAME_MARKERS,
|
|
_MODAL_GPU_COST_USD_PER_SECOND,
|
|
_MODAL_GPU_VRAM_GB,
|
|
_capabilities_hardware_payload,
|
|
_gpu_memory_kind,
|
|
_hardware_payload,
|
|
_modal_hardware_payload,
|
|
_vast_hardware_payload,
|
|
)
|
|
from intercept_route_paths import (
|
|
_analysis_route_path,
|
|
_cancel_preparation_route_path,
|
|
_container_status_route_path,
|
|
_delete_modal_caches_route_path,
|
|
_delete_modal_volume_route_path,
|
|
_progress_state_route_path,
|
|
_remote_environment_bootstrap_route_path,
|
|
_remote_environment_probe_route_path,
|
|
_remote_environment_status_route_path,
|
|
_remote_environment_stop_route_path,
|
|
_remote_environments_route_path,
|
|
)
|
|
from modal_admin_ops import (
|
|
_call_modal_sdk,
|
|
_delete_modal_named_object,
|
|
_modal_cache_dict_names,
|
|
_modal_not_found_error_types,
|
|
delete_modal_cache_dicts,
|
|
delete_modal_volume,
|
|
)
|
|
from modal_ui_events import (
|
|
_emit_modal_status,
|
|
modal_ui_events_for_client,
|
|
record_modal_ui_event,
|
|
)
|
|
from remote_graph_analysis import (
|
|
_prompt_node_required_provider,
|
|
_iter_payload_input_strings,
|
|
_looks_like_workflow_node,
|
|
_iter_workflow_nodes,
|
|
_workflow_subgraph_definitions,
|
|
_iter_workflow_nodes_with_ancestors,
|
|
_resolve_prompt_node_ids_for_workflow_node,
|
|
_workflow_node_path,
|
|
_extract_marked_workflow_node_paths,
|
|
_build_workflow_prompt_resolution_maps,
|
|
_resolve_requested_prompt_node_ids,
|
|
_best_workflow_path_for_prompt_node,
|
|
_resolve_workflow_node_paths_for_prompt_nodes,
|
|
extract_remote_node_ids,
|
|
requested_remote_node_ids,
|
|
_workflow_node_remote_enabled,
|
|
_normalize_output_metadata,
|
|
_is_transportable_output_type,
|
|
_is_inexpensive_remote_boundary_type,
|
|
_build_consumer_map,
|
|
_sandwiched_local_node_ids,
|
|
_node_output_refs,
|
|
_downstream_node_ids_from_targets,
|
|
_is_non_returning_tap_terminal_node,
|
|
_non_returning_local_tap_node_ids,
|
|
_non_returning_local_output_consumers,
|
|
_output_supports_parallel_local_materialization,
|
|
_expand_component_for_non_transportable_local_outputs,
|
|
_remote_output_io_type,
|
|
_remote_output_is_list,
|
|
_remote_group_dependency_edges,
|
|
_has_alternate_group_path_through_protected_group,
|
|
_remote_component_partition_groups,
|
|
_component_topological_order,
|
|
_component_dependency_graph,
|
|
_component_execution_stages,
|
|
_estimated_stage_parallelism,
|
|
_merge_cyclic_component_groups,
|
|
_build_remote_components,
|
|
_expand_remote_node_ids_for_non_transportable_inputs,
|
|
_terminal_remote_video_source,
|
|
_expand_remote_node_ids_for_terminal_video_sinks,
|
|
analyze_remote_node_selection,
|
|
)
|
|
|
|
if __package__:
|
|
from .component_planning import (
|
|
_REMOTE_REPLICA_NODE_PREFIX,
|
|
_REPLICABLE_REMOTE_OBJECT_TYPES,
|
|
_build_component_plan,
|
|
_filter_boundary_inputs_for_node_ids,
|
|
_filter_boundary_outputs_for_node_ids,
|
|
_subset_component_prompt,
|
|
_preview_target_node_ids,
|
|
_component_downstream_closure,
|
|
_component_upstream_closure,
|
|
_component_ancestors_of_local_source,
|
|
_component_has_local_reentry_dependency,
|
|
_component_has_parallel_local_remote_fanout,
|
|
_order_execute_node_ids_for_transportable_splits,
|
|
_subgraph_topological_node_order,
|
|
_remote_dependency_closure,
|
|
_can_replicate_remote_dependency_closure,
|
|
_replica_node_id_mapping,
|
|
_install_remote_dependency_replica,
|
|
_cross_group_replicable_boundaries,
|
|
_replicate_safe_nontransportable_provider_boundaries,
|
|
_workflow_visible_remote_node_ids,
|
|
_build_component_plans,
|
|
_mark_remote_to_remote_session_boundaries,
|
|
_remote_session_component_ids,
|
|
_boundary_output_payload,
|
|
_proxy_boundary_output_is_list,
|
|
_implicitly_mapped_boundary_output_sources,
|
|
_mark_payload_scheduler_list_outputs,
|
|
_mapped_boundary_origin_io_type,
|
|
_describe_output_boundary_error,
|
|
_describe_input_boundary_error,
|
|
validate_remote_component_transport_compatibility,
|
|
)
|
|
else: # pragma: no cover - flat import inside the Modal container.
|
|
from component_planning import (
|
|
_REMOTE_REPLICA_NODE_PREFIX,
|
|
_REPLICABLE_REMOTE_OBJECT_TYPES,
|
|
_build_component_plan,
|
|
_filter_boundary_inputs_for_node_ids,
|
|
_filter_boundary_outputs_for_node_ids,
|
|
_subset_component_prompt,
|
|
_preview_target_node_ids,
|
|
_component_downstream_closure,
|
|
_component_upstream_closure,
|
|
_component_ancestors_of_local_source,
|
|
_component_has_local_reentry_dependency,
|
|
_component_has_parallel_local_remote_fanout,
|
|
_order_execute_node_ids_for_transportable_splits,
|
|
_subgraph_topological_node_order,
|
|
_remote_dependency_closure,
|
|
_can_replicate_remote_dependency_closure,
|
|
_replica_node_id_mapping,
|
|
_install_remote_dependency_replica,
|
|
_cross_group_replicable_boundaries,
|
|
_replicate_safe_nontransportable_provider_boundaries,
|
|
_workflow_visible_remote_node_ids,
|
|
_build_component_plans,
|
|
_mark_remote_to_remote_session_boundaries,
|
|
_remote_session_component_ids,
|
|
_boundary_output_payload,
|
|
_proxy_boundary_output_is_list,
|
|
_implicitly_mapped_boundary_output_sources,
|
|
_mark_payload_scheduler_list_outputs,
|
|
_mapped_boundary_origin_io_type,
|
|
_describe_output_boundary_error,
|
|
_describe_input_boundary_error,
|
|
validate_remote_component_transport_compatibility,
|
|
)
|
|
|
|
if __package__:
|
|
from .prompt_payload_metadata import (
|
|
_ROOT_LOADER_PREWARM_CLASS_TYPES,
|
|
_prompt_value_signature_fragment,
|
|
_prompt_node_signature_digest,
|
|
_iter_loader_snapshot_prompt_payloads,
|
|
_is_root_literal_loader_node,
|
|
_loader_prewarm_plan_signature,
|
|
_uses_llm_worker_affinity,
|
|
_payload_loader_snapshot_profile_key,
|
|
_stamp_snapshot_profile_key,
|
|
_attach_snapshot_profile_key,
|
|
_resolved_llm_profile_entry,
|
|
_attach_resolved_llm_profiles,
|
|
_boundary_source_signature,
|
|
_serialize_boundary_input_specs,
|
|
)
|
|
else: # pragma: no cover - flat import inside the Modal container.
|
|
from prompt_payload_metadata import (
|
|
_ROOT_LOADER_PREWARM_CLASS_TYPES,
|
|
_prompt_value_signature_fragment,
|
|
_prompt_node_signature_digest,
|
|
_iter_loader_snapshot_prompt_payloads,
|
|
_is_root_literal_loader_node,
|
|
_loader_prewarm_plan_signature,
|
|
_uses_llm_worker_affinity,
|
|
_payload_loader_snapshot_profile_key,
|
|
_stamp_snapshot_profile_key,
|
|
_attach_snapshot_profile_key,
|
|
_resolved_llm_profile_entry,
|
|
_attach_resolved_llm_profiles,
|
|
_boundary_source_signature,
|
|
_serialize_boundary_input_specs,
|
|
)
|
|
|
|
if __package__:
|
|
from .prompt_rewrite import (
|
|
_SPECULATIVE_PREWARM_TARGET_KEY,
|
|
_SPECULATIVE_PREWARM_PAYLOAD_FIELDS,
|
|
_sync_component_prompt_inputs,
|
|
_deduplicate_synced_assets,
|
|
_build_component_payload,
|
|
_component_uploaded_volume_paths,
|
|
_rewrite_component_into_proxy,
|
|
_modal_component_completion_output_index,
|
|
_attach_modal_artifact_finalizer,
|
|
_nearest_downstream_remote_component_ids,
|
|
_parallelize_non_returning_local_branches,
|
|
_configure_local_gap_keepalive_payloads,
|
|
_remote_proxy_payload,
|
|
_remote_proxy_dependency_edges,
|
|
_component_descendant_distances,
|
|
_speculative_prewarm_target_payload,
|
|
_configure_speculative_affinity_prewarm_payloads,
|
|
)
|
|
else: # pragma: no cover - flat import inside the Modal container.
|
|
from prompt_rewrite import (
|
|
_SPECULATIVE_PREWARM_TARGET_KEY,
|
|
_SPECULATIVE_PREWARM_PAYLOAD_FIELDS,
|
|
_sync_component_prompt_inputs,
|
|
_deduplicate_synced_assets,
|
|
_build_component_payload,
|
|
_component_uploaded_volume_paths,
|
|
_rewrite_component_into_proxy,
|
|
_modal_component_completion_output_index,
|
|
_attach_modal_artifact_finalizer,
|
|
_nearest_downstream_remote_component_ids,
|
|
_parallelize_non_returning_local_branches,
|
|
_configure_local_gap_keepalive_payloads,
|
|
_remote_proxy_payload,
|
|
_remote_proxy_dependency_edges,
|
|
_component_descendant_distances,
|
|
_speculative_prewarm_target_payload,
|
|
_configure_speculative_affinity_prewarm_payloads,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
SetupStatusCallback = Callable[[str, int | None, int | None], None]
|
|
EnvironmentSetupStatusCallback = Callable[
|
|
[str, str, int | None, int | None], None
|
|
]
|
|
ExecutionPlanStatusCallback = Callable[
|
|
[dict[str, dict[str, Any]], list[dict[str, Any]]], None
|
|
]
|
|
|
|
_ROUTE_REGISTERED = False
|
|
_MODAL_INTERRUPT_QUEUE_BRIDGE_ATTR = "__comfy_modal_interrupt_queue_bridge_installed"
|
|
_REMOTE_PREPARATION_PROMPTS_ATTR = "__comfy_modal_remote_preparation_prompts"
|
|
_REMOTE_PREPARATION_CANCELLATIONS_ATTR = (
|
|
"__comfy_modal_remote_preparation_cancellations"
|
|
)
|
|
_REMOTE_PREPARATION_LOCK_ATTR = "__comfy_modal_remote_preparation_lock"
|
|
_REMOTE_ASSET_PREPARATION_MAX_WORKERS = 8
|
|
_R2_STORAGE_USAGE_ROUTE = "/remote/storage/r2/usage"
|
|
_R2_KEYCHAIN_UNLOCK_ROUTE = "/remote/storage/r2/keychain/unlock"
|
|
_R2_STORAGE_USAGE_CACHE_SECONDS = 5 * 60
|
|
_R2_STORAGE_USAGE_CACHE_LOCK = threading.Lock()
|
|
_R2_STORAGE_USAGE_CACHE: dict[
|
|
tuple[str, str, str], tuple[float, R2StorageUsage]
|
|
] = {}
|
|
_MODEL_VRAM_WEIGHT_MULTIPLIER = 1.20
|
|
_MODEL_VRAM_HEADROOM_BYTES = 4 * 1024**3
|
|
_MODEL_RAM_HEADROOM_BYTES = 4 * 1024**3
|
|
_ADDITIVE_MODEL_CLASS_TOKENS = (
|
|
"adapter",
|
|
"controlnet",
|
|
"ipadapter",
|
|
"lora",
|
|
)
|
|
|
|
|
|
def _modal_environment_state(settings: ModalSyncSettings) -> EnvironmentSchedulingState:
|
|
"""Return the scheduler-facing state of the selected Modal GPU target."""
|
|
modal_gpu = settings.modal_gpu
|
|
vram_bytes = int(_MODAL_GPU_VRAM_GB.get(modal_gpu, 0.0) * 1024**3)
|
|
capabilities = EnvironmentCapabilities(
|
|
architecture="x86_64",
|
|
operating_system="linux",
|
|
cpu_count=1,
|
|
total_ram_bytes=max(vram_bytes, 1),
|
|
available_ram_bytes=None,
|
|
available_disk_bytes=None,
|
|
docker_version="modal-managed",
|
|
docker_rootless=False,
|
|
nvidia_container_runtime=True,
|
|
gpus=(
|
|
GpuCapability(
|
|
uuid=f"modal-{modal_gpu.lower()}",
|
|
name=modal_gpu,
|
|
total_vram_bytes=vram_bytes,
|
|
),
|
|
),
|
|
)
|
|
return EnvironmentSchedulingState(
|
|
environment_id=f"modal:{modal_gpu}",
|
|
provider=ExecutionProvider.MODAL,
|
|
enabled=True,
|
|
health=EnvironmentHealth.READY,
|
|
cost_usd_per_second=_MODAL_GPU_COST_USD_PER_SECOND.get(modal_gpu),
|
|
capabilities=capabilities,
|
|
maximum_workers=settings.max_containers or 1,
|
|
)
|
|
|
|
|
|
def _modal_configuration_environment_state(
|
|
configuration: ModalRemoteConfiguration,
|
|
settings: ModalSyncSettings,
|
|
) -> EnvironmentSchedulingState:
|
|
"""Return one scheduler state for a workflow-declared Modal capacity pool."""
|
|
selected_settings = settings_for_modal_gpu(settings, configuration.gpu_type)
|
|
base_state = _modal_environment_state(selected_settings)
|
|
return replace(
|
|
base_state,
|
|
environment_id=(
|
|
f"modal:{configuration.configuration_id}:{configuration.gpu_type}"
|
|
),
|
|
configuration_id=configuration.configuration_id,
|
|
display_name=configuration.display_name,
|
|
maximum_workers=configuration.instance_count,
|
|
)
|
|
|
|
|
|
def _ssh_host_registry(settings: ModalSyncSettings) -> RemoteHostRegistry | None:
|
|
"""Return the persistent SSH host registry when a user directory exists."""
|
|
user_directory = discover_comfyui_user_directory(settings)
|
|
if user_directory is None:
|
|
return None
|
|
return RemoteHostRegistry.for_user_directory(user_directory)
|
|
|
|
|
|
def _configured_ssh_hosts(settings: ModalSyncSettings) -> tuple[SshHostConfig, ...]:
|
|
"""Return configured SSH hosts without probing during queue submission."""
|
|
registry = _ssh_host_registry(settings)
|
|
if registry is None:
|
|
return ()
|
|
return registry.load().hosts
|
|
|
|
|
|
def _schedulable_ssh_hosts(settings: ModalSyncSettings) -> tuple[SshHostConfig, ...]:
|
|
"""Probe every enabled host immediately before cost-aware scheduling."""
|
|
hosts = _configured_ssh_hosts(settings)
|
|
registry = _ssh_host_registry(settings)
|
|
if registry is None:
|
|
return hosts
|
|
probe_hosts = [host for host in hosts if host.enabled and not host.draining]
|
|
if not probe_hosts:
|
|
return hosts
|
|
|
|
with ThreadPoolExecutor(max_workers=min(8, len(probe_hosts))) as executor:
|
|
refreshed = {
|
|
host.environment_id: future.result()
|
|
for host, future in (
|
|
(host, executor.submit(_refresh_ssh_host, host, registry))
|
|
for host in probe_hosts
|
|
)
|
|
}
|
|
return tuple(refreshed.get(host.environment_id, host) for host in hosts)
|
|
|
|
|
|
def _refresh_ssh_host(
|
|
host: SshHostConfig,
|
|
registry: RemoteHostRegistry,
|
|
) -> SshHostConfig:
|
|
"""Probe and persist one host without aborting other candidates."""
|
|
try:
|
|
capabilities = SshDockerController(host).probe_capabilities()
|
|
return registry.update_probe_result(
|
|
host.environment_id,
|
|
capabilities=capabilities,
|
|
health=EnvironmentHealth.READY,
|
|
last_error=None,
|
|
)
|
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
logger.warning(
|
|
"Remote environment probe failed environment=%s: %s",
|
|
host.environment_id,
|
|
exc,
|
|
)
|
|
try:
|
|
return registry.update_probe_result(
|
|
host.environment_id,
|
|
capabilities=host.capabilities,
|
|
health=EnvironmentHealth.UNAVAILABLE,
|
|
last_error=str(exc),
|
|
)
|
|
except (KeyError, OSError, ValueError):
|
|
return replace(
|
|
host,
|
|
health=EnvironmentHealth.UNAVAILABLE,
|
|
last_error=str(exc),
|
|
)
|
|
|
|
|
|
def _prompt_llm_model_references(prompt: Mapping[str, Any]) -> tuple[str, ...]:
|
|
"""Return every fixed Modal LLM model reference in a prompt."""
|
|
references: set[str] = set()
|
|
for prompt_node in prompt.values():
|
|
if not isinstance(prompt_node, Mapping):
|
|
continue
|
|
if str(prompt_node.get("class_type") or "") != "ModalLLM":
|
|
continue
|
|
inputs = prompt_node.get("inputs")
|
|
if not isinstance(inputs, Mapping):
|
|
continue
|
|
model_reference = inputs.get("model_profile")
|
|
if isinstance(model_reference, str) and model_reference.strip():
|
|
references.add(model_reference.strip())
|
|
return tuple(sorted(references))
|
|
|
|
|
|
def _resolve_prompt_llm_profiles(
|
|
prompt: Mapping[str, Any],
|
|
settings: ModalSyncSettings,
|
|
) -> dict[str, Any]:
|
|
"""Resolve LLM metadata before environment admission and cost ranking."""
|
|
from .llm_profiles import get_llm_profile
|
|
from .llm_resolver import resolve_model_profile
|
|
|
|
storage_root = Path(
|
|
getattr(settings, "local_storage_root", "/tmp/comfyui-modal-sync-storage")
|
|
)
|
|
profiles: dict[str, Any] = {}
|
|
for model_reference in _prompt_llm_model_references(prompt):
|
|
try:
|
|
profile = get_llm_profile(model_reference, storage_root=storage_root)
|
|
except ValueError as profile_error:
|
|
if model_reference.startswith("hf-"):
|
|
raise ModalPromptValidationError(str(profile_error)) from profile_error
|
|
try:
|
|
profile = resolve_model_profile(model_reference, storage_root).profile
|
|
except ValueError as resolution_error:
|
|
raise ModalPromptValidationError(
|
|
str(resolution_error)
|
|
) from resolution_error
|
|
profiles[model_reference] = profile
|
|
logger.info(
|
|
"Resolved planner LLM profile model=%s profile=%s weights_gib=%.2f "
|
|
"estimated_vram_gib=%.2f.",
|
|
model_reference,
|
|
profile.profile_id,
|
|
profile.artifact_bytes / 1024**3,
|
|
profile.estimated_vram_gb,
|
|
)
|
|
return profiles
|
|
|
|
|
|
def _component_profile_memory_estimate(
|
|
component: RemoteComponentPlan,
|
|
prompt: Mapping[str, Any],
|
|
preferences: WorkflowExecutionPreferences,
|
|
resolved_profiles: Mapping[str, Any],
|
|
) -> ComponentMemoryEstimate:
|
|
"""Estimate a component's RAM and VRAM floors from resolved LLM profiles."""
|
|
minimum_vram_bytes = preferences.minimum_vram_bytes
|
|
profiles: dict[str, Any] = {}
|
|
for node_id in component.node_ids:
|
|
prompt_node = prompt.get(node_id)
|
|
if not isinstance(prompt_node, Mapping):
|
|
continue
|
|
if str(prompt_node.get("class_type") or "") != "ModalLLM":
|
|
continue
|
|
inputs = prompt_node.get("inputs")
|
|
if not isinstance(inputs, Mapping):
|
|
continue
|
|
model_profile = inputs.get("model_profile")
|
|
if not isinstance(model_profile, str) or not model_profile.strip():
|
|
continue
|
|
profile = resolved_profiles.get(model_profile.strip())
|
|
if profile is None:
|
|
continue
|
|
profiles[profile.profile_id] = profile
|
|
minimum_vram_bytes = max(
|
|
minimum_vram_bytes,
|
|
int(max(0.0, profile.estimated_vram_gb) * 1024**3),
|
|
)
|
|
artifact_sizes = [int(profile.artifact_bytes) for profile in profiles.values()]
|
|
return ComponentMemoryEstimate(
|
|
minimum_vram_bytes=minimum_vram_bytes,
|
|
minimum_ram_bytes=(sum(artifact_sizes) + _MODEL_RAM_HEADROOM_BYTES)
|
|
if artifact_sizes
|
|
else 0,
|
|
model_asset_count=len(artifact_sizes),
|
|
largest_model_bytes=max(artifact_sizes, default=0),
|
|
)
|
|
|
|
|
|
def _component_required_provider(
|
|
component: RemoteComponentPlan,
|
|
prompt: Mapping[str, Any],
|
|
resolved_profiles: Mapping[str, Any],
|
|
) -> ExecutionProvider | None:
|
|
"""Require SSH when a component uses an SSH-only resident backend."""
|
|
for node_id in component.node_ids:
|
|
prompt_node = prompt.get(node_id)
|
|
if not isinstance(prompt_node, Mapping):
|
|
continue
|
|
if str(prompt_node.get("class_type") or "") != "ModalLLM":
|
|
continue
|
|
inputs = prompt_node.get("inputs")
|
|
if not isinstance(inputs, Mapping):
|
|
continue
|
|
model_reference = inputs.get("model_profile")
|
|
if not isinstance(model_reference, str):
|
|
continue
|
|
profile = resolved_profiles.get(model_reference.strip())
|
|
if (
|
|
profile is not None
|
|
and getattr(profile, "backend", "") == "llama_cpp_server"
|
|
):
|
|
return ExecutionProvider.SSH_DOCKER
|
|
return None
|
|
|
|
|
|
def _iter_prompt_string_values(value: object) -> Iterator[str]:
|
|
"""Yield nested prompt string values while ignoring graph links and scalars."""
|
|
if isinstance(value, str):
|
|
yield value
|
|
return
|
|
if isinstance(value, Mapping):
|
|
for child in value.values():
|
|
yield from _iter_prompt_string_values(child)
|
|
return
|
|
if isinstance(value, (list, tuple)) and not _is_link(value):
|
|
for child in value:
|
|
yield from _iter_prompt_string_values(child)
|
|
|
|
|
|
def _is_additive_model_node(class_type: str) -> bool:
|
|
"""Return whether a loader contributes weights alongside a primary model."""
|
|
normalized = class_type.casefold()
|
|
return any(token in normalized for token in _ADDITIVE_MODEL_CLASS_TOKENS)
|
|
|
|
|
|
def _component_model_asset_sizes(
|
|
component: RemoteComponentPlan,
|
|
prompt: Mapping[str, Any],
|
|
settings: ModalSyncSettings,
|
|
) -> tuple[tuple[int, ...], tuple[int, ...]]:
|
|
"""Return unique primary and additive model sizes referenced by a component."""
|
|
asset_roles: dict[Path, bool] = {}
|
|
for node_id in component.node_ids:
|
|
prompt_node = prompt.get(node_id)
|
|
if not isinstance(prompt_node, Mapping):
|
|
continue
|
|
inputs = prompt_node.get("inputs")
|
|
if not isinstance(inputs, Mapping):
|
|
continue
|
|
additive = _is_additive_model_node(str(prompt_node.get("class_type") or ""))
|
|
for value in _iter_prompt_string_values(inputs):
|
|
resolved_path = resolve_model_path(
|
|
value,
|
|
comfyui_root=getattr(settings, "comfyui_root", None),
|
|
extensions=MODEL_FILE_EXTENSIONS,
|
|
)
|
|
if resolved_path is None:
|
|
continue
|
|
asset_roles[resolved_path] = (
|
|
asset_roles.get(resolved_path, True) and additive
|
|
)
|
|
|
|
primary_sizes: list[int] = []
|
|
additive_sizes: list[int] = []
|
|
for asset_path, additive in asset_roles.items():
|
|
try:
|
|
size_bytes = asset_path.stat().st_size
|
|
except OSError as exc:
|
|
logger.warning(
|
|
"Unable to inspect model size for placement path=%s: %s",
|
|
asset_path,
|
|
exc,
|
|
)
|
|
continue
|
|
if size_bytes <= 0:
|
|
continue
|
|
target = additive_sizes if additive else primary_sizes
|
|
target.append(size_bytes)
|
|
return tuple(primary_sizes), tuple(additive_sizes)
|
|
|
|
|
|
def _component_memory_estimate(
|
|
component: RemoteComponentPlan,
|
|
prompt: Mapping[str, Any],
|
|
preferences: WorkflowExecutionPreferences,
|
|
settings: ModalSyncSettings,
|
|
resolved_llm_profiles: Mapping[str, Any] | None = None,
|
|
) -> ComponentMemoryEstimate:
|
|
"""Infer conservative RAM and VRAM floors from resident model weight sizes."""
|
|
profile_estimate = _component_profile_memory_estimate(
|
|
component,
|
|
prompt,
|
|
preferences,
|
|
resolved_llm_profiles
|
|
if resolved_llm_profiles is not None
|
|
else _resolve_prompt_llm_profiles(prompt, settings),
|
|
)
|
|
primary_sizes, additive_sizes = _component_model_asset_sizes(
|
|
component,
|
|
prompt,
|
|
settings,
|
|
)
|
|
all_sizes = primary_sizes + additive_sizes
|
|
if not all_sizes:
|
|
return profile_estimate
|
|
|
|
primary_peak_bytes = max(primary_sizes or all_sizes)
|
|
additive_bytes = sum(additive_sizes) if primary_sizes else 0
|
|
resident_weight_bytes = primary_peak_bytes + additive_bytes
|
|
model_vram_bytes = (
|
|
math.ceil(resident_weight_bytes * _MODEL_VRAM_WEIGHT_MULTIPLIER)
|
|
+ _MODEL_VRAM_HEADROOM_BYTES
|
|
)
|
|
return ComponentMemoryEstimate(
|
|
minimum_vram_bytes=max(
|
|
profile_estimate.minimum_vram_bytes,
|
|
model_vram_bytes,
|
|
),
|
|
minimum_ram_bytes=max(
|
|
profile_estimate.minimum_ram_bytes,
|
|
resident_weight_bytes + _MODEL_RAM_HEADROOM_BYTES,
|
|
),
|
|
model_asset_count=len(all_sizes) + profile_estimate.model_asset_count,
|
|
largest_model_bytes=max(
|
|
max(all_sizes),
|
|
profile_estimate.largest_model_bytes,
|
|
),
|
|
)
|
|
|
|
|
|
def _component_execution_signature(
|
|
component: RemoteComponentPlan,
|
|
prompt: Mapping[str, Any],
|
|
) -> str:
|
|
"""Return a stable workload signature for runtime-history estimates."""
|
|
cost_shaping_input_names = frozenset(
|
|
{
|
|
"batch_size",
|
|
"duration",
|
|
"frames",
|
|
"height",
|
|
"max_new_tokens",
|
|
"model_profile",
|
|
"num_frames",
|
|
"steps",
|
|
"width",
|
|
}
|
|
)
|
|
nodes: list[dict[str, Any]] = []
|
|
for node_id in sorted(component.node_ids):
|
|
prompt_node = prompt.get(node_id)
|
|
if not isinstance(prompt_node, Mapping):
|
|
continue
|
|
inputs = prompt_node.get("inputs")
|
|
input_mapping = inputs if isinstance(inputs, Mapping) else {}
|
|
nodes.append(
|
|
{
|
|
"class_type": str(prompt_node.get("class_type") or ""),
|
|
"inputs": {
|
|
name: input_mapping[name]
|
|
for name in sorted(cost_shaping_input_names & set(input_mapping))
|
|
if not _is_link(input_mapping[name])
|
|
},
|
|
}
|
|
)
|
|
serialized = json.dumps(nodes, separators=(",", ":"), sort_keys=True).encode(
|
|
"utf-8"
|
|
)
|
|
return hashlib.sha256(serialized).hexdigest()
|
|
|
|
|
|
def _execution_history(settings: ModalSyncSettings) -> ExecutionHistory | None:
|
|
"""Return local runtime history when the ComfyUI user directory is known."""
|
|
user_directory = discover_comfyui_user_directory(settings)
|
|
if user_directory is None:
|
|
return None
|
|
return ExecutionHistory.for_user_directory(user_directory)
|
|
|
|
|
|
def _maximum_capacity_state(
|
|
environment: EnvironmentSchedulingState,
|
|
) -> EnvironmentSchedulingState:
|
|
"""Return an optimistic state used only to identify reclaimable SSH capacity."""
|
|
capabilities = environment.capabilities
|
|
if environment.provider is not ExecutionProvider.SSH_DOCKER or capabilities is None:
|
|
return environment
|
|
return replace(
|
|
environment,
|
|
capabilities=replace(
|
|
capabilities,
|
|
available_ram_bytes=capabilities.total_ram_bytes,
|
|
gpus=tuple(
|
|
replace(gpu, free_vram_bytes=gpu.total_vram_bytes)
|
|
for gpu in capabilities.gpus
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
def _reprobe_reclaimed_ssh_host(
|
|
host: SshHostConfig,
|
|
settings: ModalSyncSettings,
|
|
) -> SshHostConfig:
|
|
"""Probe a reclaimed host and persist its new free-memory measurements."""
|
|
registry = _ssh_host_registry(settings)
|
|
if registry is not None:
|
|
return _refresh_ssh_host(host, registry)
|
|
try:
|
|
capabilities = SshDockerController(host).probe_capabilities()
|
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
logger.warning(
|
|
"Remote environment re-probe failed after worker reclaim "
|
|
"environment=%s: %s",
|
|
host.environment_id,
|
|
exc,
|
|
)
|
|
return replace(
|
|
host,
|
|
health=EnvironmentHealth.UNAVAILABLE,
|
|
last_error=str(exc),
|
|
)
|
|
return replace(
|
|
host,
|
|
capabilities=capabilities,
|
|
health=EnvironmentHealth.READY,
|
|
last_error=None,
|
|
)
|
|
|
|
|
|
def _probe_workflow_ssh_configuration(
|
|
configuration: SshRemoteConfiguration,
|
|
) -> SshHostConfig:
|
|
"""Probe one workflow-declared SSH host without mutating the global registry."""
|
|
host = configuration.host
|
|
try:
|
|
capabilities = SshDockerController(host).probe_capabilities()
|
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
logger.warning(
|
|
"Workflow SSH environment probe failed configuration=%s environment=%s: %s",
|
|
configuration.display_name,
|
|
host.environment_id,
|
|
exc,
|
|
)
|
|
return replace(
|
|
host,
|
|
capabilities=None,
|
|
health=EnvironmentHealth.UNAVAILABLE,
|
|
last_error=str(exc),
|
|
)
|
|
return replace(
|
|
host,
|
|
capabilities=capabilities,
|
|
health=EnvironmentHealth.READY,
|
|
last_error=None,
|
|
)
|
|
|
|
|
|
def _optional_scheduler_choice(
|
|
scheduler: CostAwareEnvironmentScheduler,
|
|
environments: list[EnvironmentSchedulingState],
|
|
requirements: ComponentResourceRequirements,
|
|
) -> tuple[
|
|
ExecutionAssignment | None,
|
|
NoCompatibleExecutionEnvironmentError | None,
|
|
]:
|
|
"""Return a scheduler choice and preserve a compatibility failure for fallback."""
|
|
try:
|
|
return scheduler.choose(environments, requirements), None
|
|
except NoCompatibleExecutionEnvironmentError as exc:
|
|
return None, exc
|
|
|
|
|
|
def _require_scheduler_choice(
|
|
assignment: ExecutionAssignment | None,
|
|
error: NoCompatibleExecutionEnvironmentError | None,
|
|
) -> ExecutionAssignment:
|
|
"""Return an existing scheduler choice or raise its compatibility detail."""
|
|
if assignment is not None:
|
|
return assignment
|
|
if error is not None:
|
|
raise error
|
|
raise RuntimeError("Scheduler failed without an incompatibility reason.")
|
|
|
|
|
|
def _remove_idle_ssh_workers_for_reclaim(host: SshHostConfig) -> tuple[str, ...]:
|
|
"""Recycle idle managed workers without disrupting fallback placement."""
|
|
try:
|
|
return SshDockerController(host).remove_idle_managed_workers()
|
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
logger.warning(
|
|
"Could not reclaim managed SSH worker capacity environment=%s: %s",
|
|
host.environment_id,
|
|
exc,
|
|
)
|
|
return ()
|
|
|
|
|
|
def _reclaim_improves_assignment(
|
|
optimistic: ExecutionAssignment,
|
|
actual: ExecutionAssignment | None,
|
|
requirements: ComponentResourceRequirements,
|
|
) -> bool:
|
|
"""Return whether reclaim unlocks required or materially better placement."""
|
|
if actual is None:
|
|
return True
|
|
if optimistic.environment_id == actual.environment_id:
|
|
return False
|
|
preferred = requirements.preferred_environment_ids
|
|
if optimistic.environment_id in preferred:
|
|
if actual.environment_id not in preferred:
|
|
return True
|
|
return preferred.index(optimistic.environment_id) < preferred.index(
|
|
actual.environment_id
|
|
)
|
|
if optimistic.predicted_cost_usd is None:
|
|
return False
|
|
if actual.predicted_cost_usd is None:
|
|
return True
|
|
return optimistic.predicted_cost_usd < actual.predicted_cost_usd
|
|
|
|
|
|
def _choose_with_idle_ssh_worker_reclaim(
|
|
*,
|
|
scheduler: CostAwareEnvironmentScheduler,
|
|
environments: list[EnvironmentSchedulingState],
|
|
requirements: ComponentResourceRequirements,
|
|
ssh_hosts_by_id: dict[str, SshHostConfig],
|
|
settings: ModalSyncSettings,
|
|
) -> ExecutionAssignment:
|
|
"""Choose an environment, recycling a cheaper idle SSH worker when necessary."""
|
|
actual_assignment, actual_error = _optional_scheduler_choice(
|
|
scheduler,
|
|
environments,
|
|
requirements,
|
|
)
|
|
optimistic_assignment, _ = _optional_scheduler_choice(
|
|
scheduler,
|
|
[_maximum_capacity_state(environment) for environment in environments],
|
|
requirements,
|
|
)
|
|
if optimistic_assignment is None:
|
|
return _require_scheduler_choice(actual_assignment, actual_error)
|
|
|
|
if (
|
|
optimistic_assignment.provider is not ExecutionProvider.SSH_DOCKER
|
|
or not _reclaim_improves_assignment(
|
|
optimistic_assignment,
|
|
actual_assignment,
|
|
requirements,
|
|
)
|
|
):
|
|
return _require_scheduler_choice(actual_assignment, actual_error)
|
|
|
|
host = ssh_hosts_by_id.get(optimistic_assignment.environment_id)
|
|
if host is None:
|
|
return _require_scheduler_choice(actual_assignment, actual_error)
|
|
|
|
removed_workers = _remove_idle_ssh_workers_for_reclaim(host)
|
|
if not removed_workers:
|
|
return _require_scheduler_choice(actual_assignment, actual_error)
|
|
|
|
logger.info(
|
|
"Recycled idle managed SSH worker(s) environment=%s containers=%s so the "
|
|
"planner can re-evaluate full host capacity.",
|
|
host.environment_id,
|
|
removed_workers,
|
|
)
|
|
refreshed_host = _reprobe_reclaimed_ssh_host(host, settings)
|
|
ssh_hosts_by_id[host.environment_id] = refreshed_host
|
|
refreshed_environments = [
|
|
(
|
|
refreshed_host.scheduling_state()
|
|
if environment.environment_id == host.environment_id
|
|
else environment
|
|
)
|
|
for environment in environments
|
|
]
|
|
return scheduler.choose(refreshed_environments, requirements)
|
|
|
|
|
|
def _plan_component_execution_assignments(
|
|
*,
|
|
components: list[RemoteComponentPlan],
|
|
prompt: Mapping[str, Any],
|
|
workflow: Mapping[str, Any] | None,
|
|
settings: ModalSyncSettings,
|
|
status_callback: SetupStatusCallback | None = None,
|
|
resolved_llm_profiles: Mapping[str, Any] | None = None,
|
|
) -> dict[str, ExecutionAssignment]:
|
|
"""Assign components across Modal, SSH Docker, and workflow-declared Vast pools."""
|
|
preferences = WorkflowExecutionPreferences.from_workflow(workflow)
|
|
active_llm_profiles = (
|
|
dict(resolved_llm_profiles)
|
|
if resolved_llm_profiles is not None
|
|
else _resolve_prompt_llm_profiles(prompt, settings)
|
|
)
|
|
if preferences.policy is ExecutionPolicy.MODAL:
|
|
incompatible_component_ids = [
|
|
component.representative_node_id
|
|
for component in components
|
|
if _component_required_provider(
|
|
component,
|
|
prompt,
|
|
active_llm_profiles,
|
|
)
|
|
is ExecutionProvider.SSH_DOCKER
|
|
]
|
|
if incompatible_component_ids:
|
|
raise ModalPromptValidationError(
|
|
"Modal-only execution cannot run SSH-only component(s) "
|
|
f"{incompatible_component_ids}. Select Automatic or Self-hosted "
|
|
"execution for the workflow."
|
|
)
|
|
modal_state = _modal_environment_state(settings)
|
|
return {
|
|
component.representative_node_id: ExecutionAssignment(
|
|
environment_id=modal_state.environment_id,
|
|
provider=ExecutionProvider.MODAL,
|
|
predicted_cost_usd=None,
|
|
predicted_completion_seconds=0.0,
|
|
reasons=("workflow policy requires Modal",),
|
|
)
|
|
for component in components
|
|
}
|
|
|
|
try:
|
|
vast_profiles = extract_vast_profiles(prompt)
|
|
except ValueError as exc:
|
|
raise ModalPromptValidationError(str(exc)) from exc
|
|
vast_service: VastService | None = None
|
|
if preferences.policy in {ExecutionPolicy.VAST, ExecutionPolicy.AUTOMATIC}:
|
|
if preferences.policy is ExecutionPolicy.VAST and not vast_profiles:
|
|
raise ModalPromptValidationError(
|
|
"Vast.ai-only execution requires at least one disconnected "
|
|
"Vast.ai Lease Configuration node in the workflow."
|
|
)
|
|
if vast_profiles:
|
|
try:
|
|
vast_service = VastService.from_environment(
|
|
settings,
|
|
repo_root=Path(__file__).resolve().parent,
|
|
)
|
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
if preferences.policy is ExecutionPolicy.VAST:
|
|
raise ModalPromptValidationError(str(exc)) from exc
|
|
logger.warning("Skipping Vast.ai automatic placement: %s", exc)
|
|
|
|
ssh_hosts = (
|
|
_schedulable_ssh_hosts(settings)
|
|
if preferences.policy in {ExecutionPolicy.SELF_HOSTED, ExecutionPolicy.AUTOMATIC}
|
|
else ()
|
|
)
|
|
ssh_hosts_by_id = {host.environment_id: host for host in ssh_hosts}
|
|
modal_state = (
|
|
_modal_environment_state(settings)
|
|
if preferences.policy is ExecutionPolicy.AUTOMATIC
|
|
else None
|
|
)
|
|
if not ssh_hosts and modal_state is None and vast_service is None:
|
|
raise ModalPromptValidationError(
|
|
"This workflow requests remote execution, but no compatible provider is configured."
|
|
)
|
|
|
|
scheduler = CostAwareEnvironmentScheduler()
|
|
history = _execution_history(settings)
|
|
component_signatures: dict[str, str] = {}
|
|
component_memory_estimates: dict[str, ComponentMemoryEstimate] = {}
|
|
component_required_providers: dict[str, ExecutionProvider | None] = {}
|
|
for component in components:
|
|
component_id = component.representative_node_id
|
|
component_signatures[component_id] = _component_execution_signature(
|
|
component,
|
|
prompt,
|
|
)
|
|
memory_estimate = _component_memory_estimate(
|
|
component,
|
|
prompt,
|
|
preferences,
|
|
settings,
|
|
active_llm_profiles,
|
|
)
|
|
component_memory_estimates[component_id] = memory_estimate
|
|
component_required_providers[component_id] = _component_required_provider(
|
|
component,
|
|
prompt,
|
|
active_llm_profiles,
|
|
)
|
|
if memory_estimate.model_asset_count:
|
|
logger.info(
|
|
"Estimated remote component=%s memory floor vram_gib=%.2f ram_gib=%.2f "
|
|
"from model_assets=%d largest_model_gib=%.2f.",
|
|
component_id,
|
|
memory_estimate.minimum_vram_bytes / 1024**3,
|
|
memory_estimate.minimum_ram_bytes / 1024**3,
|
|
memory_estimate.model_asset_count,
|
|
memory_estimate.largest_model_bytes / 1024**3,
|
|
)
|
|
if vast_service is not None:
|
|
try:
|
|
vast_service.prefetch_offers_sync(
|
|
vast_profiles,
|
|
tuple(
|
|
VastSearchRequirements(
|
|
minimum_vram_bytes=memory_estimate.minimum_vram_bytes,
|
|
minimum_ram_bytes=memory_estimate.minimum_ram_bytes,
|
|
)
|
|
for memory_estimate in component_memory_estimates.values()
|
|
),
|
|
)
|
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
if preferences.policy is ExecutionPolicy.VAST:
|
|
raise ModalPromptValidationError(str(exc)) from exc
|
|
logger.warning(
|
|
"Unable to prefetch Vast.ai marketplace offers: %s",
|
|
exc,
|
|
)
|
|
assignments: dict[str, ExecutionAssignment] = {}
|
|
for component in components:
|
|
component_id = component.representative_node_id
|
|
environments = [
|
|
host.scheduling_state() for host in ssh_hosts_by_id.values()
|
|
]
|
|
if modal_state is not None:
|
|
environments.append(modal_state)
|
|
component_signature = component_signatures[component_id]
|
|
memory_estimate = component_memory_estimates[component_id]
|
|
vast_quote: VastProfileQuote | None = None
|
|
if vast_service is not None:
|
|
try:
|
|
vast_quote = vast_service.quote_best_profile_sync(
|
|
vast_profiles,
|
|
minimum_vram_bytes=memory_estimate.minimum_vram_bytes,
|
|
minimum_ram_bytes=memory_estimate.minimum_ram_bytes,
|
|
predicted_execution_seconds=60.0,
|
|
)
|
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
if preferences.policy is ExecutionPolicy.VAST:
|
|
raise ModalPromptValidationError(str(exc)) from exc
|
|
logger.warning(
|
|
"No Vast.ai automatic candidate for component=%s: %s",
|
|
component.representative_node_id,
|
|
exc,
|
|
)
|
|
else:
|
|
environments.append(vast_service.scheduling_state(vast_quote))
|
|
environment_ids = [
|
|
environment.environment_id for environment in environments
|
|
]
|
|
historical_estimates = (
|
|
history.estimates(component_signature, environment_ids)
|
|
if history is not None
|
|
else {}
|
|
)
|
|
component_required_provider = component_required_providers[component_id]
|
|
if preferences.policy is ExecutionPolicy.VAST:
|
|
if component_required_provider is ExecutionProvider.SSH_DOCKER:
|
|
raise ModalPromptValidationError(
|
|
"Vast.ai-only execution cannot run an SSH-only component. "
|
|
"Select Automatic or Self-hosted execution for this workflow."
|
|
)
|
|
component_required_provider = ExecutionProvider.VAST
|
|
requirements = ComponentResourceRequirements(
|
|
minimum_vram_bytes=memory_estimate.minimum_vram_bytes,
|
|
minimum_ram_bytes=memory_estimate.minimum_ram_bytes,
|
|
gpu_required=True,
|
|
architecture="x86_64",
|
|
estimated_execution_seconds=60.0,
|
|
estimated_execution_seconds_by_environment={
|
|
environment_id: estimate.execution_seconds
|
|
for environment_id, estimate in historical_estimates.items()
|
|
},
|
|
preferred_environment_ids=preferences.preferred_environment_ids,
|
|
required_provider=component_required_provider,
|
|
)
|
|
try:
|
|
assignment = _choose_with_idle_ssh_worker_reclaim(
|
|
scheduler=scheduler,
|
|
environments=environments,
|
|
requirements=requirements,
|
|
ssh_hosts_by_id=ssh_hosts_by_id,
|
|
settings=settings,
|
|
)
|
|
except NoCompatibleExecutionEnvironmentError as exc:
|
|
raise ModalPromptValidationError(str(exc)) from exc
|
|
if assignment.provider is ExecutionProvider.VAST:
|
|
if vast_service is None or vast_quote is None:
|
|
raise ModalPromptValidationError(
|
|
"Vast.ai was selected without a current marketplace quote."
|
|
)
|
|
try:
|
|
if status_callback is None:
|
|
lease = vast_service.acquire_sync(vast_quote)
|
|
else:
|
|
lease = vast_service.acquire_sync(
|
|
vast_quote,
|
|
status_callback=lambda message: status_callback(
|
|
message,
|
|
None,
|
|
None,
|
|
),
|
|
)
|
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
raise ModalPromptValidationError(
|
|
f"Unable to acquire Vast.ai capacity: {exc}"
|
|
) from exc
|
|
assignment = replace(
|
|
assignment,
|
|
environment_id=lease.environment_id,
|
|
predicted_cost_usd=vast_quote.predicted_incremental_cost_usd,
|
|
reasons=assignment.reasons
|
|
+ (
|
|
f"Vast profile {vast_quote.profile.profile_name}",
|
|
f"idle retention {lease.idle_retention_seconds / 3600:.1f}h",
|
|
),
|
|
)
|
|
assignments[component.representative_node_id] = assignment
|
|
logger.info(
|
|
"Assigned remote component=%s provider=%s environment=%s predicted_cost_usd=%s reasons=%s.",
|
|
component.representative_node_id,
|
|
assignment.provider.value,
|
|
assignment.environment_id,
|
|
assignment.predicted_cost_usd,
|
|
assignment.reasons,
|
|
)
|
|
return assignments
|
|
|
|
|
|
def _plan_component_execution(
|
|
*,
|
|
components: list[RemoteComponentPlan],
|
|
prompt: Mapping[str, Any],
|
|
workflow: Mapping[str, Any] | None,
|
|
settings: ModalSyncSettings,
|
|
status_callback: SetupStatusCallback | None = None,
|
|
environment_status_callback: EnvironmentSetupStatusCallback | None = None,
|
|
plan_callback: ExecutionPlanStatusCallback | None = None,
|
|
occupied_environment_ids: frozenset[str] = frozenset(),
|
|
) -> ComponentExecutionPlan:
|
|
"""Plan through connected configurations or synthesize the legacy behavior."""
|
|
try:
|
|
configuration_set = compile_remote_configuration_set(prompt)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ModalPromptValidationError(str(exc)) from exc
|
|
resolved_llm_profiles = _resolve_prompt_llm_profiles(prompt, settings)
|
|
if configuration_set is None:
|
|
return ComponentExecutionPlan(
|
|
assignments=_plan_component_execution_assignments(
|
|
components=components,
|
|
prompt=prompt,
|
|
workflow=workflow,
|
|
settings=settings,
|
|
status_callback=status_callback,
|
|
resolved_llm_profiles=resolved_llm_profiles,
|
|
),
|
|
resolved_llm_profiles=resolved_llm_profiles,
|
|
)
|
|
return _plan_configured_component_execution(
|
|
components=components,
|
|
prompt=prompt,
|
|
workflow=workflow,
|
|
settings=settings,
|
|
configuration_set=configuration_set,
|
|
status_callback=status_callback,
|
|
environment_status_callback=environment_status_callback,
|
|
plan_callback=plan_callback,
|
|
occupied_environment_ids=occupied_environment_ids,
|
|
resolved_llm_profiles=resolved_llm_profiles,
|
|
)
|
|
|
|
|
|
def _plan_configured_component_execution(
|
|
*,
|
|
components: list[RemoteComponentPlan],
|
|
prompt: Mapping[str, Any],
|
|
workflow: Mapping[str, Any] | None,
|
|
settings: ModalSyncSettings,
|
|
configuration_set: RemoteConfigurationSet,
|
|
status_callback: SetupStatusCallback | None = None,
|
|
environment_status_callback: EnvironmentSetupStatusCallback | None = None,
|
|
plan_callback: ExecutionPlanStatusCallback | None = None,
|
|
occupied_environment_ids: frozenset[str] = frozenset(),
|
|
resolved_llm_profiles: Mapping[str, Any] | None = None,
|
|
) -> ComponentExecutionPlan:
|
|
"""Resolve and prepare a capacity-aware plan from connected configurations."""
|
|
configurations_by_id = {
|
|
configuration.configuration_id: configuration
|
|
for configuration in configuration_set.capacity_configurations
|
|
}
|
|
ssh_hosts_by_id = _probe_configured_ssh_hosts(configuration_set)
|
|
vast_service, vast_unavailable_reason = _configured_vast_service(
|
|
configuration_set,
|
|
settings,
|
|
)
|
|
requirements_by_component = _configured_component_requirements(
|
|
components=components,
|
|
prompt=prompt,
|
|
workflow=workflow,
|
|
settings=settings,
|
|
resolved_llm_profiles=resolved_llm_profiles,
|
|
)
|
|
_prefetch_configured_vast_offers(
|
|
configuration_set=configuration_set,
|
|
requirements_by_component=requirements_by_component,
|
|
vast_service=vast_service,
|
|
)
|
|
environments_by_component, vast_quotes = _configured_candidate_environments(
|
|
configuration_set=configuration_set,
|
|
requirements_by_component=requirements_by_component,
|
|
settings=settings,
|
|
ssh_hosts_by_id=ssh_hosts_by_id,
|
|
vast_service=vast_service,
|
|
vast_unavailable_reason=vast_unavailable_reason,
|
|
occupied_environment_ids=occupied_environment_ids,
|
|
)
|
|
if _reclaim_idle_configured_ssh_capacity(
|
|
configuration_set=configuration_set,
|
|
requirements_by_component=requirements_by_component,
|
|
ssh_hosts_by_id=ssh_hosts_by_id,
|
|
occupied_environment_ids=occupied_environment_ids,
|
|
):
|
|
environments_by_component, vast_quotes = _configured_candidate_environments(
|
|
configuration_set=configuration_set,
|
|
requirements_by_component=requirements_by_component,
|
|
settings=settings,
|
|
ssh_hosts_by_id=ssh_hosts_by_id,
|
|
vast_service=vast_service,
|
|
vast_unavailable_reason=vast_unavailable_reason,
|
|
occupied_environment_ids=occupied_environment_ids,
|
|
)
|
|
_apply_historical_execution_estimates(
|
|
components=components,
|
|
environments_by_component=environments_by_component,
|
|
requirements_by_component=requirements_by_component,
|
|
prompt=prompt,
|
|
settings=settings,
|
|
)
|
|
execution_stages = _component_execution_stages(
|
|
dict(prompt),
|
|
{
|
|
component.representative_node_id: set(component.node_ids)
|
|
for component in components
|
|
},
|
|
)
|
|
try:
|
|
assignments = CostAwareEnvironmentScheduler().plan(
|
|
execution_stages=execution_stages,
|
|
environments_by_component=environments_by_component,
|
|
requirements_by_component=requirements_by_component,
|
|
)
|
|
except NoCompatibleExecutionEnvironmentError as exc:
|
|
raise ModalPromptValidationError(str(exc)) from exc
|
|
|
|
for component_id, assignment in tuple(assignments.items()):
|
|
if assignment.environment_id not in occupied_environment_ids:
|
|
continue
|
|
assignments[component_id] = replace(
|
|
assignment,
|
|
reasons=assignment.reasons
|
|
+ ("capacity available after the earlier workflow completes",),
|
|
)
|
|
|
|
safe_configurations = _safe_remote_configuration_payload(configuration_set)
|
|
if plan_callback is not None:
|
|
plan_callback(
|
|
_planned_execution_assignments_payload(
|
|
assignments,
|
|
components,
|
|
configurations_by_id=configurations_by_id,
|
|
ssh_hosts_by_id=ssh_hosts_by_id,
|
|
vast_quotes=vast_quotes,
|
|
),
|
|
safe_configurations,
|
|
)
|
|
|
|
vast_leases = _prepare_selected_vast_capacity(
|
|
assignments=assignments,
|
|
configuration_set=configuration_set,
|
|
requirements_by_component=requirements_by_component,
|
|
vast_quotes=vast_quotes,
|
|
vast_service=vast_service,
|
|
status_callback=status_callback,
|
|
environment_status_callback=environment_status_callback,
|
|
)
|
|
if plan_callback is not None and vast_leases:
|
|
plan_callback(
|
|
_planned_execution_assignments_payload(
|
|
assignments,
|
|
components,
|
|
configurations_by_id=configurations_by_id,
|
|
ssh_hosts_by_id=ssh_hosts_by_id,
|
|
vast_quotes=vast_quotes,
|
|
vast_leases_by_environment=vast_leases,
|
|
),
|
|
safe_configurations,
|
|
)
|
|
return ComponentExecutionPlan(
|
|
assignments=assignments,
|
|
configuration_set=configuration_set,
|
|
configurations_by_id=configurations_by_id,
|
|
safe_configurations=safe_configurations,
|
|
ssh_hosts_by_id=ssh_hosts_by_id,
|
|
vast_service=vast_service,
|
|
vast_leases_by_environment=vast_leases,
|
|
resolved_llm_profiles=dict(resolved_llm_profiles or {}),
|
|
)
|
|
|
|
|
|
def _safe_remote_configuration_payload(
|
|
configuration_set: RemoteConfigurationSet,
|
|
) -> list[dict[str, Any]]:
|
|
"""Return safe configuration metadata enriched with best-effort storage usage."""
|
|
payload = configuration_set.to_safe_list()
|
|
payload_by_id = {
|
|
str(configuration.get("configuration_id") or ""): configuration
|
|
for configuration in payload
|
|
}
|
|
for storage in configuration_set.storage_configurations:
|
|
if not isinstance(storage, R2StorageBackingConfiguration):
|
|
continue
|
|
safe_storage = payload_by_id.get(storage.configuration_id)
|
|
if safe_storage is None:
|
|
continue
|
|
usage, error_code = _cached_r2_storage_usage(storage)
|
|
if error_code is not None:
|
|
safe_storage["credential_error_code"] = error_code
|
|
if usage is None:
|
|
continue
|
|
safe_storage["storage_usage_bytes"] = usage.size_bytes
|
|
safe_storage["storage_object_count"] = usage.object_count
|
|
return payload
|
|
|
|
|
|
def _cached_r2_storage_usage(
|
|
storage: R2StorageBackingConfiguration,
|
|
) -> tuple[R2StorageUsage | None, str | None]:
|
|
"""Return cached bucket usage and an optional credential recovery code."""
|
|
cache_key = (storage.account_id, storage.bucket, storage.jurisdiction)
|
|
now = time.monotonic()
|
|
with _R2_STORAGE_USAGE_CACHE_LOCK:
|
|
cached = _R2_STORAGE_USAGE_CACHE.get(cache_key)
|
|
if cached is not None and now - cached[0] < _R2_STORAGE_USAGE_CACHE_SECONDS:
|
|
return cached[1], None
|
|
try:
|
|
usage = _refresh_r2_storage_usage(storage)
|
|
except R2CredentialError as exc:
|
|
logger.warning(
|
|
"Unable to read R2 storage usage for configuration=%s bucket=%s: %s",
|
|
storage.configuration_id,
|
|
storage.bucket,
|
|
exc,
|
|
)
|
|
return None, exc.code
|
|
except (R2CacheError, RuntimeError, ValueError) as exc:
|
|
logger.warning(
|
|
"Unable to read R2 storage usage for configuration=%s bucket=%s: %s",
|
|
storage.configuration_id,
|
|
storage.bucket,
|
|
exc,
|
|
)
|
|
return None, None
|
|
return usage, None
|
|
|
|
|
|
def _refresh_r2_storage_usage(
|
|
storage: R2StorageBackingConfiguration,
|
|
) -> R2StorageUsage:
|
|
"""Query current R2 bucket usage and replace the short-lived cache entry."""
|
|
configuration = R2CredentialStore().cache_configuration(storage)
|
|
usage = R2CacheClient(configuration).storage_usage()
|
|
cache_key = (storage.account_id, storage.bucket, storage.jurisdiction)
|
|
with _R2_STORAGE_USAGE_CACHE_LOCK:
|
|
_R2_STORAGE_USAGE_CACHE[cache_key] = (time.monotonic(), usage)
|
|
return usage
|
|
|
|
|
|
def _r2_storage_from_usage_payload(
|
|
payload: Mapping[str, Any],
|
|
) -> R2StorageBackingConfiguration:
|
|
"""Build a validated R2 reference from a same-origin usage refresh request."""
|
|
configuration_id = str(
|
|
payload.get("configuration_id") or "r2-storage-refresh"
|
|
).strip()
|
|
return R2StorageBackingConfiguration(
|
|
configuration_id=configuration_id,
|
|
display_name=str(payload.get("display_name") or "R2 storage").strip(),
|
|
account_id=str(payload.get("account_id") or "").strip(),
|
|
bucket=str(payload.get("bucket") or "").strip(),
|
|
credential_id=str(payload.get("credential_id") or "").strip(),
|
|
jurisdiction=str(payload.get("jurisdiction") or "default")
|
|
.strip()
|
|
.casefold(),
|
|
key_prefix=str(
|
|
payload.get("key_prefix") or "comfy-modal-cache/v1/blobs/sha256"
|
|
).strip(),
|
|
write_back_mode=str(payload.get("write_back_mode") or "async")
|
|
.strip()
|
|
.casefold(),
|
|
)
|
|
|
|
|
|
def _planned_execution_assignments_payload(
|
|
assignments: Mapping[str, ExecutionAssignment],
|
|
components: Sequence[RemoteComponentPlan],
|
|
*,
|
|
configurations_by_id: Mapping[str, Any] | None = None,
|
|
ssh_hosts_by_id: Mapping[str, SshHostConfig] | None = None,
|
|
vast_quotes: Mapping[tuple[str, str], VastProfileQuote] | None = None,
|
|
vast_leases_by_environment: Mapping[str, Any] | None = None,
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""Serialize scheduler choices before provider capacity is acquired."""
|
|
component_nodes = {
|
|
component.representative_node_id: list(component.node_ids)
|
|
for component in components
|
|
}
|
|
return {
|
|
component_id: {
|
|
"provider": assignment.provider.value,
|
|
"environment_id": assignment.environment_id,
|
|
"configuration_id": assignment.configuration_id,
|
|
"node_ids": component_nodes.get(component_id, [component_id]),
|
|
"predicted_cost_usd": assignment.predicted_cost_usd,
|
|
"predicted_completion_seconds": assignment.predicted_completion_seconds,
|
|
"worker_index": assignment.capacity_slot_index,
|
|
"reasons": list(assignment.reasons),
|
|
"hardware": _assignment_hardware_payload(
|
|
component_id=component_id,
|
|
assignment=assignment,
|
|
configurations_by_id=configurations_by_id or {},
|
|
ssh_hosts_by_id=ssh_hosts_by_id or {},
|
|
vast_quotes=vast_quotes or {},
|
|
vast_leases_by_environment=vast_leases_by_environment or {},
|
|
),
|
|
}
|
|
for component_id, assignment in sorted(assignments.items())
|
|
}
|
|
|
|
|
|
def _configuration_field(configuration: Any, field_name: str) -> Any:
|
|
"""Read one field from a configuration model or safe mapping."""
|
|
if isinstance(configuration, Mapping):
|
|
return configuration.get(field_name)
|
|
return getattr(configuration, field_name, None)
|
|
|
|
|
|
def _configuration_host(configuration: Any) -> SshHostConfig | None:
|
|
"""Return a workflow SSH host from a model or safe configuration mapping."""
|
|
host = _configuration_field(configuration, "host")
|
|
if isinstance(host, SshHostConfig):
|
|
return host
|
|
if isinstance(host, Mapping):
|
|
try:
|
|
return SshHostConfig.from_dict(host)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return None
|
|
|
|
|
|
def _assignment_hardware_payload(
|
|
*,
|
|
component_id: str,
|
|
assignment: ExecutionAssignment,
|
|
configurations_by_id: Mapping[str, Any],
|
|
ssh_hosts_by_id: Mapping[str, SshHostConfig],
|
|
vast_quotes: Mapping[tuple[str, str], VastProfileQuote],
|
|
vast_leases_by_environment: Mapping[str, Any],
|
|
) -> dict[str, Any] | None:
|
|
"""Return the best hardware identity known for one planned assignment."""
|
|
configuration_id = str(assignment.configuration_id or "")
|
|
configuration = configurations_by_id.get(configuration_id)
|
|
if assignment.provider is ExecutionProvider.MODAL:
|
|
gpu_type = str(
|
|
_configuration_field(configuration, "gpu_type")
|
|
or assignment.environment_id.rsplit(":", 1)[-1]
|
|
)
|
|
return _modal_hardware_payload(gpu_type)
|
|
if assignment.provider is ExecutionProvider.SSH_DOCKER:
|
|
host = ssh_hosts_by_id.get(configuration_id) or _configuration_host(
|
|
configuration
|
|
)
|
|
return _capabilities_hardware_payload(
|
|
host.capabilities if host is not None else None
|
|
)
|
|
lease = vast_leases_by_environment.get(assignment.environment_id)
|
|
if lease is not None:
|
|
return _vast_hardware_payload(lease)
|
|
quote = vast_quotes.get((component_id, configuration_id))
|
|
if quote is None:
|
|
return None
|
|
resource = getattr(quote, "existing_lease", None) or getattr(
|
|
quote,
|
|
"offer",
|
|
None,
|
|
)
|
|
return _vast_hardware_payload(resource) if resource is not None else None
|
|
|
|
|
|
def _probe_configured_ssh_hosts(
|
|
configuration_set: RemoteConfigurationSet,
|
|
) -> dict[str, SshHostConfig]:
|
|
"""Probe all workflow-declared SSH hosts concurrently."""
|
|
ssh_configurations = [
|
|
configuration
|
|
for configuration in configuration_set.configurations
|
|
if isinstance(configuration, SshRemoteConfiguration)
|
|
]
|
|
if not ssh_configurations:
|
|
return {}
|
|
with ThreadPoolExecutor(max_workers=min(8, len(ssh_configurations))) as executor:
|
|
probed_hosts = {
|
|
configuration.configuration_id: future.result()
|
|
for configuration, future in (
|
|
(
|
|
configuration,
|
|
executor.submit(
|
|
_probe_workflow_ssh_configuration,
|
|
configuration,
|
|
),
|
|
)
|
|
for configuration in ssh_configurations
|
|
)
|
|
}
|
|
return probed_hosts
|
|
|
|
|
|
def _configured_vast_service(
|
|
configuration_set: RemoteConfigurationSet,
|
|
settings: ModalSyncSettings,
|
|
) -> tuple[VastService | None, str | None]:
|
|
"""Construct one Vast service only when the connected graph requests it."""
|
|
vast_configured = any(
|
|
isinstance(configuration, VastRemoteConfiguration)
|
|
for configuration in configuration_set.capacity_configurations
|
|
)
|
|
if not vast_configured:
|
|
return None, None
|
|
try:
|
|
return (
|
|
VastService.from_environment(
|
|
settings,
|
|
repo_root=Path(__file__).resolve().parent,
|
|
),
|
|
None,
|
|
)
|
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
has_fallback_provider = any(
|
|
not isinstance(configuration, VastRemoteConfiguration)
|
|
for configuration in configuration_set.capacity_configurations
|
|
)
|
|
if not has_fallback_provider:
|
|
raise ModalPromptValidationError(str(exc)) from exc
|
|
logger.warning(
|
|
"Skipping unavailable workflow Vast.ai capacity because another "
|
|
"configured provider can be planned: %s",
|
|
exc,
|
|
)
|
|
return None, str(exc)
|
|
|
|
|
|
def _configured_component_requirements(
|
|
*,
|
|
components: list[RemoteComponentPlan],
|
|
prompt: Mapping[str, Any],
|
|
workflow: Mapping[str, Any] | None,
|
|
settings: ModalSyncSettings,
|
|
resolved_llm_profiles: Mapping[str, Any] | None = None,
|
|
) -> dict[str, ComponentResourceRequirements]:
|
|
"""Build provider-neutral requirements for every remote component."""
|
|
preferences = WorkflowExecutionPreferences.from_workflow(workflow)
|
|
active_llm_profiles = (
|
|
dict(resolved_llm_profiles)
|
|
if resolved_llm_profiles is not None
|
|
else _resolve_prompt_llm_profiles(prompt, settings)
|
|
)
|
|
requirements: dict[str, ComponentResourceRequirements] = {}
|
|
for component in components:
|
|
memory_estimate = _component_memory_estimate(
|
|
component,
|
|
prompt,
|
|
preferences,
|
|
settings,
|
|
active_llm_profiles,
|
|
)
|
|
component_id = component.representative_node_id
|
|
requirements[component_id] = ComponentResourceRequirements(
|
|
minimum_vram_bytes=memory_estimate.minimum_vram_bytes,
|
|
minimum_ram_bytes=memory_estimate.minimum_ram_bytes,
|
|
gpu_required=True,
|
|
architecture="x86_64",
|
|
estimated_execution_seconds=60.0,
|
|
preferred_environment_ids=preferences.preferred_environment_ids,
|
|
required_provider=_component_required_provider(
|
|
component,
|
|
prompt,
|
|
active_llm_profiles,
|
|
),
|
|
)
|
|
return requirements
|
|
|
|
|
|
def _configured_candidate_environments(
|
|
*,
|
|
configuration_set: RemoteConfigurationSet,
|
|
requirements_by_component: Mapping[str, ComponentResourceRequirements],
|
|
settings: ModalSyncSettings,
|
|
ssh_hosts_by_id: Mapping[str, SshHostConfig],
|
|
vast_service: VastService | None,
|
|
vast_unavailable_reason: str | None,
|
|
occupied_environment_ids: frozenset[str] = frozenset(),
|
|
) -> tuple[
|
|
dict[str, list[EnvironmentSchedulingState]],
|
|
dict[tuple[str, str], VastProfileQuote],
|
|
]:
|
|
"""Resolve candidate pool states for every component without acquiring capacity."""
|
|
candidates: dict[str, list[EnvironmentSchedulingState]] = {
|
|
component_id: [] for component_id in requirements_by_component
|
|
}
|
|
vast_quotes: dict[tuple[str, str], VastProfileQuote] = {}
|
|
for component_id, requirements in requirements_by_component.items():
|
|
for configuration in configuration_set.capacity_configurations:
|
|
state, quote = _configured_candidate_environment(
|
|
configuration=configuration,
|
|
requirements=requirements,
|
|
settings=settings,
|
|
ssh_hosts_by_id=ssh_hosts_by_id,
|
|
vast_service=vast_service,
|
|
vast_unavailable_reason=vast_unavailable_reason,
|
|
occupied_environment_ids=occupied_environment_ids,
|
|
)
|
|
if state is not None:
|
|
candidates[component_id].append(state)
|
|
if quote is not None:
|
|
vast_quotes[(component_id, configuration.configuration_id)] = quote
|
|
return candidates, vast_quotes
|
|
|
|
|
|
def _reclaim_idle_configured_ssh_capacity(
|
|
*,
|
|
configuration_set: RemoteConfigurationSet,
|
|
requirements_by_component: Mapping[str, ComponentResourceRequirements],
|
|
ssh_hosts_by_id: dict[str, SshHostConfig],
|
|
occupied_environment_ids: frozenset[str],
|
|
) -> bool:
|
|
"""Recycle idle configured workers when only their cached memory blocks work."""
|
|
scheduler = CostAwareEnvironmentScheduler()
|
|
reclaimed = False
|
|
for configuration in configuration_set.capacity_configurations:
|
|
if not isinstance(configuration, SshRemoteConfiguration):
|
|
continue
|
|
host = ssh_hosts_by_id[configuration.configuration_id]
|
|
if host.environment_id in occupied_environment_ids:
|
|
continue
|
|
state = replace(
|
|
host.scheduling_state(),
|
|
configuration_id=configuration.configuration_id,
|
|
display_name=configuration.display_name,
|
|
)
|
|
optimistic_state = _maximum_capacity_state(state)
|
|
reclaimable = any(
|
|
_optional_scheduler_choice(scheduler, [state], requirements)[0] is None
|
|
and _optional_scheduler_choice(
|
|
scheduler,
|
|
[optimistic_state],
|
|
requirements,
|
|
)[0]
|
|
is not None
|
|
for requirements in requirements_by_component.values()
|
|
)
|
|
if not reclaimable:
|
|
continue
|
|
removed_workers = _remove_idle_ssh_workers_for_reclaim(host)
|
|
if not removed_workers:
|
|
continue
|
|
refreshed_host = _probe_workflow_ssh_configuration(configuration)
|
|
ssh_hosts_by_id[configuration.configuration_id] = refreshed_host
|
|
reclaimed = True
|
|
logger.info(
|
|
"Recycled idle workflow-configured SSH worker(s) environment=%s "
|
|
"containers=%s and refreshed available memory.",
|
|
host.environment_id,
|
|
removed_workers,
|
|
)
|
|
return reclaimed
|
|
|
|
|
|
def _prefetch_configured_vast_offers(
|
|
*,
|
|
configuration_set: RemoteConfigurationSet,
|
|
requirements_by_component: Mapping[str, ComponentResourceRequirements],
|
|
vast_service: VastService | None,
|
|
) -> None:
|
|
"""Warm distinct Vast searches in parallel before deterministic planning."""
|
|
if vast_service is None:
|
|
return
|
|
profiles = [
|
|
configuration.profile
|
|
for configuration in configuration_set.configurations
|
|
if isinstance(configuration, VastRemoteConfiguration)
|
|
]
|
|
prefetch = getattr(vast_service, "prefetch_offers_sync", None)
|
|
if not callable(prefetch):
|
|
return
|
|
try:
|
|
prefetch(
|
|
profiles,
|
|
tuple(
|
|
VastSearchRequirements(
|
|
minimum_vram_bytes=requirements.minimum_vram_bytes,
|
|
minimum_ram_bytes=requirements.minimum_ram_bytes,
|
|
)
|
|
for requirements in requirements_by_component.values()
|
|
),
|
|
)
|
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
logger.warning(
|
|
"Unable to prefetch configured Vast.ai marketplace offers: %s",
|
|
exc,
|
|
)
|
|
|
|
|
|
def _configured_candidate_environment(
|
|
*,
|
|
configuration: RemoteConfiguration,
|
|
requirements: ComponentResourceRequirements,
|
|
settings: ModalSyncSettings,
|
|
ssh_hosts_by_id: Mapping[str, SshHostConfig],
|
|
vast_service: VastService | None,
|
|
vast_unavailable_reason: str | None,
|
|
occupied_environment_ids: frozenset[str] = frozenset(),
|
|
) -> tuple[EnvironmentSchedulingState | None, VastProfileQuote | None]:
|
|
"""Resolve one configuration as a candidate for one component."""
|
|
if isinstance(configuration, ModalRemoteConfiguration):
|
|
return _modal_configuration_environment_state(configuration, settings), None
|
|
if isinstance(configuration, SshRemoteConfiguration):
|
|
host = ssh_hosts_by_id[configuration.configuration_id]
|
|
state = replace(
|
|
host.scheduling_state(),
|
|
configuration_id=configuration.configuration_id,
|
|
display_name=configuration.display_name,
|
|
)
|
|
if state.environment_id in occupied_environment_ids:
|
|
state = _maximum_capacity_state(state)
|
|
logger.info(
|
|
"Planning queued work against reclaimable SSH capacity "
|
|
"environment=%s because an earlier workflow owns that host.",
|
|
state.environment_id,
|
|
)
|
|
return state, None
|
|
if not isinstance(configuration, VastRemoteConfiguration):
|
|
raise TypeError(
|
|
f"Unsupported remote configuration type {type(configuration).__name__}."
|
|
)
|
|
if vast_service is None:
|
|
return EnvironmentSchedulingState(
|
|
environment_id=f"vast:{configuration.configuration_id}",
|
|
provider=ExecutionProvider.VAST,
|
|
enabled=True,
|
|
health=EnvironmentHealth.UNAVAILABLE,
|
|
cost_usd_per_second=None,
|
|
capabilities=None,
|
|
configuration_id=configuration.configuration_id,
|
|
display_name=configuration.display_name,
|
|
unavailable_reason=(
|
|
vast_unavailable_reason
|
|
or "Vast.ai controller is unavailable."
|
|
),
|
|
maximum_workers=configuration.capacity_limit,
|
|
), None
|
|
try:
|
|
quote = vast_service.quote_best_profile_sync(
|
|
[configuration.profile],
|
|
minimum_vram_bytes=requirements.minimum_vram_bytes,
|
|
minimum_ram_bytes=requirements.minimum_ram_bytes,
|
|
predicted_execution_seconds=requirements.estimated_execution_seconds,
|
|
)
|
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
logger.warning(
|
|
"Vast configuration has no candidate configuration=%s: %s",
|
|
configuration.display_name,
|
|
exc,
|
|
)
|
|
return EnvironmentSchedulingState(
|
|
environment_id=f"vast:{configuration.configuration_id}",
|
|
provider=ExecutionProvider.VAST,
|
|
enabled=True,
|
|
health=EnvironmentHealth.UNAVAILABLE,
|
|
cost_usd_per_second=None,
|
|
capabilities=None,
|
|
configuration_id=configuration.configuration_id,
|
|
display_name=configuration.display_name,
|
|
unavailable_reason=str(exc),
|
|
maximum_workers=configuration.capacity_limit,
|
|
), None
|
|
state = vast_service.scheduling_state(quote)
|
|
return replace(
|
|
state,
|
|
environment_id=f"vast:{configuration.configuration_id}",
|
|
configuration_id=configuration.configuration_id,
|
|
display_name=configuration.display_name,
|
|
maximum_workers=configuration.capacity_limit,
|
|
), quote
|
|
|
|
|
|
def _apply_historical_execution_estimates(
|
|
*,
|
|
components: list[RemoteComponentPlan],
|
|
environments_by_component: Mapping[str, list[EnvironmentSchedulingState]],
|
|
requirements_by_component: dict[str, ComponentResourceRequirements],
|
|
prompt: Mapping[str, Any],
|
|
settings: ModalSyncSettings,
|
|
) -> None:
|
|
"""Add environment-specific historical timing estimates in place."""
|
|
history = _execution_history(settings)
|
|
if history is None:
|
|
return
|
|
for component in components:
|
|
component_id = component.representative_node_id
|
|
environment_ids = [
|
|
environment.environment_id
|
|
for environment in environments_by_component.get(component_id, [])
|
|
]
|
|
estimates = history.estimates(
|
|
_component_execution_signature(component, prompt),
|
|
environment_ids,
|
|
)
|
|
requirements_by_component[component_id] = replace(
|
|
requirements_by_component[component_id],
|
|
estimated_execution_seconds_by_environment={
|
|
environment_id: estimate.execution_seconds
|
|
for environment_id, estimate in estimates.items()
|
|
},
|
|
)
|
|
|
|
|
|
def _prepare_selected_vast_capacity(
|
|
*,
|
|
assignments: dict[str, ExecutionAssignment],
|
|
configuration_set: RemoteConfigurationSet,
|
|
requirements_by_component: Mapping[str, ComponentResourceRequirements],
|
|
vast_quotes: Mapping[tuple[str, str], VastProfileQuote],
|
|
vast_service: VastService | None,
|
|
status_callback: SetupStatusCallback | None = None,
|
|
environment_status_callback: EnvironmentSetupStatusCallback | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Acquire only Vast slots selected by the completed provider-neutral plan."""
|
|
selected_slots: dict[tuple[str, int], list[str]] = defaultdict(list)
|
|
for component_id, assignment in assignments.items():
|
|
if assignment.provider is not ExecutionProvider.VAST:
|
|
continue
|
|
if assignment.configuration_id is None:
|
|
raise ModalPromptValidationError(
|
|
"Vast assignment is missing its configuration identity."
|
|
)
|
|
selected_slots[
|
|
(assignment.configuration_id, assignment.capacity_slot_index)
|
|
].append(component_id)
|
|
if not selected_slots:
|
|
return {}
|
|
if vast_service is None:
|
|
raise ModalPromptValidationError(
|
|
"Vast capacity was selected without an initialized Vast service."
|
|
)
|
|
|
|
configurations_by_id = {
|
|
configuration.configuration_id: configuration
|
|
for configuration in configuration_set.configurations
|
|
if isinstance(configuration, VastRemoteConfiguration)
|
|
}
|
|
leases_by_environment: dict[str, Any] = {}
|
|
ordered_slots = sorted(selected_slots.items())
|
|
total_slots = len(ordered_slots)
|
|
for slot_number, ((configuration_id, slot_index), component_ids) in enumerate(
|
|
ordered_slots,
|
|
start=1,
|
|
):
|
|
configuration = configurations_by_id[configuration_id]
|
|
planned_environment_id = assignments[component_ids[0]].environment_id
|
|
quote = _quote_selected_vast_slot(
|
|
configuration=configuration,
|
|
component_ids=component_ids,
|
|
requirements_by_component=requirements_by_component,
|
|
vast_quotes=vast_quotes,
|
|
vast_service=vast_service,
|
|
)
|
|
try:
|
|
_emit_vast_capacity_status(
|
|
environment_id=planned_environment_id,
|
|
message=f"Acquiring Vast.ai capacity {slot_number} of {total_slots}",
|
|
current=slot_number - 1,
|
|
total=total_slots,
|
|
status_callback=status_callback,
|
|
environment_status_callback=environment_status_callback,
|
|
)
|
|
if status_callback is None and environment_status_callback is None:
|
|
lease = vast_service.acquire_sync(quote, slot=slot_index)
|
|
else:
|
|
lease = vast_service.acquire_sync(
|
|
quote,
|
|
slot=slot_index,
|
|
status_callback=(
|
|
lambda message, environment_id=planned_environment_id,
|
|
current=slot_number - 1: _emit_vast_capacity_status(
|
|
environment_id=environment_id,
|
|
message=message,
|
|
current=current,
|
|
total=total_slots,
|
|
status_callback=status_callback,
|
|
environment_status_callback=environment_status_callback,
|
|
)
|
|
),
|
|
)
|
|
except SyncCancelledError:
|
|
raise
|
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
raise ModalPromptValidationError(
|
|
f"Unable to acquire Vast.ai capacity for configuration "
|
|
f"{configuration.display_name!r} slot {slot_index}: {exc}"
|
|
) from exc
|
|
leases_by_environment[lease.environment_id] = lease
|
|
cost_share = quote.predicted_incremental_cost_usd / len(component_ids)
|
|
for component_id in component_ids:
|
|
assignment = assignments[component_id]
|
|
assignments[component_id] = replace(
|
|
assignment,
|
|
environment_id=lease.environment_id,
|
|
predicted_cost_usd=cost_share,
|
|
reasons=assignment.reasons
|
|
+ (
|
|
f"Vast profile {configuration.display_name}",
|
|
f"Vast capacity slot {slot_index}",
|
|
),
|
|
)
|
|
_emit_vast_capacity_status(
|
|
environment_id=lease.environment_id,
|
|
message=f"Vast.ai capacity {slot_number} of {total_slots} is ready",
|
|
environment_message="Vast.ai worker ready; preparing remote assets next",
|
|
current=slot_number,
|
|
total=total_slots,
|
|
status_callback=status_callback,
|
|
environment_status_callback=environment_status_callback,
|
|
)
|
|
return leases_by_environment
|
|
|
|
|
|
def _emit_vast_capacity_status(
|
|
*,
|
|
environment_id: str,
|
|
message: str,
|
|
environment_message: str | None = None,
|
|
current: int,
|
|
total: int,
|
|
status_callback: SetupStatusCallback | None,
|
|
environment_status_callback: EnvironmentSetupStatusCallback | None,
|
|
) -> None:
|
|
"""Publish one Vast setup update at prompt and environment scopes."""
|
|
if status_callback is not None:
|
|
status_callback(message, current, total)
|
|
if environment_status_callback is not None:
|
|
environment_status_callback(
|
|
environment_id,
|
|
environment_message or message,
|
|
None,
|
|
None,
|
|
)
|
|
|
|
|
|
def _quote_selected_vast_slot(
|
|
*,
|
|
configuration: VastRemoteConfiguration,
|
|
component_ids: list[str],
|
|
requirements_by_component: Mapping[str, ComponentResourceRequirements],
|
|
vast_quotes: Mapping[tuple[str, str], VastProfileQuote],
|
|
vast_service: VastService,
|
|
) -> VastProfileQuote:
|
|
"""Quote aggregate slot requirements immediately before acquisition."""
|
|
minimum_vram_bytes = max(
|
|
requirements_by_component[component_id].minimum_vram_bytes
|
|
for component_id in component_ids
|
|
)
|
|
minimum_ram_bytes = max(
|
|
requirements_by_component[component_id].minimum_ram_bytes
|
|
for component_id in component_ids
|
|
)
|
|
predicted_execution_seconds = sum(
|
|
requirements_by_component[component_id].estimated_execution_seconds
|
|
for component_id in component_ids
|
|
)
|
|
if len(component_ids) == 1:
|
|
existing_quote = vast_quotes.get(
|
|
(component_ids[0], configuration.configuration_id)
|
|
)
|
|
if existing_quote is not None:
|
|
return existing_quote
|
|
return vast_service.quote_best_profile_sync(
|
|
[configuration.profile],
|
|
minimum_vram_bytes=minimum_vram_bytes,
|
|
minimum_ram_bytes=minimum_ram_bytes,
|
|
predicted_execution_seconds=predicted_execution_seconds,
|
|
)
|
|
|
|
|
|
def _ssh_sync_engine(
|
|
*,
|
|
host: SshHostConfig,
|
|
settings: ModalSyncSettings,
|
|
r2_cache: R2CacheClient | None = None,
|
|
) -> ModalAssetSyncEngine:
|
|
"""Build a content-addressed sync engine for one SSH Docker host."""
|
|
ssh_settings = replace(
|
|
settings,
|
|
execution_mode="ssh_docker",
|
|
volume_name=host.resolved_storage_volume_name,
|
|
local_storage_root=(
|
|
settings.local_storage_root / "ssh" / host.environment_id
|
|
).resolve(),
|
|
remote_storage_root="/storage",
|
|
)
|
|
resolved_r2_cache = r2_cache or R2CacheClient.from_environment()
|
|
controller = SshDockerController(host)
|
|
runtime_manager = SshRuntimeManager(
|
|
controller=controller,
|
|
repo_root=Path(__file__).resolve().parent,
|
|
settings=settings,
|
|
)
|
|
materializer_spec = runtime_manager.runtime_spec()
|
|
|
|
def prepare_materializer_image() -> None:
|
|
"""Make the SSH runtime image available before an R2 helper starts."""
|
|
runtime_manager.ensure_image(materializer_spec)
|
|
|
|
volume = SshDockerVolumeBackend(
|
|
controller,
|
|
host.resolved_storage_volume_name,
|
|
materializer_image=materializer_spec.image_tag,
|
|
materializer_image_preparer=(
|
|
prepare_materializer_image if resolved_r2_cache is not None else None
|
|
),
|
|
)
|
|
return ModalAssetSyncEngine(
|
|
volume=volume,
|
|
settings=ssh_settings,
|
|
r2_cache=resolved_r2_cache,
|
|
)
|
|
|
|
|
|
def _workflow_r2_cache(
|
|
configuration_set: RemoteConfigurationSet | None,
|
|
) -> R2CacheClient | None:
|
|
"""Resolve the connected R2 backing through its opaque OS-keyring reference."""
|
|
if configuration_set is None:
|
|
return None
|
|
r2_configurations = [
|
|
configuration
|
|
for configuration in configuration_set.storage_configurations
|
|
if isinstance(configuration, R2StorageBackingConfiguration)
|
|
]
|
|
if not r2_configurations:
|
|
return None
|
|
storage = r2_configurations[0]
|
|
try:
|
|
configuration = R2CredentialStore().cache_configuration(storage)
|
|
except (RuntimeError, ValueError) as exc:
|
|
raise ModalPromptValidationError(str(exc)) from exc
|
|
return R2CacheClient(configuration)
|
|
|
|
|
|
def _stamp_execution_assignment(
|
|
payload: dict[str, Any],
|
|
assignment: ExecutionAssignment,
|
|
worker_index: int = 0,
|
|
execution_history_signature: str | None = None,
|
|
execution_location: str | None = None,
|
|
provider_metadata: Mapping[str, Any] | None = None,
|
|
) -> None:
|
|
"""Attach provider placement to a payload and every nested proxy phase."""
|
|
payload["execution_provider"] = assignment.provider.value
|
|
payload["execution_environment_id"] = assignment.environment_id
|
|
payload["execution_worker_index"] = worker_index
|
|
if execution_location:
|
|
payload["execution_location"] = execution_location
|
|
if execution_history_signature:
|
|
payload["execution_history_signature"] = execution_history_signature
|
|
if provider_metadata:
|
|
payload.update(provider_metadata)
|
|
split_payloads = payload.get("split_proxy_payloads")
|
|
nested_payloads: list[dict[str, Any]] = []
|
|
if isinstance(split_payloads, dict):
|
|
nested_payloads.extend(
|
|
nested_payload
|
|
for nested_payload in split_payloads.values()
|
|
if isinstance(nested_payload, dict)
|
|
)
|
|
elif isinstance(split_payloads, list):
|
|
nested_payloads.extend(
|
|
nested_payload
|
|
for nested_payload in split_payloads
|
|
if isinstance(nested_payload, dict)
|
|
)
|
|
for nested_payload in nested_payloads:
|
|
_stamp_execution_assignment(
|
|
nested_payload,
|
|
assignment,
|
|
worker_index,
|
|
execution_history_signature,
|
|
execution_location,
|
|
provider_metadata,
|
|
)
|
|
|
|
|
|
def _ssh_hostname(ssh_target: str) -> str:
|
|
"""Return the host portion of one validated OpenSSH destination."""
|
|
host = ssh_target.rsplit("@", 1)[-1].strip()
|
|
if host.startswith("[") and "]" in host:
|
|
return host[1 : host.index("]")]
|
|
return host
|
|
|
|
|
|
def _execution_location_for_assignment(
|
|
assignment: ExecutionAssignment,
|
|
ssh_hosts_by_id: Mapping[str, SshHostConfig],
|
|
vast_leases_by_environment: Mapping[str, Any] | None = None,
|
|
) -> str | None:
|
|
"""Return the runtime location label known before remote dispatch."""
|
|
if assignment.provider is ExecutionProvider.MODAL:
|
|
return None
|
|
if assignment.provider is ExecutionProvider.VAST:
|
|
lease = (vast_leases_by_environment or {}).get(assignment.environment_id)
|
|
if lease is not None:
|
|
return str(lease.ssh_host or lease.gpu_name)
|
|
return assignment.environment_id
|
|
host = ssh_hosts_by_id.get(assignment.environment_id)
|
|
return _ssh_hostname(host.ssh_target) if host is not None else assignment.environment_id
|
|
|
|
|
|
def _vast_provider_metadata(lease: Any) -> dict[str, Any]:
|
|
"""Return credential-free lease details for execution and local status output."""
|
|
return {
|
|
"vast_instance_id": lease.instance_id,
|
|
"vast_profile_id": lease.profile_id,
|
|
"vast_profile_name": lease.profile_name,
|
|
"vast_gpu_name": lease.gpu_name,
|
|
"vast_gpu_count": lease.gpu_count,
|
|
"vast_gpu_ram_mb": lease.gpu_ram_mb,
|
|
"vast_hourly_cost_usd": lease.hourly_cost_usd,
|
|
"vast_idle_retention_seconds": lease.idle_retention_seconds,
|
|
}
|
|
|
|
|
|
def _configured_provider_metadata(
|
|
*,
|
|
execution_plan: ComponentExecutionPlan,
|
|
assignment: ExecutionAssignment,
|
|
vast_leases_by_environment: Mapping[str, Any],
|
|
) -> dict[str, Any] | None:
|
|
"""Return safe provider metadata needed to execute one prepared assignment."""
|
|
if assignment.provider is ExecutionProvider.VAST:
|
|
return _vast_provider_metadata(
|
|
vast_leases_by_environment[assignment.environment_id]
|
|
)
|
|
configuration_id = assignment.configuration_id
|
|
if configuration_id is None:
|
|
return None
|
|
configuration = execution_plan.configurations_by_id.get(configuration_id)
|
|
if isinstance(configuration, ModalRemoteConfiguration):
|
|
return {
|
|
"remote_configuration_id": configuration.configuration_id,
|
|
"remote_configuration_name": configuration.display_name,
|
|
"modal_gpu": configuration.gpu_type,
|
|
"modal_max_containers": configuration.instance_count,
|
|
}
|
|
if isinstance(configuration, SshRemoteConfiguration):
|
|
host = execution_plan.ssh_hosts_by_id[configuration.configuration_id]
|
|
portable_host = replace(
|
|
host,
|
|
health=EnvironmentHealth.UNKNOWN,
|
|
last_error=None,
|
|
)
|
|
return {
|
|
"remote_configuration_id": configuration.configuration_id,
|
|
"remote_configuration_name": configuration.display_name,
|
|
"ssh_host_config": portable_host.to_dict(),
|
|
}
|
|
return None
|
|
|
|
|
|
def _ensure_remote_sync_backend(
|
|
settings: ModalSyncSettings,
|
|
sync_engine: ModalAssetSyncEngine,
|
|
) -> None:
|
|
"""Fail before queueing when remote execution cannot write to Modal-visible storage."""
|
|
if settings.execution_mode == "local":
|
|
return
|
|
if isinstance(sync_engine.volume, ModalVolumeBackend):
|
|
return
|
|
raise ModalPromptValidationError(
|
|
"Remote Modal execution requires asset sync to use the Modal volume backend, "
|
|
f"but the active sync backend is {type(sync_engine.volume).__name__}. "
|
|
"Restart ComfyUI with COMFY_MODAL_EXECUTION_MODE=remote and the Modal SDK available "
|
|
"so synced assets and custom_nodes bundles are visible inside Modal workers."
|
|
)
|
|
|
|
|
|
def _get_nodes_module() -> Any:
|
|
"""Import the ComfyUI nodes module lazily."""
|
|
import nodes
|
|
|
|
return nodes
|
|
|
|
|
|
def _get_server_module() -> Any:
|
|
"""Import the ComfyUI server module lazily."""
|
|
import server
|
|
|
|
return server
|
|
|
|
|
|
def _get_execution_module() -> Any:
|
|
"""Import the ComfyUI execution module lazily."""
|
|
import execution
|
|
|
|
return execution
|
|
|
|
|
|
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 _prompt_node_class_type(prompt: Mapping[str, Any], node_id: str) -> str:
|
|
"""Return the class type for a prompt node, or a diagnostic placeholder."""
|
|
prompt_node = prompt.get(str(node_id))
|
|
if not isinstance(prompt_node, Mapping):
|
|
return "<missing>"
|
|
return str(prompt_node.get("class_type", "<missing>"))
|
|
|
|
|
|
def _prompt_graph_links(prompt: Mapping[str, Any]) -> list[PromptGraphLink]:
|
|
"""Return direct prompt dependency edges from linked inputs."""
|
|
links: list[PromptGraphLink] = []
|
|
for target_node_id in sorted(str(node_id) for node_id in prompt):
|
|
prompt_node = prompt.get(target_node_id)
|
|
if not isinstance(prompt_node, Mapping):
|
|
continue
|
|
inputs = prompt_node.get("inputs") or {}
|
|
if not isinstance(inputs, Mapping):
|
|
continue
|
|
for input_name, input_value in sorted(
|
|
inputs.items(), key=lambda item: str(item[0])
|
|
):
|
|
if not _is_link(input_value):
|
|
continue
|
|
source_node_id = str(input_value[0])
|
|
links.append(
|
|
PromptGraphLink(
|
|
source_node_id=source_node_id,
|
|
source_output_index=int(input_value[1]),
|
|
target_node_id=target_node_id,
|
|
target_input_name=str(input_name),
|
|
source_class_type=_prompt_node_class_type(prompt, source_node_id),
|
|
target_class_type=_prompt_node_class_type(prompt, target_node_id),
|
|
)
|
|
)
|
|
return links
|
|
|
|
|
|
def _prompt_link_dict(link: PromptGraphLink) -> dict[str, Any]:
|
|
"""Return a JSON-safe representation of one prompt dependency edge."""
|
|
return {
|
|
"source_node_id": link.source_node_id,
|
|
"source_output_index": link.source_output_index,
|
|
"source_class_type": link.source_class_type,
|
|
"target_node_id": link.target_node_id,
|
|
"target_input_name": link.target_input_name,
|
|
"target_class_type": link.target_class_type,
|
|
}
|
|
|
|
|
|
def _find_prompt_dependency_cycles(prompt: Mapping[str, Any]) -> list[list[str]]:
|
|
"""Return representative dependency cycles in the prompt graph."""
|
|
links = _prompt_graph_links(prompt)
|
|
adjacency: dict[str, list[str]] = defaultdict(list)
|
|
for link in links:
|
|
if link.source_node_id not in prompt or link.target_node_id not in prompt:
|
|
continue
|
|
adjacency[link.source_node_id].append(link.target_node_id)
|
|
|
|
visited_node_ids: set[str] = set()
|
|
active_node_ids: set[str] = set()
|
|
path: list[str] = []
|
|
cycles: list[list[str]] = []
|
|
seen_cycle_keys: set[tuple[str, ...]] = set()
|
|
|
|
def normalize_cycle(cycle: list[str]) -> tuple[str, ...]:
|
|
"""Return a rotation-stable key for a detected cycle."""
|
|
if not cycle:
|
|
return ()
|
|
rotations = [
|
|
tuple(cycle[index:] + cycle[:index]) for index in range(len(cycle))
|
|
]
|
|
return min(rotations)
|
|
|
|
def visit(node_id: str) -> None:
|
|
"""Depth-first search one prompt node for dependency back-edges."""
|
|
if node_id in active_node_ids:
|
|
cycle_start_index = path.index(node_id)
|
|
cycle = path[cycle_start_index:] + [node_id]
|
|
cycle_key = normalize_cycle(cycle[:-1])
|
|
if cycle_key not in seen_cycle_keys:
|
|
seen_cycle_keys.add(cycle_key)
|
|
cycles.append(cycle)
|
|
return
|
|
if node_id in visited_node_ids:
|
|
return
|
|
visited_node_ids.add(node_id)
|
|
active_node_ids.add(node_id)
|
|
path.append(node_id)
|
|
for downstream_node_id in sorted(adjacency.get(node_id, [])):
|
|
visit(downstream_node_id)
|
|
path.pop()
|
|
active_node_ids.remove(node_id)
|
|
|
|
for node_id in sorted(str(node_id) for node_id in prompt):
|
|
visit(node_id)
|
|
return cycles
|
|
|
|
|
|
def _modal_proxy_payload_summaries(prompt: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
"""Return compact summaries for Modal proxy payloads embedded in a rewritten prompt."""
|
|
summaries: list[dict[str, Any]] = []
|
|
for node_id in sorted(str(node_id) for node_id in prompt):
|
|
prompt_node = prompt.get(node_id)
|
|
if not isinstance(prompt_node, Mapping):
|
|
continue
|
|
inputs = prompt_node.get("inputs") or {}
|
|
if not isinstance(inputs, Mapping):
|
|
continue
|
|
payload = inputs.get("original_node_data")
|
|
if not isinstance(payload, Mapping):
|
|
continue
|
|
boundary_inputs = [
|
|
{
|
|
"proxy_input_name": str(boundary_input.get("proxy_input_name")),
|
|
"io_type": str(boundary_input.get("io_type")),
|
|
"targets": copy.deepcopy(boundary_input.get("targets", [])),
|
|
}
|
|
for boundary_input in payload.get("boundary_inputs", [])
|
|
if isinstance(boundary_input, Mapping)
|
|
]
|
|
boundary_outputs = [
|
|
{
|
|
"proxy_output_name": str(boundary_output.get("proxy_output_name")),
|
|
"node_id": str(boundary_output.get("node_id")),
|
|
"output_index": int(boundary_output.get("output_index", 0)),
|
|
"io_type": str(boundary_output.get("io_type")),
|
|
"is_list": bool(boundary_output.get("is_list", False)),
|
|
"session_output": bool(boundary_output.get("session_output", False)),
|
|
}
|
|
for boundary_output in payload.get("boundary_outputs", [])
|
|
if isinstance(boundary_output, Mapping)
|
|
]
|
|
summaries.append(
|
|
{
|
|
"proxy_node_id": node_id,
|
|
"proxy_class_type": _prompt_node_class_type(prompt, node_id),
|
|
"payload_kind": str(payload.get("payload_kind")),
|
|
"component_id": str(payload.get("component_id")),
|
|
"component_node_ids": [
|
|
str(value) for value in payload.get("component_node_ids", [])
|
|
],
|
|
"execute_node_ids": [
|
|
str(value) for value in payload.get("execute_node_ids", [])
|
|
],
|
|
"boundary_inputs": boundary_inputs,
|
|
"boundary_outputs": boundary_outputs,
|
|
}
|
|
)
|
|
return summaries
|
|
|
|
|
|
def _modal_rewritten_prompt_diagnostics(
|
|
prompt: Mapping[str, Any],
|
|
summary: RewriteSummary | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Return compact diagnostics for a rewritten Modal prompt graph."""
|
|
node_class_types = {
|
|
str(node_id): _prompt_node_class_type(prompt, str(node_id))
|
|
for node_id in sorted(str(node_id) for node_id in prompt)
|
|
}
|
|
diagnostics: dict[str, Any] = {
|
|
"node_count": len(prompt),
|
|
"node_class_types": node_class_types,
|
|
"links": [_prompt_link_dict(link) for link in _prompt_graph_links(prompt)],
|
|
"cycles": _find_prompt_dependency_cycles(prompt),
|
|
"modal_proxy_payloads": _modal_proxy_payload_summaries(prompt),
|
|
}
|
|
if summary is not None:
|
|
diagnostics["remote_node_ids"] = list(summary.remote_node_ids)
|
|
diagnostics["remote_component_ids"] = list(summary.remote_component_ids)
|
|
diagnostics["component_node_ids_by_representative"] = copy.deepcopy(
|
|
summary.component_node_ids_by_representative
|
|
)
|
|
diagnostics["component_dependency_ids_by_representative"] = copy.deepcopy(
|
|
summary.component_dependency_ids_by_representative
|
|
)
|
|
diagnostics["component_execution_stages"] = copy.deepcopy(
|
|
summary.component_execution_stages
|
|
)
|
|
diagnostics["parallel_local_branch_node_ids"] = list(
|
|
summary.parallel_local_branch_node_ids
|
|
)
|
|
diagnostics["rewritten_node_id_map"] = copy.deepcopy(
|
|
summary.rewritten_node_id_map
|
|
)
|
|
return diagnostics
|
|
|
|
|
|
def _log_modal_rewritten_prompt_diagnostics(
|
|
*,
|
|
prompt_id: str | None,
|
|
prompt: Mapping[str, Any],
|
|
summary: RewriteSummary | None = None,
|
|
reason: str,
|
|
level: int = logging.INFO,
|
|
) -> None:
|
|
"""Log compact diagnostics for a rewritten Modal prompt graph."""
|
|
diagnostics = _modal_rewritten_prompt_diagnostics(prompt, summary)
|
|
cycles = diagnostics.get("cycles") or []
|
|
if cycles:
|
|
logger.warning(
|
|
"Modal rewritten prompt contains dependency cycle(s) prompt_id=%s reason=%s cycles=%s",
|
|
prompt_id,
|
|
reason,
|
|
cycles,
|
|
)
|
|
logger.log(
|
|
level,
|
|
"Modal rewritten prompt diagnostics prompt_id=%s reason=%s diagnostics=%s",
|
|
prompt_id,
|
|
reason,
|
|
json.dumps(diagnostics, sort_keys=True),
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _environment_setup_status_callback(
|
|
environment_id: str,
|
|
status_callback: SetupStatusCallback | None,
|
|
environment_status_callback: EnvironmentSetupStatusCallback | None,
|
|
) -> SetupStatusCallback | None:
|
|
"""Return a setup callback that updates prompt-wide and environment UI."""
|
|
if status_callback is None and environment_status_callback is None:
|
|
return None
|
|
|
|
def emit_status(
|
|
message: str,
|
|
current: int | None,
|
|
total: int | None,
|
|
) -> None:
|
|
"""Forward one setup update with its concrete execution environment."""
|
|
if status_callback is not None:
|
|
status_callback(message, current, total)
|
|
if environment_status_callback is not None:
|
|
environment_status_callback(environment_id, message, current, total)
|
|
|
|
return emit_status
|
|
|
|
|
|
def _prepare_environment_assets(
|
|
*,
|
|
environment_id: str,
|
|
components: Sequence[RemoteComponentPlan],
|
|
sync_engine: ModalAssetSyncEngine,
|
|
engine_lock: threading.Lock,
|
|
rewritten_prompt: dict[str, Any],
|
|
sync_custom_nodes: bool,
|
|
completion_message: str,
|
|
status_callback: SetupStatusCallback | None,
|
|
environment_status_callback: EnvironmentSetupStatusCallback | None,
|
|
) -> _EnvironmentAssetPreparationResult:
|
|
"""Prepare one environment's custom nodes and prompt assets in order."""
|
|
started_at = time.perf_counter()
|
|
logger.info(
|
|
"Starting remote asset preparation environment=%s components=%d.",
|
|
environment_id,
|
|
len(components),
|
|
)
|
|
environment_callback = _environment_setup_status_callback(
|
|
environment_id,
|
|
status_callback,
|
|
environment_status_callback,
|
|
)
|
|
if environment_callback is not None:
|
|
environment_callback("Preparing remote assets", None, None)
|
|
with engine_lock:
|
|
sync_engine.preflight_r2_access(status_callback=environment_callback)
|
|
custom_nodes_bundle = (
|
|
sync_engine.sync_custom_nodes_directory(
|
|
status_callback=environment_callback,
|
|
)
|
|
if sync_custom_nodes
|
|
else None
|
|
)
|
|
component_prompts, assets_by_component_id = (
|
|
_sync_environment_prompt_assets(
|
|
components=components,
|
|
sync_engine=sync_engine,
|
|
rewritten_prompt=rewritten_prompt,
|
|
status_callback=environment_callback,
|
|
)
|
|
)
|
|
if environment_callback is not None:
|
|
environment_callback(completion_message, None, None)
|
|
logger.info(
|
|
"Finished remote asset preparation environment=%s components=%d assets=%d elapsed_seconds=%.3f.",
|
|
environment_id,
|
|
len(components),
|
|
sum(len(assets) for assets in assets_by_component_id.values()),
|
|
time.perf_counter() - started_at,
|
|
)
|
|
return _EnvironmentAssetPreparationResult(
|
|
environment_id=environment_id,
|
|
custom_nodes_bundle=custom_nodes_bundle,
|
|
component_prompts=component_prompts,
|
|
assets_by_component_id=assets_by_component_id,
|
|
)
|
|
|
|
|
|
def _sync_environment_prompt_assets(
|
|
*,
|
|
components: Sequence[RemoteComponentPlan],
|
|
sync_engine: ModalAssetSyncEngine,
|
|
rewritten_prompt: dict[str, Any],
|
|
status_callback: SetupStatusCallback | None,
|
|
) -> tuple[dict[str, dict[str, Any]], dict[str, list[SyncedAsset]]]:
|
|
"""Sync every component assigned to one environment with one request cache."""
|
|
request_cache = sync_engine.create_request_asset_cache(
|
|
rewritten_prompt[node_id].get("inputs", {})
|
|
for component in components
|
|
for node_id in component.node_ids
|
|
)
|
|
component_prompts: dict[str, dict[str, Any]] = {}
|
|
assets_by_component_id: dict[str, list[SyncedAsset]] = {}
|
|
for component in components:
|
|
component_prompt, synced_assets = _sync_component_prompt_inputs(
|
|
component=component,
|
|
rewritten_prompt=rewritten_prompt,
|
|
sync_engine=sync_engine,
|
|
request_cache=request_cache,
|
|
status_callback=status_callback,
|
|
)
|
|
component_id = component.representative_node_id
|
|
component_prompts[component_id] = component_prompt
|
|
assets_by_component_id[component_id] = list(synced_assets)
|
|
return component_prompts, assets_by_component_id
|
|
|
|
|
|
def _prepare_remote_environment_assets(
|
|
*,
|
|
components: Sequence[RemoteComponentPlan],
|
|
assignments_by_component_id: Mapping[str, ExecutionAssignment],
|
|
sync_engines_by_environment: Mapping[str, ModalAssetSyncEngine],
|
|
rewritten_prompt: dict[str, Any],
|
|
sync_custom_nodes: bool,
|
|
status_callback: SetupStatusCallback | None,
|
|
environment_status_callback: EnvironmentSetupStatusCallback | None,
|
|
) -> dict[str, _EnvironmentAssetPreparationResult]:
|
|
"""Prepare distinct remote environments concurrently in a bounded pool."""
|
|
components_by_environment: dict[str, list[RemoteComponentPlan]] = {
|
|
environment_id: [] for environment_id in sync_engines_by_environment
|
|
}
|
|
providers_by_environment: dict[str, ExecutionProvider] = {}
|
|
for component in components:
|
|
assignment = assignments_by_component_id[component.representative_node_id]
|
|
components_by_environment[assignment.environment_id].append(component)
|
|
providers_by_environment[assignment.environment_id] = assignment.provider
|
|
locks_by_engine: dict[int, threading.Lock] = {}
|
|
environment_ids = list(components_by_environment)
|
|
if not environment_ids:
|
|
return {}
|
|
max_workers = min(_REMOTE_ASSET_PREPARATION_MAX_WORKERS, len(environment_ids))
|
|
logger.info(
|
|
"Preparing remote assets across %d environments with %d workers.",
|
|
len(environment_ids),
|
|
max_workers,
|
|
)
|
|
with ThreadPoolExecutor(
|
|
max_workers=max_workers,
|
|
thread_name_prefix="comfy-remote-assets",
|
|
) as executor:
|
|
futures_by_environment = {
|
|
environment_id: executor.submit(
|
|
_prepare_environment_assets,
|
|
environment_id=environment_id,
|
|
components=components_by_environment[environment_id],
|
|
sync_engine=sync_engines_by_environment[environment_id],
|
|
engine_lock=locks_by_engine.setdefault(
|
|
id(sync_engines_by_environment[environment_id]),
|
|
threading.Lock(),
|
|
),
|
|
rewritten_prompt=rewritten_prompt,
|
|
sync_custom_nodes=sync_custom_nodes,
|
|
completion_message=_asset_preparation_completion_message(
|
|
providers_by_environment[environment_id]
|
|
),
|
|
status_callback=status_callback,
|
|
environment_status_callback=environment_status_callback,
|
|
)
|
|
for environment_id in environment_ids
|
|
}
|
|
return {
|
|
environment_id: futures_by_environment[environment_id].result()
|
|
for environment_id in environment_ids
|
|
}
|
|
|
|
|
|
def _asset_preparation_completion_message(provider: ExecutionProvider) -> str:
|
|
"""Return the truthful state after assets, but not runtime startup, finish."""
|
|
if provider is ExecutionProvider.SSH_DOCKER:
|
|
return "Remote assets prepared; SSH runtime starts on dispatch"
|
|
if provider is ExecutionProvider.MODAL:
|
|
return "Remote assets prepared; Modal runtime starts on dispatch"
|
|
return "Ready for remote execution"
|
|
|
|
|
|
def rewrite_prompt_for_modal(
|
|
prompt: dict[str, Any],
|
|
workflow: dict[str, Any] | None,
|
|
sync_engine: ModalAssetSyncEngine | None = None,
|
|
settings: ModalSyncSettings | None = None,
|
|
nodes_module: Any | None = None,
|
|
extra_data: dict[str, Any] | None = None,
|
|
status_callback: SetupStatusCallback | None = None,
|
|
environment_status_callback: EnvironmentSetupStatusCallback | None = None,
|
|
plan_callback: ExecutionPlanStatusCallback | None = None,
|
|
cancellation_check: Callable[[], bool] | None = None,
|
|
occupied_environment_ids: frozenset[str] = frozenset(),
|
|
) -> tuple[dict[str, Any], RewriteSummary]:
|
|
"""Rewrite connected remote components into Modal proxy nodes."""
|
|
if cancellation_check is not None and cancellation_check():
|
|
raise SyncCancelledError("Remote workflow preparation was cancelled.")
|
|
resolved_settings = settings or get_settings()
|
|
remote_node_ids = requested_remote_node_ids(
|
|
prompt=prompt,
|
|
workflow=workflow,
|
|
settings=resolved_settings,
|
|
)
|
|
summary = RewriteSummary(remote_node_ids=sorted(remote_node_ids))
|
|
logger.info(
|
|
"Found %d workflow nodes marked for remote execution.", len(remote_node_ids)
|
|
)
|
|
|
|
if not remote_node_ids:
|
|
return copy.deepcopy(prompt), summary
|
|
|
|
resolved_nodes_module = nodes_module or _get_nodes_module()
|
|
resolved_sync_engine = sync_engine or ModalAssetSyncEngine.from_environment(
|
|
resolved_settings
|
|
)
|
|
rewritten_prompt = copy.deepcopy(prompt)
|
|
expanded_remote_node_ids, _ = _expand_remote_node_ids_for_non_transportable_inputs(
|
|
prompt=rewritten_prompt,
|
|
remote_node_ids=remote_node_ids,
|
|
nodes_module=resolved_nodes_module,
|
|
)
|
|
expanded_remote_node_ids = _expand_remote_node_ids_for_terminal_video_sinks(
|
|
prompt=rewritten_prompt,
|
|
remote_node_ids=expanded_remote_node_ids,
|
|
nodes_module=resolved_nodes_module,
|
|
)
|
|
summary.remote_node_ids = sorted(expanded_remote_node_ids)
|
|
summary.sandwiched_local_node_ids = sorted(
|
|
_sandwiched_local_node_ids(rewritten_prompt, expanded_remote_node_ids)
|
|
)
|
|
if summary.sandwiched_local_node_ids:
|
|
logger.warning(
|
|
"Detected local nodes sandwiched between remote graph regions; "
|
|
"these nodes may force additional remote phases: %s",
|
|
summary.sandwiched_local_node_ids,
|
|
)
|
|
components = _build_component_plans(
|
|
rewritten_prompt,
|
|
expanded_remote_node_ids,
|
|
resolved_nodes_module,
|
|
)
|
|
validate_remote_component_transport_compatibility(
|
|
prompt=rewritten_prompt,
|
|
components=components,
|
|
nodes_module=resolved_nodes_module,
|
|
)
|
|
|
|
execution_plan = _plan_component_execution(
|
|
components=components,
|
|
prompt=rewritten_prompt,
|
|
workflow=workflow,
|
|
settings=resolved_settings,
|
|
status_callback=status_callback,
|
|
environment_status_callback=environment_status_callback,
|
|
plan_callback=plan_callback,
|
|
occupied_environment_ids=occupied_environment_ids,
|
|
)
|
|
assignments_by_component_id = execution_plan.assignments
|
|
if execution_plan.configuration_set is not None:
|
|
summary.remote_configurations = list(execution_plan.safe_configurations)
|
|
for assignment in assignments_by_component_id.values():
|
|
execution_location = _execution_location_for_assignment(
|
|
assignment,
|
|
execution_plan.ssh_hosts_by_id,
|
|
execution_plan.vast_leases_by_environment,
|
|
)
|
|
if execution_location:
|
|
summary.execution_locations_by_environment[
|
|
assignment.environment_id
|
|
] = execution_location
|
|
session_component_ids = _mark_remote_to_remote_session_boundaries(
|
|
rewritten_prompt,
|
|
components,
|
|
resolved_nodes_module,
|
|
assignments_by_component_id,
|
|
)
|
|
prompt_id = (extra_data or {}).get("prompt_id")
|
|
remote_sessions_by_component_id = {
|
|
component.representative_node_id: RemoteSessionHandle(
|
|
session_id=uuid.uuid4().hex,
|
|
prompt_id=(str(prompt_id) if prompt_id is not None else None),
|
|
owner_component_id=component.representative_node_id,
|
|
).to_payload()
|
|
for component in components
|
|
if component.representative_node_id in session_component_ids
|
|
}
|
|
if remote_sessions_by_component_id:
|
|
logger.info(
|
|
"Enabled environment-local remote references for components=%s.",
|
|
sorted(remote_sessions_by_component_id),
|
|
)
|
|
summary.execution_assignments_by_representative = dict(assignments_by_component_id)
|
|
|
|
vast_service = execution_plan.vast_service
|
|
vast_leases_by_environment = dict(
|
|
execution_plan.vast_leases_by_environment
|
|
)
|
|
if vast_service is None and any(
|
|
assignment.provider is ExecutionProvider.VAST
|
|
for assignment in assignments_by_component_id.values()
|
|
):
|
|
try:
|
|
vast_service = VastService.from_environment(
|
|
resolved_settings,
|
|
repo_root=Path(__file__).resolve().parent,
|
|
)
|
|
vast_leases_by_environment = {
|
|
assignment.environment_id: vast_service.lease_for_environment_id(
|
|
assignment.environment_id
|
|
)
|
|
for assignment in assignments_by_component_id.values()
|
|
if assignment.provider is ExecutionProvider.VAST
|
|
}
|
|
except (KeyError, OSError, RuntimeError, ValueError) as exc:
|
|
raise ModalPromptValidationError(
|
|
f"Unable to resolve the acquired Vast.ai lease: {exc}"
|
|
) from exc
|
|
|
|
workflow_r2_cache = (
|
|
_workflow_r2_cache(execution_plan.configuration_set)
|
|
if any(
|
|
assignment.provider is not ExecutionProvider.MODAL
|
|
for assignment in assignments_by_component_id.values()
|
|
)
|
|
else None
|
|
)
|
|
if workflow_r2_cache is not None and vast_service is not None:
|
|
vast_service.r2_cache = workflow_r2_cache
|
|
|
|
sync_engines_by_environment: dict[str, ModalAssetSyncEngine] = {}
|
|
ssh_hosts_by_id = dict(execution_plan.ssh_hosts_by_id)
|
|
if not ssh_hosts_by_id:
|
|
ssh_hosts_by_id = {
|
|
host.environment_id: host
|
|
for host in _configured_ssh_hosts(resolved_settings)
|
|
}
|
|
session_environment_ids = {
|
|
assignments_by_component_id[component_id].environment_id
|
|
for component_id in session_component_ids
|
|
}
|
|
worker_counts_by_environment: dict[str, int] = defaultdict(int)
|
|
for component in components:
|
|
component_id = component.representative_node_id
|
|
assignment = assignments_by_component_id[component_id]
|
|
if assignment.provider is ExecutionProvider.MODAL:
|
|
summary.execution_worker_indices_by_representative[component_id] = 0
|
|
continue
|
|
if assignment.provider is ExecutionProvider.VAST:
|
|
summary.execution_worker_indices_by_representative[component_id] = 0
|
|
continue
|
|
if execution_plan.configuration_set is not None:
|
|
summary.execution_worker_indices_by_representative[component_id] = (
|
|
assignment.capacity_slot_index
|
|
)
|
|
logger.info(
|
|
"Assigned workflow-configured remote component=%s environment=%s "
|
|
"worker_index=%d.",
|
|
component_id,
|
|
assignment.environment_id,
|
|
assignment.capacity_slot_index,
|
|
)
|
|
continue
|
|
host = ssh_hosts_by_id.get(assignment.environment_id)
|
|
maximum_workers = max(1, host.maximum_workers if host is not None else 1)
|
|
if component_id in session_component_ids:
|
|
worker_index = 0
|
|
else:
|
|
first_worker = (
|
|
1
|
|
if assignment.environment_id in session_environment_ids
|
|
and maximum_workers > 1
|
|
else 0
|
|
)
|
|
available_workers = max(1, maximum_workers - first_worker)
|
|
worker_index = first_worker + (
|
|
worker_counts_by_environment[assignment.environment_id]
|
|
% available_workers
|
|
)
|
|
worker_counts_by_environment[assignment.environment_id] += 1
|
|
summary.execution_worker_indices_by_representative[component_id] = worker_index
|
|
logger.info(
|
|
"Assigned remote component=%s environment=%s worker_index=%d.",
|
|
component_id,
|
|
assignment.environment_id,
|
|
worker_index,
|
|
)
|
|
for assignment in assignments_by_component_id.values():
|
|
if assignment.environment_id in sync_engines_by_environment:
|
|
continue
|
|
if assignment.provider is ExecutionProvider.MODAL:
|
|
_ensure_remote_sync_backend(resolved_settings, resolved_sync_engine)
|
|
sync_engines_by_environment[
|
|
assignment.environment_id
|
|
] = resolved_sync_engine
|
|
continue
|
|
if assignment.provider is ExecutionProvider.VAST:
|
|
if vast_service is None:
|
|
raise ModalPromptValidationError(
|
|
"Vast.ai assignment has no active controller service."
|
|
)
|
|
lease = vast_leases_by_environment[assignment.environment_id]
|
|
vast_sync_engine = vast_service.sync_engine(lease)
|
|
vast_sync_engine.cancellation_check = cancellation_check
|
|
sync_engines_by_environment[assignment.environment_id] = vast_sync_engine
|
|
continue
|
|
host = ssh_hosts_by_id.get(assignment.environment_id)
|
|
if host is None:
|
|
raise ModalPromptValidationError(
|
|
f"Assigned SSH execution environment {assignment.environment_id!r} is no longer configured."
|
|
)
|
|
sync_engines_by_environment[assignment.environment_id] = _ssh_sync_engine(
|
|
host=host,
|
|
settings=resolved_settings,
|
|
r2_cache=workflow_r2_cache,
|
|
)
|
|
|
|
if status_callback is not None:
|
|
status_callback("Preparing assets for remote execution", None, None)
|
|
|
|
try:
|
|
environment_preparations = _prepare_remote_environment_assets(
|
|
components=components,
|
|
assignments_by_component_id=assignments_by_component_id,
|
|
sync_engines_by_environment=sync_engines_by_environment,
|
|
rewritten_prompt=rewritten_prompt,
|
|
sync_custom_nodes=resolved_settings.sync_custom_nodes,
|
|
status_callback=status_callback,
|
|
environment_status_callback=environment_status_callback,
|
|
)
|
|
except R2CacheError as exc:
|
|
raise ModalPromptValidationError(str(exc)) from exc
|
|
if resolved_settings.sync_custom_nodes:
|
|
summary.custom_nodes_bundles_by_environment = {
|
|
environment_id: preparation.custom_nodes_bundle
|
|
for environment_id, preparation in environment_preparations.items()
|
|
}
|
|
modal_bundle = next(
|
|
(
|
|
summary.custom_nodes_bundles_by_environment[assignment.environment_id]
|
|
for assignment in assignments_by_component_id.values()
|
|
if assignment.provider is ExecutionProvider.MODAL
|
|
),
|
|
None,
|
|
)
|
|
summary.custom_nodes_bundle = modal_bundle or next(
|
|
iter(summary.custom_nodes_bundles_by_environment.values()),
|
|
None,
|
|
)
|
|
else:
|
|
logger.info(
|
|
"Skipping custom_nodes bundle sync because sync is disabled for execution_mode=%s.",
|
|
resolved_settings.execution_mode,
|
|
)
|
|
|
|
synced_component_prompts: dict[str, dict[str, Any]] = {}
|
|
synced_assets_by_component_id: dict[str, list[SyncedAsset]] = {}
|
|
for component in components:
|
|
assignment = assignments_by_component_id[component.representative_node_id]
|
|
preparation = environment_preparations[assignment.environment_id]
|
|
component_id = component.representative_node_id
|
|
synced_component_prompts[component_id] = preparation.component_prompts[
|
|
component_id
|
|
]
|
|
synced_assets_by_component_id[component_id] = (
|
|
preparation.assets_by_component_id[component_id]
|
|
)
|
|
summary.synced_assets.extend(
|
|
preparation.assets_by_component_id[component_id]
|
|
)
|
|
|
|
summary.synced_assets = _deduplicate_synced_assets(summary.synced_assets)
|
|
|
|
modal_environment_ids = {
|
|
assignment.environment_id
|
|
for assignment in assignments_by_component_id.values()
|
|
if assignment.provider is ExecutionProvider.MODAL
|
|
}
|
|
modal_component_ids = {
|
|
component.representative_node_id
|
|
for component in components
|
|
if assignments_by_component_id[component.representative_node_id].provider
|
|
is ExecutionProvider.MODAL
|
|
}
|
|
requires_volume_reload = any(
|
|
asset.uploaded
|
|
for component_id, assets in synced_assets_by_component_id.items()
|
|
if component_id in modal_component_ids
|
|
for asset in assets
|
|
) or any(
|
|
bundle is not None and bundle.uploaded
|
|
for environment_id, bundle in summary.custom_nodes_bundles_by_environment.items()
|
|
if environment_id in modal_environment_ids
|
|
)
|
|
volume_reload_marker = uuid.uuid4().hex if requires_volume_reload else None
|
|
logger.info(
|
|
"Resolved request-wide Modal volume reload requirement: requires_volume_reload=%s volume_reload_marker=%s synced_assets=%d custom_nodes_uploaded=%s",
|
|
requires_volume_reload,
|
|
volume_reload_marker,
|
|
len(summary.synced_assets),
|
|
bool(
|
|
summary.custom_nodes_bundle is not None
|
|
and summary.custom_nodes_bundle.uploaded
|
|
),
|
|
)
|
|
summary.requires_volume_reload = requires_volume_reload
|
|
summary.volume_reload_marker = volume_reload_marker
|
|
summary.uploaded_volume_paths = [
|
|
asset.remote_path for asset in summary.synced_assets if asset.uploaded
|
|
]
|
|
mapped_proxy_component_ids: set[str] = set()
|
|
sandwiched_local_node_id_set = set(summary.sandwiched_local_node_ids)
|
|
for component in components:
|
|
logger.info(
|
|
"Rewriting remote component %s covering nodes %s.",
|
|
component.representative_node_id,
|
|
component.node_ids,
|
|
)
|
|
assignment = assignments_by_component_id[component.representative_node_id]
|
|
component_custom_nodes_bundle = summary.custom_nodes_bundles_by_environment.get(
|
|
assignment.environment_id
|
|
)
|
|
uploaded_volume_paths = _component_uploaded_volume_paths(
|
|
component_prompt=synced_component_prompts[component.representative_node_id],
|
|
synced_assets=synced_assets_by_component_id[
|
|
component.representative_node_id
|
|
],
|
|
custom_nodes_bundle=component_custom_nodes_bundle,
|
|
)
|
|
payload = _build_component_payload(
|
|
component=component,
|
|
component_prompt=synced_component_prompts[component.representative_node_id],
|
|
signature_prompt=prompt,
|
|
extra_data=extra_data,
|
|
settings=resolved_settings,
|
|
requires_volume_reload=(
|
|
assignment.provider is ExecutionProvider.MODAL
|
|
and bool(uploaded_volume_paths)
|
|
),
|
|
volume_reload_marker=volume_reload_marker,
|
|
custom_nodes_bundle=component_custom_nodes_bundle,
|
|
uploaded_volume_paths=uploaded_volume_paths,
|
|
terminate_container_on_error=resolved_settings.terminate_container_on_error,
|
|
nodes_module=resolved_nodes_module,
|
|
remote_session=remote_sessions_by_component_id.get(
|
|
component.representative_node_id
|
|
),
|
|
)
|
|
_attach_resolved_llm_profiles(
|
|
payload,
|
|
execution_plan.resolved_llm_profiles,
|
|
resolved_settings,
|
|
)
|
|
_stamp_execution_assignment(
|
|
payload,
|
|
assignment,
|
|
summary.execution_worker_indices_by_representative.get(
|
|
component.representative_node_id,
|
|
0,
|
|
),
|
|
_component_execution_signature(component, prompt),
|
|
_execution_location_for_assignment(
|
|
assignment,
|
|
ssh_hosts_by_id,
|
|
vast_leases_by_environment,
|
|
),
|
|
_configured_provider_metadata(
|
|
execution_plan=execution_plan,
|
|
assignment=assignment,
|
|
vast_leases_by_environment=vast_leases_by_environment,
|
|
),
|
|
)
|
|
implicitly_mapped_output_sources = _implicitly_mapped_boundary_output_sources(
|
|
component=component,
|
|
original_prompt=prompt,
|
|
rewritten_prompt=rewritten_prompt,
|
|
nodes_module=resolved_nodes_module,
|
|
)
|
|
_mark_payload_scheduler_list_outputs(
|
|
payload,
|
|
implicitly_mapped_output_sources,
|
|
)
|
|
proxy_node_ids = _rewrite_component_into_proxy(
|
|
component=component,
|
|
rewritten_prompt=rewritten_prompt,
|
|
payload=payload,
|
|
nodes_module=resolved_nodes_module,
|
|
)
|
|
for proxy_node_id in proxy_node_ids:
|
|
summary.execution_assignments_by_representative[proxy_node_id] = assignment
|
|
summary.execution_worker_indices_by_representative[proxy_node_id] = (
|
|
summary.execution_worker_indices_by_representative.get(
|
|
component.representative_node_id,
|
|
0,
|
|
)
|
|
)
|
|
split_proxy_payloads = payload.get("split_proxy_payloads")
|
|
if isinstance(split_proxy_payloads, dict):
|
|
static_proxy_node_id, mapped_proxy_node_id = proxy_node_ids
|
|
summary.remote_component_ids.extend(proxy_node_ids)
|
|
summary.component_node_ids_by_representative[static_proxy_node_id] = (
|
|
_workflow_visible_remote_node_ids(component.static_node_ids)
|
|
)
|
|
summary.component_node_ids_by_representative[mapped_proxy_node_id] = (
|
|
_workflow_visible_remote_node_ids(component.mapped_node_ids)
|
|
)
|
|
for node_id in _workflow_visible_remote_node_ids(
|
|
component.static_node_ids
|
|
):
|
|
summary.rewritten_node_id_map[node_id] = static_proxy_node_id
|
|
for node_id in _workflow_visible_remote_node_ids(
|
|
component.mapped_node_ids
|
|
):
|
|
summary.rewritten_node_id_map[node_id] = mapped_proxy_node_id
|
|
mapped_proxy_component_ids.add(mapped_proxy_node_id)
|
|
continue
|
|
if isinstance(split_proxy_payloads, list):
|
|
summary.remote_component_ids.extend(proxy_node_ids)
|
|
mapped_node_id_set = set(component.mapped_node_ids)
|
|
for phase_payload in split_proxy_payloads:
|
|
phase_proxy_node_id = str(phase_payload["component_id"])
|
|
phase_component_node_ids = [
|
|
str(node_id) for node_id in phase_payload["component_node_ids"]
|
|
]
|
|
summary.component_node_ids_by_representative[
|
|
phase_proxy_node_id
|
|
] = _workflow_visible_remote_node_ids(phase_component_node_ids)
|
|
for node_id in _workflow_visible_remote_node_ids(
|
|
phase_component_node_ids
|
|
):
|
|
summary.rewritten_node_id_map[node_id] = phase_proxy_node_id
|
|
if mapped_node_id_set and mapped_node_id_set.intersection(
|
|
phase_component_node_ids
|
|
):
|
|
mapped_proxy_component_ids.add(phase_proxy_node_id)
|
|
continue
|
|
|
|
summary.remote_component_ids.extend(proxy_node_ids)
|
|
summary.component_node_ids_by_representative[proxy_node_ids[0]] = (
|
|
_workflow_visible_remote_node_ids(component.node_ids)
|
|
)
|
|
for node_id in _workflow_visible_remote_node_ids(component.node_ids):
|
|
summary.rewritten_node_id_map[node_id] = proxy_node_ids[0]
|
|
if component.mapped_boundary_input_name or implicitly_mapped_output_sources:
|
|
mapped_proxy_component_ids.add(proxy_node_ids[0])
|
|
|
|
_configure_local_gap_keepalive_payloads(
|
|
rewritten_prompt=rewritten_prompt,
|
|
remote_component_ids=summary.remote_component_ids,
|
|
sandwiched_local_node_ids=sandwiched_local_node_id_set,
|
|
)
|
|
summary.parallel_local_branch_node_ids = _parallelize_non_returning_local_branches(
|
|
rewritten_prompt=rewritten_prompt,
|
|
remote_component_ids=summary.remote_component_ids,
|
|
nodes_module=resolved_nodes_module,
|
|
)
|
|
summary.artifact_finalizer_node_id = _attach_modal_artifact_finalizer(
|
|
rewritten_prompt=rewritten_prompt,
|
|
remote_component_ids=summary.remote_component_ids,
|
|
nodes_module=resolved_nodes_module,
|
|
)
|
|
|
|
proxy_component_groups = {
|
|
component_id: {component_id} for component_id in summary.remote_component_ids
|
|
}
|
|
_, dependency_edges, _ = _component_dependency_graph(
|
|
rewritten_prompt, proxy_component_groups
|
|
)
|
|
execution_stages = _component_execution_stages(
|
|
rewritten_prompt, proxy_component_groups
|
|
)
|
|
_configure_speculative_affinity_prewarm_payloads(
|
|
rewritten_prompt=rewritten_prompt,
|
|
execution_stages=execution_stages,
|
|
)
|
|
summary.component_dependency_ids_by_representative = {
|
|
representative_node_id: sorted(
|
|
upstream_component_id
|
|
for upstream_component_id, downstream_component_ids in dependency_edges.items()
|
|
if representative_node_id in downstream_component_ids
|
|
)
|
|
for representative_node_id in sorted(proxy_component_groups)
|
|
}
|
|
summary.component_execution_stages = [list(stage) for stage in execution_stages]
|
|
summary.mapped_component_ids = sorted(mapped_proxy_component_ids)
|
|
summary.estimated_max_parallel_requests = _estimated_stage_parallelism(
|
|
execution_stages,
|
|
mapped_proxy_component_ids,
|
|
mapped_component_weight=1,
|
|
)
|
|
if execution_plan.configuration_set is not None:
|
|
configured_capacity = sum(
|
|
configuration.capacity_limit
|
|
for configuration in execution_plan.configuration_set.capacity_configurations
|
|
)
|
|
summary.max_parallel_requests_upper_bound = min(
|
|
summary.estimated_max_parallel_requests,
|
|
configured_capacity,
|
|
)
|
|
elif resolved_settings.max_containers is not None:
|
|
summary.max_parallel_requests_upper_bound = min(
|
|
summary.estimated_max_parallel_requests,
|
|
resolved_settings.max_containers,
|
|
)
|
|
else:
|
|
summary.max_parallel_requests_upper_bound = (
|
|
summary.estimated_max_parallel_requests
|
|
)
|
|
|
|
logger.info(
|
|
"Estimated remote parallelism after proxy rewrite: known_max_parallel_requests=%d max_parallel_requests_upper_bound=%s mapped_components=%s execution_stages=%s",
|
|
summary.estimated_max_parallel_requests,
|
|
summary.max_parallel_requests_upper_bound,
|
|
summary.mapped_component_ids,
|
|
summary.component_execution_stages,
|
|
)
|
|
_log_modal_rewritten_prompt_diagnostics(
|
|
prompt_id=(
|
|
str(extra_data.get("prompt_id"))
|
|
if isinstance(extra_data, Mapping) and extra_data.get("prompt_id")
|
|
else None
|
|
),
|
|
prompt=rewritten_prompt,
|
|
summary=summary,
|
|
reason="post_rewrite",
|
|
)
|
|
|
|
return rewritten_prompt, summary
|
|
|
|
|
|
async def rewrite_prompt_for_modal_async(
|
|
prompt: dict[str, Any],
|
|
workflow: dict[str, Any] | None,
|
|
sync_engine: ModalAssetSyncEngine | None = None,
|
|
settings: ModalSyncSettings | None = None,
|
|
nodes_module: Any | None = None,
|
|
extra_data: dict[str, Any] | None = None,
|
|
status_callback: SetupStatusCallback | None = None,
|
|
environment_status_callback: EnvironmentSetupStatusCallback | None = None,
|
|
plan_callback: ExecutionPlanStatusCallback | None = None,
|
|
cancellation_check: Callable[[], bool] | None = None,
|
|
occupied_environment_ids: frozenset[str] = frozenset(),
|
|
) -> tuple[dict[str, Any], RewriteSummary]:
|
|
"""Prepare one Modal prompt without blocking ComfyUI's event loop."""
|
|
return await asyncio.to_thread(
|
|
rewrite_prompt_for_modal,
|
|
prompt=prompt,
|
|
workflow=workflow,
|
|
sync_engine=sync_engine,
|
|
settings=settings,
|
|
nodes_module=nodes_module,
|
|
extra_data=extra_data,
|
|
status_callback=status_callback,
|
|
environment_status_callback=environment_status_callback,
|
|
plan_callback=plan_callback,
|
|
cancellation_check=cancellation_check,
|
|
occupied_environment_ids=occupied_environment_ids,
|
|
)
|
|
|
|
|
|
def _execution_assignments_payload(
|
|
summary: RewriteSummary,
|
|
settings: ModalSyncSettings,
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""Return planner assignments with member nodes and safe runtime labels."""
|
|
ssh_hosts_by_id = {
|
|
host.environment_id: host for host in _configured_ssh_hosts(settings)
|
|
}
|
|
vast_leases_by_environment: dict[str, Any] = {}
|
|
vast_registry = _vast_registry(settings)
|
|
if vast_registry is not None:
|
|
try:
|
|
vast_leases_by_environment = {
|
|
lease.environment_id: lease for lease in vast_registry.load().leases
|
|
}
|
|
except ValueError as exc:
|
|
logger.warning("Unable to load Vast lease labels for UI status: %s", exc)
|
|
configurations_by_id = {
|
|
str(configuration.get("configuration_id") or ""): configuration
|
|
for configuration in summary.remote_configurations
|
|
}
|
|
return {
|
|
component_id: {
|
|
"provider": assignment.provider.value,
|
|
"environment_id": assignment.environment_id,
|
|
"configuration_id": assignment.configuration_id,
|
|
"execution_location": _execution_location_for_assignment(
|
|
assignment, ssh_hosts_by_id, vast_leases_by_environment
|
|
)
|
|
or summary.execution_locations_by_environment.get(
|
|
assignment.environment_id
|
|
),
|
|
"node_ids": list(
|
|
summary.component_node_ids_by_representative.get(component_id, [])
|
|
),
|
|
"predicted_cost_usd": assignment.predicted_cost_usd,
|
|
"predicted_completion_seconds": assignment.predicted_completion_seconds,
|
|
"worker_index": summary.execution_worker_indices_by_representative.get(
|
|
component_id,
|
|
0,
|
|
),
|
|
"reasons": list(assignment.reasons),
|
|
"hardware": _assignment_hardware_payload(
|
|
component_id=component_id,
|
|
assignment=assignment,
|
|
configurations_by_id=configurations_by_id,
|
|
ssh_hosts_by_id=ssh_hosts_by_id,
|
|
vast_quotes={},
|
|
vast_leases_by_environment=vast_leases_by_environment,
|
|
),
|
|
}
|
|
for component_id, assignment in sorted(
|
|
summary.execution_assignments_by_representative.items()
|
|
)
|
|
}
|
|
|
|
|
|
def _prompt_uses_remote_execution_configurator(prompt: Mapping[str, Any]) -> bool:
|
|
"""Return whether the serialized prompt opts into workflow-scoped capacity."""
|
|
return _remote_execution_configurator_node_id(prompt) is not None
|
|
|
|
|
|
def _remote_execution_configurator_node_id(
|
|
prompt: Mapping[str, Any],
|
|
) -> str | None:
|
|
"""Return the serialized identity of the workflow's sole configurator node."""
|
|
configurator_node_ids = [
|
|
str(node_id)
|
|
for node_id, prompt_node in prompt.items()
|
|
if isinstance(prompt_node, Mapping)
|
|
and str(prompt_node.get("class_type") or "")
|
|
== REMOTE_EXECUTION_CONFIGURATOR_NODE_ID
|
|
]
|
|
return configurator_node_ids[0] if len(configurator_node_ids) == 1 else None
|
|
|
|
|
|
def _selected_modal_gpus(
|
|
summary: RewriteSummary,
|
|
legacy_modal_gpu: str,
|
|
) -> list[str]:
|
|
"""Return distinct Modal GPUs selected by the completed execution plan."""
|
|
modal_assignments = [
|
|
assignment
|
|
for assignment in summary.execution_assignments_by_representative.values()
|
|
if assignment.provider is ExecutionProvider.MODAL
|
|
]
|
|
if not modal_assignments:
|
|
return []
|
|
configured_gpus = {
|
|
str(configuration.get("configuration_id") or ""): str(
|
|
configuration.get("gpu_type") or ""
|
|
)
|
|
for configuration in summary.remote_configurations
|
|
if configuration.get("provider") == ExecutionProvider.MODAL.value
|
|
and configuration.get("configuration_id")
|
|
and configuration.get("gpu_type")
|
|
}
|
|
selected_gpus = {
|
|
configured_gpus.get(str(assignment.configuration_id or ""), "")
|
|
for assignment in modal_assignments
|
|
}
|
|
selected_gpus.discard("")
|
|
if not selected_gpus:
|
|
selected_gpus.add(legacy_modal_gpu)
|
|
return sorted(selected_gpus)
|
|
|
|
|
|
async def _queue_prompt_json(
|
|
prompt_server: Any,
|
|
json_data: dict[str, Any],
|
|
modal_response_payload: dict[str, Any] | None = None,
|
|
) -> web.Response:
|
|
"""Queue a possibly rewritten prompt using ComfyUI's native semantics."""
|
|
execution = _get_execution_module()
|
|
json_data = prompt_server.trigger_on_prompt(json_data)
|
|
|
|
if "number" in json_data:
|
|
number = float(json_data["number"])
|
|
else:
|
|
number = prompt_server.number
|
|
if json_data.get("front"):
|
|
number = -number
|
|
prompt_server.number += 1
|
|
|
|
if "prompt" not in json_data:
|
|
return web.json_response(
|
|
{
|
|
"error": {
|
|
"type": "no_prompt",
|
|
"message": "No prompt provided",
|
|
"details": "No prompt provided",
|
|
"extra_info": {},
|
|
}
|
|
},
|
|
status=400,
|
|
)
|
|
|
|
prompt = json_data["prompt"]
|
|
prompt_id = str(json_data.get("prompt_id", uuid.uuid4()))
|
|
partial_execution_targets = json_data.get("partial_execution_targets")
|
|
extra_data = dict(json_data.get("extra_data", {}))
|
|
if "client_id" in json_data:
|
|
extra_data["client_id"] = json_data["client_id"]
|
|
valid = await execution.validate_prompt(
|
|
prompt_id, prompt, partial_execution_targets
|
|
)
|
|
|
|
if not valid[0]:
|
|
modal_extra = extra_data.get("modal")
|
|
if isinstance(modal_extra, Mapping) and modal_extra.get("remote_component_ids"):
|
|
logger.warning(
|
|
"ComfyUI rejected rewritten Modal prompt prompt_id=%s error=%s node_errors=%s",
|
|
prompt_id,
|
|
valid[1],
|
|
valid[3],
|
|
)
|
|
_log_modal_rewritten_prompt_diagnostics(
|
|
prompt_id=prompt_id,
|
|
prompt=prompt,
|
|
reason="comfy_validation_failure",
|
|
level=logging.WARNING,
|
|
)
|
|
else:
|
|
logger.warning("invalid prompt: %s", valid[1])
|
|
return web.json_response(
|
|
{"error": valid[1], "node_errors": valid[3]}, status=400
|
|
)
|
|
|
|
outputs_to_execute = valid[2]
|
|
sensitive: dict[str, Any] = {}
|
|
for sensitive_key in execution.SENSITIVE_EXTRA_DATA_KEYS:
|
|
if sensitive_key in extra_data:
|
|
sensitive[sensitive_key] = extra_data.pop(sensitive_key)
|
|
|
|
extra_data["create_time"] = int(time.time() * 1000)
|
|
prompt_server.prompt_queue.put(
|
|
(number, prompt_id, prompt, extra_data, outputs_to_execute, sensitive)
|
|
)
|
|
response_payload: dict[str, Any] = {
|
|
"prompt_id": prompt_id,
|
|
"number": number,
|
|
"node_errors": valid[3],
|
|
}
|
|
if modal_response_payload:
|
|
response_payload.update(modal_response_payload)
|
|
return web.json_response(response_payload)
|
|
|
|
|
|
def _vast_registry(settings: ModalSyncSettings) -> VastLeaseRegistry | None:
|
|
"""Return persistent credential-free Vast lease state when available."""
|
|
user_directory = discover_comfyui_user_directory(settings)
|
|
if user_directory is None:
|
|
return None
|
|
return VastLeaseRegistry.for_user_directory(user_directory)
|
|
|
|
|
|
def _install_modal_interrupt_queue_bridge(prompt_server: Any) -> None:
|
|
"""Expose active remote work through every ComfyUI queue-state view."""
|
|
prompt_queue = getattr(prompt_server, "prompt_queue", None)
|
|
if prompt_queue is None or getattr(
|
|
prompt_queue, _MODAL_INTERRUPT_QUEUE_BRIDGE_ATTR, False
|
|
):
|
|
return
|
|
|
|
original_get_current_queue = getattr(prompt_queue, "get_current_queue", None)
|
|
original_get_current_queue_volatile = getattr(
|
|
prompt_queue, "get_current_queue_volatile", None
|
|
)
|
|
original_get_tasks_remaining = getattr(prompt_queue, "get_tasks_remaining", None)
|
|
original_interrupt_if_running = getattr(
|
|
prompt_queue, "interrupt_if_running", None
|
|
)
|
|
original_task_done = getattr(prompt_queue, "task_done", None)
|
|
original_wipe_queue = getattr(prompt_queue, "wipe_queue", None)
|
|
original_delete_queue_item = getattr(prompt_queue, "delete_queue_item", None)
|
|
if not any(
|
|
callable(method)
|
|
for method in (
|
|
original_get_current_queue,
|
|
original_get_current_queue_volatile,
|
|
original_get_tasks_remaining,
|
|
)
|
|
):
|
|
logger.debug(
|
|
"Prompt queue does not expose queue-state methods; skipping remote queue bridge."
|
|
)
|
|
return
|
|
|
|
preparation_prompts: dict[str, tuple[Any, ...]] = {}
|
|
preparation_cancellations: dict[str, threading.Event] = {}
|
|
preparation_lock = threading.RLock()
|
|
setattr(prompt_queue, _REMOTE_PREPARATION_PROMPTS_ATTR, preparation_prompts)
|
|
setattr(
|
|
prompt_queue,
|
|
_REMOTE_PREPARATION_CANCELLATIONS_ATTR,
|
|
preparation_cancellations,
|
|
)
|
|
setattr(prompt_queue, _REMOTE_PREPARATION_LOCK_ATTR, preparation_lock)
|
|
|
|
def preparation_items() -> list[tuple[Any, ...]]:
|
|
"""Return a stable snapshot of prompts still preparing remote capacity."""
|
|
with preparation_lock:
|
|
return list(preparation_prompts.values())
|
|
|
|
def append_missing_preparations(
|
|
running: Iterable[Any],
|
|
queued: Iterable[Any],
|
|
) -> tuple[list[Any], Any]:
|
|
"""Add preparation entries that are not already in ComfyUI's native queue."""
|
|
running_items = list(running)
|
|
queued_items = list(queued)
|
|
native_prompt_ids = _queue_item_prompt_ids((*running_items, *queued_items))
|
|
running_items.extend(
|
|
item
|
|
for item in preparation_items()
|
|
if str(item[1]) not in native_prompt_ids
|
|
)
|
|
return running_items, queued
|
|
|
|
if callable(original_get_current_queue):
|
|
|
|
def remote_get_current_queue() -> tuple[list[Any], Any]:
|
|
"""Return native work plus preparation and active remote prompt entries."""
|
|
running, queued = original_get_current_queue()
|
|
running_items, queued = append_missing_preparations(running, queued)
|
|
running_prompt_ids = _queue_item_prompt_ids(running_items)
|
|
try:
|
|
from .remote.modal_app import active_remote_modal_prompt_ids
|
|
except ImportError:
|
|
return running_items, queued
|
|
|
|
for prompt_id in sorted(
|
|
active_remote_modal_prompt_ids() - running_prompt_ids
|
|
):
|
|
running_items.append((0, prompt_id, {}, {}, [], {}))
|
|
return running_items, queued
|
|
|
|
setattr(prompt_queue, "get_current_queue", remote_get_current_queue)
|
|
|
|
if callable(original_get_current_queue_volatile):
|
|
|
|
def remote_get_current_queue_volatile() -> tuple[list[Any], Any]:
|
|
"""Include remote preparation in ComfyUI's public `/queue` response."""
|
|
running, queued = original_get_current_queue_volatile()
|
|
return append_missing_preparations(running, queued)
|
|
|
|
setattr(
|
|
prompt_queue,
|
|
"get_current_queue_volatile",
|
|
remote_get_current_queue_volatile,
|
|
)
|
|
|
|
if callable(original_get_tasks_remaining):
|
|
|
|
def remote_get_tasks_remaining() -> int:
|
|
"""Count remote preparation as work in websocket queue status."""
|
|
remaining = int(original_get_tasks_remaining())
|
|
native_prompt_ids: set[str] = set()
|
|
native_queue_method = (
|
|
original_get_current_queue_volatile
|
|
if callable(original_get_current_queue_volatile)
|
|
else original_get_current_queue
|
|
)
|
|
if callable(native_queue_method):
|
|
running, queued = native_queue_method()
|
|
native_prompt_ids = _queue_item_prompt_ids((*running, *queued))
|
|
return remaining + sum(
|
|
str(item[1]) not in native_prompt_ids
|
|
for item in preparation_items()
|
|
)
|
|
|
|
setattr(prompt_queue, "get_tasks_remaining", remote_get_tasks_remaining)
|
|
|
|
if callable(original_interrupt_if_running):
|
|
|
|
def remote_interrupt_if_running(prompt_id: str) -> bool:
|
|
"""Cancel remote preparation or interrupt matching native execution."""
|
|
if _cancel_remote_preparation(prompt_server, prompt_id):
|
|
return True
|
|
return bool(original_interrupt_if_running(prompt_id))
|
|
|
|
setattr(prompt_queue, "interrupt_if_running", remote_interrupt_if_running)
|
|
|
|
if callable(original_task_done):
|
|
|
|
def remote_task_done(item_id: Any, *args: Any, **kwargs: Any) -> Any:
|
|
"""Release background cache work after the whole prompt terminates."""
|
|
currently_running = getattr(prompt_queue, "currently_running", {})
|
|
running_item = (
|
|
currently_running.get(item_id)
|
|
if isinstance(currently_running, Mapping)
|
|
else None
|
|
)
|
|
prompt_id = (
|
|
str(running_item[1])
|
|
if isinstance(running_item, (list, tuple)) and len(running_item) > 1
|
|
else None
|
|
)
|
|
try:
|
|
return original_task_done(item_id, *args, **kwargs)
|
|
finally:
|
|
if prompt_id is not None:
|
|
finish_r2_writeback_prompt(prompt_id)
|
|
|
|
setattr(prompt_queue, "task_done", remote_task_done)
|
|
|
|
if callable(original_wipe_queue):
|
|
|
|
def remote_wipe_queue() -> Any:
|
|
"""Release reservations belonging to every discarded queued prompt."""
|
|
queued_items: list[Any] = []
|
|
if callable(original_get_current_queue):
|
|
_running, queued = original_get_current_queue()
|
|
queued_items = list(queued)
|
|
try:
|
|
return original_wipe_queue()
|
|
finally:
|
|
for queued_prompt_id in _queue_item_prompt_ids(queued_items):
|
|
finish_r2_writeback_prompt(queued_prompt_id)
|
|
|
|
setattr(prompt_queue, "wipe_queue", remote_wipe_queue)
|
|
|
|
if callable(original_delete_queue_item):
|
|
|
|
def remote_delete_queue_item(predicate: Callable[[Any], bool]) -> Any:
|
|
"""Release the exact reservation removed through ComfyUI's queue API."""
|
|
before_prompt_ids: set[str] = set()
|
|
if callable(original_get_current_queue):
|
|
_running, queued = original_get_current_queue()
|
|
before_prompt_ids = _queue_item_prompt_ids(queued)
|
|
result = original_delete_queue_item(predicate)
|
|
if result and callable(original_get_current_queue):
|
|
_running, queued = original_get_current_queue()
|
|
after_prompt_ids = _queue_item_prompt_ids(queued)
|
|
for removed_prompt_id in before_prompt_ids - after_prompt_ids:
|
|
finish_r2_writeback_prompt(removed_prompt_id)
|
|
return result
|
|
|
|
setattr(prompt_queue, "delete_queue_item", remote_delete_queue_item)
|
|
|
|
setattr(prompt_queue, _MODAL_INTERRUPT_QUEUE_BRIDGE_ATTR, True)
|
|
logger.info("Installed remote preparation bridge on ComfyUI prompt queue.")
|
|
|
|
|
|
def _queue_item_prompt_ids(items: Iterable[Any]) -> set[str]:
|
|
"""Return prompt IDs from well-formed native or synthetic queue items."""
|
|
return {
|
|
str(item[1])
|
|
for item in items
|
|
if isinstance(item, (list, tuple)) and len(item) > 1
|
|
}
|
|
|
|
|
|
def _cancel_remote_preparation(prompt_server: Any, prompt_id: str) -> bool:
|
|
"""Signal cancellation when the prompt is preparing remote capacity."""
|
|
normalized_prompt_id = str(prompt_id).strip()
|
|
prompt_queue = getattr(prompt_server, "prompt_queue", None)
|
|
cancellations = getattr(
|
|
prompt_queue,
|
|
_REMOTE_PREPARATION_CANCELLATIONS_ATTR,
|
|
None,
|
|
)
|
|
preparation_lock = getattr(prompt_queue, _REMOTE_PREPARATION_LOCK_ATTR, None)
|
|
if (
|
|
not normalized_prompt_id
|
|
or not isinstance(cancellations, dict)
|
|
or preparation_lock is None
|
|
):
|
|
return False
|
|
with preparation_lock:
|
|
cancellation_event = cancellations.get(normalized_prompt_id)
|
|
if cancellation_event is None:
|
|
return False
|
|
cancellation_event.set()
|
|
logger.info(
|
|
"Cancelled remote preparation for prompt %s.", normalized_prompt_id
|
|
)
|
|
return True
|
|
|
|
|
|
def _queued_ssh_environment_ids(
|
|
prompt_server: Any,
|
|
*,
|
|
excluding_prompt_id: str | None = None,
|
|
) -> frozenset[str]:
|
|
"""Return SSH environments reserved by prompts already in ComfyUI's queue."""
|
|
prompt_queue = getattr(prompt_server, "prompt_queue", None)
|
|
get_current_queue = getattr(prompt_queue, "get_current_queue", None)
|
|
if not callable(get_current_queue):
|
|
return frozenset()
|
|
queue_state = get_current_queue()
|
|
if not isinstance(queue_state, (list, tuple)) or len(queue_state) != 2:
|
|
return frozenset()
|
|
running, queued = queue_state
|
|
queue_items = [
|
|
item
|
|
for collection in (running, queued)
|
|
if isinstance(collection, (list, tuple))
|
|
for item in collection
|
|
]
|
|
environment_ids: set[str] = set()
|
|
for item in queue_items:
|
|
if not isinstance(item, (list, tuple)) or len(item) <= 3:
|
|
continue
|
|
prompt_id = str(item[1]) if len(item) > 1 else ""
|
|
if excluding_prompt_id is not None and prompt_id == excluding_prompt_id:
|
|
continue
|
|
extra_data = item[3]
|
|
if not isinstance(extra_data, Mapping):
|
|
continue
|
|
remote_execution = extra_data.get("remote_execution")
|
|
if not isinstance(remote_execution, Mapping):
|
|
continue
|
|
assignments = remote_execution.get("assignments")
|
|
if not isinstance(assignments, Mapping):
|
|
continue
|
|
for assignment in assignments.values():
|
|
if not isinstance(assignment, Mapping):
|
|
continue
|
|
provider = str(assignment.get("provider") or "").strip().lower()
|
|
environment_id = str(assignment.get("environment_id") or "").strip()
|
|
if provider == ExecutionProvider.SSH_DOCKER.value and environment_id:
|
|
environment_ids.add(environment_id)
|
|
return frozenset(environment_ids)
|
|
|
|
|
|
def _set_remote_preparation(
|
|
prompt_server: Any,
|
|
*,
|
|
prompt_id: str,
|
|
prompt: Mapping[str, Any],
|
|
extra_data: Mapping[str, Any],
|
|
cancellation_event: threading.Event | None = None,
|
|
) -> bool:
|
|
"""Register one pre-queue remote workflow and publish its active queue state."""
|
|
prompt_queue = getattr(prompt_server, "prompt_queue", None)
|
|
preparations = getattr(prompt_queue, _REMOTE_PREPARATION_PROMPTS_ATTR, None)
|
|
preparation_lock = getattr(prompt_queue, _REMOTE_PREPARATION_LOCK_ATTR, None)
|
|
if not isinstance(preparations, dict) or preparation_lock is None:
|
|
return False
|
|
preparation_extra_data = copy.deepcopy(dict(extra_data))
|
|
preparation_extra_data.setdefault("create_time", int(time.time() * 1000))
|
|
with preparation_lock:
|
|
preparations[prompt_id] = (
|
|
0,
|
|
prompt_id,
|
|
copy.deepcopy(dict(prompt)),
|
|
preparation_extra_data,
|
|
[],
|
|
{},
|
|
)
|
|
cancellations = getattr(
|
|
prompt_queue,
|
|
_REMOTE_PREPARATION_CANCELLATIONS_ATTR,
|
|
None,
|
|
)
|
|
if isinstance(cancellations, dict) and cancellation_event is not None:
|
|
cancellations[prompt_id] = cancellation_event
|
|
queue_updated = getattr(prompt_server, "queue_updated", None)
|
|
if callable(queue_updated):
|
|
queue_updated()
|
|
return True
|
|
|
|
|
|
def _clear_remote_preparation(prompt_server: Any, prompt_id: str) -> None:
|
|
"""Remove one pre-queue remote workflow and publish the resulting queue state."""
|
|
prompt_queue = getattr(prompt_server, "prompt_queue", None)
|
|
preparations = getattr(prompt_queue, _REMOTE_PREPARATION_PROMPTS_ATTR, None)
|
|
preparation_lock = getattr(prompt_queue, _REMOTE_PREPARATION_LOCK_ATTR, None)
|
|
if not isinstance(preparations, dict) or preparation_lock is None:
|
|
return
|
|
with preparation_lock:
|
|
removed = preparations.pop(prompt_id, None)
|
|
cancellations = getattr(
|
|
prompt_queue,
|
|
_REMOTE_PREPARATION_CANCELLATIONS_ATTR,
|
|
None,
|
|
)
|
|
if isinstance(cancellations, dict):
|
|
cancellations.pop(prompt_id, None)
|
|
if removed is not None:
|
|
queue_updated = getattr(prompt_server, "queue_updated", None)
|
|
if callable(queue_updated):
|
|
queue_updated()
|
|
|
|
|
|
def setup_modal_queue_route(
|
|
prompt_server: Any | None = None,
|
|
sync_engine: ModalAssetSyncEngine | None = None,
|
|
settings: ModalSyncSettings | None = None,
|
|
) -> None:
|
|
"""Register the `/modal/queue_prompt` route once for the active PromptServer."""
|
|
global _ROUTE_REGISTERED
|
|
if _ROUTE_REGISTERED:
|
|
return
|
|
|
|
try:
|
|
resolved_server_module = _get_server_module()
|
|
except ModuleNotFoundError:
|
|
logger.debug(
|
|
"ComfyUI server module is not available; skipping route registration."
|
|
)
|
|
return
|
|
|
|
resolved_settings = settings or get_settings()
|
|
prompt_server = prompt_server or getattr(
|
|
resolved_server_module.PromptServer, "instance", None
|
|
)
|
|
if prompt_server is None:
|
|
logger.debug(
|
|
"PromptServer.instance is not available; skipping route registration."
|
|
)
|
|
return
|
|
|
|
setup_r2_oauth_routes(prompt_server)
|
|
|
|
resolved_sync_engine = sync_engine or ModalAssetSyncEngine.from_environment(
|
|
resolved_settings
|
|
)
|
|
analysis_route_path = _analysis_route_path(resolved_settings.route_path)
|
|
progress_state_route_path = _progress_state_route_path(resolved_settings.route_path)
|
|
container_status_route_path = _container_status_route_path(
|
|
resolved_settings.route_path
|
|
)
|
|
container_stop_route_path = container_status_route_path.replace(
|
|
"/container_status", "/container_stop"
|
|
)
|
|
delete_caches_route_path = _delete_modal_caches_route_path(
|
|
resolved_settings.route_path
|
|
)
|
|
delete_volume_route_path = _delete_modal_volume_route_path(
|
|
resolved_settings.route_path
|
|
)
|
|
cancel_preparation_route_path = _cancel_preparation_route_path(
|
|
resolved_settings.route_path
|
|
)
|
|
remote_environments_route_path = _remote_environments_route_path(
|
|
resolved_settings.route_path
|
|
)
|
|
remote_environment_probe_route_path = _remote_environment_probe_route_path(
|
|
resolved_settings.route_path
|
|
)
|
|
remote_environment_bootstrap_route_path = _remote_environment_bootstrap_route_path(
|
|
resolved_settings.route_path
|
|
)
|
|
remote_environment_status_route_path = _remote_environment_status_route_path(
|
|
resolved_settings.route_path
|
|
)
|
|
remote_environment_stop_route_path = _remote_environment_stop_route_path(
|
|
resolved_settings.route_path
|
|
)
|
|
remote_host_registry = _ssh_host_registry(resolved_settings)
|
|
vast_registry = _vast_registry(resolved_settings)
|
|
_install_modal_interrupt_queue_bridge(prompt_server)
|
|
|
|
@prompt_server.routes.post(_R2_STORAGE_USAGE_ROUTE)
|
|
async def refresh_r2_storage_usage(request: web.Request) -> web.Response:
|
|
"""Refresh one configured R2 bucket's safe aggregate storage state."""
|
|
try:
|
|
payload = await request.json()
|
|
if not isinstance(payload, Mapping):
|
|
raise TypeError("R2 storage usage request must be a JSON object.")
|
|
storage = _r2_storage_from_usage_payload(payload)
|
|
except (TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
try:
|
|
usage = await asyncio.to_thread(_refresh_r2_storage_usage, storage)
|
|
except R2CredentialError as exc:
|
|
logger.warning(
|
|
"Unable to refresh R2 storage usage configuration=%s bucket=%s: %s",
|
|
storage.configuration_id,
|
|
storage.bucket,
|
|
exc,
|
|
)
|
|
status = 423 if exc.code == R2_KEYCHAIN_UNLOCK_REQUIRED_CODE else 502
|
|
return web.json_response(
|
|
{"error": str(exc), "code": exc.code},
|
|
status=status,
|
|
)
|
|
except (R2CacheError, RuntimeError, ValueError) as exc:
|
|
logger.warning(
|
|
"Unable to refresh R2 storage usage configuration=%s bucket=%s: %s",
|
|
storage.configuration_id,
|
|
storage.bucket,
|
|
exc,
|
|
)
|
|
return web.json_response({"error": str(exc)}, status=502)
|
|
return web.json_response(
|
|
{
|
|
"configuration_id": storage.configuration_id,
|
|
"storage_usage_bytes": usage.size_bytes,
|
|
"storage_object_count": usage.object_count,
|
|
"refreshed_at": time.time(),
|
|
}
|
|
)
|
|
|
|
@prompt_server.routes.post(_R2_KEYCHAIN_UNLOCK_ROUTE)
|
|
async def unlock_r2_keychain(request: web.Request) -> web.Response:
|
|
"""Display macOS's system-owned login-keychain unlock prompt."""
|
|
del request
|
|
try:
|
|
await asyncio.to_thread(request_macos_keychain_unlock)
|
|
except R2CredentialError as exc:
|
|
logger.warning("Unable to unlock the macOS login keychain: %s", exc)
|
|
return web.json_response({"error": str(exc)}, status=409)
|
|
return web.json_response({"unlocked": True})
|
|
|
|
@prompt_server.routes.post(cancel_preparation_route_path)
|
|
async def cancel_remote_preparation(request: web.Request) -> web.Response:
|
|
"""Cancel one prompt while it is still preparing remote execution."""
|
|
payload = await request.json()
|
|
prompt_id = str(payload.get("prompt_id") or "").strip()
|
|
cancelled = _cancel_remote_preparation(prompt_server, prompt_id)
|
|
return web.json_response({"cancelled": cancelled, "prompt_id": prompt_id})
|
|
|
|
if hasattr(prompt_server.routes, "get"):
|
|
|
|
@prompt_server.routes.get(remote_environments_route_path)
|
|
async def remote_environments(request: web.Request) -> web.Response:
|
|
"""Return credential-free SSH host configuration and discovered state."""
|
|
del request
|
|
if remote_host_registry is None:
|
|
return web.json_response(
|
|
{
|
|
"error": "The ComfyUI user directory could not be resolved.",
|
|
"hosts": [],
|
|
},
|
|
status=503,
|
|
)
|
|
try:
|
|
config = await asyncio.to_thread(remote_host_registry.load)
|
|
except ValueError as exc:
|
|
return web.json_response({"error": str(exc), "hosts": []}, status=500)
|
|
return web.json_response(config.to_dict())
|
|
|
|
@prompt_server.routes.get(progress_state_route_path)
|
|
async def modal_progress_state(request: web.Request) -> web.Response:
|
|
"""Return recent Modal UI events for the requesting ComfyUI client."""
|
|
client_id = request.query.get("client_id")
|
|
return web.json_response({"events": modal_ui_events_for_client(client_id)})
|
|
|
|
@prompt_server.routes.get(container_status_route_path)
|
|
async def modal_container_status(request: web.Request) -> web.Response:
|
|
"""Return active containers and hourly billing for one selected GPU app."""
|
|
from .remote.modal_app import (
|
|
ModalBillingStatusError,
|
|
ModalContainerStatusError,
|
|
get_hourly_modal_app_billing,
|
|
list_active_modal_containers,
|
|
)
|
|
|
|
requested_modal_gpu = request.query.get(
|
|
"modal_gpu",
|
|
resolved_settings.modal_gpu,
|
|
)
|
|
try:
|
|
selected_settings = settings_for_modal_gpu(
|
|
resolved_settings,
|
|
requested_modal_gpu,
|
|
)
|
|
except ValueError as exc:
|
|
return web.json_response(
|
|
{"containers": [], "error": str(exc), "polled_at": time.time()},
|
|
status=400,
|
|
)
|
|
|
|
containers_task = asyncio.create_task(
|
|
list_active_modal_containers(resolved_settings)
|
|
)
|
|
include_billing = request.query.get("include_billing", "true").casefold() not in {
|
|
"0",
|
|
"false",
|
|
"no",
|
|
}
|
|
billing_task = (
|
|
asyncio.create_task(
|
|
get_hourly_modal_app_billing(
|
|
selected_settings.modal_gpu,
|
|
resolved_settings,
|
|
)
|
|
)
|
|
if include_billing
|
|
else None
|
|
)
|
|
try:
|
|
containers = await containers_task
|
|
except ModalContainerStatusError as exc:
|
|
if billing_task is not None:
|
|
billing_task.cancel()
|
|
await asyncio.gather(billing_task, return_exceptions=True)
|
|
logger.warning("Unable to refresh Modal container status: %s", exc)
|
|
return web.json_response(
|
|
{"containers": [], "error": str(exc), "polled_at": time.time()},
|
|
status=502,
|
|
)
|
|
billing = None
|
|
billing_error = None
|
|
if billing_task is not None:
|
|
try:
|
|
billing = await billing_task
|
|
except ModalBillingStatusError as exc:
|
|
billing_error = str(exc)
|
|
logger.warning("Unable to refresh Modal hourly billing: %s", exc)
|
|
return web.json_response(
|
|
{
|
|
"containers": [container.as_dict() for container in containers],
|
|
"billing": billing.as_dict() if billing is not None else None,
|
|
"billing_error": billing_error,
|
|
"polled_at": time.time(),
|
|
}
|
|
)
|
|
|
|
@prompt_server.routes.post(container_stop_route_path)
|
|
async def modal_container_stop(request: web.Request) -> web.Response:
|
|
"""Stop one exact active Modal container owned by this installation."""
|
|
from .remote.modal_app import (
|
|
ModalContainerStatusError,
|
|
stop_managed_modal_container,
|
|
)
|
|
|
|
try:
|
|
payload = await request.json()
|
|
container_id = str(payload.get("container_id") or "").strip()
|
|
stopped = await stop_managed_modal_container(
|
|
container_id,
|
|
resolved_settings,
|
|
)
|
|
except (ModalContainerStatusError, TypeError, ValueError) as exc:
|
|
return web.json_response({"error": str(exc)}, status=502)
|
|
return web.json_response(
|
|
{"container_id": container_id, "stopped": stopped}
|
|
)
|
|
|
|
@prompt_server.routes.get("/remote/vast/status")
|
|
async def vast_status(request: web.Request) -> web.Response:
|
|
"""Return refreshed credential-free managed Vast lease inventory."""
|
|
del request
|
|
if vast_registry is None:
|
|
return web.json_response(
|
|
{"configured": False, "leases": [], "error": "ComfyUI user directory unavailable."},
|
|
status=503,
|
|
)
|
|
try:
|
|
if not os.getenv("VAST_API_KEY"):
|
|
state = await asyncio.to_thread(vast_registry.load)
|
|
leases = state.leases
|
|
else:
|
|
api_key = str(os.getenv("VAST_API_KEY") or "").strip()
|
|
base_url = str(
|
|
os.getenv("COMFY_MODAL_VAST_API_BASE_URL") or ""
|
|
).strip()
|
|
api_client = VastApiClient(
|
|
api_key,
|
|
**({"base_url": base_url} if base_url else {}),
|
|
)
|
|
manager = VastLeaseManager.for_inventory(
|
|
api_client=api_client,
|
|
registry=vast_registry,
|
|
owner_id=resolved_settings.app_name,
|
|
)
|
|
leases = await manager.refresh_owned_leases()
|
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
return web.json_response(
|
|
{"configured": bool(os.getenv("VAST_API_KEY")), "leases": [], "error": str(exc)},
|
|
status=502,
|
|
)
|
|
return web.json_response(
|
|
{
|
|
"configured": bool(os.getenv("VAST_API_KEY")),
|
|
"image_configured": bool(os.getenv("COMFY_MODAL_VAST_IMAGE")),
|
|
"leases": [lease.to_dict() for lease in leases],
|
|
}
|
|
)
|
|
|
|
if hasattr(prompt_server.routes, "put"):
|
|
|
|
@prompt_server.routes.put(remote_environments_route_path)
|
|
async def remote_environments_update(request: web.Request) -> web.Response:
|
|
"""Validate and atomically replace SSH host configuration."""
|
|
if remote_host_registry is None:
|
|
return web.json_response(
|
|
{"error": "The ComfyUI user directory could not be resolved."},
|
|
status=503,
|
|
)
|
|
try:
|
|
payload = await request.json()
|
|
if not isinstance(payload, Mapping):
|
|
raise ValueError(
|
|
"Remote environment configuration must be a JSON object."
|
|
)
|
|
config = RemoteExecutionConfig.from_dict(payload)
|
|
await asyncio.to_thread(remote_host_registry.save, config)
|
|
except (TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
return web.json_response(config.to_dict())
|
|
|
|
@prompt_server.routes.post(remote_environment_probe_route_path)
|
|
async def remote_environment_probe(request: web.Request) -> web.Response:
|
|
"""Probe one configured SSH host and persist its discovered capabilities."""
|
|
if remote_host_registry is None:
|
|
return web.json_response(
|
|
{"error": "The ComfyUI user directory could not be resolved."},
|
|
status=503,
|
|
)
|
|
try:
|
|
payload = await request.json()
|
|
environment_id = str(payload.get("environment_id") or "").strip()
|
|
host = await asyncio.to_thread(
|
|
remote_host_registry.get_host, environment_id
|
|
)
|
|
capabilities = await asyncio.to_thread(
|
|
SshDockerController(host).probe_capabilities
|
|
)
|
|
updated_host = await asyncio.to_thread(
|
|
remote_host_registry.update_probe_result,
|
|
environment_id,
|
|
capabilities=capabilities,
|
|
health=EnvironmentHealth.READY,
|
|
last_error=None,
|
|
)
|
|
except (KeyError, TypeError, ValueError, RuntimeError) as exc:
|
|
environment_id = str(locals().get("environment_id") or "").strip()
|
|
if environment_id:
|
|
try:
|
|
await asyncio.to_thread(
|
|
remote_host_registry.update_probe_result,
|
|
environment_id,
|
|
capabilities=None,
|
|
health=EnvironmentHealth.UNAVAILABLE,
|
|
last_error=str(exc),
|
|
)
|
|
except (KeyError, ValueError):
|
|
pass
|
|
return web.json_response({"error": str(exc)}, status=502)
|
|
return web.json_response(updated_host.to_dict())
|
|
|
|
@prompt_server.routes.post("/remote/vast/verify")
|
|
async def vast_verify(request: web.Request) -> web.Response:
|
|
"""Verify the configured Vast credential without returning it."""
|
|
del request
|
|
api_key = str(os.getenv("VAST_API_KEY") or "").strip()
|
|
if not api_key:
|
|
return web.json_response(
|
|
{"verified": False, "error": "Set VAST_API_KEY first."},
|
|
status=400,
|
|
)
|
|
base_url = str(os.getenv("COMFY_MODAL_VAST_API_BASE_URL") or "").strip()
|
|
try:
|
|
client = VastApiClient(api_key, **({"base_url": base_url} if base_url else {}))
|
|
account = await client.verify_credentials()
|
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
return web.json_response(
|
|
{"verified": False, "error": str(exc)},
|
|
status=502,
|
|
)
|
|
return web.json_response({"verified": True, "account": account})
|
|
|
|
@prompt_server.routes.post("/remote/vast/reap")
|
|
async def vast_reap(request: web.Request) -> web.Response:
|
|
"""Destroy only owned idle leases whose configured deadline has expired."""
|
|
del request
|
|
try:
|
|
service = VastService.from_environment(
|
|
resolved_settings,
|
|
repo_root=Path(__file__).resolve().parent,
|
|
)
|
|
destroyed = await service.lease_manager.destroy_expired()
|
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
return web.json_response({"error": str(exc)}, status=502)
|
|
return web.json_response({"destroyed_instance_ids": list(destroyed)})
|
|
|
|
@prompt_server.routes.post("/remote/vast/destroy")
|
|
async def vast_destroy(request: web.Request) -> web.Response:
|
|
"""Destroy one exact idle registry-owned lease after server-side label checks."""
|
|
try:
|
|
payload = await request.json()
|
|
instance_id = int(payload.get("instance_id"))
|
|
api_key = str(os.getenv("VAST_API_KEY") or "").strip()
|
|
if not api_key:
|
|
raise RuntimeError("Set VAST_API_KEY before destroying Vast capacity.")
|
|
if vast_registry is None:
|
|
raise RuntimeError("ComfyUI user directory is unavailable.")
|
|
base_url = str(
|
|
os.getenv("COMFY_MODAL_VAST_API_BASE_URL") or ""
|
|
).strip()
|
|
api_client = VastApiClient(
|
|
api_key,
|
|
**({"base_url": base_url} if base_url else {}),
|
|
)
|
|
manager = VastLeaseManager.for_inventory(
|
|
api_client=api_client,
|
|
registry=vast_registry,
|
|
owner_id=resolved_settings.app_name,
|
|
)
|
|
destroyed = await manager.destroy_owned_lease(
|
|
instance_id,
|
|
allow_active_work=payload.get("force") is True,
|
|
)
|
|
except (OSError, RuntimeError, TypeError, ValueError) as exc:
|
|
return web.json_response({"error": str(exc)}, status=502)
|
|
return web.json_response({"instance_id": instance_id, "destroyed": destroyed})
|
|
|
|
@prompt_server.routes.post(remote_environment_bootstrap_route_path)
|
|
async def remote_environment_bootstrap(request: web.Request) -> web.Response:
|
|
"""Build the current runtime and start one compatible warm SSH worker."""
|
|
if remote_host_registry is None:
|
|
return web.json_response(
|
|
{"error": "The ComfyUI user directory could not be resolved."},
|
|
status=503,
|
|
)
|
|
environment_id = ""
|
|
try:
|
|
from .ssh_runtime import SshRuntimeManager
|
|
|
|
payload = await request.json()
|
|
environment_id = str(payload.get("environment_id") or "").strip()
|
|
worker_index = int(payload.get("worker_index", 0))
|
|
host = await asyncio.to_thread(
|
|
remote_host_registry.get_host, environment_id
|
|
)
|
|
manager = SshRuntimeManager(
|
|
controller=SshDockerController(host),
|
|
repo_root=Path(__file__).resolve().parent,
|
|
settings=resolved_settings,
|
|
)
|
|
spec = await asyncio.to_thread(manager.ensure_worker, worker_index)
|
|
except (KeyError, TypeError, ValueError, RuntimeError) as exc:
|
|
if remote_host_registry is not None and environment_id:
|
|
try:
|
|
await asyncio.to_thread(
|
|
remote_host_registry.update_probe_result,
|
|
environment_id,
|
|
capabilities=host.capabilities if "host" in locals() else None,
|
|
health=EnvironmentHealth.UNAVAILABLE,
|
|
last_error=str(exc),
|
|
)
|
|
except (KeyError, ValueError):
|
|
pass
|
|
return web.json_response({"error": str(exc)}, status=502)
|
|
await asyncio.to_thread(
|
|
remote_host_registry.update_probe_result,
|
|
environment_id,
|
|
capabilities=host.capabilities,
|
|
health=EnvironmentHealth.READY,
|
|
last_error=None,
|
|
)
|
|
return web.json_response(
|
|
{
|
|
"environment_id": environment_id,
|
|
"worker_index": worker_index,
|
|
"container_name": spec.container_name,
|
|
"image_tag": spec.image_tag,
|
|
"runtime_fingerprint": spec.identity.fingerprint,
|
|
}
|
|
)
|
|
|
|
@prompt_server.routes.post(remote_environment_status_route_path)
|
|
async def remote_environment_status(request: web.Request) -> web.Response:
|
|
"""Return managed worker state for one SSH execution environment."""
|
|
if remote_host_registry is None:
|
|
return web.json_response(
|
|
{"error": "The ComfyUI user directory could not be resolved."},
|
|
status=503,
|
|
)
|
|
try:
|
|
payload = await request.json()
|
|
environment_id = str(payload.get("environment_id") or "").strip()
|
|
host = await asyncio.to_thread(
|
|
remote_host_registry.get_host, environment_id
|
|
)
|
|
workers = await asyncio.to_thread(
|
|
SshDockerController(host).list_managed_workers
|
|
)
|
|
except (KeyError, TypeError, ValueError, RuntimeError) as exc:
|
|
return web.json_response({"error": str(exc)}, status=502)
|
|
return web.json_response(
|
|
{
|
|
"environment_id": environment_id,
|
|
"workers": [worker.to_dict() for worker in workers],
|
|
}
|
|
)
|
|
|
|
@prompt_server.routes.post(remote_environment_stop_route_path)
|
|
async def remote_environment_stop(request: web.Request) -> web.Response:
|
|
"""Stop all node-pack-managed workers on one configured SSH host."""
|
|
if remote_host_registry is None:
|
|
return web.json_response(
|
|
{"error": "The ComfyUI user directory could not be resolved."},
|
|
status=503,
|
|
)
|
|
try:
|
|
from .ssh_runtime import SshRuntimeManager
|
|
|
|
payload = await request.json()
|
|
environment_id = str(payload.get("environment_id") or "").strip()
|
|
host = await asyncio.to_thread(
|
|
remote_host_registry.get_host, environment_id
|
|
)
|
|
manager = SshRuntimeManager(
|
|
controller=SshDockerController(host),
|
|
repo_root=Path(__file__).resolve().parent,
|
|
settings=resolved_settings,
|
|
)
|
|
removed = await asyncio.to_thread(manager.stop_all_workers)
|
|
except (KeyError, TypeError, ValueError, RuntimeError) as exc:
|
|
return web.json_response({"error": str(exc)}, status=502)
|
|
return web.json_response(
|
|
{"environment_id": environment_id, "removed_containers": list(removed)}
|
|
)
|
|
|
|
@prompt_server.routes.post(analysis_route_path)
|
|
async def modal_analyze_remote_nodes(request: web.Request) -> web.Response:
|
|
"""Analyze which workflow nodes should be marked remote for the current graph."""
|
|
logger.info("Received Modal remote-node analysis request.")
|
|
try:
|
|
request_started_at = time.perf_counter()
|
|
json_data = await request.json()
|
|
prompt = json_data.get("prompt")
|
|
if not isinstance(prompt, dict):
|
|
raise ValueError(
|
|
"Modal remote-node analysis requires a 'prompt' object."
|
|
)
|
|
|
|
workflow = json_data.get("workflow")
|
|
seed_node_ids = json_data.get("seed_node_ids") or []
|
|
if not isinstance(seed_node_ids, list):
|
|
raise ValueError(
|
|
"Modal remote-node analysis requires 'seed_node_ids' to be a list."
|
|
)
|
|
|
|
analysis = analyze_remote_node_selection(
|
|
prompt=prompt,
|
|
workflow=workflow if isinstance(workflow, dict) else None,
|
|
seed_workflow_node_paths=[
|
|
str(seed_node_id) for seed_node_id in seed_node_ids
|
|
],
|
|
settings=resolved_settings,
|
|
)
|
|
logger.info(
|
|
"Modal remote-node analysis finished in %.3fs with %d requested nodes and %d additions.",
|
|
time.perf_counter() - request_started_at,
|
|
len(analysis.requested_workflow_node_paths),
|
|
len(analysis.added_workflow_node_paths),
|
|
)
|
|
return web.json_response(
|
|
{
|
|
"requested_node_ids": analysis.requested_node_ids,
|
|
"requested_workflow_node_paths": analysis.requested_workflow_node_paths,
|
|
"current_remote_node_ids": analysis.current_remote_node_ids,
|
|
"current_remote_workflow_node_paths": (
|
|
analysis.current_remote_workflow_node_paths
|
|
),
|
|
"resolved_remote_node_ids": analysis.resolved_remote_node_ids,
|
|
"resolved_workflow_node_paths": analysis.resolved_workflow_node_paths,
|
|
"added_node_ids": analysis.added_node_ids,
|
|
"added_workflow_node_paths": analysis.added_workflow_node_paths,
|
|
"sandwiched_local_node_ids": analysis.sandwiched_local_node_ids,
|
|
"reasons": [
|
|
{
|
|
"node_id": reason.node_id,
|
|
"class_type": reason.class_type,
|
|
"required_by_node_id": reason.required_by_node_id,
|
|
"required_by_class_type": reason.required_by_class_type,
|
|
"output_index": reason.output_index,
|
|
"io_type": reason.io_type,
|
|
}
|
|
for reason in analysis.reasons
|
|
],
|
|
}
|
|
)
|
|
except (TypeError, ValueError) as exc:
|
|
logger.warning("Modal remote-node analysis request was invalid: %s", exc)
|
|
return web.json_response({"error": str(exc), "node_errors": []}, status=400)
|
|
|
|
@prompt_server.routes.post(delete_caches_route_path)
|
|
async def modal_delete_caches(request: web.Request) -> web.Response:
|
|
"""Delete persistent Modal cache Dicts for the active configuration."""
|
|
del request
|
|
logger.info("Received Modal cache deletion request.")
|
|
try:
|
|
return web.json_response(await delete_modal_cache_dicts(resolved_settings))
|
|
except RuntimeError as exc:
|
|
logger.warning("Modal cache deletion request failed: %s", exc)
|
|
return web.json_response({"error": str(exc), "node_errors": []}, status=400)
|
|
|
|
@prompt_server.routes.post(delete_volume_route_path)
|
|
async def modal_delete_volume(request: web.Request) -> web.Response:
|
|
"""Delete the configured Modal Volume for the active configuration."""
|
|
del request
|
|
logger.info("Received Modal volume deletion request.")
|
|
try:
|
|
return web.json_response(await delete_modal_volume(resolved_settings))
|
|
except RuntimeError as exc:
|
|
logger.warning("Modal volume deletion request failed: %s", exc)
|
|
return web.json_response({"error": str(exc), "node_errors": []}, status=400)
|
|
|
|
@prompt_server.routes.post(resolved_settings.route_path)
|
|
async def modal_queue_prompt(request: web.Request) -> web.Response:
|
|
"""Handle prompt queue requests that include Modal remote markers."""
|
|
logger.info("Received Modal queue request.")
|
|
json_data: dict[str, Any] | None = None
|
|
workflow: dict[str, Any] | None = None
|
|
remote_node_ids: list[str] = []
|
|
request_modal_gpu: str | None = None
|
|
summary = RewriteSummary()
|
|
preparation_prompt_id: str | None = None
|
|
preparation_cancellation = threading.Event()
|
|
configurator_node_id: str | None = None
|
|
r2_writeback_prompt_id: str | None = None
|
|
prompt_queued = False
|
|
try:
|
|
request_started_at = time.perf_counter()
|
|
json_data = await request.json()
|
|
json_data.setdefault("prompt_id", str(uuid.uuid4()))
|
|
json_data.setdefault("extra_data", {})
|
|
json_data["extra_data"]["prompt_id"] = json_data["prompt_id"]
|
|
if json_data.get("client_id") is not None:
|
|
json_data["extra_data"]["client_id"] = json_data["client_id"]
|
|
client_id = (
|
|
str(json_data.get("client_id")) if json_data.get("client_id") else None
|
|
)
|
|
prompt_id = (
|
|
str(json_data.get("prompt_id")) if json_data.get("prompt_id") else None
|
|
)
|
|
extra_pnginfo = (json_data.get("extra_data") or {}).get(
|
|
"extra_pnginfo"
|
|
) or {}
|
|
workflow = extra_pnginfo.get("workflow")
|
|
remote_node_ids = sorted(
|
|
requested_remote_node_ids(
|
|
prompt=json_data.get("prompt", {}),
|
|
workflow=workflow,
|
|
settings=resolved_settings,
|
|
)
|
|
)
|
|
configurator_node_id = _remote_execution_configurator_node_id(
|
|
json_data.get("prompt", {})
|
|
)
|
|
if "prompt" in json_data and not remote_node_ids:
|
|
logger.info(
|
|
"No workflow nodes are marked for Modal execution; forwarding prompt without Modal status or rewrite."
|
|
)
|
|
response = await _queue_prompt_json(prompt_server, json_data)
|
|
logger.info(
|
|
"Modal queue request completed in %.3fs.",
|
|
time.perf_counter() - request_started_at,
|
|
)
|
|
return response
|
|
|
|
if prompt_id is not None:
|
|
begin_r2_writeback_prompt(prompt_id)
|
|
r2_writeback_prompt_id = prompt_id
|
|
|
|
if prompt_id is not None:
|
|
extra_pnginfo[MODAL_PROMPT_ID_EXTRA_PNGINFO_KEY] = prompt_id
|
|
json_data["extra_data"]["extra_pnginfo"] = extra_pnginfo
|
|
logger.debug(
|
|
"Attached prompt-scoped Modal execution metadata prompt_id=%s.",
|
|
prompt_id,
|
|
)
|
|
if _set_remote_preparation(
|
|
prompt_server,
|
|
prompt_id=prompt_id,
|
|
prompt=json_data.get("prompt", {}),
|
|
extra_data=json_data.get("extra_data", {}),
|
|
cancellation_event=preparation_cancellation,
|
|
):
|
|
preparation_prompt_id = prompt_id
|
|
|
|
try:
|
|
request_settings = settings_for_modal_gpu(
|
|
resolved_settings,
|
|
modal_gpu_from_workflow(workflow, resolved_settings.modal_gpu),
|
|
)
|
|
except ValueError as exc:
|
|
raise ModalPromptValidationError(str(exc)) from exc
|
|
logger.info(
|
|
"Resolved workflow Modal GPU selection gpu=%s prompt_id=%s.",
|
|
request_settings.modal_gpu,
|
|
prompt_id,
|
|
)
|
|
status_modal_gpu = (
|
|
None
|
|
if _prompt_uses_remote_execution_configurator(
|
|
json_data.get("prompt", {})
|
|
)
|
|
else request_settings.modal_gpu
|
|
)
|
|
request_modal_gpu = status_modal_gpu
|
|
|
|
def emit_setup_status(
|
|
message: str,
|
|
current: int | None = None,
|
|
total: int | None = None,
|
|
) -> None:
|
|
"""Forward one queue-time Modal setup update into the websocket stream."""
|
|
if preparation_cancellation.is_set():
|
|
raise SyncCancelledError(
|
|
"Remote workflow preparation was cancelled."
|
|
)
|
|
_emit_modal_status(
|
|
prompt_server=prompt_server,
|
|
phase="setup",
|
|
client_id=client_id,
|
|
prompt_id=prompt_id,
|
|
node_ids=remote_node_ids,
|
|
configurator_node_id=configurator_node_id,
|
|
modal_gpu=status_modal_gpu,
|
|
component_node_ids_by_representative=(
|
|
summary.component_node_ids_by_representative or None
|
|
),
|
|
status_message=message,
|
|
status_current=current,
|
|
status_total=total,
|
|
)
|
|
|
|
def emit_execution_plan(
|
|
assignments: dict[str, dict[str, Any]],
|
|
configurations: list[dict[str, Any]],
|
|
) -> None:
|
|
"""Publish scheduler choices before capacity acquisition can block."""
|
|
component_nodes = {
|
|
component_id: list(assignment.get("node_ids", []))
|
|
for component_id, assignment in assignments.items()
|
|
}
|
|
_emit_modal_status(
|
|
prompt_server=prompt_server,
|
|
phase="setup",
|
|
client_id=client_id,
|
|
prompt_id=prompt_id,
|
|
node_ids=remote_node_ids,
|
|
configurator_node_id=configurator_node_id,
|
|
modal_gpu=status_modal_gpu,
|
|
component_node_ids_by_representative=component_nodes,
|
|
status_message="Remote execution plan ready",
|
|
remote_execution_assignments=assignments,
|
|
remote_execution_configurations=configurations,
|
|
)
|
|
|
|
def emit_environment_setup_status(
|
|
environment_id: str,
|
|
message: str,
|
|
current: int | None = None,
|
|
total: int | None = None,
|
|
) -> None:
|
|
"""Publish setup progress for one concrete remote environment."""
|
|
if preparation_cancellation.is_set():
|
|
raise SyncCancelledError(
|
|
"Remote workflow preparation was cancelled."
|
|
)
|
|
_emit_modal_status(
|
|
prompt_server=prompt_server,
|
|
phase="setup",
|
|
client_id=client_id,
|
|
prompt_id=prompt_id,
|
|
node_ids=remote_node_ids,
|
|
configurator_node_id=configurator_node_id,
|
|
modal_gpu=status_modal_gpu,
|
|
component_node_ids_by_representative=(
|
|
summary.component_node_ids_by_representative or None
|
|
),
|
|
status_message=message,
|
|
status_current=current,
|
|
status_total=total,
|
|
execution_environment_id=environment_id,
|
|
)
|
|
|
|
if "prompt" in json_data:
|
|
emit_setup_status("Preparing remote workflow")
|
|
rewrite_started_at = time.perf_counter()
|
|
occupied_environment_ids = _queued_ssh_environment_ids(
|
|
prompt_server,
|
|
excluding_prompt_id=prompt_id,
|
|
)
|
|
if occupied_environment_ids:
|
|
logger.info(
|
|
"Queued workflow may reuse SSH capacity after earlier "
|
|
"prompts finish environments=%s.",
|
|
sorted(occupied_environment_ids),
|
|
)
|
|
rewritten_prompt, summary = await rewrite_prompt_for_modal_async(
|
|
prompt=json_data["prompt"],
|
|
workflow=workflow,
|
|
sync_engine=resolved_sync_engine,
|
|
settings=request_settings,
|
|
extra_data=json_data.get("extra_data"),
|
|
status_callback=emit_setup_status,
|
|
environment_status_callback=emit_environment_setup_status,
|
|
plan_callback=emit_execution_plan,
|
|
cancellation_check=preparation_cancellation.is_set,
|
|
occupied_environment_ids=occupied_environment_ids,
|
|
)
|
|
selected_modal_gpus = _selected_modal_gpus(
|
|
summary,
|
|
request_settings.modal_gpu,
|
|
)
|
|
status_modal_gpu = (
|
|
selected_modal_gpus[0]
|
|
if len(selected_modal_gpus) == 1
|
|
else None
|
|
)
|
|
request_modal_gpu = status_modal_gpu
|
|
logger.info(
|
|
"Modal prompt rewrite finished in %.3fs for %d remote nodes across %d components.",
|
|
time.perf_counter() - rewrite_started_at,
|
|
len(summary.remote_node_ids),
|
|
len(summary.remote_component_ids),
|
|
)
|
|
remote_node_ids = list(summary.remote_node_ids)
|
|
json_data["prompt"] = rewritten_prompt
|
|
if json_data.get("partial_execution_targets"):
|
|
rewritten_targets = {
|
|
summary.rewritten_node_id_map.get(str(target), str(target))
|
|
for target in json_data["partial_execution_targets"]
|
|
}
|
|
json_data["partial_execution_targets"] = sorted(rewritten_targets)
|
|
json_data.setdefault("extra_data", {}).setdefault("modal", {})
|
|
json_data["extra_data"]["modal"]["gpu"] = request_settings.modal_gpu
|
|
json_data["extra_data"]["modal"][
|
|
"remote_node_ids"
|
|
] = summary.remote_node_ids
|
|
json_data["extra_data"]["modal"][
|
|
"remote_component_ids"
|
|
] = summary.remote_component_ids
|
|
json_data["extra_data"]["modal"][
|
|
"component_dependency_ids_by_representative"
|
|
] = summary.component_dependency_ids_by_representative
|
|
json_data["extra_data"]["modal"][
|
|
"component_execution_stages"
|
|
] = summary.component_execution_stages
|
|
json_data["extra_data"]["modal"][
|
|
"mapped_component_ids"
|
|
] = summary.mapped_component_ids
|
|
json_data["extra_data"]["modal"][
|
|
"estimated_max_parallel_requests"
|
|
] = summary.estimated_max_parallel_requests
|
|
json_data["extra_data"]["modal"][
|
|
"max_parallel_requests_upper_bound"
|
|
] = summary.max_parallel_requests_upper_bound
|
|
json_data["extra_data"]["modal"]["synced_assets"] = [
|
|
asset.remote_path for asset in summary.synced_assets
|
|
]
|
|
json_data["extra_data"]["remote_execution"] = {
|
|
"assignments": _execution_assignments_payload(
|
|
summary,
|
|
request_settings,
|
|
),
|
|
"configurations": list(summary.remote_configurations),
|
|
}
|
|
if summary.custom_nodes_bundle is not None:
|
|
json_data["extra_data"]["modal"][
|
|
"custom_nodes_bundle"
|
|
] = summary.custom_nodes_bundle.remote_path
|
|
_emit_modal_status(
|
|
prompt_server=prompt_server,
|
|
phase="setup",
|
|
client_id=client_id,
|
|
prompt_id=prompt_id,
|
|
node_ids=remote_node_ids,
|
|
configurator_node_id=configurator_node_id,
|
|
modal_gpu=status_modal_gpu,
|
|
component_node_ids_by_representative=summary.component_node_ids_by_representative,
|
|
status_message="Submitting remote workflow",
|
|
)
|
|
response = await _queue_prompt_json(
|
|
prompt_server,
|
|
json_data,
|
|
modal_response_payload=(
|
|
{
|
|
"modal_gpu": status_modal_gpu,
|
|
"remote_execution_configurator_node_id": configurator_node_id,
|
|
"remote_execution_modal_gpus": selected_modal_gpus,
|
|
"modal_remote_node_ids": list(summary.remote_node_ids),
|
|
"modal_sandwiched_local_node_ids": list(
|
|
summary.sandwiched_local_node_ids
|
|
),
|
|
"modal_parallel_local_branch_node_ids": list(
|
|
summary.parallel_local_branch_node_ids
|
|
),
|
|
"remote_execution_assignments": _execution_assignments_payload(
|
|
summary,
|
|
request_settings,
|
|
),
|
|
"remote_execution_configurations": list(
|
|
summary.remote_configurations
|
|
),
|
|
"modal_components": [
|
|
{
|
|
"representative_node_id": representative_node_id,
|
|
"node_ids": list(component_node_ids),
|
|
}
|
|
for representative_node_id, component_node_ids in sorted(
|
|
summary.component_node_ids_by_representative.items()
|
|
)
|
|
],
|
|
}
|
|
if summary.remote_node_ids
|
|
else None
|
|
),
|
|
)
|
|
prompt_queued = response.status < 400
|
|
logger.info(
|
|
"Modal queue request completed in %.3fs.",
|
|
time.perf_counter() - request_started_at,
|
|
)
|
|
return response
|
|
except SyncCancelledError as exc:
|
|
logger.info("Remote workflow preparation cancelled: %s", exc)
|
|
if json_data is not None:
|
|
_emit_modal_status(
|
|
prompt_server=prompt_server,
|
|
phase="execution_interrupted",
|
|
client_id=str(json_data.get("client_id"))
|
|
if json_data.get("client_id")
|
|
else None,
|
|
prompt_id=str(json_data.get("prompt_id"))
|
|
if json_data.get("prompt_id")
|
|
else None,
|
|
node_ids=remote_node_ids,
|
|
configurator_node_id=configurator_node_id,
|
|
modal_gpu=request_modal_gpu,
|
|
status_message=str(exc),
|
|
)
|
|
return web.json_response(
|
|
{"error": str(exc), "node_errors": [], "cancelled": True},
|
|
status=409,
|
|
)
|
|
except FileNotFoundError as exc:
|
|
logger.exception("Modal asset sync failed.")
|
|
if json_data is not None:
|
|
_emit_modal_status(
|
|
prompt_server=prompt_server,
|
|
phase="error",
|
|
client_id=str(json_data.get("client_id"))
|
|
if json_data.get("client_id")
|
|
else None,
|
|
prompt_id=str(json_data.get("prompt_id"))
|
|
if json_data.get("prompt_id")
|
|
else None,
|
|
node_ids=remote_node_ids,
|
|
configurator_node_id=configurator_node_id,
|
|
modal_gpu=request_modal_gpu,
|
|
error_message=str(exc),
|
|
)
|
|
return web.json_response({"error": str(exc), "node_errors": []}, status=400)
|
|
except ModalPromptValidationError as exc:
|
|
logger.exception("Modal prompt validation failed.")
|
|
if json_data is not None:
|
|
_emit_modal_status(
|
|
prompt_server=prompt_server,
|
|
phase="error",
|
|
client_id=str(json_data.get("client_id"))
|
|
if json_data.get("client_id")
|
|
else None,
|
|
prompt_id=str(json_data.get("prompt_id"))
|
|
if json_data.get("prompt_id")
|
|
else None,
|
|
node_ids=remote_node_ids,
|
|
configurator_node_id=configurator_node_id,
|
|
modal_gpu=request_modal_gpu,
|
|
error_message=str(exc),
|
|
)
|
|
return web.json_response({"error": str(exc), "node_errors": []}, status=400)
|
|
except Exception as exc:
|
|
logger.exception("Modal queue handler failed.")
|
|
if json_data is not None:
|
|
_emit_modal_status(
|
|
prompt_server=prompt_server,
|
|
phase="error",
|
|
client_id=str(json_data.get("client_id"))
|
|
if json_data.get("client_id")
|
|
else None,
|
|
prompt_id=str(json_data.get("prompt_id"))
|
|
if json_data.get("prompt_id")
|
|
else None,
|
|
node_ids=remote_node_ids,
|
|
configurator_node_id=configurator_node_id,
|
|
modal_gpu=request_modal_gpu,
|
|
error_message=str(exc),
|
|
)
|
|
return web.json_response({"error": str(exc), "node_errors": []}, status=500)
|
|
finally:
|
|
if preparation_prompt_id is not None:
|
|
_clear_remote_preparation(prompt_server, preparation_prompt_id)
|
|
if r2_writeback_prompt_id is not None and not prompt_queued:
|
|
finish_r2_writeback_prompt(r2_writeback_prompt_id)
|
|
|
|
_ROUTE_REGISTERED = True
|
|
logger.info(
|
|
"Registered Modal queue route at %s, analysis route at %s, progress state route at %s, container status route at %s, cache deletion route at %s, and volume deletion route at %s",
|
|
resolved_settings.route_path,
|
|
analysis_route_path,
|
|
progress_state_route_path,
|
|
container_status_route_path,
|
|
delete_caches_route_path,
|
|
delete_volume_route_path,
|
|
)
|