lucisearch 0.8.1

Embeddable, in-process search engine — the SQLite/DuckDB of search
Documentation
//! Regression test for metadata-only-commit durability.
//!
//! Surfaced by the cycle-#12 adversarial review of
//! `optimization-keyword-dict-offset-index`. `SingleFileDirectory::commit`
//! applies the header root-flip at `single_file.rs:417` BEFORE the first
//! `begin_write()` at `single_file.rs:421`. On a pure metadata-only commit
//! (deletions persisted via `set_user_metadata`, no segment flushed) the lock
//! is still SHARED at line 421, so `begin_write()` re-reads the header from
//! disk (`single_file.rs:239`) and discards the flip — the new metadata block
//! is written but the header keeps pointing at the pre-deletion root. The
//! in-memory `self.committed` snapshot masks the loss within the process; on
//! reopen the deletion reverts.
//!
//! Reachable path: `delete()` only marks the doc (no commit), and `txn_commit()`
//! with no buffered docs flushes nothing — so the commit is metadata-only.

use luci::index::Index;
use luci::mapping::{FieldType, Mapping};
use luci::search::expression::parse_search;
use serde_json::json;

fn test_dir(name: &str) -> std::path::PathBuf {
    let dir = std::env::temp_dir().join(format!("luci_deltest_{}_{name}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    dir
}

fn schema() -> Mapping {
    Mapping::builder()
        .field("title", FieldType::Text)
        .field("tag", FieldType::Keyword)
        .build()
}

/// A deletion committed via a metadata-only `txn_commit()` must survive reopen.
#[test]
fn metadata_only_delete_commit_persists_across_reopen() {
    let path = test_dir("metadata_only_delete");

    let deleted_id;
    // Phase 1: two docs (each auto-commits with a segment flush), delete one
    // (mark-only — `delete()` does not commit), then `txn_commit()` with no
    // buffered docs => a pure metadata-only commit.
    {
        let index = Index::create_with_mapping(&path, schema()).unwrap();
        index.add(json!({"title": "doc one", "tag": "a"})).unwrap();
        index.add(json!({"title": "doc two", "tag": "b"})).unwrap();
        assert_eq!(
            index.count(json!({"match_all": {}})).unwrap(),
            2,
            "both docs present before deletion"
        );

        let expr = parse_search(json!({"term": {"tag": "a"}}), 1).unwrap();
        let results = index.search(&expr).unwrap();
        deleted_id = results
            .hit(0)
            .and_then(|h| h.id())
            .expect("tag=a doc must have an _id");

        assert!(index.delete(&deleted_id).unwrap(), "doc found and marked");
        index.txn_commit().unwrap(); // metadata-only commit (no buffered docs)

        // In-process the deletion is visible via the writer's committed snapshot.
        assert_eq!(
            index.count(json!({"match_all": {}})).unwrap(),
            1,
            "deletion visible in-process immediately after txn_commit"
        );
    }

    // Phase 2: reopen from disk — the deletion must still be applied.
    {
        let index = Index::open(&path).unwrap();
        assert_eq!(
            index.count(json!({"match_all": {}})).unwrap(),
            1,
            "deletion must persist across reopen (metadata-only commit must flip the root)"
        );
        assert!(
            index.get(&deleted_id).unwrap().is_none(),
            "deleted doc must not reappear on reopen"
        );
    }

    let _ = std::fs::remove_dir_all(&path);
}

/// A bare `delete()` — no explicit commit, no following add/bulk — must be
/// durable across reopen. Guaranteed by delete() auto-committing (Bug A) plus
/// the metadata-only commit root-flip fix (Bug B) working together.
#[test]
fn bare_delete_auto_commits_and_persists_across_reopen() {
    let path = test_dir("bare_delete");

    let deleted_id;
    {
        let index = Index::create_with_mapping(&path, schema()).unwrap();
        index
            .add(json!({"title": "keep me", "tag": "keep"}))
            .unwrap();
        index
            .add(json!({"title": "remove me", "tag": "drop"}))
            .unwrap();

        let expr = parse_search(json!({"term": {"tag": "drop"}}), 1).unwrap();
        deleted_id = index
            .search(&expr)
            .unwrap()
            .hit(0)
            .and_then(|h| h.id())
            .expect("tag=drop doc must have an _id");

        // Bare delete() — NO txn_commit and no following add/bulk.
        assert!(index.delete(&deleted_id).unwrap());
        // Index dropped here with no further call.
    }

    {
        let index = Index::open(&path).unwrap();
        assert_eq!(
            index.count(json!({"match_all": {}})).unwrap(),
            1,
            "bare delete() must persist across reopen"
        );
        assert!(index.get(&deleted_id).unwrap().is_none());
    }

    let _ = std::fs::remove_dir_all(&path);
}