Files
ComfyAssets-ComfyUI_PromptM…/py/model_gate.py
T
Vito Sansevero 489d449ec8 feat(autotag): scored suggestions with a configurable count behind a shared model gate
- py/model_gate.py: one process-wide, non-blocking gate for loading,
  swapping, running, downloading and unloading a tagging model. Worker
  calls run shielded; a release requested while one still runs takes
  effect only when it exits (client disconnect keeps the gate).
- Legacy /autotag/single, /start, /download/{type} and /unload hold the
  gate and answer 409 {code: "model-busy"} while another operation holds
  it; their bodies are otherwise unchanged. /autotag/models gains
  suggestion_limit.
- AutoTagService.generate_tag_suggestions: WD14 (name, probability) best
  first, GGUF/HF (name, None) in output order, first `limit` distinct
  case-insensitively; generate_tags keeps its return; the image file is
  closed before inference returns.
- POST /prompt_manager/image-annotations/suggest runs one engine on the
  pinned capture, with strict per-engine options, never downloads
  (409 model-unavailable) and never writes.
- config.json autotag.defaults.suggestion_limit, integer 1..100, default
  10, validated on load.
- Capture probe decides Generate from the image header alone, so prompt
  metadata over the inspection budgets no longer blocks it.
2026-10-22 09:12:01 -07:00

149 lines
4.7 KiB
Python

"""One process-wide, non-blocking gate around the tagging models (#249 B2).
Loading, swapping, running, downloading and unloading a tagging model all
happen under this gate, whether the caller is a legacy Auto-tag route, the
Tag dialog's Generate or (later) a bulk job. A caller that cannot take the
gate is refused at once (``409 model-busy``), never queued.
Work that runs in a worker thread is admitted with :meth:`ModelGate.run`. A
release requested while such work still runs (a client disconnect cancels
the waiting coroutine, not the thread) takes effect only when the last
admitted call really exits, so nobody can swap the model under a running
inference.
"""
from __future__ import annotations
import asyncio
import threading
import uuid
from typing import Any, Callable, Optional
class GateNotHeld(RuntimeError):
"""Raised when work is admitted for an owner that does not hold the gate."""
class ModelGate:
"""A single-owner gate whose release waits for admitted worker calls."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._holder: Optional[str] = None
self._inflight = 0
self._releasing = False
def try_acquire(self, owner: str) -> bool:
"""Take the gate for *owner*; False at once when anyone holds it."""
with self._lock:
if self._holder is not None:
return False
self._holder = owner
self._inflight = 0
self._releasing = False
return True
def release(self, owner: str) -> None:
"""Let go; deferred until admitted work of *owner* has exited."""
with self._lock:
if self._holder != owner:
return
if self._inflight:
self._releasing = True
else:
self._clear_locked()
def holder(self) -> Optional[str]:
with self._lock:
return self._holder
def begin(self, owner: str) -> None:
"""Admit one worker call; the holder must still hold the gate."""
with self._lock:
if self._holder != owner or self._releasing:
raise GateNotHeld(f"{owner} does not hold the model gate")
self._inflight += 1
def end(self, owner: str) -> None:
"""One admitted call exited; completes a deferred release."""
with self._lock:
self._end_locked(owner)
async def run(self, owner: str, func: Callable[..., Any], *args, **kwargs):
"""Run ``func`` in the default executor while *owner* holds the gate.
The executor future is shielded: cancelling the caller never cancels
a call that has not started yet (which would leave it admitted
forever) and never ends the admission of a call that is running.
"""
self.begin(owner)
state = {"started": False, "finished": False}
def finish() -> None:
with self._lock:
if not state["finished"]:
state["finished"] = True
self._end_locked(owner)
def work():
with self._lock:
state["started"] = True
try:
return func(*args, **kwargs)
finally:
finish()
def never_ran(future) -> None:
# Only a loop shutdown cancels the shielded future; a call that
# never started must still give its admission back.
if future.cancelled():
with self._lock:
skipped = not state["started"] and not state["finished"]
if skipped:
state["finished"] = True
self._end_locked(owner)
try:
future = asyncio.get_running_loop().run_in_executor(None, work)
except BaseException:
finish()
raise
future.add_done_callback(never_ran)
return await asyncio.shield(future)
def _end_locked(self, owner: str) -> None:
if self._holder != owner or self._inflight == 0:
return
self._inflight -= 1
if self._releasing and self._inflight == 0:
self._clear_locked()
def _clear_locked(self) -> None:
self._holder = None
self._inflight = 0
self._releasing = False
_GATE = ModelGate()
def new_owner(kind: str) -> str:
"""A unique owner name such as ``legacy-single:<uuid>``."""
return f"{kind}:{uuid.uuid4()}"
def try_acquire(owner: str) -> bool:
return _GATE.try_acquire(owner)
def release(owner: str) -> None:
_GATE.release(owner)
def holder() -> Optional[str]:
return _GATE.holder()
async def run(owner: str, func: Callable[..., Any], *args, **kwargs):
return await _GATE.run(owner, func, *args, **kwargs)