use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::{Mutex, MutexGuard, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore};
use tokio_util::sync::CancellationToken;
use crate::adapters::lance::LanceStore;
use crate::app::config::Config;
use crate::domain::common::expand_tilde;
use crate::domain::corpus::{MarkdownChunker, load_tokenizer};
use crate::domain::embeddings::Embedder;
use crate::domain::search::{FastembedCrossencoder, canonical_crossencoder_model};
const CHUNK_BUDGET_TOKENS: usize = 384;
#[derive(Default)]
struct CorpusLockMap {
inner: Mutex<HashMap<String, Arc<Mutex<()>>>>,
}
impl CorpusLockMap {
async fn lock(&self, corpus: &str) -> OwnedMutexGuard<()> {
let mutex = {
let mut map = self.inner.lock().await;
map.entry(corpus.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
};
mutex.lock_owned().await
}
}
#[derive(Clone)]
pub struct DaemonState {
inner: Arc<DaemonStateInner>,
}
struct DaemonStateInner {
baseline: Config,
baseline_xdg_path: Option<PathBuf>,
store: Arc<LanceStore>,
ground_dir: PathBuf,
embeddings_enabled: bool,
corpus_locks: CorpusLockMap,
write_lane: Arc<Semaphore>,
embedder: Arc<Mutex<Option<Embedder>>>,
crossencoders: Arc<Mutex<HashMap<String, FastembedCrossencoder>>>,
tokenizer: tokenizers::Tokenizer,
shutdown: CancellationToken,
}
pub struct MutationGuard {
_permit: OwnedSemaphorePermit,
_corpus: OwnedMutexGuard<()>,
}
impl DaemonState {
pub async fn open(cfg: Config, xdg_path: Option<PathBuf>) -> anyhow::Result<Self> {
let ground_dir = expand_tilde(&cfg.storage.ground_dir);
if let Some(parent) = ground_dir.parent()
&& !parent.as_os_str().is_empty()
{
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| anyhow::anyhow!("create ground dir parent: {e}"))?;
}
let store = LanceStore::open_or_create(
&ground_dir,
&cfg.embeddings.model,
cfg.embeddings.quantized,
cfg.embeddings.enabled,
)
.await
.map_err(|e| anyhow::anyhow!("open ground dir {}: {e}", ground_dir.display()))?;
let cache_dir = expand_tilde(&cfg.embeddings.cache_dir);
let embedder = if cfg.embeddings.enabled {
match Embedder::try_new(&cfg.embeddings.model, cfg.embeddings.quantized, &cache_dir) {
Ok(e) => Some(e),
Err(e) => {
tracing::warn!(
target: "hallouminate::daemon",
model = %cfg.embeddings.model,
error = %e,
"embedder unavailable at startup; will retry on first embedding request",
);
None
}
}
} else {
None
};
let tokenizer = load_tokenizer(&cfg.embeddings.model)
.map_err(|e| anyhow::anyhow!("load tokenizer for {}: {e}", cfg.embeddings.model))?;
let mut crossencoders: HashMap<String, FastembedCrossencoder> = HashMap::new();
if let Some(model) = cfg.search.crossencoder.as_deref() {
match canonical_crossencoder_model(model)
.map_err(anyhow::Error::from)
.and_then(|canonical| {
FastembedCrossencoder::try_new(canonical, &cache_dir)
.map(|c| (canonical, c))
.map_err(anyhow::Error::from)
}) {
Ok((canonical, c)) => {
crossencoders.insert(canonical.to_string(), c);
}
Err(e) => {
tracing::warn!(
target: "hallouminate::daemon",
model = %model,
error = %e,
"crossencoder unavailable at startup; ground will skip rerank until reload",
);
}
}
}
Ok(DaemonState {
inner: Arc::new(DaemonStateInner {
embeddings_enabled: cfg.embeddings.enabled,
baseline: cfg,
baseline_xdg_path: xdg_path,
store: Arc::new(store),
ground_dir,
corpus_locks: CorpusLockMap::default(),
write_lane: Arc::new(Semaphore::new(1)),
embedder: Arc::new(Mutex::new(embedder)),
crossencoders: Arc::new(Mutex::new(crossencoders)),
tokenizer,
shutdown: CancellationToken::new(),
}),
})
}
pub fn shutdown_token(&self) -> &CancellationToken {
&self.inner.shutdown
}
pub fn baseline_xdg_path(&self) -> Option<&Path> {
self.inner.baseline_xdg_path.as_deref()
}
pub fn baseline(&self) -> &Config {
&self.inner.baseline
}
pub fn store(&self) -> Arc<LanceStore> {
self.inner.store.clone()
}
pub fn ground_dir(&self) -> &std::path::Path {
&self.inner.ground_dir
}
pub fn embeddings_enabled(&self) -> bool {
self.inner.embeddings_enabled
}
pub async fn embedder(&self) -> anyhow::Result<EmbedderGuard<'_>> {
let mut guard = self.inner.embedder.lock().await;
if guard.is_none() {
let cache_dir = expand_tilde(&self.inner.baseline.embeddings.cache_dir);
let embedder = Embedder::try_new(
&self.inner.baseline.embeddings.model,
self.inner.baseline.embeddings.quantized,
&cache_dir,
)
.map_err(|e| {
anyhow::anyhow!(
"init embedder ({}): {e}",
self.inner.baseline.embeddings.model
)
})?;
*guard = Some(embedder);
}
Ok(EmbedderGuard { guard })
}
pub async fn crossencoder(
&self,
model_name: Option<&str>,
) -> anyhow::Result<Option<CrossencoderGuard<'_>>> {
let Some(model_name) = model_name else {
return Ok(None);
};
let canonical = canonical_crossencoder_model(model_name)?;
let mut guard = self.inner.crossencoders.lock().await;
if !guard.contains_key(canonical) {
let cache_dir = expand_tilde(&self.inner.baseline.embeddings.cache_dir);
let model = FastembedCrossencoder::try_new(canonical, &cache_dir)
.map_err(|e| anyhow::anyhow!("init crossencoder ({canonical}): {e}"))?;
guard.insert(canonical.to_string(), model);
}
Ok(Some(CrossencoderGuard {
guard,
key: canonical.to_string(),
}))
}
pub fn make_chunker(&self) -> MarkdownChunker<tokenizers::Tokenizer> {
MarkdownChunker::new(self.inner.tokenizer.clone(), CHUNK_BUDGET_TOKENS)
}
pub async fn lock_corpus(&self, corpus: &str) -> OwnedMutexGuard<()> {
self.inner.corpus_locks.lock(corpus).await
}
pub fn write_lane(&self) -> Arc<Semaphore> {
self.inner.write_lane.clone()
}
pub async fn acquire_mutation_guard(
&self,
corpus: &str,
) -> Result<MutationGuard, &'static str> {
let corpus = self.lock_corpus(corpus).await;
let permit = self
.write_lane()
.acquire_owned()
.await
.map_err(|_| "write lane closed")?;
Ok(MutationGuard {
_permit: permit,
_corpus: corpus,
})
}
}
pub struct EmbedderGuard<'a> {
guard: MutexGuard<'a, Option<Embedder>>,
}
impl std::ops::Deref for EmbedderGuard<'_> {
type Target = Embedder;
fn deref(&self) -> &Embedder {
self.guard.as_ref().expect("embedder loaded")
}
}
impl std::ops::DerefMut for EmbedderGuard<'_> {
fn deref_mut(&mut self) -> &mut Embedder {
self.guard.as_mut().expect("embedder loaded")
}
}
pub struct CrossencoderGuard<'a> {
guard: MutexGuard<'a, HashMap<String, FastembedCrossencoder>>,
key: String,
}
impl std::ops::Deref for CrossencoderGuard<'_> {
type Target = FastembedCrossencoder;
fn deref(&self) -> &FastembedCrossencoder {
self.guard.get(&self.key).expect("crossencoder loaded")
}
}
impl std::ops::DerefMut for CrossencoderGuard<'_> {
fn deref_mut(&mut self) -> &mut FastembedCrossencoder {
self.guard.get_mut(&self.key).expect("crossencoder loaded")
}
}
impl std::fmt::Debug for DaemonState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DaemonState")
.field("ground_dir", &self.inner.ground_dir)
.field("model", &self.inner.baseline.embeddings.model)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn baseline_returns_the_configured_config() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut cfg = Config::default();
cfg.storage.ground_dir = tmp.path().to_string_lossy().into_owned();
let expected_model = cfg.embeddings.model.clone();
let state = DaemonState::open(cfg, None)
.await
.expect("open daemon state");
assert_eq!(state.baseline().embeddings.model, expected_model);
}
}