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
Owner

Po co

Health-check librariana był zwykłym GET / — sprawdzał tylko, że Flask nasłuchuje. Nie mówił nic o tym, czy librarian realnie przyjmie zapytanie, przepuści je przez swoją wewnętrzną kolejkę i worker, i odeśle odpowiedź. Cog mógł się włączyć przy librarianie z zawieszonym workerem albo takim, który nie ma drogi powrotnej do bota.

Co robi ping

Zapytanie testowe idzie dokładnie tą samą drogą co prawdziwe wyszukanie, po obu stronach:

bot:       QueryControl -> OUT_COMM_Q -> scan_queue -> awaiting_q
librarian: POST /ping   -> librarian_queue -> worker ZDEJMUJE z kolejki
           (BEZ Crossref/DOI search) -> pong z tym samym uuid
bot:       /conjurer -> incoming_q -> scan_incoming dopasowuje uuid -> budzi czekającego

Cog włącza się tylko gdy cała ta pętla domknie się w 3s. Efekt uboczny: ping potwierdza też drogę powrotną librarian→bot, czego GET nigdy nie robił.

Bezpieczeństwo / brak blokad (wprost z wymagań)

  • uuid losowe dla każdego pingu (uuid4).
  • nic się nie blokuje: POST i wait są ograniczone czasowo; health-check biegnie w asyncio.to_thread, więc nie blokuje pętli zdarzeń; endpoint /ping po stronie librariana zwraca 200 od razu (tylko wrzuca do kolejki).
  • brak wycieków: ping, którego pong nie wróci (martwy librarian), jest zamiatany z awaiting_q po PING_TTL_SECONDS (30s). Wszystkie zapisy do awaiting_q zostają w scan_queue (append) i scan_incoming (remove) — bez locków, bez mutacji między wątkami.
  • pong bez oczekującego (np. po timeout) jest porzucany, nie trafia do IN_COMM_Q jako „Orphaned" — inaczej cog wyplułby na Discorda fałszywe „nie ma nic".

Pliki

  • communication_subroutine.pylibrarian_ping(), obsługa pongu + sweep w scan_incoming.
  • conjurer_librarian/conjurer_librarian.py — endpoint POST /ping + gałąź ping w workerze (pong bez wyszukiwania).
  • bot.py — librarian gatowany przez librarian_ping (pozostałe usługi bez zmian).
  • constants.pyLIBRARIAN_PING = "/ping".

Testy

Nowy tests/integration/test_librarian_ping.py (stub podszywa się pod librariana): OK round-trip, timeout gdy przyjęte-ale-bez-pongu, unreachable, non-200, porzucenie sierocego pongu, oraz że normalne wyniki nadal docierają do IN_COMM_Q. Lokalnie: 24 integration + 41 unit zielone.

Obserwacja na przyszłość

Jeśli librarian akurat mieli wielogodzinne wyszukanie, ping czeka za nim w kolejce i po 3s cog zostaje wyłączony — watchdog spróbuje ponownie za 300s. To świadomie zachowawcze (ping idzie przez kolejkę, nie omija jej). Gdyby przeszkadzało, można dać pingowi osobny, priorytetowy tor.

🤖 Generated with Claude Code

