haiku.rag/tests/ingester/test_pollers.py
Yiorgis Gozadinos 4aee18dcbe
Operator dashboard at GET /; tighten Logfire span shape
Self-contained HTML status page served from the ingester's FastAPI app.
Polls /health, /sources, /stats, /jobs?status={claimed,dead,succeeded}
every 3s from the browser and renders queue chips, sources with
last-poll/skip-reason/circuit state, active jobs with cancel, recent
failures with retry, and recently-completed feed with op badges so
DELETE rows are visually distinct from UPSERTs. Zero external deps —
single static HTML, no CDN, no fonts, no images. Works offline.

To support the dashboard:
- New /stats endpoint exposing rolling throughput (5m/30m/1h), worker
  occupancy, oldest-queued age, and per-source DLQ + queue-depth
  breakdowns. Each field is a single SQL aggregation against the queue.
- JobRepo gains count_succeeded_since, oldest_queued_age_seconds,
  counts_by_source.
- SourceSummary gains last_skip_reason. BasePoller now records the
  reason the most recent sweep attempt was skipped ("pending_work" /
  "circuit_open"), cleared on the next successful poll. Closes the
  gap where operators couldn't tell from /sources alone why a source
  wasn't picking up new work.

Auth: dashboard route is unauthenticated (markup only). The JS attaches
the bearer to its own JSON fetches; on 401 it prompts once and stashes
the token in localStorage.

Two Logfire fixes that landed alongside:

- Drop logfire.instrument_fastapi() and the [fastapi] extra. The control
  plane is polled frequently (dashboard + docker healthcheck), so every
  endpoint became a span and drowned the useful traces. logfire itself
  stays — pulled in transitively via pydantic-ai-slim[logfire] — so
  ingester.poller.* / ingester.job / document.* spans keep emitting.

- Wrap FSPoller._handle_watch_change in an ingester.poller.watch_event
  span and pass _enqueue_extra. Without this, the watchfiles callback
  ran with no active context, the _otel carrier in job.extra was empty,
  and the worker's ingester.job span surfaced as an orphan trace root
  instead of nesting under the FS event that caused it.
2026-05-26 11:44:47 +03:00

387 lines
12 KiB
Python

