ImageGen-Studio / core /task_scheduler.py
BlueSkyXN's picture
Deploy GitHub a51e6f6df2b2d5093fd2526a7953c2ee6a422e37
8a28a8d verified
Raw
History Blame Contribute Delete
3.97 kB
"""One bounded scheduler for UI, API, and MCP generation requests."""
from __future__ import annotations
import threading
from concurrent.futures import Future, ThreadPoolExecutor
from contextlib import contextmanager
from functools import wraps
from typing import Callable, Iterator, TypeVar
from .runtime_config import CONFIG
T = TypeVar("T")
class QueueFullError(RuntimeError):
pass
class TaskCancelledError(RuntimeError):
pass
_pending_slots = threading.BoundedSemaphore(CONFIG.mcp_max_pending)
_executor = ThreadPoolExecutor(
max_workers=CONFIG.gpu_concurrency,
thread_name_prefix="imagegen-worker",
)
class _FairGenerationGate:
"""FIFO gate for the single process-global ComfyUI runtime."""
def __init__(self) -> None:
self._condition = threading.Condition()
self._next_ticket = 0
self._serving_ticket = 0
self._active = False
self._cancelled_tickets: set[int] = set()
def _skip_cancelled_locked(self) -> None:
while not self._active and self._serving_ticket in self._cancelled_tickets:
self._cancelled_tickets.remove(self._serving_ticket)
self._serving_ticket += 1
def acquire(self, cancel_event: threading.Event | None = None) -> None:
with self._condition:
ticket = self._next_ticket
self._next_ticket += 1
while True:
if cancel_event is not None and cancel_event.is_set():
self._cancelled_tickets.add(ticket)
self._skip_cancelled_locked()
self._condition.notify_all()
raise TaskCancelledError("任务已在等待 GPU 时取消。")
self._skip_cancelled_locked()
if ticket == self._serving_ticket and not self._active:
self._active = True
return
# A cancellation event does not notify the gate, so poll it at
# a low frequency. Jobs without cancellation remain fully
# condition-driven.
self._condition.wait(timeout=0.2 if cancel_event is not None else None)
def release(self) -> None:
with self._condition:
self._active = False
self._serving_ticket += 1
self._skip_cancelled_locked()
self._condition.notify_all()
_generation_gate = _FairGenerationGate()
@contextmanager
def generation_slot(cancel_event: threading.Event | None = None) -> Iterator[None]:
"""Serialize access to the shared in-process ComfyUI runtime by default."""
_generation_gate.acquire(cancel_event)
try:
yield
finally:
_generation_gate.release()
def generation_guard(function: Callable[..., T]) -> Callable[..., T]:
@wraps(function)
def wrapped(*args, **kwargs):
ui_inputs = kwargs.get("ui_inputs")
if ui_inputs is None:
ui_inputs = next(
(value for value in args if isinstance(value, dict)), None
)
cancel_event = (
ui_inputs.get("_cancel_event") if isinstance(ui_inputs, dict) else None
)
with generation_slot(cancel_event):
if cancel_event is not None and cancel_event.is_set():
raise TaskCancelledError("任务已在进入 GPU 前取消。")
return function(*args, **kwargs)
return wrapped
def submit_background(function: Callable[..., T], *args, **kwargs) -> Future[T]:
"""Submit an MCP job without creating an unbounded daemon thread."""
if not _pending_slots.acquire(blocking=False):
raise QueueFullError(
f"任务队列已满(最多 {CONFIG.mcp_max_pending} 个待处理任务),请稍后再试。"
)
def run_and_release() -> T:
try:
return function(*args, **kwargs)
finally:
_pending_slots.release()
return _executor.submit(run_and_release)