Skip to content

Commit 40d19a5

Browse files
Add engine integrity regression tests
1 parent 8a94ab4 commit 40d19a5

4 files changed

Lines changed: 564 additions & 6 deletions

File tree

crates/contextdb-engine/tests/sql_surface_tests.rs

Lines changed: 335 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use contextdb_core::Value;
1+
use contextdb_core::{MemoryAccountant, Value};
22
use contextdb_engine::Database;
33
use std::collections::HashMap;
44
use std::sync::atomic::{AtomicBool, Ordering};
@@ -3237,3 +3237,337 @@ fn sql_13_null_in_nullable_uuid() {
32373237
assert_eq!(out.rows.len(), 1);
32383238
assert_eq!(out.rows[0][0], Value::Null);
32393239
}
3240+
3241+
#[test]
3242+
fn integrity_01_text_memory_estimate_not_pathologically_high() {
3243+
let accountant = Arc::new(MemoryAccountant::with_budget(32 * 1024));
3244+
let db = Database::open_memory_with_accountant(accountant.clone());
3245+
db.execute(
3246+
"CREATE TABLE docs (id UUID PRIMARY KEY, body TEXT)",
3247+
&empty(),
3248+
)
3249+
.unwrap();
3250+
3251+
let body = "x".repeat(1024);
3252+
let result = db.execute(
3253+
"INSERT INTO docs (id, body) VALUES ($id, $body)",
3254+
&params(vec![
3255+
("id", Value::Uuid(Uuid::new_v4())),
3256+
("body", Value::Text(body)),
3257+
]),
3258+
);
3259+
assert!(
3260+
result.is_ok(),
3261+
"1KiB TEXT insert should fit within a 32KiB budget: {result:?}"
3262+
);
3263+
assert!(
3264+
accountant.usage().used < 16 * 1024,
3265+
"1KiB TEXT row should not consume pathological memory, got {} bytes",
3266+
accountant.usage().used
3267+
);
3268+
}
3269+
3270+
#[test]
3271+
fn integrity_02_upsert_noop_does_not_leak_memory() {
3272+
let accountant = Arc::new(MemoryAccountant::with_budget(12 * 1024));
3273+
let db = Database::open_memory_with_accountant(accountant.clone());
3274+
db.execute("CREATE TABLE kv (id UUID PRIMARY KEY, val TEXT)", &empty())
3275+
.unwrap();
3276+
3277+
let id = Uuid::new_v4();
3278+
db.execute(
3279+
"INSERT INTO kv (id, val) VALUES ($id, 'same')",
3280+
&params(vec![("id", Value::Uuid(id))]),
3281+
)
3282+
.unwrap();
3283+
let baseline = accountant.usage().used;
3284+
3285+
for _ in 0..20 {
3286+
db.execute(
3287+
"INSERT INTO kv (id, val) VALUES ($id, 'same') ON CONFLICT (id) DO UPDATE SET val = 'same'",
3288+
&params(vec![("id", Value::Uuid(id))]),
3289+
)
3290+
.unwrap();
3291+
}
3292+
3293+
let used = accountant.usage().used;
3294+
assert!(
3295+
used <= baseline + 256,
3296+
"noop upserts must not leak memory: baseline={baseline}, used={used}"
3297+
);
3298+
}
3299+
3300+
#[test]
3301+
fn integrity_03_retain_pruning_releases_memory() {
3302+
let accountant = Arc::new(MemoryAccountant::with_budget(256 * 1024));
3303+
let db = Database::open_memory_with_accountant(accountant.clone());
3304+
db.execute(
3305+
"CREATE TABLE obs (id UUID PRIMARY KEY, data TEXT) RETAIN 1 SECONDS",
3306+
&empty(),
3307+
)
3308+
.unwrap();
3309+
3310+
let baseline = accountant.usage().used;
3311+
db.execute(
3312+
"INSERT INTO obs (id, data) VALUES ($id, $data)",
3313+
&params(vec![
3314+
("id", Value::Uuid(Uuid::new_v4())),
3315+
("data", Value::Text("x".repeat(4096))),
3316+
]),
3317+
)
3318+
.unwrap();
3319+
let used_after_insert = accountant.usage().used;
3320+
assert!(used_after_insert > baseline);
3321+
3322+
std::thread::sleep(Duration::from_millis(1100));
3323+
let pruned = db.run_pruning_cycle();
3324+
assert_eq!(pruned, 1, "expired row must be pruned");
3325+
3326+
let used_after_prune = accountant.usage().used;
3327+
assert!(
3328+
used_after_prune + 512 < used_after_insert,
3329+
"pruning must release row memory: before={used_after_insert}, after={used_after_prune}"
3330+
);
3331+
assert_eq!(
3332+
db.execute("SELECT COUNT(*) FROM obs", &empty())
3333+
.unwrap()
3334+
.rows[0][0],
3335+
Value::Int64(0)
3336+
);
3337+
}
3338+
3339+
#[test]
3340+
fn integrity_04_edge_delete_releases_memory() {
3341+
let accountant = Arc::new(MemoryAccountant::with_budget(256 * 1024));
3342+
let db = Database::open_memory_with_accountant(accountant.clone());
3343+
3344+
let source = Uuid::new_v4();
3345+
let target = Uuid::new_v4();
3346+
let baseline = accountant.usage().used;
3347+
3348+
let tx = db.begin();
3349+
assert!(
3350+
db.insert_edge(tx, source, target, "REL".to_string(), HashMap::new())
3351+
.unwrap()
3352+
);
3353+
db.commit(tx).unwrap();
3354+
3355+
let used_after_insert = accountant.usage().used;
3356+
assert!(used_after_insert > baseline);
3357+
3358+
let tx = db.begin();
3359+
db.delete_edge(tx, source, target, "REL").unwrap();
3360+
db.commit(tx).unwrap();
3361+
3362+
let used_after_delete = accountant.usage().used;
3363+
assert!(
3364+
used_after_delete + 128 < used_after_insert,
3365+
"edge delete must release adjacency memory: before={used_after_insert}, after={used_after_delete}"
3366+
);
3367+
}
3368+
3369+
#[test]
3370+
fn integrity_05_drop_table_releases_edge_memory() {
3371+
let accountant = Arc::new(MemoryAccountant::with_budget(256 * 1024));
3372+
let db = Database::open_memory_with_accountant(accountant.clone());
3373+
db.execute(
3374+
"CREATE TABLE edges (id UUID PRIMARY KEY, source_id UUID, target_id UUID, edge_type TEXT)",
3375+
&empty(),
3376+
)
3377+
.unwrap();
3378+
3379+
let source = Uuid::new_v4();
3380+
let target = Uuid::new_v4();
3381+
db.execute(
3382+
"INSERT INTO edges (id, source_id, target_id, edge_type) VALUES ($id, $source, $target, 'REL')",
3383+
&params(vec![
3384+
("id", Value::Uuid(Uuid::new_v4())),
3385+
("source", Value::Uuid(source)),
3386+
("target", Value::Uuid(target)),
3387+
]),
3388+
)
3389+
.unwrap();
3390+
let used_after_insert = accountant.usage().used;
3391+
3392+
db.execute("DROP TABLE edges", &empty()).unwrap();
3393+
3394+
assert!(
3395+
accountant.usage().used + 128 < used_after_insert,
3396+
"DROP TABLE must release edge allocations: before={used_after_insert}, after={}",
3397+
accountant.usage().used
3398+
);
3399+
let bfs = db
3400+
.query_bfs(
3401+
source,
3402+
Some(&["REL".to_string()]),
3403+
contextdb_core::Direction::Outgoing,
3404+
1,
3405+
db.snapshot(),
3406+
)
3407+
.unwrap();
3408+
assert_eq!(
3409+
bfs.nodes.len(),
3410+
0,
3411+
"dropped edge table must not leave graph edges behind"
3412+
);
3413+
}
3414+
3415+
#[test]
3416+
fn integrity_06_create_table_honors_disk_budget() {
3417+
let tmp = TempDir::new().unwrap();
3418+
let path = tmp.path().join("integrity-create-table.db");
3419+
let db = Database::open(&path).unwrap();
3420+
3421+
let limit_kib = disk_limit_kib_for_path(&path, 0);
3422+
db.execute(&format!("SET DISK_LIMIT '{limit_kib}K'"), &empty())
3423+
.unwrap();
3424+
3425+
let result = db.execute("CREATE TABLE blocked (id UUID PRIMARY KEY)", &empty());
3426+
assert!(
3427+
result.is_err(),
3428+
"CREATE TABLE must fail when disk budget is already exhausted"
3429+
);
3430+
let err = result.unwrap_err().to_string();
3431+
assert!(
3432+
err.to_lowercase().contains("disk budget"),
3433+
"disk-budget rejection must mention disk budget, got: {err}"
3434+
);
3435+
assert!(
3436+
db.table_meta("blocked").is_none(),
3437+
"failed CREATE TABLE must not leave table metadata behind"
3438+
);
3439+
}
3440+
3441+
#[test]
3442+
fn integrity_07_alter_table_honors_disk_budget() {
3443+
let tmp = TempDir::new().unwrap();
3444+
let path = tmp.path().join("integrity-alter-table.db");
3445+
let db = Database::open(&path).unwrap();
3446+
db.execute("CREATE TABLE items (id UUID PRIMARY KEY)", &empty())
3447+
.unwrap();
3448+
3449+
let limit_kib = disk_limit_kib_for_path(&path, 0);
3450+
db.execute(&format!("SET DISK_LIMIT '{limit_kib}K'"), &empty())
3451+
.unwrap();
3452+
3453+
let result = db.execute("ALTER TABLE items ADD COLUMN note TEXT", &empty());
3454+
assert!(
3455+
result.is_err(),
3456+
"ALTER TABLE must fail when disk budget is already exhausted"
3457+
);
3458+
let err = result.unwrap_err().to_string();
3459+
assert!(
3460+
err.to_lowercase().contains("disk budget"),
3461+
"disk-budget rejection must mention disk budget, got: {err}"
3462+
);
3463+
let meta = db.table_meta("items").unwrap();
3464+
assert!(
3465+
meta.columns.iter().all(|c| c.name != "note"),
3466+
"failed ALTER TABLE must not mutate schema"
3467+
);
3468+
}
3469+
3470+
#[test]
3471+
fn integrity_08_upsert_insert_indexes_vector() {
3472+
let db = Database::open_memory();
3473+
db.execute(
3474+
"CREATE TABLE docs (id UUID PRIMARY KEY, embedding VECTOR(3))",
3475+
&empty(),
3476+
)
3477+
.unwrap();
3478+
3479+
let id = Uuid::new_v4();
3480+
db.execute(
3481+
"INSERT INTO docs (id, embedding) VALUES ($id, '[1.0, 0.0, 0.0]')",
3482+
&params(vec![("id", Value::Uuid(id))]),
3483+
)
3484+
.unwrap();
3485+
db.execute(
3486+
"INSERT INTO docs (id, embedding) VALUES ($id, '[0.0, 1.0, 0.0]') ON CONFLICT (id) DO UPDATE SET embedding = '[0.0, 1.0, 0.0]'",
3487+
&params(vec![("id", Value::Uuid(id))]),
3488+
)
3489+
.unwrap();
3490+
3491+
let result = db
3492+
.execute(
3493+
"SELECT id FROM docs ORDER BY embedding <=> $query LIMIT 1",
3494+
&params(vec![("query", Value::Vector(vec![0.0, 1.0, 0.0]))]),
3495+
)
3496+
.unwrap()
3497+
.rows;
3498+
assert_eq!(
3499+
result.len(),
3500+
1,
3501+
"vector search must still find the upserted row"
3502+
);
3503+
assert_eq!(
3504+
result[0][0],
3505+
Value::Uuid(id),
3506+
"vector search must resolve to the row updated by ON CONFLICT DO UPDATE"
3507+
);
3508+
}
3509+
3510+
#[test]
3511+
fn integrity_09_drop_table_removes_vectors() {
3512+
let db = Database::open_memory();
3513+
db.execute(
3514+
"CREATE TABLE docs (id UUID PRIMARY KEY, embedding VECTOR(3))",
3515+
&empty(),
3516+
)
3517+
.unwrap();
3518+
3519+
let id = Uuid::new_v4();
3520+
db.execute(
3521+
"INSERT INTO docs (id, embedding) VALUES ($id, '[0.1, 0.2, 0.3]')",
3522+
&params(vec![("id", Value::Uuid(id))]),
3523+
)
3524+
.unwrap();
3525+
let row_id = db
3526+
.point_lookup("docs", "id", &Value::Uuid(id), db.snapshot())
3527+
.unwrap()
3528+
.expect("row must exist")
3529+
.row_id;
3530+
assert!(
3531+
db.live_vector_entry(row_id, db.snapshot()).is_some(),
3532+
"vector must exist before DROP TABLE"
3533+
);
3534+
3535+
db.execute("DROP TABLE docs", &empty()).unwrap();
3536+
3537+
assert!(
3538+
db.live_vector_entry(row_id, db.snapshot()).is_none(),
3539+
"DROP TABLE must remove vector entries for dropped rows"
3540+
);
3541+
}
3542+
3543+
#[test]
3544+
fn integrity_10_failed_insert_does_not_leak_memory() {
3545+
let accountant = Arc::new(MemoryAccountant::with_budget(12 * 1024));
3546+
let db = Database::open_memory_with_accountant(accountant.clone());
3547+
db.execute(
3548+
"CREATE TABLE items (id UUID PRIMARY KEY, name TEXT UNIQUE)",
3549+
&empty(),
3550+
)
3551+
.unwrap();
3552+
3553+
db.execute(
3554+
"INSERT INTO items (id, name) VALUES ($id, 'dup')",
3555+
&params(vec![("id", Value::Uuid(Uuid::new_v4()))]),
3556+
)
3557+
.unwrap();
3558+
let baseline = accountant.usage().used;
3559+
3560+
for _ in 0..20 {
3561+
let result = db.execute(
3562+
"INSERT INTO items (id, name) VALUES ($id, 'dup')",
3563+
&params(vec![("id", Value::Uuid(Uuid::new_v4()))]),
3564+
);
3565+
assert!(result.is_err(), "duplicate UNIQUE insert must fail");
3566+
}
3567+
3568+
let used = accountant.usage().used;
3569+
assert!(
3570+
used <= baseline + 256,
3571+
"failed inserts must not leak memory: baseline={baseline}, used={used}"
3572+
);
3573+
}

