merge: #796 — drop a pool host that dies mid-run and retry the batch on the survivors (refs #796)
All checks were successful
CI / lint (push) Successful in 28s
CI / notebooks-smoke (push) Successful in 1m41s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / llm (push) Has been skipped
Deploy / mc (push) Has been skipped
CI / test (push) Successful in 2m14s
Infra CI / zotero (push) Successful in 13s
Infra CI / docs (push) Successful in 26s
Infra CI / notebooks (push) Successful in 59s
Infra CI / llm (push) Successful in 49s
Infra CI / api (push) Successful in 1m2s
Infra CI / mc (push) Successful in 14s
Deploy / report (push) Successful in 14s

This commit is contained in:
kert
2026-09-22 15:52:30 -04:00
2 changed files with 88 additions and 8 deletions

View File

@@ -13,6 +13,7 @@ stay least-loaded (``acquire``).
from __future__ import annotations from __future__ import annotations
import logging
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@@ -25,6 +26,7 @@ from langchain_core.embeddings import Embeddings
from llm import metrics from llm import metrics
_TIMEOUT = httpx.Timeout(120.0, connect=5.0) _TIMEOUT = httpx.Timeout(120.0, connect=5.0)
log = logging.getLogger(__name__)
class HostPool: class HostPool:
@@ -108,9 +110,20 @@ class HostPool:
if host in self._in_flight: if host in self._in_flight:
self._in_flight[host] -= 1 self._in_flight[host] -= 1
def drop(self, host: str) -> None:
"""Forget ``host`` for the rest of the run — a transport failure
after ``check()`` passed means it went away mid-run (#796)."""
with self._lock:
self._in_flight.pop(host.rstrip("/"), None)
self._models.pop(host.rstrip("/"), None)
@contextmanager @contextmanager
def acquire(self) -> Iterator[str]: def acquire(self) -> Iterator[str]:
with self._lock: with self._lock:
if not self._in_flight:
raise RuntimeError(
"no live Ollama host in pool — check() or drop() left none"
)
host = min(self._in_flight, key=self._in_flight.__getitem__) host = min(self._in_flight, key=self._in_flight.__getitem__)
self._in_flight[host] += 1 self._in_flight[host] += 1
try: try:
@@ -127,6 +140,10 @@ class HostPool:
serve the model; this then realises "largest GPU available now". serve the model; this then realises "largest GPU available now".
""" """
with self._lock: with self._lock:
if not self._in_flight:
raise RuntimeError(
"no live Ollama host in pool — check() or drop() left none"
)
host = max( host = max(
self._in_flight, self._in_flight,
key=lambda h: (self._vram.get(h, 0.0), -self._in_flight[h]), key=lambda h: (self._vram.get(h, 0.0), -self._in_flight[h]),
@@ -166,14 +183,37 @@ def embed_texts(
results: dict[int, list[list[float]]] = {} results: dict[int, list[list[float]]] = {}
def run(start: int, batch: list[str]) -> None: def run(start: int, batch: list[str]) -> None:
with pool.acquire() as host, httpx.Client(timeout=_TIMEOUT) as client: # A host that passed check() can still vanish mid-run (mDNS name
began = time.monotonic() # stops resolving, box sleeps): drop it and retry the batch on the
resp = client.post( # survivors instead of failing the whole run (#796). A host that
f"{host}/api/embed", json={"model": model, "input": batch} # answers with an HTTP error is a model/config problem and still
) # raises through raise_for_status.
resp.raise_for_status() while True:
results[start] = resp.json()["embeddings"] with pool.acquire() as host, httpx.Client(timeout=_TIMEOUT) as client:
metrics.embedded(host, len(batch), time.monotonic() - began) began = time.monotonic()
try:
resp = client.post(
f"{host}/api/embed", json={"model": model, "input": batch}
)
except httpx.TransportError as exc:
pool.drop(host)
left = pool.hosts
log.warning(
"dropping %s after %s: %s; %d host(s) left",
host,
type(exc).__name__,
exc,
len(left),
)
if not left:
raise RuntimeError(
f"every Ollama host failed; last {host}: {exc}"
) from exc
continue
resp.raise_for_status()
results[start] = resp.json()["embeddings"]
metrics.embedded(host, len(batch), time.monotonic() - began)
return
workers = max(1, min(len(pool.hosts) * 2, len(batches))) workers = max(1, min(len(pool.hosts) * 2, len(batches)))
with ThreadPoolExecutor(max_workers=workers) as ex: with ThreadPoolExecutor(max_workers=workers) as ex:

View File

@@ -2,6 +2,7 @@
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import httpx
import pytest import pytest
from llm.config import LlmConfig from llm.config import LlmConfig
@@ -116,6 +117,45 @@ class TestEmbedTexts:
out = embed_texts(pool, "m", ["a", "bb", "ccc"], batch_size=1) out = embed_texts(pool, "m", ["a", "bb", "ccc"], batch_size=1)
assert out == [[1.0], [2.0], [3.0]] assert out == [[1.0], [2.0], [3.0]]
@patch("llm.pool.httpx.Client")
def test_dying_host_is_dropped_and_batch_retried_on_survivor(self, MockClient):
"""#796: a transport error from one host must not abort the run —
the host leaves the pool and the batch completes elsewhere."""
client = MockClient.return_value.__enter__.return_value
calls: list[str] = []
def fake_post(url, json=None, **kw):
calls.append(url)
if url.startswith(H1):
raise httpx.ConnectError("[Errno -2] Name or service not known")
return _resp({"embeddings": [[float(len(t))] for t in json["input"]]})
client.post.side_effect = fake_post
pool = HostPool([H1, H2])
out = embed_texts(pool, "m", ["a", "bb", "ccc"], batch_size=1)
assert out == [[1.0], [2.0], [3.0]]
assert pool.hosts == [H2]
assert any(u.startswith(H1) for u in calls) # H1 was tried, then dropped
assert pool._in_flight == {H2: 0} # dropped host never left a count behind
@patch("llm.pool.httpx.Client")
def test_every_host_dying_raises_runtime_error(self, MockClient):
client = MockClient.return_value.__enter__.return_value
client.post.side_effect = httpx.ReadTimeout("timed out")
pool = HostPool([H1, H2])
with pytest.raises(RuntimeError, match="every Ollama host failed"):
embed_texts(pool, "m", ["a"])
assert pool.hosts == []
def test_acquire_on_empty_pool_is_a_clear_error(self):
pool = HostPool([])
with pytest.raises(RuntimeError, match="no live Ollama host"):
with pool.acquire():
pass
with pytest.raises(RuntimeError, match="no live Ollama host"):
with pool.acquire_generation():
pass
@patch("llm.pool.httpx.Client") @patch("llm.pool.httpx.Client")
def test_http_error_raises(self, MockClient): def test_http_error_raises(self, MockClient):
client = MockClient.return_value.__enter__.return_value client = MockClient.return_value.__enter__.return_value