2026-07-22 16:06:02 +02:00
|
|
|
"""Unit tests for the Ollama embedding client -- no real HTTP, no real Ollama."""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
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
|
|
|
import aiohttp
|
2026-07-22 16:06:02 +02:00
|
|
|
import pytest
|
|
|
|
|
|
2026-07-22 19:01:02 +02:00
|
|
|
from kb_retrieval.embed import (
|
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
|
|
|
TRANSIENT_EMBED_ERRORS,
|
2026-07-22 19:01:02 +02:00
|
|
|
EmbeddingDimensionError,
|
|
|
|
|
_vector_literal,
|
|
|
|
|
check_ollama_health,
|
|
|
|
|
embed_batch,
|
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
|
|
|
embed_batch_resilient,
|
2026-07-22 19:01:02 +02:00
|
|
|
embed_chunk,
|
|
|
|
|
)
|
2026-07-22 16:06:02 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestVectorLiteral:
|
|
|
|
|
def test_formats_as_bracketed_csv(self):
|
|
|
|
|
assert _vector_literal([0.1, 0.2, -0.3]) == "[0.1,0.2,-0.3]"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeEmbedResponse:
|
|
|
|
|
def __init__(self, payload, status=200):
|
|
|
|
|
self._payload = payload
|
|
|
|
|
self._status = status
|
|
|
|
|
|
|
|
|
|
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._status >= 400:
|
|
|
|
|
raise RuntimeError(f"HTTP {self._status}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeOllamaSession:
|
|
|
|
|
"""Serves a fixed embedding vector for every /api/embeddings POST, or errors by prompt."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, dim=1024, fail_for=None):
|
|
|
|
|
self._dim = dim
|
|
|
|
|
self._fail_for = fail_for or set()
|
|
|
|
|
self.requests: list[dict] = []
|
|
|
|
|
|
|
|
|
|
def post(self, url, json):
|
|
|
|
|
self.requests.append({"url": url, "json": json})
|
|
|
|
|
if json["prompt"] in self._fail_for:
|
|
|
|
|
return _FakeEmbedResponse({}, status=500)
|
|
|
|
|
return _FakeEmbedResponse({"embedding": [0.01] * self._dim})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEmbedChunk:
|
|
|
|
|
async def test_returns_embedding_and_elapsed(self):
|
|
|
|
|
session = _FakeOllamaSession(dim=1024)
|
|
|
|
|
embedding, elapsed = await embed_chunk(session, "http://fake-ollama", "bge-m3", "hello world")
|
|
|
|
|
assert len(embedding) == 1024
|
|
|
|
|
assert elapsed >= 0
|
|
|
|
|
assert session.requests == [
|
|
|
|
|
{"url": "http://fake-ollama/api/embeddings", "json": {"model": "bge-m3", "prompt": "hello world"}}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
async def test_missing_embedding_key_raises(self):
|
|
|
|
|
class _EmptyResponse(_FakeEmbedResponse):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
class _Session:
|
|
|
|
|
def post(self, url, json):
|
|
|
|
|
return _EmptyResponse({})
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
await embed_chunk(_Session(), "http://fake-ollama", "bge-m3", "text")
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 19:01:02 +02:00
|
|
|
class _FakeBatchResponse(_FakeEmbedResponse):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeBatchOllamaSession:
|
|
|
|
|
"""Serves a fixed set of embeddings for every /api/embed POST."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, dim=1024, n_override=None, bad_index=None):
|
|
|
|
|
self._dim = dim
|
|
|
|
|
self._n_override = n_override
|
|
|
|
|
self._bad_index = bad_index
|
|
|
|
|
self.requests: list[dict] = []
|
|
|
|
|
|
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
|
|
|
def post(self, url, json, timeout=None):
|
|
|
|
|
self.requests.append({"url": url, "json": json, "timeout": timeout})
|
2026-07-22 19:01:02 +02:00
|
|
|
texts = json["input"]
|
|
|
|
|
n = self._n_override if self._n_override is not None else len(texts)
|
|
|
|
|
embeddings = [[0.01] * self._dim for _ in range(n)]
|
|
|
|
|
if self._bad_index is not None and self._bad_index < len(embeddings):
|
|
|
|
|
embeddings[self._bad_index] = [0.01] * (self._dim - 1)
|
|
|
|
|
return _FakeBatchResponse({"embeddings": embeddings})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEmbedBatch:
|
|
|
|
|
async def test_returns_embeddings_and_elapsed(self):
|
|
|
|
|
session = _FakeBatchOllamaSession(dim=1024)
|
|
|
|
|
texts = ["hello", "world", "third"]
|
|
|
|
|
embeddings, elapsed = await embed_batch(session, "http://fake-ollama", "bge-m3", texts)
|
|
|
|
|
assert len(embeddings) == 3
|
|
|
|
|
assert all(len(e) == 1024 for e in embeddings)
|
|
|
|
|
assert elapsed >= 0
|
|
|
|
|
assert session.requests == [
|
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
|
|
|
{"url": "http://fake-ollama/api/embed",
|
|
|
|
|
"json": {"model": "bge-m3", "input": texts}, "timeout": None}
|
2026-07-22 19:01:02 +02:00
|
|
|
]
|
|
|
|
|
|
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
|
|
|
async def test_timeout_s_bounds_the_single_request(self):
|
|
|
|
|
session = _FakeBatchOllamaSession(dim=1024)
|
|
|
|
|
await embed_batch(session, "http://fake-ollama", "bge-m3", ["a"], timeout_s=5.0)
|
|
|
|
|
assert session.requests[0]["timeout"].total == 5.0
|
|
|
|
|
|
2026-07-22 19:01:02 +02:00
|
|
|
async def test_length_mismatch_raises_dimension_error(self):
|
|
|
|
|
session = _FakeBatchOllamaSession(dim=1024, n_override=2)
|
|
|
|
|
with pytest.raises(EmbeddingDimensionError):
|
|
|
|
|
await embed_batch(session, "http://fake-ollama", "bge-m3", ["a", "b", "c"])
|
|
|
|
|
|
|
|
|
|
async def test_wrong_dimension_on_one_item_raises(self):
|
|
|
|
|
session = _FakeBatchOllamaSession(dim=1024, bad_index=1)
|
|
|
|
|
with pytest.raises(EmbeddingDimensionError):
|
|
|
|
|
await embed_batch(session, "http://fake-ollama", "bge-m3", ["a", "b", "c"])
|
|
|
|
|
|
|
|
|
|
async def test_missing_embeddings_key_raises_value_error(self):
|
|
|
|
|
class _Session:
|
|
|
|
|
def post(self, url, json):
|
|
|
|
|
return _FakeBatchResponse({})
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
await embed_batch(_Session(), "http://fake-ollama", "bge-m3", ["a"])
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 16:06:02 +02:00
|
|
|
class _FakeTagsResponse:
|
|
|
|
|
def __init__(self, status):
|
|
|
|
|
self.status = status
|
|
|
|
|
|
|
|
|
|
async def __aenter__(self):
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
async def __aexit__(self, *exc):
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeHealthSession:
|
|
|
|
|
def __init__(self, status=200, raises=None):
|
|
|
|
|
self._status = status
|
|
|
|
|
self._raises = raises
|
|
|
|
|
|
|
|
|
|
def get(self, url, timeout=None):
|
|
|
|
|
if self._raises is not None:
|
|
|
|
|
raise self._raises
|
|
|
|
|
return _FakeTagsResponse(self._status)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCheckOllamaHealth:
|
|
|
|
|
async def test_up_on_2xx(self):
|
|
|
|
|
session = _FakeHealthSession(status=200)
|
|
|
|
|
assert await check_ollama_health(session, "http://fake-ollama", 0.5) is True
|
|
|
|
|
|
|
|
|
|
async def test_down_on_error_status(self):
|
|
|
|
|
session = _FakeHealthSession(status=500)
|
|
|
|
|
assert await check_ollama_health(session, "http://fake-ollama", 0.5) is False
|
|
|
|
|
|
|
|
|
|
async def test_down_on_connection_error(self):
|
|
|
|
|
import aiohttp
|
|
|
|
|
|
|
|
|
|
session = _FakeHealthSession(raises=aiohttp.ClientConnectionError("refused"))
|
|
|
|
|
assert await check_ollama_health(session, "http://fake-ollama", 0.5) is False
|
|
|
|
|
|
|
|
|
|
async def test_down_on_timeout(self):
|
|
|
|
|
session = _FakeHealthSession(raises=TimeoutError())
|
|
|
|
|
assert await check_ollama_health(session, "http://fake-ollama", 0.5) is False
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FailResponse(_FakeEmbedResponse):
|
|
|
|
|
"""A transport failure, i.e. something in TRANSIENT_EMBED_ERRORS -- not the plain
|
|
|
|
|
RuntimeError `_FakeEmbedResponse` raises, which would (correctly) never be retried."""
|
|
|
|
|
|
|
|
|
|
def raise_for_status(self):
|
|
|
|
|
raise aiohttp.ClientConnectionError("simulated transport failure")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _ResilientSession:
|
|
|
|
|
"""Drives every branch of `embed_batch_resilient`.
|
|
|
|
|
|
|
|
|
|
* `poison` -- texts that fail whatever batch they land in (the all-or-nothing property of
|
|
|
|
|
/api/embed that bisection exists to work around)
|
|
|
|
|
* `fail_first` -- the first N posts fail regardless (a transient blip)
|
|
|
|
|
* `post_exc` -- raised instead of returning a response (used for the TimeoutError regression)
|
|
|
|
|
* `health_sequence` -- consumed one entry per /api/tags probe, then falls back to `health`
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, *, dim=1024, poison=(), fail_first=0, health=True,
|
|
|
|
|
health_sequence=None, post_exc=None):
|
|
|
|
|
self._dim = dim
|
|
|
|
|
self._poison = set(poison)
|
|
|
|
|
self._fail_first = fail_first
|
|
|
|
|
self._health = health
|
|
|
|
|
self._health_sequence = list(health_sequence or [])
|
|
|
|
|
self._post_exc = post_exc
|
|
|
|
|
self.posts: list[list[str]] = []
|
|
|
|
|
self.health_checks = 0
|
|
|
|
|
|
|
|
|
|
def post(self, url, json, timeout=None):
|
|
|
|
|
texts = json["input"]
|
|
|
|
|
self.posts.append(list(texts))
|
|
|
|
|
if self._post_exc is not None:
|
|
|
|
|
raise self._post_exc
|
|
|
|
|
if self._fail_first > 0:
|
|
|
|
|
self._fail_first -= 1
|
|
|
|
|
return _FailResponse({})
|
|
|
|
|
if self._poison & set(texts):
|
|
|
|
|
return _FailResponse({})
|
|
|
|
|
return _FakeBatchResponse({"embeddings": [[0.01] * self._dim for _ in texts]})
|
|
|
|
|
|
|
|
|
|
def get(self, url, timeout=None):
|
|
|
|
|
self.health_checks += 1
|
|
|
|
|
up = self._health_sequence.pop(0) if self._health_sequence else self._health
|
|
|
|
|
return _FakeTagsResponse(200 if up else 500)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _recorder():
|
|
|
|
|
sleeps: list[float] = []
|
|
|
|
|
|
|
|
|
|
async def fake_sleep(seconds):
|
|
|
|
|
sleeps.append(seconds)
|
|
|
|
|
|
|
|
|
|
return sleeps, fake_sleep
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEmbedBatchResilient:
|
|
|
|
|
async def test_clean_run_costs_one_request_and_no_health_probe(self):
|
|
|
|
|
session = _ResilientSession()
|
|
|
|
|
outcome = await embed_batch_resilient(
|
|
|
|
|
session, "http://fake-ollama", "bge-m3", ["a", "b", "c"]
|
|
|
|
|
)
|
|
|
|
|
assert outcome.ok_count == 3
|
|
|
|
|
assert outcome.failed_indices == []
|
|
|
|
|
assert outcome.gave_up is False
|
|
|
|
|
assert outcome.requests == 1
|
|
|
|
|
assert outcome.retries == 0
|
|
|
|
|
assert session.health_checks == 0
|
|
|
|
|
|
|
|
|
|
async def test_transient_failure_is_retried_with_exponential_backoff(self):
|
|
|
|
|
session = _ResilientSession(fail_first=2)
|
|
|
|
|
sleeps, fake_sleep = _recorder()
|
|
|
|
|
outcome = await embed_batch_resilient(
|
|
|
|
|
session, "http://fake-ollama", "bge-m3", ["a", "b"],
|
|
|
|
|
retries=2, backoff_s=1.0, sleep=fake_sleep,
|
|
|
|
|
)
|
|
|
|
|
assert outcome.ok_count == 2
|
|
|
|
|
assert outcome.requests == 3
|
|
|
|
|
assert outcome.retries == 2
|
|
|
|
|
assert sleeps == [1.0, 2.0]
|
|
|
|
|
assert session.health_checks == 0 # never needed -- the retry recovered it
|
|
|
|
|
|
|
|
|
|
async def test_timeout_is_transient_and_never_escapes(self):
|
|
|
|
|
"""Regression: aiohttp raises a bare builtins.TimeoutError when ClientTimeout(total=...)
|
|
|
|
|
expires, and that is NOT an aiohttp.ClientError. Callers catching ClientError alone
|
|
|
|
|
crashed instead of degrading -- exactly Ollama@SOLARIA's hang-not-refuse failure mode."""
|
|
|
|
|
assert not isinstance(TimeoutError(), aiohttp.ClientError) # the trap this guards
|
|
|
|
|
assert isinstance(TimeoutError(), TRANSIENT_EMBED_ERRORS)
|
|
|
|
|
|
|
|
|
|
session = _ResilientSession(post_exc=TimeoutError(), health=False)
|
|
|
|
|
_sleeps, fake_sleep = _recorder()
|
|
|
|
|
outcome = await embed_batch_resilient(
|
|
|
|
|
session, "http://fake-ollama", "bge-m3", ["a", "b"], retries=1, sleep=fake_sleep,
|
|
|
|
|
)
|
|
|
|
|
assert outcome.gave_up is True
|
|
|
|
|
assert outcome.failed_indices == [0, 1]
|
|
|
|
|
|
|
|
|
|
async def test_dead_backend_gives_up_without_bisecting(self):
|
|
|
|
|
texts = [f"t{i}" for i in range(8)]
|
|
|
|
|
session = _ResilientSession(poison=texts, health=False)
|
|
|
|
|
_sleeps, fake_sleep = _recorder()
|
|
|
|
|
|
|
|
|
|
outcome = await embed_batch_resilient(
|
|
|
|
|
session, "http://fake-ollama", "bge-m3", texts, retries=1, sleep=fake_sleep,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert outcome.gave_up is True
|
|
|
|
|
assert outcome.ok_count == 0
|
|
|
|
|
assert outcome.failed_indices == list(range(8))
|
|
|
|
|
# 1 attempt + 1 retry, then the health probe stops it. Bisecting a dead backend would
|
|
|
|
|
# cost 2n-1 requests and delay the caller's circuit breaker.
|
|
|
|
|
assert len(session.posts) == 2
|
|
|
|
|
assert session.health_checks == 1
|
|
|
|
|
|
|
|
|
|
async def test_poison_item_is_isolated_and_the_rest_still_embed(self):
|
|
|
|
|
texts = [f"t{i}" for i in range(8)]
|
|
|
|
|
session = _ResilientSession(poison={"t3"}, health=True)
|
|
|
|
|
outcome = await embed_batch_resilient(
|
|
|
|
|
session, "http://fake-ollama", "bge-m3", texts, retries=0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert outcome.gave_up is False
|
|
|
|
|
assert outcome.failed_indices == [3]
|
|
|
|
|
assert outcome.ok_count == 7
|
|
|
|
|
assert outcome.embeddings[3] is None
|
|
|
|
|
assert all(outcome.embeddings[i] is not None for i in range(8) if i != 3)
|
|
|
|
|
assert len(session.posts) < 2 * len(texts) # bisection, not one request per item
|
|
|
|
|
|
|
|
|
|
async def test_bisected_spans_do_not_retry(self):
|
|
|
|
|
"""The top-level attempt already proved this isn't a blip; re-retrying every sub-span
|
|
|
|
|
would multiply a poison chunk's cost by (retries+1) at every level of the bisection."""
|
|
|
|
|
texts = ["a", "b", "c", "d"]
|
|
|
|
|
session = _ResilientSession(poison={"c"}, health=True)
|
|
|
|
|
sleeps, fake_sleep = _recorder()
|
|
|
|
|
|
|
|
|
|
outcome = await embed_batch_resilient(
|
|
|
|
|
session, "http://fake-ollama", "bge-m3", texts,
|
|
|
|
|
retries=2, backoff_s=1.0, sleep=fake_sleep,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert outcome.failed_indices == [2]
|
|
|
|
|
assert sleeps == [1.0, 2.0] # top level only -- no backoff inside the bisection
|
|
|
|
|
assert outcome.retries == 2
|
|
|
|
|
|
|
|
|
|
async def test_backend_dying_mid_bisection_fails_every_unresolved_item(self):
|
|
|
|
|
"""The abandoned right-hand span must still be reported failed. Accumulating
|
|
|
|
|
failed_indices per span silently under-reported it -- it never reaches a leaf."""
|
|
|
|
|
texts = ["a", "b", "c", "d"]
|
|
|
|
|
session = _ResilientSession(poison=texts, health_sequence=[True, False])
|
|
|
|
|
outcome = await embed_batch_resilient(
|
|
|
|
|
session, "http://fake-ollama", "bge-m3", texts, retries=0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert outcome.gave_up is True
|
|
|
|
|
assert outcome.ok_count == 0
|
|
|
|
|
assert outcome.failed_indices == [0, 1, 2, 3]
|
|
|
|
|
assert all(e is None for e in outcome.embeddings)
|
|
|
|
|
|
|
|
|
|
async def test_dimension_error_is_never_retried_or_swallowed(self):
|
|
|
|
|
session = _FakeBatchOllamaSession(dim=1024, n_override=2)
|
|
|
|
|
with pytest.raises(EmbeddingDimensionError):
|
|
|
|
|
await embed_batch_resilient(
|
|
|
|
|
session, "http://fake-ollama", "bge-m3", ["a", "b", "c"], retries=3,
|
|
|
|
|
)
|
|
|
|
|
assert len(session.requests) == 1
|
|
|
|
|
|
|
|
|
|
async def test_empty_input_makes_no_requests(self):
|
|
|
|
|
session = _ResilientSession()
|
|
|
|
|
outcome = await embed_batch_resilient(session, "http://fake-ollama", "bge-m3", [])
|
|
|
|
|
assert outcome.embeddings == []
|
|
|
|
|
assert outcome.requests == 0
|
|
|
|
|
assert session.posts == []
|
|
|
|
|
|
|
|
|
|
async def test_elapsed_and_requests_are_accumulated_across_retries(self):
|
|
|
|
|
session = _ResilientSession(fail_first=1)
|
|
|
|
|
_sleeps, fake_sleep = _recorder()
|
|
|
|
|
outcome = await embed_batch_resilient(
|
|
|
|
|
session, "http://fake-ollama", "bge-m3", ["a"], retries=1, sleep=fake_sleep,
|
|
|
|
|
)
|
|
|
|
|
assert outcome.requests == 2
|
|
|
|
|
assert outcome.elapsed_s >= 0
|