use super::{purge_incident_edges_statement, GRAPH_DDL};
use crate::sql_bridge::bind_params;
use crate::stores::event::hard_delete_lineage_warning_statements;
use khive_storage::types::{SqlStatement, SqlValue};
use khive_types::SubstrateKind;
use rusqlite::{Connection, StatementStatus};
use serde_json::{json, Value};
use std::collections::BTreeSet;
use uuid::Uuid;
const EVENTS_DDL: &str = include_str!("../../sql/events-ddl.sql");
const SCAN_PURGE: &str = "DELETE FROM graph_edges WHERE source_id = ?1 OR target_id = ?1";
const SCAN_INCIDENT: &str = "SELECT id, namespace, source_id, target_id, deleted_at \
FROM graph_edges WHERE (source_id = ?1 OR target_id = ?1) AND relation = ?2 \
ORDER BY namespace, id";
const NAMESPACES: [&str; 3] = ["ns-a", "ns-b", "ns-c"];
const RELATIONS: [&str; 6] = [
"derived_from",
"supersedes",
"precedes",
"supports",
"refutes",
"enables",
];
fn deleted_node() -> Uuid {
Uuid::from_u128(0x0dd0_0000_0000_4000_8000_0000_0000_0001)
}
fn insert_edge(
conn: &Connection,
namespace: &str,
id: &str,
source: &str,
target: &str,
relation: &str,
deleted_at: Option<i64>,
) {
conn.execute(
"INSERT INTO graph_edges \
(namespace, id, source_id, target_id, relation, weight, created_at, updated_at, deleted_at) \
VALUES (?1, ?2, ?3, ?4, ?5, 1.0, 1, 1, ?6)",
rusqlite::params![namespace, id, source, target, relation, deleted_at],
)
.unwrap();
}
fn fixture(unrelated_per_namespace: usize, analyze: bool) -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(GRAPH_DDL).unwrap();
conn.execute_batch(EVENTS_DDL).unwrap();
let x = deleted_node().to_string();
for (namespace, id, source, target, relation, deleted_at) in [
("ns-a", "in-1", x.as_str(), "a-1", "derived_from", None),
("ns-a", "in-2", "a-2", x.as_str(), "supports", None),
("ns-a", "in-3", x.as_str(), x.as_str(), "precedes", None),
("ns-a", "in-4", "a-3", x.as_str(), "derived_from", None),
("ns-b", "in-5", x.as_str(), "b-1", "refutes", Some(7)),
("ns-b", "in-6", "b-2", x.as_str(), "supersedes", None),
("ns-b", "in-7", x.as_str(), "b-3", "enables", None),
] {
insert_edge(&conn, namespace, id, source, target, relation, deleted_at);
}
for namespace in NAMESPACES {
for i in 0..unrelated_per_namespace {
insert_edge(
&conn,
namespace,
&format!("u-{namespace}-{i}"),
&format!("n-{i}"),
&format!("n-{}", i + 1),
RELATIONS[i % RELATIONS.len()],
(i % 50 == 0).then_some(9),
);
}
}
if analyze {
conn.execute_batch("ANALYZE").unwrap();
}
conn
}
fn execute(conn: &Connection, sql: &str, params: &[SqlValue]) -> (usize, i32) {
let mut stmt = conn.prepare(sql).unwrap();
bind_params(&mut stmt, params).unwrap();
let changed = stmt.raw_execute().unwrap();
(changed, stmt.get_status(StatementStatus::VmStep))
}
fn run_all(conn: &Connection, statements: &[SqlStatement]) -> i32 {
statements
.iter()
.map(|statement| execute(conn, &statement.sql, &statement.params).1)
.sum()
}
fn remaining_edges(conn: &Connection) -> BTreeSet<(String, String)> {
let mut stmt = conn
.prepare("SELECT namespace, id FROM graph_edges")
.unwrap();
stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.unwrap()
.collect::<Result<_, _>>()
.unwrap()
}
fn scan_incident(conn: &Connection, node: &str, relation: &str) -> Vec<Value> {
let mut stmt = conn.prepare(SCAN_INCIDENT).unwrap();
stmt.query_map(rusqlite::params![node, relation], |row| {
Ok(json!({
"id": row.get::<_, String>(0)?,
"namespace": row.get::<_, String>(1)?,
"source_id": row.get::<_, String>(2)?,
"target_id": row.get::<_, String>(3)?,
"deleted_at": row.get::<_, Option<i64>>(4)?,
}))
})
.unwrap()
.collect::<Result<_, _>>()
.unwrap()
}
fn warnings_written(conn: &Connection) -> Vec<(String, i64, Value)> {
let mut stmt = conn
.prepare("SELECT payload FROM events ORDER BY json_extract(payload, '$.relation')")
.unwrap();
stmt.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.map(|payload| {
let payload: Value = serde_json::from_str(&payload.unwrap()).unwrap();
(
payload["relation"].as_str().unwrap().to_string(),
payload["edge_count"].as_i64().unwrap(),
payload["edges"].clone(),
)
})
.collect()
}
#[test]
fn incident_statements_find_the_edges_the_endpoint_scan_finds() {
let x = deleted_node();
let node = x.to_string();
for analyze in [false, true] {
let walked = fixture(200, analyze);
let warnings =
hard_delete_lineage_warning_statements("ns-a", "agent:test", x, SubstrateKind::Entity);
run_all(&walked, &warnings);
let mut expected = Vec::new();
for statement in &warnings {
let SqlValue::Text(relation) = &statement.params[8] else {
panic!("relation parameter");
};
let rows = scan_incident(&walked, &node, relation);
if !rows.is_empty() {
expected.push((relation.clone(), rows.len() as i64, Value::Array(rows)));
}
}
expected.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(warnings_written(&walked), expected, "analyze={analyze}");
assert_eq!(expected.len(), 5, "fixture covers every warning relation");
let scanned = fixture(200, analyze);
let (scan_removed, _) = execute(&scanned, SCAN_PURGE, &[SqlValue::Text(node.clone())]);
let purge = purge_incident_edges_statement(x);
let (walk_removed, _) = execute(&walked, &purge.sql, &purge.params);
assert_eq!(walk_removed, scan_removed, "analyze={analyze}");
assert_eq!(walk_removed, 7);
assert_eq!(remaining_edges(&walked), remaining_edges(&scanned));
}
let walked = fixture(50, false);
let other = Uuid::from_u128(42);
let warnings =
hard_delete_lineage_warning_statements("ns-a", "agent:test", other, SubstrateKind::Note);
run_all(&walked, &warnings);
assert!(warnings_written(&walked).is_empty());
let purge = purge_incident_edges_statement(other);
assert_eq!(execute(&walked, &purge.sql, &purge.params).0, 0);
let empty = fixture(0, false);
empty.execute_batch("DELETE FROM graph_edges").unwrap();
let purge = purge_incident_edges_statement(x);
assert_eq!(execute(&empty, &purge.sql, &purge.params).0, 0);
}
#[test]
fn incident_statement_work_does_not_follow_the_edge_table() {
let x = deleted_node();
for analyze in [false, true] {
let mut walk_steps = Vec::new();
let mut scan_steps = Vec::new();
for unrelated in [100, 10_000] {
let conn = fixture(unrelated, analyze);
let mut steps = run_all(
&conn,
&hard_delete_lineage_warning_statements(
"ns-a",
"agent:test",
x,
SubstrateKind::Entity,
),
);
let purge = purge_incident_edges_statement(x);
steps += execute(&conn, &purge.sql, &purge.params).1;
walk_steps.push(steps);
let control = fixture(unrelated, analyze);
let node = SqlValue::Text(x.to_string());
let mut steps = 0;
for relation in &RELATIONS[..5] {
let mut stmt = control.prepare(SCAN_INCIDENT).unwrap();
bind_params(
&mut stmt,
&[node.clone(), SqlValue::Text((*relation).to_string())],
)
.unwrap();
let mut rows = stmt.raw_query();
while rows.next().unwrap().is_some() {}
drop(rows);
steps += stmt.get_status(StatementStatus::VmStep);
}
steps += execute(&control, SCAN_PURGE, &[node]).1;
scan_steps.push(steps);
}
println!("analyze={analyze} walk steps {walk_steps:?} scan steps {scan_steps:?}");
if !analyze {
assert!(
scan_steps[1] > scan_steps[0] * 10,
"analyze={analyze}: {scan_steps:?}"
);
}
assert!(
walk_steps[1] <= walk_steps[0] + 512,
"analyze={analyze}: {walk_steps:?}"
);
}
}