fix(api): run the RAG reconcile in the background, never on the bind path (#346)

The lifespan awaited the reconcile, and the journal/learning backfill
made it expensive: up to 200 entries each needing an Ollama embedding
behind a busy Ollama held the API bind down for 30+ minutes on the NAS
(observed: ~6 embeds/min). uvicorn only binds after the lifespan
completes, so the whole stack 502'd while a best-effort maintenance
pass ran. The reconcile is now a background task scheduled at the end
of startup (crash-logged via done-callback, cancelled at shutdown); the
backfill still converges across boots under its per-boot cap.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-09 01:59:23 +02:00
committed by GitHub
co-authored by Renn F
parent f0b6390189
commit 199cc5d2bc
2 changed files with 95 additions and 17 deletions
+44 -1
View File
@@ -8,7 +8,8 @@ extraction, optimal-service).
from __future__ import annotations
from contextlib import ExitStack, asynccontextmanager
import asyncio
from contextlib import ExitStack, asynccontextmanager, suppress
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock, patch
@@ -142,6 +143,48 @@ async def test_lifespan_startup_and_shutdown_happy_path() -> None:
transcription_mock.stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_lifespan_never_awaits_the_rag_reconcile() -> None:
"""The reconcile (incl. the journal backfill) runs as a background task —
a slow pass must not delay the API bind (the 2026-07-09 outage: a
200-entry backfill behind a busy Ollama held startup for 30+ minutes)."""
release = asyncio.Event()
entered = asyncio.Event()
async def _slow_reconcile(_app: object) -> None:
entered.set()
await release.wait()
transcription_mock = MagicMock()
transcription_mock.start = AsyncMock()
transcription_mock.stop = AsyncMock()
with (
patch("roboco.api.app.init_db", new=AsyncMock()),
patch("roboco.api.app.close_db", new=AsyncMock()),
patch("roboco.api.app.TranscriptionService", return_value=transcription_mock),
patch("roboco.api.app.ExtractionService"),
patch("roboco.api.app.ExtractionPipeline"),
patch(
"roboco.api.app.get_optimal_service",
new=AsyncMock(return_value=MagicMock()),
),
patch("roboco.api.app.close_optimal_service", new=AsyncMock()),
patch("roboco.api.app._reconcile_rag_indexes", new=_slow_reconcile),
):
app = create_app()
async with lifespan(app):
# Startup completed while the reconcile is still blocked — it was
# scheduled, not awaited.
task = app.state.rag_reconcile_task
assert not task.done()
await asyncio.wait_for(entered.wait(), timeout=2)
# Shutdown requested cancellation; let the loop deliver it.
with suppress(asyncio.CancelledError):
await task
assert task.cancelled()
@pytest.mark.asyncio
async def test_lifespan_stops_orchestrator_before_closing_db_and_optimal() -> None:
"""orchestrator.stop() runs BEFORE close_optimal_service / close_db on