use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use khive_runtime::{NamespaceToken, RuntimeError};
use khive_storage::types::{DeleteMode, SqlStatement, SqlValue};
use crate::ann;
use crate::MemoryPack;
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(super) struct PruneParams {
pub min_salience: Option<f64>,
pub before: Option<i64>,
pub namespace: Option<String>,
#[serde(default)]
pub dry_run: bool,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(super) struct VacuumParams {
}
impl MemoryPack {
pub(crate) async fn handle_prune(
&self,
token: &NamespaceToken,
params: Value,
) -> Result<Value, RuntimeError> {
let p: PruneParams = serde_json::from_value(params).map_err(|e| {
RuntimeError::InvalidInput(format!("memory.prune: invalid params: {e}"))
})?;
let namespace = p.namespace.as_deref().unwrap_or("local").to_string();
let now_micros = chrono::Utc::now().timestamp_micros();
let sql = self.runtime.sql();
let mut reader = sql.reader().await?;
let rows = reader
.query_all(SqlStatement {
sql: "SELECT id, salience, expires_at \
FROM notes \
WHERE kind = 'memory' \
AND namespace = ? \
AND deleted_at IS NULL"
.to_string(),
params: vec![SqlValue::Text(namespace.clone())],
label: Some("memory.prune.candidates".to_string()),
})
.await?;
let mut to_delete: Vec<uuid::Uuid> = Vec::new();
for row in rows {
let id_str = match row.get("id") {
Some(SqlValue::Text(s)) => s.clone(),
_ => continue,
};
let id: uuid::Uuid = match id_str.parse() {
Ok(u) => u,
Err(_) => continue,
};
if let Some(min_sal) = p.min_salience {
let sal = match row.get("salience") {
Some(SqlValue::Float(f)) => *f,
Some(SqlValue::Integer(i)) => *i as f64,
_ => 0.0, };
if sal < min_sal {
to_delete.push(id);
continue;
}
}
let cutoff = match p.before {
Some(0) => None, Some(ts) => Some(ts),
None => Some(now_micros), };
if let Some(cutoff_ts) = cutoff {
let exp = match row.get("expires_at") {
Some(SqlValue::Integer(i)) => Some(*i),
_ => None,
};
if let Some(e) = exp {
if e <= cutoff_ts {
to_delete.push(id);
}
}
}
}
let count = to_delete.len();
if p.dry_run {
return Ok(json!({
"pruned": 0,
"dry_run": true,
"would_prune": count,
"namespace": namespace,
}));
}
let note_store = self.runtime.notes(token)?;
let mut pruned = 0usize;
for id in to_delete {
if note_store.delete_note(id, DeleteMode::Soft).await? {
pruned += 1;
}
}
if pruned > 0 {
for model in self.runtime.registered_embedding_model_names() {
let key = ann::AnnKey::new(namespace.as_str(), model.as_str());
ann::bump_generation(&self.ann, &key).await;
ann::ensure_ann_background(&self.runtime, token, &self.ann, &model).await;
}
}
Ok(json!({
"pruned": pruned,
"dry_run": false,
"namespace": namespace,
}))
}
pub(crate) async fn handle_vacuum(&self, params: Value) -> Result<Value, RuntimeError> {
let _: VacuumParams = serde_json::from_value(params).map_err(|e| {
RuntimeError::InvalidInput(format!("memory.vacuum: invalid params: {e}"))
})?;
let sql = self.runtime.sql();
let mut writer = sql.writer().await?;
writer
.execute_script_top_level("VACUUM;".to_string())
.await?;
Ok(json!({ "ok": true }))
}
}
#[cfg(test)]
mod vacuum_write_queue_tests {
#[tokio::test]
async fn vacuum_top_level_succeeds_with_write_queue_enabled() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("memory-vacuum-write-queue.db");
let pool_cfg = khive_db::PoolConfig {
path: Some(db_path),
write_queue_enabled: true,
..khive_db::PoolConfig::default()
};
let pool = std::sync::Arc::new(khive_db::ConnectionPool::new(pool_cfg).expect("pool"));
{
let mut writer = pool.writer().expect("writer");
khive_db::run_migrations(writer.conn_mut()).expect("migrations");
}
assert!(
pool.writer_task_handle().unwrap().is_some(),
"writer task must be spawned with the flag on for a file-backed pool"
);
let sql: std::sync::Arc<dyn khive_storage::SqlAccess> =
std::sync::Arc::new(khive_db::SqlBridge::new(std::sync::Arc::clone(&pool), true));
let mut writer = sql.writer().await.expect("writer handle");
let result = writer.execute_script_top_level("VACUUM;".to_string()).await;
assert!(
result.is_ok(),
"VACUUM via execute_script_top_level must succeed under \
KHIVE_WRITE_QUEUE (no BEGIN IMMEDIATE wrap); got {result:?}"
);
}
}