Skip to main content

clawft_kernel/cog_ingest/
store.rs

1//! The store an ingested batch lands in, owned by one project's kernel (or
2//! by the placing controller when the placement has no project).
3//!
4//! Vectors are namespaced per instance: the key is `(instance, id)`, so one
5//! instance can neither overwrite nor dedup against another instance's ids
6//! or values inside a shared project store. Dedup is honoured here, at the
7//! owner, never at the bridge: with `dedup: true` a vector is skipped when
8//! the same instance already holds that id or a bit-identical value; with
9//! `dedup: false` the id is upserted.
10
11use 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/// Result of one batch.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
22pub struct IngestOutcome {
23    /// Vectors written.
24    pub accepted: usize,
25    /// Vectors skipped as duplicates.
26    pub deduped: usize,
27    /// Vectors held afterwards.
28    pub total: usize,
29}
30
31/// Store failure.
32#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
33pub enum StoreError {
34    /// The store is at its configured size.
35    #[error("store full: {0} vectors")]
36    Full(usize),
37    /// Backend failure.
38    #[error("{0}")]
39    Backend(String),
40}
41
42/// Where a batch came from (kept as metadata, never trusted for routing).
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Provenance {
45    /// Instance that posted it.
46    pub instance_id: String,
47    /// Node whose bridge forwarded it.
48    pub source_node: String,
49}
50
51/// One nearest-neighbour result.
52#[derive(Debug, Clone, PartialEq)]
53pub struct Hit {
54    /// Instance that wrote the vector.
55    pub instance_id: String,
56    /// The id the instance gave it.
57    pub id: u64,
58    /// Distance to the query (lower is closer).
59    pub distance: f32,
60}
61
62/// A store that takes ingested batches.
63pub trait IngestStore: Send + Sync {
64    /// Write a batch.
65    fn ingest(
66        &self,
67        from: &Provenance,
68        vectors: &[IngestVector],
69        dedup: bool,
70    ) -> Result<IngestOutcome, StoreError>;
71
72    /// The `k` nearest stored vectors to `q`, closest first.
73    fn query(&self, q: &[f32; DIMS], k: usize) -> Vec<Hit>;
74
75    /// Vectors held.
76    fn len(&self) -> usize;
77
78    /// True when empty.
79    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/// Per-instance value index with reference counts (an upsert frees the old
99/// value's entry only when no other id of that instance holds it).
100#[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
126/// In-memory store (tests, and a node's scratch store).
127pub struct MemoryIngestStore {
128    max: usize,
129    inner: Mutex<MemInner>,
130}
131
132impl MemoryIngestStore {
133    /// Store holding at most `max` vectors.
134    pub fn new(max: usize) -> Self {
135        Self {
136            max,
137            inner: Mutex::default(),
138        }
139    }
140
141    /// Provenance of the vector `instance` stored under `id`.
142    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    /// Backend id -> the (instance, id) it stands for.
221    names: HashMap<u64, (String, u64)>,
222    values: ValueIndex,
223    /// Durable log of accepted batches; `None` for a memory-only store.
224    log: Option<VectorLog>,
225}
226
227/// Adapter over the kernel's [`VectorBackend`](crate::vector_backend::VectorBackend)
228/// (HNSW, DiskANN, hybrid). The backend's dimensionality must be [`DIMS`].
229/// The backend id is a 64-bit hash of `(instance, id)`; a hash collision
230/// between two different pairs is refused rather than overwriting.
231#[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    /// Wrap `backend`.
249    pub fn new(backend: Arc<dyn crate::vector_backend::VectorBackend>) -> Self {
250        Self {
251            backend,
252            inner: Mutex::default(),
253        }
254    }
255
256    /// Wrap `backend` and keep every accepted batch in the log at `path`
257    /// (capped at `max_log_bytes`). Records already in the log are replayed
258    /// into `backend` first, so a restarted owner has what it had.
259    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                // The log is the truth: a record the index refuses is skipped, not fatal.
275                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    /// Insert one vector into the backend and the indexes. `Ok(true)` when
285    /// written, `Ok(false)` when skipped as a duplicate.
286    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        // Plan first: decide what is accepted, with no change to the index.
339        // `seen_*` stand in for the vectors this batch has already accepted.
340        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        // Log first, then the index: a restart never has less than was acknowledged.
361        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            // Only vectors that will be written count against the cap; dedup hits do not.
375            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
414/// Which store a project's batches land in, on the node that owns stores.
415pub trait StoreDirectory: Send + Sync {
416    /// The store of `project_id`, or the node's fallback (the placing
417    /// controller's store) when `project_id` is `None`. `None` result: no
418    /// such store here.
419    fn store_for(&self, project_id: Option<&str>) -> Option<Arc<dyn IngestStore>>;
420}
421
422/// Directory over fixed maps.
423#[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    /// Empty directory.
431    pub fn new() -> Self {
432        Self::default()
433    }
434
435    /// Register a project's store.
436    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    /// Register the store used when a placement has no project.
444    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}