//! Write-Ahead Log for signal event durability. //! //! The WAL is the durability primitive for signal events. Every view, like, //! skip, and completion is appended to the WAL before any aggregation occurs. //! Signal aggregates, decay scores, and windowed counts are derived state //! that can always be rebuilt from WAL replay. //! //! # Architecture //! //! - **Batch-oriented**: events are grouped into batches (up to 256 events) //! and written as a single atomic unit with one BLAKE3 checksum and one fsync. //! - **Group commit**: a dedicated writer thread accumulates events from //! concurrent callers, forming batches by count or timeout. //! - **Segment files**: the WAL is split into 16 MB segment files for //! efficient truncation after checkpointing. //! - **Deduplication**: a double-buffered `HashSet` detects duplicate //! events within a configurable time window. //! - **Crash recovery**: two-phase validation (magic + bounds, then BLAKE3) //! with automatic truncation of corrupted tails. pub mod checkpoint; pub mod compaction; pub mod config; pub mod dedup; pub mod diagnostics; pub mod error; pub mod format; pub mod reader; pub mod segment; pub mod session_journal; pub mod writer; use std::{fs, path::PathBuf}; pub use config::WalConfig; use crossbeam::channel::{Sender, bounded}; use self::{ dedup::DedupWindow, error::WalError, format::{EventRecord, SessionWalEvent}, segment::SegmentWriter, session_journal::SessionJournal, writer::{WalCommand, WriterConfig}, }; use crate::replication::{RegionId, ShardId}; /// Default channel capacity for the writer command channel. const DEFAULT_CHANNEL_CAPACITY: usize = 10_000; /// A signal event to be appended to the WAL. /// /// This is the public write type. It maps 1:1 to the internal /// `EventRecord` wire format. #[derive(Debug, Clone, PartialEq)] pub struct SignalEvent { pub entity_id: u64, pub signal_type: u8, pub weight: f32, pub timestamp_nanos: u64, } impl From for EventRecord { fn from(e: SignalEvent) -> Self { // A bare `SignalEvent` is a default-scope (local) write; the WAL v3 // governance envelope defaults to zero. Scoped writes construct the // `EventRecord` directly via `EventRecord::scoped` and bypass this path. Self::signal(e.entity_id, e.signal_type, e.weight, e.timestamp_nanos) } } impl From for SignalEvent { fn from(e: EventRecord) -> Self { Self { entity_id: e.entity_id, signal_type: e.signal_type, weight: e.weight, timestamp_nanos: e.timestamp_nanos, } } } /// A cloneable, `Send + Sync` sender for WAL append operations. /// /// Created by [`WalHandle::sender()`]. Allows WAL append to be called /// from components (e.g. `WalHandleWriter`) that must be `Send + Sync` /// without sharing the full `WalHandle` (which owns the writer thread). #[derive(Clone)] pub struct WalSender { tx: Sender, } impl WalSender { /// Append a signal event. Blocks until the batch containing this event /// has been durably fsynced to disk. /// /// Returns the assigned monotonic sequence number. /// Returns `Ok(0)` if the event was deduplicated. /// /// # Errors /// /// Returns `WalError::SendFailed` if the writer thread has panicked. pub fn append(&self, event: SignalEvent) -> Result { let (reply_tx, reply_rx) = bounded(1); self.tx .send(WalCommand::Append { event: event.into(), reply: reply_tx, }) .map_err(|_| WalError::SendFailed)?; reply_rx.recv().map_err(|_| WalError::SendFailed)? } /// Append a pre-built [`EventRecord`], preserving its v3 governance envelope /// (scope, writer agent, share-policy version, membership epoch). /// /// This is the scoped-write counterpart to [`append`](Self::append), which /// only carries the default (local) envelope. Blocks until the batch /// containing this event has been durably fsynced. Returns the assigned /// monotonic sequence number, or `Ok(0)` if the event was deduplicated. /// /// # Errors /// /// Returns `WalError::SendFailed` if the writer thread has panicked. pub fn append_record(&self, record: EventRecord) -> Result { let (reply_tx, reply_rx) = bounded(1); self.tx .send(WalCommand::Append { event: record, reply: reply_tx, }) .map_err(|_| WalError::SendFailed)?; reply_rx.recv().map_err(|_| WalError::SendFailed)? } } /// Handle to the WAL. Provides the public API for appending events, /// checkpointing, and truncation. /// /// Internally manages a writer thread that performs group commit. /// All public methods are safe to call from multiple threads concurrently. pub struct WalHandle { tx: Sender, thread: Option>>, wal_dir: PathBuf, } impl std::fmt::Debug for WalHandle { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("WalHandle") .field("wal_dir", &self.wal_dir) .finish_non_exhaustive() } } impl WalHandle { /// Clone the channel sender for use in separate components. /// /// The returned [`WalSender`] is `Clone + Send + Sync` and can be used /// from multiple threads concurrently to append events without holding /// a reference to the full `WalHandle`. #[must_use] pub fn sender(&self) -> WalSender { WalSender { tx: self.tx.clone(), } } /// Return the number of pending commands in the writer channel. /// /// O(1) operation. Used by the backpressure check in `TidalDb::signal()` /// to detect queue saturation before enqueuing. #[must_use] pub fn channel_len(&self) -> usize { self.tx.len() } /// Open the WAL directory, recover from any crash, and return a ready handle. /// /// Returns the handle, a list of replayed signal events since the last /// checkpoint, and a list of recovered session journal events (for the /// session materializer to process). /// /// # Errors /// /// Returns `WalError` on I/O failure or unrecoverable corruption. // Config is consumed by value: fields are moved into WriterConfig for the spawned thread. #[allow(clippy::needless_pass_by_value)] pub fn open( config: WalConfig, ) -> Result<(Self, Vec, Vec), WalError> { // Validate the batch size against the wire-format limit *before* spawning // the writer thread. A `batch_size` above `MAX_EVENTS_PER_BATCH` would // make the first full batch fail to encode, terminating the writer thread // and permanently wedging every subsequent append. Reject it here so the // caller fails loudly at startup with a typed `InvalidConfig` error // instead of silently after the first full batch. writer::validate_writer_config(config.batch_size)?; let wal_dir = config.wal_dir(); fs::create_dir_all(&wal_dir)?; // Recover from any previous crash let recovery = reader::recover(&wal_dir)?; let replayed_events: Vec = recovery .events .iter() .cloned() .map(SignalEvent::from) .collect(); // Sequence 0 is reserved as the dedup sentinel (returned for duplicate events). // Real events always get seq >= 1. let next_seq = recovery.next_seq.max(1); // Recover session journal events. let session_journal_path = wal_dir.join(session_journal::SESSION_JOURNAL_FILENAME); let session_events = SessionJournal::recover(&session_journal_path)?; // Initialize dedup window from replayed events let mut dedup = DedupWindow::new(config.dedup_window); dedup.populate_from_events(recovery.events); // Open (or create) the current segment // Find the segment that should receive new writes let segments = segment::list_segments(&wal_dir)?; let segment_first_seq = if let Some((last_seg_seq, _)) = segments.last() { *last_seg_seq } else { // No segments exist yet. next_seq }; let segment = SegmentWriter::open( &wal_dir, ShardId::SINGLE, segment_first_seq, config.segment_size, )?; // Create the command channel let (tx, rx) = bounded(DEFAULT_CHANNEL_CAPACITY); let writer_config = WriterConfig { dir: wal_dir.clone(), segment_size: config.segment_size, batch_size: config.batch_size, batch_timeout: config.batch_timeout, dedup_window: config.dedup_window, session_journal_path: Some(session_journal_path), // Single-node default. Multi-shard deployments override via NodeConfig // before open() is called. shard_id: ShardId::SINGLE, region_id: RegionId::SINGLE, }; // Spawn the writer thread let thread = std::thread::Builder::new() .name("tidaldb-wal-writer".into()) .spawn(move || writer::run_writer(&rx, &writer_config, segment, next_seq, dedup)) .map_err(|e| WalError::Io(std::io::Error::other(e)))?; let segment_count = segments.len(); let replayed_count = replayed_events.len(); let session_event_count = session_events.len(); tracing::info!( segments = segment_count, replayed_events = replayed_count, session_events = session_event_count, next_seq, "wal: recovery complete" ); Ok(( Self { tx, thread: Some(thread), wal_dir, }, replayed_events, session_events, )) } /// Append a signal event. Blocks until the batch containing this event /// has been durably fsynced to disk. /// /// Returns the assigned monotonic sequence number. /// Returns `Ok(0)` if the event was deduplicated (already seen). /// /// # Errors /// /// Returns `WalError::Closed` if the WAL has been shut down. /// Returns `WalError::SendFailed` if the writer thread has panicked. pub fn append(&self, event: SignalEvent) -> Result { let (reply_tx, reply_rx) = bounded(1); self.tx .send(WalCommand::Append { event: event.into(), reply: reply_tx, }) .map_err(|_| WalError::SendFailed)?; reply_rx.recv().map_err(|_| WalError::SendFailed)? } // ── Session journal methods ──────────────────────────────────────────── // // These send fire-and-forget session commands to the writer thread. // The writer thread writes them to the session journal (separate file) // with per-write fsync. Errors are swallowed: session WAL writes are // best-effort; in-memory state is the source of truth. /// Record a session start in the session journal. /// /// # Errors /// /// Returns `WalError::SendFailed` if the writer thread has exited. pub fn session_start( &self, session_id: u64, user_id: u64, started_at_ns: u64, agent_id: &str, policy_name: &str, ) -> Result<(), WalError> { self.tx .send(WalCommand::SessionStart { session_id, user_id, started_at_ns, agent_id: agent_id.to_owned(), policy_name: policy_name.to_owned(), }) .map_err(|_| WalError::SendFailed) } /// Record a session signal in the session journal. /// /// # Errors /// /// Returns `WalError::SendFailed` if the writer thread has exited. #[allow(clippy::too_many_arguments)] pub fn session_signal( &self, session_id: u64, entity_id: u64, weight: f32, ts_ns: u64, signal_name: &str, annotation: Option<&str>, session_seqno: Option, idempotency_key: Option, ) -> Result<(), WalError> { self.tx .send(WalCommand::SessionSignal { session_id, entity_id, weight, ts_ns, signal_name: signal_name.to_owned(), annotation: annotation.map(str::to_owned), session_seqno, idempotency_key, }) .map_err(|_| WalError::SendFailed) } /// Record a session close in the session journal. /// /// # Errors /// /// Returns `WalError::SendFailed` if the writer thread has exited. pub fn session_close(&self, session_id: u64) -> Result<(), WalError> { self.tx .send(WalCommand::SessionClose { session_id }) .map_err(|_| WalError::SendFailed) } /// Write a checkpoint marker at the given sequence number. /// /// Called by the signal materializer (P1.4) after flushing in-memory /// signal state to durable storage. /// /// # Errors /// /// Returns `WalError::Io` on filesystem failure. /// /// # Panics /// /// Panics if the system clock is before the Unix epoch. pub fn checkpoint(&self, seq: u64) -> Result<(), WalError> { let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("system clock is before Unix epoch") .as_nanos(); let ts_u64 = ts as u64; checkpoint::CheckpointManager::write(&self.wal_dir, seq, ts_u64) } /// Delete WAL segments whose events are all before `seq`. /// /// The truncation runs inside the writer thread to avoid racing with /// concurrent writes to segment files. Blocks until the writer thread /// has completed the deletion. /// /// # Errors /// /// Returns `WalError::Io` on filesystem failure. /// Returns `WalError::Closed` if the writer thread has exited. pub fn truncate_before(&self, seq: u64) -> Result<(), WalError> { let (reply_tx, reply_rx) = crossbeam::channel::bounded(1); self.tx .send(WalCommand::TruncateBefore { before_seq: seq, reply: reply_tx, }) .map_err(|_| WalError::Closed)?; reply_rx.recv().map_err(|_| WalError::Closed)? } /// Graceful shutdown: signal the writer thread to flush remaining events, /// fsync, and exit. Blocks until the writer thread terminates. /// /// # Errors /// /// Returns `WalError::ShutdownFailed` if the writer thread panicked. pub fn shutdown(mut self) -> Result<(), WalError> { // Send shutdown command (ignore send error -- writer may already be gone) let _ = self.tx.send(WalCommand::Shutdown); if let Some(thread) = self.thread.take() { match thread.join() { Ok(result) => result?, Err(_) => return Err(WalError::ShutdownFailed), } } Ok(()) } } impl Drop for WalHandle { fn drop(&mut self) { // Best-effort shutdown if not already shut down if self.thread.is_some() { let _ = self.tx.send(WalCommand::Shutdown); if let Some(thread) = self.thread.take() { let _ = thread.join(); } } } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; fn test_config(dir: &std::path::Path) -> WalConfig { WalConfig { dir: dir.to_path_buf(), ..WalConfig::default() } } fn make_event(id: u64) -> SignalEvent { SignalEvent { entity_id: id, signal_type: 1, weight: 1.0, timestamp_nanos: id * 1_000_000_000, } } #[test] fn open_creates_wal_directory() { let dir = tempfile::tempdir().expect("tempdir creation should succeed"); let config = test_config(dir.path()); let wal_dir = config.wal_dir(); let (handle, replayed, _session_events) = WalHandle::open(config).expect("open should succeed"); assert!(wal_dir.exists()); assert!(replayed.is_empty()); handle.shutdown().expect("shutdown should succeed"); } #[test] fn append_returns_sequence_number() { let dir = tempfile::tempdir().expect("tempdir creation should succeed"); let config = test_config(dir.path()); let (handle, _, _) = WalHandle::open(config).expect("open should succeed"); let seq = handle.append(make_event(1)).expect("append should succeed"); // Sequence is always non-negative (u64), just verify we got a value let _ = seq; handle.shutdown().expect("shutdown should succeed"); } #[test] fn append_multiple_monotonic() { let dir = tempfile::tempdir().expect("tempdir creation should succeed"); let config = test_config(dir.path()); let (handle, _, _) = WalHandle::open(config).expect("open should succeed"); let mut seqs = Vec::new(); for i in 1..=10 { let seq = handle.append(make_event(i)).expect("append should succeed"); seqs.push(seq); } // Filter out dedup seq=0 (should be none for unique events) let non_zero: Vec = seqs.iter().copied().filter(|&s| s > 0).collect(); for window in non_zero.windows(2) { assert!(window[0] < window[1], "not monotonic: {non_zero:?}"); } handle.shutdown().expect("shutdown should succeed"); } #[test] fn dedup_returns_zero() { let dir = tempfile::tempdir().expect("tempdir creation should succeed"); let config = test_config(dir.path()); let (handle, _, _) = WalHandle::open(config).expect("open should succeed"); let event = make_event(42); let seq1 = handle .append(event.clone()) .expect("first append should succeed"); let seq2 = handle.append(event).expect("second append should succeed"); assert!(seq1 > 0); assert_eq!(seq2, 0); // deduplicated handle.shutdown().expect("shutdown should succeed"); } #[test] fn checkpoint_writes_file() { let dir = tempfile::tempdir().expect("tempdir creation should succeed"); let config = test_config(dir.path()); let wal_dir = config.wal_dir(); let (handle, _, _) = WalHandle::open(config).expect("open should succeed"); handle.append(make_event(1)).expect("append should succeed"); handle.checkpoint(1).expect("checkpoint should succeed"); let cp = checkpoint::CheckpointManager::read(&wal_dir).expect("read should succeed"); assert!(cp.is_some()); let (seq, _ts) = cp.expect("checkpoint should exist"); assert_eq!(seq, 1); handle.shutdown().expect("shutdown should succeed"); } #[test] fn close_and_reopen_continues_sequence() { let dir = tempfile::tempdir().expect("tempdir creation should succeed"); // First session let config = test_config(dir.path()); let (handle, _, _) = WalHandle::open(config).expect("open should succeed"); let mut last_seq = 0; for i in 1..=5 { let seq = handle.append(make_event(i)).expect("append should succeed"); if seq > last_seq { last_seq = seq; } } handle.shutdown().expect("shutdown should succeed"); // Second session let config = test_config(dir.path()); let (handle, replayed, _session_events) = WalHandle::open(config).expect("reopen should succeed"); assert_eq!(replayed.len(), 5); // New events should get higher sequence numbers let new_seq = handle .append(make_event(100)) .expect("append should succeed"); assert!( new_seq > last_seq, "new_seq {new_seq} should be > last_seq {last_seq}" ); handle.shutdown().expect("shutdown should succeed"); } #[test] fn signal_event_converts_to_event_record() { let signal = make_event(42); let record: EventRecord = signal.clone().into(); assert_eq!(record.entity_id, 42); assert_eq!(record.signal_type, 1); assert_eq!(record.weight.to_bits(), signal.weight.to_bits()); } #[test] fn event_record_converts_to_signal_event() { let record = EventRecord::signal(42, 1, 2.5, 1_000_000_000); let signal: SignalEvent = record.into(); assert_eq!(signal.entity_id, 42); assert_eq!(signal.signal_type, 1); assert_eq!(signal.weight.to_bits(), 2.5_f32.to_bits()); } }