From c74dd530defc26824b249836675a01d78a228a29 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 07:54:57 +0100 Subject: [PATCH] daemon: embeds through the endpoint pool, GPUs together and the CPU last query-embed-on-cpu, follow-up daemon-embedder-gpu-pool: huginn-daemon takes repeated --ollama-url (GPU hosts) and --reserve-ollama-url (CPU hosts) and builds the same EmbedderPool as huginn-sources, through one ollama::pool constructor. A lone --ollama-url is a pool of one. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 4 +- .../huginn-daemon/src/bin/huginn-sources.rs | 6 +-- crates/huginn-daemon/src/index/ollama.rs | 8 ++++ crates/huginn-daemon/src/main.rs | 4 +- crates/huginn-daemon/src/serve.rs | 41 +++++++++++++++---- 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index aec5f07..9fea14c 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ Upstream: `https://github.com/GameCult/Huginn.git` restart compares the mind with the collection and writes what is missing. An unreachable Qdrant or Ollama shows in `whoami` as the index's status and is retried; admission never waits on it. The daemon requires `--qdrant-url`, - `--ollama-url` and `--embedding-model`. A `query` carrying `semantic` is + `--ollama-url` and `--embedding-model`; `--ollama-url` repeats for each GPU + host and `--reserve-ollama-url` for each CPU host, and the GPU hosts embed + together while the CPU hosts serve only when none can. A `query` carrying `semantic` is answered through the same selection as any other: the index supplies candidate ids and scores, and the mind keeps only the documents it holds and the selection admits (ask for `in_force` to exclude resolved ones), ordered by diff --git a/crates/huginn-daemon/src/bin/huginn-sources.rs b/crates/huginn-daemon/src/bin/huginn-sources.rs index e46503a..63115f7 100644 --- a/crates/huginn-daemon/src/bin/huginn-sources.rs +++ b/crates/huginn-daemon/src/bin/huginn-sources.rs @@ -7,8 +7,7 @@ use std::path::PathBuf; use std::process::ExitCode; use anyhow::{Context, Result, ensure}; -use huginn_daemon::index::ollama::OllamaEmbedder; -use huginn_daemon::index::pool::EmbedderPool; +use huginn_daemon::index::ollama::pool; use huginn_daemon::index::qdrant::QdrantIndex; use huginn_daemon::sources::forge::HttpForge; use huginn_daemon::sources::refresh; @@ -77,8 +76,7 @@ fn main() -> ExitCode { return ExitCode::from(2); } }; - let endpoint = |url: &String| (url.clone(), OllamaEmbedder::new(url, &options.embedding_model)); - let mut embedder = EmbedderPool::new(options.ollama_urls.iter().map(endpoint).collect(), options.reserve_urls.iter().map(endpoint).collect()); + let mut embedder = pool(&options.ollama_urls, &options.reserve_urls, &options.embedding_model); let mut index = QdrantIndex::new(&options.qdrant_url); let mut forge = HttpForge::new(&options.forge_url); let mut journal = |report: &huginn_daemon::sources::Report| println!("{report}"); diff --git a/crates/huginn-daemon/src/index/ollama.rs b/crates/huginn-daemon/src/index/ollama.rs index 0461729..da7d356 100644 --- a/crates/huginn-daemon/src/index/ollama.rs +++ b/crates/huginn-daemon/src/index/ollama.rs @@ -73,3 +73,11 @@ impl Embedder for OllamaEmbedder { .collect() } } + +/// One pool over Ollama endpoints serving `model`: the `primaries` (GPU hosts) +/// share the work and the `reserve` (CPU hosts) serve only when none of them +/// can. The one embedder client of every Huginn process that embeds. +pub fn pool(primaries: &[String], reserve: &[String], model: &str) -> super::pool::EmbedderPool { + let endpoint = |url: &String| (url.clone(), OllamaEmbedder::new(url, model)); + super::pool::EmbedderPool::new(primaries.iter().map(endpoint).collect(), reserve.iter().map(endpoint).collect()) +} diff --git a/crates/huginn-daemon/src/main.rs b/crates/huginn-daemon/src/main.rs index ce28fb3..91c49d8 100644 --- a/crates/huginn-daemon/src/main.rs +++ b/crates/huginn-daemon/src/main.rs @@ -7,13 +7,13 @@ use std::sync::atomic::AtomicBool; use anyhow::{Context, Result}; use huginn_daemon::index::Backoff; -use huginn_daemon::index::ollama::OllamaEmbedder; +use huginn_daemon::index::ollama::pool; use huginn_daemon::index::qdrant::QdrantIndex; use huginn_daemon::serve::{ServeOptions, parse_options, run, startup}; fn main() -> Result<()> { let options = parse_options(std::env::args().skip(1))?; - let embedder = OllamaEmbedder::new(&options.ollama_url, &options.embedding_model); + let embedder = pool(&options.ollama_urls, &options.reserve_ollama_urls, &options.embedding_model); let index = QdrantIndex::new(&options.qdrant_url); let (mut daemon, mut hub, registry) = match startup(&options, embedder, index, Backoff::default()) { Ok(started) => started, diff --git a/crates/huginn-daemon/src/serve.rs b/crates/huginn-daemon/src/serve.rs index 72db18c..2555da0 100644 --- a/crates/huginn-daemon/src/serve.rs +++ b/crates/huginn-daemon/src/serve.rs @@ -101,7 +101,7 @@ impl Default for ServeOptions { } } -/// The six things the operator supplies. Nothing here reads the environment: +/// The things the operator supplies. Nothing here reads the environment: /// how Idunn supplies `--bind` is Cut 14's. The index has no off switch: an /// unreachable Qdrant or Ollama is a degraded projection, reported by /// `whoami`, not a configuration. @@ -111,23 +111,34 @@ pub struct Options { pub instance: Slug, pub bind: SocketAddr, pub qdrant_url: String, - pub ollama_url: String, + /// The GPU hosts: all take embedding at once. One is the plain case. + pub ollama_urls: Vec, + /// The CPU host: takes embedding only while no GPU host can. + pub reserve_ollama_urls: Vec, pub embedding_model: String, } /// Exactly `--state-root`, `--instance`, `--bind`, `--qdrant-url`, -/// `--ollama-url` and `--embedding-model`, each required, each once. The two -/// URLs are plain `http://`: this build carries no TLS. +/// `--ollama-url` and `--embedding-model`, each required, each once, except +/// `--ollama-url`, which repeats for each GPU host, and the optional +/// `--reserve-ollama-url`, which repeats for each CPU host. The URLs are plain +/// `http://`: this build carries no TLS. pub fn parse_options(args: impl Iterator) -> Result { let mut args = args; let mut values: BTreeMap = BTreeMap::new(); + let (mut ollama_urls, mut reserve_ollama_urls) = (Vec::new(), Vec::new()); while let Some(name) = args.next() { let name = name.strip_prefix("--").with_context(|| format!("expected --option, got {name:?}"))?; ensure!( - matches!(name, "state-root" | "instance" | "bind" | "qdrant-url" | "ollama-url" | "embedding-model"), + matches!(name, "state-root" | "instance" | "bind" | "qdrant-url" | "ollama-url" | "reserve-ollama-url" | "embedding-model"), "unsupported Huginn option --{name}" ); let value = args.next().with_context(|| format!("missing value for --{name}"))?; + if name == "ollama-url" || name == "reserve-ollama-url" { + ensure!(value.starts_with("http://"), "--{name} must be a plain http:// address, got {value:?}"); + if name == "ollama-url" { ollama_urls.push(value) } else { reserve_ollama_urls.push(value) } + continue; + } ensure!(values.insert(name.to_owned(), value).is_none(), "duplicate Huginn option --{name}"); } let take = |name: &str| -> Result { @@ -148,7 +159,11 @@ pub fn parse_options(args: impl Iterator) -> Result { instance: Slug(take("instance")?), bind: bind.parse().with_context(|| format!("--bind must be an ip:port, got {bind:?}"))?, qdrant_url: url("qdrant-url")?, - ollama_url: url("ollama-url")?, + ollama_urls: { + ensure!(!ollama_urls.is_empty(), "--ollama-url is required; repeat it for each GPU host"); + ollama_urls + }, + reserve_ollama_urls, embedding_model, }) } @@ -1347,7 +1362,8 @@ mod tests { instance: slug(INSTANCE), bind: port, qdrant_url: "http://127.0.0.1:1".into(), - ollama_url: "http://127.0.0.1:1".into(), + ollama_urls: vec!["http://127.0.0.1:1".into()], + reserve_ollama_urls: Vec::new(), embedding_model: "a-model".into(), }; let started = |options: &Options| { @@ -1369,7 +1385,7 @@ mod tests { } #[test] - fn options_are_exactly_the_six_the_operator_supplies() { + fn options_are_the_ones_the_operator_supplies() { let root = if cfg!(windows) { r"C:\state" } else { "/state" }; let args = |values: &[&str]| values.iter().map(|value| value.to_string()).collect::>().into_iter(); let good = [ @@ -1383,7 +1399,8 @@ mod tests { instance: slug("yggdrasil"), bind: "127.0.0.1:17872".parse().unwrap(), qdrant_url: "http://127.0.0.1:6333".into(), - ollama_url: "http://10.77.0.4:11434".into(), + ollama_urls: vec!["http://10.77.0.4:11434".into()], + reserve_ollama_urls: Vec::new(), embedding_model: "qwen3-embedding:0.6b".into(), } ); @@ -1411,5 +1428,11 @@ mod tests { ] { assert!(parse_options(args(&bad)).is_err(), "{bad:?}"); } + // The GPU hosts and the CPU host repeat; a lone --ollama-url is the plain case. + let pooled = [good.to_vec(), vec!["--ollama-url", "http://10.77.0.2:11434", "--reserve-ollama-url", "http://127.0.0.1:11434"]].concat(); + let parsed = parse_options(args(&pooled)).unwrap(); + assert_eq!(parsed.ollama_urls, ["http://10.77.0.4:11434", "http://10.77.0.2:11434"]); + assert_eq!(parsed.reserve_ollama_urls, ["http://127.0.0.1:11434"]); + assert!(parse_options(args(&[good.to_vec(), vec!["--reserve-ollama-url", "127.0.0.1:11434"]].concat())).is_err()); } }