tidaldb/tidal/src/wal/mod.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

612 lines
21 KiB
Rust

//! 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<u128>` 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<SignalEvent> 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<EventRecord> 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<WalCommand>,
}
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<u64, WalError> {
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<u64, WalError> {
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<WalCommand>,
thread: Option<std::thread::JoinHandle<Result<(), WalError>>>,
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<SignalEvent>, Vec<SessionWalEvent>), 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<SignalEvent> = 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<u64, WalError> {
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<u64>,
idempotency_key: Option<u128>,
) -> 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<u64> = 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());
}
}