use std::collections::HashMap;
#[cfg(feature = "ecc")]
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::types::{DIMS, IngestVector};
#[cfg(feature = "ecc")]
use super::store_log::{LogRecord, OpenError, VectorLog, run_blocking};
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct IngestOutcome {
pub accepted: usize,
pub deduped: usize,
pub total: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum StoreError {
#[error("store full: {0} vectors")]
Full(usize),
#[error("{0}")]
Backend(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Provenance {
pub instance_id: String,
pub source_node: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Hit {
pub instance_id: String,
pub id: u64,
pub distance: f32,
}
pub trait IngestStore: Send + Sync {
fn ingest(
&self,
from: &Provenance,
vectors: &[IngestVector],
dedup: bool,
) -> Result<IngestOutcome, StoreError>;
fn query(&self, q: &[f32; DIMS], k: usize) -> Vec<Hit>;
fn len(&self) -> usize;
fn is_empty(&self) -> bool {
self.len() == 0
}
}
fn value_key(instance: &str, v: &[f32; DIMS]) -> [u8; 32] {
let mut h = blake3::Hasher::new();
h.update(&(instance.len() as u64).to_le_bytes());
h.update(instance.as_bytes());
for f in v {
h.update(&f.to_le_bytes());
}
*h.finalize().as_bytes()
}
fn dist(a: &[f32], b: &[f32]) -> f32 {
a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum::<f32>().sqrt()
}
#[derive(Default)]
struct ValueIndex(HashMap<[u8; 32], usize>);
impl ValueIndex {
fn has(&self, k: &[u8; 32]) -> bool {
self.0.contains_key(k)
}
fn add(&mut self, k: [u8; 32]) {
*self.0.entry(k).or_default() += 1;
}
fn remove(&mut self, k: &[u8; 32]) {
if let Some(n) = self.0.get_mut(k) {
*n -= 1;
if *n == 0 {
self.0.remove(k);
}
}
}
}
#[derive(Default)]
struct MemInner {
by_key: HashMap<(String, u64), ([f32; DIMS], String)>,
values: ValueIndex,
}
pub struct MemoryIngestStore {
max: usize,
inner: Mutex<MemInner>,
}
impl MemoryIngestStore {
pub fn new(max: usize) -> Self {
Self {
max,
inner: Mutex::default(),
}
}
pub fn provenance(&self, instance: &str, id: u64) -> Option<Provenance> {
let g = self.inner.lock().ok()?;
let (_, node) = g.by_key.get(&(instance.to_string(), id))?;
Some(Provenance {
instance_id: instance.to_string(),
source_node: node.clone(),
})
}
}
impl IngestStore for MemoryIngestStore {
fn ingest(
&self,
from: &Provenance,
vectors: &[IngestVector],
dedup: bool,
) -> Result<IngestOutcome, StoreError> {
let mut g = self
.inner
.lock()
.map_err(|_| StoreError::Backend("store lock poisoned".into()))?;
let inst = &from.instance_id;
let (mut accepted, mut deduped) = (0, 0);
for v in vectors {
let key = (inst.clone(), v.id);
let vk = value_key(inst, &v.values);
let present = g.by_key.contains_key(&key);
if dedup && (present || g.values.has(&vk)) {
deduped += 1;
continue;
}
if !present && g.by_key.len() >= self.max {
return Err(StoreError::Full(g.by_key.len()));
}
if let Some((old, _)) = g.by_key.insert(key, (v.values, from.source_node.clone())) {
g.values.remove(&value_key(inst, &old));
}
g.values.add(vk);
accepted += 1;
}
Ok(IngestOutcome {
accepted,
deduped,
total: g.by_key.len(),
})
}
fn query(&self, q: &[f32; DIMS], k: usize) -> Vec<Hit> {
let Ok(g) = self.inner.lock() else {
return vec![];
};
let mut hits: Vec<_> = g
.by_key
.iter()
.map(|((inst, id), (v, _))| Hit {
instance_id: inst.clone(),
id: *id,
distance: dist(q, v),
})
.collect();
hits.sort_by(|a, b| {
a.distance
.total_cmp(&b.distance)
.then(a.id.cmp(&b.id))
.then(a.instance_id.cmp(&b.instance_id))
});
hits.truncate(k);
hits
}
fn len(&self) -> usize {
self.inner.lock().map(|g| g.by_key.len()).unwrap_or(0)
}
}
#[cfg(feature = "ecc")]
#[derive(Default)]
struct BackendInner {
names: HashMap<u64, (String, u64)>,
values: ValueIndex,
log: Option<VectorLog>,
}
#[cfg(feature = "ecc")]
pub struct VectorBackendStore {
backend: Arc<dyn crate::vector_backend::VectorBackend>,
inner: Mutex<BackendInner>,
}
#[cfg(feature = "ecc")]
fn backend_id(instance: &str, id: u64) -> u64 {
let mut h = blake3::Hasher::new();
h.update(&(instance.len() as u64).to_le_bytes());
h.update(instance.as_bytes());
h.update(&id.to_le_bytes());
u64::from_le_bytes(h.finalize().as_bytes()[..8].try_into().unwrap_or([0; 8]))
}
#[cfg(feature = "ecc")]
impl VectorBackendStore {
pub fn new(backend: Arc<dyn crate::vector_backend::VectorBackend>) -> Self {
Self {
backend,
inner: Mutex::default(),
}
}
pub fn persistent(
backend: Arc<dyn crate::vector_backend::VectorBackend>,
path: &std::path::Path,
max_log_bytes: u64,
) -> Result<Self, OpenError> {
let (log, records) = VectorLog::open(path, max_log_bytes)?;
let store = Self::new(backend);
{
let mut g = store
.inner
.lock()
.map_err(|_| OpenError::Io("store lock poisoned".into()))?;
for r in records {
let from = Provenance { instance_id: r.instance, source_node: r.node };
let v = IngestVector { id: r.id, values: r.values };
if let Err(e) = store.apply(&mut g, &from, &v, false) {
tracing::warn!(error = %e, path = %path.display(), id = v.id, "skipping a logged vector the index refused");
}
}
g.log = Some(log);
}
Ok(store)
}
fn apply(
&self,
g: &mut BackendInner,
from: &Provenance,
v: &IngestVector,
dedup: bool,
) -> Result<bool, StoreError> {
let inst = &from.instance_id;
let bid = backend_id(inst, v.id);
let vk = value_key(inst, &v.values);
let present = match g.names.get(&bid) {
Some((i, id)) if i == inst && *id == v.id => true,
Some(_) => {
return Err(StoreError::Backend("vector id hash collision".into()));
}
None => false,
};
if dedup && (present || g.values.has(&vk)) {
return Ok(false);
}
let meta = serde_json::json!({
"instance": from.instance_id,
"node": from.source_node,
"id": v.id,
});
self.backend
.insert(bid, &format!("cog-ingest:{inst}:{}", v.id), &v.values, meta)
.map_err(|e| match e {
crate::vector_backend::VectorError::StoreFull { current, .. } => {
StoreError::Full(current)
}
other => StoreError::Backend(other.to_string()),
})?;
g.names.insert(bid, (inst.clone(), v.id));
g.values.add(vk);
Ok(true)
}
}
#[cfg(feature = "ecc")]
impl IngestStore for VectorBackendStore {
fn ingest(
&self,
from: &Provenance,
vectors: &[IngestVector],
dedup: bool,
) -> Result<IngestOutcome, StoreError> {
let mut g = self
.inner
.lock()
.map_err(|_| StoreError::Backend("store lock poisoned".into()))?;
let inst = &from.instance_id;
let mut seen_ids: HashSet<u64> = HashSet::new();
let mut seen_values: HashSet<[u8; 32]> = HashSet::new();
let mut accepted: Vec<&IngestVector> = Vec::new();
let mut deduped = 0;
for v in vectors {
let bid = backend_id(inst, v.id);
let vk = value_key(inst, &v.values);
let present = match g.names.get(&bid) {
Some((i, id)) if i == inst && *id == v.id => true,
Some(_) => return Err(StoreError::Backend("vector id hash collision".into())),
None => false,
} || seen_ids.contains(&bid);
if dedup && (present || g.values.has(&vk) || seen_values.contains(&vk)) {
deduped += 1;
continue;
}
seen_ids.insert(bid);
seen_values.insert(vk);
accepted.push(v);
}
if let Some(log) = g.log.as_mut() {
let records: Vec<LogRecord> = accepted
.iter()
.map(|v| LogRecord {
instance: from.instance_id.clone(),
node: from.source_node.clone(),
id: v.id,
values: v.values,
})
.collect();
if !records.iter().all(LogRecord::is_encodable) {
return Err(StoreError::Backend("instance or node id too long to persist".into()));
}
if !log.has_room_for(&records) {
return Err(StoreError::Full(self.backend.len()));
}
run_blocking(|| log.append(&records))
.map_err(|e| StoreError::Backend(format!("vector log append: {e}")))?;
}
for v in &accepted {
self.apply(&mut g, from, v, false)?;
}
Ok(IngestOutcome {
accepted: accepted.len(),
deduped,
total: self.backend.len(),
})
}
fn query(&self, q: &[f32; DIMS], k: usize) -> Vec<Hit> {
let Ok(g) = self.inner.lock() else {
return vec![];
};
self.backend
.search(q, k)
.into_iter()
.filter_map(|r| {
let (instance_id, id) = g.names.get(&r.id)?.clone();
Some(Hit {
instance_id,
id,
distance: r.distance,
})
})
.collect()
}
fn len(&self) -> usize {
self.backend.len()
}
}
pub trait StoreDirectory: Send + Sync {
fn store_for(&self, project_id: Option<&str>) -> Option<Arc<dyn IngestStore>>;
}
#[derive(Default)]
pub struct StaticDirectory {
projects: Mutex<HashMap<String, Arc<dyn IngestStore>>>,
fallback: Mutex<Option<Arc<dyn IngestStore>>>,
}
impl StaticDirectory {
pub fn new() -> Self {
Self::default()
}
pub fn with_project_store(self, project_id: &str, store: Arc<dyn IngestStore>) -> Self {
if let Ok(mut m) = self.projects.lock() {
m.insert(project_id.to_string(), store);
}
self
}
pub fn with_fallback(self, store: Arc<dyn IngestStore>) -> Self {
if let Ok(mut f) = self.fallback.lock() {
*f = Some(store);
}
self
}
}
impl StoreDirectory for StaticDirectory {
fn store_for(&self, project_id: Option<&str>) -> Option<Arc<dyn IngestStore>> {
match project_id {
Some(p) => self.projects.lock().ok()?.get(p).cloned(),
None => self.fallback.lock().ok()?.clone(),
}
}
}