use std::collections::HashSet;
use std::sync::{Arc, OnceLock};
use async_trait::async_trait;
use uuid::Uuid;
use khive_score::DeterministicScore;
use khive_storage::error::StorageError;
use khive_storage::types::{
BatchWriteSummary, IndexRebuildScope, OrphanSweepConfig, OrphanSweepResult, SqlStatement,
SqlValue, VectorIndexKind, VectorRecord, VectorSearchHit, VectorSearchRequest,
VectorStoreCapabilities, VectorStoreInfo,
};
use khive_storage::StorageCapability;
use khive_storage::StorageResult;
use khive_storage::VectorStore;
use khive_types::SubstrateKind;
use crate::error::SqliteError;
use crate::pool::ConnectionPool;
use crate::sql_bridge::bind_params;
pub fn delete_vector_statement(table: &str, subject_id: Uuid, namespace: &str) -> SqlStatement {
SqlStatement {
sql: format!("DELETE FROM {table} WHERE subject_id = ?1 AND namespace = ?2"),
params: vec![
SqlValue::Text(subject_id.to_string()),
SqlValue::Text(namespace.to_string()),
],
label: Some(format!("vec-delete-{table}")),
}
}
#[cfg(test)]
mod failpoint {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::cell::RefCell;
thread_local! {
pub(super) static CURRENT: RefCell<Option<Arc<AtomicBool>>> = const { RefCell::new(None) };
}
#[cfg(feature = "vectors")]
pub(super) fn arm() {
let flag = Arc::new(AtomicBool::new(true));
CURRENT.with(|c| *c.borrow_mut() = Some(flag));
}
#[cfg(feature = "vectors")]
pub(super) fn disarm() {
CURRENT.with(|c| *c.borrow_mut() = None);
}
pub(super) fn take(flag: &Arc<AtomicBool>) -> bool {
flag.compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
}
#[cfg(feature = "vectors")]
pub(super) struct FailpointGuard;
#[cfg(feature = "vectors")]
impl FailpointGuard {
pub(super) fn new() -> Self {
arm();
Self
}
}
#[cfg(feature = "vectors")]
impl Drop for FailpointGuard {
fn drop(&mut self) {
disarm();
}
}
}
fn f32_slice_as_bytes(data: &[f32]) -> &[u8] {
unsafe { std::slice::from_raw_parts(data.as_ptr() as *const u8, std::mem::size_of_val(data)) }
}
#[cfg(test)]
fn current_failpoint() -> Option<std::sync::Arc<std::sync::atomic::AtomicBool>> {
failpoint::CURRENT.with(|c| c.borrow().clone())
}
#[cfg(not(test))]
fn current_failpoint() -> Option<std::sync::Arc<std::sync::atomic::AtomicBool>> {
None
}
fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
StorageError::driver(StorageCapability::Vectors, op, e)
}
fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
StorageError::driver(StorageCapability::Vectors, op, e)
}
fn non_finite_index(data: &[f32]) -> Option<usize> {
data.iter().position(|v| !v.is_finite())
}
fn non_finite_vector_error(op: &'static str, idx: usize, value: f32) -> StorageError {
StorageError::InvalidInput {
capability: StorageCapability::Vectors,
operation: op.into(),
message: format!(
"non-finite value at index {idx}: {value} \
(NaN/Inf values corrupt distance computations)"
),
}
}
fn validate_model_key(model_key: &str) -> Result<(), SqliteError> {
if model_key.is_empty()
|| !model_key
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
return Err(SqliteError::InvalidData(format!(
"invalid model_key '{}': must be non-empty and contain only ASCII alphanumeric/underscore characters",
model_key
)));
}
Ok(())
}
pub struct SqliteVecStore {
pool: Arc<ConnectionPool>,
is_file_backed: bool,
model_key: String,
embedding_model: String,
dimensions: usize,
table_name: String,
namespace: String,
writer_task: Option<crate::writer_task::WriterTaskHandle>,
}
impl SqliteVecStore {
pub fn new(
pool: Arc<ConnectionPool>,
is_file_backed: bool,
model_key: String,
embedding_model: String,
dimensions: usize,
namespace: String,
) -> Result<Self, SqliteError> {
validate_model_key(&model_key)?;
let table_name = format!("vec_{}", model_key);
let writer_task = pool.writer_task_handle().ok().flatten();
Ok(Self {
pool,
is_file_backed,
model_key,
embedding_model,
dimensions,
table_name,
namespace,
writer_task,
})
}
fn open_standalone_reader(&self) -> Result<rusqlite::Connection, StorageError> {
let config = self.pool.config();
let path = config.path.as_ref().ok_or_else(|| StorageError::Pool {
operation: "vec_reader".into(),
message: "in-memory databases do not support standalone connections".into(),
})?;
let conn = rusqlite::Connection::open_with_flags(
path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
| rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
| rusqlite::OpenFlags::SQLITE_OPEN_URI,
)
.map_err(|e| map_err(e, "open_vec_reader"))?;
conn.busy_timeout(config.busy_timeout)
.map_err(|e| map_err(e, "open_vec_reader"))?;
conn.pragma_update(None, "foreign_keys", "ON")
.map_err(|e| map_err(e, "open_vec_reader"))?;
conn.pragma_update(None, "synchronous", "NORMAL")
.map_err(|e| map_err(e, "open_vec_reader"))?;
Ok(conn)
}
async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
where
F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
R: Send + 'static,
{
if let Some(writer_task) = &self.writer_task {
return writer_task
.send(move |conn| f(conn).map_err(|e| map_err(e, op)))
.await;
}
self.with_writer_unmanaged(op, f).await
}
async fn with_writer_unmanaged<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
where
F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
R: Send + 'static,
{
let pool = Arc::clone(&self.pool);
tokio::task::spawn_blocking(move || {
let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
f(guard.conn()).map_err(|e| map_err(e, op))
})
.await
.map_err(|e| StorageError::driver(StorageCapability::Vectors, op, e))?
}
async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
where
F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
R: Send + 'static,
{
if self.is_file_backed {
let conn = self.open_standalone_reader()?;
tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
.await
.map_err(|e| StorageError::driver(StorageCapability::Vectors, op, e))?
} else {
let pool = Arc::clone(&self.pool);
tokio::task::spawn_blocking(move || {
let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
f(guard.conn()).map_err(|e| map_err(e, op))
})
.await
.map_err(|e| StorageError::driver(StorageCapability::Vectors, op, e))?
}
}
}
struct VectorRowRef<'a> {
subject_id: Uuid,
namespace: &'a str,
kind: &'a str,
field: &'a str,
embedding_model: &'a str,
embedding: &'a [f32],
}
fn replace_vector_row_dml(
conn: &rusqlite::Connection,
table: &str,
dims: usize,
row: VectorRowRef<'_>,
failpoint_flag: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> Result<(), rusqlite::Error> {
if row.embedding.len() != dims {
return Err(rusqlite::Error::InvalidParameterCount(
row.embedding.len(),
dims,
));
}
let del_sql = format!("DELETE FROM {table} WHERE subject_id = ?1 AND namespace = ?2");
conn.execute(
&del_sql,
rusqlite::params![row.subject_id.to_string(), row.namespace],
)?;
#[cfg(test)]
if let Some(ref fp) = failpoint_flag {
if failpoint::take(fp) {
return Err(rusqlite::Error::InvalidParameterName(
"__test_failpoint_after_delete__".into(),
));
}
}
#[cfg(not(test))]
let _ = failpoint_flag;
let ins_sql = format!(
"INSERT INTO {table} (subject_id, namespace, kind, field, embedding_model, embedding) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
);
let blob = f32_slice_as_bytes(row.embedding);
conn.execute(
&ins_sql,
rusqlite::params![
row.subject_id.to_string(),
row.namespace,
row.kind,
row.field,
row.embedding_model,
blob
],
)?;
Ok(())
}
pub fn delete_subject_from_vector_tables(
conn: &rusqlite::Connection,
tables: &[String],
subject_id: Uuid,
namespace: &str,
) -> Result<(), rusqlite::Error> {
for table in tables {
let sql = format!("DELETE FROM {table} WHERE subject_id = ?1 AND namespace = ?2");
conn.execute(&sql, rusqlite::params![subject_id.to_string(), namespace])?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn batch_insert_vectors_dml(
conn: &rusqlite::Connection,
table: &str,
dims: usize,
store_embedding_model: &str,
records: &[VectorRecord],
attempted: u64,
failpoint_flag: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> Result<BatchWriteSummary, rusqlite::Error> {
let mut affected = 0u64;
let mut failed = 0u64;
let mut first_error = String::new();
for record in records {
if record.vectors.len() != 1 {
if first_error.is_empty() {
first_error = format!("expected 1 vector per record, got {}", record.vectors.len());
}
failed += 1;
continue;
}
let embedding = &record.vectors[0];
if embedding.len() != dims {
if first_error.is_empty() {
first_error = format!(
"wrong vector dimension: expected {dims}, got {}",
embedding.len()
);
}
failed += 1;
continue;
}
if non_finite_index(embedding).is_some() {
if first_error.is_empty() {
first_error = "embedding contains non-finite values (NaN or Inf)".to_string();
}
failed += 1;
continue;
}
let kind_str = record.kind.to_string();
conn.execute_batch("SAVEPOINT vec_batch_record")?;
let result = replace_vector_row_dml(
conn,
table,
dims,
VectorRowRef {
subject_id: record.subject_id,
namespace: &record.namespace,
kind: &kind_str,
field: &record.field,
embedding_model: store_embedding_model,
embedding,
},
failpoint_flag.clone(),
);
match result {
Ok(()) => {
conn.execute_batch("RELEASE SAVEPOINT vec_batch_record")?;
affected += 1;
}
Err(e) => {
let _ = conn.execute_batch("ROLLBACK TO SAVEPOINT vec_batch_record");
let _ = conn.execute_batch("RELEASE SAVEPOINT vec_batch_record");
if first_error.is_empty() {
first_error = e.to_string();
}
failed += 1;
}
}
}
Ok(BatchWriteSummary {
attempted,
affected,
failed,
first_error,
})
}
#[allow(clippy::too_many_arguments)]
fn vec_upsert_atomic_dml(
conn: &rusqlite::Connection,
table: &str,
dims: usize,
subject_id: Uuid,
kind_str: &str,
namespace: &str,
field: &str,
embedding_model: &str,
embedding: &[f32],
savepoint_name: &'static str,
failpoint_flag: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> Result<(), rusqlite::Error> {
conn.execute_batch(&format!("SAVEPOINT {savepoint_name}"))?;
let result = replace_vector_row_dml(
conn,
table,
dims,
VectorRowRef {
subject_id,
namespace,
kind: kind_str,
field,
embedding_model,
embedding,
},
failpoint_flag,
);
match result {
Ok(()) => {
conn.execute_batch(&format!("RELEASE SAVEPOINT {savepoint_name}"))?;
Ok(())
}
Err(e) => {
let _ = conn.execute_batch(&format!("ROLLBACK TO SAVEPOINT {savepoint_name}"));
let _ = conn.execute_batch(&format!("RELEASE SAVEPOINT {savepoint_name}"));
Err(e)
}
}
}
fn orphan_sweep_dml(
conn: &rusqlite::Connection,
table: &str,
ns_json: Option<&str>,
kind_json: Option<&str>,
allow_json: Option<&str>,
max_delete: i64,
dry_run: bool,
) -> Result<OrphanSweepResult, rusqlite::Error> {
let filter_pred = "(?1 IS NULL OR namespace IN (SELECT value FROM json_each(?1))) \
AND (?2 IS NULL OR kind IN (SELECT value FROM json_each(?2))) \
AND (?3 IS NULL OR subject_id IN (SELECT value FROM json_each(?3)))";
let live_subq = "SELECT id FROM entities WHERE deleted_at IS NULL \
UNION ALL \
SELECT id FROM notes WHERE deleted_at IS NULL";
let orphan_pred = format!(
"subject_id NOT IN ({live}) AND {f}",
live = live_subq,
f = filter_pred,
);
let scan_sql = format!(
"SELECT COUNT(*) FROM {t} WHERE {f}",
t = table,
f = filter_pred
);
let scanned: i64 = conn.query_row(
&scan_sql,
rusqlite::params![ns_json, kind_json, allow_json],
|row| row.get(0),
)?;
let count_sql = format!(
"SELECT COUNT(*) FROM {t} WHERE {p}",
t = table,
p = orphan_pred,
);
let would_delete: i64 = conn.query_row(
&count_sql,
rusqlite::params![ns_json, kind_json, allow_json],
|row| row.get(0),
)?;
let max_delete_hit = would_delete > max_delete;
let deleted: i64 = if dry_run {
0
} else {
let del_sql = format!(
"DELETE FROM {t} WHERE subject_id IN (\
SELECT subject_id FROM {t} WHERE {p} LIMIT ?4\
)",
t = table,
p = orphan_pred,
);
conn.execute(
&del_sql,
rusqlite::params![ns_json, kind_json, allow_json, max_delete],
)? as i64
};
Ok(OrphanSweepResult {
scanned: scanned as u64,
would_delete: would_delete as u64,
deleted: deleted as u64,
max_delete_hit,
})
}
#[async_trait]
impl VectorStore for SqliteVecStore {
async fn insert(
&self,
subject_id: Uuid,
kind: SubstrateKind,
namespace: &str,
field: &str,
vectors: Vec<Vec<f32>>,
) -> Result<(), StorageError> {
if vectors.len() != 1 {
return Err(StorageError::Unsupported {
capability: StorageCapability::Vectors,
operation: "vec_insert".into(),
message: "sqlite-vec supports exactly one vector per record".into(),
});
}
let embedding = vectors.into_iter().next().expect("len checked");
let table = self.table_name.clone();
let dims = self.dimensions;
let namespace = namespace.to_string();
let field = field.to_string();
let kind_str = kind.to_string();
let embedding_model = self.embedding_model.clone();
if embedding.len() == dims {
if let Some(idx) = non_finite_index(&embedding) {
return Err(non_finite_vector_error("vec_insert", idx, embedding[idx]));
}
}
let failpoint_flag = current_failpoint();
if let Some(writer_task) = &self.writer_task {
let table2 = table.clone();
let namespace2 = namespace.clone();
let field2 = field.clone();
let kind_str2 = kind_str.clone();
let embedding_model2 = embedding_model.clone();
let embedding2 = embedding.clone();
return writer_task
.send(move |conn| {
vec_upsert_atomic_dml(
conn,
&table2,
dims,
subject_id,
&kind_str2,
&namespace2,
&field2,
&embedding_model2,
&embedding2,
"vec_insert_atomic",
failpoint_flag,
)
.map_err(|e| map_err(e, "vec_insert"))
})
.await;
}
self.with_writer("vec_insert", move |conn| {
let _tx_handle =
khive_storage::tx_registry::register(Some("vec_insert_tx".to_string()));
let tx = conn.unchecked_transaction()?;
replace_vector_row_dml(
&tx,
&table,
dims,
VectorRowRef {
subject_id,
namespace: &namespace,
kind: &kind_str,
field: &field,
embedding_model: &embedding_model,
embedding: &embedding,
},
failpoint_flag,
)?;
tx.commit()
})
.await
}
async fn insert_batch(
&self,
records: Vec<VectorRecord>,
) -> Result<BatchWriteSummary, StorageError> {
let table = self.table_name.clone();
let dims = self.dimensions;
let attempted = records.len() as u64;
let store_embedding_model = self.embedding_model.clone();
let failpoint_flag = current_failpoint();
if let Some(writer_task) = &self.writer_task {
let table2 = table.clone();
let store_embedding_model2 = store_embedding_model.clone();
return writer_task
.send(move |conn| {
batch_insert_vectors_dml(
conn,
&table2,
dims,
&store_embedding_model2,
&records,
attempted,
failpoint_flag,
)
.map_err(|e| map_err(e, "vec_insert_batch"))
})
.await;
}
self.with_writer("vec_insert_batch", move |conn| {
conn.execute_batch("BEGIN IMMEDIATE")?;
let _tx_handle =
khive_storage::tx_registry::register(Some("vector_insert_batch".to_string()));
let summary = batch_insert_vectors_dml(
conn,
&table,
dims,
&store_embedding_model,
&records,
attempted,
failpoint_flag,
)?;
conn.execute_batch("COMMIT")?;
Ok(summary)
})
.await
}
async fn update(
&self,
subject_id: Uuid,
kind: SubstrateKind,
namespace: &str,
field: &str,
vectors: Vec<Vec<f32>>,
) -> Result<(), StorageError> {
if vectors.len() != 1 {
return Err(StorageError::Unsupported {
capability: StorageCapability::Vectors,
operation: "vec_update".into(),
message: "sqlite-vec supports exactly one vector per record".into(),
});
}
let embedding = vectors.into_iter().next().expect("len checked");
let table = self.table_name.clone();
let dims = self.dimensions;
let namespace = namespace.to_string();
let field = field.to_string();
let kind_str = kind.to_string();
let embedding_model = self.embedding_model.clone();
if embedding.len() == dims {
if let Some(idx) = non_finite_index(&embedding) {
return Err(non_finite_vector_error("vec_update", idx, embedding[idx]));
}
}
let failpoint_flag = current_failpoint();
if let Some(writer_task) = &self.writer_task {
let table2 = table.clone();
let namespace2 = namespace.clone();
let field2 = field.clone();
let kind_str2 = kind_str.clone();
let embedding_model2 = embedding_model.clone();
let embedding2 = embedding.clone();
return writer_task
.send(move |conn| {
vec_upsert_atomic_dml(
conn,
&table2,
dims,
subject_id,
&kind_str2,
&namespace2,
&field2,
&embedding_model2,
&embedding2,
"vec_update_atomic",
failpoint_flag,
)
.map_err(|e| map_err(e, "vec_update"))
})
.await;
}
self.with_writer("vec_update", move |conn| {
let _tx_handle =
khive_storage::tx_registry::register(Some("vec_update_tx".to_string()));
let tx = conn.unchecked_transaction()?;
replace_vector_row_dml(
&tx,
&table,
dims,
VectorRowRef {
subject_id,
namespace: &namespace,
kind: &kind_str,
field: &field,
embedding_model: &embedding_model,
embedding: &embedding,
},
failpoint_flag,
)?;
tx.commit()
})
.await
}
async fn delete(&self, subject_id: Uuid) -> Result<bool, StorageError> {
let statement = delete_vector_statement(&self.table_name, subject_id, &self.namespace);
self.with_writer("vec_delete", move |conn| {
let mut stmt = conn.prepare(&statement.sql)?;
bind_params(&mut stmt, &statement.params)?;
Ok(stmt.raw_execute()? > 0)
})
.await
}
async fn count(&self) -> Result<u64, StorageError> {
let table = self.table_name.clone();
let namespace = self.namespace.clone();
self.with_reader("vec_count", move |conn| {
let sql = format!("SELECT COUNT(*) FROM {} WHERE namespace = ?1", table);
let count: i64 =
conn.query_row(&sql, rusqlite::params![&namespace], |row| row.get(0))?;
Ok(count as u64)
})
.await
}
async fn search(
&self,
request: VectorSearchRequest,
) -> Result<Vec<VectorSearchHit>, StorageError> {
if request.filter.as_ref().is_some_and(|f| !f.is_empty()) {
return Err(StorageError::Unsupported {
capability: StorageCapability::Vectors,
operation: "vec_search".into(),
message: "use search_with_filter for filtered queries".into(),
});
}
if request.query_vectors.len() != 1 {
return Err(StorageError::Unsupported {
capability: StorageCapability::Vectors,
operation: "vec_search".into(),
message: "sqlite-vec supports exactly one query vector per search".into(),
});
}
let query_embedding = request.query_vectors[0].clone();
let table = self.table_name.clone();
let dims = self.dimensions;
let namespace = request
.namespace
.clone()
.unwrap_or_else(|| self.namespace.clone());
let kind_filter = request.kind.map(|k| k.to_string());
let effective_model = request
.embedding_model
.clone()
.unwrap_or_else(|| self.embedding_model.clone());
if query_embedding.len() == dims {
if let Some(idx) = non_finite_index(&query_embedding) {
return Err(non_finite_vector_error(
"vec_search",
idx,
query_embedding[idx],
));
}
}
self.with_reader("vec_search", move |conn| {
if query_embedding.len() != dims {
return Err(rusqlite::Error::InvalidParameterCount(
query_embedding.len(),
dims,
));
}
let kind_clause = if kind_filter.is_some() {
"AND kind = ?5"
} else {
""
};
let sql = format!(
"SELECT subject_id, distance \
FROM {t} \
WHERE embedding MATCH ?1 \
AND namespace = ?3 \
AND embedding_model = ?4 \
{kind_clause} \
ORDER BY distance \
LIMIT ?2",
t = table,
kind_clause = kind_clause
);
let query_blob = f32_slice_as_bytes(&query_embedding);
let mut stmt = conn.prepare(&sql)?;
let raw_rows: Vec<rusqlite::Result<(String, f64)>> =
if let Some(ref kind_str) = kind_filter {
stmt.query_map(
rusqlite::params![
query_blob,
request.top_k,
&namespace,
&effective_model,
kind_str
],
|row| {
let id_str: String = row.get(0)?;
let distance: f64 = row.get(1)?;
Ok((id_str, distance))
},
)?
.collect()
} else {
stmt.query_map(
rusqlite::params![query_blob, request.top_k, &namespace, &effective_model],
|row| {
let id_str: String = row.get(0)?;
let distance: f64 = row.get(1)?;
Ok((id_str, distance))
},
)?
.collect()
};
let mut hits = Vec::new();
for (rank_idx, row) in raw_rows.into_iter().enumerate() {
let (id_str, distance) = row?;
let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
0,
rusqlite::types::Type::Text,
Box::new(e),
)
})?;
let similarity = 1.0 - (distance / 2.0);
hits.push(VectorSearchHit {
subject_id,
score: DeterministicScore::from_f64(similarity),
rank: (rank_idx + 1) as u32,
});
}
Ok(hits)
})
.await
}
async fn info(&self) -> Result<VectorStoreInfo, StorageError> {
let count = self.count().await?;
Ok(VectorStoreInfo {
model_name: self.model_key.clone(),
dimensions: self.dimensions,
index_kind: VectorIndexKind::SqliteVec,
entry_count: count,
needs_rebuild: false,
last_rebuild_at: None,
})
}
async fn rebuild(&self, _scope: IndexRebuildScope) -> Result<VectorStoreInfo, StorageError> {
self.info().await
}
async fn delete_subjects(&self, ids: &[Uuid]) -> Result<u64, StorageError> {
if ids.is_empty() {
return Ok(0);
}
let table = self.table_name.clone();
let id_strings: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
let mut total_deleted: u64 = 0;
for chunk in id_strings.chunks(400) {
let placeholders: String = (1..=chunk.len())
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.join(", ");
let sql = format!("DELETE FROM {table} WHERE subject_id IN ({placeholders})");
let chunk_owned = chunk.to_vec();
let table_cl = table.clone();
let deleted = self
.with_writer("vec_delete_subjects", move |conn| {
let mut stmt = conn.prepare(&sql)?;
for (i, id_str) in chunk_owned.iter().enumerate() {
stmt.raw_bind_parameter(i + 1, id_str.as_str())?;
}
stmt.raw_execute().map(|n| n as u64)
})
.await
.map_err(|e| {
tracing::warn!(error = %e, table = %table_cl, "delete_subjects chunk failed");
e
})?;
total_deleted += deleted;
}
Ok(total_deleted)
}
async fn batch_exists(
&self,
ids: &[Uuid],
namespace: &str,
) -> Result<HashSet<Uuid>, StorageError> {
if ids.is_empty() {
return Ok(HashSet::new());
}
let table = self.table_name.clone();
let namespace = namespace.to_string();
let model = self.embedding_model.clone();
let id_strings: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
self.with_reader("vec_batch_exists", move |conn| {
let mut found = HashSet::new();
for chunk in id_strings.chunks(400) {
let placeholders: String = (0..chunk.len())
.map(|i| format!("?{}", i + 3))
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"SELECT subject_id FROM {} WHERE namespace = ?1 \
AND embedding_model = ?2 AND subject_id IN ({})",
table, placeholders
);
let mut stmt = conn.prepare(&sql)?;
stmt.raw_bind_parameter(1, namespace.as_str())?;
stmt.raw_bind_parameter(2, model.as_str())?;
for (i, id_str) in chunk.iter().enumerate() {
stmt.raw_bind_parameter(i + 3, id_str.as_str())?;
}
let mut rows = stmt.raw_query();
while let Some(row) = rows.next()? {
let id_str: String = row.get(0)?;
if let Ok(uuid) = Uuid::parse_str(&id_str) {
found.insert(uuid);
}
}
}
Ok(found)
})
.await
}
async fn orphan_sweep(&self, config: &OrphanSweepConfig) -> StorageResult<OrphanSweepResult> {
let table = self.table_name.clone();
let ns_json: Option<String> = if config.namespaces.is_empty() {
None
} else {
serde_json::to_string(&config.namespaces).ok()
};
let kind_json: Option<String> = if config.substrate_kinds.is_empty() {
None
} else {
let strs: Vec<String> = config
.substrate_kinds
.iter()
.map(|k| k.to_string())
.collect();
serde_json::to_string(&strs).ok()
};
let allow_json: Option<String> = config.subject_id_allowlist.as_ref().map(|ids| {
let strs: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
serde_json::to_string(&strs).unwrap_or_default()
});
let max_delete = config.max_delete as i64;
let dry_run = config.dry_run;
if let Some(writer_task) = &self.writer_task {
let table2 = table.clone();
let ns_json2 = ns_json.clone();
let kind_json2 = kind_json.clone();
let allow_json2 = allow_json.clone();
return writer_task
.send(move |conn| {
orphan_sweep_dml(
conn,
&table2,
ns_json2.as_deref(),
kind_json2.as_deref(),
allow_json2.as_deref(),
max_delete,
dry_run,
)
.map_err(|e| map_err(e, "orphan_sweep"))
})
.await;
}
self.with_writer_unmanaged("orphan_sweep", move |conn| {
let _tx_handle =
khive_storage::tx_registry::register(Some("vec_orphan_sweep".to_string()));
let tx = rusqlite::Transaction::new_unchecked(
conn,
rusqlite::TransactionBehavior::Immediate,
)?;
let result = orphan_sweep_dml(
conn,
&table,
ns_json.as_deref(),
kind_json.as_deref(),
allow_json.as_deref(),
max_delete,
dry_run,
)?;
tx.commit()?;
Ok(result)
})
.await
}
fn capabilities(&self) -> &'static VectorStoreCapabilities {
static SQLITE_VEC_CAPABILITIES: OnceLock<VectorStoreCapabilities> = OnceLock::new();
SQLITE_VEC_CAPABILITIES.get_or_init(|| VectorStoreCapabilities {
supports_filter: false,
supports_batch_search: false,
supports_quantization: false,
supports_update: false,
supports_orphan_sweep: true,
supports_multi_field: false,
max_dimensions: Some(8192),
index_kinds: vec![VectorIndexKind::SqliteVec],
})
}
}
impl SqliteVecStore {
pub async fn score_candidates(
&self,
query_embedding: &[f32],
candidate_ids: &[Uuid],
) -> Result<Vec<VectorSearchHit>, StorageError> {
if candidate_ids.is_empty() || query_embedding.is_empty() {
return Ok(Vec::new());
}
let dims = self.dimensions;
if query_embedding.len() != dims {
return Err(StorageError::InvalidInput {
capability: StorageCapability::Vectors,
operation: "score_candidates".into(),
message: format!(
"query has {} dims, expected {}",
query_embedding.len(),
dims
),
});
}
if let Some(idx) = non_finite_index(query_embedding) {
return Err(non_finite_vector_error(
"score_candidates",
idx,
query_embedding[idx],
));
}
let table = self.table_name.clone();
let namespace = self.namespace.clone();
let embedding_model = self.embedding_model.clone();
let query_vec = query_embedding.to_vec();
let ids: Vec<String> = candidate_ids.iter().map(|id| id.to_string()).collect();
self.with_reader("score_candidates", move |conn| {
let mut all_hits: Vec<VectorSearchHit> = Vec::new();
let query_blob = f32_slice_as_bytes(&query_vec);
for chunk in ids.chunks(399) {
let placeholders: String = chunk
.iter()
.enumerate()
.map(|(i, _)| format!("?{}", i + 4))
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"SELECT e.subject_id, vec_distance_cosine(e.embedding, ?1) as distance \
FROM {} e \
WHERE e.namespace = ?2 AND e.embedding_model = ?3 \
AND e.subject_id IN ({})",
table, placeholders
);
let mut stmt = conn.prepare(&sql)?;
stmt.raw_bind_parameter(1, query_blob)?;
stmt.raw_bind_parameter(2, namespace.as_str())?;
stmt.raw_bind_parameter(3, embedding_model.as_str())?;
for (i, id_str) in chunk.iter().enumerate() {
stmt.raw_bind_parameter(i + 4, id_str.as_str())?;
}
let mut rows = stmt.raw_query();
while let Some(row) = rows.next()? {
let id_str: String = row.get(0)?;
let distance: f64 = row.get(1)?;
let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
0,
rusqlite::types::Type::Text,
Box::new(e),
)
})?;
let similarity = 1.0 - (distance / 2.0);
all_hits.push(VectorSearchHit {
subject_id,
score: DeterministicScore::from_f64(similarity),
rank: 0,
});
}
}
all_hits.sort_by_key(|hit| std::cmp::Reverse(hit.score));
for (i, hit) in all_hits.iter_mut().enumerate() {
hit.rank = (i + 1) as u32;
}
Ok(all_hits)
})
.await
}
}
#[cfg(all(test, feature = "vectors"))]
mod batch_exists_tests {
use std::collections::HashSet;
use std::sync::Arc;
use khive_types::SubstrateKind;
use uuid::Uuid;
use super::*;
fn make_vec_pool() -> Arc<crate::pool::ConnectionPool> {
use crate::pool::{ConnectionPool, PoolConfig};
crate::extension::ensure_extensions_loaded();
let config = PoolConfig {
path: None,
..PoolConfig::default()
};
Arc::new(ConnectionPool::new(config).expect("in-memory pool"))
}
fn create_vec_table(pool: &Arc<crate::pool::ConnectionPool>, model_key: &str, dims: usize) {
let writer = pool.try_writer().expect("pool writer");
let ddl = format!(
"CREATE VIRTUAL TABLE IF NOT EXISTS vec_{} USING vec0(\
subject_id TEXT PRIMARY KEY, \
namespace TEXT NOT NULL, \
kind TEXT NOT NULL, \
field TEXT NOT NULL, \
embedding_model TEXT NOT NULL, \
embedding float[{}] distance_metric=cosine)",
model_key, dims
);
writer.conn().execute_batch(&ddl).expect("create vec table");
}
#[tokio::test]
async fn batch_exists_returns_correct_set_for_underscored_model_key() {
let pool = make_vec_pool();
let model_key = "all_minilm_l6_v2";
let dims = 4;
let ns = "ns:test";
create_vec_table(&pool, model_key, dims);
let store = SqliteVecStore::new(
pool,
false,
model_key.to_string(),
model_key.to_string(),
dims,
ns.to_string(),
)
.expect("SqliteVecStore::new");
let id1 = Uuid::new_v4();
let id2 = Uuid::new_v4();
let id_absent = Uuid::new_v4();
store
.insert(
id1,
SubstrateKind::Entity,
ns,
"body",
vec![vec![0.1, 0.2, 0.3, 0.4]],
)
.await
.expect("insert id1");
store
.insert(
id2,
SubstrateKind::Entity,
ns,
"body",
vec![vec![0.5, 0.6, 0.7, 0.8]],
)
.await
.expect("insert id2");
let exists = store
.batch_exists(&[id1, id2, id_absent], ns)
.await
.expect("batch_exists");
assert!(exists.contains(&id1), "id1 must be found");
assert!(exists.contains(&id2), "id2 must be found");
assert!(
!exists.contains(&id_absent),
"absent id must not be returned"
);
assert_eq!(exists.len(), 2);
}
#[tokio::test]
async fn batch_exists_empty_ids_returns_empty_set() {
let pool = make_vec_pool();
let model_key = "empty_test_model";
create_vec_table(&pool, model_key, 4);
let store = SqliteVecStore::new(
pool,
false,
model_key.to_string(),
model_key.to_string(),
4,
"ns:test".to_string(),
)
.expect("SqliteVecStore::new");
let exists: HashSet<Uuid> = store
.batch_exists(&[], "ns:test")
.await
.expect("batch_exists");
assert!(exists.is_empty());
}
#[tokio::test]
async fn vector_search_namespace_predicate_prevents_recall_starvation() {
let pool = make_vec_pool();
let model_key = "knn_namespace_scope";
let dims = 4;
create_vec_table(&pool, model_key, dims);
let store = SqliteVecStore::new(
pool,
false,
model_key.to_string(),
model_key.to_string(),
dims,
"ns:b".to_string(),
)
.expect("SqliteVecStore::new");
let distractor_a = Uuid::new_v4();
let victim_b = Uuid::new_v4();
store
.insert(
distractor_a,
SubstrateKind::Entity,
"ns:a",
"body",
vec![vec![1.0, 0.0, 0.0, 0.0]],
)
.await
.expect("insert nearer cross-namespace vector");
store
.insert(
victim_b,
SubstrateKind::Entity,
"ns:b",
"body",
vec![vec![0.8, 0.2, 0.0, 0.0]],
)
.await
.expect("insert in-namespace vector");
let hits = store
.search(VectorSearchRequest {
query_vectors: vec![vec![1.0, 0.0, 0.0, 0.0]],
top_k: 1,
namespace: Some("ns:b".to_string()),
kind: Some(SubstrateKind::Entity),
embedding_model: None,
filter: None,
backend_hints: None,
})
.await
.expect("search");
assert_eq!(
hits.len(),
1,
"namespace B must not be starved by namespace A"
);
assert_eq!(
hits[0].subject_id, victim_b,
"top-1 in ns:b must be victim_b, not cross-namespace distractor_a"
);
}
#[test]
fn hyphenated_model_key_is_rejected_at_construction() {
use crate::pool::{ConnectionPool, PoolConfig};
let pool = Arc::new(
ConnectionPool::new(PoolConfig {
path: None,
..PoolConfig::default()
})
.expect("pool"),
);
let result = SqliteVecStore::new(
pool,
false,
"all-minilm-l6-v2".to_string(),
"all-minilm-l6-v2".to_string(),
4,
"ns:test".to_string(),
);
assert!(
result.is_err(),
"hyphenated model_key 'all-minilm-l6-v2' must be rejected; \
the store's table_name would differ from what a hand-rolled sanitizer produces"
);
}
}
#[cfg(test)]
mod first_error_tests {
use super::*;
use khive_storage::types::VectorRecord;
use khive_storage::VectorStore;
use khive_types::SubstrateKind;
use uuid::Uuid;
fn make_pool() -> Arc<crate::pool::ConnectionPool> {
use crate::pool::{ConnectionPool, PoolConfig};
let config = PoolConfig {
path: None,
..PoolConfig::default()
};
Arc::new(ConnectionPool::new(config).expect("in-memory pool"))
}
#[tokio::test]
async fn insert_batch_first_error_populated_on_dimension_mismatch() {
let dims = 4usize;
let store = SqliteVecStore::new(
make_pool(),
false,
"first_err_vec".into(),
"first_err_vec".into(),
dims,
"ns:test".into(),
)
.expect("SqliteVecStore::new");
let summary = store
.insert_batch(vec![
VectorRecord {
subject_id: Uuid::new_v4(),
kind: SubstrateKind::Entity,
namespace: "ns:test".to_string(),
field: "body".to_string(),
embedding_model: None,
vectors: vec![vec![0.0f32; dims + 1]],
updated_at: chrono::Utc::now(),
},
VectorRecord {
subject_id: Uuid::new_v4(),
kind: SubstrateKind::Entity,
namespace: "ns:test".to_string(),
field: "body".to_string(),
embedding_model: None,
vectors: vec![vec![0.0f32; dims + 2]],
updated_at: chrono::Utc::now(),
},
])
.await
.expect("insert_batch must return Ok (best-effort semantics)");
assert_eq!(summary.attempted, 2);
assert_eq!(
summary.failed, 2,
"both wrong-dims records must be counted as failed"
);
assert_eq!(summary.affected, 0);
assert!(
!summary.first_error.is_empty(),
"first_error must be populated when failed > 0; \
got empty string; the validation error is silently swallowed"
);
}
}
#[cfg(test)]
mod capabilities_tests {
use super::*;
fn make_pool() -> Arc<crate::pool::ConnectionPool> {
use crate::pool::{ConnectionPool, PoolConfig};
let config = PoolConfig {
path: None,
..PoolConfig::default()
};
Arc::new(ConnectionPool::new(config).expect("in-memory pool"))
}
#[test]
fn sqlite_vec_store_capabilities_are_correct() {
let store = SqliteVecStore::new(
make_pool(),
false,
"test_model".into(),
"test_model".into(),
4,
"ns:test".into(),
)
.expect("SqliteVecStore::new");
let caps = store.capabilities();
assert!(
!caps.supports_filter,
"sqlite-vec does not support filter pushdown"
);
assert!(
!caps.supports_batch_search,
"sqlite-vec does not support native batch search"
);
assert!(
!caps.supports_quantization,
"sqlite-vec does not support quantization"
);
assert!(
!caps.supports_update,
"sqlite-vec does not support in-place update"
);
assert!(
caps.supports_orphan_sweep,
"SqliteVecStore must advertise supports_orphan_sweep = true"
);
assert_eq!(caps.max_dimensions, Some(8192));
assert_eq!(
caps.index_kinds,
vec![VectorIndexKind::SqliteVec],
"index_kinds should be [SqliteVec]"
);
}
#[test]
fn max_dimensions_reflects_sqlite_vec_hard_limit_not_k_max() {
let store = SqliteVecStore::new(
make_pool(),
false,
"test_dim_limit".into(),
"test_dim_limit".into(),
4,
"ns:test".into(),
)
.expect("SqliteVecStore::new");
let caps = store.capabilities();
let max = caps
.max_dimensions
.expect("SqliteVecStore must declare a finite dimension limit");
assert!(
max >= 8192,
"max_dimensions ({max}) must be at least 8192 — the sqlite-vec hard limit"
);
}
#[test]
fn capabilities_is_idempotent() {
let store = SqliteVecStore::new(
make_pool(),
false,
"test_idempotent".into(),
"test_idempotent".into(),
4,
"ns:test".into(),
)
.expect("SqliteVecStore::new");
let caps1 = store.capabilities();
let caps2 = store.capabilities();
assert_eq!(
caps1 as *const _, caps2 as *const _,
"capabilities() must return the same static reference each call"
);
}
}
#[cfg(all(test, feature = "vectors"))]
mod atomic_replace_tests {
use std::sync::Arc;
use khive_storage::types::VectorRecord;
use khive_storage::VectorStore;
use khive_types::SubstrateKind;
use uuid::Uuid;
use super::*;
fn make_vec_pool() -> Arc<crate::pool::ConnectionPool> {
use crate::pool::{ConnectionPool, PoolConfig};
crate::extension::ensure_extensions_loaded();
let config = PoolConfig {
path: None,
..PoolConfig::default()
};
Arc::new(ConnectionPool::new(config).expect("in-memory pool"))
}
fn create_vec_table(pool: &Arc<crate::pool::ConnectionPool>, model_key: &str, dims: usize) {
let writer = pool.try_writer().expect("pool writer");
let ddl = format!(
"CREATE VIRTUAL TABLE IF NOT EXISTS vec_{} USING vec0(\
subject_id TEXT PRIMARY KEY, \
namespace TEXT NOT NULL, \
kind TEXT NOT NULL, \
field TEXT NOT NULL, \
embedding_model TEXT NOT NULL, \
embedding float[{}] distance_metric=cosine)",
model_key, dims
);
writer.conn().execute_batch(&ddl).expect("create vec table");
}
#[tokio::test]
async fn insert_batch_failed_record_preserves_prior_vector() {
let pool = make_vec_pool();
let model_key = "atomic_batch_test";
let dims = 4;
let ns = "ns:atomic";
create_vec_table(&pool, model_key, dims);
let store = SqliteVecStore::new(
Arc::clone(&pool),
false,
model_key.to_string(),
model_key.to_string(),
dims,
ns.to_string(),
)
.expect("SqliteVecStore::new");
let id_existing = Uuid::new_v4();
let id_new = Uuid::new_v4();
let original_vec = vec![0.1f32, 0.2, 0.3, 0.4];
store
.insert(
id_existing,
SubstrateKind::Entity,
ns,
"body",
vec![original_vec.clone()],
)
.await
.expect("initial insert");
let summary = store
.insert_batch(vec![
VectorRecord {
subject_id: id_existing,
kind: SubstrateKind::Entity,
namespace: ns.to_string(),
field: "body".to_string(),
embedding_model: None,
vectors: vec![vec![9.9f32; dims + 1]],
updated_at: chrono::Utc::now(),
},
VectorRecord {
subject_id: id_new,
kind: SubstrateKind::Entity,
namespace: ns.to_string(),
field: "body".to_string(),
embedding_model: None,
vectors: vec![vec![0.5f32, 0.6, 0.7, 0.8]],
updated_at: chrono::Utc::now(),
},
])
.await
.expect("insert_batch");
assert_eq!(summary.attempted, 2);
assert_eq!(summary.affected, 1, "only id_new should succeed");
assert_eq!(summary.failed, 1, "id_existing with wrong dims must fail");
let existing_still_present = store
.batch_exists(&[id_existing], ns)
.await
.expect("batch_exists");
assert!(
existing_still_present.contains(&id_existing),
"prior vector for id_existing must survive a failed batch replace"
);
let new_present = store
.batch_exists(&[id_new], ns)
.await
.expect("batch_exists for id_new");
assert!(
new_present.contains(&id_new),
"id_new with valid dims must be inserted"
);
}
#[tokio::test]
async fn update_failed_preserves_prior_vector() {
let pool = make_vec_pool();
let model_key = "atomic_update_test";
let dims = 4;
let ns = "ns:atomic_upd";
create_vec_table(&pool, model_key, dims);
let store = SqliteVecStore::new(
Arc::clone(&pool),
false,
model_key.to_string(),
model_key.to_string(),
dims,
ns.to_string(),
)
.expect("SqliteVecStore::new");
let id = Uuid::new_v4();
store
.insert(
id,
SubstrateKind::Entity,
ns,
"body",
vec![vec![0.1f32, 0.2, 0.3, 0.4]],
)
.await
.expect("initial insert");
let result = store
.update(
id,
SubstrateKind::Entity,
ns,
"body",
vec![vec![9.9f32; dims + 1]],
)
.await;
assert!(result.is_err(), "update with wrong dims must fail");
let still_present = store
.batch_exists(&[id], ns)
.await
.expect("batch_exists after failed update");
assert!(
still_present.contains(&id),
"prior vector must survive a failed update"
);
}
#[tokio::test]
async fn insert_batch_savepoint_rollback_on_pk_conflict_preserves_stale() {
let pool = make_vec_pool();
let model_key = "atomic_pk_batch";
let dims = 4;
let ns_a = "ns:pk_a";
let ns_b = "ns:pk_b";
create_vec_table(&pool, model_key, dims);
let store = SqliteVecStore::new(
Arc::clone(&pool),
false,
model_key.to_string(),
model_key.to_string(),
dims,
ns_a.to_string(),
)
.expect("SqliteVecStore::new");
let id_x = Uuid::new_v4();
let stale_vec = vec![0.1f32, 0.2, 0.3, 0.4];
store
.insert(
id_x,
SubstrateKind::Entity,
ns_a,
"body",
vec![stale_vec.clone()],
)
.await
.expect("stale insert");
let summary = store
.insert_batch(vec![VectorRecord {
subject_id: id_x,
kind: SubstrateKind::Entity,
namespace: ns_b.to_string(),
field: "body".to_string(),
embedding_model: None,
vectors: vec![vec![0.5f32, 0.6, 0.7, 0.8]],
updated_at: chrono::Utc::now(),
}])
.await
.expect("insert_batch must complete (outer tx must commit)");
assert_eq!(summary.attempted, 1);
assert_eq!(summary.affected, 0, "PK conflict must count as failed");
assert_eq!(
summary.failed, 1,
"failed counter must increment after ROLLBACK TO SAVEPOINT"
);
let post = store
.batch_exists(&[id_x], ns_a)
.await
.expect("batch_exists ns:a");
assert!(
post.contains(&id_x),
"stale row in ns:a must survive after SAVEPOINT + INSERT failure"
);
let hits = store
.search(VectorSearchRequest {
query_vectors: vec![stale_vec.clone()],
top_k: 1,
namespace: Some(ns_a.to_string()),
kind: Some(SubstrateKind::Entity),
embedding_model: None,
filter: None,
backend_hints: None,
})
.await
.expect("search ns:a after batch");
assert_eq!(hits.len(), 1, "stale vector must be searchable");
assert_eq!(hits[0].subject_id, id_x);
let sim = hits[0].score.to_f64();
assert!(
sim > 0.999,
"cosine similarity of stale_vec to itself must be ~1.0 (got {sim:.6}); \
a lower value means the SAVEPOINT/ROLLBACK left partial writes visible"
);
}
#[tokio::test]
async fn insert_batch_rollback_does_not_corrupt_subsequent_record() {
let pool = make_vec_pool();
let model_key = "atomic_sib_batch";
let dims = 4;
let ns_a = "ns:sib_a";
let ns_b = "ns:sib_b";
create_vec_table(&pool, model_key, dims);
let store = SqliteVecStore::new(
Arc::clone(&pool),
false,
model_key.to_string(),
model_key.to_string(),
dims,
ns_a.to_string(),
)
.expect("SqliteVecStore::new");
let id_x = Uuid::new_v4();
let stale_vec = vec![0.1f32, 0.2, 0.3, 0.4];
let new_vec = vec![0.9f32, 0.1, 0.1, 0.1];
store
.insert(
id_x,
SubstrateKind::Entity,
ns_a,
"body",
vec![stale_vec.clone()],
)
.await
.expect("stale insert");
let summary = store
.insert_batch(vec![
VectorRecord {
subject_id: id_x,
kind: SubstrateKind::Entity,
namespace: ns_b.to_string(),
field: "body".to_string(),
embedding_model: None,
vectors: vec![vec![0.5f32, 0.6, 0.7, 0.8]],
updated_at: chrono::Utc::now(),
},
VectorRecord {
subject_id: id_x,
kind: SubstrateKind::Entity,
namespace: ns_a.to_string(),
field: "body".to_string(),
embedding_model: None,
vectors: vec![new_vec.clone()],
updated_at: chrono::Utc::now(),
},
])
.await
.expect("insert_batch");
assert_eq!(summary.attempted, 2);
assert_eq!(summary.affected, 1, "Record B must succeed");
assert_eq!(summary.failed, 1, "Record A must fail (PK conflict)");
let hits = store
.search(VectorSearchRequest {
query_vectors: vec![new_vec.clone()],
top_k: 1,
namespace: Some(ns_a.to_string()),
kind: Some(SubstrateKind::Entity),
embedding_model: None,
filter: None,
backend_hints: None,
})
.await
.expect("search after batch");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].subject_id, id_x);
let sim = hits[0].score.to_f64();
assert!(
sim > 0.999,
"new_vec similarity to itself must be ~1.0 (got {sim:.6}); \
Record A's ROLLBACK must not corrupt Record B's write"
);
}
#[tokio::test]
async fn update_pk_conflict_rolls_back_transaction_preserves_stale() {
let pool = make_vec_pool();
let model_key = "atomic_upd_pk";
let dims = 4;
let ns_a = "ns:upk_a";
let ns_b = "ns:upk_b";
create_vec_table(&pool, model_key, dims);
let store = SqliteVecStore::new(
Arc::clone(&pool),
false,
model_key.to_string(),
model_key.to_string(),
dims,
ns_a.to_string(),
)
.expect("store");
let id_x = Uuid::new_v4();
let stale_vec = vec![0.1f32, 0.2, 0.3, 0.4];
store
.insert(
id_x,
SubstrateKind::Entity,
ns_a,
"body",
vec![stale_vec.clone()],
)
.await
.expect("stale insert");
let result = store
.update(
id_x,
SubstrateKind::Entity,
ns_b,
"body",
vec![vec![0.5f32, 0.6, 0.7, 0.8]],
)
.await;
assert!(
result.is_err(),
"update must fail when INSERT hits the vec0 PK constraint"
);
let post = store
.batch_exists(&[id_x], ns_a)
.await
.expect("batch_exists after failed update");
assert!(
post.contains(&id_x),
"stale row in ns:a must survive after update transaction rollback"
);
let hits = store
.search(VectorSearchRequest {
query_vectors: vec![stale_vec.clone()],
top_k: 1,
namespace: Some(ns_a.to_string()),
kind: Some(SubstrateKind::Entity),
embedding_model: None,
filter: None,
backend_hints: None,
})
.await
.expect("search after failed update");
assert_eq!(hits.len(), 1, "stale vector must be searchable");
assert_eq!(hits[0].subject_id, id_x);
let sim = hits[0].score.to_f64();
assert!(
sim > 0.999,
"cosine similarity of stale_vec to itself must be ~1.0 (got {sim:.6}); \
transaction rollback must leave embedding bytes unchanged"
);
}
#[tokio::test]
async fn insert_batch_rollback_restores_deleted_stale_after_post_delete_insert_failure() {
let pool = make_vec_pool();
let model_key = "sentinel_batch_rb";
let dims = 4;
let ns = "ns:sentinel_batch";
create_vec_table(&pool, model_key, dims);
let store = SqliteVecStore::new(
Arc::clone(&pool),
false,
model_key.to_string(),
model_key.to_string(),
dims,
ns.to_string(),
)
.expect("SqliteVecStore::new");
let id_x = Uuid::new_v4();
let vec1 = vec![0.1f32, 0.2, 0.3, 0.4];
let vec2 = vec![0.9f32, 0.0, 0.0, 0.0];
store
.insert(id_x, SubstrateKind::Entity, ns, "body", vec![vec1.clone()])
.await
.expect("stale insert");
let _guard = failpoint::FailpointGuard::new();
let summary = store
.insert_batch(vec![VectorRecord {
subject_id: id_x,
kind: SubstrateKind::Entity,
namespace: ns.to_string(),
field: "body".to_string(),
embedding_model: None,
vectors: vec![vec2.clone()],
updated_at: chrono::Utc::now(),
}])
.await
.expect("insert_batch must complete (outer tx must commit regardless)");
drop(_guard);
assert_eq!(summary.attempted, 1);
assert_eq!(
summary.affected, 0,
"failpoint must prevent INSERT from succeeding"
);
assert_eq!(
summary.failed, 1,
"failed counter must increment after injected failure"
);
let present = store
.batch_exists(&[id_x], ns)
.await
.expect("batch_exists after failpoint");
assert!(
present.contains(&id_x),
"ROLLBACK TO SAVEPOINT must restore the stale row after DELETE + injected failure"
);
let hits = store
.search(VectorSearchRequest {
query_vectors: vec![vec1.clone()],
top_k: 1,
namespace: Some(ns.to_string()),
kind: Some(SubstrateKind::Entity),
embedding_model: None,
filter: None,
backend_hints: None,
})
.await
.expect("search after failpoint");
assert_eq!(
hits.len(),
1,
"stale vector must be searchable after rollback"
);
assert_eq!(hits[0].subject_id, id_x);
let sim = hits[0].score.to_f64();
assert!(
sim > 0.999,
"similarity to vec1 must be ~1.0 (got {sim:.6}); \
a lower value means the stale embedding was not restored — ROLLBACK TO SAVEPOINT failed"
);
let hits2 = store
.search(VectorSearchRequest {
query_vectors: vec![vec2.clone()],
top_k: 1,
namespace: Some(ns.to_string()),
kind: Some(SubstrateKind::Entity),
embedding_model: None,
filter: None,
backend_hints: None,
})
.await
.expect("search vec2 after failpoint");
let sim2 = hits2.first().map(|h| h.score.to_f64()).unwrap_or(0.0);
assert!(
sim2 < 0.99,
"similarity to vec2 must be < 0.99 (got {sim2:.6}); \
vec2 must not be the stored embedding after a rolled-back INSERT"
);
}
#[tokio::test]
async fn update_rollback_restores_deleted_stale_after_post_delete_insert_failure() {
let pool = make_vec_pool();
let model_key = "sentinel_upd_rb";
let dims = 4;
let ns = "ns:sentinel_upd";
create_vec_table(&pool, model_key, dims);
let store = SqliteVecStore::new(
Arc::clone(&pool),
false,
model_key.to_string(),
model_key.to_string(),
dims,
ns.to_string(),
)
.expect("SqliteVecStore::new");
let id_x = Uuid::new_v4();
let vec1 = vec![0.1f32, 0.2, 0.3, 0.4];
let vec2 = vec![0.9f32, 0.0, 0.0, 0.0];
store
.insert(id_x, SubstrateKind::Entity, ns, "body", vec![vec1.clone()])
.await
.expect("stale insert");
let _guard = failpoint::FailpointGuard::new();
let result = store
.update(id_x, SubstrateKind::Entity, ns, "body", vec![vec2.clone()])
.await;
drop(_guard);
assert!(
result.is_err(),
"update must propagate the injected error back to the caller"
);
let present = store
.batch_exists(&[id_x], ns)
.await
.expect("batch_exists after failpoint");
assert!(
present.contains(&id_x),
"transaction rollback must restore the stale row after DELETE + injected failure"
);
let hits = store
.search(VectorSearchRequest {
query_vectors: vec![vec1.clone()],
top_k: 1,
namespace: Some(ns.to_string()),
kind: Some(SubstrateKind::Entity),
embedding_model: None,
filter: None,
backend_hints: None,
})
.await
.expect("search after failpoint");
assert_eq!(
hits.len(),
1,
"stale vector must be searchable after rollback"
);
assert_eq!(hits[0].subject_id, id_x);
let sim = hits[0].score.to_f64();
assert!(
sim > 0.999,
"similarity to vec1 must be ~1.0 (got {sim:.6}); \
a lower value means the stale embedding was not restored — transaction rollback failed"
);
}
#[tokio::test]
async fn insert_rollback_restores_deleted_stale_after_post_delete_insert_failure() {
let pool = make_vec_pool();
let model_key = "sentinel_ins_rb";
let dims = 4;
let ns = "ns:sentinel_ins";
create_vec_table(&pool, model_key, dims);
let store = SqliteVecStore::new(
Arc::clone(&pool),
false,
model_key.to_string(),
model_key.to_string(),
dims,
ns.to_string(),
)
.expect("SqliteVecStore::new");
let id_x = Uuid::new_v4();
let vec1 = vec![0.1f32, 0.2, 0.3, 0.4];
let vec2 = vec![0.9f32, 0.0, 0.0, 0.0];
store
.insert(id_x, SubstrateKind::Entity, ns, "body", vec![vec1.clone()])
.await
.expect("stale insert");
let _guard = failpoint::FailpointGuard::new();
let result = store
.insert(id_x, SubstrateKind::Entity, ns, "body", vec![vec2.clone()])
.await;
drop(_guard);
assert!(
result.is_err(),
"insert must propagate the injected error back to the caller"
);
let present = store
.batch_exists(&[id_x], ns)
.await
.expect("batch_exists after failpoint");
assert!(
present.contains(&id_x),
"transaction rollback must restore the stale row after DELETE + injected failure"
);
let hits = store
.search(VectorSearchRequest {
query_vectors: vec![vec1.clone()],
top_k: 1,
namespace: Some(ns.to_string()),
kind: Some(SubstrateKind::Entity),
embedding_model: None,
filter: None,
backend_hints: None,
})
.await
.expect("search after failpoint");
assert_eq!(
hits.len(),
1,
"stale vector must be searchable after rollback"
);
assert_eq!(hits[0].subject_id, id_x);
let sim = hits[0].score.to_f64();
assert!(
sim > 0.999,
"similarity to vec1 must be ~1.0 (got {sim:.6}); \
a lower value means the stale embedding was not restored — transaction rollback failed"
);
}
}
#[cfg(all(test, feature = "vectors"))]
mod orphan_sweep_tests {
use std::sync::Arc;
use khive_storage::types::{OrphanSweepConfig, OrphanSweepResult};
use khive_storage::VectorStore;
use khive_types::SubstrateKind;
use uuid::Uuid;
use super::*;
fn make_pool() -> Arc<crate::pool::ConnectionPool> {
use crate::pool::{ConnectionPool, PoolConfig};
crate::extension::ensure_extensions_loaded();
Arc::new(
ConnectionPool::new(PoolConfig {
path: None,
..PoolConfig::default()
})
.expect("in-memory pool"),
)
}
fn create_substrate_tables(pool: &Arc<crate::pool::ConnectionPool>) {
pool.try_writer()
.expect("writer")
.conn()
.execute_batch(
"CREATE TABLE IF NOT EXISTS entities \
(id TEXT PRIMARY KEY, deleted_at INTEGER); \
CREATE TABLE IF NOT EXISTS notes \
(id TEXT PRIMARY KEY, deleted_at INTEGER);",
)
.expect("create substrate tables");
}
fn create_vec_table(pool: &Arc<crate::pool::ConnectionPool>, model_key: &str, dims: usize) {
let ddl = format!(
"CREATE VIRTUAL TABLE IF NOT EXISTS vec_{} USING vec0(\
subject_id TEXT PRIMARY KEY, \
namespace TEXT NOT NULL, \
kind TEXT NOT NULL, \
field TEXT NOT NULL, \
embedding_model TEXT NOT NULL, \
embedding float[{}] distance_metric=cosine)",
model_key, dims
);
pool.try_writer()
.expect("writer")
.conn()
.execute_batch(&ddl)
.expect("create vec table");
}
fn make_store(
pool: Arc<crate::pool::ConnectionPool>,
model_key: &str,
dims: usize,
ns: &str,
) -> SqliteVecStore {
SqliteVecStore::new(
pool,
false,
model_key.to_string(),
model_key.to_string(),
dims,
ns.to_string(),
)
.expect("SqliteVecStore::new")
}
fn insert_entity(pool: &Arc<crate::pool::ConnectionPool>, id: Uuid, deleted_at: Option<i64>) {
let id_str = id.to_string();
pool.try_writer()
.expect("writer")
.conn()
.execute(
"INSERT INTO entities (id, deleted_at) VALUES (?1, ?2)",
rusqlite::params![id_str, deleted_at],
)
.expect("insert entity");
}
fn vec4(a: f32, b: f32, c: f32, d: f32) -> Vec<f32> {
vec![a, b, c, d]
}
fn sweep_all(max_delete: u32, dry_run: bool) -> OrphanSweepConfig {
OrphanSweepConfig {
subject_id_allowlist: None,
namespaces: vec![],
substrate_kinds: vec![],
max_delete,
dry_run,
}
}
#[tokio::test]
async fn orphan_sweep_keeps_live_subject() {
let pool = make_pool();
create_substrate_tables(&pool);
create_vec_table(&pool, "sw_live", 4);
let store = make_store(Arc::clone(&pool), "sw_live", 4, "ns:sw");
let ns = "ns:sw";
let id = Uuid::new_v4();
insert_entity(&pool, id, None);
store
.insert(
id,
SubstrateKind::Entity,
ns,
"body",
vec![vec4(0.1, 0.2, 0.3, 0.4)],
)
.await
.expect("insert vec");
let r: OrphanSweepResult = store
.orphan_sweep(&sweep_all(100, false))
.await
.expect("sweep");
assert_eq!(r.scanned, 1, "one vec row exists");
assert_eq!(r.would_delete, 0, "live subject is not an orphan");
assert_eq!(r.deleted, 0);
assert!(!r.max_delete_hit);
let present = store.batch_exists(&[id], ns).await.expect("exists");
assert!(present.contains(&id), "live subject's vec must survive");
}
#[tokio::test]
async fn orphan_sweep_sweeps_soft_deleted_subject() {
let pool = make_pool();
create_substrate_tables(&pool);
create_vec_table(&pool, "sw_soft", 4);
let store = make_store(Arc::clone(&pool), "sw_soft", 4, "ns:soft");
let ns = "ns:soft";
let id = Uuid::new_v4();
insert_entity(&pool, id, Some(1_000_000));
store
.insert(
id,
SubstrateKind::Entity,
ns,
"body",
vec![vec4(0.5, 0.5, 0.5, 0.5)],
)
.await
.expect("insert vec");
let r = store
.orphan_sweep(&sweep_all(100, false))
.await
.expect("sweep");
assert_eq!(r.scanned, 1);
assert_eq!(r.would_delete, 1, "soft-deleted subject counts as orphan");
assert_eq!(r.deleted, 1);
assert!(!r.max_delete_hit);
let present = store.batch_exists(&[id], ns).await.expect("exists");
assert!(
!present.contains(&id),
"soft-deleted subject's vec must be swept"
);
}
#[tokio::test]
async fn orphan_sweep_sweeps_absent_subject() {
let pool = make_pool();
create_substrate_tables(&pool);
create_vec_table(&pool, "sw_absent", 4);
let store = make_store(Arc::clone(&pool), "sw_absent", 4, "ns:absent");
let ns = "ns:absent";
let id = Uuid::new_v4();
store
.insert(
id,
SubstrateKind::Entity,
ns,
"body",
vec![vec4(0.1, 0.2, 0.3, 0.4)],
)
.await
.expect("insert vec");
let r = store
.orphan_sweep(&sweep_all(100, false))
.await
.expect("sweep");
assert_eq!(r.scanned, 1);
assert_eq!(r.would_delete, 1, "absent subject counts as orphan");
assert_eq!(r.deleted, 1);
let present = store.batch_exists(&[id], ns).await.expect("exists");
assert!(!present.contains(&id), "absent subject's vec must be swept");
}
#[tokio::test]
async fn orphan_sweep_dry_run_does_not_delete() {
let pool = make_pool();
create_substrate_tables(&pool);
create_vec_table(&pool, "sw_dry", 4);
let store = make_store(Arc::clone(&pool), "sw_dry", 4, "ns:dry");
let ns = "ns:dry";
let id = Uuid::new_v4(); store
.insert(
id,
SubstrateKind::Entity,
ns,
"body",
vec![vec4(0.1, 0.2, 0.3, 0.4)],
)
.await
.expect("insert vec");
let r = store
.orphan_sweep(&sweep_all(100, true))
.await
.expect("sweep");
assert_eq!(r.would_delete, 1, "dry-run must still count the orphan");
assert_eq!(r.deleted, 0, "dry-run must not delete anything");
let present = store.batch_exists(&[id], ns).await.expect("exists");
assert!(present.contains(&id), "dry-run must not remove the vec");
}
#[tokio::test]
async fn orphan_sweep_max_delete_caps_deletion() {
let pool = make_pool();
create_substrate_tables(&pool);
create_vec_table(&pool, "sw_cap", 4);
let store = make_store(Arc::clone(&pool), "sw_cap", 4, "ns:cap");
let ns = "ns:cap";
let ids: Vec<Uuid> = (0..5).map(|_| Uuid::new_v4()).collect();
for (i, &id) in ids.iter().enumerate() {
let v = i as f32 / 10.0;
store
.insert(
id,
SubstrateKind::Entity,
ns,
"body",
vec![vec![v, v + 0.1, v + 0.2, v + 0.3]],
)
.await
.expect("insert vec");
}
let r = store
.orphan_sweep(&OrphanSweepConfig {
subject_id_allowlist: None,
namespaces: vec![],
substrate_kinds: vec![],
max_delete: 2,
dry_run: false,
})
.await
.expect("sweep");
assert_eq!(r.scanned, 5);
assert_eq!(r.would_delete, 5);
assert_eq!(r.deleted, 2, "cap must stop at max_delete");
assert!(
r.max_delete_hit,
"max_delete_hit must be true when cap triggered"
);
let mut surviving = 0usize;
for &id in &ids {
if store
.batch_exists(&[id], ns)
.await
.expect("exists")
.contains(&id)
{
surviving += 1;
}
}
assert_eq!(surviving, 3, "3 orphans must survive after cap");
}
#[tokio::test]
async fn orphan_sweep_namespace_filter_scopes_sweep() {
let pool = make_pool();
create_substrate_tables(&pool);
create_vec_table(&pool, "sw_ns", 4);
let store = make_store(Arc::clone(&pool), "sw_ns", 4, "ns:a");
let id_a = Uuid::new_v4();
let id_b = Uuid::new_v4();
store
.insert(
id_a,
SubstrateKind::Entity,
"ns:a",
"body",
vec![vec4(0.1, 0.2, 0.3, 0.4)],
)
.await
.expect("insert ns:a");
store
.insert(
id_b,
SubstrateKind::Entity,
"ns:b",
"body",
vec![vec4(0.5, 0.6, 0.7, 0.8)],
)
.await
.expect("insert ns:b");
let r = store
.orphan_sweep(&OrphanSweepConfig {
subject_id_allowlist: None,
namespaces: vec!["ns:a".to_string()],
substrate_kinds: vec![],
max_delete: 100,
dry_run: false,
})
.await
.expect("sweep");
assert_eq!(r.scanned, 1, "only ns:a row visible to scoped sweep");
assert_eq!(r.deleted, 1);
let exists_a = store.batch_exists(&[id_a], "ns:a").await.expect("exists a");
let exists_b = store.batch_exists(&[id_b], "ns:b").await.expect("exists b");
assert!(!exists_a.contains(&id_a), "ns:a orphan must be swept");
assert!(exists_b.contains(&id_b), "ns:b vec must be untouched");
}
#[tokio::test]
async fn orphan_sweep_substrate_kinds_filter_scopes_sweep() {
let pool = make_pool();
create_substrate_tables(&pool);
create_vec_table(&pool, "sw_kind", 4);
let store = make_store(Arc::clone(&pool), "sw_kind", 4, "ns:kind");
let ns = "ns:kind";
let id_ent = Uuid::new_v4();
let id_note = Uuid::new_v4();
store
.insert(
id_ent,
SubstrateKind::Entity,
ns,
"body",
vec![vec4(0.1, 0.2, 0.3, 0.4)],
)
.await
.expect("insert entity vec");
store
.insert(
id_note,
SubstrateKind::Note,
ns,
"body",
vec![vec4(0.5, 0.6, 0.7, 0.8)],
)
.await
.expect("insert note vec");
let r = store
.orphan_sweep(&OrphanSweepConfig {
subject_id_allowlist: None,
namespaces: vec![],
substrate_kinds: vec![SubstrateKind::Entity],
max_delete: 100,
dry_run: false,
})
.await
.expect("sweep");
assert_eq!(r.scanned, 1, "kind filter restricts scanned count");
assert_eq!(r.deleted, 1, "only entity-kind orphan is swept");
let ent_exists = store.batch_exists(&[id_ent], ns).await.expect("ent exists");
let note_exists = store
.batch_exists(&[id_note], ns)
.await
.expect("note exists");
assert!(
!ent_exists.contains(&id_ent),
"entity-kind orphan must be swept"
);
assert!(
note_exists.contains(&id_note),
"note-kind vec must be untouched"
);
}
#[tokio::test]
async fn orphan_sweep_allowlist_restricts_eligible_rows() {
let pool = make_pool();
create_substrate_tables(&pool);
create_vec_table(&pool, "sw_allow", 4);
let store = make_store(Arc::clone(&pool), "sw_allow", 4, "ns:allow");
let ns = "ns:allow";
let id1 = Uuid::new_v4();
let id2 = Uuid::new_v4();
let id3 = Uuid::new_v4();
for (i, &id) in [id1, id2, id3].iter().enumerate() {
let v = i as f32 * 0.1 + 0.1;
store
.insert(
id,
SubstrateKind::Entity,
ns,
"body",
vec![vec![v, v, v, v]],
)
.await
.expect("insert vec");
}
let r = store
.orphan_sweep(&OrphanSweepConfig {
subject_id_allowlist: Some(vec![id1, id2]),
namespaces: vec![],
substrate_kinds: vec![],
max_delete: 100,
dry_run: false,
})
.await
.expect("sweep");
assert_eq!(r.scanned, 2, "allowlist restricts scanned to 2");
assert_eq!(r.would_delete, 2);
assert_eq!(r.deleted, 2, "both allowlisted orphans deleted");
let e1 = store.batch_exists(&[id1], ns).await.expect("e1");
let e2 = store.batch_exists(&[id2], ns).await.expect("e2");
let e3 = store.batch_exists(&[id3], ns).await.expect("e3");
assert!(!e1.contains(&id1), "id1 must be swept");
assert!(!e2.contains(&id2), "id2 must be swept");
assert!(e3.contains(&id3), "id3 not in allowlist must survive");
}
fn insert_note(pool: &Arc<crate::pool::ConnectionPool>, id: Uuid, deleted_at: Option<i64>) {
let id_str = id.to_string();
pool.try_writer()
.expect("writer")
.conn()
.execute(
"INSERT INTO notes (id, deleted_at) VALUES (?1, ?2)",
rusqlite::params![id_str, deleted_at],
)
.expect("insert note");
}
#[tokio::test]
async fn orphan_sweep_keeps_live_note() {
let pool = make_pool();
create_substrate_tables(&pool);
create_vec_table(&pool, "sw_note_live", 4);
let store = make_store(Arc::clone(&pool), "sw_note_live", 4, "ns:nlive");
let ns = "ns:nlive";
let id = Uuid::new_v4();
insert_note(&pool, id, None);
store
.insert(
id,
SubstrateKind::Note,
ns,
"body",
vec![vec4(0.1, 0.2, 0.3, 0.4)],
)
.await
.expect("insert vec");
let r = store
.orphan_sweep(&sweep_all(100, false))
.await
.expect("sweep");
assert_eq!(r.scanned, 1);
assert_eq!(r.would_delete, 0, "live note is not an orphan");
assert_eq!(r.deleted, 0);
let present = store.batch_exists(&[id], ns).await.expect("exists");
assert!(present.contains(&id), "live note's vec must survive");
}
#[tokio::test]
async fn orphan_sweep_sweeps_soft_deleted_note() {
let pool = make_pool();
create_substrate_tables(&pool);
create_vec_table(&pool, "sw_note_soft", 4);
let store = make_store(Arc::clone(&pool), "sw_note_soft", 4, "ns:nsoft");
let ns = "ns:nsoft";
let id = Uuid::new_v4();
insert_note(&pool, id, Some(1_000_000));
store
.insert(
id,
SubstrateKind::Note,
ns,
"body",
vec![vec4(0.5, 0.5, 0.5, 0.5)],
)
.await
.expect("insert vec");
let r = store
.orphan_sweep(&sweep_all(100, false))
.await
.expect("sweep");
assert_eq!(r.scanned, 1);
assert_eq!(r.would_delete, 1, "soft-deleted note counts as orphan");
assert_eq!(r.deleted, 1);
let present = store.batch_exists(&[id], ns).await.expect("exists");
assert!(
!present.contains(&id),
"soft-deleted note's vec must be swept"
);
}
#[tokio::test]
async fn orphan_sweep_error_does_not_poison_connection() {
let pool = make_pool();
create_vec_table(&pool, "sw_poison", 4);
let store = make_store(Arc::clone(&pool), "sw_poison", 4, "ns:poison");
let ns = "ns:poison";
let sweep_result = store.orphan_sweep(&sweep_all(100, false)).await;
assert!(
sweep_result.is_err(),
"sweep must fail when substrate tables are absent"
);
let id = Uuid::new_v4();
store
.insert(
id,
SubstrateKind::Entity,
ns,
"body",
vec![vec4(0.1, 0.2, 0.3, 0.4)],
)
.await
.expect("insert after failed sweep must succeed (connection not poisoned)");
let present = store.batch_exists(&[id], ns).await.expect("exists");
assert!(
present.contains(&id),
"vector inserted after failed sweep must be present"
);
}
}
#[cfg(all(test, feature = "vectors"))]
mod write_queue_tests {
use std::sync::Arc;
use std::time::Duration;
use khive_storage::types::VectorRecord;
use khive_storage::VectorStore;
use khive_types::SubstrateKind;
use uuid::Uuid;
use super::*;
use crate::pool::{ConnectionPool, PoolConfig};
fn create_vec_table(pool: &Arc<ConnectionPool>, model_key: &str, dims: usize) {
let ddl = format!(
"CREATE VIRTUAL TABLE IF NOT EXISTS vec_{} USING vec0(\
subject_id TEXT PRIMARY KEY, \
namespace TEXT NOT NULL, \
kind TEXT NOT NULL, \
field TEXT NOT NULL, \
embedding_model TEXT NOT NULL, \
embedding float[{}] distance_metric=cosine)",
model_key, dims
);
pool.writer()
.expect("writer")
.conn()
.execute_batch(&ddl)
.expect("create vec table");
}
#[tokio::test]
async fn insert_batch_routes_through_writer_task_when_flag_enabled() {
crate::extension::ensure_extensions_loaded();
let model_key = "write_queue_flag_test";
let dims = 4usize;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("write_queue_vectors.db");
let pool = Arc::new(
ConnectionPool::new(PoolConfig {
path: Some(path),
write_queue_enabled: true,
..PoolConfig::default()
})
.expect("file-backed pool"),
);
create_vec_table(&pool, model_key, dims);
let store = SqliteVecStore::new(
Arc::clone(&pool),
true,
model_key.to_string(),
model_key.to_string(),
dims,
"ns:test".to_string(),
)
.expect("SqliteVecStore::new");
let id1 = Uuid::new_v4();
let id2 = Uuid::new_v4();
let records = vec![
VectorRecord {
subject_id: id1,
kind: SubstrateKind::Entity,
namespace: "ns:test".to_string(),
field: "body".to_string(),
embedding_model: None,
vectors: vec![vec![0.1, 0.2, 0.3, 0.4]],
updated_at: chrono::Utc::now(),
},
VectorRecord {
subject_id: id2,
kind: SubstrateKind::Entity,
namespace: "ns:test".to_string(),
field: "body".to_string(),
embedding_model: None,
vectors: vec![vec![0.5, 0.6, 0.7, 0.8]],
updated_at: chrono::Utc::now(),
},
];
let summary = store.insert_batch(records).await.unwrap();
assert_eq!(summary.attempted, 2);
assert_eq!(summary.affected, 2);
assert_eq!(summary.failed, 0);
let present = store
.batch_exists(&[id1, id2], "ns:test")
.await
.expect("batch_exists");
assert!(present.contains(&id1));
assert!(present.contains(&id2));
assert_eq!(
pool.writer_task_spawn_count(),
1,
"the flag-ON path must actually spawn and use the writer task"
);
}
fn create_substrate_tables(pool: &Arc<ConnectionPool>) {
pool.try_writer()
.expect("writer")
.conn()
.execute_batch(
"CREATE TABLE IF NOT EXISTS entities \
(id TEXT PRIMARY KEY, deleted_at INTEGER); \
CREATE TABLE IF NOT EXISTS notes \
(id TEXT PRIMARY KEY, deleted_at INTEGER);",
)
.expect("create substrate tables");
}
fn insert_entity(pool: &Arc<ConnectionPool>, id: Uuid, deleted_at: Option<i64>) {
let id_str = id.to_string();
pool.try_writer()
.expect("writer")
.conn()
.execute(
"INSERT INTO entities (id, deleted_at) VALUES (?1, ?2)",
rusqlite::params![id_str, deleted_at],
)
.expect("insert entity");
}
#[tokio::test]
async fn orphan_sweep_routes_through_writer_task_when_flag_enabled() {
crate::extension::ensure_extensions_loaded();
let model_key = "write_queue_orphan_sweep";
let dims = 4usize;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("write_queue_orphan_sweep.db");
let pool = Arc::new(
ConnectionPool::new(PoolConfig {
path: Some(path),
write_queue_enabled: true,
..PoolConfig::default()
})
.expect("file-backed pool"),
);
create_substrate_tables(&pool);
create_vec_table(&pool, model_key, dims);
let store = SqliteVecStore::new(
Arc::clone(&pool),
true,
model_key.to_string(),
model_key.to_string(),
dims,
"ns:test".to_string(),
)
.expect("SqliteVecStore::new");
let live_id = Uuid::new_v4();
insert_entity(&pool, live_id, None); let orphan_id = Uuid::new_v4();
store
.insert(
live_id,
SubstrateKind::Entity,
"ns:test",
"body",
vec![vec![0.1, 0.2, 0.3, 0.4]],
)
.await
.expect("insert live vector");
store
.insert(
orphan_id,
SubstrateKind::Entity,
"ns:test",
"body",
vec![vec![0.5, 0.6, 0.7, 0.8]],
)
.await
.expect("insert orphan vector");
let dry = store
.orphan_sweep(&OrphanSweepConfig {
subject_id_allowlist: None,
namespaces: vec![],
substrate_kinds: vec![],
max_delete: 100,
dry_run: true,
})
.await
.expect("dry-run sweep");
assert_eq!(dry.scanned, 2);
assert_eq!(dry.would_delete, 1);
assert_eq!(dry.deleted, 0);
assert!(!dry.max_delete_hit);
let real = store
.orphan_sweep(&OrphanSweepConfig {
subject_id_allowlist: None,
namespaces: vec![],
substrate_kinds: vec![],
max_delete: 100,
dry_run: false,
})
.await
.expect("real sweep");
assert_eq!(real.scanned, 2);
assert_eq!(real.would_delete, 1);
assert_eq!(real.deleted, 1);
assert!(!real.max_delete_hit);
let present = store
.batch_exists(&[live_id, orphan_id], "ns:test")
.await
.expect("batch_exists");
assert!(
present.contains(&live_id),
"live vector must survive the sweep"
);
assert!(
!present.contains(&orphan_id),
"orphaned vector must be swept"
);
let writer_task = pool
.writer_task_handle()
.expect("writer task handle")
.expect("writer task must be spawned for a file-backed pool with the flag on");
let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
let occupier = {
let writer_task = writer_task.clone();
tokio::spawn(async move {
writer_task
.send(move |_conn| {
let _ = started_tx.send(());
let _ = release_rx.blocking_recv();
Ok::<(), StorageError>(())
})
.await
})
};
started_rx
.await
.expect("occupier must signal it has started running inside the writer task");
assert_eq!(
writer_task.queue_depth(),
0,
"channel must start empty once the occupier has been dequeued and is running"
);
let sweep_task = tokio::spawn(async move {
store
.orphan_sweep(&OrphanSweepConfig {
subject_id_allowlist: None,
namespaces: vec![],
substrate_kinds: vec![],
max_delete: 100,
dry_run: true,
})
.await
});
let mut saw_enqueued = false;
for _ in 0..100 {
if writer_task.queue_depth() >= 1 {
saw_enqueued = true;
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert!(
saw_enqueued,
"orphan_sweep's write request never appeared in the writer task's channel \
while the occupier held the single drain slot — orphan_sweep is not routing \
through the shared writer task"
);
release_tx
.send(())
.expect("occupier must still be waiting on the release signal");
occupier
.await
.expect("occupier task must not panic")
.expect("occupier write must succeed");
let post_sweep = sweep_task
.await
.expect("sweep task must not panic")
.expect("orphan_sweep must succeed once unblocked");
assert_eq!(
post_sweep.scanned, 1,
"only the surviving live vector remains after the earlier real sweep"
);
}
#[tokio::test]
async fn orphan_sweep_old_unmanaged_shape_nests_transaction_under_write_queue() {
crate::extension::ensure_extensions_loaded();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("write_queue_orphan_sweep_regression.db");
let pool = Arc::new(
ConnectionPool::new(PoolConfig {
path: Some(path),
write_queue_enabled: true,
..PoolConfig::default()
})
.expect("file-backed pool"),
);
create_substrate_tables(&pool);
create_vec_table(&pool, "write_queue_orphan_sweep_regression", 4);
let writer_task = pool
.writer_task_handle()
.expect("writer task handle")
.expect("writer task must spawn for a file-backed pool with the flag on");
let result: Result<(), StorageError> = writer_task
.send(move |conn| {
let tx = rusqlite::Transaction::new_unchecked(
conn,
rusqlite::TransactionBehavior::Immediate,
)
.map_err(|e| map_err(e, "orphan_sweep_old_shape"))?;
tx.commit()
.map_err(|e| map_err(e, "orphan_sweep_old_shape"))?;
Ok(())
})
.await;
let err = result.expect_err(
"routing the OLD orphan_sweep closure (its own BEGIN IMMEDIATE) through the \
WriterTask must fail under KHIVE_WRITE_QUEUE — if this now succeeds, re-audit \
whether the WriterTask still owns the sole BEGIN IMMEDIATE for this connection",
);
let msg = err.to_string();
assert!(
msg.contains("cannot start a transaction within a transaction"),
"expected the deterministic nested-transaction failure (SQLite's own message \
for a second BEGIN issued inside an already-open transaction), got: {msg}"
);
}
}