use std::collections::HashMap;
use std::io::Read;
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
use crate::embed::cache::{CacheKey, GlobalEmbeddingCache, Normalization, Pooling, ProbeResult};
pub(crate) struct CacheProbeOutcome {
pub(crate) hits: HashMap<usize, Vec<f32>>,
pub(crate) miss_positions: Vec<usize>,
miss_keys: Vec<CacheKey>,
}
struct Frontend {
model_name: String,
store: GlobalEmbeddingCache,
model_digest: [u8; 32],
tokenizer_digest: [u8; 32],
output_dimensions: u32,
}
static FRONTEND: OnceLock<Mutex<Option<Frontend>>> = OnceLock::new();
static HITS: AtomicU64 = AtomicU64::new(0);
static MISSES: AtomicU64 = AtomicU64::new(0);
static OPEN_FAILED: AtomicBool = AtomicBool::new(false);
#[cfg(any(feature = "cli", test))]
pub(crate) fn counters() -> (u64, u64) {
(HITS.load(Ordering::Relaxed), MISSES.load(Ordering::Relaxed))
}
pub(crate) fn probe_texts<S: AsRef<str>>(
model_name: &str,
output_dimensions: usize,
texts: &[S],
) -> Option<CacheProbeOutcome> {
if texts.is_empty() || output_dimensions == 0 {
return None;
}
let mut guard = frontend_lock().lock().ok()?;
ensure_frontend(&mut guard, model_name, output_dimensions)?;
let frontend = guard.as_mut()?;
if frontend.model_name != model_name || frontend.output_dimensions != output_dimensions as u32 {
return None;
}
let keys: Vec<CacheKey> = texts
.iter()
.map(|text| key_for(frontend, text.as_ref()))
.collect();
let ProbeResult { hits, misses } = frontend.store.probe(&keys).ok()?;
HITS.fetch_add(hits.len() as u64, Ordering::Relaxed);
MISSES.fetch_add(misses.len() as u64, Ordering::Relaxed);
let mut hits = hits;
let mut miss_positions = misses;
let stale: Vec<usize> = hits
.iter()
.filter(|(_, vector)| vector.len() != output_dimensions)
.map(|(position, _)| *position)
.collect();
for position in stale {
hits.remove(&position);
miss_positions.push(position);
MISSES.fetch_add(1, Ordering::Relaxed);
}
miss_positions.sort_unstable();
let miss_keys = miss_positions
.iter()
.map(|&position| keys[position].clone())
.collect();
Some(CacheProbeOutcome {
hits,
miss_positions,
miss_keys,
})
}
pub(crate) fn store_misses(outcome: &CacheProbeOutcome, miss_vectors: &[Vec<f32>]) {
if miss_vectors.is_empty() {
return;
}
let Ok(mut guard) = frontend_lock().lock() else {
return;
};
let Some(frontend) = guard.as_mut() else {
return;
};
let entries: Vec<(CacheKey, Vec<f32>)> = outcome
.miss_keys
.iter()
.zip(miss_vectors.iter())
.filter(|(_, vector)| {
!vector.is_empty() && vector.len() == frontend.output_dimensions as usize
})
.map(|(key, vector)| ((*key).clone(), (*vector).clone()))
.collect();
if entries.is_empty() {
return;
}
match frontend.store.put_batch(&entries) {
Ok(written) => {
tracing::debug!(
stored = written,
total = entries.len(),
"embed cache batch store complete"
);
}
Err(error) => {
tracing::warn!(
%error,
"embed cache batch store failed; vectors will be recomputed next run"
);
}
}
}
fn frontend_lock() -> &'static Mutex<Option<Frontend>> {
FRONTEND.get_or_init(|| Mutex::new(None))
}
fn ensure_frontend(
slot: &mut Option<Frontend>,
model_name: &str,
output_dimensions: usize,
) -> Option<()> {
if slot.is_some() {
return Some(());
}
if OPEN_FAILED.load(Ordering::Acquire) {
return None;
}
if !crate::feature_flags::FeatureFlag::GlobalEmbedCache.is_enabled() {
return None;
}
let open_failure = |context: &str| {
OPEN_FAILED.store(true, Ordering::Release);
tracing::warn!(
model = model_name,
context,
"embed cache unavailable for this process; embedding proceeds uncached"
);
};
let model_path = match crate::embed::model_path::ModelResolver::resolve(model_name) {
Ok(path) => path,
Err(error) => {
tracing::debug!(%error, "embed cache: model path unresolved");
open_failure("model-path");
return None;
}
};
let tokenizer_path =
match crate::embed::model_path::ModelResolver::resolve_tokenizer(model_name) {
Ok(path) => path,
Err(error) => {
tracing::debug!(%error, "embed cache: tokenizer path unresolved");
open_failure("tokenizer-path");
return None;
}
};
let model_digest = match streaming_digest(&model_path) {
Ok(digest) => digest,
Err(error) => {
tracing::debug!(
path = %model_path.display(),
%error,
"embed cache: model digest unavailable"
);
open_failure("model-digest");
return None;
}
};
let tokenizer_digest = match streaming_digest(&tokenizer_path) {
Ok(digest) => digest,
Err(error) => {
tracing::debug!(
path = %tokenizer_path.display(),
%error,
"embed cache: tokenizer digest unavailable"
);
open_failure("tokenizer-digest");
return None;
}
};
let root = cache_root();
let store = match GlobalEmbeddingCache::open(&root) {
Ok(store) => store,
Err(error) => {
tracing::warn!(
root = %root.display(),
%error,
"embed cache open failed; embedding proceeds uncached"
);
open_failure("store-open");
return None;
}
};
*slot = Some(Frontend {
model_name: model_name.to_string(),
store,
model_digest,
tokenizer_digest,
output_dimensions: output_dimensions as u32,
});
tracing::info!(
model = model_name,
root = %root.display(),
"client-side embed cache opened"
);
Some(())
}
fn cache_root() -> std::path::PathBuf {
if let Ok(home) = std::env::var("LEINDEX_HOME") {
return std::path::PathBuf::from(home).join("embed-cache");
}
if let Ok(home) = std::env::var("HOME") {
return std::path::PathBuf::from(home)
.join(".leindex")
.join("embed-cache");
}
std::path::PathBuf::from(".leindex").join("embed-cache")
}
fn key_for(frontend: &Frontend, text: &str) -> CacheKey {
CacheKey {
model_digest: frontend.model_digest,
tokenizer_digest: frontend.tokenizer_digest,
prompt_role_and_version: 0,
pooling: Pooling::LastToken,
normalization: Normalization::L2,
output_dimensions: frontend.output_dimensions,
content_hash: CacheKey::content_hash(text),
}
}
fn streaming_digest(path: &Path) -> std::io::Result<[u8; 32]> {
let mut file = std::fs::File::open(path)?;
let mut hasher = blake3::Hasher::new();
let mut buffer = [0u8; 64 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(hasher.finalize().into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_counters_start_at_zero() {
let (hits_before, misses_before) = counters();
assert!(probe_texts("model", 8, &[] as &[String]).is_none());
assert!(probe_texts("model", 0, &["text".to_string()]).is_none());
let (hits_after, misses_after) = counters();
assert_eq!(
(hits_before, misses_before),
(hits_after, misses_after),
"bypassed probes must not change the cache counters"
);
}
#[test]
fn test_probe_texts_empty_bypasses_cache() {
assert!(probe_texts("model", 8, &[] as &[String]).is_none());
assert!(probe_texts("model", 0, &["text".to_string()]).is_none());
}
#[test]
fn test_outcome_miss_keys_align_with_positions() {
let outcome = CacheProbeOutcome {
hits: HashMap::new(),
miss_positions: vec![0, 2],
miss_keys: vec![
CacheKey {
model_digest: [0; 32],
tokenizer_digest: [0; 32],
prompt_role_and_version: 0,
pooling: Pooling::LastToken,
normalization: Normalization::L2,
output_dimensions: 8,
content_hash: CacheKey::content_hash("a"),
},
CacheKey {
model_digest: [0; 32],
tokenizer_digest: [0; 32],
prompt_role_and_version: 0,
pooling: Pooling::LastToken,
normalization: Normalization::L2,
output_dimensions: 8,
content_hash: CacheKey::content_hash("c"),
},
],
};
assert_eq!(outcome.miss_keys.len(), outcome.miss_positions.len());
store_misses(&outcome, &[vec![0.0; 3]]);
}
}