Files
ComfyAssets-ComfyUI_PromptM…/tests/test_api_review_events.py
T
Vito Sansevero afe3733b69 fix(review): coalesce queue change events and answer queue thumbnails
- The change stream sends the first notice at once and collapses the
  notices of the next 0.4 s into one trailing change, so a busy scan costs
  an open page one re-read per 0.4 s at most (COALESCE_SECONDS).
- Queue items answer thumbnail_url, the same URL the Images listing builds
  (one shared helper, media_thumbnail_url), or null without a thumbnail.
- The "duplicate and refused decision send nothing" test spies on
  notify_review_change instead of reading the next event.
2026-10-22 22:46:02 -07:00

343 lines
13 KiB
Python

"""Needs review change notices (#249 PR F2).
``GET /prompt_manager/review/events`` is a server-sent event stream: one
``ready`` event when it opens, then one ``change`` event after every
committed queue change (an item recorded by the scan or the monitor, a
spelling refresh, a decision from any tab) and after a restore. An open
Needs review page re-reads its queue on ``change``; nothing polls. A refused
decision and a duplicate recording change nothing and send nothing.
"""
import asyncio
import json
import sqlite3
import threading
try:
from tests.test_api_review import ReviewRoutesTestCase
except ImportError: # discovered with tests/ as the top-level directory
from test_api_review import ReviewRoutesTestCase
try: # guarded so a missing module fails assertions, not test loading
from database import review_changes
except ImportError:
review_changes = None
try:
from py.api import review_events
except ImportError:
review_events = None
EVENTS_URL = "/prompt_manager/review/events"
# A safety bound for one read; every wait below ends on an event first.
READ_SECONDS = 10
class ReviewEventsTestCase(ReviewRoutesTestCase):
def _require_events(self):
self.assertIsNotNone(review_changes, "database.review_changes is missing")
self.assertIsNotNone(review_events, "py.api.review_events is missing")
async def _stream(self):
resp = await self.client.get(EVENTS_URL)
self.assertEqual(resp.status, 200)
self.assertEqual(resp.headers["Content-Type"], "text/event-stream")
self.assertEqual(resp.headers["Cache-Control"], "no-store")
self.addAsyncCleanup(self._close, resp)
self.assertEqual(await self._event(resp), "ready")
return resp
@staticmethod
async def _close(resp):
resp.close()
async def _event(self, resp):
"""The next event's name (keepalive comments are skipped)."""
return (await self._event_with_data(resp))[0]
async def _event_with_data(self, resp):
"""The next event's name and its parsed ``data`` line."""
name, data = None, None
while True:
line = await asyncio.wait_for(resp.content.readline(), READ_SECONDS)
self.assertTrue(line, "the stream ended")
text = line.decode("utf-8").rstrip("\n")
if text.startswith("event: "):
name = text[len("event: ") :]
elif text.startswith("data: "):
data = json.loads(text[len("data: ") :])
elif text == "" and name is not None:
return name, data
async def _ready_version(self):
resp = await self.client.get(EVENTS_URL)
self.assertEqual(resp.status, 200)
self.addAsyncCleanup(self._close, resp)
name, data = await self._event_with_data(resp)
self.assertEqual(name, "ready")
return data
async def _record_in_thread(self, path):
"""Record as the monitor does: from a worker thread, not the loop."""
self._roots()
outcomes = []
thread = threading.Thread(
target=lambda: outcomes.append(self.api.record_review_file(str(path)))
)
thread.start()
await asyncio.get_running_loop().run_in_executor(None, thread.join)
return outcomes[0]
class TestReviewEvents(ReviewEventsTestCase):
async def test_monitor_recording_sends_one_change(self):
self._require_events()
stream = await self._stream()
self.assertEqual(await self._record_in_thread(self._ambiguous()), "recorded")
self.assertEqual(await self._event(stream), "change")
async def test_duplicate_recording_and_refused_decision_send_nothing(self):
# A spy on the notice itself: a wrong notice would otherwise pass as
# the next real change's event.
self._require_events()
from unittest.mock import patch
from database import review as review_db
from database import review_decisions
calls = []
original = review_changes.notify_review_change
def spy(db_path):
calls.append(db_path)
original(db_path)
path = self._ambiguous("two.png")
self._record(path)
with (
patch.object(review_db, "notify_review_change", spy),
patch.object(review_decisions, "notify_review_change", spy),
):
# The same bytes again: nothing changes.
self.assertEqual(await self._record_in_thread(path), "duplicate")
self.assertEqual(calls, [], "a duplicate recording sent a notice")
item, detail, observed = await self._open()
# A stale revision: refused with zero writes.
body = {
**self._body(detail, observed, "unresolved"),
"expected_revision": 9,
}
status, data = await self._decide(item["id"], body)
self.assertEqual(status, 409, data)
self.assertEqual(calls, [], "a refused decision sent a notice")
self._record(self._ambiguous("other.png", color=(0, 0, 9)))
self.assertEqual(len(calls), 1, "a real recording sends one notice")
self.assertEqual(len(self._items()), 2)
async def test_a_burst_of_changes_coalesces_into_one_more_event(self):
# M9: a busy scan records file after file; an open page hears the
# first at once and the rest as one trailing ``change``.
self._require_events()
from unittest.mock import patch
path = self.api.db.model.db_path
with (
patch.object(review_events, "COALESCE_SECONDS", 0.3),
patch.object(review_events, "HEARTBEAT_SECONDS", 0.6),
):
stream = await self._stream()
review_changes.notify_review_change(path)
self.assertEqual(await self._event(stream), "change")
for _ in range(5):
review_changes.notify_review_change(path)
await asyncio.sleep(0.02)
# Every change event up to the next keepalive: exactly one.
changes = 0
while True:
line = await asyncio.wait_for(stream.content.readline(), READ_SECONDS)
self.assertTrue(line, "the stream ended")
if line == b": keepalive\n":
break
changes += line == b"event: change\n"
self.assertEqual(changes, 1)
async def test_decision_from_any_tab_sends_a_change(self):
self._require_events()
self._record(self._ambiguous())
stream = await self._stream()
item, detail, observed = await self._open()
status, data = await self._decide(
item["id"], self._body(detail, observed, "unresolved")
)
self.assertEqual(status, 200, data)
self.assertEqual(await self._event(stream), "change")
async def test_restore_sends_a_change(self):
self._require_events()
stream = await self._stream()
from database.execution_history import notify_history_restore
notify_history_restore(self.api.db.model.db_path)
self.assertEqual(await self._event(stream), "change")
async def test_scan_recording_sends_a_change(self):
self._require_events()
self._roots()
self._ambiguous("sub/two.png")
stream = await self._stream()
job = await self._scan("all")
self.assertEqual(job["scope"]["review_items"], 1)
self.assertEqual(await self._event(stream), "change")
async def test_ready_carries_the_version_a_list_read_answers(self):
# F3 (no double read per visit): the page skips the re-read on
# ``ready`` when its first read already answered this version.
self._require_events()
self._record(self._ambiguous())
page = await self._list()
self.assertIsInstance(page.get("review_revision"), int, page)
version = await self._ready_version()
self.assertEqual(
version,
{
"library_epoch": page["library_epoch"],
"review_revision": page["review_revision"],
},
)
async def test_a_queue_change_moves_the_version_ready_carries(self):
self._require_events()
before = await self._list()
self._record(self._ambiguous())
after = await self._list()
self.assertIn("review_revision", after)
self.assertGreater(after["review_revision"], before["review_revision"])
version = await self._ready_version()
self.assertEqual(version["review_revision"], after["review_revision"])
self.assertNotEqual(version["review_revision"], before["review_revision"])
async def test_ready_names_no_version_when_it_cannot_be_read(self):
# The page then re-reads, as it did before versions existed.
self._require_events()
def broken():
raise sqlite3.DatabaseError("locked")
self.api.db.review_queue_version = broken
self.assertEqual(await self._ready_version(), {})
async def test_a_stream_counts_as_open_once_it_has_sent_ready(self):
# The browser fixture waits on this count before it changes the
# queue, so a change after it is a ``change`` event and never part of
# the version ``ready`` named.
self._require_events()
reading, release = asyncio.Event(), asyncio.Event()
original = self.api._queue_version
async def held():
reading.set()
await release.wait()
return await original()
self.api._queue_version = held
opening = asyncio.ensure_future(self._ready_version())
await asyncio.wait_for(reading.wait(), READ_SECONDS)
self.assertEqual(self.api.review_stream_count(), 0)
release.set()
await asyncio.wait_for(opening, READ_SECONDS)
self.assertEqual(self.api.review_stream_count(), 1)
async def test_change_events_carry_no_data(self):
self._require_events()
stream = await self._stream()
self._record(self._ambiguous())
self.assertEqual(await self._event_with_data(stream), ("change", {}))
async def test_closing_the_stream_unsubscribes(self):
self._require_events()
path = self.api.db.model.db_path
stream = await self._stream()
self.assertEqual(review_changes.observer_count(path), 1)
self.assertEqual(self.api.review_stream_count(), 1)
stream.close()
# The server notices the closed socket at its next write: a change.
self._record(self._ambiguous())
for _ in range(100):
if not self.api.review_stream_count():
break
await asyncio.sleep(0.02)
self.assertEqual(self.api.review_stream_count(), 0)
self.assertEqual(review_changes.observer_count(path), 0)
async def test_shutdown_ends_every_stream(self):
self._require_events()
stream = await self._stream()
await self.api.close_review_events(None)
line = await asyncio.wait_for(stream.content.read(), READ_SECONDS)
self.assertEqual(line, b"")
self.assertEqual(self.api.review_stream_count(), 0)
# A stream opened after shutdown ends at once.
resp = await self.client.get(EVENTS_URL)
self.assertEqual(resp.status, 503)
resp.close()
async def test_keepalive_comment_is_skipped_by_clients(self):
self._require_events()
with self.patch_heartbeat(0.05):
stream = await self._stream()
line = await asyncio.wait_for(stream.content.readline(), READ_SECONDS)
self.assertEqual(line, b": keepalive\n")
def patch_heartbeat(self, seconds):
from unittest.mock import patch
return patch.object(review_events, "HEARTBEAT_SECONDS", seconds)
class TestReviewChangeRegistry(ReviewEventsTestCase):
def test_a_failing_observer_never_fails_a_write(self):
self._require_events()
path = self.api.db.model.db_path
seen = []
def broken():
raise RuntimeError("observer failed")
unsubscribe_broken = review_changes.subscribe_review_changes(path, broken)
unsubscribe = review_changes.subscribe_review_changes(
path, lambda: seen.append(1)
)
try:
with self.assertLogs(
"prompt_manager.database.review_changes", level="WARNING"
):
self._record(self._ambiguous())
self.assertEqual(seen, [1])
finally:
unsubscribe_broken()
unsubscribe()
self.assertEqual(review_changes.observer_count(path), 0)
def test_notice_comes_after_the_commit(self):
self._require_events()
path = self.api.db.model.db_path
seen = []
def committed():
# A separate connection sees only committed rows.
other = sqlite3.connect(path)
try:
seen.append(
other.execute("SELECT COUNT(*) FROM media_review_items").fetchone()[
0
]
)
finally:
other.close()
unsubscribe = review_changes.subscribe_review_changes(path, committed)
try:
self._record(self._ambiguous())
finally:
unsubscribe()
self.assertEqual(seen, [1])