Files
conjurer/tests/integration/test_librarian_ping.py
T
gitea c36d6d3fc2
CI / compile (pull_request) Successful in 9s
CI / unit (pull_request) Successful in 16s
CI / integration (pull_request) Successful in 18s
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 <noreply@anthropic.com>
2026-08-01 14:57:02 +02:00

122 lines
4.6 KiB
Python

"""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