fix: count prompt runs when ComfyUI dequeues the prompt, not when it is submitted

The on-prompt hook runs before ComfyUI validates the request, so counting
there bumped run_count for prompts that were rejected or deleted from the
queue. The hook now only resolves the positive PromptManager texts, mints a
UUID4 prompt_id when the client sent none, and stashes the hashes under that
id (QueuedRuns, bounded to 1000 entries). PromptQueue.get is wrapped
idempotently; when a prompt is dequeued for execution its stashed runs are
counted (or remembered as pending first use). Servers without a prompt_queue
fall back to counting at submit time with one warning.

__init__ defines _global_db before the monitoring block and passes a factory
that raises RuntimeError while the database is unavailable; the package is
now imported under test with server and folder_paths stubbed.
This commit is contained in:
Vito Sansevero
2026-10-08 18:01:34 -07:00
parent 0a46420385
commit 729190309c
4 changed files with 671 additions and 42 deletions
+14 -2
View File
@@ -82,6 +82,10 @@ except Exception as e:
except Exception:
pass
# Shared database, set up with the monitoring system below. Stays None when that
# fails so the queue hook can report the database as unavailable instead of crashing.
_global_db = None
# Start image monitoring globally at module import time
# This ensures images are linked regardless of which node is used in the workflow
try:
@@ -111,13 +115,21 @@ except Exception as e:
except Exception:
print(f"[ComfyUI-PromptManager] Warning: Failed to start image monitoring: {e}")
# Count prompt runs when workflows are queued (ComfyUI skips unchanged nodes, so
def _db_factory():
"""The shared database for the queue hook; called lazily per request."""
if _global_db is None:
raise RuntimeError("PromptManager database unavailable")
return _global_db
# Count prompt runs when ComfyUI dequeues workflows (it skips unchanged nodes, so
# node execution alone misses re-runs). Separate from monitoring so either can fail.
try:
from .py.config import server_instance
from .utils.usage_tracking import register_queue_hook
register_queue_hook(server_instance, lambda: _global_db)
register_queue_hook(server_instance, _db_factory)
except Exception as e:
try:
from .utils.logging_config import get_logger
+178
View File
@@ -0,0 +1,178 @@
"""Importing the extension package wires the queue hook to the shared database.
The package is loaded the way ComfyUI loads it (``__init__`` with relative
imports) with ComfyUI's ``server`` and ``folder_paths`` modules stubbed.
"""
import importlib.util
import os
import sys
import tempfile
import types
import unittest
import unittest.mock as mock
from aiohttp import web
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
class FakePromptQueue:
def __init__(self):
self.results = []
def get(self, timeout=None):
return self.results.pop(0) if self.results else None
def fake_server_module():
"""A ``server`` module whose PromptServer.instance accepts routes and hooks."""
instance = types.SimpleNamespace(
routes=web.RouteTableDef(),
prompt_queue=FakePromptQueue(),
handlers=[],
)
instance.add_on_prompt_handler = instance.handlers.append
module = types.ModuleType("server")
module.PromptServer = type("PromptServer", (), {"instance": instance})
return module
def fake_folder_paths_module(output_dir):
module = types.ModuleType("folder_paths")
module.base_path = output_dir
module.models_dir = os.path.join(output_dir, "models")
module.get_output_directory = lambda: output_dir
module.get_folder_paths = lambda name: []
return module
def load_package(name, db_path, output_dir):
"""Import ROOT/__init__.py as package ``name`` with ComfyUI stubbed.
Returns (package, server instance). The caller stops the image monitor.
"""
saved = {key: sys.modules.get(key) for key in ("server", "folder_paths")}
server = fake_server_module()
sys.modules["server"] = server
sys.modules["folder_paths"] = fake_folder_paths_module(output_dir)
try:
from py.config import PromptManagerConfig
with mock.patch.object(PromptManagerConfig, "DEFAULT_DB_PATH", db_path):
spec = importlib.util.spec_from_file_location(
name,
os.path.join(ROOT, "__init__.py"),
submodule_search_locations=[ROOT],
)
package = importlib.util.module_from_spec(spec)
sys.modules[name] = package
try:
spec.loader.exec_module(package)
except BaseException:
sys.modules.pop(name, None)
raise
finally:
for key, module in saved.items():
if module is None:
sys.modules.pop(key, None)
else:
sys.modules[key] = module
return package, server.PromptServer.instance
class PackageTestCase(unittest.TestCase):
package_name = "prompt_manager_pkg"
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.TemporaryDirectory()
cls.output_dir = os.path.join(cls.tmp.name, "output")
os.makedirs(cls.output_dir)
cls.db_path = cls.make_db_path(cls.tmp.name)
cls.package, cls.server = load_package(
cls.package_name, cls.db_path, cls.output_dir
)
@classmethod
def make_db_path(cls, tmp):
return os.path.join(tmp, "prompts.db")
@classmethod
def tearDownClass(cls):
monitor = getattr(cls.package, "_global_image_monitor", None)
if monitor is not None:
monitor.stop_monitoring()
sys.modules.pop(cls.package_name, None)
cls.tmp.cleanup()
class TestPackageWiring(PackageTestCase):
def test_nodes_are_registered(self):
self.assertEqual(
sorted(self.package.NODE_CLASS_MAPPINGS),
["PromptManager", "PromptManagerText", "PromptSearchList"],
)
def test_db_factory_returns_the_shared_database(self):
self.assertIsNotNone(self.package._global_db)
self.assertIs(self.package._db_factory(), self.package._global_db)
self.assertEqual(
os.path.normcase(os.path.realpath(self.package._global_db.model.db_path)),
os.path.normcase(os.path.realpath(self.db_path)),
)
def test_db_factory_raises_while_the_database_is_unavailable(self):
with mock.patch.object(self.package, "_global_db", None):
with self.assertRaisesRegex(RuntimeError, "database unavailable"):
self.package._db_factory()
def test_queue_hook_is_registered_and_the_queue_get_is_wrapped(self):
self.assertEqual(len(self.server.handlers), 1)
self.assertTrue(
getattr(self.server.prompt_queue.get, "_prompt_manager_dequeue_hook", False)
)
def test_image_monitor_watches_the_stubbed_output_directory(self):
status = self.package._global_image_monitor.get_status()
self.assertTrue(status["running"])
self.assertEqual(
[
os.path.normcase(os.path.realpath(d))
for d in status["monitored_directories"]
],
[os.path.normcase(os.path.realpath(self.output_dir))],
)
class TestPackageWithoutDatabase(PackageTestCase):
"""The database failing to initialise must not break the queue hook."""
package_name = "prompt_manager_pkg_nodb"
@classmethod
def make_db_path(cls, tmp):
blocker = os.path.join(tmp, "blocker")
with open(blocker, "w", encoding="utf-8") as f:
f.write("not a directory")
return os.path.join(blocker, "prompts.db") # parent is a file: init fails
def test_global_db_is_defined_but_unset(self):
self.assertIsNone(self.package._global_db)
with self.assertRaisesRegex(RuntimeError, "database unavailable"):
self.package._db_factory()
def test_queue_hook_still_registers_and_survives_a_dequeue(self):
self.assertEqual(len(self.server.handlers), 1)
request = self.server.handlers[0](
{"prompt": {"1": {"class_type": "PromptManager", "inputs": {"text": "x"}}}}
)
self.server.prompt_queue.results.append(
((0, request["prompt_id"], request["prompt"], {}, [], False), 1)
)
self.assertIsNotNone(self.server.prompt_queue.get())
if __name__ == "__main__":
unittest.main()
+292 -9
View File
@@ -1,14 +1,18 @@
"""Run counting and node roles: only positive PromptManager nodes count and link.
Runs are counted when a prompt is queued (ComfyUI's on-prompt hook), because
ComfyUI skips unchanged nodes, so node execution can't be relied on to count.
Runs are resolved when a prompt is queued (ComfyUI's on-prompt hook, which runs
before validation) and counted when ComfyUI dequeues the prompt for execution,
because ComfyUI skips unchanged nodes, so node execution can't be relied on to
count, and a rejected or cancelled queue entry must not count either.
"""
import os
import sys
import tempfile
import threading
import unittest
import unittest.mock as mock
import uuid
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -16,7 +20,16 @@ from database.operations import PromptDatabase
from prompt_manager_base import PromptManagerBase
from utils.hashing import generate_prompt_hash
from utils.prompt_tracker import PromptTracker
from utils.usage_tracking import PendingFirstUse, handle_queued_prompt
from utils import usage_tracking
from utils.usage_tracking import (
PendingFirstUse,
QueuedRuns,
_wrap_queue_get,
count_queued_prompt,
handle_queued_prompt,
on_dequeue,
register_queue_hook,
)
LOADER = {
"class_type": "CheckpointLoaderSimple",
@@ -41,14 +54,52 @@ def workflow(positive_text="a cat", negative_text="blurry"):
}
class FakePromptQueue:
"""Stand-in for execution.PromptQueue: get() pops preset results."""
def __init__(self):
self.results = []
self.get_calls = []
def get(self, timeout=None):
self.get_calls.append(timeout)
return self.results.pop(0) if self.results else None
class FakeServer:
"""Stand-in for PromptServer: on-prompt handlers plus an optional queue."""
def __init__(self, with_queue=True):
self.handlers = []
if with_queue:
self.prompt_queue = FakePromptQueue()
def add_on_prompt_handler(self, handler):
self.handlers.append(handler)
def on_prompt(self, json_data):
for handler in self.handlers:
json_data = handler(json_data)
return json_data
def queue_item(prompt_id, graph):
"""What PromptQueue.get returns for a prompt that passed validation."""
return ((0, prompt_id, graph, {}, [], False), 1)
class DbTestCase(unittest.TestCase):
def setUp(self):
fd, self.path = tempfile.mkstemp(suffix=".db")
os.close(fd)
self.db = PromptDatabase(self.path)
self.pending = PendingFirstUse()
self.queued = QueuedRuns()
def tearDown(self):
close = getattr(self.db, "close", None)
if callable(close):
close()
for suffix in ("", "-wal", "-shm"):
if os.path.exists(self.path + suffix):
os.unlink(self.path + suffix)
@@ -60,9 +111,12 @@ class DbTestCase(unittest.TestCase):
return self.db.get_prompt_by_id(prompt_id)["run_count"]
def _queue(self, graph):
return handle_queued_prompt(
{"prompt": graph, "client_id": "x"}, self.db, self.pending
"""Queue a graph and let ComfyUI dequeue it for execution."""
request = handle_queued_prompt({"prompt": graph, "client_id": "x"}, self.queued)
on_dequeue(
request.get("prompt_id", ""), self.queued, lambda: self.db, self.pending
)
return request
class TestQueueHook(DbTestCase):
@@ -121,14 +175,243 @@ class TestQueueHook(DbTestCase):
self._queue(graph)
self.assertEqual(self._runs(positive), 0)
def test_returns_the_request_unchanged_and_never_raises(self):
def test_returns_the_request_and_never_raises(self):
request = {"prompt": workflow(), "client_id": "x", "extra_data": {}}
self.assertIs(handle_queued_prompt(request, self.db, self.pending), request)
self.assertIs(handle_queued_prompt(request, self.queued), request)
for bad in (None, {}, {"prompt": "x"}, {"prompt": None}, []):
self.assertIs(handle_queued_prompt(bad, self.db, self.pending), bad)
self.assertIs(handle_queued_prompt(bad, self.queued), bad)
broken_db = mock.Mock()
broken_db.get_prompt_by_hash.side_effect = RuntimeError("db locked")
self.assertIs(handle_queued_prompt(request, broken_db, self.pending), request)
with self.assertLogs("prompt_manager.usage_tracking", level="WARNING"):
on_dequeue(
request["prompt_id"], self.queued, lambda: broken_db, self.pending
)
def test_graph_without_prompt_manager_nodes_is_not_stashed_or_tagged(self):
request = {"prompt": {"20": LOADER}}
self.assertIs(handle_queued_prompt(request, self.queued), request)
self.assertNotIn("prompt_id", request)
self.assertEqual(len(self.queued), 0)
class TestCountAtDequeue(DbTestCase):
"""End to end through the registered hook and the wrapped queue."""
def setUp(self):
super().setUp()
self.server = FakeServer()
self._register(self.server)
def _register(self, server):
with mock.patch.object(usage_tracking, "_hook_registered", threading.Event()):
return register_queue_hook(
server, lambda: self.db, pending=self.pending, queued=self.queued
)
def _submit(self, graph, prompt_id=None):
request = {"prompt": graph, "client_id": "x"}
if prompt_id is not None:
request["prompt_id"] = prompt_id
return self.server.on_prompt(request)
def _dequeue(self, request):
self.server.prompt_queue.results.append(
queue_item(request["prompt_id"], request["prompt"])
)
return self.server.prompt_queue.get(timeout=1.0)
def test_hook_adds_a_lowercase_uuid4_prompt_id_when_absent(self):
request = self._submit(workflow())
prompt_id = request["prompt_id"]
self.assertEqual(str(uuid.UUID(prompt_id, version=4)), prompt_id)
self.assertEqual(prompt_id, prompt_id.lower())
def test_hook_keeps_an_existing_prompt_id(self):
request = self._submit(workflow(), prompt_id="client-chosen")
self.assertEqual(request["prompt_id"], "client-chosen")
def test_hook_alone_does_not_count(self):
positive = self._save("a cat")
self._submit(workflow())
self.assertEqual(self._runs(positive), 0)
def test_dequeue_counts_exactly_once(self):
positive = self._save("a cat")
request = self._submit(workflow())
result = self._dequeue(request)
self.assertEqual(result[0][1], request["prompt_id"])
self.assertEqual(self.server.prompt_queue.get_calls, [1.0])
self.assertEqual(self._runs(positive), 1)
self._dequeue(request) # a second dequeue of the same id has nothing left
self.assertEqual(self._runs(positive), 1)
def test_two_queued_prompts_are_each_counted_once(self):
positive = self._save("a cat")
first = self._submit(workflow())
second = self._submit(workflow())
self.assertNotEqual(first["prompt_id"], second["prompt_id"])
self._dequeue(first)
self.assertEqual(self._runs(positive), 1)
self._dequeue(second)
self.assertEqual(self._runs(positive), 2)
def test_rejected_prompt_is_never_counted(self):
positive = self._save("a cat")
self._submit(workflow()) # validation fails: never dequeued
other = self._submit(workflow())
self._dequeue(other)
self.assertEqual(self._runs(positive), 1)
def test_get_returning_none_is_a_noop(self):
positive = self._save("a cat")
self._submit(workflow())
self.assertIsNone(self.server.prompt_queue.get(timeout=0.1))
self.assertEqual(self._runs(positive), 0)
def test_unknown_text_is_pending_only_after_dequeue(self):
prompt_hash = generate_prompt_hash("never saved")
request = self._submit(workflow(positive_text="never saved"))
self.assertEqual(self.pending.consume(prompt_hash), 0)
self._dequeue(request)
self.assertEqual(self.pending.consume(prompt_hash), 1)
def test_shorter_and_longer_queue_items_are_tolerated(self):
positive = self._save("a cat")
request = self._submit(workflow())
self.server.prompt_queue.results.append(
((0, request["prompt_id"], request["prompt"], {}, []), 1)
)
self.server.prompt_queue.get()
self.assertEqual(self._runs(positive), 1)
request = self._submit(workflow())
self.server.prompt_queue.results.append(
((0, request["prompt_id"], request["prompt"], {}, [], False, "x"), 2)
)
self.server.prompt_queue.get()
self.assertEqual(self._runs(positive), 2)
def test_malformed_queue_item_is_logged_and_returned(self):
item = (("not", "a", "tuple"),)
self.server.prompt_queue.results.append(item)
self.server.prompt_queue.results.append("garbage")
with self.assertLogs("prompt_manager.usage_tracking", level="WARNING"):
self.assertEqual(self.server.prompt_queue.get(), item)
self.assertEqual(self.server.prompt_queue.get(), "garbage")
def test_db_factory_raising_at_dequeue_is_logged_not_raised(self):
def broken():
raise RuntimeError("PromptManager database unavailable")
queued = QueuedRuns()
server = FakeServer()
with mock.patch.object(usage_tracking, "_hook_registered", threading.Event()):
register_queue_hook(server, broken, pending=self.pending, queued=queued)
request = server.on_prompt({"prompt": workflow()})
server.prompt_queue.results.append(queue_item(request["prompt_id"], {}))
with self.assertLogs("prompt_manager.usage_tracking", level="WARNING") as logs:
self.assertIsNotNone(server.prompt_queue.get())
self.assertIn("database unavailable", "\n".join(logs.output))
def test_server_without_prompt_queue_counts_at_queue_time_and_warns_once(self):
positive = self._save("a cat")
server = FakeServer(with_queue=False)
with self.assertLogs("prompt_manager.usage_tracking", level="WARNING") as logs:
self.assertTrue(self._register(server))
warnings = [line for line in logs.output if line.startswith("WARNING")]
self.assertEqual(len(warnings), 1)
server.on_prompt({"prompt": workflow()})
self.assertEqual(self._runs(positive), 1)
server.on_prompt({"prompt": workflow(positive_text="new text")})
self.assertEqual(self.pending.consume(generate_prompt_hash("new text")), 1)
def test_server_whose_queue_has_no_get_uses_legacy_counting(self):
positive = self._save("a cat")
server = FakeServer(with_queue=False)
server.prompt_queue = object()
with self.assertLogs("prompt_manager.usage_tracking", level="WARNING"):
self._register(server)
server.on_prompt({"prompt": workflow()})
self.assertEqual(self._runs(positive), 1)
def test_legacy_counting_calls_db_factory_inside_the_guard(self):
def broken():
raise RuntimeError("PromptManager database unavailable")
request = {"prompt": workflow()}
with self.assertLogs("prompt_manager.usage_tracking", level="WARNING"):
self.assertIs(count_queued_prompt(request, broken, self.pending), request)
def test_wrapping_twice_does_not_double_count(self):
positive = self._save("a cat")
prompt_queue = self.server.prompt_queue
wrapped_get = prompt_queue.get
calls = []
self.assertFalse(_wrap_queue_get(prompt_queue, calls.append))
self.assertIs(prompt_queue.get, wrapped_get)
request = self._submit(workflow())
self._dequeue(request)
self.assertEqual(self._runs(positive), 1)
self.assertEqual(calls, [])
def test_wrap_rejects_queues_without_a_callable_get(self):
self.assertFalse(_wrap_queue_get(object(), lambda prompt_id: None))
self.assertFalse(_wrap_queue_get(None, lambda prompt_id: None))
def test_wrapper_reports_the_dequeued_prompt_id(self):
prompt_queue = FakePromptQueue()
seen = []
self.assertTrue(_wrap_queue_get(prompt_queue, seen.append))
prompt_queue.results.append(queue_item("abc", {}))
self.assertEqual(prompt_queue.get(timeout=2.5), queue_item("abc", {}))
self.assertEqual(prompt_queue.get_calls, [2.5])
self.assertEqual(seen, ["abc"])
def test_dequeue_callback_errors_are_logged_not_raised(self):
prompt_queue = FakePromptQueue()
def explode(prompt_id):
raise ValueError("boom")
_wrap_queue_get(prompt_queue, explode)
prompt_queue.results.append(queue_item("abc", {}))
with self.assertLogs("prompt_manager.usage_tracking", level="WARNING"):
self.assertIsNotNone(prompt_queue.get())
class TestQueuedRuns(unittest.TestCase):
def test_pop_returns_runs_once(self):
queued = QueuedRuns()
queued.put("p1", [("h1", "text one")])
self.assertEqual(queued.pop("p1"), [("h1", "text one")])
self.assertEqual(queued.pop("p1"), [])
self.assertEqual(queued.pop("missing"), [])
def test_bound_evicts_oldest(self):
queued = QueuedRuns(max_entries=3)
for i in range(4):
queued.put(f"p{i}", [(f"h{i}", f"text {i}")])
self.assertEqual(len(queued), 3)
self.assertEqual(queued.pop("p0"), [])
self.assertEqual(queued.pop("p3"), [("h3", "text 3")])
def test_default_bound_is_one_thousand(self):
queued = QueuedRuns()
for i in range(1001):
queued.put(str(i), [("h", "t")])
self.assertEqual(len(queued), 1000)
self.assertEqual(queued.pop("0"), [])
self.assertEqual(queued.pop("1000"), [("h", "t")])
def test_requeue_of_the_same_id_replaces_the_runs(self):
queued = QueuedRuns()
queued.put("p1", [("h1", "one")])
queued.put("p1", [("h2", "two")])
self.assertEqual(queued.pop("p1"), [("h2", "two")])
def test_empty_runs_are_not_stored(self):
queued = QueuedRuns()
queued.put("p1", [])
self.assertEqual(len(queued), 0)
class TestPendingFirstUse(unittest.TestCase):
+187 -31
View File
@@ -1,19 +1,30 @@
"""Count prompt runs when a workflow is queued, not when nodes execute.
"""Count prompt runs when ComfyUI dequeues a workflow, not when nodes execute.
ComfyUI skips nodes whose inputs are unchanged, so a PromptManager node does not
execute when the same prompt is re-run. The on-prompt hook sees every queued
workflow instead: it counts a run for each PromptManager node feeding a sampler's
positive input. Prompts not yet in the database are remembered as pending and
counted by the node when it first saves them.
execute when the same prompt is re-run. Instead:
1. The on-prompt hook sees every submitted workflow before validation. It resolves
the text of each PromptManager node feeding a sampler's positive input and
stashes the hashes under the request's prompt_id (minting one when the client
sent none; ComfyUI keeps a client-supplied id).
2. ``PromptQueue.get`` is wrapped. When ComfyUI dequeues a prompt for execution,
its stashed hashes are counted: one run per prompt already in the database,
otherwise a pending first use that the node consumes when it first saves it.
A prompt that fails validation or is deleted from the queue is never dequeued, so
it never counts. Servers without a ``prompt_queue`` fall back to counting at
submit time.
Text from known pure string nodes is resolved at queue time too. Anything else
(PromptSearchList batches, unknown nodes) is counted by the node as it executes.
"""
import functools
import threading
import time
import uuid
from collections import OrderedDict
from typing import Any, Tuple
from typing import Any, Callable, List, Optional, Tuple
try:
from .hashing import generate_prompt_hash
@@ -28,13 +39,16 @@ logger = get_logger("prompt_manager.usage_tracking")
PENDING_TTL_SECONDS = 3600 # a queued prompt that never executes is forgotten
PENDING_MAX_ENTRIES = 1000
QUEUED_MAX_ENTRIES = 1000 # prompts submitted but not yet dequeued
Run = Tuple[str, str] # (prompt hash, resolved text)
class PendingFirstUse:
"""Queued runs of positive prompts that were not in the database yet.
"""Dequeued runs of positive prompts that were not in the database yet.
Counts per hash: the same new prompt can be queued several times before its
first job runs, and later jobs may reuse ComfyUI's cached node.
Counts per hash: the same new prompt can be run several times before its
first job saves it, and later jobs may reuse ComfyUI's cached node.
"""
def __init__(
@@ -61,47 +75,189 @@ class PendingFirstUse:
return count if count and time.monotonic() - added <= self._ttl else 0
class QueuedRuns:
"""Runs resolved at submit time, keyed by prompt_id until ComfyUI dequeues them.
Bounded: a prompt rejected by validation or deleted from the queue is never
dequeued, so the oldest entries are evicted once the bound is reached.
"""
def __init__(self, max_entries: int = QUEUED_MAX_ENTRIES):
self._max = max_entries
self._entries: OrderedDict[str, List[Run]] = OrderedDict()
self._lock = threading.Lock()
def put(self, prompt_id: str, runs: List[Run]) -> None:
if not runs:
return
with self._lock:
self._entries.pop(prompt_id, None)
self._entries[prompt_id] = list(runs)
while len(self._entries) > self._max:
self._entries.popitem(last=False)
def pop(self, prompt_id: str) -> List[Run]:
with self._lock:
return self._entries.pop(prompt_id, [])
def __len__(self) -> int:
with self._lock:
return len(self._entries)
# Shared between the queue hook and the nodes
PENDING_FIRST_USE = PendingFirstUse()
QUEUED_RUNS = QueuedRuns()
def handle_queued_prompt(json_data: Any, db: Any, pending: PendingFirstUse) -> Any:
"""ComfyUI on-prompt handler body: count runs, always return the request as-is."""
def queued_prompt_runs(graph: Any) -> List[Run]:
"""(hash, text) of each positive PromptManager node whose text is knowable.
Each hash once: the same text in two positive nodes is one run of the prompt.
"""
runs: List[Run] = []
seen = set()
for node_id in run_prompt_nodes(graph):
text = resolve_text(graph, node_id)
if not text:
continue
prompt_hash = generate_prompt_hash(text)
if prompt_hash in seen:
continue
seen.add(prompt_hash)
runs.append((prompt_hash, text))
return runs
def count_runs(runs: List[Run], db: Any, pending: PendingFirstUse) -> None:
"""Record one use per prompt in the database; remember the rest for first save."""
for prompt_hash, _text in runs:
existing = db.get_prompt_by_hash(prompt_hash)
if existing:
db.record_prompt_use(existing["id"])
else:
pending.add(prompt_hash)
def handle_queued_prompt(json_data: Any, queued: QueuedRuns = QUEUED_RUNS) -> Any:
"""ComfyUI on-prompt handler body: stash the runs, always return the request.
Adds a canonical UUID4 ``prompt_id`` when the client sent none, so the stash
can be matched when ComfyUI dequeues the prompt. The database is not touched:
the request has not been validated yet.
"""
try:
graph = json_data.get("prompt") if isinstance(json_data, dict) else None
counted = set()
for node_id in run_prompt_nodes(graph):
text = resolve_text(graph, node_id)
if not text:
continue
prompt_hash = generate_prompt_hash(text)
if prompt_hash in counted:
continue
counted.add(prompt_hash)
existing = db.get_prompt_by_hash(prompt_hash)
if existing:
db.record_prompt_use(existing["id"])
else:
pending.add(prompt_hash)
runs = queued_prompt_runs(graph)
if not runs:
return json_data
prompt_id = json_data.get("prompt_id")
if prompt_id is None:
prompt_id = str(uuid.uuid4())
json_data["prompt_id"] = prompt_id
queued.put(str(prompt_id), runs)
except Exception as e:
logger.warning(f"Could not resolve queued prompt runs: {e}")
return json_data
def on_dequeue(
prompt_id: str,
queued: QueuedRuns,
db_factory: Callable[[], Any],
pending: PendingFirstUse,
) -> None:
"""Count the runs stashed for a prompt ComfyUI is about to execute."""
try:
runs = queued.pop(str(prompt_id))
if runs:
count_runs(runs, db_factory(), pending)
except Exception as e:
logger.warning(f"Could not count runs of dequeued prompt {prompt_id}: {e}")
def count_queued_prompt(
json_data: Any, db_factory: Callable[[], Any], pending: PendingFirstUse
) -> Any:
"""Legacy on-prompt handler body: count at submit time (no prompt_queue)."""
try:
graph = json_data.get("prompt") if isinstance(json_data, dict) else None
runs = queued_prompt_runs(graph)
if runs:
count_runs(runs, db_factory(), pending)
except Exception as e:
logger.warning(f"Could not count queued prompt runs: {e}")
return json_data
_WRAPPED_MARKER = "_prompt_manager_dequeue_hook"
def _wrap_queue_get(prompt_queue: Any, on_dequeued: Callable[[str], None]) -> bool:
"""Wrap ``prompt_queue.get`` so each dequeued prompt_id is reported once.
The wrapper forwards every argument and result unchanged. Idempotent: a queue
whose ``get`` is already wrapped is left alone.
"""
original = getattr(prompt_queue, "get", None)
if not callable(original):
return False
if getattr(original, _WRAPPED_MARKER, False):
return False
@functools.wraps(original)
def get(*args, **kwargs):
result = original(*args, **kwargs)
if result is not None:
try:
on_dequeued(str(result[0][1]))
except Exception as e:
logger.warning(f"Dequeue hook failed: {e}")
return result
setattr(get, _WRAPPED_MARKER, True)
prompt_queue.get = get
return True
_hook_registered = threading.Event()
_register_lock = threading.Lock()
def register_queue_hook(
server: Any, db_factory: Any, pending: PendingFirstUse = PENDING_FIRST_USE
server: Any,
db_factory: Callable[[], Any],
pending: PendingFirstUse = PENDING_FIRST_USE,
queued: Optional[QueuedRuns] = None,
) -> bool:
"""Register the on-prompt handler once; db_factory is called lazily per request."""
"""Register the on-prompt handler once and wrap the prompt queue's ``get``.
``db_factory`` is called lazily, inside the guarded handlers, so a database
that is unavailable never raises into ComfyUI. Without a usable
``server.prompt_queue`` runs are counted at submit time instead.
"""
if queued is None:
queued = QUEUED_RUNS
with _register_lock:
if _hook_registered.is_set():
return False
server.add_on_prompt_handler(
lambda json_data: handle_queued_prompt(json_data, db_factory(), pending)
)
prompt_queue = getattr(server, "prompt_queue", None)
if callable(getattr(prompt_queue, "get", None)):
server.add_on_prompt_handler(
lambda json_data: handle_queued_prompt(json_data, queued)
)
_wrap_queue_get(
prompt_queue,
lambda prompt_id: on_dequeue(prompt_id, queued, db_factory, pending),
)
logger.info("Registered prompt usage hook (counting at dequeue)")
else:
logger.warning(
"PromptServer has no prompt_queue; counting prompt runs at queue "
"time, so rejected prompts may be counted"
)
server.add_on_prompt_handler(
lambda json_data: count_queued_prompt(json_data, db_factory, pending)
)
_hook_registered.set()
logger.info("Registered prompt usage hook")
return True