## Po co Health-check librariana był zwykłym GET `/` — sprawdzał tylko, że Flask nasłuchuje. Nie mówił nic o tym, czy librarian **realnie przyjmie zapytanie, przepuści je przez swoją wewnętrzną kolejkę i worker, i odeśle odpowiedź**. Cog mógł się włączyć przy librarianie z zawieszonym workerem albo takim, który nie ma drogi powrotnej do bota. ## Co robi ping Zapytanie testowe idzie **dokładnie tą samą drogą co prawdziwe wyszukanie**, po obu stronach: ``` bot: QueryControl -> OUT_COMM_Q -> scan_queue -> awaiting_q librarian: POST /ping -> librarian_queue -> worker ZDEJMUJE z kolejki (BEZ Crossref/DOI search) -> pong z tym samym uuid bot: /conjurer -> incoming_q -> scan_incoming dopasowuje uuid -> budzi czekającego ``` Cog włącza się **tylko gdy cała ta pętla domknie się w 3s**. Efekt uboczny: ping potwierdza też drogę powrotną librarian→bot, czego GET nigdy nie robił. ## Bezpieczeństwo / brak blokad (wprost z wymagań) - **uuid losowe** dla każdego pingu (`uuid4`). - **nic się nie blokuje**: POST i `wait` są ograniczone czasowo; health-check biegnie w `asyncio.to_thread`, więc nie blokuje pętli zdarzeń; endpoint `/ping` po stronie librariana zwraca 200 od razu (tylko wrzuca do kolejki). - **brak wycieków**: ping, którego pong nie wróci (martwy librarian), jest zamiatany z `awaiting_q` po `PING_TTL_SECONDS` (30s). Wszystkie zapisy do `awaiting_q` zostają w `scan_queue` (append) i `scan_incoming` (remove) — bez locków, bez mutacji między wątkami. - **pong bez oczekującego** (np. po timeout) jest **porzucany**, nie trafia do `IN_COMM_Q` jako „Orphaned" — inaczej cog wyplułby na Discorda fałszywe „nie ma nic". ## Pliki - `communication_subroutine.py` — `librarian_ping()`, obsługa pongu + sweep w `scan_incoming`. - `conjurer_librarian/conjurer_librarian.py` — endpoint `POST /ping` + gałąź ping w workerze (pong bez wyszukiwania). - `bot.py` — librarian gatowany przez `librarian_ping` (pozostałe usługi bez zmian). - `constants.py` — `LIBRARIAN_PING = "/ping"`. ## Testy Nowy `tests/integration/test_librarian_ping.py` (stub podszywa się pod librariana): OK round-trip, timeout gdy przyjęte-ale-bez-pongu, unreachable, non-200, porzucenie sierocego pongu, oraz że **normalne wyniki nadal docierają do IN_COMM_Q**. Lokalnie: **24 integration + 41 unit** zielone. ## Obserwacja na przyszłość Jeśli librarian akurat mieli wielogodzinne wyszukanie, ping czeka za nim w kolejce i po 3s cog zostaje wyłączony — watchdog spróbuje ponownie za 300s. To świadomie zachowawcze (ping idzie *przez* kolejkę, nie omija jej). Gdyby przeszkadzało, można dać pingowi osobny, priorytetowy tor. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
gitea self-assigned this 2026-08-01 12:57:34 +00:00
gitea added 1 commit 2026-08-01 13:00:22 +00:00
Gate librarian cog on a full ping round-trip, not a bare GET
CI / compile (pull_request) Successful in 9s
CI / unit (pull_request) Successful in 18s
CI / integration (pull_request) Successful in 18s
ed8b271b4e
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>
gitea force-pushed librarian-ping-healthcheck from c36d6d3fc2 to ed8b271b4e 2026-08-01 13:00:22 +00:00 Compare
Author
Owner

Aktualizacja zakresu — druga część (commit ce18b38)

Po uwadze recenzenta dorobione dwa mechanizmy rozróżniające przypadki awarii:

1. Ping świadomy "mielenia" (przypadek b — zerwana ścieżka powrotna)

Gdy worker akurat mieli wyszukanie, ping nie czeka już w kolejce za nim (co robiło ze zdrowego-ale-zajętego librariana "martwego"). Librarian trzyma flagę worker_busy i przy zajętości odsyła pong od razu, bez kolejki. Zajętość = zdrowie, można dokładać kolejne wyszukiwania. Ping i tak przechodzi ścieżką powrotną librarian→bot, więc dalej łapie to, co ma łapać: zerwaną/niekompatybilną ścieżkę powrotną, na której zapytania giną. Ping w stanie bezczynności nadal idzie przez kolejkę wewnętrzną.

2. Watchdog per-zapytanie (przypadek a — skończone, ale wynik zginął)

Librarian śledzi cykl życia każdego uuid (queued → processing → zniknął) w active_queries, wystawione przez nowe POST /query_status. Po wysłaniu zapytania bot zapisuje je w self.pending; watch_pending odpytuje /query_status. Dopóki librarian zna uuid — zapytanie idzie, nie ruszamy. Gdy uuid zniknie u librariana, a u bota wciąż jest pending → wynik policzony, ale niedostarczony: po oknie karencji (żeby odsiać wynik "w locie") bot wrzuca info na czat — tylko wtedy. Normalnie dostarczony wynik jest zdejmowany z self.pending przez check_data_q i nigdy nie trafia na ścieżkę alarmu.

