26b6ab636e
So one librarian can serve several bots (test + deploy) instead of firing
every result/pong at a single static CONJURER_MAIN_BOT.
* The bot includes its own callback address (CONJURER_SELF_CALLBACK) in
every /query and /ping.
* The librarian stores that callback with the query (persisted with the
request, so a replay after restart still answers the right bot) and, for
results, in the OUTBOX entry ({target, payload}) so the resender delivers
to the origin bot even across a librarian restart.
* Pongs go back to the pinging bot too - otherwise a second bot's health
check would be ponged to the first and always time out, so it could
never enable its librarian cog.
* Empty callback falls back to MAIN_BOT_ADDRESS, and a legacy OUTBOX entry
(raw payload, pre-callback) is still delivered to the default bot, so the
upgrade is seamless.
Tests: per-origin result delivery + legacy-shape fallback (outbox),
busy/idle pong routed to the callback bot vs default (lifecycle). Suite:
58 unit + 52 integration green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
126 lines
4.1 KiB
Python
126 lines
4.1 KiB
Python
"""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", "callback": "http://bot-a:5000"})
|
|
|
|
assert resp.status_code == 200
|
|
# Idle => it went onto the internal queue for the worker (carrying the
|
|
# callback so the worker pongs the right bot), NOT posted directly.
|
|
assert posted == []
|
|
assert lib.librarian_queue.get_nowait() == {
|
|
"__ping__": "ping-idle",
|
|
"callback": "http://bot-a:5000",
|
|
}
|
|
|
|
|
|
def test_ping_while_busy_pongs_directly_to_the_pinging_bot(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", "callback": "http://bot-b:5000"})
|
|
|
|
assert resp.status_code == 200
|
|
# Busy => direct pong, NOTHING queued, and it goes to the CALLBACK bot (not
|
|
# the static default) so a shared librarian health-checks each bot correctly.
|
|
assert lib.librarian_queue.empty()
|
|
assert len(posted) == 1
|
|
assert posted[0]["json"] == {"__pong__": "ping-busy"}
|
|
assert posted[0]["url"] == "http://bot-b:5000" + lib.SEND_RESULTS
|
|
|
|
|
|
def test_ping_without_callback_pongs_to_default_bot(monkeypatch):
|
|
_reset()
|
|
lib.worker_busy.set()
|
|
posted = []
|
|
|
|
class _Resp:
|
|
status_code = 200
|
|
|
|
monkeypatch.setattr(
|
|
lib.requests, "post",
|
|
lambda url, **k: posted.append(url) or _Resp(),
|
|
)
|
|
client = _client()
|
|
client.post("/ping", json={"UUID": "ping-nocb"}) # no callback
|
|
|
|
assert posted == [lib.MAIN_BOT_ADDRESS + 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
|