tidaldb/tidal/src/wal/writer.rs
jx12n b55ad70141 fix: M0-M10 third-pass remediation — durability, replication, and CLI hardening
Resolves the 142 findings from tidal/docs/reviews/CODE_REVIEW_m0-m10.md across
the engine, server, net, and CLI surfaces:

- WAL/session-journal durability, checkpoint format, and crash-recovery hardening
- Replication shipper/receiver, tenant isolation, and migration paths
- Cluster scatter-gather, router, standalone server + health/offload endpoints
- tidalctl refactored into command modules with JSON output and WAL-state tooling
- Cohort, governance, signal-ledger, and vector-registry correctness fixes
- Expanded UAT/integration/durability test coverage across all milestones
2026-06-08 10:28:34 -06:00

625 lines
27 KiB
Rust

use std::{
path::PathBuf,
time::{Duration, Instant},
};
use crossbeam::channel::Receiver;
use super::{
dedup::DedupWindow,
error::WalError,
format::{self, EventRecord, MAX_EVENTS_PER_BATCH, SessionSeqNo, SessionWalEvent},
segment::{self, SegmentWriter},
session_journal::SessionJournal,
};
use crate::replication::{RegionId, ShardId};
/// A single queued append: the event plus the caller's reply channel.
///
/// The reply channel receives the assigned sequence number once the batch
/// containing this event is durably fsynced, the dedup sentinel `0` if the
/// event was suppressed as a duplicate, or the write error if the batch failed
/// to persist. It is critical that *every* queued append eventually resolves
/// its reply — dropping the sender silently surfaces as `Closed` to a caller
/// that is in fact still durable-or-not, so both the steady-state loop and the
/// shutdown drain funnel through the same [`flush_batch`] routine.
type QueuedAppend = (
EventRecord,
crossbeam::channel::Sender<Result<u64, WalError>>,
);
// Narrow test-only fault hook: when set, the next `flush_batch` write fails.
//
// Lets a unit test drive the *error* arm of `flush_batch` deterministically
// (without a real I/O fault) to prove that both the steady-state loop and the
// shutdown-drain tail notify every waiting caller with an error instead of
// dropping their reply channels. Thread-local so concurrent tests don't
// interfere. Always compiled out of production builds.
#[cfg(test)]
thread_local! {
static FAIL_NEXT_FLUSH: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Arm the test-only flush-failure hook for the current thread.
#[cfg(test)]
pub(crate) fn arm_flush_failure() {
FAIL_NEXT_FLUSH.with(|c| c.set(true));
}
/// Returns and clears the test-only flush-failure flag for the current thread.
#[cfg(test)]
fn take_flush_failure() -> bool {
FAIL_NEXT_FLUSH.with(|c| c.replace(false))
}
/// Commands sent from `WalHandle` to the writer thread.
pub enum WalCommand {
/// Append a signal event. The reply channel receives the assigned
/// sequence number (or an error) once the batch containing this
/// event has been durably fsynced.
Append {
event: EventRecord,
reply: crossbeam::channel::Sender<Result<u64, WalError>>,
},
/// Delete segments whose first sequence number is less than `before_seq`.
/// Runs inside the writer thread to avoid racing with concurrent writes.
TruncateBefore {
before_seq: u64,
reply: crossbeam::channel::Sender<Result<(), WalError>>,
},
/// Graceful shutdown: flush remaining events and exit.
Shutdown,
// ── Session lifecycle commands ────────────────────────────────────────
// These are fire-and-forget (no reply channel). They bypass the signal
// batch system and write directly to the session journal with fsync.
/// Record that a session was started.
SessionStart {
session_id: u64,
user_id: u64,
started_at_ns: u64,
agent_id: String,
policy_name: String,
},
/// Record that a signal was written within a session.
SessionSignal {
session_id: u64,
entity_id: u64,
weight: f64,
ts_ns: u64,
signal_name: String,
annotation: Option<String>,
/// Monotonic sequence number for this write (used for idempotent replay).
/// `None` for legacy writes that predate the seqno mechanism.
session_seqno: Option<u64>,
/// BLAKE3-derived idempotency key for duplicate suppression.
/// `None` for legacy writes.
idempotency_key: Option<u128>,
},
/// Record that a session was closed.
SessionClose { session_id: u64 },
}
/// Configuration for the group commit writer.
pub struct WriterConfig {
pub dir: PathBuf,
pub segment_size: u64,
pub batch_size: usize,
pub batch_timeout: Duration,
pub dedup_window: Duration,
/// Path for the session journal file (optional; `None` in ephemeral mode).
pub session_journal_path: Option<PathBuf>,
/// Shard identity for this writer. Written into every batch header so
/// `WalShipper` and receivers can identify the source shard.
/// Defaults to `ShardId::SINGLE` for single-node deployments.
pub shard_id: ShardId,
/// Region identity for this writer. Written into every batch header.
/// Defaults to `RegionId::SINGLE` for single-node deployments.
pub region_id: RegionId,
}
/// Validate a writer configuration before the writer thread is spawned.
///
/// The wire format caps a single batch at [`MAX_EVENTS_PER_BATCH`] events
/// (`format::encode_batch_with_shard` rejects anything larger). The writer
/// drains up to `batch_size` events into one batch, so a `batch_size` above the
/// cap would make the *first* full batch fail to encode — and because that
/// failure propagates out of `run_writer`, it would terminate the writer thread
/// and wedge every subsequent append forever. We reject it here so the caller of
/// `WalHandle::open` fails loudly at startup instead of silently after the first
/// full batch.
///
/// # Errors
///
/// Returns [`WalError::InvalidConfig`] if `batch_size` is `0` or exceeds
/// [`MAX_EVENTS_PER_BATCH`].
pub(crate) fn validate_writer_config(batch_size: usize) -> Result<(), WalError> {
let max = usize::from(MAX_EVENTS_PER_BATCH);
if batch_size == 0 {
return Err(WalError::InvalidConfig {
message: "batch_size must be at least 1".to_string(),
});
}
if batch_size > max {
return Err(WalError::InvalidConfig {
message: format!("batch_size {batch_size} exceeds MAX_EVENTS_PER_BATCH ({max})"),
});
}
Ok(())
}
/// The largest batch the wire format can encode in a single fsync.
///
/// `run_writer` clamps its drain limit to this value as a defence-in-depth
/// belt-and-braces guard: `validate_writer_config` already rejects an oversized
/// `batch_size` at open time, but if a future caller constructs a `WriterConfig`
/// directly (bypassing validation) the clamp still prevents the thread from ever
/// building an unencodable batch and crashing. Correctness is preserved either
/// way — clamping only means more, smaller batches, never a dropped event.
fn effective_batch_size(batch_size: usize) -> usize {
batch_size.clamp(1, usize::from(MAX_EVENTS_PER_BATCH))
}
/// Encode, write, and fsync one batch of non-duplicate events, notifying every
/// caller of the outcome.
///
/// This is the single shared commit routine used by both the steady-state loop
/// in [`run_writer`] and its shutdown-drain tail, so the two paths cannot
/// diverge in how they handle a write failure (the divergence that previously
/// let the drain path drop reply channels on error). Responsibilities:
///
/// 1. Encode the batch at `batch_seq` with the writer's shard/region identity.
/// 2. Rotate the segment first if it has reached its size threshold.
/// 3. Write the encoded bytes and fsync.
/// 4. On success: notify each caller of its assigned sequence number and return
/// the next free sequence number.
/// 5. On failure: notify *every* waiting caller with the underlying error before
/// propagating it, so no caller is ever left blocked on a dropped channel.
///
/// `kept_events` and `kept_replies` are 1:1 and must be non-empty (the callers
/// only invoke this when there is at least one event to persist).
///
/// # Errors
///
/// Returns the underlying [`WalError`] from encode/rotate/write/sync. On error,
/// all reply channels have already been notified with an equivalent error.
///
/// Never panics on a clock anomaly: the batch timestamp is sourced from
/// [`crate::schema::Timestamp::now`], which saturates a pre-Unix-epoch clock to
/// the epoch and logs a warning rather than panicking. WAL ordering is by
/// sequence number, not timestamp, so a clamped timestamp is informational only.
fn flush_batch(
segment: &mut SegmentWriter,
config: &WriterConfig,
batch_seq: u64,
kept_events: &[EventRecord],
kept_replies: Vec<crossbeam::channel::Sender<Result<u64, WalError>>>,
) -> Result<u64, WalError> {
debug_assert_eq!(kept_events.len(), kept_replies.len());
// Source the batch timestamp from the canonical clock-anomaly-safe helper:
// a pre-Unix-epoch wall clock (NTP step-back, dead/uninitialized RTC at boot)
// saturates to the epoch and logs a warning instead of panicking the writer
// thread. The timestamp is informational metadata only — the WAL is ordered
// by sequence number — so a clamped value is safe.
let batch_ts = crate::schema::Timestamp::now().as_nanos();
let write_result = (|| -> Result<(), WalError> {
// Test-only deterministic fault: exercise the caller-notification error
// arm without a real I/O failure. Compiled out of production builds.
#[cfg(test)]
if take_flush_failure() {
return Err(WalError::Io(std::io::Error::other(
"injected flush failure",
)));
}
let encoded = format::encode_batch_with_shard(
kept_events,
batch_seq,
batch_ts,
config.shard_id,
config.region_id,
)?;
if segment.needs_rotation() {
segment.rotate(batch_seq)?;
}
segment.write_batch_bytes(&encoded)?;
segment.sync()?;
Ok(())
})();
match write_result {
Ok(()) => {
let event_count = kept_events.len() as u64;
tracing::debug!(seq = batch_seq, events = event_count, "wal: batch appended");
for (i, reply) in kept_replies.into_iter().enumerate() {
let _ = reply.send(Ok(batch_seq + i as u64));
}
Ok(batch_seq + event_count)
}
Err(err) => {
// Notify all waiting callers with the actual error before
// propagating. We cannot clone WalError, so we send a synthetic
// I/O error carrying the same description; dropping the channels
// would surface as a generic `Closed` and hide the real cause.
let err_msg = err.to_string();
for reply in kept_replies {
let _ = reply.send(Err(WalError::Io(std::io::Error::other(err_msg.clone()))));
}
Err(err)
}
}
}
/// Outcome of dispatching one [`WalCommand`] through [`handle_aux_command`].
///
/// The three command loops in [`run_writer`] (blocking recv, deadline drain,
/// shutdown drain) share their handling of the side-effecting *auxiliary*
/// commands (`TruncateBefore`, `Session*`) but differ only in their
/// continue/break control flow. Routing every command through one helper that
/// returns this enum means each loop matches on the enum — never on the raw
/// command bodies — so the auxiliary-command logic (including the
/// active-segment clamp in [`handle_aux_command`]) lives in exactly one place
/// and cannot drift between the three sites.
enum CommandOutcome {
/// An `Append` was received: the loop must push `(event, reply)` onto its
/// pending batch.
Pushed(QueuedAppend),
/// A side-effecting command (`TruncateBefore`, `Session*`) was fully
/// handled inside the helper; the loop should keep going.
Handled,
/// `Shutdown` was received (or the channel disconnected): the loop must
/// stop draining.
Shutdown,
}
/// Dispatch a single received [`WalCommand`], executing any side effect.
///
/// `Append` is returned as [`CommandOutcome::Pushed`] for the caller to batch;
/// every other variant is fully handled here and reported as
/// [`CommandOutcome::Handled`] or [`CommandOutcome::Shutdown`]. Sharing this
/// dispatch across all three loops in [`run_writer`] keeps the auxiliary-command
/// bodies byte-identical by construction.
///
/// # Active-segment protection (`TruncateBefore`)
///
/// `TruncateBefore` must NOT unlink the segment the live writer is appending to.
/// After any write burst the active segment's `first_seq` sits below the
/// materialized checkpoint, so a naive `delete_segments_before(checkpoint_seq)`
/// would `remove_file` the very inode this writer still holds open — and on
/// Linux the writer would keep appending to the now-unlinked inode, silently
/// losing every post-checkpoint, already-fsync'd, acknowledged write on the next
/// open. We therefore clamp the deletion floor to the live segment's
/// `first_seq`, exactly as [`crate::wal::compaction::compact_wal_online`] does,
/// guaranteeing the active segment always survives. The writer thread already
/// owns `active_first_seq`, so the clamp is a single `min` with no extra I/O.
fn handle_aux_command(
cmd: WalCommand,
config: &WriterConfig,
active_first_seq: u64,
session_journal: &mut Option<SessionJournal>,
) -> CommandOutcome {
match cmd {
WalCommand::Append { event, reply } => CommandOutcome::Pushed((event, reply)),
WalCommand::TruncateBefore { before_seq, reply } => {
// Clamp the deletion floor so the live segment (the maximum-first_seq
// segment, which this writer holds open) is never unlinked out from
// under our FD. See the function-level rustdoc and
// `compaction::compact_wal_online` for the full hazard analysis.
let floor = before_seq.min(active_first_seq);
let result = segment::delete_segments_before(&config.dir, floor);
let _ = reply.send(result.map(|_| ()));
CommandOutcome::Handled
}
cmd @ (WalCommand::SessionStart { .. }
| WalCommand::SessionSignal { .. }
| WalCommand::SessionClose { .. }) => {
handle_session_command(cmd, session_journal);
CommandOutcome::Handled
}
WalCommand::Shutdown => CommandOutcome::Shutdown,
}
}
/// Split a drained batch into kept events (1:1 with their replies) and duplicate
/// replies, notifying duplicates immediately with the dedup sentinel `0`.
///
/// Shared by the steady-state loop and the shutdown drain so dedup accounting
/// cannot diverge between them.
fn partition_dedup(
dedup: &mut DedupWindow,
batch: impl IntoIterator<Item = QueuedAppend>,
) -> (
Vec<EventRecord>,
Vec<crossbeam::channel::Sender<Result<u64, WalError>>>,
) {
let mut kept_events: Vec<EventRecord> = Vec::new();
let mut kept_replies: Vec<crossbeam::channel::Sender<Result<u64, WalError>>> = Vec::new();
// Hashes kept *within this batch*, so two identical events in the same drained
// batch still dedup against each other even though the durable dedup window is
// only updated AFTER a successful flush (see `run_writer`). This preserves
// intra-batch suppression without violating the "mark as seen only once
// durable" invariant.
let mut batch_seen: std::collections::HashSet<u128> = std::collections::HashSet::new();
for (event, reply) in batch {
let hash = format::event_content_hash(&event);
// `dedup.contains` CHECKS membership without recording; `batch_seen.insert`
// returns false when this exact event already appeared earlier in this
// batch. Recording into `dedup` happens in `run_writer`, only after the
// batch is durably persisted.
if dedup.contains(&event) || !batch_seen.insert(hash) {
// Duplicate: notify with the dedup sentinel (seq=0) immediately.
let _ = reply.send(Ok(0));
} else {
kept_events.push(event);
kept_replies.push(reply);
}
}
(kept_events, kept_replies)
}
/// The group commit writer loop.
///
/// Runs on a dedicated thread. Receives events via crossbeam channel,
/// accumulates them into batches, writes batches to the WAL segment,
/// and fsyncs once per batch. Callers are notified of their sequence
/// numbers via per-event reply channels.
///
/// # Batch formation
///
/// 1. Block until the first event arrives.
/// 2. Drain additional events from the channel up to `batch_size` or
/// until `batch_timeout` elapses (whichever comes first).
/// 3. Deduplicate events, encode the batch, write to segment, fsync.
/// 4. Send sequence numbers back to all waiting callers.
///
/// # Resilience
///
/// A flush failure in the **steady-state loop** does NOT terminate the writer.
/// `flush_batch` notifies every waiting caller with the error (so they can
/// retry), the batch's events are left un-recorded in the dedup window (so a
/// retry is accepted, not suppressed as a phantom duplicate), and the loop keeps
/// serving — a transient I/O fault (ENOSPC, EINTR, NFS blip) must never convert
/// into a permanent write outage by dropping the command channel.
///
/// # Errors
///
/// Only the shutdown drain / final fsync propagate an error out of this function
/// (the WAL is closing anyway, and callers were already notified). The encoding
/// path cannot fail under normal operation — `effective_batch_size` clamps the
/// drain limit to `MAX_EVENTS_PER_BATCH` so an oversized `batch_size` can never
/// produce an unencodable batch.
///
/// Never panics on a clock anomaly: [`flush_batch`] sources its batch timestamp
/// from the clock-anomaly-safe [`crate::schema::Timestamp::now`], so a
/// pre-Unix-epoch wall clock cannot kill the writer thread.
// The encode/dedup/write/sync logic is extracted into `flush_batch` and
// `partition_dedup`, and the per-command dispatch into `handle_aux_command`;
// what remains is the steady-state loop plus the shutdown drain, each matching
// only on the `CommandOutcome` enum. Splitting the control flow further would
// obscure the single read-recv/drain structure.
#[allow(clippy::too_many_lines)]
pub fn run_writer(
rx: &Receiver<WalCommand>,
config: &WriterConfig,
mut segment: SegmentWriter,
start_seq: u64,
mut dedup: DedupWindow,
) -> Result<(), WalError> {
let mut next_seq = start_seq;
// Clamp the drain limit to what the wire format can encode in one batch.
// `validate_writer_config` rejects an oversized `batch_size` at open time;
// this is the defence-in-depth guard for any caller that builds a
// `WriterConfig` directly. See `effective_batch_size`.
let max_batch = effective_batch_size(config.batch_size);
let mut batch: Vec<QueuedAppend> = Vec::with_capacity(max_batch);
let mut shutdown_requested = false;
// Open the session journal if a path was provided (persistent mode).
let mut session_journal: Option<SessionJournal> = config
.session_journal_path
.as_ref()
.and_then(|p| match SessionJournal::open(p) {
Ok(j) => Some(j),
Err(e) => {
tracing::error!(error = %e, "failed to open session journal; session WAL writes will be skipped");
None
}
});
loop {
// Block until the first event arrives (or shutdown/disconnect). All
// side-effecting commands route through `handle_aux_command` so the
// `TruncateBefore` active-segment clamp and the `Session*` delegation
// live in exactly one place; this loop only steers control flow.
match rx.recv() {
Ok(cmd) => {
match handle_aux_command(cmd, config, segment.first_seq(), &mut session_journal) {
CommandOutcome::Pushed(queued) => batch.push(queued),
CommandOutcome::Handled => continue,
CommandOutcome::Shutdown => break,
}
}
Err(_) => break,
}
// Drain up to the (clamped) batch limit with a deadline.
let deadline = Instant::now() + config.batch_timeout;
while batch.len() < max_batch {
match rx.recv_deadline(deadline) {
Ok(cmd) => {
match handle_aux_command(cmd, config, segment.first_seq(), &mut session_journal)
{
CommandOutcome::Pushed(queued) => batch.push(queued),
// Side-effecting commands bypass the batch; keep draining.
CommandOutcome::Handled => {}
CommandOutcome::Shutdown => {
shutdown_requested = true;
break;
}
}
}
Err(crossbeam::channel::RecvTimeoutError::Disconnected) => {
shutdown_requested = true;
break;
}
Err(crossbeam::channel::RecvTimeoutError::Timeout) => break,
}
}
// Deduplicate, then commit through the shared flush routine so this
// path cannot diverge from the shutdown drain below. `partition_dedup`
// notifies duplicate senders with the dedup sentinel; `flush_batch`
// notifies every kept caller (success *or* error) before returning.
// drain(..) reuses batch's heap allocation across loop iterations.
#[allow(clippy::iter_with_drain)]
let (kept_events, kept_replies) = partition_dedup(&mut dedup, batch.drain(..));
if !kept_events.is_empty() {
match flush_batch(&mut segment, config, next_seq, &kept_events, kept_replies) {
Ok(seq) => {
next_seq = seq;
// Record events as seen ONLY now that the batch is durably
// persisted. Recording earlier (in `partition_dedup`) would
// suppress a legitimate retry of a batch that failed to flush.
for event in &kept_events {
dedup.record(event);
}
}
Err(e) => {
// A transient I/O fault (a brief ENOSPC that an operator then
// clears, an EINTR, an NFS/network-storage blip) must NOT tear
// down the writer thread — that would drop the command channel
// and wedge every future write forever, since the WAL is the
// source of truth for all entity/signal/relationship writes.
// `flush_batch` has already notified each waiting caller with
// the error, so they can retry; we keep `next_seq` unchanged
// (the failed batch's sequence range is free for the retry),
// leave the events UNRECORDED in the dedup window so the retry
// is accepted, and keep serving.
tracing::error!(
error = %e,
seq = next_seq,
events = kept_events.len(),
"wal: batch flush failed; callers notified to retry, writer continuing"
);
}
}
}
if shutdown_requested {
break;
}
}
// Drain any remaining commands that arrived before senders observed
// the shutdown. This ensures in-flight append() calls are not silently
// dropped, which would cause callers to block forever or receive
// WalError::Closed instead of a real sequence number.
let mut final_batch: Vec<QueuedAppend> = Vec::new();
// Same shared dispatch as the steady loop: `Append` queues into the final
// batch, `TruncateBefore`/`Session*` are handled (with the active-segment
// clamp), and a duplicate `Shutdown` is a no-op. The loop ends when the
// channel is empty or disconnected.
while let Ok(cmd) = rx.try_recv() {
match handle_aux_command(cmd, config, segment.first_seq(), &mut session_journal) {
CommandOutcome::Pushed(queued) => final_batch.push(queued),
CommandOutcome::Handled | CommandOutcome::Shutdown => {}
}
}
// Flush the final drain batch through the SAME shared routine the steady
// loop uses. Routing both paths through `flush_batch` is what guarantees the
// drain path notifies waiting callers on a write error instead of dropping
// their reply channels (the divergence this consolidation fixes). A drained
// batch can exceed `max_batch` if many appends queued during shutdown, so we
// commit it in `max_batch`-sized chunks the wire format can encode.
if !final_batch.is_empty() {
let (kept_events, kept_replies) = partition_dedup(&mut dedup, final_batch);
let mut events = kept_events.into_iter();
let mut replies = kept_replies.into_iter();
// `next_seq` is advanced per chunk so sequence numbers stay monotonic
// across the (rare) multi-chunk drain. It is not propagated past the
// writer's exit, but is read on each subsequent chunk iteration.
loop {
let chunk_events: Vec<EventRecord> = events.by_ref().take(max_batch).collect();
if chunk_events.is_empty() {
break;
}
let chunk_replies: Vec<_> = replies.by_ref().take(chunk_events.len()).collect();
next_seq = flush_batch(&mut segment, config, next_seq, &chunk_events, chunk_replies)?;
}
}
// Final sync before exit
segment.sync()?;
Ok(())
}
/// Write a session lifecycle command to the session journal.
///
/// This function is called from the writer thread. Session commands bypass the
/// signal batch system entirely. Errors are logged and swallowed -- session WAL
/// writes are best-effort; the in-memory session state is the source of truth.
fn handle_session_command(cmd: WalCommand, journal: &mut Option<SessionJournal>) {
let Some(journal) = journal.as_mut() else {
// No session journal open (should not happen in persistent mode, but
// log defensively).
return;
};
let event = match cmd {
WalCommand::SessionStart {
session_id,
user_id,
started_at_ns,
agent_id,
policy_name,
} => SessionWalEvent::Start {
session_id,
user_id,
started_at_ns,
agent_id,
policy_name,
},
WalCommand::SessionSignal {
session_id,
entity_id,
weight,
ts_ns,
signal_name,
annotation,
session_seqno,
idempotency_key,
} => SessionWalEvent::Signal {
session_id,
entity_id,
weight,
ts_ns,
signal_name,
annotation,
session_seqno: session_seqno.map(SessionSeqNo),
idempotency_key,
},
WalCommand::SessionClose { session_id } => SessionWalEvent::Close { session_id },
// Other commands are not handled here.
_ => return,
};
if let Err(e) = journal.append(&event) {
tracing::warn!(error = %e, "session journal write failed");
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::similar_names)]
#[path = "writer_tests.rs"]
mod tests;