use crate::db::Db;
use crate::error::TopoError;
use crate::graph::Snapshot;
use crate::ids::{NodeId, Scope, ScopeSet};
use crate::op::Op;
use crate::state::NodeRecord;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, RwLock};
pub(crate) fn read_or_closed<T>(
l: &std::sync::RwLock<T>,
) -> Result<std::sync::RwLockReadGuard<'_, T>, TopoError> {
l.read().map_err(|_| TopoError::Closed)
}
#[derive(Debug, Clone)]
pub struct VectorQuery {
pub scopes: ScopeSet,
pub model: String,
pub vector: Vec<f32>,
pub k: usize,
pub candidates: Option<Vec<NodeId>>,
}
pub(crate) struct Slab {
pub(crate) dim: usize,
pub(crate) ids: Vec<Option<NodeId>>,
pub(crate) data: Vec<f32>,
pub(crate) row_of: HashMap<NodeId, usize>,
pub(crate) dead: usize,
}
impl Slab {
pub(crate) fn new(dim: usize) -> Slab {
Slab {
dim,
ids: Vec::new(),
data: Vec::new(),
row_of: HashMap::new(),
dead: 0,
}
}
pub(crate) fn upsert(&mut self, id: NodeId, v: &[f32]) {
let live = self.ids.len() - self.dead;
if v.len() != self.dim && live == 0 {
self.ids.clear();
self.data.clear();
self.row_of.clear();
self.dead = 0;
self.dim = v.len();
}
debug_assert_eq!(
v.len(),
self.dim,
"upsert vector length must match slab dim"
);
self.tombstone(id);
let row = self.ids.len();
self.ids.push(Some(id));
self.data.extend_from_slice(v);
self.row_of.insert(id, row);
}
pub(crate) fn tombstone(&mut self, id: NodeId) {
if let Some(row) = self.row_of.remove(&id) {
if self.ids[row].is_some() {
self.ids[row] = None;
self.dead += 1;
}
}
}
pub(crate) fn maybe_compact(&mut self) {
let live = self.ids.len() - self.dead;
if self.dead <= live {
return;
}
let mut new_ids: Vec<Option<NodeId>> = Vec::with_capacity(live);
let mut new_data: Vec<f32> = Vec::with_capacity(live * self.dim);
let mut new_row_of: HashMap<NodeId, usize> = HashMap::with_capacity(live);
for (row, slot) in self.ids.iter().enumerate() {
if let Some(id) = slot {
let new_row = new_ids.len();
new_ids.push(Some(*id));
new_data.extend_from_slice(&self.data[row * self.dim..(row + 1) * self.dim]);
new_row_of.insert(*id, new_row);
}
}
self.ids = new_ids;
self.data = new_data;
self.row_of = new_row_of;
self.dead = 0;
}
pub(crate) fn top_k(
&self,
query: &[f32],
k: usize,
filter: Option<&HashSet<NodeId>>,
) -> Vec<(NodeId, f32)> {
let mut hits: Vec<(NodeId, f32)> = Vec::new();
for (row, slot) in self.ids.iter().enumerate() {
let Some(id) = slot else { continue };
if let Some(f) = filter {
if !f.contains(id) {
continue;
}
}
let start = row * self.dim;
let vec = &self.data[start..start + self.dim];
if let Some(score) = cosine(vec, query) {
hits.push((*id, score));
}
}
hits.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.0.cmp(&b.0))
});
hits.truncate(k);
hits
}
}
fn cosine(a: &[f32], b: &[f32]) -> Option<f32> {
let (mut dot, mut na, mut nb) = (0.0f32, 0.0f32, 0.0f32);
for (x, y) in a.iter().zip(b) {
dot += x * y;
na += x * x;
nb += y * y;
}
if na == 0.0 || nb == 0.0 {
return None;
}
Some(dot / (na.sqrt() * nb.sqrt()))
}
pub(crate) struct VectorIndex {
#[allow(clippy::type_complexity)]
pub(crate) slabs: RwLock<HashMap<(String, Scope), Arc<RwLock<Slab>>>>,
}
impl VectorIndex {
pub(crate) fn new() -> VectorIndex {
VectorIndex {
slabs: RwLock::new(HashMap::new()),
}
}
pub(crate) fn from_snapshot(snap: &Snapshot) -> VectorIndex {
let idx = VectorIndex::new();
for n in snap.nodes.values() {
if let Some((model, vector)) = &n.embedding {
idx.upsert(model, n.scope, n.id, vector);
}
}
idx
}
pub(crate) fn dim_of(&self, model: &str, scope: Scope) -> Option<usize> {
let slabs = self.slabs.read().unwrap();
let arc = slabs.get(&(model.to_string(), scope))?;
let slab = arc.read().unwrap();
let live = slab.ids.len() - slab.dead;
(live > 0).then_some(slab.dim)
}
pub(crate) fn upsert(&self, model: &str, scope: Scope, id: NodeId, v: &[f32]) {
let key = (model.to_string(), scope);
let arc = {
let slabs = self.slabs.read().unwrap();
slabs.get(&key).cloned()
};
let arc = match arc {
Some(a) => a,
None => {
let mut slabs = self.slabs.write().unwrap();
slabs
.entry(key)
.or_insert_with(|| Arc::new(RwLock::new(Slab::new(v.len()))))
.clone()
}
};
arc.write().unwrap().upsert(id, v);
}
pub(crate) fn tombstone(&self, model: &str, scope: Scope, id: NodeId) {
let arc = {
let slabs = self.slabs.read().unwrap();
slabs.get(&(model.to_string(), scope)).cloned()
};
if let Some(arc) = arc {
arc.write().unwrap().tombstone(id);
}
}
pub(crate) fn maybe_compact(&self, model: &str, scope: Scope) {
let arc = {
let slabs = self.slabs.read().unwrap();
slabs.get(&(model.to_string(), scope)).cloned()
};
if let Some(arc) = arc {
arc.write().unwrap().maybe_compact();
}
}
pub(crate) fn prevalidate_dims(&self, cur: &Snapshot, ops: &[Op]) -> Result<(), TopoError> {
let mut created_scope: HashMap<NodeId, Scope> = HashMap::new();
let mut batch_dims: HashMap<(String, Scope), usize> = HashMap::new();
for op in ops {
match op {
Op::CreateNode { id, scope, .. } => {
created_scope.insert(*id, *scope);
}
Op::SetEmbedding { id, model, vector } => {
let scope = match created_scope.get(id) {
Some(s) => *s,
None => match cur.nodes.get(id) {
Some(n) => n.scope,
None => continue,
},
};
let dim = vector.len();
let key = (model.clone(), scope);
match batch_dims.get(&key) {
Some(&d) if d != dim => {
return Err(TopoError::Rejected(format!(
"inconsistent embedding dim for model {model:?} within batch: {d} vs {dim}"
)));
}
Some(_) => {}
None => {
batch_dims.insert(key, dim);
}
}
if let Some(existing) = self.dim_of(model, scope) {
if existing != dim {
return Err(TopoError::Rejected(format!(
"embedding dim {dim} does not match existing slab dim {existing} for model {model:?}"
)));
}
}
}
_ => {}
}
}
Ok(())
}
pub(crate) fn maintain(&self, cur: &Snapshot, resolved: &[Op]) {
let mut local: HashMap<NodeId, (Scope, Option<String>)> = HashMap::new();
let mut touched: HashSet<(String, Scope)> = HashSet::new();
for op in resolved {
match op {
Op::CreateNode { id, scope, .. } => {
local.insert(*id, (*scope, None));
}
Op::SetEmbedding { id, model, vector } => {
let (scope, old_model) = match local.get(id) {
Some(s) => s.clone(),
None => match cur.nodes.get(id) {
Some(n) => (n.scope, n.embedding.as_ref().map(|(m, _)| m.clone())),
None => continue,
},
};
if let Some(om) = &old_model {
if om != model {
self.tombstone(om, scope, *id);
touched.insert((om.clone(), scope));
}
}
self.upsert(model, scope, *id, vector);
touched.insert((model.clone(), scope));
local.insert(*id, (scope, Some(model.clone())));
}
Op::RemoveNode { id } => {
let (scope, old_model) = match local.get(id) {
Some(s) => s.clone(),
None => match cur.nodes.get(id) {
Some(n) => (n.scope, n.embedding.as_ref().map(|(m, _)| m.clone())),
None => continue,
},
};
if let Some(om) = &old_model {
self.tombstone(om, scope, *id);
touched.insert((om.clone(), scope));
}
local.insert(*id, (scope, None));
}
_ => {}
}
}
for (model, scope) in touched {
self.maybe_compact(&model, scope);
}
}
pub(crate) fn rebuild_from(&self, snap: &Snapshot) {
let fresh = VectorIndex::from_snapshot(snap);
let new_map = fresh.slabs.into_inner().unwrap();
*self.slabs.write().unwrap() = new_map;
}
}
impl Db {
pub fn search_vector(&self, q: &VectorQuery) -> Result<Vec<(NodeRecord, f32)>, TopoError> {
if q.k == 0 || q.vector.is_empty() {
return Err(TopoError::Rejected(
"vector search requires k > 0 and a non-empty query vector".into(),
));
}
let filter: Option<HashSet<NodeId>> =
q.candidates.as_ref().map(|c| c.iter().copied().collect());
let vectors = self.vectors();
let mut merged: Vec<(NodeId, f32)> = Vec::new();
{
let slabs = read_or_closed(&vectors.slabs)?;
for ((model, scope), arc) in slabs.iter() {
if model != &q.model || !q.scopes.contains(*scope) {
continue;
}
let slab = read_or_closed(arc)?;
if slab.dim != q.vector.len() {
continue;
}
merged.extend(slab.top_k(&q.vector, q.k, filter.as_ref()));
}
}
merged.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.0.cmp(&b.0))
});
merged.truncate(q.k);
let snap = self.snapshot();
let mut out: Vec<(NodeRecord, f32)> = Vec::with_capacity(merged.len());
for (id, score) in merged {
if let Some(rec) = snap.nodes.get(&id) {
out.push((rec.clone(), score));
}
}
self.bump(out.iter().map(|(n, _)| n.id));
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::ScopeId;
#[test]
fn poisoned_slab_lock_maps_to_closed_not_panic() {
let idx = VectorIndex::new();
let scope = Scope::Id(ScopeId::new());
idx.upsert("m1", scope, NodeId::new(), &[1.0, 0.0]);
let other_scope = Scope::Id(ScopeId::new());
idx.upsert("m1", other_scope, NodeId::new(), &[1.0, 0.0]);
let poisoned_arc = {
let slabs = idx.slabs.read().unwrap();
slabs.get(&("m1".to_string(), scope)).unwrap().clone()
};
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _g = poisoned_arc.write().unwrap();
panic!("poison this slab only");
}));
assert!(r.is_err());
match read_or_closed(&poisoned_arc) {
Err(TopoError::Closed) => {}
other => panic!("expected Closed, got {:?}", other.map(|_| ())),
};
let healthy_arc = {
let slabs = idx.slabs.read().unwrap();
slabs.get(&("m1".to_string(), other_scope)).unwrap().clone()
};
assert!(read_or_closed(&healthy_arc).is_ok());
}
#[test]
fn poisoned_outer_lock_maps_to_closed_not_panic() {
let idx = VectorIndex::new();
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _g = idx.slabs.write().unwrap();
panic!("poison it");
}));
assert!(r.is_err());
match read_or_closed(&idx.slabs) {
Err(TopoError::Closed) => {}
other => panic!("expected Closed, got {:?}", other.map(|_| ())),
};
}
}