diff --git a/src/openhuman/memory/tree/score/embed/ollama.rs b/src/openhuman/memory/tree/score/embed/ollama.rs index c1aadf820..5884999ed 100644 --- a/src/openhuman/memory/tree/score/embed/ollama.rs +++ b/src/openhuman/memory/tree/score/embed/ollama.rs @@ -66,16 +66,19 @@ impl OllamaEmbedder { timeout_ms }; let timeout = Duration::from_millis(timeout_ms); + // No body-read timeout. Ollama is local — slow responses mean + // the model is genuinely processing, not that the network + // broke. A body-read timeout here would cancel mid-stream and + // force retries against the same slow model. `timeout` is + // repurposed as the TCP connect timeout (fast-fail when + // Ollama is actually down). let client = reqwest::Client::builder() - .timeout(timeout) + .connect_timeout(timeout) .build() - // Falling back to the default client keeps `new` infallible; - // timeouts will apply per-request via explicit `.timeout()` - // calls below. .unwrap_or_else(|e| { log::warn!( "[memory_tree::embed::ollama] failed to build client \ - with timeout — using default: {e}" + with connect_timeout — using default: {e}" ); reqwest::Client::new() }); @@ -154,9 +157,11 @@ impl Embedder for OllamaEmbedder { }; let resp = self .client + // No per-request body-read timeout — see `OllamaEmbedder::new` + // for rationale. The Client's `connect_timeout` still applies + // and fails fast if Ollama isn't reachable. .post(self.embed_url()) .json(&req) - .timeout(self.timeout) .send() .await .with_context(|| { diff --git a/src/openhuman/memory/tree/score/extract/llm.rs b/src/openhuman/memory/tree/score/extract/llm.rs index e7e7c0fac..975bbacce 100644 --- a/src/openhuman/memory/tree/score/extract/llm.rs +++ b/src/openhuman/memory/tree/score/extract/llm.rs @@ -49,8 +49,13 @@ pub struct LlmExtractorConfig { pub endpoint: String, /// Model identifier as known to the endpoint (e.g. `qwen2.5:0.5b`). pub model: String, - /// Per-request timeout. The default is generous because the first - /// request after a model swap may need to load weights. + /// TCP connect timeout. Used only to fail fast when Ollama is + /// down; no body-read timeout is applied (see `Self::new` for + /// rationale — local Ollama on slow CPU inference can take + /// minutes per call, and cancelling mid-stream triggered + /// cancellation-path retry storms). The default is generous + /// because the first request after a model swap may need to load + /// weights. pub timeout: Duration, /// Which entity kinds the LLM is allowed to emit. Anything outside this /// set is mapped to [`EntityKind::Misc`] or dropped depending on @@ -105,8 +110,18 @@ impl LlmEntityExtractor { /// Build the extractor and its inner HTTP client. Fails only when /// `reqwest` rejects the timeout configuration. pub fn new(cfg: LlmExtractorConfig) -> anyhow::Result { + // No body-read timeout. Ollama is a local process — slow + // responses mean the model is genuinely processing under CPU + // load (e.g. gemma3:1b on CPU-only inference can take minutes + // per call), not that the network broke. A body-read timeout + // here would cancel mid-flight generation and force the + // existing 3× retry-with-backoff to re-run the same prompt + // against the same slow model, which empirically caused retry + // storms during high-load ingest. `cfg.timeout` is now the + // TCP connect timeout — short enough to fail fast when + // Ollama is actually unreachable. let http = Client::builder() - .timeout(cfg.timeout) + .connect_timeout(cfg.timeout) .build() .map_err(anyhow::Error::from)?; Ok(Self { cfg, http }) diff --git a/src/openhuman/memory/tree/tree_source/summariser/llm.rs b/src/openhuman/memory/tree/tree_source/summariser/llm.rs index 0580d8a33..59e4620a1 100644 --- a/src/openhuman/memory/tree/tree_source/summariser/llm.rs +++ b/src/openhuman/memory/tree/tree_source/summariser/llm.rs @@ -115,8 +115,14 @@ impl LlmSummariser { /// Build a summariser around `cfg`. Returns an error only if the HTTP /// client fails to construct (timeout / TLS init). pub fn new(cfg: LlmSummariserConfig) -> Result { + // No body-read timeout. Ollama is local — slow responses mean + // the model is genuinely processing, not that the network + // broke. A body-read timeout here would cancel mid-stream and + // force retries against the same slow model. `cfg.timeout` is + // repurposed as the TCP connect timeout (fast-fail when + // Ollama is actually down). let http = Client::builder() - .timeout(cfg.timeout) + .connect_timeout(cfg.timeout) .build() .map_err(anyhow::Error::from)?; Ok(Self {