use super::*;
use crate::migrations::run_migrations;
use rusqlite::{Connection, StatementStatus};
const NAMESPACE: &str = "local";
type OpenEvents = fn(usize) -> Connection;
fn insert_events(conn: &Connection, rows: usize) {
let mut stmt = conn
.prepare(
"INSERT INTO events (id, namespace, verb, substrate, actor, kind, outcome, created_at) \
VALUES (?1, ?2, 'remember', 'note', 'actor:test', 'audit', 'success', ?3)",
)
.unwrap();
for i in 1..=rows {
stmt.execute(rusqlite::params![
Uuid::from_u128(i as u128).to_string(),
if i % 50 == 0 { "other" } else { NAMESPACE },
i as i64,
])
.unwrap();
}
}
fn events_database(rows: usize) -> Connection {
let conn = Connection::open_in_memory().unwrap();
ensure_events_schema(&conn).unwrap();
insert_events(&conn, rows);
conn
}
fn main_database(rows: usize) -> Connection {
let mut conn = Connection::open_in_memory().unwrap();
run_migrations(&mut conn).unwrap();
ensure_operation_attribution_columns(&conn).unwrap();
insert_events(&conn, rows);
conn
}
fn plan_rows_work(
conn: &Connection,
sql: &str,
params: &[Box<dyn rusqlite::ToSql>],
) -> (Vec<String>, Vec<String>, i32) {
let refs = params.iter().map(|p| p.as_ref()).collect::<Vec<_>>();
let plan = conn
.prepare(&format!("EXPLAIN QUERY PLAN {sql}"))
.unwrap()
.query_map(refs.as_slice(), |row| row.get(3))
.unwrap()
.collect::<Result<Vec<String>, _>>()
.unwrap();
let mut stmt = conn.prepare(sql).unwrap();
let ids = stmt
.query_map(refs.as_slice(), |row| row.get::<_, String>(0))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
(plan, ids, stmt.get_status(StatementStatus::VmStep))
}
#[test]
fn id_filtered_reads_seek_the_primary_key_without_statistics() {
let schemas: [(&str, OpenEvents); 2] = [("events", events_database), ("main", main_database)];
for (schema, open) in schemas {
for analyzed in [false, true] {
let mut work = Vec::new();
for size in [1_000, 10_000] {
let conn = open(size);
if analyzed {
conn.execute_batch("ANALYZE").unwrap();
}
let present = Uuid::from_u128(7);
let mut ids: Vec<Uuid> = (0..7).map(|i| Uuid::from_u128(u128::MAX - i)).collect();
ids.push(present);
let filter = EventFilter {
ids: ids.clone(),
..EventFilter::default()
};
let (clause, mut params) =
build_event_filter_sql(&conn, NAMESPACE, &filter).unwrap();
params.push(Box::new(i64::try_from(ids.len()).unwrap()));
params.push(Box::new(0_i64));
let sql = query_events_sql(&clause, params.len() - 1, params.len());
let (plan, found, steps) = plan_rows_work(&conn, &sql, ¶ms);
eprintln!(
"event id probe schema={schema} size={size} analyzed={analyzed} vm_steps={steps} plan={plan:?}"
);
assert!(
plan.iter()
.any(|line| line.contains("sqlite_autoindex_events_1")),
"an ID-filtered read must seek the primary key ({schema}, analyzed={analyzed}): {plan:?}"
);
assert!(
!plan.iter().any(|line| line.contains("idx_events_ns_created")),
"an ID-filtered read must not walk the namespace in created_at order ({schema}, analyzed={analyzed}): {plan:?}"
);
assert_eq!(found, vec![present.to_string()]);
work.push(steps);
}
assert!(
work[1] < work[0] * 2,
"ID-filtered read work must not track namespace size ({schema}, analyzed={analyzed}): {work:?}"
);
}
}
}
#[test]
#[ignore = "manual scale measurement, not a CI assertion; see doc comment"]
fn event_plan_scale_measurement() {
const ROUNDS: usize = 15;
const PROVENANCE_WHERE: &str = "WHERE namespace = ?1 AND verb = ?2 AND target_id = ?3 \
AND outcome = 'success' AND json_extract(payload, '$.provenance') = ?4 \
AND json_extract(payload, '$.event_type') = ?5";
let rows: usize = std::env::var("KHIVE_EVENT_PLAN_ROWS")
.ok()
.map(|v| v.parse().expect("KHIVE_EVENT_PLAN_ROWS is a row count"))
.unwrap_or(500_000);
let path =
std::env::temp_dir().join(format!("khive-event-plan-scale-{}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let mut conn = Connection::open(&path).expect("open scratch db under the temp dir");
run_migrations(&mut conn).expect("migrate scratch db");
ensure_operation_attribution_columns(&conn).expect("attribution columns");
let target = Uuid::from_u128(0xfeed);
let seed_start = std::time::Instant::now();
conn.execute_batch("BEGIN").expect("begin seed");
{
let mut insert = conn
.prepare(
"INSERT INTO events (id, namespace, verb, substrate, actor, kind, outcome, payload, \
target_id, created_at) VALUES (?1, ?2, ?3, 'note', 'actor:scale', 'audit', 'success', ?4, ?5, ?6)",
)
.expect("prepare seed insert");
for i in 1..=rows {
let provenance = i == rows / 2;
let verb = if provenance {
"schedule.creator_provenance".to_string()
} else {
format!("verb.{}", i % 40)
};
let payload = if provenance {
serde_json::json!({"provenance": "schedule_creator_v1", "event_type": "remind"})
.to_string()
} else {
format!(
"{{\"request\":\"{}\",\"detail\":\"{}\"}}",
i,
"x".repeat(200)
)
};
let target_id = if provenance {
target
} else {
Uuid::from_u128((1_u128 << 64) | i as u128)
};
insert
.execute(rusqlite::params![
Uuid::from_u128(i as u128).to_string(),
NAMESPACE,
verb,
payload,
target_id.to_string(),
i as i64,
])
.expect("insert seed row");
}
}
conn.execute_batch("COMMIT").expect("commit seed");
eprintln!("seeded {rows} rows in {:?}", seed_start.elapsed());
let filter = EventFilter {
ids: (0..8).map(|i| Uuid::from_u128(u128::MAX - i)).collect(),
..EventFilter::default()
};
let (clause, mut probe_params) = build_event_filter_sql(&conn, NAMESPACE, &filter).unwrap();
probe_params.push(Box::new(8_i64));
probe_params.push(Box::new(0_i64));
let probe_new = query_events_sql(&clause, probe_params.len() - 1, probe_params.len());
assert!(probe_new.contains("+namespace"), "{probe_new}");
let probe_old = probe_new.replace("+namespace", "namespace");
let provenance_old = format!(
"SELECT actor FROM events {PROVENANCE_WHERE} ORDER BY created_at ASC, id ASC LIMIT 2"
);
let provenance_new = format!(
"SELECT actor FROM events {PROVENANCE_WHERE} ORDER BY +created_at ASC, +id ASC LIMIT 2"
);
let provenance_params: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(NAMESPACE),
Box::new("schedule.creator_provenance"),
Box::new(target.to_string()),
Box::new("schedule_creator_v1"),
Box::new("remind"),
];
let time = |sql: &str, params: &[Box<dyn rusqlite::ToSql>]| {
let started = std::time::Instant::now();
let (_, found, steps) = plan_rows_work(&conn, sql, params);
(started.elapsed().as_micros(), found.len(), steps)
};
for (name, old, new, params, expected) in [
(
"legacy id probe",
&probe_old,
&probe_new,
&probe_params,
0_usize,
),
(
"creator provenance",
&provenance_old,
&provenance_new,
&provenance_params,
1,
),
] {
let mut old_us = Vec::new();
let mut new_us = Vec::new();
let (mut old_steps, mut new_steps) = (0, 0);
for round in 0..ROUNDS + 2 {
let (us, found, steps) = time(old.as_str(), params.as_slice());
assert_eq!(found, expected, "{name} OLD rows");
let (nus, nfound, nsteps) = time(new.as_str(), params.as_slice());
assert_eq!(nfound, expected, "{name} NEW rows");
if round >= 2 {
old_us.push(us);
new_us.push(nus);
}
(old_steps, new_steps) = (steps, nsteps);
}
old_us.sort_unstable();
new_us.sort_unstable();
let plan = |sql: &str| plan_rows_work(&conn, sql, params).0;
eprintln!(
"{name}: rows={rows} OLD median={}us max={}us vm_steps={old_steps} plan={:?}",
old_us[ROUNDS / 2],
old_us[ROUNDS - 1],
plan(old.as_str())
);
eprintln!(
"{name}: rows={rows} NEW median={}us max={}us vm_steps={new_steps} plan={:?}",
new_us[ROUNDS / 2],
new_us[ROUNDS - 1],
plan(new.as_str())
);
}
drop(conn);
let _ = std::fs::remove_file(&path);
}