embedder: connect bound, a read bound sized to the batch, one budget across retries; tests with endpoints that stall and refuse mid-batch

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-31 09:05:47 +01:00
commit 39decde8a2
3 changed files with 196 additions and 6 deletions

View file

@ -1142,8 +1142,18 @@ pub(crate) enum Method {
impl Http {
pub(crate) fn new(base_url: &str, timeout: Duration) -> Self {
let agent: ureq::Agent =
ureq::Agent::config_builder().timeout_global(Some(timeout)).http_status_as_error(false).build().into();
Self::connecting_within(base_url, timeout, None)
}
/// As `new`, with a bound on the connect alone, so a host that never
/// answers the connection is not waited on for the whole `timeout`.
pub(crate) fn connecting_within(base_url: &str, timeout: Duration, connect: Option<Duration>) -> Self {
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(timeout))
.timeout_connect(connect)
.http_status_as_error(false)
.build()
.into();
Self { agent, base: base_url.trim_end_matches('/').to_owned() }
}

View file

@ -14,15 +14,43 @@ use super::{Embedder, Http, Method, ModelIdentity};
pub const QUERY_INSTRUCTION: &str =
"Given a question about a project's pipeline state, retrieve the documents that answer it";
/// What an embed call may take, sized to the batch and never beyond the
/// caller's budget: a host that froze after accepting the batch is given up on
/// after `floor + per_text * texts`, not after the caller's whole `within`.
#[derive(Clone, Copy)]
pub struct EmbedBounds {
/// The bound on connecting alone.
pub connect: Duration,
/// What any batch is allowed, a model load included.
pub floor: Duration,
/// What each text adds.
pub per_text: Duration,
}
impl EmbedBounds {
pub const DEFAULT: Self =
Self { connect: Duration::from_secs(3), floor: Duration::from_secs(20), per_text: Duration::from_millis(500) };
fn for_batch(&self, texts: usize, within: Duration) -> Duration {
(self.floor + self.per_text.saturating_mul(u32::try_from(texts).unwrap_or(u32::MAX))).min(within)
}
}
/// One model on one Ollama. Every call carries its own bound.
pub struct OllamaEmbedder {
base_url: String,
model: String,
bounds: EmbedBounds,
}
impl OllamaEmbedder {
pub fn new(base_url: &str, model: &str) -> Self {
Self { base_url: base_url.to_owned(), model: model.to_owned() }
Self { base_url: base_url.to_owned(), model: model.to_owned(), bounds: EmbedBounds::DEFAULT }
}
pub fn bounded_by(mut self, bounds: EmbedBounds) -> Self {
self.bounds = bounds;
self
}
fn ask(&self, http: &Http, method: Method, path: &str, body: Option<&Value>) -> Result<Value> {
@ -57,7 +85,8 @@ impl Embedder for OllamaEmbedder {
}
fn embed(&mut self, texts: &[String], within: Duration) -> Result<Vec<Vec<f32>>> {
let bounded = Http::new(&self.base_url, within);
let allowed = self.bounds.for_batch(texts.len(), within);
let bounded = Http::connecting_within(&self.base_url, allowed, Some(self.bounds.connect.min(allowed)));
let answer = self.ask(&bounded, Method::Post, "/api/embed", Some(&json!({ "model": self.model, "input": texts })))?;
let embeddings = answer["embeddings"].as_array().context("Ollama's answer has no embeddings")?;
embeddings

View file

@ -241,6 +241,9 @@ impl<E: Embedder + Send + 'static> Embedder for EmbedderPool<E> {
}
fn embed(&mut self, texts: &[String], within: Duration) -> Result<Vec<Vec<f32>>> {
// One budget for the batch: a piece moved off a failed endpoint is
// embedded under what is left of it, not under a fresh `within`.
let deadline = Instant::now() + within;
self.admit(within);
let mut vectors: Vec<Option<Vec<f32>>> = vec![None; texts.len()];
// Pieces still owed, as (first index, length) over `texts`.
@ -250,12 +253,16 @@ impl<E: Embedder + Send + 'static> Embedder for EmbedderPool<E> {
if active.is_empty() {
// Every member that took the last round failed; the reserve may
// still be on its first read.
self.admit(within);
self.admit(deadline.saturating_duration_since(Instant::now()));
active = self.active();
}
if active.is_empty() {
bail!("{}", locked(&self.shared.roster).unavailable());
}
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
bail!("the batch's budget of {within:?} is spent; {}", locked(&self.shared.roster).unavailable());
}
// Each owed piece is cut evenly over the active members.
let mut jobs: Vec<(usize, usize, usize)> = Vec::new(); // (member, first, length)
for (first, length) in owed.drain(..) {
@ -274,7 +281,7 @@ impl<E: Embedder + Send + 'static> Embedder for EmbedderPool<E> {
owed.push((first, length));
} else {
let (piece, embedder) = (&texts[first..first + length], &self.embedders[member]);
running.push((member, first, length, scope.spawn(move || locked(embedder).embed(piece, within))));
running.push((member, first, length, scope.spawn(move || locked(embedder).embed(piece, left))));
}
}
for (member, first, length, handle) in running {
@ -560,4 +567,148 @@ mod tests {
pool.embed(&texts(3), WITHIN).unwrap();
assert_eq!((used(&fakes[0]), used(&fakes[1])), (3, 0));
}
// Real Ollama clients against sockets that go wrong after the pool has
// admitted them.
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use crate::index::ollama::{EmbedBounds, OllamaEmbedder};
const TAGS: &str = r#"{"models":[{"name":"m:1","model":"m:1","digest":"d1"}]}"#;
const SHOW: &str = r#"{"model_info":{"qwen3.embedding_length":1}}"#;
const QUICK: EmbedBounds =
EmbedBounds { connect: Duration::from_millis(200), floor: Duration::from_millis(300), per_text: Duration::ZERO };
/// An Ollama on a local port. `stalls` makes it take an embed request and
/// never answer; `gone` closes the listener, so the next connect is refused.
/// An embedding is `[text length]`, so order is checkable.
struct Endpoint {
url: String,
stalls: Arc<AtomicBool>,
gone: Arc<AtomicBool>,
listening: std::thread::JoinHandle<()>,
}
fn endpoint() -> Endpoint {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let (stalls, gone) = (Arc::new(AtomicBool::new(false)), Arc::new(AtomicBool::new(false)));
let (stalling, leaving) = (Arc::clone(&stalls), Arc::clone(&gone));
let listening = std::thread::spawn(move || {
while !leaving.load(Ordering::SeqCst) {
match listener.accept() {
Ok((stream, _)) => {
let stalling = Arc::clone(&stalling);
std::thread::spawn(move || answer(stream, &stalling));
}
Err(_) => std::thread::sleep(Duration::from_millis(5)),
}
}
});
Endpoint { url, stalls, gone, listening }
}
fn answer(mut stream: TcpStream, stalls: &AtomicBool) {
stream.set_nonblocking(false).unwrap();
let mut buffer = Vec::new();
let (head, length) = loop {
let mut chunk = [0_u8; 4096];
match stream.read(&mut chunk) {
Ok(0) | Err(_) => return,
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
}
let text = String::from_utf8_lossy(&buffer).into_owned();
if let Some(end) = text.find("\r\n\r\n") {
let length = text.lines().find_map(|line| line.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse().unwrap())).unwrap_or(0);
break (end + 4, length);
}
};
while buffer.len() < head + length {
let mut chunk = [0_u8; 4096];
match stream.read(&mut chunk) {
Ok(0) | Err(_) => return,
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
}
}
let request = String::from_utf8_lossy(&buffer[..head]).into_owned();
let body = if request.starts_with("GET /api/tags") {
TAGS.to_owned()
} else if request.starts_with("POST /api/show") {
SHOW.to_owned()
} else {
if stalls.load(Ordering::SeqCst) {
std::thread::sleep(Duration::from_secs(30));
return;
}
let asked: serde_json::Value = serde_json::from_slice(&buffer[head..head + length]).unwrap();
let vectors: Vec<Vec<usize>> = asked["input"].as_array().unwrap().iter().map(|text| vec![text.as_str().unwrap().len()]).collect();
serde_json::json!({ "embeddings": vectors }).to_string()
};
let _ = write!(stream, "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{body}", body.len());
}
fn real_pool(endpoints: &[&Endpoint], bounds: EmbedBounds) -> EmbedderPool<OllamaEmbedder> {
let members = endpoints.iter().map(|at| (at.url.clone(), OllamaEmbedder::new(&at.url, "m:1").bounded_by(bounds))).collect();
let pool = EmbedderPool::new(members, Vec::new()).retrying_after(Duration::from_secs(3600));
warm_real(&pool);
pool
}
fn warm_real(pool: &EmbedderPool<OllamaEmbedder>) {
pool.admit(Duration::from_secs(5));
pool.settle();
}
fn lengths(vectors: &[Vec<f32>]) -> Vec<f32> {
vectors.iter().map(|vector| vector[0]).collect()
}
/// An endpoint that takes the connection and then says nothing costs the
/// batch its own bound, not the caller's budget; its share goes to the
/// healthy endpoint, in order, and it stays out until its retry time.
#[test]
fn an_endpoint_that_stalls_mid_batch_costs_the_batch_its_bound_not_the_budget() {
let (healthy, stalling) = (endpoint(), endpoint());
let mut pool = real_pool(&[&healthy, &stalling], QUICK);
stalling.stalls.store(true, Ordering::SeqCst);
let started = Instant::now();
let vectors = pool.embed(&texts(8), Duration::from_secs(20)).unwrap();
assert!(started.elapsed() < Duration::from_secs(3), "took {:?}", started.elapsed());
assert_eq!(lengths(&vectors), (1..=8).map(|n| n as f32).collect::<Vec<_>>());
assert!(locked(&pool.shared.roster).members[1].out.is_some());
let again = Instant::now();
pool.embed(&texts(4), Duration::from_secs(20)).unwrap();
assert!(again.elapsed() < Duration::from_millis(250), "the dropped endpoint was tried again: {:?}", again.elapsed());
}
/// An endpoint that stops listening after it was admitted: the same.
#[test]
fn an_endpoint_that_refuses_mid_batch_costs_the_batch_nothing_the_healthy_one_could_not_do() {
let (healthy, leaving) = (endpoint(), endpoint());
let mut pool = real_pool(&[&healthy, &leaving], QUICK);
leaving.gone.store(true, Ordering::SeqCst);
leaving.listening.join().unwrap();
let started = Instant::now();
let vectors = pool.embed(&texts(6), Duration::from_secs(20)).unwrap();
assert!(started.elapsed() < Duration::from_secs(3), "took {:?}", started.elapsed());
assert_eq!(lengths(&vectors), (1..=6).map(|n| n as f32).collect::<Vec<_>>());
assert!(locked(&pool.shared.roster).members[1].out.is_some());
}
/// The bound never exceeds what the caller gave: with one endpoint, stalled
/// and a floor far above the budget, the batch fails at the budget.
#[test]
fn a_stall_is_never_waited_on_beyond_the_callers_budget() {
let only = endpoint();
let generous = EmbedBounds { floor: Duration::from_secs(60), ..QUICK };
let mut pool = real_pool(&[&only], generous);
only.stalls.store(true, Ordering::SeqCst);
let started = Instant::now();
assert!(pool.embed(&texts(2), Duration::from_millis(400)).is_err());
assert!(started.elapsed() < Duration::from_secs(3), "took {:?}", started.elapsed());
}
}