minigraf 1.1.1

Zero-config, single-file, embedded graph database with bi-temporal Datalog queries
Documentation
//! Long-run reliability smoke suite (#220).
//!
//! Imports a large synthetic graph, runs representative queries, and reopens
//! repeatedly to verify durability and correctness invariants.
//!
//! Skipped in standard CI. Run manually:
//!   cargo test --test smoke_test -- --include-ignored --nocapture
//!
//! Nightly CI runs this via .github/workflows/smoke.yml.
#![cfg(not(target_arch = "wasm32"))]

use minigraf::db::Minigraf;
use minigraf::{BindValue, QueryResult, Value};

fn count_results(r: QueryResult) -> usize {
    match r {
        QueryResult::QueryResults { results, .. } => results.len(),
        _ => 0,
    }
}

/// The full long-haul smoke test.
///
/// Workload:
/// - 500 entities × 10 attributes = 5,000 facts
/// - 10 reopen + checkpoint cycles
/// - Representative queries after each cycle (basic, temporal, recursive-capable, aggregation)
/// - Invariants checked after each cycle
#[test]
#[ignore]
fn smoke_large_graph_10_cycles() {
    eprintln!("smoke: starting long-haul smoke suite");

    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("smoke.graph");

    const NUM_ENTITIES: usize = 500;
    const NUM_CYCLES: usize = 10;

    // ── Phase 1: Load 5,000 facts ─────────────────────────────────────────────
    eprintln!("smoke: loading {} entities × 10 attributes", NUM_ENTITIES);
    {
        let db = Minigraf::open(&path).unwrap();

        // Batch transact in groups of 50 entities to keep individual transactions small.
        for batch_start in (0..NUM_ENTITIES).step_by(50) {
            let batch_end = (batch_start + 50).min(NUM_ENTITIES);
            let facts: String = (batch_start..batch_end)
                .flat_map(|i| {
                    vec![
                        format!(r#"[:entity{i} :name "entity-{i}"]"#),
                        format!(r#"[:entity{i} :index {i}]"#),
                        format!(r#"[:entity{i} :bucket {}]"#, i % 10),
                        format!(r#"[:entity{i} :active {}]"#, i % 3 != 0),
                        format!(r#"[:entity{i} :score {}]"#, (i * 7) % 100),
                        format!(r#"[:entity{i} :group {}]"#, i % 5),
                        format!(r#"[:entity{i} :tier {}]"#, i % 4),
                        format!(r#"[:entity{i} :region "region-{}"]"#, i % 8),
                        format!(r#"[:entity{i} :version 1]"#),
                        format!(r#"[:entity{i} :label "label-{}"]"#, i % 20),
                    ]
                })
                .collect::<Vec<_>>()
                .join(" ");
            db.execute(&format!("(transact [{facts}])")).unwrap();
        }

        db.checkpoint().unwrap();
    }
    eprintln!("smoke: initial load complete");

    // ── Phase 2: 10 reopen + query + checkpoint cycles ───────────────────────
    for cycle in 0..NUM_CYCLES {
        eprintln!("smoke: cycle {}/{}", cycle + 1, NUM_CYCLES);

        let db = Minigraf::open(&path).unwrap();

        // Invariant 1: all 500 entities must be queryable by :name.
        let n_names = count_results(
            db.execute("(query [:find ?e :where [?e :name ?n]])")
                .unwrap(),
        );
        assert_eq!(
            n_names, NUM_ENTITIES,
            "cycle {cycle}: expected {NUM_ENTITIES} entities by :name, got {n_names}"
        );

        // Invariant 2: :index values must be unique per entity.
        let n_index = count_results(
            db.execute("(query [:find ?e ?i :where [?e :index ?i]])")
                .unwrap(),
        );
        assert_eq!(
            n_index, NUM_ENTITIES,
            "cycle {cycle}: expected {NUM_ENTITIES} :index facts, got {n_index}"
        );

        // Invariant 3: bucket distribution — 10 buckets, 50 entities each.
        for b in 0..10usize {
            let n_bucket = count_results(
                db.execute(&format!("(query [:find ?e :where [?e :bucket {b}]])"))
                    .unwrap(),
            );
            assert_eq!(
                n_bucket, 50,
                "cycle {cycle}: bucket {b} should have 50 entities, got {n_bucket}"
            );
        }

        // Invariant 4: active entities (index % 3 != 0) = 333.
        let n_active = count_results(
            db.execute("(query [:find ?e :where [?e :active true]])")
                .unwrap(),
        );
        // 500 entities, active if i % 3 != 0. Inactive: i=0,3,6,...,498 → 167 entities.
        // ceil(NUM_ENTITIES / 3) = number of multiples of 3 in [0, NUM_ENTITIES).
        let n_inactive = (NUM_ENTITIES + 2) / 3;
        let expected_active = NUM_ENTITIES - n_inactive;
        assert_eq!(
            n_active, expected_active,
            "cycle {cycle}: active entity count mismatch; got {n_active}"
        );

        // Invariant 5: temporal query (:as-of 1) returns data from tx 1.
        let n_temporal = count_results(
            db.execute("(query [:find ?e :where [?e :version 1]] :as-of 1)")
                .unwrap_or(QueryResult::QueryResults {
                    vars: vec![],
                    results: vec![],
                }),
        );
        // Should return some results — exact count depends on tx ordering.
        // Just verify it doesn't error and returns > 0 for tx 1.
        assert!(
            n_temporal > 0,
            "cycle {cycle}: temporal query :as-of 1 returned no results"
        );

        // Invariant 6: prepared query returns consistent results.
        let prep = db
            .prepare("(query [:find ?e :where [?e :region $region]])")
            .unwrap();
        let r0 = prep
            .execute(&[(
                "region",
                BindValue::Val(Value::String("region-0".to_string())),
            )])
            .unwrap();
        let n_region0 = count_results(r0);
        // region-0: entities where i % 8 == 0 → 0,8,16,...,496 → 63 entities.
        assert_eq!(
            n_region0, 63,
            "cycle {cycle}: prepared query for region-0 returned {n_region0}"
        );

        // Invariant 7: write a new fact each cycle.
        db.execute(&format!(
            r#"(transact [[:cycle{cycle} :cycle-fact {cycle}]])"#
        ))
        .unwrap();

        // Checkpoint to flush all to disk.
        db.checkpoint().unwrap();

        eprintln!(
            "smoke: cycle {}/{} complete — all invariants passed",
            cycle + 1,
            NUM_CYCLES
        );
    }

    // ── Phase 3: Final reopen and full invariant check ────────────────────────
    eprintln!("smoke: final reopen and invariant verification");
    let db_final = Minigraf::open(&path).unwrap();

    let n_final = count_results(
        db_final
            .execute("(query [:find ?e :where [?e :name ?n]])")
            .unwrap(),
    );
    assert_eq!(
        n_final, NUM_ENTITIES,
        "final reopen: expected {NUM_ENTITIES} entities, got {n_final}"
    );

    // Cycle facts from all 10 cycles must be present.
    let n_cycles = count_results(
        db_final
            .execute("(query [:find ?e :where [?e :cycle-fact ?c]])")
            .unwrap(),
    );
    assert_eq!(
        n_cycles, NUM_CYCLES,
        "final reopen: expected {NUM_CYCLES} cycle facts, got {n_cycles}"
    );

    eprintln!("smoke: all invariants passed across {NUM_CYCLES} cycles");
}