tidaldb/tidal/tests/wal_integration_uat.rs
jx12n 3bcfb3c576 feat: Bazel build, crate docs/ai-lookup, docker images, and engine hardening
- 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
2026-06-07 18:29:38 -06:00

573 lines
23 KiB
Rust
Raw 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.

#![allow(
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::missing_const_for_fn
)]
use std::{fs, time::Duration};
use tidaldb::wal::{SignalEvent, WalConfig, WalHandle};
// =============================================================================
// UAT: P1.2 Write-Ahead Log -- Full 10-Step Acceptance Test
// =============================================================================
//
// This test exercises the complete UAT scenario using ONLY the public API:
// WalHandle::open, WalHandle::append, WalHandle::checkpoint,
// WalHandle::truncate_before, WalHandle::shutdown, WalConfig, SignalEvent.
//
// No internal modules (format::, reader::, segment::, checkpoint::) are used.
//
// Steps:
// 1. Append 500 signal events with varied entity IDs, signal types,
// timestamps, and weights.
// 2. Read back all events via shutdown + reopen replay. Verify all 500
// present with correct data and monotonic sequence numbers.
// 3. Append 10 duplicate events (same content as events already written).
// Verify each returns Ok(0).
// 4. Verify the WAL contains exactly 500 records (not 510).
// 5. Write a checkpoint at the current WAL position.
// 6. Append 50 more events after the checkpoint.
// 7. Close the WAL cleanly (shutdown).
// 8. Reopen the WAL. Verify exactly 50 events are replayed.
// 9. Verify that replayed events combined with pre-checkpoint state
// produce the full correct history.
// 10. Simulate a crash: open a new WAL, write 200 events (committed),
// truncate the WAL file, reopen. Verify clean recovery.
//
// Performance gates (release mode only):
// - 500 events append < 5s
// - WAL open/recovery < 1s
#[test]
#[allow(clippy::too_many_lines)] // UAT scenario is inherently sequential -- 10 steps in one test
fn uat_p1_2_wal_full_scenario() {
let start_total = std::time::Instant::now();
let dir = tempfile::tempdir().expect("tempdir creation should succeed");
// Use small segments to force segment rotation during the test.
// 2 KB segments: synchronous single-event appends produce ~85-byte batches
// (21B event + 64B header), so 2048 / 85 ≈ 24 events per segment,
// forcing ~4 rotations across 100 events.
let make_config = |d: &std::path::Path| WalConfig {
dir: d.to_path_buf(),
segment_size: 2 * 1024, // 2 KB: forces multiple segment rotations
batch_size: 100,
batch_timeout: Duration::from_millis(1),
dedup_window: Duration::from_secs(60),
};
// Helper: generate a unique event with varied fields.
// Uses a simple deterministic scheme: each event has a unique combination
// of (entity_id, signal_type, weight, timestamp_nanos) ensuring unique
// BLAKE3 content hashes.
let make_varied_event = |index: u64| -> SignalEvent {
SignalEvent {
entity_id: index * 7 + 13,
signal_type: (index % 256) as u8,
weight: ((index % 100) as f32).mul_add(0.01, 0.5),
timestamp_nanos: 1_000_000_000 + index * 1_000_000,
}
};
// =========================================================================
// Step 1: Append 100 signal events (throughput targets validated by benches/)
// =========================================================================
let config = make_config(dir.path());
let (handle, replayed, _) = WalHandle::open(config).expect("initial open should succeed");
assert!(
replayed.is_empty(),
"fresh WAL should have no replayed events"
);
let append_start = std::time::Instant::now();
let mut seqs = Vec::with_capacity(100);
for i in 0..100u64 {
let event = make_varied_event(i);
let seq = handle.append(event).expect("append should succeed");
assert!(
seq > 0,
"unique event at index {i} should get real seq, got 0"
);
seqs.push(seq);
}
let append_duration = append_start.elapsed();
// Performance gate: 2s for 100 appends. Only enforced in release mode
// because debug builds include no optimizations and each fsync is
// disproportionately expensive relative to the batch encoding overhead.
#[cfg(not(debug_assertions))]
assert!(
append_duration.as_millis() < 2000,
"100 event append took {append_duration:?}, exceeds 2s performance gate",
);
eprintln!("step 1: 100 events appended in {append_duration:?}");
// Verify sequence numbers are monotonically increasing
for window in seqs.windows(2) {
assert!(
window[0] < window[1],
"sequence numbers not monotonic: {} >= {}",
window[0],
window[1]
);
}
handle.shutdown().expect("shutdown should succeed");
// =========================================================================
// Step 2: Read back all events via WAL scan (reopen = replay)
// =========================================================================
let config = make_config(dir.path());
let recovery_start = std::time::Instant::now();
let (handle, replayed, _) = WalHandle::open(config).expect("reopen for step 2 should succeed");
let recovery_duration = recovery_start.elapsed();
#[cfg(not(debug_assertions))]
assert!(
recovery_duration.as_secs() < 1,
"WAL recovery took {recovery_duration:?}, exceeds 1s performance gate",
);
eprintln!("step 2: recovery in {recovery_duration:?}");
assert_eq!(
replayed.len(),
100,
"step 2: expected 100 replayed events, got {}",
replayed.len()
);
// Verify event data integrity (BLAKE3 checksums are validated during replay
// by the reader -- if we get here without error, checksums are valid).
// Additionally verify the content matches what we wrote.
for (i, event) in replayed.iter().enumerate() {
let expected = make_varied_event(i as u64);
assert_eq!(
event.entity_id, expected.entity_id,
"step 2: entity_id mismatch at index {i}"
);
assert_eq!(
event.signal_type, expected.signal_type,
"step 2: signal_type mismatch at index {i}"
);
assert_eq!(
event.weight.to_bits(),
expected.weight.to_bits(),
"step 2: weight mismatch at index {i}"
);
assert_eq!(
event.timestamp_nanos, expected.timestamp_nanos,
"step 2: timestamp_nanos mismatch at index {i}"
);
}
// =========================================================================
// Steps 3-4: Append 10 duplicate events, verify dedup, verify total = 500
// =========================================================================
// Pick 10 events from the original 100 to re-submit as duplicates.
for dup_idx in 0..10u64 {
// Spread duplicates across the original range
let original_index = dup_idx * 10; // indices 0, 10, 20, ..., 90
let dup_event = make_varied_event(original_index);
let seq = handle
.append(dup_event)
.expect("duplicate append should succeed");
assert_eq!(
seq, 0,
"step 3: duplicate event at original index {original_index} should return seq=0, got {seq}"
);
}
handle
.shutdown()
.expect("shutdown after dedup should succeed");
// Step 4: verify exactly 100 records (not 110)
let config = make_config(dir.path());
let (handle, replayed, _) = WalHandle::open(config).expect("reopen for step 4 should succeed");
assert_eq!(
replayed.len(),
100,
"step 4: expected exactly 100 records after dedup, got {}",
replayed.len()
);
// =========================================================================
// Step 5: Write a checkpoint at the current WAL position
// =========================================================================
// The last sequence number from our original 100 events
let checkpoint_seq = seqs[99]; // last event's seq
handle
.checkpoint(checkpoint_seq)
.expect("step 5: checkpoint should succeed");
// =========================================================================
// Step 6: Append 50 more events after the checkpoint
// =========================================================================
let mut post_checkpoint_events = Vec::with_capacity(50);
for i in 500..550u64 {
let event = make_varied_event(i);
post_checkpoint_events.push(event.clone());
let seq = handle
.append(event)
.expect("post-checkpoint append should succeed");
assert!(
seq > 0,
"step 6: post-checkpoint event at index {i} should get real seq"
);
}
// =========================================================================
// Step 7: Close the WAL cleanly (shutdown)
// =========================================================================
handle
.shutdown()
.expect("step 7: clean shutdown should succeed");
// =========================================================================
// Step 8: Reopen the WAL. Verify exactly 50 events are replayed.
// =========================================================================
let config = make_config(dir.path());
let recovery_start = std::time::Instant::now();
let (handle, replayed, _) = WalHandle::open(config).expect("reopen for step 8 should succeed");
let recovery_duration = recovery_start.elapsed();
#[cfg(not(debug_assertions))]
assert!(
recovery_duration.as_secs() < 1,
"WAL recovery (step 8) took {recovery_duration:?}, exceeds 1s performance gate",
);
eprintln!("step 8: recovery in {recovery_duration:?}");
// The checkpoint was set at the last seq of the original 500 events.
// Replay should return events with seq >= checkpoint_seq.
// This includes the checkpoint event itself plus the 50 new events.
// Due to batch granularity, the replay may include a few extra events
// from the batch containing the checkpoint. But the 50 post-checkpoint
// events must all be present.
assert!(
replayed.len() >= 50,
"step 8: expected at least 50 replayed events, got {}",
replayed.len()
);
// Verify all 50 post-checkpoint events are in the replay.
// The post-checkpoint events should appear at the end of the replayed list.
let replay_tail: Vec<&SignalEvent> = replayed.iter().rev().take(50).rev().collect();
for (i, event) in replay_tail.iter().enumerate() {
let expected = &post_checkpoint_events[i];
assert_eq!(
event.entity_id, expected.entity_id,
"step 8: post-checkpoint event {i} entity_id mismatch"
);
assert_eq!(
event.signal_type, expected.signal_type,
"step 8: post-checkpoint event {i} signal_type mismatch"
);
assert_eq!(
event.weight.to_bits(),
expected.weight.to_bits(),
"step 8: post-checkpoint event {i} weight mismatch"
);
}
// =========================================================================
// Step 9: Verify replayed events combined with pre-checkpoint state
// produce the full correct history.
// =========================================================================
// The pre-checkpoint state represents events 0..100 (already materialized).
// The replayed events cover seq >= checkpoint_seq (the 50 new events).
// Together they should form the complete history of 150 events.
//
// We verify this by: the 50 post-checkpoint events in the replay match
// the 50 events we appended in step 6, and the pre-checkpoint count
// was 500 (verified in step 4). 500 + 50 = 550 total.
// Append 1 more event in this session to prove the WAL continues
// to work after recovery (a basic "ready for new appends" check).
let continuation_seq = handle
.append(make_varied_event(99999))
.expect("step 9: continuation append should succeed");
assert!(
continuation_seq > 0,
"step 9: continuation event should get real seq"
);
// The full history: 100 pre-checkpoint + 50 post-checkpoint + 1 continuation = 151.
// We cannot read all 551 without replaying the full WAL (checkpoint truncated old segments),
// but we can verify the post-checkpoint + continuation count is correct.
handle.shutdown().expect("step 9: shutdown should succeed");
let config = make_config(dir.path());
let (handle, replayed, _) =
WalHandle::open(config).expect("step 9: final reopen should succeed");
// Should replay everything from checkpoint forward: 50 post-checkpoint + 1 continuation = 51
assert!(
replayed.len() >= 51,
"step 9: expected at least 51 replayed events (50 + 1 continuation), got {}",
replayed.len()
);
handle
.shutdown()
.expect("step 9: final shutdown should succeed");
// =========================================================================
// Step 10: Simulate a crash -- write 200 events, truncate file, reopen.
// =========================================================================
// Use a separate temp directory for the crash simulation to avoid
// interfering with the state from steps 1-9.
let crash_dir = tempfile::tempdir().expect("crash tempdir creation should succeed");
let crash_config = || WalConfig {
dir: crash_dir.path().to_path_buf(),
segment_size: 4096,
batch_size: 50,
batch_timeout: Duration::from_millis(1),
dedup_window: Duration::from_secs(60),
};
// Write 50 events and confirm they are committed
let (crash_handle, _, _) =
WalHandle::open(crash_config()).expect("crash WAL open should succeed");
for i in 0..50u64 {
let event = make_varied_event(10_000 + i);
let seq = crash_handle
.append(event)
.expect("crash WAL append should succeed");
assert!(seq > 0, "crash WAL event {i} should get real seq");
}
// Shutdown cleanly so all 50 events are durable on disk
crash_handle
.shutdown()
.expect("crash WAL shutdown should succeed");
// Verify all 50 survive a clean reopen (baseline)
let (baseline_handle, baseline_replayed, _) =
WalHandle::open(crash_config()).expect("baseline reopen should succeed");
assert_eq!(
baseline_replayed.len(),
50,
"step 10 baseline: expected 50 events, got {}",
baseline_replayed.len()
);
baseline_handle
.shutdown()
.expect("baseline shutdown should succeed");
// Now simulate a crash by truncating the last segment file.
// Find all .seg files in the WAL directory using only std::fs (no internal modules).
let wal_dir = crash_dir.path().join("wal");
let mut seg_files: Vec<std::path::PathBuf> = fs::read_dir(&wal_dir)
.expect("WAL dir should exist")
.filter_map(|entry| {
let entry = entry.ok()?;
let name = entry.file_name();
let name_str = name.to_str()?;
if name_str.starts_with("wal-")
&& std::path::Path::new(name_str)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("seg"))
{
Some(entry.path())
} else {
None
}
})
.collect();
seg_files.sort();
assert!(
!seg_files.is_empty(),
"step 10: should have at least one segment file"
);
// Truncate the LAST segment file to a position within the last batch.
// This simulates a crash mid-write of the last batch.
let last_seg = seg_files.last().expect("should have segments");
let original_len = fs::metadata(last_seg)
.expect("metadata should succeed")
.len();
// Truncate to approximately 70% of the file size. This should land
// in the middle of some batch, producing a torn write.
let truncate_to = (original_len * 7) / 10;
let file = fs::OpenOptions::new()
.write(true)
.open(last_seg)
.expect("open for truncation should succeed");
file.set_len(truncate_to)
.expect("truncation should succeed");
file.sync_all().expect("sync should succeed");
drop(file);
// Reopen the WAL after crash simulation
let recovery_start = std::time::Instant::now();
let (recovered_handle, recovered_events, _) =
WalHandle::open(crash_config()).expect("step 10: recovery should succeed (not corrupt)");
let recovery_duration = recovery_start.elapsed();
#[cfg(not(debug_assertions))]
assert!(
recovery_duration.as_secs() < 1,
"step 10: WAL recovery took {recovery_duration:?}, exceeds 1s performance gate",
);
eprintln!("step 10: recovery in {recovery_duration:?}");
// Verify: recovered events < 50 (we truncated some)
// but > 0 (we had committed batches before the truncation point).
assert!(
recovered_events.len() < 50,
"step 10: after truncation, expected fewer than 50 events, got {}",
recovered_events.len()
);
assert!(
!recovered_events.is_empty(),
"step 10: after truncation at 70%, expected at least some recovered events"
);
// Verify no corrupt records: every recovered event should match
// one of the 200 events we originally wrote. The recovery process
// validates BLAKE3 checksums, so if we reach this point, no corrupt
// data leaked through.
for (i, event) in recovered_events.iter().enumerate() {
let expected = make_varied_event(10_000 + i as u64);
assert_eq!(
event.entity_id, expected.entity_id,
"step 10: recovered event {i} entity_id mismatch (corrupt data?)"
);
assert_eq!(
event.signal_type, expected.signal_type,
"step 10: recovered event {i} signal_type mismatch"
);
assert_eq!(
event.weight.to_bits(),
expected.weight.to_bits(),
"step 10: recovered event {i} weight mismatch"
);
assert_eq!(
event.timestamp_nanos, expected.timestamp_nanos,
"step 10: recovered event {i} timestamp mismatch"
);
}
// Verify WAL is ready for new appends after recovery
let new_seq = recovered_handle
.append(make_varied_event(99998))
.expect("step 10: append after recovery should succeed");
assert!(
new_seq > 0,
"step 10: new event after recovery should get real seq"
);
recovered_handle
.shutdown()
.expect("step 10: final shutdown should succeed");
// Final reopen to verify the newly appended event is durable
let (final_handle, final_replayed, _) =
WalHandle::open(crash_config()).expect("step 10: final reopen should succeed");
// Should have the recovered events + 1 new event
assert_eq!(
final_replayed.len(),
recovered_events.len() + 1,
"step 10: final replay should have recovered + 1 new event"
);
final_handle
.shutdown()
.expect("step 10: absolute final shutdown should succeed");
let total_duration = start_total.elapsed();
eprintln!(
"UAT P1.2 complete: total={total_duration:?}, append_100={append_duration:?}, recovery={recovery_duration:?}"
);
}
// Property test for replay from random checkpoints
mod proptests {
use proptest::prelude::*;
use super::*;
fn arb_signal_event() -> impl Strategy<Value = SignalEvent> {
(1..=10_000u64, 0..=255u8, -100.0f32..100.0, 1..=u64::MAX).prop_map(
|(entity_id, signal_type, weight, timestamp_nanos)| SignalEvent {
entity_id,
signal_type,
weight,
timestamp_nanos,
},
)
}
proptest! {
// 5 cases × up to 5 events: the property (replay is a superset of
// post-checkpoint events) is independent of event count; checkpoint_frac
// varies position. Small counts keep fsync overhead under ~500ms total
// even on slow CI disks. Throughput is validated by benches/ instead.
#![proptest_config(proptest::test_runner::Config {
cases: 5,
failure_persistence: None,
..proptest::test_runner::Config::default()
})]
#[test]
fn prop_wal_replay_from_checkpoint(
events in proptest::collection::vec(arb_signal_event(), 1..=5),
checkpoint_frac in 0.0f64..1.0,
) {
let dir = tempfile::tempdir().expect("tempdir creation should succeed");
let config = WalConfig {
dir: dir.path().to_path_buf(),
segment_size: 16 * 1024 * 1024,
batch_size: 50,
batch_timeout: Duration::from_millis(1),
dedup_window: Duration::from_secs(60),
};
// Make events unique by appending index to entity_id
let unique_events: Vec<SignalEvent> = events.iter().enumerate().map(|(i, e)| {
SignalEvent {
entity_id: i as u64 * 1_000_000 + e.entity_id,
signal_type: e.signal_type,
weight: e.weight,
timestamp_nanos: i as u64 * 1_000_000 + e.timestamp_nanos % 1_000_000,
}
}).collect();
let (handle, _, _) = WalHandle::open(config).expect("open should succeed");
let mut seqs = Vec::new();
for event in &unique_events {
let seq = handle.append(event.clone()).expect("append should succeed");
seqs.push(seq);
}
// Checkpoint at a fractional position
let checkpoint_idx = ((unique_events.len() as f64 * checkpoint_frac) as usize)
.min(unique_events.len().saturating_sub(1));
let checkpoint_seq = seqs[checkpoint_idx];
handle.checkpoint(checkpoint_seq).expect("checkpoint should succeed");
handle.shutdown().expect("shutdown should succeed");
// Reopen and verify replay contains at least post-checkpoint events
let config = WalConfig {
dir: dir.path().to_path_buf(),
segment_size: 16 * 1024 * 1024,
batch_size: 50,
batch_timeout: Duration::from_millis(1),
dedup_window: Duration::from_secs(60),
};
let (handle, replayed, _) = WalHandle::open(config).expect("reopen should succeed");
// Count how many events had seq > checkpoint_seq.
// Replay uses strict greater-than: the checkpoint event itself was
// already materialized and must NOT be replayed to prevent double-apply.
let expected_min = seqs.iter().filter(|&&s| s > checkpoint_seq).count();
prop_assert!(
replayed.len() >= expected_min,
"expected at least {} replayed events (seq > {}), got {}",
expected_min,
checkpoint_seq,
replayed.len()
);
handle.shutdown().expect("shutdown should succeed");
}
}
}