tests/acceptance/query_surface.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -692,7 +692,14 @@ fn f96_upsert_user_preference_preserves_graph_and_vector_consistency() {
692692
let result = db
693693
.query_vector(&[0.0, 1.0, 0.0], 1, None, db.snapshot())
694694
.expect("updated vector");
695-
assert_eq!(result[0].0, 1);
695+
assert_eq!(result.len(), 1, "updated embedding must remain searchable");
696+
let row = db
697+
.execute(
698+
"SELECT value FROM prefs WHERE id = $id",
699+
&params(vec![("id", Value::Uuid(pref))]),
700+
)
701+
.expect("read updated pref");
702+
assert_eq!(row.rows[0][0], Value::Text("light".into()));
696703
assert_eq!(
697704
db.edge_count(user, "HAS_PREF", db.snapshot())
698705
.expect("edge count"),

tests/acceptance/sync.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -681,7 +681,8 @@ async fn f12b_auto_sync_pushes_updates_not_just_inserts() {
681681

682682
// Wait for UPDATE to appear on server while CLI is still running
683683
let mut checker_idx = 0u32;
684-
let found = wait_until(Duration::from_secs(10), || {
684+
let mut last_stdout = String::new();
685+
let found = wait_until(Duration::from_secs(20), || {
685686
checker_idx += 1;
686687
let fresh_path = edge_path.with_file_name(format!("f12b-checker-{checker_idx}.db"));
687688
let check = run_cli_script(
@@ -693,16 +694,19 @@ async fn f12b_auto_sync_pushes_updates_not_just_inserts() {
693694
.quit\n",
694695
);
695696
let stdout = output_string(&check.stdout);
697+
last_stdout = stdout.clone();
696698
stdout.contains("updated")
697699
});
698700

699701
write_child_stdin(&mut child, ".quit\n");
700-
let _ = child.wait();
702+
let output = child.wait_with_output().expect("collect f12b child output");
701703
stop_child(&mut server);
702704

703705
assert!(
704706
found,
705-
"UPDATE must auto-sync to server while CLI is still running"
707+
"UPDATE must auto-sync to server while CLI is still running; child stdout={}; child stderr={}; last checker stdout={last_stdout}",
708+
output_string(&output.stdout),
709+
output_string(&output.stderr),
706710
);
707711
}
708712

0 commit comments

Comments
 (0)