From ed8b271b4e42469a8fbc0baca574b8f2675db493 Mon Sep 17 00:00:00 2001 From: Polish Hammer Date: Sat, 1 Aug 2026 14:57:02 +0200 Subject: [PATCH 1/2] Gate librarian cog on a full ping round-trip, not a bare GET The librarian health check was a plain GET to '/', which only proved Flask was listening - not that the service could actually take a query, run it through its internal queue+worker, and answer back. So the cog could load against a librarian whose worker was wedged or that couldn't reach the bot on the return leg. Replace it with a ping that travels the SAME path a real search does, on both sides: bot: QueryControl -> OUT_COMM_Q -> scan_queue -> awaiting_q librarian: POST /ping -> librarian_queue -> worker pulls it off (no Crossref/DOI search) -> pongs back with the same uuid bot: /conjurer -> incoming_q -> scan_incoming matches uuid, wakes waiter The cog enables only when that whole loop closes within 3s. This also proves the librarian->bot return path, which a GET never did. Safety: uuid is random per ping; the wait and POST are both bounded so startup can't stall; a pong that finds no waiter is dropped (never orphaned into IN_COMM_Q, which would make the cog post a bogus 'no results' message); and a ping whose pong never returns is swept out of awaiting_q after PING_TTL_SECONDS so nothing leaks. All awaiting_q writes stay within scan_queue (append) and scan_incoming (remove) - no locks, no cross-thread mutation. Integration tests cover: OK round-trip, timeout when accepted-but-no-pong, unreachable, non-200, orphan-pong-dropped, and that real results still reach IN_COMM_Q. Suite: 24 integration + 41 unit green. Co-Authored-By: Claude Opus 4.8 --- bot.py | 54 +++++++--- communication_subroutine.py | 83 ++++++++++++++++ conjurer_librarian/conjurer_librarian.py | 48 ++++++++- constants.py | 3 + tests/integration/test_librarian_ping.py | 121 +++++++++++++++++++++++ 5 files changed, 295 insertions(+), 14 deletions(-) create mode 100644 tests/integration/test_librarian_ping.py diff --git a/bot.py b/bot.py index 76dacbe..ddccace 100755 --- a/bot.py +++ b/bot.py @@ -30,17 +30,23 @@ import discord import requests from discord.ext import commands -from communication_subroutine import comm_subroutine +from communication_subroutine import comm_subroutine, librarian_ping from constants import ( ENCODING, FILE_SERVICE_ADDRESS, GET_MP3, + LIBRARIAN_PING, LIBRARIAN_SERVICE_ADDRESS, LOGFILE, RADIO_SERVICE_ADDRESS, TOKEN, + service_headers, ) +# Round-trip health check budget for the librarian ping (bot -> librarian +# internal queue -> pong back). Deliberately short so startup never stalls. +LIBRARIAN_PING_TIMEOUT = 3.0 + logger = logging.getLogger("discord") logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") @@ -110,9 +116,11 @@ SERVICE_EXTENSION_GROUPS = { "health_url": f"{RADIO_SERVICE_ADDRESS}/ping", "extensions": ["radio_commands"], }, - # librarian: DOI / Crossref search + # librarian: DOI / Crossref search. NOTE: this one is NOT a plain GET - it + # is gated on a full ping round-trip (see _load_service_groups); the URL + # here is only the label used in the "unreachable" log line. "librarian": { - "health_url": f"{LIBRARIAN_SERVICE_ADDRESS}/", + "health_url": f"{LIBRARIAN_SERVICE_ADDRESS}{LIBRARIAN_PING}", "extensions": ["librarian_commands"], }, } @@ -154,17 +162,37 @@ async def _load_service_groups() -> bool: missing = [e for e in group["extensions"] if e not in client.extensions] if not missing: continue - err = await asyncio.to_thread(_service_health, group["health_url"]) - if err is not None: - logger.warning( - "Service '%s' unreachable (%s) [%s: %s] - cogs stay disabled: %s", - service, - group["health_url"], - type(err).__name__, - err, - ", ".join(missing), + if service == "librarian": + # A plain GET only proves Flask is up. The librarian is only useful + # once its internal queue + worker are flowing, so prove exactly that + # with a ping that must complete the full round-trip (see + # communication_subroutine.librarian_ping). + alive = await asyncio.to_thread( + librarian_ping, + LIBRARIAN_SERVICE_ADDRESS, + LIBRARIAN_PING, + service_headers(), + LIBRARIAN_PING_TIMEOUT, ) - continue + if not alive: + logger.warning( + "Service 'librarian' ping round-trip failed (%s) - cogs stay disabled: %s", + group["health_url"], + ", ".join(missing), + ) + continue + else: + err = await asyncio.to_thread(_service_health, group["health_url"]) + if err is not None: + logger.warning( + "Service '%s' unreachable (%s) [%s: %s] - cogs stay disabled: %s", + service, + group["health_url"], + type(err).__name__, + err, + ", ".join(missing), + ) + continue logger.info("Service '%s' is alive - enabling: %s", service, ", ".join(missing)) for extension in missing: if await _load_extension_safe(extension): diff --git a/communication_subroutine.py b/communication_subroutine.py index dad76fd..49867c1 100644 --- a/communication_subroutine.py +++ b/communication_subroutine.py @@ -4,10 +4,12 @@ import os import re import threading import time +import uuid as uuidlib from queue import Empty, Queue from typing import Optional from urllib import request as urequest +import requests from flask import Flask, abort, jsonify, request from waitress import serve @@ -26,6 +28,11 @@ SRCHTITLE = re.compile(rb"StreamTitle=\\*(?P[^;]*);").search awaiting_q = [] incoming_q = Queue() +# A health-check ping whose pong never comes back (dead/dropped librarian) would +# otherwise leave its record in awaiting_q forever. scan_incoming sweeps ping +# records older than this. Kept well above the ping timeout so a slow-but-alive +# round-trip is never swept out from under a waiter. +PING_TTL_SECONDS = 30 app = Flask(__name__) @@ -232,9 +239,36 @@ def scan_incoming(stop_event: Optional[threading.Event] = None): if stop_event and stop_event.is_set(): logger.info("scan_incoming: stop requested") break + # Sweep stale health-check pings first: if a librarian is dead the pong + # never arrives, so drop ping records past their TTL. scan_incoming is + # the sole remover of awaiting_q, so this needs no lock (scan_queue only + # appends). Snapshot with list() so removal during iteration is safe. + now = time.monotonic() + for record in list(awaiting_q): + if ( + getattr(record, "is_ping", False) + and getattr(record, "answered", None) is not None + and not record.answered.is_set() + and now - getattr(record, "created", now) > PING_TTL_SECONDS + ): + awaiting_q.remove(record) try: answer = incoming_q.get(block=False) logger.info("DATA FOUND") + # Health-check pong (shape: {"__pong__": uuid}). Close the waiter's + # event and drop its record. It must NEVER fall through to the + # result/orphan path below, or the librarian cog would later pull it + # off IN_COMM_Q and post a bogus "no results" message to Discord. + if isinstance(answer, dict) and "__pong__" in answer: + pong_uuid = answer["__pong__"] + for record in list(awaiting_q): + if getattr(record, "uuid", None) == pong_uuid: + event = getattr(record, "answered", None) + if event is not None: + event.set() + awaiting_q.remove(record) + logger.info("PONG matched for %s", pong_uuid) + continue # Collect matched records and drop them from awaiting_q afterwards - # they used to stay forever (awaiting_q only ever grew), leaking # memory over the bot's uptime and letting a reused UUID re-match a @@ -258,6 +292,55 @@ def scan_incoming(stop_event: Optional[threading.Event] = None): time.sleep(1) +def librarian_ping(address: str, endpoint: str, headers: Optional[dict] = None, + timeout: float = 3.0) -> bool: + """Health-check the librarian by round-tripping a ping through the FULL path. + + The ping is a pseudo-query that exercises exactly the same machinery a real + search does, on BOTH sides: + + * bot out: a ``QueryControl`` rides ``OUT_COMM_Q`` -> ``scan_queue`` -> + ``awaiting_q`` just like a real query, + * librarian: it is POSTed to the librarian, which must pull it off its OWN + internal queue and answer WITHOUT running a search, + * bot in: the pong comes back over ``/conjurer`` -> ``incoming_q`` -> + ``scan_incoming``, which matches it by uuid and sets our event. + + Returns True only when that whole loop closes within ``timeout``. Never + blocks longer than roughly ``timeout`` and cannot deadlock: the POST is + bounded, the wait is bounded, and a ping whose pong never arrives is swept + out of ``awaiting_q`` by ``scan_incoming`` (PING_TTL_SECONDS). + """ + logger = logging.getLogger("discord") + ping_uuid = str(uuidlib.uuid4()) + answered = threading.Event() + query = QueryControl("healthcheck", ping_uuid, "__ping__", None) + query.is_ping = True + query.answered = answered + query.created = time.monotonic() + # Enter the bot-side comm queue BEFORE the POST, so the record is already in + # awaiting_q by the time the pong can come back (no lost-wakeup race). + OUT_COMM_Q.put(query) + try: + response = requests.post( + f"{address}{endpoint}", + json={"UUID": ping_uuid}, + headers=headers or {}, + timeout=timeout, + ) + except requests.exceptions.RequestException as exc: + logger.info("Librarian ping POST failed (%s): %s", ping_uuid, exc) + return False # stale record is swept by scan_incoming + if response.status_code != 200: + logger.info("Librarian ping rejected (%s): HTTP %s", ping_uuid, response.status_code) + return False + if answered.wait(timeout): + logger.info("Librarian ping round-trip OK (%s)", ping_uuid) + return True + logger.info("Librarian ping timed out after %ss (%s)", timeout, ping_uuid) + return False + + def get_stream_title(tag: bytes) -> str: title = "" if m := SRCHTITLE(tag): diff --git a/conjurer_librarian/conjurer_librarian.py b/conjurer_librarian/conjurer_librarian.py index 4450fe4..c2dc0ef 100644 --- a/conjurer_librarian/conjurer_librarian.py +++ b/conjurer_librarian/conjurer_librarian.py @@ -411,7 +411,30 @@ class BackgroundTaskSearch(threading.Thread): while True: database = None ndb_database = None - librarian = librarian_queue.get() + item = librarian_queue.get() + # Health-check ping: it has flowed through the internal queue and is + # now pulled off it - that is the whole point. Pong it straight back + # with the same uuid and DO NOT run a search. + if isinstance(item, dict) and "__ping__" in item: + ping_uuid = item["__ping__"] + self.app.logger.info( + "PING %s pulled off internal queue - ponging back (no search)", + ping_uuid, + ) + try: + await asyncio.to_thread( + requests.post, + f"{MAIN_BOT_ADDRESS}{SEND_RESULTS}", + json={"__pong__": ping_uuid}, + headers=_service_headers(), + timeout=5, + ) + except requests.exceptions.RequestException as exc: + self.app.logger.warning( + "PING pong send failed for %s: %s", ping_uuid, exc + ) + continue + librarian = item self.app.logger.info("STARTED") result = await librarian.answer_query(librarian.deep_search) result = {librarian.uuid: result} @@ -530,6 +553,29 @@ async def query_database(): return return_data +@app.route("/ping", methods=["POST"]) +async def ping_roundtrip(): + _authorize_request() + """ + Health-check round-trip. + + Puts a lightweight ping marker onto the SAME internal ``librarian_queue`` + that real searches go through and returns 200 immediately. The background + worker pulls it off the queue and pongs it back to the bot with the same + uuid, WITHOUT running any Crossref/DOI search. A successful pong therefore + proves the whole pipeline (HTTP in -> internal queue -> worker -> HTTP out) + is flowing, not just that Flask is up. + """ + record = json.loads(request.data) + ping_uuid = record["UUID"] + app.logger.info("PING received %s - queued for round-trip", ping_uuid) + librarian_queue.put({"__ping__": ping_uuid}) + return ( + jsonify(isError=False, message="ping-queued", statusCode=200, data=ping_uuid), + 200, + ) + + @app.route("/get_partial_result", methods=["POST"]) async def get_partial(): _authorize_request() diff --git a/constants.py b/constants.py index 83a3986..1fc67e1 100644 --- a/constants.py +++ b/constants.py @@ -90,6 +90,9 @@ CREATE_PRIO_PLAYLIST = "/create_priority_playlist" REQUEST_MUSIC = "/request_radio_file" CLEAR_PRIO = "/clear_pr_pls" SEND_QUERY = "/query" +# Health-check round-trip: a pseudo-query that the librarian must pull off its +# own internal queue and answer (same uuid) WITHOUT running a real search. +LIBRARIAN_PING = "/ping" TIME_BETWEEN_CALLS = 100000 LAST_SPONTANEOUS_CALL = datetime.now() diff --git a/tests/integration/test_librarian_ping.py b/tests/integration/test_librarian_ping.py new file mode 100644 index 0000000..36ad6de --- /dev/null +++ b/tests/integration/test_librarian_ping.py @@ -0,0 +1,121 @@ +"""Integration: the librarian health check is a full comm round-trip, not a GET. + +``librarian_ping`` injects a pseudo-query into the SAME machinery a real search +uses - it rides ``OUT_COMM_Q`` -> ``scan_queue`` -> ``awaiting_q``, the librarian +is expected to pull it off its own queue and pong it back over ``/conjurer`` -> +``incoming_q`` -> ``scan_incoming``, which matches it by uuid and wakes the +waiter. These tests stand in for the librarian with a stubbed ``requests.post`` +and assert the loop closes (and, crucially, that a pong never leaks into +``IN_COMM_Q`` where the librarian cog would mistake it for a real result). +""" +import threading +import time + +import pytest + +import communication_subroutine as cs + + +def _drain(queue): + while not queue.empty(): + queue.get() + + +@pytest.fixture +def comm_threads(): + """Run scan_queue + scan_incoming (the two workers librarian_ping relies on) + for the duration of a test, on cleared shared state.""" + cs.awaiting_q.clear() + _drain(cs.incoming_q) + _drain(cs.OUT_COMM_Q) + _drain(cs.IN_COMM_Q) + stop = threading.Event() + workers = [ + threading.Thread(target=cs.scan_queue, kwargs={"stop_event": stop}, daemon=True), + threading.Thread(target=cs.scan_incoming, kwargs={"stop_event": stop}, daemon=True), + ] + for worker in workers: + worker.start() + yield + stop.set() + for worker in workers: + worker.join(timeout=3) + + +def _await_in_awaiting(ping_uuid, timeout=2): + """Block until scan_queue has moved the ping into awaiting_q.""" + deadline = time.time() + timeout + while time.time() < deadline: + if any(getattr(r, "uuid", None) == ping_uuid for r in list(cs.awaiting_q)): + return True + time.sleep(0.01) + return False + + +class _Resp: + def __init__(self, status_code=200): + self.status_code = status_code + + +def test_ping_round_trip_ok(comm_threads, monkeypatch): + # Stub librarian: only pong AFTER the record is in awaiting_q, mirroring the + # real network latency that always lets scan_queue win. + def fake_post(url, json=None, headers=None, timeout=None): + ping_uuid = json["UUID"] + _await_in_awaiting(ping_uuid) + cs.incoming_q.put({"__pong__": ping_uuid}) + return _Resp(200) + + monkeypatch.setattr(cs.requests, "post", fake_post) + + assert cs.librarian_ping("http://lib", "/ping", {}, timeout=3.0) is True + # A pong must NEVER reach the cog's inbound queue... + assert cs.IN_COMM_Q.empty() + # ...and the ping record must be cleaned out of awaiting_q. + assert not any(getattr(r, "is_ping", False) for r in list(cs.awaiting_q)) + + +def test_ping_times_out_when_librarian_accepts_but_never_pongs(comm_threads, monkeypatch): + monkeypatch.setattr(cs.requests, "post", lambda *a, **k: _Resp(200)) + start = time.monotonic() + assert cs.librarian_ping("http://lib", "/ping", {}, timeout=0.3) is False + # Bounded: it must not block much beyond the timeout. + assert time.monotonic() - start < 2.0 + + +def test_ping_false_when_librarian_unreachable(comm_threads, monkeypatch): + def boom(*a, **k): + raise cs.requests.exceptions.RequestException("no route to host") + + monkeypatch.setattr(cs.requests, "post", boom) + assert cs.librarian_ping("http://lib", "/ping", {}, timeout=0.3) is False + + +def test_ping_false_on_non_200(comm_threads, monkeypatch): + monkeypatch.setattr(cs.requests, "post", lambda *a, **k: _Resp(503)) + assert cs.librarian_ping("http://lib", "/ping", {}, timeout=0.3) is False + + +def test_orphan_pong_is_dropped_not_enqueued(comm_threads): + # A pong with no matching waiter (e.g. after the ping already timed out) must + # be silently dropped - never turned into an "Orphaned" IN_COMM_Q record that + # makes the librarian cog post a bogus "no results" message. + cs.incoming_q.put({"__pong__": "no-such-uuid"}) + deadline = time.time() + 3 + while time.time() < deadline and not cs.incoming_q.empty(): + time.sleep(0.02) + time.sleep(0.2) # give scan_incoming a beat to (not) enqueue anything + assert cs.IN_COMM_Q.empty() + + +def test_real_result_still_reaches_in_comm_q(comm_threads): + # Guard the existing path: a normal {uuid: {...}} result must still match its + # QueryControl and land in IN_COMM_Q for the cog to render. + query = cs.QueryControl("user", "real-uuid", "jakieś zapytanie", None) + cs.OUT_COMM_Q.put(query) + assert _await_in_awaiting("real-uuid") + cs.incoming_q.put({"real-uuid": {"10.1/x": {"Title": ["Tytuł"], "type": "article"}}}) + got = cs.IN_COMM_Q.get(timeout=3) + assert got.uuid == "real-uuid" + assert got.stop is True + assert "10.1/x" in got.entries -- 2.52.0 From 5d321f2f5b6c3fdedcc96c23c06fa5bb9444c4eb Mon Sep 17 00:00:00 2001 From: Polish Hammer <mtuszowski@gmail.com> Date: Sat, 1 Aug 2026 18:56:46 +0200 Subject: [PATCH 2/2] Librarian: busy-aware ping + per-query lost-result watchdog Two refinements to the librarian health/delivery story, matching how it actually behaves under load: 1. Busy-aware ping (case b - broken return path). A ping arriving while the worker is grinding a search no longer queues behind it (which made a healthy-but-busy librarian time out and look dead). The librarian tracks worker_busy and, when set, pongs back IMMEDIATELY without touching the queue. Being busy is fine - you can keep piling searches on. The ping still travels the librarian->bot return path, so it keeps catching the one thing it must: a disrupted/incompatible return path where queries vanish. Idle pings still go through the internal queue. 2. Per-query watchdog (case a - finished but result lost). The librarian now tracks every search uuid's lifecycle (queued -> processing -> gone) in active_queries, exposed via a new POST /query_status. After dispatching a search the bot records it in self.pending; watch_pending polls /query_status for each. While the librarian still knows the uuid the search is progressing - left alone. The moment a uuid VANISHES there while still pending on the bot, its result was computed but never delivered: after a grace window (to rule out an in-flight result) the bot posts a notice to the channel - but ONLY then. A normally delivered result is popped from self.pending by check_data_q and never flagged. Hardening: the worker's search body is now wrapped in try/except/finally so a crashing search can't kill the worker thread (which would freeze the queue), and worker_busy / active_queries are always cleared. The grace logic lives in a dependency-free librarian_watchdog.pending_verdict so it is unit-testable without discord/pdf libs. /ping and /query_status are plain (sync) views so they run without flask[async]. Tests: unit test_librarian_watchdog (verdict transitions); integration test_librarian_query_lifecycle (query_status known/unknown + auth, idle-ping-queues, busy-ping-pongs-directly). Suite: 28 integration + 48 unit green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- conjurer_librarian/conjurer_librarian.py | 277 +++++++++++------- constants.py | 3 + librarian_commands.py | 106 ++++++- librarian_watchdog.py | 51 ++++ .../test_librarian_query_lifecycle.py | 103 +++++++ tests/unit/test_librarian_watchdog.py | 51 ++++ 6 files changed, 491 insertions(+), 100 deletions(-) create mode 100644 librarian_watchdog.py create mode 100644 tests/integration/test_librarian_query_lifecycle.py create mode 100644 tests/unit/test_librarian_watchdog.py diff --git a/conjurer_librarian/conjurer_librarian.py b/conjurer_librarian/conjurer_librarian.py index c2dc0ef..5b27c6c 100644 --- a/conjurer_librarian/conjurer_librarian.py +++ b/conjurer_librarian/conjurer_librarian.py @@ -69,6 +69,19 @@ app = Flask(__name__) librarian_queue = Queue() librarian_list = [] +# Lifecycle of every real search uuid: "queued" (accepted, sitting in +# librarian_queue) -> "processing" (worker pulled it) -> removed (worker +# finished AND attempted to send the result). The bot's per-query watchdog polls +# /query_status against this: a uuid that VANISHES from here without its result +# reaching the bot is a lost result (finished-but-never-delivered) and gets +# flagged in chat. +active_queries: Dict[str, str] = {} +_active_lock = threading.Lock() +# Set while the worker is grinding a real search. A ping arriving during this +# pongs back immediately WITHOUT queueing - being busy is healthy (you can keep +# piling searches on), so "busy" must never look like "dead" to the health check. +worker_busy = threading.Event() + def _service_headers() -> Dict[str, str]: if API_KEY: @@ -81,6 +94,25 @@ def _authorize_request() -> None: abort(401) +def _post_pong(app_logger, ping_uuid) -> None: + """POST a pong for ``ping_uuid`` back to the bot. Non-fatal on failure. + + This is the SAME return path a real result takes (bot's /conjurer), so a + delivered pong proves the librarian->bot leg works - the one thing the ping + needs to establish. Used directly by the /ping route (busy ping, which skips + the queue) and, wrapped in a thread, by the worker (idle ping). Synchronous + so the /ping route can stay a plain (non-async) view.""" + try: + requests.post( + f"{MAIN_BOT_ADDRESS}{SEND_RESULTS}", + json={"__pong__": ping_uuid}, + headers=_service_headers(), + timeout=5, + ) + except requests.exceptions.RequestException as exc: + app_logger.warning("PING pong send failed for %s: %s", ping_uuid, exc) + + # trunk-ignore(pylint/R0902) class Librarian(object): """ @@ -421,102 +453,108 @@ class BackgroundTaskSearch(threading.Thread): "PING %s pulled off internal queue - ponging back (no search)", ping_uuid, ) - try: - await asyncio.to_thread( - requests.post, - f"{MAIN_BOT_ADDRESS}{SEND_RESULTS}", - json={"__pong__": ping_uuid}, - headers=_service_headers(), - timeout=5, - ) - except requests.exceptions.RequestException as exc: - self.app.logger.warning( - "PING pong send failed for %s: %s", ping_uuid, exc - ) + await asyncio.to_thread(_post_pong, self.app.logger, ping_uuid) continue librarian = item - self.app.logger.info("STARTED") - result = await librarian.answer_query(librarian.deep_search) - result = {librarian.uuid: result} - self.app.logger.info("Saving to file") - - # Save results to "not_in_db.json" file - with open(lib_paths.NOT_IN_DB, "r+", encoding="utf-8") as ndb_file: - ndb_database = {} - try: - ndb_database = json.load(ndb_file) - except JSONDecodeError: - pass - if ndb_database: - ndb_database.update(librarian.not_in_db) - else: - ndb_database = librarian.not_in_db - ndb_file.truncate(0) - ndb_file.seek(0) - json.dump(ndb_database, ndb_file) - - # Save results to "s_results.json" file - with open(lib_paths.S_RESULTS, "r+", encoding="utf-8") as s_file: - database = {} - try: - database = json.load(s_file) - except JSONDecodeError: - pass - if database: - self.app.logger.info(database) - self.app.logger.info(result) - database.update(result) - else: - database = result - self.app.logger.info("DUMPING DATA") - s_file.truncate(0) - s_file.seek(0) - json.dump(database, s_file) - self.app.logger.info("FINISHED") - - # Send the result back to the bot. Log EXACTLY what goes out (target, - # uuid, how many DOIs and which) so the librarian log makes it plain a - # result was sent and what was in it. - payload = result # shape: {uuid: {DOI: {"Title": ..., "type": ...}}} - hits = payload.get(librarian.uuid, {}) if isinstance(payload, dict) else {} - target = f"{MAIN_BOT_ADDRESS}{SEND_RESULTS}" - self.app.logger.info( - "SENDING result for %s to %s: %d DOI(s): %s", - librarian.uuid, - target, - len(hits), - list(hits.keys()), - ) - # A failed send must NOT kill this worker - otherwise a bot that is - # momentarily down stalls every future query until the librarian is - # restarted. Log and carry on to the next queued search. + # Mark busy + processing for the whole search, and ALWAYS clear both + # (even on a crash) in finally: worker_busy so a ping doesn't wait + # behind us, and active_queries so the bot's watchdog can tell a + # finished-and-gone query from one still in flight. + worker_busy.set() + with _active_lock: + active_queries[str(librarian.uuid)] = "processing" try: - response = await asyncio.to_thread( - requests.post, - target, - json=payload, - headers=_service_headers(), - timeout=360, - ) - if response.status_code == 200: - self.app.logger.info( - "SENT result for %s -> HTTP 200 (bot accepted)", librarian.uuid - ) - else: - self.app.logger.warning( - "SENT result for %s but bot returned HTTP %s: %s", - librarian.uuid, - response.status_code, - response.text[:500], - ) - except requests.exceptions.RequestException as exc: - self.app.logger.error( - "FAILED to send result for %s to %s: %s", + self.app.logger.info("STARTED") + result = await librarian.answer_query(librarian.deep_search) + result = {librarian.uuid: result} + self.app.logger.info("Saving to file") + + # Save results to "not_in_db.json" file + with open(lib_paths.NOT_IN_DB, "r+", encoding="utf-8") as ndb_file: + ndb_database = {} + try: + ndb_database = json.load(ndb_file) + except JSONDecodeError: + pass + if ndb_database: + ndb_database.update(librarian.not_in_db) + else: + ndb_database = librarian.not_in_db + ndb_file.truncate(0) + ndb_file.seek(0) + json.dump(ndb_database, ndb_file) + + # Save results to "s_results.json" file + with open(lib_paths.S_RESULTS, "r+", encoding="utf-8") as s_file: + database = {} + try: + database = json.load(s_file) + except JSONDecodeError: + pass + if database: + self.app.logger.info(database) + self.app.logger.info(result) + database.update(result) + else: + database = result + self.app.logger.info("DUMPING DATA") + s_file.truncate(0) + s_file.seek(0) + json.dump(database, s_file) + self.app.logger.info("FINISHED") + + # Send the result back to the bot. Log EXACTLY what goes out + # (target, uuid, how many DOIs and which) so the librarian log + # makes it plain a result was sent and what was in it. + payload = result # shape: {uuid: {DOI: {"Title": ..., "type": ...}}} + hits = payload.get(librarian.uuid, {}) if isinstance(payload, dict) else {} + target = f"{MAIN_BOT_ADDRESS}{SEND_RESULTS}" + self.app.logger.info( + "SENDING result for %s to %s: %d DOI(s): %s", librarian.uuid, target, - exc, + len(hits), + list(hits.keys()), ) - await asyncio.sleep(1) + # A failed send must NOT kill this worker - otherwise a bot that + # is momentarily down stalls every future query until the + # librarian is restarted. Log and carry on to the next search. + try: + response = await asyncio.to_thread( + requests.post, + target, + json=payload, + headers=_service_headers(), + timeout=360, + ) + if response.status_code == 200: + self.app.logger.info( + "SENT result for %s -> HTTP 200 (bot accepted)", librarian.uuid + ) + else: + self.app.logger.warning( + "SENT result for %s but bot returned HTTP %s: %s", + librarian.uuid, + response.status_code, + response.text[:500], + ) + except requests.exceptions.RequestException as exc: + self.app.logger.error( + "FAILED to send result for %s to %s: %s", + librarian.uuid, + target, + exc, + ) + except Exception as exc: # pylint: disable=broad-exception-caught + # A crashing search must not kill the worker thread (which would + # freeze the whole queue). Log and move on; finally still clears + # busy/active so the query is correctly seen as "gone". + self.app.logger.exception("Search %s crashed: %s", librarian.uuid, exc) + finally: + worker_busy.clear() + with _active_lock: + active_queries.pop(str(librarian.uuid), None) + await asyncio.sleep(1) # ==================================SERVER ROUTES========================================== @@ -545,6 +583,10 @@ async def query_database(): cl = Librarian(app, record["query"], uuid, deep_search) librarian_queue.put(cl) librarian_list.append(cl) + # The bot's per-query watchdog polls /query_status for this uuid; mark it + # "queued" now so it counts as known the moment we accept it. + with _active_lock: + active_queries[str(uuid)] = "queued" answer_data = (record["query"], record["UUID"], librarian_queue.qsize()) return_data = ( jsonify(isError=False, message="Success", statusCode=200, data=answer_data), @@ -554,28 +596,65 @@ async def query_database(): @app.route("/ping", methods=["POST"]) -async def ping_roundtrip(): +def ping_roundtrip(): _authorize_request() """ Health-check round-trip. - Puts a lightweight ping marker onto the SAME internal ``librarian_queue`` - that real searches go through and returns 200 immediately. The background - worker pulls it off the queue and pongs it back to the bot with the same - uuid, WITHOUT running any Crossref/DOI search. A successful pong therefore - proves the whole pipeline (HTTP in -> internal queue -> worker -> HTTP out) - is flowing, not just that Flask is up. + Two cases, one guarantee - the pong always comes back over the librarian->bot + return path (the only thing the ping must prove): + + * IDLE: put a ping marker onto the SAME internal ``librarian_queue`` real + searches use and return 200. The worker pulls it off and pongs it back, + so a successful pong proves the whole pipeline flows (queue + worker + the + return leg), not just that Flask is up. + * BUSY (a search is grinding): DO NOT queue - the ping would just wait behind + a possibly hours-long search and time out, making a perfectly healthy busy + librarian look dead. Pong back immediately instead. Being busy is fine; you + can keep piling searches on. The ping only needs to catch a BROKEN return + path, and the direct pong exercises exactly that. """ record = json.loads(request.data) ping_uuid = record["UUID"] - app.logger.info("PING received %s - queued for round-trip", ping_uuid) - librarian_queue.put({"__ping__": ping_uuid}) + if worker_busy.is_set(): + app.logger.info("PING %s while busy grinding - direct pong (skip queue)", ping_uuid) + _post_pong(app.logger, ping_uuid) + else: + app.logger.info("PING received %s - queued for round-trip", ping_uuid) + librarian_queue.put({"__ping__": ping_uuid}) return ( jsonify(isError=False, message="ping-queued", statusCode=200, data=ping_uuid), 200, ) +@app.route("/query_status", methods=["POST"]) +def query_status(): + _authorize_request() + """ + Per-query watchdog probe. + + Returns whether ``UUID`` is still known to the librarian (queued or being + processed). The bot polls this after dispatching a search: while the uuid is + known the search is progressing; once it VANISHES here without the result + ever reaching the bot, the result was lost in transit and the bot tells the + user. A busy/queued search is never mistaken for a lost one. + """ + record = json.loads(request.data) + uuid = str(record["UUID"]) + with _active_lock: + state = active_queries.get(uuid, "unknown") + return ( + jsonify( + isError=False, + message="Success", + statusCode=200, + data={"uuid": uuid, "known": state != "unknown", "state": state}, + ), + 200, + ) + + @app.route("/get_partial_result", methods=["POST"]) async def get_partial(): _authorize_request() diff --git a/constants.py b/constants.py index 1fc67e1..686c21d 100644 --- a/constants.py +++ b/constants.py @@ -93,6 +93,9 @@ SEND_QUERY = "/query" # Health-check round-trip: a pseudo-query that the librarian must pull off its # own internal queue and answer (same uuid) WITHOUT running a real search. LIBRARIAN_PING = "/ping" +# Per-query watchdog probe: "do you still know this uuid?" (queued/processing). +# A uuid that vanishes here without its result reaching the bot was lost. +QUERY_STATUS = "/query_status" TIME_BETWEEN_CALLS = 100000 LAST_SPONTANEOUS_CALL = datetime.now() diff --git a/librarian_commands.py b/librarian_commands.py index 05580a8..9cf195c 100644 --- a/librarian_commands.py +++ b/librarian_commands.py @@ -3,6 +3,7 @@ import io import logging import os import random +import time import uuid from queue import Empty @@ -15,15 +16,40 @@ from discord.ext import commands, tasks from ai_functions import handle_response from communication_subroutine import IN_COMM_Q, OUT_COMM_Q, QueryControl, submit_ai_query -from constants import DIR_PATH_SADOX, LIBRARIAN_SERVICE_ADDRESS, SEND_QUERY, service_headers +from constants import ( + DIR_PATH_SADOX, + LIBRARIAN_SERVICE_ADDRESS, + QUERY_STATUS, + SEND_QUERY, + service_headers, +) +from librarian_watchdog import FLAG, pending_verdict SERVICE_HEADERS = service_headers() +# Per-query watchdog tuning. +PENDING_WATCH_SECONDS = 30 # how often to ask the librarian about a uuid +PENDING_GRACE_SECONDS = 45 # unknown-but-pending must persist this long +PENDING_HARD_TTL = 60 * 60 * 24 * 3 # drop tracking after 3 days no matter what + class DataModule(commands.Cog): def __init__(self, bot, logger_name): self.bot = bot self.logger = logging.getLogger(logger_name) + # uuid -> {"ctx", "query", "created", "unknown_since"} for every search + # dispatched but not yet answered. watch_pending polls the librarian for + # each; check_data_q removes an entry the moment its result is rendered. + self.pending = {} + + def _track_pending(self, query_uuid, query, ctx): + """Start watching a dispatched search so a lost result can be caught.""" + self.pending[str(query_uuid)] = { + "ctx": ctx, + "query": query, + "created": time.monotonic(), + "unknown_since": None, + } @commands.hybrid_command( nsfw=True, @@ -100,6 +126,9 @@ class DataModule(commands.Cog): fresh_data = IN_COMM_Q.get(block=False) entries = [] if fresh_data.stop: + # The result arrived and is about to be rendered - stop the + # watchdog from ever flagging this uuid as lost. + self.pending.pop(str(fresh_data.uuid), None) searcher = fresh_data.author query = fresh_data.content # ai_lines is a clean, plain rendering of the SAME list in the @@ -175,6 +204,77 @@ class DataModule(commands.Cog): except Empty: pass + @tasks.loop(seconds=PENDING_WATCH_SECONDS) + async def watch_pending(self): + """Per-query safety net for lost results (case a). + + For each dispatched-but-unanswered search, ask the librarian whether it + still knows the uuid (queued or processing). While it does, the search is + progressing - leave it alone (a busy librarian is fine). The moment a + uuid VANISHES on the librarian while still pending here, its result was + computed but never reached us: after a short grace window (to rule out a + result that is merely in flight) we tell the channel - but ONLY then. + + A normally-delivered result is popped from self.pending by check_data_q, + so it never reaches the flag path. + """ + now = time.monotonic() + for query_uuid in list(self.pending.keys()): + info = self.pending.get(query_uuid) + if info is None: + continue + # Hard cap so a permanently-unreachable librarian can't leak entries. + if now - info["created"] > PENDING_HARD_TTL: + self.logger.warning("Dropping stale pending query %s (hard TTL)", query_uuid) + self.pending.pop(query_uuid, None) + continue + try: + response = await asyncio.to_thread( + requests.post, + f"{LIBRARIAN_SERVICE_ADDRESS}{QUERY_STATUS}", + json={"UUID": query_uuid}, + headers=SERVICE_HEADERS, + timeout=5, + ) + known = ( + response.status_code == 200 + and response.json().get("data", {}).get("known", False) + ) + except ( + requests.exceptions.RequestException, + ValueError, + AttributeError, + KeyError, + TypeError, + ) as exc: + # Librarian unreachable / garbled or unexpected answer: we can't + # judge, so don't cry wolf, and don't let one bad poll kill the + # loop. Reset the clock and try again next tick. + self.logger.info("Pending check for %s inconclusive: %s", query_uuid, exc) + info["unknown_since"] = None + continue + action, info["unknown_since"] = pending_verdict( + known, info["unknown_since"], now, PENDING_GRACE_SECONDS + ) + # Re-check membership: the await above yields, so check_data_q may + # have just delivered (and popped) this result. + if action == FLAG and query_uuid in self.pending: + await self._flag_lost(query_uuid, info) + self.pending.pop(query_uuid, None) + + async def _flag_lost(self, query_uuid, info): + """Tell the querent their finished search never made it back.""" + message = ( + "*Winda na książki z hukiem wraca z podziemi PUSTA. Z głośnika trzeszczy:* " + f"Twoje zapytanie {query_uuid} (\"{info['query']}\") przemieliło się w " + "bibliotece do końca, ale wynik przepadł gdzieś w drodze do baru - nic nie " + "dotarło. Zawołaj szefa albo puść jeszcze raz." + ) + try: + await info["ctx"].send(message) + except Exception: # pylint: disable=broad-exception-caught + self.logger.exception("Failed to post lost-result notice for %s", query_uuid) + @commands.hybrid_command( name="wyszukaj_linki_do_dokumentow", description="Szuka linkow doi w bazie crossref i podaje linki do scihuba", @@ -233,6 +333,7 @@ class DataModule(commands.Cog): username = ctx.message.author.name query_object = QueryControl(username, query_uuid, query, ctx) OUT_COMM_Q.put(query_object) + self._track_pending(query_uuid, query, ctx) await ctx.send( f"No dobra poszło. Zapamiętaj proszę {query_uuid} - to identyfikator twojego zapytania. Jesteś {queue_size} w kolejce." + " Zapytania obsługuje algorytm zasilany czterema chomikami zapierdalającymi w kołowrotku - więc wyniki najwcześniej za kilka godzi - ale mogą być też dni." @@ -291,6 +392,7 @@ class DataModule(commands.Cog): username = ctx.message.author.name query_object = QueryControl(username, query_uuid, query, ctx, ai_review=True) OUT_COMM_Q.put(query_object) + self._track_pending(query_uuid, query, ctx) await ctx.send( f"Poszło z recenzją AI. Identyfikator: {query_uuid}. Jesteś {queue_size} w kolejce." + " Najpierw dojadą surowe wyniki, a zaraz po nich przesortowanie i recenzja od AI." @@ -387,6 +489,7 @@ class DataModule(commands.Cog): username = ctx.message.author.name query_object = QueryControl(username, query_uuid, query, ctx) OUT_COMM_Q.put(query_object) + self._track_pending(query_uuid, query, ctx) await ctx.send( f"Wypij wypi {query_uuid} - to identyfikator twojego zapytania. Jesteś {queue_size} w kolejce niestety kolejka obowiazuje zawsze." + " Zapytania obsługuje algorytm zasilany czterema chomikami zapierdalającymi w kołowrotku - więc wyniki najwcześniej za kilka godzin - ale mogą być też dni. Głębokie zabawy trwają dłużej. *Znowu mruga*" @@ -397,5 +500,6 @@ async def setup(bot): logger = logging.getLogger("discord") dm = DataModule(bot, "discord") dm.check_data_q.start() + dm.watch_pending.start() await bot.add_cog(dm) logger.info("Loading data sharing commands module done") diff --git a/librarian_watchdog.py b/librarian_watchdog.py new file mode 100644 index 0000000..b095d4f --- /dev/null +++ b/librarian_watchdog.py @@ -0,0 +1,51 @@ +"""Pure decision logic for the librarian per-query watchdog. + +Split out of ``librarian_commands`` (which pulls in discord / pdf libs, so it is +not importable in the pytest-only unit job) so the one subtle part - the grace +window that stops a just-delivered result from being falsely flagged as lost - +can be unit-tested in isolation. + +The watchdog polls the librarian's /query_status for each dispatched query: + +* the librarian reports the uuid ``known`` while it is queued or processing, +* once the search finishes it is dropped there, so the uuid goes ``unknown``. + +A result that arrives normally is removed from the pending set by the result +handler, so the watchdog never even sees it. Only a uuid that goes ``unknown`` +on the librarian *and is still pending on the bot* is a lost result - but we +require it to stay that way for a grace window first, because there is always a +brief moment where the librarian has finished (uuid gone) yet the result is +still in flight / not yet rendered. +""" + +WAIT = "wait" +FLAG = "flag" + + +def pending_verdict(known, unknown_since, now, grace_seconds): + """Decide what to do this tick for one pending query. + + Args: + known: did the librarian report the uuid as still known this tick? + unknown_since: monotonic timestamp the uuid was first seen unknown, or + None if it was known last tick. + now: current monotonic time. + grace_seconds: how long a uuid must stay unknown-but-pending before it + is declared lost. + + Returns: + (action, unknown_since) where action is WAIT or FLAG and the returned + ``unknown_since`` is what the caller should store for the next tick. + """ + if known: + # Still queued/processing (or freshly back to known) - reset the clock. + return WAIT, None + if unknown_since is None: + # First tick we see it gone: start the grace clock, don't flag yet - the + # result may simply be in flight. + return WAIT, now + if now - unknown_since >= grace_seconds: + # Gone for the whole grace window and still pending: the result was lost. + return FLAG, unknown_since + # Gone, but not long enough yet - keep waiting. + return WAIT, unknown_since diff --git a/tests/integration/test_librarian_query_lifecycle.py b/tests/integration/test_librarian_query_lifecycle.py new file mode 100644 index 0000000..03b27b9 --- /dev/null +++ b/tests/integration/test_librarian_query_lifecycle.py @@ -0,0 +1,103 @@ +"""Integration: the librarian's health/liveness surface. + +Two behaviours, both proven against the real Flask app: + +* /ping is busy-aware - while a search is grinding it pongs back immediately + WITHOUT queueing (busy is healthy); when idle it routes the ping through the + internal queue for the worker to answer. +* /query_status reports whether a uuid is still known (queued/processing), which + is what the bot's per-query watchdog polls to catch a lost result. + +Only the SYNC routes (/ping, /query_status) are exercised - the async /query +route needs flask[async], which the integration job doesn't install, so query +state is seeded directly on the module. +""" +import sys +import types + +# conjurer_librarian does `from habanero import Crossref` at import time and +# habanero isn't installed in the integration job. Stub it before importing the +# service (we never build a real Librarian here, so Crossref is just a name). +if "habanero" not in sys.modules: + _habanero = types.ModuleType("habanero") + _habanero.Crossref = object + sys.modules["habanero"] = _habanero + +import conjurer_librarian as lib # noqa: E402 + + +def _client(key=None): + lib.API_KEY = key + return lib.app.test_client() + + +def _reset(): + with lib._active_lock: + lib.active_queries.clear() + lib.worker_busy.clear() + while not lib.librarian_queue.empty(): + lib.librarian_queue.get() + + +def test_query_status_known_vs_unknown(): + _reset() + client = _client() + with lib._active_lock: + lib.active_queries["abc"] = "queued" + + known = client.post("/query_status", json={"UUID": "abc"}).get_json()["data"] + assert known == {"uuid": "abc", "known": True, "state": "queued"} + + unknown = client.post("/query_status", json={"UUID": "nope"}).get_json()["data"] + assert unknown == {"uuid": "nope", "known": False, "state": "unknown"} + + +def test_ping_idle_routes_through_internal_queue(monkeypatch): + _reset() + posted = [] + monkeypatch.setattr(lib.requests, "post", lambda *a, **k: posted.append((a, k))) + client = _client() + + resp = client.post("/ping", json={"UUID": "ping-idle"}) + + assert resp.status_code == 200 + # Idle => it went onto the internal queue for the worker, NOT posted directly. + assert posted == [] + assert lib.librarian_queue.get_nowait() == {"__ping__": "ping-idle"} + + +def test_ping_while_busy_pongs_directly_without_queue(monkeypatch): + _reset() + lib.worker_busy.set() # a search is grinding + posted = [] + + class _Resp: + status_code = 200 + + def fake_post(url, json=None, headers=None, timeout=None): + posted.append({"url": url, "json": json}) + return _Resp() + + monkeypatch.setattr(lib.requests, "post", fake_post) + client = _client() + + resp = client.post("/ping", json={"UUID": "ping-busy"}) + + assert resp.status_code == 200 + # Busy => direct pong back to the bot, and NOTHING queued (it would only wait + # behind the long search). + assert lib.librarian_queue.empty() + assert len(posted) == 1 + assert posted[0]["json"] == {"__pong__": "ping-busy"} + assert posted[0]["url"].endswith(lib.SEND_RESULTS) + + +def test_query_status_enforces_api_key(): + _reset() + client = _client(key="secret") + denied = client.post("/query_status", json={"UUID": "x"}) + assert denied.status_code == 401 + ok = client.post( + "/query_status", json={"UUID": "x"}, headers={"X-Conjurer-Api-Key": "secret"} + ) + assert ok.status_code == 200 diff --git a/tests/unit/test_librarian_watchdog.py b/tests/unit/test_librarian_watchdog.py new file mode 100644 index 0000000..0875d1f --- /dev/null +++ b/tests/unit/test_librarian_watchdog.py @@ -0,0 +1,51 @@ +"""Unit tests for the per-query watchdog verdict logic. + +The grace window is the whole point: a search that has just finished is briefly +'unknown' on the librarian while its result is still in flight, and we must NOT +flag that as lost. Only a uuid that stays unknown-but-pending past the grace +window is a genuinely lost result. +""" +from librarian_watchdog import FLAG, WAIT, pending_verdict + +GRACE = 45 + + +def test_known_resets_clock_and_waits(): + action, unknown_since = pending_verdict( + known=True, unknown_since=100.0, now=200.0, grace_seconds=GRACE + ) + assert action == WAIT + assert unknown_since is None # clock reset while it's still known + + +def test_first_unknown_starts_grace_but_does_not_flag(): + action, unknown_since = pending_verdict( + known=False, unknown_since=None, now=1000.0, grace_seconds=GRACE + ) + assert action == WAIT + assert unknown_since == 1000.0 # clock started now + + +def test_unknown_within_grace_keeps_waiting(): + action, unknown_since = pending_verdict( + known=False, unknown_since=1000.0, now=1000.0 + GRACE - 1, grace_seconds=GRACE + ) + assert action == WAIT + assert unknown_since == 1000.0 # unchanged, still counting + + +def test_unknown_past_grace_flags_lost(): + action, unknown_since = pending_verdict( + known=False, unknown_since=1000.0, now=1000.0 + GRACE, grace_seconds=GRACE + ) + assert action == FLAG + assert unknown_since == 1000.0 + + +def test_recovered_to_known_after_being_unknown_resets(): + # It reappeared (e.g. requeued / status flapped): do not flag, reset. + action, unknown_since = pending_verdict( + known=True, unknown_since=1000.0, now=1000.0 + GRACE + 10, grace_seconds=GRACE + ) + assert action == WAIT + assert unknown_since is None -- 2.52.0