1use std::collections::HashMap;
12#[cfg(feature = "ecc")]
13use std::collections::HashSet;
14use std::sync::{Arc, Mutex};
15
16use super::types::{DIMS, IngestVector};
17#[cfg(feature = "ecc")]
18use super::store_log::{LogRecord, OpenError, VectorLog, run_blocking};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
22pub struct IngestOutcome {
23 pub accepted: usize,
25 pub deduped: usize,
27 pub total: usize,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
33pub enum StoreError {
34 #[error("store full: {0} vectors")]
36 Full(usize),
37 #[error("{0}")]
39 Backend(String),
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Provenance {
45 pub instance_id: String,
47 pub source_node: String,
49}
50
51#[derive(Debug, Clone, PartialEq)]
53pub struct Hit {
54 pub instance_id: String,
56 pub id: u64,
58 pub distance: f32,
60}
61
62pub trait IngestStore: Send + Sync {
64 fn ingest(
66 &self,
67 from: &Provenance,
68 vectors: &[IngestVector],
69 dedup: bool,
70 ) -> Result<IngestOutcome, StoreError>;
71
72 fn query(&self, q: &[f32; DIMS], k: usize) -> Vec<Hit>;
74
75 fn len(&self) -> usize;
77
78 fn is_empty(&self) -> bool {
80 self.len() == 0
81 }
82}
83
84fn value_key(instance: &str, v: &[f32; DIMS]) -> [u8; 32] {
85 let mut h = blake3::Hasher::new();
86 h.update(&(instance.len() as u64).to_le_bytes());
87 h.update(instance.as_bytes());
88 for f in v {
89 h.update(&f.to_le_bytes());
90 }
91 *h.finalize().as_bytes()
92}
93
94fn dist(a: &[f32], b: &[f32]) -> f32 {
95 a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum::<f32>().sqrt()
96}
97
98#[derive(Default)]
101struct ValueIndex(HashMap<[u8; 32], usize>);
102
103impl ValueIndex {
104 fn has(&self, k: &[u8; 32]) -> bool {
105 self.0.contains_key(k)
106 }
107 fn add(&mut self, k: [u8; 32]) {
108 *self.0.entry(k).or_default() += 1;
109 }
110 fn remove(&mut self, k: &[u8; 32]) {
111 if let Some(n) = self.0.get_mut(k) {
112 *n -= 1;
113 if *n == 0 {
114 self.0.remove(k);
115 }
116 }
117 }
118}
119
120#[derive(Default)]
121struct MemInner {
122 by_key: HashMap<(String, u64), ([f32; DIMS], String)>,
123 values: ValueIndex,
124}
125
126pub struct MemoryIngestStore {
128 max: usize,
129 inner: Mutex<MemInner>,
130}
131
132impl MemoryIngestStore {
133 pub fn new(max: usize) -> Self {
135 Self {
136 max,
137 inner: Mutex::default(),
138 }
139 }
140
141 pub fn provenance(&self, instance: &str, id: u64) -> Option<Provenance> {
143 let g = self.inner.lock().ok()?;
144 let (_, node) = g.by_key.get(&(instance.to_string(), id))?;
145 Some(Provenance {
146 instance_id: instance.to_string(),
147 source_node: node.clone(),
148 })
149 }
150}
151
152impl IngestStore for MemoryIngestStore {
153 fn ingest(
154 &self,
155 from: &Provenance,
156 vectors: &[IngestVector],
157 dedup: bool,
158 ) -> Result<IngestOutcome, StoreError> {
159 let mut g = self
160 .inner
161 .lock()
162 .map_err(|_| StoreError::Backend("store lock poisoned".into()))?;
163 let inst = &from.instance_id;
164 let (mut accepted, mut deduped) = (0, 0);
165 for v in vectors {
166 let key = (inst.clone(), v.id);
167 let vk = value_key(inst, &v.values);
168 let present = g.by_key.contains_key(&key);
169 if dedup && (present || g.values.has(&vk)) {
170 deduped += 1;
171 continue;
172 }
173 if !present && g.by_key.len() >= self.max {
174 return Err(StoreError::Full(g.by_key.len()));
175 }
176 if let Some((old, _)) = g.by_key.insert(key, (v.values, from.source_node.clone())) {
177 g.values.remove(&value_key(inst, &old));
178 }
179 g.values.add(vk);
180 accepted += 1;
181 }
182 Ok(IngestOutcome {
183 accepted,
184 deduped,
185 total: g.by_key.len(),
186 })
187 }
188
189 fn query(&self, q: &[f32; DIMS], k: usize) -> Vec<Hit> {
190 let Ok(g) = self.inner.lock() else {
191 return vec![];
192 };
193 let mut hits: Vec<_> = g
194 .by_key
195 .iter()
196 .map(|((inst, id), (v, _))| Hit {
197 instance_id: inst.clone(),
198 id: *id,
199 distance: dist(q, v),
200 })
201 .collect();
202 hits.sort_by(|a, b| {
203 a.distance
204 .total_cmp(&b.distance)
205 .then(a.id.cmp(&b.id))
206 .then(a.instance_id.cmp(&b.instance_id))
207 });
208 hits.truncate(k);
209 hits
210 }
211
212 fn len(&self) -> usize {
213 self.inner.lock().map(|g| g.by_key.len()).unwrap_or(0)
214 }
215}
216
217#[cfg(feature = "ecc")]
218#[derive(Default)]
219struct BackendInner {
220 names: HashMap<u64, (String, u64)>,
222 values: ValueIndex,
223 log: Option<VectorLog>,
225}
226
227#[cfg(feature = "ecc")]
232pub struct VectorBackendStore {
233 backend: Arc<dyn crate::vector_backend::VectorBackend>,
234 inner: Mutex<BackendInner>,
235}
236
237#[cfg(feature = "ecc")]
238fn backend_id(instance: &str, id: u64) -> u64 {
239 let mut h = blake3::Hasher::new();
240 h.update(&(instance.len() as u64).to_le_bytes());
241 h.update(instance.as_bytes());
242 h.update(&id.to_le_bytes());
243 u64::from_le_bytes(h.finalize().as_bytes()[..8].try_into().unwrap_or([0; 8]))
244}
245
246#[cfg(feature = "ecc")]
247impl VectorBackendStore {
248 pub fn new(backend: Arc<dyn crate::vector_backend::VectorBackend>) -> Self {
250 Self {
251 backend,
252 inner: Mutex::default(),
253 }
254 }
255
256 pub fn persistent(
260 backend: Arc<dyn crate::vector_backend::VectorBackend>,
261 path: &std::path::Path,
262 max_log_bytes: u64,
263 ) -> Result<Self, OpenError> {
264 let (log, records) = VectorLog::open(path, max_log_bytes)?;
265 let store = Self::new(backend);
266 {
267 let mut g = store
268 .inner
269 .lock()
270 .map_err(|_| OpenError::Io("store lock poisoned".into()))?;
271 for r in records {
272 let from = Provenance { instance_id: r.instance, source_node: r.node };
273 let v = IngestVector { id: r.id, values: r.values };
274 if let Err(e) = store.apply(&mut g, &from, &v, false) {
276 tracing::warn!(error = %e, path = %path.display(), id = v.id, "skipping a logged vector the index refused");
277 }
278 }
279 g.log = Some(log);
280 }
281 Ok(store)
282 }
283
284 fn apply(
287 &self,
288 g: &mut BackendInner,
289 from: &Provenance,
290 v: &IngestVector,
291 dedup: bool,
292 ) -> Result<bool, StoreError> {
293 let inst = &from.instance_id;
294 let bid = backend_id(inst, v.id);
295 let vk = value_key(inst, &v.values);
296 let present = match g.names.get(&bid) {
297 Some((i, id)) if i == inst && *id == v.id => true,
298 Some(_) => {
299 return Err(StoreError::Backend("vector id hash collision".into()));
300 }
301 None => false,
302 };
303 if dedup && (present || g.values.has(&vk)) {
304 return Ok(false);
305 }
306 let meta = serde_json::json!({
307 "instance": from.instance_id,
308 "node": from.source_node,
309 "id": v.id,
310 });
311 self.backend
312 .insert(bid, &format!("cog-ingest:{inst}:{}", v.id), &v.values, meta)
313 .map_err(|e| match e {
314 crate::vector_backend::VectorError::StoreFull { current, .. } => {
315 StoreError::Full(current)
316 }
317 other => StoreError::Backend(other.to_string()),
318 })?;
319 g.names.insert(bid, (inst.clone(), v.id));
320 g.values.add(vk);
321 Ok(true)
322 }
323}
324
325#[cfg(feature = "ecc")]
326impl IngestStore for VectorBackendStore {
327 fn ingest(
328 &self,
329 from: &Provenance,
330 vectors: &[IngestVector],
331 dedup: bool,
332 ) -> Result<IngestOutcome, StoreError> {
333 let mut g = self
334 .inner
335 .lock()
336 .map_err(|_| StoreError::Backend("store lock poisoned".into()))?;
337 let inst = &from.instance_id;
338 let mut seen_ids: HashSet<u64> = HashSet::new();
341 let mut seen_values: HashSet<[u8; 32]> = HashSet::new();
342 let mut accepted: Vec<&IngestVector> = Vec::new();
343 let mut deduped = 0;
344 for v in vectors {
345 let bid = backend_id(inst, v.id);
346 let vk = value_key(inst, &v.values);
347 let present = match g.names.get(&bid) {
348 Some((i, id)) if i == inst && *id == v.id => true,
349 Some(_) => return Err(StoreError::Backend("vector id hash collision".into())),
350 None => false,
351 } || seen_ids.contains(&bid);
352 if dedup && (present || g.values.has(&vk) || seen_values.contains(&vk)) {
353 deduped += 1;
354 continue;
355 }
356 seen_ids.insert(bid);
357 seen_values.insert(vk);
358 accepted.push(v);
359 }
360 if let Some(log) = g.log.as_mut() {
362 let records: Vec<LogRecord> = accepted
363 .iter()
364 .map(|v| LogRecord {
365 instance: from.instance_id.clone(),
366 node: from.source_node.clone(),
367 id: v.id,
368 values: v.values,
369 })
370 .collect();
371 if !records.iter().all(LogRecord::is_encodable) {
372 return Err(StoreError::Backend("instance or node id too long to persist".into()));
373 }
374 if !log.has_room_for(&records) {
376 return Err(StoreError::Full(self.backend.len()));
377 }
378 run_blocking(|| log.append(&records))
379 .map_err(|e| StoreError::Backend(format!("vector log append: {e}")))?;
380 }
381 for v in &accepted {
382 self.apply(&mut g, from, v, false)?;
383 }
384 Ok(IngestOutcome {
385 accepted: accepted.len(),
386 deduped,
387 total: self.backend.len(),
388 })
389 }
390
391 fn query(&self, q: &[f32; DIMS], k: usize) -> Vec<Hit> {
392 let Ok(g) = self.inner.lock() else {
393 return vec![];
394 };
395 self.backend
396 .search(q, k)
397 .into_iter()
398 .filter_map(|r| {
399 let (instance_id, id) = g.names.get(&r.id)?.clone();
400 Some(Hit {
401 instance_id,
402 id,
403 distance: r.distance,
404 })
405 })
406 .collect()
407 }
408
409 fn len(&self) -> usize {
410 self.backend.len()
411 }
412}
413
414pub trait StoreDirectory: Send + Sync {
416 fn store_for(&self, project_id: Option<&str>) -> Option<Arc<dyn IngestStore>>;
420}
421
422#[derive(Default)]
424pub struct StaticDirectory {
425 projects: Mutex<HashMap<String, Arc<dyn IngestStore>>>,
426 fallback: Mutex<Option<Arc<dyn IngestStore>>>,
427}
428
429impl StaticDirectory {
430 pub fn new() -> Self {
432 Self::default()
433 }
434
435 pub fn with_project_store(self, project_id: &str, store: Arc<dyn IngestStore>) -> Self {
437 if let Ok(mut m) = self.projects.lock() {
438 m.insert(project_id.to_string(), store);
439 }
440 self
441 }
442
443 pub fn with_fallback(self, store: Arc<dyn IngestStore>) -> Self {
445 if let Ok(mut f) = self.fallback.lock() {
446 *f = Some(store);
447 }
448 self
449 }
450}
451
452impl StoreDirectory for StaticDirectory {
453 fn store_for(&self, project_id: Option<&str>) -> Option<Arc<dyn IngestStore>> {
454 match project_id {
455 Some(p) => self.projects.lock().ok()?.get(p).cloned(),
456 None => self.fallback.lock().ok()?.clone(),
457 }
458 }
459}