- Add BUILD.bazel across tidal, tidal-net, tidal-server, tidalctl for bzlmod build - Add tidal/ crate docs (README, CHANGELOG, CONTRIBUTING, AGENTS, CLAUDE, API, ARCHITECTURE) and ai-lookup reference - Add docker standalone/cluster/deploy images, compose, and prometheus config - Harden WAL (batch format, writer, dedup, diagnostics), text syncer/collectors, and vector registry - Expand tidalctl CLI and tests; restructure WAL/visibility integration test suites - Refine tidal-net transport/client/server and tidal-server cluster/scatter-gather
516 lines
21 KiB
Rust
516 lines
21 KiB
Rust
//! Milestone 7: Mid-write crash recovery for the M6 surfaces.
|
|
//!
|
|
//! The four crash points below are wired into the M6-surface write paths but
|
|
//! were never *armed* by any test — `m7_crash_m6.rs` only exercises clean
|
|
//! `close()` + reopen roundtrips, and `m7_crash_property.rs` only arms the WAL /
|
|
//! signal-ledger crash points. This file closes that gap: for each M6 crash
|
|
//! point we open a DB, perform the corresponding write, ARM the injector so the
|
|
//! process panics mid-write, let `Drop` run the durability path during unwind,
|
|
//! reopen, and assert the recovered surface satisfies the WAL crash tests'
|
|
//! durability bounds — **no fully-acked write lost, no write duplicated**.
|
|
//!
|
|
//! Crash points under test (exact `CrashPoint` variants):
|
|
//! 1. `CrashPoint::CohortLedgerUpdate` — `CohortSignalLedger::record`, after
|
|
//! the hot-tier `on_signal` CAS, before the warm-tier `increment`
|
|
//! (`src/cohort/ledger.rs:100`).
|
|
//! 2. `CrashPoint::CollectionIndexUpdate` — `TidalDb::add_to_collection`, after
|
|
//! `persist_collection`, before return (`src/db/collections.rs:78`).
|
|
//! 3. `CrashPoint::CoEngagementUpdate` — `CoEngagementIndex::record_positive`,
|
|
//! after the edge-insertion loop, before the eviction check
|
|
//! (`src/entities/co_engagement.rs:98`).
|
|
//! 4. `CrashPoint::CheckpointPostFlush` — `signals::checkpoint`, *after*
|
|
//! `write_batch` + `flush` (`src/signals/checkpoint/mod.rs:118`).
|
|
//!
|
|
//! ## Durability model
|
|
//!
|
|
//! Cohort + co-engagement state is **checkpoint-only** (no WAL): `record` /
|
|
//! `record_positive` mutate in-memory state and are persisted by the final
|
|
//! checkpoint that `shutdown_inner` runs from both `close()` AND `Drop`. When an
|
|
//! armed injector panics mid-write, the unwind drops the `TidalDb`, `Drop` runs
|
|
//! `shutdown_inner`, and the *current* in-memory state is checkpointed. The
|
|
//! injector is one-shot, so the crash aborts the in-flight call (and the
|
|
//! enclosing write loop) but the durability path still runs to completion.
|
|
//!
|
|
//! Because the crash points sit at precise pre/post boundaries we can assert
|
|
//! *exact* recovered counts rather than just bounds:
|
|
//! - `CohortLedgerUpdate` fires before `warm.increment`, so a crash on call
|
|
//! `K+1` (after `K` complete) leaves the warm counter at exactly `K`.
|
|
//! - `CollectionIndexUpdate` fires after `persist_collection`, so a crash on the
|
|
//! add of item `K+1` (after `K` complete adds) leaves items `1..=K+1` durable.
|
|
//! - `CoEngagementUpdate` fires after the edge-insertion loop, so edges from the
|
|
//! crashing call are already in-memory and checkpointed by `Drop`.
|
|
//! - `CheckpointPostFlush` fires after the flush completes, so the checkpoint is
|
|
//! fully durable and every surface must recover exactly.
|
|
|
|
#![allow(clippy::unwrap_used, clippy::cast_precision_loss)]
|
|
|
|
use std::{collections::HashMap, time::Duration};
|
|
|
|
use tidaldb::{
|
|
TidalDb,
|
|
cohort::{CohortDef, Predicate},
|
|
entities::Visibility,
|
|
schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window},
|
|
testing::{CrashInjector, CrashPoint, crash_injector::run_with_crash},
|
|
};
|
|
|
|
// ── Schema + metadata helpers ────────────────────────────────────────────────
|
|
|
|
/// M6 schema with view + like signals across all windows and velocity enabled.
|
|
fn m6_schema() -> tidaldb::schema::Schema {
|
|
let mut builder = SchemaBuilder::new();
|
|
for &(name, half_life_days) in &[("view", 7u64), ("like", 14)] {
|
|
let _ = builder
|
|
.signal(
|
|
name,
|
|
EntityKind::Item,
|
|
DecaySpec::Exponential {
|
|
half_life: Duration::from_secs(half_life_days * 24 * 3600),
|
|
},
|
|
)
|
|
.windows(&[
|
|
Window::OneHour,
|
|
Window::TwentyFourHours,
|
|
Window::SevenDays,
|
|
Window::AllTime,
|
|
])
|
|
.velocity(true)
|
|
.add();
|
|
}
|
|
builder.build().expect("m6 schema must be valid")
|
|
}
|
|
|
|
fn item_metadata(category: &str, creator_id: u64) -> HashMap<String, String> {
|
|
let mut meta = HashMap::new();
|
|
meta.insert("category".to_string(), category.to_string());
|
|
meta.insert("creator_id".to_string(), creator_id.to_string());
|
|
meta
|
|
}
|
|
|
|
fn user_metadata(locale: &str, primary_category: &str) -> HashMap<String, String> {
|
|
let mut m = HashMap::new();
|
|
m.insert("locale".to_string(), locale.to_string());
|
|
m.insert("primary_category".to_string(), primary_category.to_string());
|
|
m
|
|
}
|
|
|
|
fn open_db(dir: &std::path::Path, schema: &tidaldb::schema::Schema) -> TidalDb {
|
|
TidalDb::builder()
|
|
.with_data_dir(dir)
|
|
.with_schema(schema.clone())
|
|
.open()
|
|
.unwrap()
|
|
}
|
|
|
|
// ── Test 1: CohortLedgerUpdate mid-write crash ───────────────────────────────
|
|
//
|
|
// Arms `CrashPoint::CohortLedgerUpdate` (src/cohort/ledger.rs:100). We record
|
|
// N cohort-attributed `like` signals onto a SINGLE item for a single cohort, so
|
|
// the per-key warm counter accumulates. The injector fires on cohort `record`
|
|
// call K+1 — after `hot.on_signal`, before `warm.increment` — so that call's
|
|
// warm increment is lost and the loop aborts. `Drop` checkpoints the in-memory
|
|
// state (warm == K). On reopen, the recovered cohort windowed count must be
|
|
// EXACTLY K: every fully-acked record present (no loss), the in-flight one
|
|
// dropped and never re-applied (no duplicate — cohort state is checkpoint-only,
|
|
// so there is no WAL-replay double-apply).
|
|
|
|
#[test]
|
|
fn cohort_ledger_update_crash_recovers_exactly_acked() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let schema = m6_schema();
|
|
|
|
let total_likes = 8u64;
|
|
let crash_after = 5u64; // K = 5 complete records, crash on the 6th.
|
|
let user = 1001u64;
|
|
let item = EntityId::new(1);
|
|
let cohort = "tech_en";
|
|
|
|
{
|
|
let db = open_db(dir.path(), &schema);
|
|
|
|
db.define_cohort(CohortDef {
|
|
name: cohort.to_string(),
|
|
predicate: Predicate::And(vec![
|
|
Predicate::Eq {
|
|
field: "locale".into(),
|
|
value: "en".into(),
|
|
},
|
|
Predicate::Eq {
|
|
field: "primary_category".into(),
|
|
value: "tech".into(),
|
|
},
|
|
]),
|
|
})
|
|
.unwrap();
|
|
|
|
db.write_user(EntityId::new(user), &user_metadata("en", "tech"))
|
|
.unwrap();
|
|
db.write_item_with_metadata(item, &item_metadata("tech", 100))
|
|
.unwrap();
|
|
|
|
// Each `like` with user context drives try_cohort_attribution -> record.
|
|
// `like` is a positive-engagement signal, so it ALSO drives co-engagement,
|
|
// but only cohort `record` crosses CohortLedgerUpdate.
|
|
let injector = CrashInjector::new(CrashPoint::CohortLedgerUpdate, crash_after);
|
|
let ts = Timestamp::now();
|
|
let outcome = run_with_crash(&injector, || {
|
|
for _ in 0..total_likes {
|
|
db.signal_with_context("like", item, 1.0, ts, Some(user), Some(100))
|
|
.unwrap();
|
|
}
|
|
// db dropped here on the normal path; on crash, dropped during unwind.
|
|
drop(db);
|
|
});
|
|
|
|
assert_eq!(
|
|
outcome,
|
|
Err(CrashPoint::CohortLedgerUpdate),
|
|
"injector must fire at CohortLedgerUpdate"
|
|
);
|
|
assert!(injector.has_fired(), "injector should have fired");
|
|
}
|
|
|
|
// Reopen: cohort warm count must be EXACTLY the number of fully-acked records.
|
|
{
|
|
let db = open_db(dir.path(), &schema);
|
|
let ledger = db.cohort_ledger();
|
|
|
|
let count = ledger
|
|
.read_windowed_count(cohort, item, "like", Window::AllTime)
|
|
.unwrap();
|
|
assert_eq!(
|
|
count, crash_after,
|
|
"cohort recovered count must equal acked records ({crash_after}): \
|
|
no acked write lost, no in-flight/duplicate write applied; got {count}"
|
|
);
|
|
|
|
// Decay score must be finite + non-negative (recovered hot tier).
|
|
if let Some(score) = ledger.read_decay_score(cohort, item, "like", 0).unwrap() {
|
|
assert!(
|
|
score.is_finite(),
|
|
"recovered cohort decay score not finite: {score}"
|
|
);
|
|
assert!(
|
|
score >= 0.0,
|
|
"recovered cohort decay score negative: {score}"
|
|
);
|
|
}
|
|
|
|
db.close().unwrap();
|
|
}
|
|
}
|
|
|
|
// ── Test 2: CollectionIndexUpdate mid-write crash ────────────────────────────
|
|
//
|
|
// Arms `CrashPoint::CollectionIndexUpdate` (src/db/collections.rs:78). The crash
|
|
// point fires AFTER `persist_collection`, so on the add of item K+1 the in-memory
|
|
// index already holds item K+1 and the updated collection has already been
|
|
// persisted to durable storage. We crash on add K+1, the loop aborts, `Drop`
|
|
// re-persists the in-memory index. On reopen: items 1..=K+1 must be present
|
|
// (every acked add + the persisted-then-crashed add), items K+2.. must be absent
|
|
// (their adds never ran). No membership lost, no phantom membership.
|
|
|
|
#[test]
|
|
fn collection_index_update_crash_recovers_persisted_adds() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let schema = m6_schema();
|
|
let owner = EntityId::new(42);
|
|
|
|
let total_items = 8u64; // items 10, 20, ..., 80
|
|
let crash_after = 5u64; // K = 5 complete adds, crash on add #6 (item 60).
|
|
|
|
let collection_name = "favorites";
|
|
|
|
{
|
|
let db = open_db(dir.path(), &schema);
|
|
let cid = db
|
|
.create_collection(owner, collection_name, Visibility::Private)
|
|
.unwrap();
|
|
|
|
let injector = CrashInjector::new(CrashPoint::CollectionIndexUpdate, crash_after);
|
|
let outcome = run_with_crash(&injector, || {
|
|
for i in 1..=total_items {
|
|
db.add_to_collection(cid, EntityId::new(i * 10)).unwrap();
|
|
}
|
|
drop(db);
|
|
});
|
|
|
|
assert_eq!(
|
|
outcome,
|
|
Err(CrashPoint::CollectionIndexUpdate),
|
|
"injector must fire at CollectionIndexUpdate"
|
|
);
|
|
assert!(injector.has_fired(), "injector should have fired");
|
|
}
|
|
|
|
// Reopen: items 1..=K+1 present (acked adds + the persisted-then-crashed add),
|
|
// items K+2.. absent.
|
|
{
|
|
let db = open_db(dir.path(), &schema);
|
|
let collections = db.list_collections(owner).unwrap();
|
|
assert_eq!(
|
|
collections.len(),
|
|
1,
|
|
"the collection must survive the mid-write crash"
|
|
);
|
|
let col = collections
|
|
.iter()
|
|
.find(|c| c.name == collection_name)
|
|
.expect("favorites collection must survive");
|
|
|
|
let index = db.collection_index();
|
|
|
|
// The crash fires AFTER persist on add K+1, so item K+1 is durable too.
|
|
let durable_through = crash_after + 1;
|
|
for i in 1..=durable_through {
|
|
let item = i * 10;
|
|
assert!(
|
|
index.contains(col.id, u32::try_from(item).unwrap()),
|
|
"item {item} (acked or persisted-then-crashed add) must survive restart"
|
|
);
|
|
}
|
|
for i in (durable_through + 1)..=total_items {
|
|
let item = i * 10;
|
|
assert!(
|
|
!index.contains(col.id, u32::try_from(item).unwrap()),
|
|
"item {item} (add never ran) must NOT be present — no phantom membership"
|
|
);
|
|
}
|
|
|
|
assert_eq!(
|
|
col.item_count as u64, durable_through,
|
|
"collection item_count must equal durable adds ({durable_through}); got {}",
|
|
col.item_count
|
|
);
|
|
|
|
db.close().unwrap();
|
|
}
|
|
}
|
|
|
|
// ── Test 3: CoEngagementUpdate mid-write crash ───────────────────────────────
|
|
//
|
|
// Arms `CrashPoint::CoEngagementUpdate` (src/entities/co_engagement.rs:98). The
|
|
// crash point fires AFTER the edge-insertion loop, before the eviction check.
|
|
// We drive co-engagement by liking items in sequence for one user, which on each
|
|
// `like` adds edges (new_item -> each prior item). We crash on `record_positive`
|
|
// call K+1: that call's edges were already inserted before the crash point, so
|
|
// `Drop` checkpoints all edges through call K+1. On reopen every edge produced
|
|
// by calls 1..=K+1 must survive with its exact weight; later calls never ran so
|
|
// their edges must be absent. No edge lost, no duplicate-weight inflation.
|
|
|
|
#[test]
|
|
fn co_engagement_update_crash_recovers_inserted_edges() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let schema = m6_schema();
|
|
|
|
let user = 7u64;
|
|
// Like items in sequence: 10, 20, 30, 40, 50. Each like adds edges to all
|
|
// prior liked items. record_positive call #k corresponds to liking item k*10.
|
|
let items = [10u64, 20, 30, 40, 50];
|
|
// K = 3 complete record_positive calls (likes of 10, 20, 30), crash on the
|
|
// 4th call (like of 40). The 4th call's edges (40->10, 40->20, 40->30) are
|
|
// inserted BEFORE the crash point, so they survive too.
|
|
let crash_after = 3u64;
|
|
|
|
{
|
|
let db = open_db(dir.path(), &schema);
|
|
for &id in &items {
|
|
db.write_item_with_metadata(EntityId::new(id), &item_metadata("jazz", 100))
|
|
.unwrap();
|
|
}
|
|
|
|
let injector = CrashInjector::new(CrashPoint::CoEngagementUpdate, crash_after);
|
|
let ts = Timestamp::now();
|
|
let outcome = run_with_crash(&injector, || {
|
|
for &id in &items {
|
|
db.signal_with_context("like", EntityId::new(id), 1.0, ts, Some(user), Some(100))
|
|
.unwrap();
|
|
}
|
|
drop(db);
|
|
});
|
|
|
|
assert_eq!(
|
|
outcome,
|
|
Err(CrashPoint::CoEngagementUpdate),
|
|
"injector must fire at CoEngagementUpdate"
|
|
);
|
|
assert!(injector.has_fired(), "injector should have fired");
|
|
}
|
|
|
|
// Reopen: edges from calls 1..=K+1 must survive. The crash fires on call
|
|
// K+1 (like of item items[K]) AFTER its edges are inserted, so edges out of
|
|
// items[K] back to items[0..K] are durable; edges out of items[K+1..] are not.
|
|
{
|
|
let db = open_db(dir.path(), &schema);
|
|
let co_eng = db.co_engagement();
|
|
|
|
let crash_idx = usize::try_from(crash_after).unwrap(); // index of the crashing like
|
|
// Every directed edge (items[j] -> items[i]) for i < j and j <= crash_idx
|
|
// must have weight 1.0 (each pair co-engaged exactly once).
|
|
for j in 1..=crash_idx {
|
|
for i in 0..j {
|
|
let score = co_eng.score(EntityId::new(items[j]), EntityId::new(items[i]));
|
|
assert!(
|
|
(score - 1.0).abs() < f32::EPSILON,
|
|
"edge ({}->{}) from acked/crashing like must be 1.0; got {score}",
|
|
items[j],
|
|
items[i]
|
|
);
|
|
// Asymmetric: reverse edge must be absent (no phantom edge).
|
|
let reverse = co_eng.score(EntityId::new(items[i]), EntityId::new(items[j]));
|
|
assert!(
|
|
(reverse - 0.0).abs() < f32::EPSILON,
|
|
"reverse edge ({}->{}) must be 0.0 (asymmetric); got {reverse}",
|
|
items[i],
|
|
items[j]
|
|
);
|
|
}
|
|
}
|
|
|
|
// Likes after the crash never ran, so their edges must be absent.
|
|
for j in (crash_idx + 1)..items.len() {
|
|
for i in 0..j {
|
|
let score = co_eng.score(EntityId::new(items[j]), EntityId::new(items[i]));
|
|
assert!(
|
|
(score - 0.0).abs() < f32::EPSILON,
|
|
"edge ({}->{}) from a never-run like must be absent; got {score}",
|
|
items[j],
|
|
items[i]
|
|
);
|
|
}
|
|
}
|
|
|
|
db.close().unwrap();
|
|
}
|
|
}
|
|
|
|
// ── Test 4: CheckpointPostFlush crash ────────────────────────────────────────
|
|
//
|
|
// Arms `CrashPoint::CheckpointPostFlush` (src/signals/checkpoint/mod.rs:118).
|
|
// This point fires AFTER `write_batch` + `flush`, so the signal checkpoint is
|
|
// already fully durable on disk when the crash occurs. We write a full M6
|
|
// workload (signals + a collection), then `close()` — which runs the final
|
|
// checkpoint and crashes right after the signal-ledger flush.
|
|
//
|
|
// ## Why the signal surface recovers with at-least-once (not exactly-once)
|
|
//
|
|
// `shutdown_inner` flushes the signal checkpoint FIRST, then (later, after the
|
|
// crash point) writes the WAL checkpoint *marker* that fences WAL replay. The
|
|
// crash fires between those two steps, so the marker for this close is never
|
|
// written. On reopen, `db/open.rs:172-187` restores the durable checkpoint
|
|
// (which captured every view: count == views_per_item) AND then replays the WAL
|
|
// events that post-date the *previous* marker — i.e. the same view events — via
|
|
// `apply_wal_event`, re-adding them on top. This is the documented checkpoint +
|
|
// WAL overlap contract (see `m7_crash_property.rs` Test 2): the signal ledger
|
|
// guarantees **no acked write is lost**, with at-most-double overlap when a
|
|
// checkpoint is interrupted before its WAL marker lands. It does NOT promise
|
|
// exactly-once across an interrupted checkpoint — asserting that would be the
|
|
// wrong invariant. We therefore bound the signal surface no-lost + at-most-2x
|
|
// and assert exact recovery only for the collection surface, which is persisted
|
|
// per-add independently of the signal checkpoint/WAL and so is unaffected.
|
|
|
|
#[test]
|
|
fn checkpoint_post_flush_crash_recovers_durable_state() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let schema = m6_schema();
|
|
let owner = EntityId::new(1);
|
|
|
|
let item_count = 6u64;
|
|
let views_per_item = 3u64;
|
|
|
|
{
|
|
let db = open_db(dir.path(), &schema);
|
|
|
|
for id in 1..=item_count {
|
|
db.write_item_with_metadata(EntityId::new(id), &item_metadata("tech", 100))
|
|
.unwrap();
|
|
}
|
|
|
|
// Signal-ledger surface: deterministic per-item view counts.
|
|
let ts = Timestamp::now();
|
|
for id in 1..=item_count {
|
|
for _ in 0..views_per_item {
|
|
db.signal("view", EntityId::new(id), 1.0, ts).unwrap();
|
|
}
|
|
}
|
|
|
|
// Collection surface (persisted per-add, independent of signal checkpoint).
|
|
let cid = db
|
|
.create_collection(owner, "best-of", Visibility::Public)
|
|
.unwrap();
|
|
for id in [1u64, 3, 5] {
|
|
db.add_to_collection(cid, EntityId::new(id)).unwrap();
|
|
}
|
|
|
|
// Crash AFTER the signal checkpoint's flush completes. close() consumes
|
|
// the db and runs shutdown_inner -> ledger.checkpoint -> flush -> fire.
|
|
// The flush already persisted the checkpoint, so reopen must recover it.
|
|
let injector = CrashInjector::new(CrashPoint::CheckpointPostFlush, 0);
|
|
let outcome = run_with_crash(&injector, move || {
|
|
let _ = db.close();
|
|
});
|
|
|
|
assert_eq!(
|
|
outcome,
|
|
Err(CrashPoint::CheckpointPostFlush),
|
|
"injector must fire at CheckpointPostFlush"
|
|
);
|
|
assert!(injector.has_fired(), "injector should have fired");
|
|
}
|
|
|
|
// Reopen: durable checkpoint yields no-lost signal counts (with bounded
|
|
// checkpoint+WAL overlap); collection surface recovers exactly.
|
|
{
|
|
let db = open_db(dir.path(), &schema);
|
|
|
|
for id in 1..=item_count {
|
|
let count = db
|
|
.read_windowed_count(EntityId::new(id), "view", Window::AllTime)
|
|
.unwrap();
|
|
// No-lost: the durable checkpoint captured every acked view.
|
|
assert!(
|
|
count >= views_per_item,
|
|
"item {id}: post-flush crash lost acked views — expected >= {views_per_item}; got {count}"
|
|
);
|
|
// Bounded overlap: an interrupted checkpoint (marker not yet written)
|
|
// replays the same WAL events once on top — at most 2x, never runaway.
|
|
assert!(
|
|
count <= 2 * views_per_item,
|
|
"item {id}: checkpoint+WAL overlap exceeded the 2x bound — \
|
|
expected <= {}; got {count}",
|
|
2 * views_per_item
|
|
);
|
|
let score = db.read_decay_score(EntityId::new(id), "view", 0).unwrap();
|
|
assert!(score.is_some(), "item {id} decay score must survive");
|
|
let score = score.unwrap();
|
|
assert!(
|
|
score.is_finite() && score > 0.0,
|
|
"item {id} score must be finite + positive: {score}"
|
|
);
|
|
}
|
|
|
|
let collections = db.list_collections(owner).unwrap();
|
|
assert_eq!(
|
|
collections.len(),
|
|
1,
|
|
"collection must survive post-flush crash"
|
|
);
|
|
let col = &collections[0];
|
|
assert_eq!(col.name, "best-of");
|
|
assert_eq!(col.item_count, 3, "collection must recover all 3 items");
|
|
let index = db.collection_index();
|
|
for id in [1u64, 3, 5] {
|
|
assert!(
|
|
index.contains(col.id, u32::try_from(id).unwrap()),
|
|
"collection item {id} must survive"
|
|
);
|
|
}
|
|
assert!(
|
|
!index.contains(col.id, 2),
|
|
"item 2 was never added — must not appear"
|
|
);
|
|
|
|
db.close().unwrap();
|
|
}
|
|
}
|