import asyncio
from datetime import UTC, datetime
from pathlib import Path
import aiosqlite
import pytest
from haiku.rag.config import (
CircuitBreakerConfig,
FSSourceConfig,
HTTPSourceConfig,
S3SourceConfig,
WebDAVSourceConfig,
)
from haiku.rag.ingester.pollers.circuit_breaker import CircuitBreaker
from haiku.rag.ingester.pollers.manager import PollerManager
from haiku.rag.ingester.pollers.periodic import PeriodicPoller
from haiku.rag.ingester.queue.migrations import apply_migrations
from haiku.rag.ingester.queue.models import JobOp, JobStatus
from haiku.rag.ingester.queue.repository import JobRepo, SyncStateRepo
from haiku.rag.ingester.sources.base import (
FetchResult,
SourceEvent,
SourceEventKind,
)
@pytest.fixture
async def conn(tmp_path):
path = tmp_path / "queue.db"
connection = await aiosqlite.connect(str(path))
connection.row_factory = aiosqlite.Row
await apply_migrations(connection)
yield connection
await connection.close()
@pytest.fixture
def jobs(conn):
return JobRepo(conn)
@pytest.fixture
def sync(conn):
return SyncStateRepo(conn)
class _StubSource:
"""Test double that yields a scripted sequence of events on each
discover() call. `fetch` and `supports` aren't exercised by pollers."""
def __init__(self, source_id: str, sweeps: list[list[SourceEvent]]):
self.source_id = source_id
self._sweeps = list(sweeps)
self.discover_calls = 0
self.fail_with: Exception | None = None
def supports(self, uri: str) -> bool: # pragma: no cover - unused here
return True
async def head(self, uri: str) -> str | None: # pragma: no cover
return None
async def fetch(self, uri: str) -> FetchResult: # pragma: no cover
raise NotImplementedError
async def discover(self, since=None):
self.discover_calls += 1
if self.fail_with is not None:
raise self.fail_with
events = self._sweeps.pop(0) if self._sweeps else []
for event in events:
yield event
def _event(
uri: str,
kind=SourceEventKind.UPSERT,
revision: str | None = "v1",
source_id: str = "src",
):
return SourceEvent(
source_id=source_id,
uri=uri,
kind=kind,
revision=None if kind is SourceEventKind.DELETE else revision,
discovered_at=datetime.now(UTC),
)
@pytest.fixture
def fs_config(tmp_path):
return FSSourceConfig(
type="fs",
id="src",
root=tmp_path,
delete_orphans=True,
poll_interval_s=0.05,
)
def _periodic(source, config, jobs, sync, **kwargs):
return PeriodicPoller(
source=source,
config=config,
job_repo=jobs,
sync_repo=sync,
**kwargs,
)
# --- _sweep_once / event handling on the base class via PeriodicPoller ---
@pytest.mark.asyncio
async def test_upsert_event_enqueues_job_and_touches_sync_state(fs_config, jobs, sync):
source = _StubSource("src", [[_event("file:///a.md", revision="r1")]])
poller = _periodic(source, fs_config, jobs, sync)
ok = await poller._sweep_once()
assert ok is True
queued = await jobs.list_jobs(source_id="src")
assert len(queued) == 1
assert queued[0].op is JobOp.UPSERT
assert queued[0].revision == "r1"
# Pollers DO NOT write revision to sync_state — the worker does that
# after a successful ingest. But last_seen_at is bumped.
snapshot = await sync.get_snapshot("src")
assert snapshot == {} # revision left empty by the poller
@pytest.mark.asyncio
async def test_unchanged_event_touches_sync_state_no_job(fs_config, jobs, sync):
source = _StubSource(
"src", [[_event("file:///a.md", kind=SourceEventKind.UNCHANGED, revision="r1")]]
)
poller = _periodic(source, fs_config, jobs, sync)
await poller._sweep_once()
assert await jobs.list_jobs(source_id="src") == []
assert await sync.get_snapshot("src") == {"file:///a.md": "r1"}
@pytest.mark.asyncio
async def test_delete_event_enqueues_delete_job(fs_config, jobs, sync):
source = _StubSource(
"src", [[_event("file:///gone.md", kind=SourceEventKind.DELETE)]]
)
poller = _periodic(source, fs_config, jobs, sync)
await poller._sweep_once()
queued = await jobs.list_jobs(source_id="src")
assert len(queued) == 1
assert queued[0].op is JobOp.DELETE
@pytest.mark.asyncio
async def test_delete_event_skipped_when_delete_orphans_false(fs_config, jobs, sync):
fs_config = fs_config.model_copy(update={"delete_orphans": False})
source = _StubSource(
"src", [[_event("file:///gone.md", kind=SourceEventKind.DELETE)]]
)
poller = _periodic(source, fs_config, jobs, sync)
await poller._sweep_once()
assert await jobs.list_jobs(source_id="src") == []
@pytest.mark.asyncio
async def test_repeated_sweep_skipped_when_queue_has_pending(fs_config, jobs, sync):
"""Backpressure: once a job is queued/claimed, the next sweep skips
discover() entirely instead of churning the listing operation."""
event = _event("file:///a.md", revision="r1")
source = _StubSource("src", [[event], [event]])
poller = _periodic(source, fs_config, jobs, sync)
assert await poller._sweep_once() is True
assert source.discover_calls == 1
# Second sweep: queue still has the live job → skip without calling discover.
assert await poller._sweep_once() is False
assert source.discover_calls == 1
@pytest.mark.asyncio
async def test_skipped_sweep_records_pending_work_reason(fs_config, jobs, sync):
"""last_skip_reason surfaces 'pending_work' while the queue is saturated
and clears once the next sweep actually polls."""
event = _event("file:///a.md", revision="r1")
source = _StubSource("src", [[event], [event], []])
poller = _periodic(source, fs_config, jobs, sync)
await poller._sweep_once() # first sweep enqueues, succeeds
assert poller.last_skip_reason is None
await poller._sweep_once() # backpressure skips
assert poller.last_skip_reason == "pending_work"
# Drain the queue, sweep again, reason clears.
claimed = await jobs.claim_next("worker")
assert claimed is not None
await jobs.mark_succeeded(claimed.id)
await poller._sweep_once()
assert poller.last_skip_reason is None
@pytest.mark.asyncio
async def test_sweep_resumes_after_queue_drains(fs_config, jobs, sync):
"""Once the queue clears (success, dead, or cancel), sweeps resume."""
event = _event("file:///a.md", revision="r1")
source = _StubSource("src", [[event], []])
poller = _periodic(source, fs_config, jobs, sync)
await poller._sweep_once()
claimed = await jobs.claim_next("worker")
assert claimed is not None
await jobs.mark_succeeded(claimed.id)
assert await poller._sweep_once() is True
assert source.discover_calls == 2
@pytest.mark.asyncio
async def test_circuit_breaker_pauses_sweeps_after_failures(fs_config, jobs, sync):
class _Clock:
now = 0.0
def __call__(self):
return self.now
clock = _Clock()
breaker = CircuitBreaker(
CircuitBreakerConfig(failure_threshold=2, cooldown_s=30.0),
now_fn=clock,
)
source = _StubSource("src", [])
source.fail_with = RuntimeError("upstream down")
poller = _periodic(source, fs_config, jobs, sync, breaker=breaker)
# Two failures open the breaker.
assert await poller._sweep_once() is False
assert await poller._sweep_once() is False
assert breaker.is_open is True
# Third call should be skipped — discover() is not invoked.
before = source.discover_calls
assert await poller._sweep_once() is False
assert source.discover_calls == before
@pytest.mark.asyncio
async def test_sweep_records_last_polled_at_on_success(fs_config, jobs, sync):
source = _StubSource("src", [[]])
poller = _periodic(source, fs_config, jobs, sync)
assert poller.last_polled_at is None
await poller._sweep_once()
assert poller.last_polled_at is not None
assert poller.last_polled_at.tzinfo is not None
@pytest.mark.asyncio
async def test_per_source_retry_policy_overrides_default(jobs, sync, tmp_path):
from haiku.rag.config import RetryPolicyConfig
cfg = FSSourceConfig(
type="fs",
id="src",
root=tmp_path,
retry=RetryPolicyConfig(max_attempts=9),
)
source = _StubSource("src", [[_event("file:///a.md")]])
poller = _periodic(source, cfg, jobs, sync, default_max_attempts=3)
await poller._sweep_once()
queued = await jobs.list_jobs(source_id="src")
assert queued[0].max_attempts == 9
@pytest.mark.asyncio
async def test_storage_options_thread_through_to_job_extra(jobs, sync):
cfg = S3SourceConfig(
type="s3",
id="bucket",
uri="s3://bucket/",
storage_options={"endpoint": "http://seaweed:8333"},
)
source = _StubSource(
"bucket", [[_event("s3://bucket/file.md", source_id="bucket")]]
)
poller = _periodic(source, cfg, jobs, sync)
await poller._sweep_once()
queued = await jobs.list_jobs(source_id="bucket")
# _otel is also threaded into extra so the worker's `ingester.job` span
# can nest under the sweep that enqueued it; assert the source-specific
# keys we care about and ignore the trace context payload.
assert queued[0].extra is not None
assert queued[0].extra["storage_options"] == {"endpoint": "http://seaweed:8333"}
@pytest.mark.asyncio
async def test_http_headers_thread_through_to_job_extra(jobs, sync):
cfg = HTTPSourceConfig(
type="http",
id="auth",
urls=["https://example.com/a.md"],
headers={"Authorization": "Bearer abc"},
)
source = _StubSource(
"auth", [[_event("https://example.com/a.md", source_id="auth")]]
)
poller = _periodic(source, cfg, jobs, sync)
await poller._sweep_once()
queued = await jobs.list_jobs(source_id="auth")
assert queued[0].extra is not None
assert queued[0].extra["headers"] == {"Authorization": "Bearer abc"}
# --- PollerManager lifecycle ---
@pytest.mark.asyncio
async def test_manager_builds_pollers_per_source(tmp_path, jobs, sync):
"""When SourceConfig.id is set, the poller's source uses it verbatim;
when omitted, the adapter auto-derives one from its target."""
configs = [
FSSourceConfig(type="fs", root=tmp_path),
S3SourceConfig(type="s3", uri="s3://bucket/"),
HTTPSourceConfig(type="http", id="urls", urls=[]),
WebDAVSourceConfig(
type="webdav", id="nc", base_url="https://nc.example.com/dav/"
),
]
manager = PollerManager(
configs=configs,
job_repo=jobs,
sync_repo=sync,
)
built = manager.build_pollers()
assert len(built) == 4
assert {p.source_id for p in built} == {
f"fs:{tmp_path.resolve()}",
"s3:bucket/",
"urls",
"nc",
}
@pytest.mark.asyncio
async def test_manager_double_start_raises(tmp_path, jobs, sync):
cfg = FSSourceConfig(
type="fs",
id="local",
root=tmp_path,
poll_interval_s=60.0,
)
manager = PollerManager(configs=[cfg], job_repo=jobs, sync_repo=sync)
await manager.start()
try:
with pytest.raises(RuntimeError, match="already started"):
await manager.start()
finally:
await manager.stop()
# --- FSPoller end-to-end smoke ---
@pytest.mark.asyncio
async def test_fs_poller_enqueues_initial_files(tmp_path, jobs, sync):
(tmp_path / "a.md").write_text("hello")
(tmp_path / "b.md").write_text("world")
cfg = FSSourceConfig(type="fs", id="local", root=tmp_path, poll_interval_s=60.0)
manager = PollerManager(
configs=[cfg], job_repo=jobs, sync_repo=sync, supported_extensions=[".md"]
)
await manager.start()
try:
# Wait for the initial sweep to land jobs.
for _ in range(40):
queued = await jobs.list_jobs(source_id="local")
if len(queued) == 2:
break
await asyncio.sleep(0.05)
finally:
await manager.stop()
queued = await jobs.list_jobs(source_id="local")
assert {Path(j.uri).name for j in queued} == {"a.md", "b.md"}
assert all(j.status is JobStatus.QUEUED for j in queued)