obj-db 1.1.2

Embedded document database. Stable file format, full ACID, single-file portability.
Documentation
//! #93 regression: the per-process `reconciled` index-cache must be
//! TRANSACTIONAL — a collection's first-ever (lazy-create) txn that
//! ROLLS BACK must NOT poison the shared cache into skipping index
//! reconciliation on a later, committed txn in the same process.
//!
//! Before the fix, `reconcile_indexes_once` inserted the collection
//! name into the shared `reconciled` set DURING the txn. A rolled-back
//! first-ever txn unwound the catalog's index rows but left the name
//! in the set, so the next insert in the same process saw the poisoned
//! cache hit, skipped reconciliation, and wrote against a catalog with
//! NO index descriptors — the index was silently missing (writes
//! unindexed, `find_unique` misses).
//!
//! These tests drive only `obj::Db`'s public API; the index's presence
//! is observed through its visible behaviour (a `find_unique` hit on
//! the indexed field) plus a clean `integrity_check`.

#![forbid(unsafe_code)]

use obj::{Db, Document, IndexSpec};
use serde::{Deserialize, Serialize};
use tempfile::TempDir;

#[derive(Debug, Clone, Serialize, Deserialize)]
struct User {
    email: String,
    name: String,
}

impl Document for User {
    const COLLECTION: &'static str = "users";
    const VERSION: u32 = 1;

    fn indexes() -> Vec<IndexSpec> {
        vec![IndexSpec::unique("by_email", "email").expect("unique")]
    }
}

/// File-backed `Db` (the rollback path needs a WAL to unwind) plus the
/// owning `TempDir` keepalive.
fn fresh_db() -> (Db, TempDir) {
    let dir = TempDir::new().expect("tmp");
    let path = dir.path().join("reconcile-rollback.obj");
    let db = Db::open(&path).expect("open");
    (db, dir)
}

/// The core #93 regression. A collection's FIRST-EVER txn in the
/// process lazily creates the collection (running index
/// reconciliation) and then rolls back. A subsequent committed txn
/// must re-reconcile so the index is present + maintained.
///
/// Without the fix this fails: the rolled-back txn poisons the shared
/// `reconciled` set, the second insert skips reconciliation, the index
/// is never created, and `find_unique` returns `None`.
#[test]
fn rolled_back_lazy_create_does_not_poison_index_reconciliation() {
    let (db, _dir) = fresh_db();

    // First-ever txn for `users` in this process: open the collection
    // (lazy-create + reconcile the `by_email` unique index), insert a
    // doc, then force a ROLLBACK by returning an error.
    let rolled_back = db.transaction(|tx| {
        let users = tx.collection::<User>()?;
        let _ = users.insert(User {
            email: "ada@example.com".to_owned(),
            name: "Ada".to_owned(),
        })?;
        // Force the surrounding `transaction` to roll back: the
        // closure's error propagates after the in-txn reconciliation
        // already ran.
        Err::<(), obj::Error>(obj::Error::InvalidArgument("force rollback"))
    });
    assert!(rolled_back.is_err(), "txn must have rolled back");

    // The collection should not exist after the rollback — the
    // lazy-create was unwound with the rest of the txn. A read against
    // the now-absent collection therefore either errors with
    // `CollectionNotFound` or (if some other path lazily re-creates an
    // empty collection) returns no document; both prove the rolled-back
    // doc is gone.
    let after_rollback = db.find_unique::<User>("by_email", "ada@example.com");
    match after_rollback {
        Ok(None) | Err(obj::Error::CollectionNotFound { .. }) => {}
        other => panic!("rolled-back lazy-create must leave no document, got {other:?}"),
    }

    // Second txn in the SAME process: insert a doc. With a
    // transactional reconciled-cache this RE-reconciles `users`, so the
    // `by_email` index is created + maintained.
    let id = db
        .insert(User {
            email: "grace@example.com".to_owned(),
            name: "Grace".to_owned(),
        })
        .expect("second insert");

    // (a) The index lookup must FIND the doc — proves reconciliation
    // re-ran and the index is present + maintained. This is the
    // assertion that fails without the fix (index silently missing).
    let found: Option<User> = db
        .find_unique::<User>("by_email", "grace@example.com")
        .expect("find_unique");
    let found = found.expect("indexed doc must be found via by_email");
    assert_eq!(found.email, "grace@example.com");
    assert_eq!(found.name, "Grace");

    // The unique constraint must be live too: a duplicate email
    // collides only if the index actually exists.
    let dup = db.insert(User {
        email: "grace@example.com".to_owned(),
        name: "Imposter".to_owned(),
    });
    assert!(
        matches!(dup, Err(obj::Error::UniqueConstraintViolation { .. })),
        "unique index must be active and reject the duplicate, got {dup:?}"
    );

    // (b) Integrity check passes (index entries agree with primary).
    let report = db.integrity_check().expect("integrity_check");
    assert!(report.is_ok(), "integrity check must pass: {report:?}");

    // Sanity: the originally-inserted id is a valid, distinct id.
    let _ = id;
}

/// The common (commit) path still reconciles EXACTLY once and the
/// index works end-to-end. A second open of the same collection in a
/// later txn hits the shared cache (no double-reconcile, no error) and
/// the index continues to function.
#[test]
fn committed_lazy_create_reconciles_and_repeat_open_is_a_cache_hit() {
    let (db, _dir) = fresh_db();

    // First txn: lazy-create + reconcile + insert, then COMMIT.
    db.insert(User {
        email: "first@example.com".to_owned(),
        name: "First".to_owned(),
    })
    .expect("first insert (commit)");

    // Second txn: opening the collection again is a cache hit (shared
    // set now contains `users`); reconciliation does NOT re-run, yet
    // the index is fully functional.
    db.insert(User {
        email: "second@example.com".to_owned(),
        name: "Second".to_owned(),
    })
    .expect("second insert (cache hit)");

    // Both docs are findable via the index.
    let a: Option<User> = db
        .find_unique::<User>("by_email", "first@example.com")
        .expect("find first");
    let b: Option<User> = db
        .find_unique::<User>("by_email", "second@example.com")
        .expect("find second");
    assert!(a.is_some(), "first doc must be indexed");
    assert!(b.is_some(), "second doc must be indexed");

    // A third open within a single explicit txn must also be a cache
    // hit (staged-set covers the repeat handle) and must not error.
    db.transaction(|tx| {
        let _first_handle = tx.collection::<User>()?;
        let second_handle = tx.collection::<User>()?;
        let _ = second_handle.insert(User {
            email: "third@example.com".to_owned(),
            name: "Third".to_owned(),
        })?;
        Ok::<(), obj::Error>(())
    })
    .expect("repeat-open in one txn");

    let report = db.integrity_check().expect("integrity_check");
    assert!(report.is_ok(), "integrity check must pass: {report:?}");
}