feat: add dynamic per-image list queue with Start and End nodes

This commit is contained in:
Robert Wojciechowski
2026-10-11 08:49:23 +00:00
parent 87c0cda9f6
commit 3e280a0e29
19 changed files with 2367 additions and 17 deletions
+1
View File
@@ -4,3 +4,4 @@ from . import tunnel_routes # noqa: F401
from . import worker_routes # noqa: F401
from . import job_routes # noqa: F401
from . import usdu_routes # noqa: F401
from . import image_list_routes # noqa: F401
+89
View File
@@ -0,0 +1,89 @@
"""Bounded, typed HTTP transport for dynamic image-list worker tasks."""
import json
from aiohttp import web
import server
from ..image_list.runtime import (
MAX_PAYLOAD_BYTES,
decode_result,
delete_worker_item,
set_master_result,
store_worker_item,
)
prompt_server = server.PromptServer.instance
MAX_REQUEST_BYTES = MAX_PAYLOAD_BYTES * 2 + 1024 * 1024
async def _json_body(request):
length = request.content_length
if length is not None and length > MAX_REQUEST_BYTES:
raise ValueError(f"Request exceeds the {MAX_REQUEST_BYTES}-byte image-list limit")
raw = bytearray()
async for chunk in request.content.iter_chunked(64 * 1024):
raw.extend(chunk)
if len(raw) > MAX_REQUEST_BYTES:
raise ValueError(f"Request exceeds the {MAX_REQUEST_BYTES}-byte image-list limit")
try:
return json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValueError(f"Invalid image-list JSON body: {exc}") from exc
@prompt_server.routes.get("/distributed/image-list/capabilities")
async def image_list_capabilities_endpoint(_request):
return web.json_response({
"supported": True,
"protocol_version": 1,
"node_ids": ["DistributedImageListStart", "DistributedImageListEnd"],
"transport": "typed-float-tensor-json-v1",
})
@prompt_server.routes.post("/distributed/image-list/item")
async def image_list_item_endpoint(request):
try:
data = await _json_body(request)
if not isinstance(data, dict):
raise ValueError("Expected an image-list item object")
context_id = data.get("context_id")
if not isinstance(context_id, str) or not context_id:
raise ValueError("context_id must be a non-empty string")
item = data.get("item")
store_worker_item(data.get("token"), item)
return web.json_response({"status": "stored", "item_index": item["index"]})
except (ValueError, TypeError) as exc:
return web.json_response({"error": str(exc)}, status=400)
except Exception as exc:
return web.json_response({"error": str(exc)}, status=500)
@prompt_server.routes.delete("/distributed/image-list/item/{token}")
async def image_list_delete_item_endpoint(request):
token = request.match_info.get("token", "")
return web.json_response({"status": "deleted", "removed": delete_worker_item(token)})
@prompt_server.routes.post("/distributed/image-list/result")
async def image_list_result_endpoint(request):
try:
data = await _json_body(request)
if not isinstance(data, dict):
raise ValueError("Expected an image-list result object")
context_id = data.get("context_id")
token = data.get("token")
item_index = data.get("item_index")
if not isinstance(context_id, str) or not context_id:
raise ValueError("context_id must be a non-empty string")
if not isinstance(token, str) or not token:
raise ValueError("token must be a non-empty string")
if isinstance(item_index, bool) or not isinstance(item_index, int) or item_index < 0:
raise ValueError("item_index must be a non-negative integer")
result = decode_result(data.get("result"))
set_master_result(context_id, item_index, token, result)
return web.json_response({"status": "accepted", "item_index": item_index})
except (ValueError, TypeError) as exc:
return web.json_response({"error": str(exc)}, status=400)
except Exception as exc:
return web.json_response({"error": str(exc)}, status=500)
+73 -3
View File
@@ -85,7 +85,7 @@ async def _dispatch_via_websocket(worker_url, payload, client_id, timeout=60.0):
data = json.loads(msg.data or "{}")
if data.get("type") == "dispatch_ack" and data.get("request_id") == request_id:
if data.get("ok"):
return
return data.get("prompt_id")
error_text = data.get("error") or "Worker rejected websocket dispatch."
validation_error = data.get("validation_error")
node_errors = data.get("node_errors")
@@ -107,6 +107,7 @@ async def dispatch_worker_prompt(
client_id=None,
use_websocket=False,
trace_execution_id=None,
return_prompt_id=False,
):
"""Send the prepared prompt to a worker ComfyUI instance."""
worker_url = build_worker_url(worker)
@@ -120,7 +121,7 @@ async def dispatch_worker_prompt(
if use_websocket:
try:
await _dispatch_via_websocket(
prompt_id = await _dispatch_via_websocket(
worker_url,
{
"prompt": prompt_obj,
@@ -128,7 +129,9 @@ async def dispatch_worker_prompt(
},
client_id,
)
return
if return_prompt_id and (not isinstance(prompt_id, str) or not prompt_id):
raise RuntimeError("Worker websocket acknowledgement omitted prompt_id")
return prompt_id if return_prompt_id else None
except Exception as exc:
worker_id = worker.get("id")
if trace_execution_id:
@@ -144,6 +147,73 @@ async def dispatch_worker_prompt(
timeout=aiohttp.ClientTimeout(total=60),
) as resp:
resp.raise_for_status()
if not return_prompt_id:
return None
try:
response_data = await resp.json()
except Exception as exc:
raise RuntimeError("Worker /prompt response did not contain JSON acknowledgement") from exc
prompt_id = response_data.get("prompt_id") if isinstance(response_data, dict) else None
if not isinstance(prompt_id, str) or not prompt_id:
raise RuntimeError("Worker /prompt response omitted prompt_id")
return prompt_id if return_prompt_id else None
async def preflight_image_list_workers(workers, require_idle=True, timeout=3.0):
"""Require protocol/node support and report which direct workers are idle.
This performs read-only GETs only; it never submits a prompt or contacts a
serverless provider. Unsupported worker releases fail before master inputs run.
"""
if not workers:
raise RuntimeError("Distributed Image List requires at least one selected direct worker")
session = await get_client_session()
required_nodes = {"DistributedImageListStart", "DistributedImageListEnd"}
semaphore = asyncio.Semaphore(8)
async def inspect(worker):
worker_id = str(worker.get("id") or "unknown")
async with semaphore:
cap_url = build_worker_url(worker, "/distributed/image-list/capabilities")
try:
async with session.get(cap_url, timeout=aiohttp.ClientTimeout(total=timeout)) as response:
if response.status != 200:
raise RuntimeError(
f"Image-list worker {worker_id} does not expose the capability endpoint "
f"(HTTP {response.status}); upgrade that worker before running this feature"
)
capability = await response.json()
except asyncio.TimeoutError as exc:
raise RuntimeError(f"Image-list worker {worker_id} capability check timed out") from exc
except aiohttp.ClientError as exc:
raise RuntimeError(f"Image-list worker {worker_id} capability check failed: {exc}") from exc
nodes = set(capability.get("node_ids", [])) if isinstance(capability, dict) else set()
if (not isinstance(capability, dict) or capability.get("supported") is not True
or capability.get("protocol_version") != 1 or not required_nodes.issubset(nodes)):
raise RuntimeError(
f"Image-list worker {worker_id} is incompatible: requires protocol v1 and nodes "
f"{sorted(required_nodes)}; no work was submitted"
)
queue_url = build_worker_url(worker, "/queue")
try:
async with session.get(queue_url, timeout=aiohttp.ClientTimeout(total=timeout)) as response:
if response.status != 200:
raise RuntimeError(f"Image-list worker {worker_id} queue preflight returned HTTP {response.status}")
queue = await response.json()
except asyncio.TimeoutError as exc:
raise RuntimeError(f"Image-list worker {worker_id} queue preflight timed out") from exc
except aiohttp.ClientError as exc:
raise RuntimeError(f"Image-list worker {worker_id} queue preflight failed: {exc}") from exc
if not isinstance(queue, dict) or not isinstance(queue.get("queue_running"), list) or not isinstance(queue.get("queue_pending"), list):
raise RuntimeError(f"Image-list worker {worker_id} returned malformed /queue state")
idle = not queue["queue_running"] and not queue["queue_pending"]
return worker_id, idle
statuses = await asyncio.gather(*(inspect(worker) for worker in workers))
result = {worker_id: idle for worker_id, idle in statuses}
if require_idle and not any(result.values()):
raise RuntimeError("No selected feature-compatible direct worker is idle; unrelated worker prompts were left untouched")
return {worker_id: True for worker_id, idle in result.items() if idle}
async def select_active_workers(
+53 -2
View File
@@ -441,11 +441,39 @@ def prepare_delegate_master_prompt(prompt_obj, collector_ids):
return pruned_prompt
def _resolve_direct_workers_for_image_list(config, requested_ids):
"""Resolve only enabled direct workers already selected by orchestration."""
requested = None if requested_ids is None else {str(worker_id) for worker_id in requested_ids}
direct = []
for worker in (config or {}).get("workers", []):
worker_id = str(worker.get("id") or "").strip()
if not worker_id or not worker.get("enabled", False):
continue
if requested is not None and worker_id not in requested:
continue
if str(worker.get("type") or "local").strip().lower() == "serverless":
continue
try:
port = int(worker.get("port", worker.get("listen_port", 8188)) or 8188)
except (TypeError, ValueError) as exc:
raise RuntimeError(f"Invalid port for image-list worker {worker_id}") from exc
direct.append({
"id": worker_id,
"name": str(worker.get("name") or worker_id),
"host": worker.get("host"),
"port": port,
"type": worker.get("type", "local"),
})
return direct
def generate_job_id_map(prompt_index, prefix):
"""Create stable per-node job IDs for distributed nodes."""
job_map = {}
distributed_nodes = prompt_index.nodes_for_class("DistributedCollector") + prompt_index.nodes_for_class(
"UltimateSDUpscaleDistributed"
distributed_nodes = (
prompt_index.nodes_for_class("DistributedCollector")
+ prompt_index.nodes_for_class("UltimateSDUpscaleDistributed")
+ prompt_index.nodes_for_class("DistributedImageListStart")
)
for node_id in distributed_nodes:
job_map[node_id] = f"{prefix}_{node_id}"
@@ -540,6 +568,21 @@ def _override_value_nodes(prompt_copy, prompt_index, is_master, participant_id,
inputs["worker_id"] = f"worker_{worker_index_map.get(participant_id, 0)}"
def _override_image_list_nodes(prompt_copy, prompt_index, participant_id, worker_ids,
job_id_map, master_url, workflow_meta=None):
"""Attach protocol context to Start without changing its visible V3 sockets."""
for node_id in prompt_index.nodes_for_class("DistributedImageListStart"):
node = prompt_copy.get(node_id)
if not isinstance(node, dict):
continue
inputs = node.setdefault("inputs", {})
inputs["image_list_enabled_worker_ids"] = json.dumps(list(worker_ids))
inputs["image_list_master_callback"] = str(master_url or "")
inputs["image_list_execution_id"] = job_id_map.get(node_id, node_id)
if workflow_meta is not None:
inputs["image_list_workflow_meta"] = workflow_meta
def apply_participant_overrides(
prompt_copy,
participant_id,
@@ -575,5 +618,13 @@ def apply_participant_overrides(
master_url,
enabled_json,
)
_override_image_list_nodes(
prompt_copy,
prompt_index,
participant_id,
enabled_worker_ids,
job_id_map,
master_url,
)
return prompt_copy
+58 -6
View File
@@ -29,7 +29,9 @@ from .orchestration.prompt_transform import (
generate_job_id_map,
prepare_delegate_master_prompt,
prune_prompt_for_worker,
_resolve_direct_workers_for_image_list,
)
from ..image_list.runtime import validate_image_list_regions
prompt_server = server.PromptServer.instance
@@ -246,8 +248,42 @@ async def orchestrate_distributed_execution(
media_sync_timeout_seconds,
) = _resolve_orchestration_limits(config)
requested_ids = enabled_worker_ids if enabled_worker_ids is not None else None
workers = _resolve_enabled_workers(config, requested_ids)
prompt_index = PromptIndex(prompt_obj)
image_list_start_ids = prompt_index.nodes_for_class("DistributedImageListStart")
image_list_mode = bool(image_list_start_ids)
if image_list_mode:
# Validate before probing or queueing anything so malformed boundaries and
# old worker releases fail before any upstream image is materialized.
validate_image_list_regions(prompt_obj)
other_distributed = (
prompt_index.nodes_for_class("DistributedCollector")
+ prompt_index.nodes_for_class("UltimateSDUpscaleDistributed")
)
if other_distributed:
raise ValueError(
"Distributed Image List v1 cannot be mixed with Distributed Collector or "
"Ultimate SD Upscale nodes in one prompt"
)
configured_serverless = [
str(worker.get("id")) for worker in config.get("workers", [])
if worker.get("enabled", False)
and str(worker.get("type") or "local").lower() == "serverless"
and (requested_ids is None or str(worker.get("id")) in {str(x) for x in requested_ids})
]
if configured_serverless:
trace_debug(
execution_trace_id,
"Image List v1 explicitly excludes unsupported serverless pools: "
+ ", ".join(configured_serverless),
)
workers = _resolve_direct_workers_for_image_list(config, requested_ids)
if not workers:
raise RuntimeError(
"Distributed Image List v1 requires an enabled selected direct local/remote worker; "
"serverless pools and master-only execution are unsupported"
)
else:
workers = _resolve_enabled_workers(config, requested_ids)
load_balance_requested = _prompt_requests_load_balance(prompt_index)
trace_debug(
execution_trace_id,
@@ -266,6 +302,10 @@ async def orchestrate_distributed_execution(
# Respect master delegate-only configuration
if delegate_master is None:
delegate_master = bool(config.get("settings", {}).get("master_delegate_only", False))
if image_list_mode:
# The master must execute each upstream dependency once to obtain the
# full typed lists; only the enclosed branch is delegated.
delegate_master = False
if not workers and delegate_master:
trace_debug(
@@ -281,8 +321,19 @@ async def orchestrate_distributed_execution(
trace_execution_id=execution_trace_id,
probe_concurrency=worker_probe_concurrency,
)
if image_list_mode:
from .orchestration.dispatch import preflight_image_list_workers
if not active_workers:
raise RuntimeError(
"No selected direct worker is reachable for Distributed Image List; "
"the master will not duplicate per-image processing"
)
idle_ids = await preflight_image_list_workers(active_workers, require_idle=True)
active_workers = [worker for worker in active_workers if worker["id"] in idle_ids]
if not active_workers:
raise RuntimeError("No feature-compatible direct worker is idle; no master job was queued")
if load_balance_requested:
if load_balance_requested and not image_list_mode:
candidate_workers = list(active_workers)
if not delegate_master:
# Include master in load balancing only when master participation is enabled.
@@ -385,7 +436,7 @@ async def orchestrate_distributed_execution(
+ ", ".join(f"{worker['name']} ({worker['id']})" for worker in active_workers),
)
worker_payloads = []
if active_workers:
if active_workers and not image_list_mode:
worker_prep_semaphore = asyncio.Semaphore(worker_prep_concurrency)
media_sync_semaphore = asyncio.Semaphore(media_sync_concurrency)
worker_payloads = await asyncio.gather(
@@ -416,8 +467,9 @@ async def orchestrate_distributed_execution(
manager.ensure_capacity(len(serverless_jobs))
# Do not leave collector queues behind when offline compilation/media validation fails.
for job_id in job_id_map.values():
await _ensure_distributed_queue(job_id)
if not image_list_mode:
for job_id in job_id_map.values():
await _ensure_distributed_queue(job_id)
if regular_payloads:
await asyncio.gather(
@@ -468,4 +520,4 @@ async def orchestrate_distributed_execution(
execution_trace_id,
f"Orchestration complete: prompt_id={prompt_id}, dispatched_workers={len(worker_payloads)}, delegate_master={delegate_master}",
)
return prompt_id, prompt_number, len(worker_payloads), node_errors
return prompt_id, prompt_number, (len(active_workers) if image_list_mode else len(worker_payloads)), node_errors