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
+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):