|
1 | | -use contextdb_core::Value; |
| 1 | +use contextdb_core::{MemoryAccountant, Value}; |
2 | 2 | use contextdb_engine::Database; |
3 | 3 | use std::collections::HashMap; |
4 | 4 | use std::sync::atomic::{AtomicBool, Ordering}; |
@@ -3237,3 +3237,337 @@ fn sql_13_null_in_nullable_uuid() { |
3237 | 3237 | assert_eq!(out.rows.len(), 1); |
3238 | 3238 | assert_eq!(out.rows[0][0], Value::Null); |
3239 | 3239 | } |
| 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 | + ¶ms(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 | + ¶ms(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 | + ¶ms(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 | + ¶ms(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 | + ¶ms(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 | + ¶ms(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 | + ¶ms(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 | + ¶ms(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 | + ¶ms(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 | + ¶ms(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 | + ¶ms(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 | +} |
0 commit comments