homelab-codex-ws/jobs/mail-body-ingest/tests/test_benchmark.py

156 lines
5.4 KiB
Python
Raw Normal View History

feat(kb-mail-batching): retry + izolacja trujacego chunka w torze embed + benchmark Batching /api/embed juz istnial (Krok 1 fazy mailowej, batch 64). Recon przed Etapem B wykazal w torze backfillu blad blokujacy i dwie luki. BUG (blokujacy dla Etapu B): flush_embed_buffer lapal wylacznie aiohttp.ClientError, a wyczerpanie ClientTimeout(total=...) rzuca goly builtins.TimeoutError, ktory NIE jest jego podklasa (zweryfikowane empirycznie na aiohttp 3.14.3). Zawieszona Ollama — czyli jej udokumentowany failure mode, "przyjmuje polaczenie i milczy" — wywalala caly run nieobsluzonym wyjatkiem, bez breakera i bez flushu threadingu. Na plastrze 50k = utrata zarobionej pracy. Klasy przejsciowe nazwane teraz jawnie w TRANSIENT_EMBED_ERRORS. kb-retrieval: - embed_batch(timeout_s=...) — bound per zadanie, skalowalny z batch size - embed_batch_resilient() — retry z backoffem wykladniczym, a po ich wyczerpaniu probe /api/tags rozstrzyga: backend zywy -> bisekcja izolujaca trujacy chunk (jeden zly tekst kosztowal caly batch 64, bo /api/embed jest all-or-nothing); backend martwy -> natychmiastowe gave_up bez bisekcji, ktora spalilaby 2n-1 zadan i opoznila breaker. EmbeddingDimensionError nigdy nie jest retry'owane. - failed_indices wyprowadzane z wyniku, nie akumulowane per span — przy gave_up w srodku bisekcji porzucone poddrzewo nigdy nie dochodzi do liscia. mail-body-ingest: - breaker liczy give-upy (backend padl), nie dowolne nieudane batche; porazka czesciowa przy zywym backendzie nie przesuwa licznika, bo te chunki i tak zlapie kolejny run przez idempotencje - wiersze zembedowane w umierajacym batchu sa commitowane przed abortem - parametryzacja: --batch-size/--embed-retries/--embed-backoff/--embed-timeout, kazdy z odpowiednikiem env MAIL_INGEST_*; bledna wartosc env = glosny SystemExit - metryka embed_ms_per_chunk (porownywalna miedzy runami, w odroznieniu od sredniej per batch) + embed_requests_total/embed_calls jako sygnal zdrowia mail-body-ingest-bench: nowy entry point, sweep batch size na realnych chunkach. Read-only (SELECT + inferencja, zero sciezki zapisu), warmup przed pomiarem, ten sam zbior chunkow dla kazdego rozmiaru. Czyni liczby z planu §1.4 odtwarzalnymi. Fallback SOLARIA->PIHA dla backfillu SWIADOMIE nie powstaje (potwierdzone przez operatora): 271k chunkow x 790 ms CPU ~ 60 h na 8 GB PIHA dzielonym z HA i Paperlessem. Wlasciwa odpowiedzia na martwy backend jest exit 2 i wznowienie plastra. Tor online (kb-query -> embed_router) zachowuje fallback — rozdzial torow udokumentowany w docstringu embed.py i w kb/services/. Testy: 117 zielonych (62 job + 22 klient embed + reszta pakietow), w tym regresja na TimeoutError, bisekcja, ograniczony koszt przy martwym backendzie i porazka czesciowa nieprzesuwajaca breakera. Bez uruchamiania backfillu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:02:41 +02:00
"""Unit tests for the batch-size benchmark — no real HTTP, no real Ollama, no DB."""
from __future__ import annotations
import aiohttp
import pytest
from mail_body_ingest.benchmark import (
FULL_CORPUS_CHUNKS,
BenchRow,
bench_size,
format_table,
split_batches,
)
class TestSplitBatches:
def test_exact_division(self):
assert split_batches([1, 2, 3, 4], 2) == [[1, 2], [3, 4]]
def test_trailing_short_batch_is_kept(self):
assert split_batches([1, 2, 3, 4, 5], 2) == [[1, 2], [3, 4], [5]]
def test_size_one_is_one_item_per_batch(self):
assert split_batches([1, 2, 3], 1) == [[1], [2], [3]]
def test_size_larger_than_input_yields_a_single_batch(self):
assert split_batches([1, 2], 64) == [[1, 2]]
def test_empty_input_yields_no_batches(self):
assert split_batches([], 8) == []
def test_covers_every_item_exactly_once(self):
items = list(range(100))
flattened = [i for batch in split_batches(items, 7) for i in batch]
assert flattened == items
def test_zero_or_negative_size_raises(self):
with pytest.raises(ValueError):
split_batches([1, 2], 0)
class TestBenchRow:
def test_ms_per_chunk(self):
row = BenchRow(batch_size=64, chunks=100, requests=2, seconds=2.0)
assert row.ms_per_chunk == 20.0
def test_chunks_per_second(self):
row = BenchRow(batch_size=64, chunks=100, requests=2, seconds=2.0)
assert row.chunks_per_s == 50.0
def test_projected_hours_scales_the_measured_rate_to_the_full_corpus(self):
row = BenchRow(batch_size=64, chunks=100, requests=2, seconds=1.0) # 10 ms/chunk
assert row.projected_hours == pytest.approx(FULL_CORPUS_CHUNKS * 0.010 / 3600)
def test_zero_chunks_does_not_divide_by_zero(self):
row = BenchRow(batch_size=1, chunks=0, requests=0, seconds=0.0)
assert row.ms_per_chunk == 0.0
assert row.chunks_per_s == 0.0
class TestFormatTable:
def _rows(self):
return [
BenchRow(batch_size=1, chunks=100, requests=100, seconds=20.0), # 200 ms/chunk
BenchRow(batch_size=64, chunks=100, requests=2, seconds=1.0), # 10 ms/chunk
]
def test_one_line_per_row_plus_header(self):
out = format_table(self._rows())
assert "batch" in out and "ms/chunk" in out
assert "200.00" in out and "10.00" in out
def test_names_the_best_size_and_the_speedup_over_batch_1(self):
out = format_table(self._rows())
assert "best: batch=64" in out
assert "20.0x faster than batch=1" in out
def test_no_speedup_note_when_batch_1_wins(self):
rows = [BenchRow(batch_size=1, chunks=100, requests=100, seconds=1.0)]
out = format_table(rows)
assert "best: batch=1" in out
assert "faster than" not in out
def test_empty_rows_still_renders_a_header(self):
assert "batch" in format_table([])
def test_a_size_that_failed_entirely_is_not_reported_as_best(self):
"""A batch size the backend chokes on measures 0 chunks; ranking it best would read as
'infinitely fast' instead of 'did not work'."""
rows = [
BenchRow(batch_size=8, chunks=100, requests=13, seconds=2.0),
BenchRow(batch_size=512, chunks=0, requests=0, seconds=0.0, failures=1),
]
assert "best: batch=8" in format_table(rows)
class _FakeResponse:
def __init__(self, payload, fail=False):
self._payload = payload
self._fail = fail
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def json(self):
return self._payload
def raise_for_status(self):
if self._fail:
raise aiohttp.ClientConnectionError("simulated failure")
class _FakeSession:
def __init__(self, dim=1024, fail_sizes=()):
self._dim = dim
self._fail_sizes = set(fail_sizes)
self.posts: list[int] = []
def post(self, url, json, timeout=None):
texts = json["input"]
self.posts.append(len(texts))
if len(texts) in self._fail_sizes:
return _FakeResponse({}, fail=True)
return _FakeResponse({"embeddings": [[0.01] * self._dim for _ in texts]})
class TestBenchSize:
async def test_measures_every_chunk_once(self):
session = _FakeSession()
chunks = [f"c{i}" for i in range(10)]
row = await bench_size(session, "http://fake", "bge-m3", chunks, 4, 60.0)
assert row.chunks == 10
assert row.requests == 3 # 4 + 4 + 2
assert session.posts == [4, 4, 2]
assert row.failures == 0
async def test_failures_are_counted_not_raised(self):
"""A size the backend can't serve is a result, not a reason to abandon the sweep."""
session = _FakeSession(fail_sizes={8})
chunks = [f"c{i}" for i in range(16)]
row = await bench_size(session, "http://fake", "bge-m3", chunks, 8, 60.0)
assert row.failures == 2
assert row.chunks == 0
assert row.ms_per_chunk == 0.0
async def test_partial_failure_still_measures_the_successful_batches(self):
session = _FakeSession(fail_sizes={2}) # only the short trailing batch fails
chunks = [f"c{i}" for i in range(10)]
row = await bench_size(session, "http://fake", "bge-m3", chunks, 4, 60.0)
assert row.chunks == 8
assert row.requests == 2
assert row.failures == 1