tidaldb/tidal/tests/m0m10_recovery.rs
jx12n b55ad70141 fix: M0-M10 third-pass remediation — durability, replication, and CLI hardening
Resolves the 142 findings from tidal/docs/reviews/CODE_REVIEW_m0-m10.md across
the engine, server, net, and CLI surfaces:

- WAL/session-journal durability, checkpoint format, and crash-recovery hardening
- Replication shipper/receiver, tenant isolation, and migration paths
- Cluster scatter-gather, router, standalone server + health/offload endpoints
- tidalctl refactored into command modules with JSON output and WAL-state tooling
- Cohort, governance, signal-ledger, and vector-registry correctness fixes
- Expanded UAT/integration/durability test coverage across all milestones
2026-06-08 10:28:34 -06:00

466 lines
17 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! M0M10 crash-recovery integration tests for the db-integration zone.
//!
//! These cover three durability/recovery contracts that had no end-to-end test
//! before the M0M10 review:
//!
//! 1. **Vector index rebuilt on restart** — durable `EMB:` embeddings repopulate
//! the in-memory ANN index on reopen, so vector SEARCH returns them without
//! rewriting embeddings (the index is derived state; the entity store is the
//! source of truth).
//! 2. **Purge tombstone replayed on crash-recovery** — a community purge that
//! crashes BEFORE the next community checkpoint still stays purged on reopen
//! (the durable `Tag::PurgeTombstone` is re-applied), closing the
//! privacy/trust window where purged data silently resurrects.
//! 3. **`created_at` indexing parity across restart** — an item written without
//! an explicit `created_at` gets the SAME `created_at` after a restart as on
//! the live write path, because the default is materialized into the
//! persisted metadata and the rebuild reads the identical value.
#![allow(clippy::too_many_lines, clippy::unwrap_used)]
use std::{collections::HashMap, time::Duration};
use tidaldb::{
CommunityId, ShareIntent, SharePolicy, TempTidalHome, TidalDb, UserId,
query::search::Search,
schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, TextFieldType, Timestamp, Window},
testing::{CrashInjector, CrashPoint, crash_injector::run_with_crash},
};
// ── Task 1: vector index rebuilt on restart ──────────────────────────────────
/// Schema with one Item embedding slot, used by the embedding-rebuild test.
fn embedding_schema(dims: usize) -> tidaldb::schema::Schema {
let mut b = SchemaBuilder::new();
let _ = b
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.velocity(false)
.add();
b.embedding_slot("content", EntityKind::Item, dims);
b.build().unwrap()
}
#[test]
fn vector_index_rebuilt_from_durable_embeddings_on_reopen() {
let home = TempTidalHome::new().unwrap();
// First open: write three orthogonal item embeddings, then clean shutdown.
{
let db = TidalDb::builder()
.with_data_dir(home.path())
.with_schema(embedding_schema(4))
.open()
.unwrap();
// An item needs metadata to be in the universe; write both.
for id in 1u64..=3 {
let mut meta = HashMap::new();
meta.insert("title".to_string(), format!("item {id}"));
db.write_item_with_metadata(EntityId::new(id), &meta)
.unwrap();
}
db.write_item_embedding(EntityId::new(1), &[1.0, 0.0, 0.0, 0.0])
.unwrap();
db.write_item_embedding(EntityId::new(2), &[0.0, 1.0, 0.0, 0.0])
.unwrap();
db.write_item_embedding(EntityId::new(3), &[0.0, 0.0, 1.0, 0.0])
.unwrap();
// Sanity: the live (pre-restart) index serves the nearest neighbor.
let live = db
.search(
&Search::builder()
.vector(vec![1.0, 0.0, 0.0, 0.0])
.limit(1)
.build()
.unwrap(),
)
.unwrap();
assert_eq!(live.items.len(), 1, "live index must serve a result");
assert_eq!(live.items[0].entity_id, EntityId::new(1));
db.shutdown().unwrap();
}
// Reopen: the in-memory ANN index is DERIVED state lost on shutdown. Without
// the rebuild-from-store wiring this search returns nothing. With it, the
// durable `EMB:content` rows repopulate the index and SEARCH works.
let db = TidalDb::builder()
.with_data_dir(home.path())
.with_schema(embedding_schema(4))
.open()
.unwrap();
let near_1 = db
.search(
&Search::builder()
.vector(vec![1.0, 0.0, 0.0, 0.0])
.limit(1)
.build()
.unwrap(),
)
.unwrap();
assert_eq!(
near_1.items.len(),
1,
"vector SEARCH must return a result after reopen (index rebuilt from EMB: keys)"
);
assert_eq!(
near_1.items[0].entity_id,
EntityId::new(1),
"nearest neighbor of e0 is item 1"
);
// A different query direction resolves to its own nearest neighbor, proving
// the whole index — not just one vector — was repopulated.
let near_2 = db
.search(
&Search::builder()
.vector(vec![0.0, 1.0, 0.0, 0.0])
.limit(1)
.build()
.unwrap(),
)
.unwrap();
assert_eq!(near_2.items.len(), 1);
assert_eq!(near_2.items[0].entity_id, EntityId::new(2));
db.shutdown().unwrap();
}
// ── Task 1b: item text index rebuilt from durable store on reopen ────────────
/// Schema with one indexed item text field, used by the text-rebuild test.
fn text_schema() -> tidaldb::schema::Schema {
let mut b = SchemaBuilder::new();
let _ = b
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.velocity(false)
.add();
b.text_field("title", TextFieldType::Text);
b.build().unwrap()
}
/// BLOCKER 7: items durably persisted to the entity store but not committed to
/// Tantivy before a hard crash must still be found by BM25 SEARCH after reopen,
/// because the item text index is unconditionally rebuilt from the durable store
/// at open (the entity store is the source of truth; Tantivy is derived state).
///
/// We model the post-crash on-disk state faithfully: after a clean write the
/// store AND Tantivy both hold the items, so we delete the `text_index`
/// directory to simulate the exact failure mode the BLOCKER describes — Tantivy
/// behind the durable store because the async syncer never committed (or a
/// power-loss truncated it). Without the rebuild-at-open wiring this SEARCH
/// returns nothing; with it, the items are re-indexed from the store.
#[test]
fn item_text_index_rebuilt_from_durable_store_on_reopen() {
let home = TempTidalHome::new().unwrap();
let titles = [
(1u64, "rust systems programming"),
(2, "jazz piano improvisation"),
(3, "italian pasta recipe"),
];
// Phase 1: write items (persisted to the store) and clean-shutdown.
{
let db = TidalDb::builder()
.with_data_dir(home.path())
.with_schema(text_schema())
.open()
.unwrap();
for (id, title) in titles {
let mut meta = HashMap::new();
meta.insert("title".to_string(), title.to_string());
db.write_item_with_metadata(EntityId::new(id), &meta)
.unwrap();
}
// Force a synchronous commit of the background text syncer so the live
// sanity search below sees the just-written items (the default async
// commit interval is 2s; without this flush the live read races it).
// This is the canonical pattern used across the search tests.
db.flush_text_index().unwrap();
// Sanity: the live index serves a BM25 hit before any restart.
let live = db
.search(&Search::builder().query("rust").limit(5).build().unwrap())
.unwrap();
assert!(
live.items.iter().any(|i| i.entity_id == EntityId::new(1)),
"live text index must serve a result"
);
db.shutdown().unwrap();
}
// Simulate the crash divergence: blow away the Tantivy item index so it is
// BEHIND the durable store, exactly as a hard crash before commit leaves it.
let text_dir = home.path().join("text_index");
assert!(
text_dir.exists(),
"text index dir must exist after a clean run"
);
std::fs::remove_dir_all(&text_dir).unwrap();
// Phase 2: reopen. rebuild_text_indexes_at_open must repopulate Tantivy from
// the durable item store so SEARCH finds the items again.
let db = TidalDb::builder()
.with_data_dir(home.path())
.with_schema(text_schema())
.open()
.unwrap();
for (id, query) in [(1u64, "rust"), (2, "jazz"), (3, "pasta")] {
let results = db
.search(&Search::builder().query(query).limit(5).build().unwrap())
.unwrap();
assert!(
results
.items
.iter()
.any(|i| i.entity_id == EntityId::new(id)),
"item {id} must be found by SEARCH '{query}' after reopen \
(text index rebuilt from the durable store)"
);
}
db.shutdown().unwrap();
}
// ── Task 2: purge tombstone replayed after a crash before checkpoint ─────────
fn community_schema() -> tidaldb::schema::Schema {
let mut b = SchemaBuilder::new();
let _ = b
.signal(
"like",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.velocity(false)
.add();
b.build().unwrap()
}
fn share_policy() -> SharePolicy {
SharePolicy::community_share(1, [ShareIntent::Like])
}
#[test]
fn purge_survives_crash_before_next_checkpoint() {
let home = TempTidalHome::new().unwrap();
let (community, item) = (CommunityId(1), EntityId::new(100));
// Phase 1: seed three contributors, then CLEAN shutdown so the PRE-purge
// community aggregate (count = 3) is persisted to the Tag::Community
// checkpoint. The community ledger is only checkpointed on the lifecycle
// path, so this clean shutdown is what puts the pre-purge snapshot on disk.
{
let db = TidalDb::builder()
.with_data_dir(home.path())
.with_schema(community_schema())
.open()
.unwrap();
let now = Timestamp::now();
for u in 1u64..=3 {
db.join_community(UserId(u), community, share_policy())
.unwrap();
assert!(
db.signal_for_community("like", item, 1.0, now, UserId(u), community)
.unwrap()
);
}
assert_eq!(
db.read_community_windowed_count(community, item, "like", Window::AllTime)
.unwrap(),
3
);
db.shutdown().unwrap();
}
// Phase 2: reopen, PURGE user 2 (writes a durable Tag::PurgeTombstone and
// mutates only the in-memory aggregate -> count 2), then CRASH before the
// next checkpoint. We arm CheckpointPreFlush so the shutdown's signal
// checkpoint panics before any post-purge state is flushed; the community
// checkpoint (later in shutdown) therefore never runs. The on-disk state is
// exactly: the PRE-purge Tag::Community snapshot (count 3) + the durable
// tombstone. The lock file is released as `db` is dropped during unwind.
let injector = CrashInjector::new(CrashPoint::CheckpointPreFlush, 0);
let outcome = run_with_crash(&injector, || {
let db = TidalDb::builder()
.with_data_dir(home.path())
.with_schema(community_schema())
.open()
.unwrap();
// Aggregate restored to the pre-purge count.
assert_eq!(
db.read_community_windowed_count(community, item, "like", Window::AllTime)
.unwrap(),
3,
"reopen must restore the pre-purge aggregate from the checkpoint"
);
// Write a LOCAL signal so the signal-ledger has a checkpointable entry:
// community-scoped contributions never touch the local aggregate
// (local-profile-intact), so without this the shutdown checkpoint over an
// empty ledger would not cross CheckpointPreFlush. This local state is
// independent of the community aggregate the assertions below check.
db.signal("like", EntityId::new(900), 1.0, Timestamp::now())
.unwrap();
let receipt = db
.purge_prior_contributions(UserId(2), community, 0..=u32::MAX)
.unwrap();
assert_eq!(receipt.contributions_removed, 1);
assert_eq!(
db.read_community_windowed_count(community, item, "like", Window::AllTime)
.unwrap(),
2,
"in-memory aggregate reflects the purge before the crash"
);
// Crash before the post-purge checkpoint flushes (CheckpointPreFlush
// fires inside the signal-ledger checkpoint during shutdown). The
// returned Result is irrelevant: the injector panic unwinds out of
// `close`, so `run_with_crash` yields `Err(CrashPoint)`.
let _ = db.close();
});
assert!(
matches!(outcome, Err(CrashPoint::CheckpointPreFlush)),
"the crash injector must have fired during the shutdown checkpoint, got {outcome:?}"
);
// Phase 3: reopen after the simulated crash. restore() reads the PRE-purge
// Tag::Community checkpoint (count 3); the tombstone replay must re-apply the
// purge so the aggregate is 2 — NOT silently resurrected to 3.
let db = TidalDb::builder()
.with_data_dir(home.path())
.with_schema(community_schema())
.open()
.unwrap();
assert_eq!(
db.read_community_windowed_count(community, item, "like", Window::AllTime)
.unwrap(),
2,
"purge must stay applied after a crash before checkpoint \
(tombstone replayed; purged data must NOT resurrect)"
);
assert!(
db.community_purge_watermark(community).is_some(),
"the purge watermark must be re-established by tombstone replay"
);
db.shutdown().unwrap();
}
// ── Task 4: created_at indexing parity across restart ────────────────────────
fn recency_schema() -> tidaldb::schema::Schema {
let mut b = SchemaBuilder::new();
let _ = b
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.velocity(false)
.add();
b.build().unwrap()
}
#[test]
fn created_at_default_is_consistent_across_restart() {
let home = TempTidalHome::new().unwrap();
// Write two items with NO explicit created_at, in order (1 then 2). Capture
// the materialized created_at each got on the live write path.
let (live_ts_1, live_ts_2);
{
let db = TidalDb::builder()
.with_data_dir(home.path())
.with_schema(recency_schema())
.open()
.unwrap();
let mut meta1 = HashMap::new();
meta1.insert("title".to_string(), "first".to_string());
db.write_item_with_metadata(EntityId::new(1), &meta1)
.unwrap();
let mut meta2 = HashMap::new();
meta2.insert("title".to_string(), "second".to_string());
db.write_item_with_metadata(EntityId::new(2), &meta2)
.unwrap();
// The write path materializes the default into the PERSISTED metadata.
live_ts_1 = created_at_of(&db, EntityId::new(1));
live_ts_2 = created_at_of(&db, EntityId::new(2));
// Item 1 was written first, so its recency timestamp must not be later
// than item 2's: the live recency ordering is 1 <= 2.
assert!(
live_ts_1 <= live_ts_2,
"live created_at ordering must be monotonic with write order"
);
db.shutdown().unwrap();
}
// Reopen. The rebuild path reads created_at from the SAME persisted metadata
// via the shared indexer, so the recovered created_at must be byte-identical
// to the live value — proving the write and rebuild paths cannot diverge.
let db = TidalDb::builder()
.with_data_dir(home.path())
.with_schema(recency_schema())
.open()
.unwrap();
let rebuilt_ts_1 = created_at_of(&db, EntityId::new(1));
let rebuilt_ts_2 = created_at_of(&db, EntityId::new(2));
assert_eq!(
rebuilt_ts_1, live_ts_1,
"item 1 created_at must be identical after restart (no default divergence)"
);
assert_eq!(
rebuilt_ts_2, live_ts_2,
"item 2 created_at must be identical after restart"
);
// And the recency ordering is preserved across the restart.
assert!(
rebuilt_ts_1 <= rebuilt_ts_2,
"recency ordering must survive a restart unchanged"
);
db.shutdown().unwrap();
}
/// Read an item's persisted `created_at` as a `u64`, asserting it is present.
fn created_at_of(db: &TidalDb, id: EntityId) -> u64 {
let meta = db
.get_item_metadata(id)
.unwrap()
.expect("item metadata must exist");
meta.get("created_at")
.expect("write path must materialize a created_at into persisted metadata")
.parse::<u64>()
.expect("created_at must be a parseable u64")
}