- Eliminate the tidal/ self-contained doc mirror; docs now have two canonical homes (root *.md and docs/), with planning/specs/research/reviews moved up - Remove stale .agents/skills and .ai mirrors; canonicalize skills under .claude/ - Add pre-commit hook + scripts/check-docs.sh doc-guard + scripts/install-hooks.sh - Implement M0-M10 seven-dimension review findings across engine, net, server, and tidalctl (durability, replication, query, WAL, storage, CLI hardening)
161 lines
5.0 KiB
Rust
161 lines
5.0 KiB
Rust
#![allow(
|
|
clippy::too_many_lines,
|
|
clippy::cast_precision_loss,
|
|
clippy::cast_sign_loss,
|
|
clippy::missing_const_for_fn
|
|
)]
|
|
|
|
use std::{fs, time::Duration};
|
|
|
|
use tidaldb::{
|
|
replication::ShardId,
|
|
wal::{
|
|
SignalEvent, WalConfig, WalHandle,
|
|
format::{self, EventRecord, HEADER_SIZE},
|
|
reader, segment,
|
|
},
|
|
};
|
|
|
|
fn test_config(dir: &std::path::Path) -> WalConfig {
|
|
WalConfig {
|
|
wal_dir_override: None,
|
|
dir: dir.to_path_buf(),
|
|
segment_size: 16 * 1024 * 1024,
|
|
batch_size: 100,
|
|
batch_timeout: Duration::from_millis(1),
|
|
dedup_window: Duration::from_secs(30),
|
|
}
|
|
}
|
|
|
|
fn make_event(id: u64) -> SignalEvent {
|
|
SignalEvent {
|
|
entity_id: id,
|
|
signal_type: 1,
|
|
weight: 1.0,
|
|
timestamp_nanos: id * 1_000_000_000,
|
|
}
|
|
}
|
|
|
|
// -- AC-13, AC-14: Crash recovery with torn write
|
|
#[test]
|
|
fn wal_crash_recovery_torn_write() {
|
|
let dir = tempfile::tempdir().expect("tempdir creation should succeed");
|
|
let wal_dir = dir.path().join("wal");
|
|
fs::create_dir_all(&wal_dir).expect("create dir should succeed");
|
|
|
|
// Write valid batches directly to simulate a crash mid-write
|
|
let events1: Vec<EventRecord> = (1..=5)
|
|
.map(|i| EventRecord::signal(i, 1, 1.0, i * 1_000_000_000))
|
|
.collect();
|
|
|
|
let events2: Vec<EventRecord> = (6..=10)
|
|
.map(|i| EventRecord::signal(i, 1, 1.0, i * 1_000_000_000))
|
|
.collect();
|
|
|
|
let batch1 = format::encode_batch(&events1, 1, 1_000_000_000).expect("encode should succeed");
|
|
let batch2 = format::encode_batch(&events2, 6, 6_000_000_000).expect("encode should succeed");
|
|
|
|
// Write batch1 fully, then truncate batch2 at various offsets
|
|
for truncate_at in [
|
|
0,
|
|
10,
|
|
32,
|
|
63,
|
|
HEADER_SIZE,
|
|
HEADER_SIZE + 5,
|
|
HEADER_SIZE + 20,
|
|
] {
|
|
let seg_name = segment::segment_filename(ShardId::SINGLE, 1);
|
|
let seg_path = wal_dir.join(&seg_name);
|
|
|
|
let mut data = batch1.clone();
|
|
if truncate_at > 0 {
|
|
data.extend_from_slice(&batch2[..truncate_at.min(batch2.len())]);
|
|
}
|
|
fs::write(&seg_path, &data).expect("write should succeed");
|
|
|
|
let recovery = reader::recover(&wal_dir).expect("recovery should succeed");
|
|
assert_eq!(
|
|
recovery.events.len(),
|
|
5,
|
|
"torn write at offset {truncate_at}: should recover 5 events"
|
|
);
|
|
|
|
// Clean up for next iteration
|
|
fs::remove_file(&seg_path).expect("cleanup should succeed");
|
|
}
|
|
}
|
|
|
|
// -- AC-15: No phantom records (clean shutdown variant)
|
|
#[test]
|
|
fn wal_clean_shutdown_no_data_loss() {
|
|
let dir = tempfile::tempdir().expect("tempdir creation should succeed");
|
|
let config = test_config(dir.path());
|
|
|
|
// Write 5 events
|
|
let (handle, _, _) = WalHandle::open(config).expect("open should succeed");
|
|
for i in 1..=5 {
|
|
handle.append(make_event(i)).expect("append should succeed");
|
|
}
|
|
handle.shutdown().expect("shutdown should succeed");
|
|
|
|
// Verify exactly 5 events on replay
|
|
let config = test_config(dir.path());
|
|
let (handle, replayed, _) = WalHandle::open(config).expect("reopen should succeed");
|
|
assert_eq!(
|
|
replayed.len(),
|
|
5,
|
|
"should replay exactly 5 events, not more"
|
|
);
|
|
|
|
// No phantom events (events from un-fsynced batches should not appear)
|
|
for event in &replayed {
|
|
assert!(
|
|
event.entity_id >= 1 && event.entity_id <= 5,
|
|
"unexpected entity_id {}",
|
|
event.entity_id
|
|
);
|
|
}
|
|
handle.shutdown().expect("shutdown should succeed");
|
|
}
|
|
|
|
// -- AC-16: Crash at any byte position never produces corrupt state
|
|
#[test]
|
|
fn wal_crash_at_any_byte_position() {
|
|
let dir = tempfile::tempdir().expect("tempdir creation should succeed");
|
|
let wal_dir = dir.path().join("wal");
|
|
fs::create_dir_all(&wal_dir).expect("create dir should succeed");
|
|
|
|
let events: Vec<EventRecord> = (1..=3)
|
|
.map(|i| EventRecord::signal(i, 1, 1.0, i * 1_000_000_000))
|
|
.collect();
|
|
let batch = format::encode_batch(&events, 1, 1_000_000_000).expect("encode should succeed");
|
|
|
|
// Test truncation at every byte offset
|
|
for truncate_at in 0..=batch.len() {
|
|
let seg_name = segment::segment_filename(ShardId::SINGLE, 1);
|
|
let seg_path = wal_dir.join(&seg_name);
|
|
|
|
fs::write(&seg_path, &batch[..truncate_at]).expect("write should succeed");
|
|
|
|
let recovery = reader::recover(&wal_dir).expect("recovery should never fail");
|
|
|
|
if truncate_at == batch.len() {
|
|
assert_eq!(
|
|
recovery.events.len(),
|
|
3,
|
|
"full batch should recover 3 events"
|
|
);
|
|
} else {
|
|
assert_eq!(
|
|
recovery.events.len(),
|
|
0,
|
|
"truncated at byte {truncate_at}: no events should be recovered"
|
|
);
|
|
}
|
|
|
|
// Clean up for next iteration
|
|
fs::remove_file(&seg_path).expect("cleanup should succeed");
|
|
}
|
|
}
|