Hardening: ciało wyszukiwania w workerze owinięte w try/except/finally — crash pojedynczego wyszukiwania nie zabije wątku (nie zamrozi kolejki), a worker_busy/active_queries zawsze się czyszczą. Logika karencji wydzielona do bezzależnościowego librarian_watchdog.pending_verdict (unit-testowalna bez discord/pdf). /ping i /query_status są sync, więc działają bez flask[async].

Testy: unit test_librarian_watchdog (przejścia werdyktu) + integration test_librarian_query_lifecycle (query_status known/unknown + auth, idle-ping-do-kolejki, busy-ping-pong-bezpośredni). Suite: 28 integration + 48 unit zielone.

## Aktualizacja zakresu — druga część (commit ce18b38) Po uwadze recenzenta dorobione dwa mechanizmy rozróżniające przypadki awarii: ### 1. Ping świadomy "mielenia" (przypadek b — zerwana ścieżka powrotna) Gdy worker akurat mieli wyszukanie, ping **nie czeka już w kolejce za nim** (co robiło ze zdrowego-ale-zajętego librariana "martwego"). Librarian trzyma flagę `worker_busy` i przy zajętości **odsyła pong od razu, bez kolejki**. Zajętość = zdrowie, można dokładać kolejne wyszukiwania. Ping i tak przechodzi ścieżką powrotną librarian→bot, więc dalej łapie to, co ma łapać: zerwaną/niekompatybilną ścieżkę powrotną, na której zapytania giną. Ping w stanie bezczynności nadal idzie przez kolejkę wewnętrzną. ### 2. Watchdog per-zapytanie (przypadek a — skończone, ale wynik zginął) Librarian śledzi cykl życia każdego uuid (`queued → processing → zniknął`) w `active_queries`, wystawione przez nowe `POST /query_status`. Po wysłaniu zapytania bot zapisuje je w `self.pending`; `watch_pending` odpytuje `/query_status`. Dopóki librarian zna uuid — zapytanie idzie, nie ruszamy. Gdy uuid **zniknie** u librariana, a u bota wciąż jest pending → wynik policzony, ale niedostarczony: po oknie karencji (żeby odsiać wynik "w locie") bot wrzuca info na czat — **tylko wtedy**. Normalnie dostarczony wynik jest zdejmowany z `self.pending` przez `check_data_q` i nigdy nie trafia na ścieżkę alarmu. **Hardening:** ciało wyszukiwania w workerze owinięte w try/except/finally — crash pojedynczego wyszukiwania nie zabije wątku (nie zamrozi kolejki), a `worker_busy`/`active_queries` zawsze się czyszczą. Logika karencji wydzielona do bezzależnościowego `librarian_watchdog.pending_verdict` (unit-testowalna bez discord/pdf). `/ping` i `/query_status` są sync, więc działają bez `flask[async]`. **Testy:** unit `test_librarian_watchdog` (przejścia werdyktu) + integration `test_librarian_query_lifecycle` (query_status known/unknown + auth, idle-ping-do-kolejki, busy-ping-pong-bezpośredni). Suite: **28 integration + 48 unit** zielone.
gitea added 1 commit 2026-08-01 16:58:05 +00:00
Librarian: busy-aware ping + per-query lost-result watchdog
CI / compile (pull_request) Successful in 10s
CI / unit (pull_request) Successful in 20s
CI / integration (pull_request) Successful in 20s
CI / compile (push) Successful in 10s
CI / unit (push) Successful in 21s
CI / integration (push) Successful in 20s
build / build (push) Successful in 57s
5d321f2f5b
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>
gitea force-pushed librarian-ping-healthcheck from ce18b386d3 to 5d321f2f5b 2026-08-01 16:58:05 +00:00 Compare
gitea merged commit 5d321f2f5b into main 2026-08-01 17:09:08 +00:00
gitea deleted branch librarian-ping-healthcheck 2026-08-01 17:09:08 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: gitea/conjurer#10