* feat: add Runpod serverless image collector pools * fix: prevent worker settings clipping and remove preview copy * fix: initialize worker settings before mounting the form * fix: validate complete serverless requests against 10 MB limit * fix: simplify serverless cards and keep status dots circular * fix: match serverless endpoint box to master card * fix: preserve serverless capacity and recovery across job failures Retain durable reservations until provider termination or explicit recovery, protect unresolved pools and credentials, and add scoped reconciliation. Consolidate lifecycle and secret policies and split prompt/media stages with regression coverage. * feat: add dynamic per-image list queue with Start and End nodes * fix(image-list): reject upstream data bypasses around Start * fix(image-list): make optional End text a connection-only input * fix(image-list): isolate unavailable workers during preflight * fix(image-list): bound acknowledgements and configure run deadline * fix(media-sync): preserve output and temp source roots * fix(image-list): own job lifecycle and preserve worker preparation * feat(image-list): let master participate unless orchestrator-only * feat(image-list)!: replace fixed Start extras with growable value_N slots Distributed Image List Start no longer has reference_image, text, strength, seed or per-slot mode dropdowns. It now takes images, mask and up to 10 generic value_N inputs (IMAGE, MASK, STRING, INT, FLOAT, BOOLEAN) that grow via native V3 Autogrow, each with a matching output. - One value is shared by every image; one per image pairs in order; any other count fails before dispatch. Only images is split into frames. - Outputs are declared up front (image, mask, item_index, context, value_1..value_10). web/imageListStart.js hides unused value outputs, mirrors input types, and follows Autogrow compaction so downstream links keep their value. - Image-list protocol bumped to v2; older workers fail preflight. Breaking: saved workflows using the old Start inputs must recreate Start. * fix(serverless): disable Clear saved API key while the worker is enabled The checkbox read "Clear saved API key (disable pool first)", but the panel never defines "pool" and saving with it ticked on an enabled worker was rejected by the backend. - Disable the checkbox while the worker is enabled, with a tooltip saying to turn the worker off first. Also disable it when no key is saved. - Use "worker" instead of "pool" in user-facing serverless errors, the serverless guide and the README entry. * feat(serverless): replace Concurrent jobs with a single Images per run setting - Remove the separate Concurrent jobs field; rename Jobs per run to Images per run (1-16) with a tooltip explaining that Runpod's max workers decides how many GPUs run them. - Backend uses a fixed per-worker limit of 16 outstanding jobs, so all images of a run are submitted at once. Unresolved jobs still count against it. The limit is constant, so it never has to change while jobs are unresolved. - Legacy 'concurrency' in saved configs and older browser tabs is accepted and ignored/dropped; it no longer triggers the busy guard. - Revert the enable toggle and show the reason when the backend rejects it. - Trim a pasted api.runpod.ai/v2/<id> URL to the endpoint ID; plainer endpoint-ID error otherwise. - Confirm before a type change deletes a saved Runpod API key. - Update docs and tests.
185 lines
9.3 KiB
Python
185 lines
9.3 KiB
Python
"""A queue-free Comfy graph slot, only while the synchronous outer End is blocked.
|
|
|
|
Do NOT use PromptExecutor.execute[_async] here. Its prompt lifecycle clears the
|
|
process interrupt, resets progress handlers, changes RAM-cache callbacks and
|
|
starts/ends a model tracker. Those belong to the suspended parent executor.
|
|
Instead use core's ExecutionList/execute (including lazy/async/expanded nodes)
|
|
with private provider-free caches and a private server facade. Model outputs are
|
|
registered with the existing parent tracker, whose lifetime includes this work.
|
|
|
|
Core progress and GraphBuilder defaults are process-global. Borrow them only
|
|
inside the owned execution thread, restore them in finally, and join that thread
|
|
before End returns. Cancellation never clears or raises the global interrupt:
|
|
it stops locally at node/progress boundaries. A non-cooperative synchronous node
|
|
must finish before cleanup can return; abandoning its thread is not safe.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
import threading
|
|
from types import SimpleNamespace
|
|
|
|
from .runtime import ImageListCancelledError
|
|
|
|
|
|
_EXECUTION_LOCK = threading.Lock()
|
|
|
|
|
|
class LocalExecutionSlot:
|
|
def __init__(self, *, model_callback=None):
|
|
self._pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="image-list-master")
|
|
self._stop = threading.Event()
|
|
self._future = None
|
|
self._caches = None
|
|
self._model_callback = model_callback
|
|
self._closed = False
|
|
self._loop = None
|
|
self._task = None
|
|
self._state_lock = threading.Lock()
|
|
|
|
def submit(self, prompt, prompt_id, end_id, workflow_meta=None):
|
|
if self._closed or self._stop.is_set():
|
|
raise ImageListCancelledError("Local image-list slot has stopped")
|
|
if self._future is not None and not self._future.done():
|
|
raise RuntimeError("Local image-list slot already has an in-flight item")
|
|
self._future = self._pool.submit(self._run, prompt, prompt_id, end_id, workflow_meta)
|
|
return self._future
|
|
|
|
def request_stop(self):
|
|
self._stop.set()
|
|
# Wakes a graph waiting on an async node as well as stopping synchronous
|
|
# nodes at their next progress/between-node checkpoint.
|
|
with self._state_lock:
|
|
if self._loop is not None and self._task is not None:
|
|
self._loop.call_soon_threadsafe(self._task.cancel)
|
|
|
|
async def close(self):
|
|
self.request_stop()
|
|
try:
|
|
if self._future is not None:
|
|
# Do not cancel this Future: cancelling an asyncio wrapper cannot
|
|
# kill its running thread, and falsely makes cleanup look done.
|
|
waiter = asyncio.wrap_future(self._future)
|
|
while not waiter.done():
|
|
try:
|
|
await asyncio.shield(waiter)
|
|
except asyncio.CancelledError:
|
|
continue
|
|
except Exception:
|
|
break # The scheduler propagates execution errors.
|
|
if waiter.done() and not waiter.cancelled():
|
|
waiter.exception()
|
|
finally:
|
|
self._pool.shutdown(wait=True, cancel_futures=True)
|
|
self._caches = None
|
|
self._closed = True
|
|
|
|
def _run(self, prompt, prompt_id, end_id, workflow_meta):
|
|
# The normal Comfy prompt worker is single-threaded and blocked in End.
|
|
# Fail closed if another local slot is nevertheless executing, rather
|
|
# than interleave writes to core's process-global execution state.
|
|
if not _EXECUTION_LOCK.acquire(blocking=False):
|
|
raise RuntimeError("Another local image-list executor is already active")
|
|
try:
|
|
asyncio.run(self._execute(prompt, prompt_id, end_id, workflow_meta))
|
|
except asyncio.CancelledError as exc:
|
|
raise ImageListCancelledError("Local image-list execution cancelled") from exc
|
|
finally:
|
|
with self._state_lock:
|
|
self._loop = self._task = None
|
|
_EXECUTION_LOCK.release()
|
|
|
|
def _check_stopped(self):
|
|
import comfy.model_management
|
|
if self._stop.is_set() or comfy.model_management.processing_interrupted():
|
|
raise ImageListCancelledError("Local image-list execution interrupted")
|
|
|
|
async def _execute(self, prompt, prompt_id, end_id, workflow_meta):
|
|
import execution
|
|
import torch
|
|
import comfy.model_management
|
|
import comfy.utils
|
|
from comfy_execution import progress
|
|
from comfy_execution.graph_utils import GraphBuilder
|
|
|
|
self._check_stopped()
|
|
with self._state_lock:
|
|
self._loop, self._task = asyncio.get_running_loop(), asyncio.current_task()
|
|
dynamic_prompt = execution.DynamicPrompt(prompt)
|
|
private_progress = progress.ProgressRegistry(prompt_id, dynamic_prompt)
|
|
parent_progress = progress.global_progress_registry
|
|
parent_hook = comfy.utils.PROGRESS_BAR_HOOK
|
|
prefix_keys = ("_default_prefix_root", "_default_prefix_call_index", "_default_prefix_graph_index")
|
|
parent_prefix = {key: getattr(GraphBuilder, key) for key in prefix_keys}
|
|
progress.global_progress_registry = private_progress
|
|
|
|
def progress_hook(value, total, preview=None, prompt_id=None, node_id=None):
|
|
self._check_stopped()
|
|
# Never route a child progress/preview event through the parent's
|
|
# client or reset its handlers. Keep the private progress accurate.
|
|
if node_id is not None:
|
|
private_progress.update_progress(node_id, value, total, preview)
|
|
|
|
comfy.utils.PROGRESS_BAR_HOOK = progress_hook
|
|
try:
|
|
with torch.inference_mode():
|
|
if self._caches is None:
|
|
outputs = execution.HierarchicalCache(execution.CacheKeySetInputSignature)
|
|
objects = execution.HierarchicalCache(execution.CacheKeySetID)
|
|
self._caches = SimpleNamespace(outputs=outputs, objects=objects, all=[outputs, objects])
|
|
caches = self._caches
|
|
changed = execution.IsChangedCache(prompt_id, dynamic_prompt, caches.outputs)
|
|
for cache in caches.all:
|
|
await cache.set_prompt(dynamic_prompt, prompt.keys(), changed)
|
|
cache.clean_unused()
|
|
execution_list = execution.ExecutionList(dynamic_prompt, caches.outputs, self._model_callback)
|
|
execution_list.add_node(end_id)
|
|
server = SimpleNamespace(client_id=None, last_node_id=None, send_sync=lambda *a, **kw: None)
|
|
assets = execution.NoAssets(execution.args)
|
|
extra = {"extra_pnginfo": {"workflow": workflow_meta}} if workflow_meta is not None else {}
|
|
executed, pending_subgraphs, pending_async, ui = set(), {}, {}, {}
|
|
while not execution_list.is_empty():
|
|
self._check_stopped()
|
|
node_id, error, exc = await execution_list.stage_node_execution()
|
|
if error is not None:
|
|
raise RuntimeError(f"Local image-list graph failed: {error}") from exc
|
|
self._check_stopped()
|
|
result, error, exc = await execution.execute(
|
|
server, dynamic_prompt, caches, node_id, extra, executed, prompt_id,
|
|
execution_list, pending_subgraphs, pending_async, ui, assets)
|
|
if result == execution.ExecutionResult.FAILURE:
|
|
if isinstance(exc, (comfy.model_management.InterruptProcessingException,
|
|
ImageListCancelledError)):
|
|
raise ImageListCancelledError("Local image-list execution interrupted") from exc
|
|
raise RuntimeError(f"Local image-list node {node_id} failed: {error}") from exc
|
|
if result == execution.ExecutionResult.PENDING:
|
|
execution_list.unstage_node_execution()
|
|
else:
|
|
execution_list.complete_node_execution()
|
|
self._check_stopped()
|
|
finally:
|
|
# Core execute creates async-node and unblock tasks. This is a private
|
|
# event loop, so every other task is owned here. Drain/cancel them
|
|
# before restoring globals, including when the graph failed early.
|
|
with self._state_lock:
|
|
self._task = None # Stop requests must not interrupt this drain.
|
|
try:
|
|
while True:
|
|
pending = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()]
|
|
if not pending:
|
|
break
|
|
for task in pending:
|
|
task.cancel()
|
|
drain = asyncio.gather(*pending, return_exceptions=True)
|
|
while not drain.done():
|
|
try:
|
|
await asyncio.shield(drain)
|
|
except asyncio.CancelledError:
|
|
continue
|
|
finally:
|
|
progress.global_progress_registry = parent_progress
|
|
comfy.utils.PROGRESS_BAR_HOOK = parent_hook
|
|
for key, value in parent_prefix.items():
|
|
setattr(GraphBuilder, key, value)
|