librarian is_alive: pełny round-trip ping przez kolejkę, nie samo GET #10

Merged
gitea merged 2 commits from librarian-ping-healthcheck into main 2026-08-01 17:09:08 +00:00
9 changed files with 765 additions and 93 deletions
+41 -13
View File
@@ -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):
+83
View File
@@ -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<title>[^;]*);").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):
+204 -79
View File
@@ -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):
"""
@@ -411,89 +443,118 @@ class BackgroundTaskSearch(threading.Thread):
while True:
database = None
ndb_database = None
librarian = librarian_queue.get()
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.
try:
response = await asyncio.to_thread(
requests.post,
target,
json=payload,
headers=_service_headers(),
timeout=360,
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,
)
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",
await asyncio.to_thread(_post_pong, self.app.logger, ping_uuid)
continue
librarian = item
# 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:
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==========================================
@@ -522,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),
@@ -530,6 +595,66 @@ async def query_database():
return return_data
@app.route("/ping", methods=["POST"])
def ping_roundtrip():
_authorize_request()
"""
Health-check round-trip.
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"]
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()
+6
View File
@@ -90,6 +90,12 @@ 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"
# 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()
+105 -1
View File
@@ -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")
+51
View File
@@ -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
+121
View File
@@ -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
@@ -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
+51
View File
@@ -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