Skip to main content

chronix_engine/wal/
writer.rs

1//! WAL writer — append records, manage rotation, and enforce fsync policies.
2
3use std::fs::{self, File, OpenOptions};
4use std::io::{self, BufWriter, Seek, Write};
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
8
9use parking_lot::{Condvar, Mutex};
10
11use chronix_core::{FsyncPolicy, WalConfig, WalError};
12
13use crate::wal::sink::{DurableFile, WalSink};
14use crate::wal::{
15    WAL_HEADER_SIZE, WAL_MAGIC, WAL_PAYLOAD_VERSION, WAL_RECORD_HEADER_SIZE, WAL_VERSION,
16    WalRecordType,
17};
18
19/// The buffered file the WAL appends to.
20///
21/// `dyn WalSink` rather than `File` so a test can make the write fail; the
22/// dynamic call is behind the 64 KiB buffer, so it happens once per flush
23/// rather than once per record. See [`crate::wal::sink`].
24type SinkWriter = BufWriter<Box<dyn WalSink>>;
25
26/// Bytes buffered before a syscall. 64 KiB reduces syscall overhead by ~8×
27/// against the default 8 KiB, which is significant for write-heavy workloads.
28const WAL_BUF_CAPACITY: usize = 64 * 1024;
29
30/// Constructs the WAL filename for a given starting sequence number.
31pub(crate) fn wal_filename(seq_start: u64) -> String {
32    format!("wal_{seq_start:020}.cxwl")
33}
34
35/// Write-Ahead Log writer.
36///
37/// Appends records to WAL files, manages rotation, and enforces the configured
38/// fsync policy. Thread-safe: internal state is protected by a mutex.
39///
40/// # Group commit
41///
42/// When using [`FsyncPolicy::PerBatch`], the [`append_durable`](Self::append_durable)
43/// method enables group commit: concurrent callers write their records under the
44/// write lock, then coordinate so that a single `fsync` covers all pending
45/// writes. The first waiter becomes the *sync leader* and performs the flush;
46/// subsequent waiters piggy-back on the leader's sync.
47pub struct WalWriter {
48    /// WAL data directory.
49    dir: PathBuf,
50    /// Configuration.
51    config: WalConfig,
52    /// Mutable writer state (protected by mutex for thread safety).
53    inner: Mutex<WalWriterInner>,
54    /// Global sequence counter (atomic for lock-free reads).
55    next_sequence: AtomicU64,
56    /// Group commit coordination state.
57    group_sync: Mutex<GroupSyncState>,
58    /// Condition variable for group commit waiters.
59    group_sync_cvar: Condvar,
60    /// Shutdown flag for the periodic sync thread.
61    periodic_shutdown: Arc<AtomicBool>,
62    /// Handle to the periodic sync thread (if running).
63    periodic_handle: Mutex<Option<std::thread::JoinHandle<()>>>,
64    /// Number of `fsync` calls issued against the WAL data file.
65    ///
66    /// Exported as `chronix_wal_fsync_total`. On flash storage the fsync rate
67    /// is the wear rate, which is what [`FsyncPolicy::Periodic`] bounds — and
68    /// an unobservable bound is one nobody can check.
69    fsync_count: AtomicU64,
70    /// Bumped every time the writer rewinds, discarding unsynced records.
71    ///
72    /// Group commit's `synced_up_to` is a **watermark**, so it assumes the
73    /// sequence space has no holes: a waiter on sequence 5 returns `Ok` as
74    /// soon as anything ≥ 5 is durable. A rewind puts a hole in it — records
75    /// 5 and 6 can be discarded while 7 is written and synced afterwards, and
76    /// the waiter on 5 would then be told its record is durable when it was
77    /// thrown away. Each writer captures the epoch when its record reaches
78    /// the buffer and refuses the acknowledgement if it has moved.
79    ///
80    /// This cannot fire spuriously: a rewind discards *everything* past the
81    /// last durable offset, so any record written before it is genuinely
82    /// gone.
83    rewind_epoch: AtomicU64,
84}
85
86/// Coordination state for group-commit syncs.
87struct GroupSyncState {
88    /// Highest sequence number that has been durably synced to disk.
89    synced_up_to: u64,
90    /// Whether a sync is currently in progress.
91    sync_in_progress: bool,
92}
93
94struct WalWriterInner {
95    /// Current WAL file writer.
96    writer: SinkWriter,
97    /// Path of the current WAL file.
98    current_path: PathBuf,
99    /// First sequence number in the current file.
100    file_start_seq: u64,
101    /// Current file size in bytes — bytes handed to the writer, buffered or
102    /// on disk.
103    file_size: u64,
104    /// Bytes of the current file covered by a **successful** `fsync`, and the
105    /// offset an I/O failure rewinds to.
106    ///
107    /// Everything past it is unacknowledged: `PerWrite` and `PerBatch` sync
108    /// before returning, and `Periodic` promises only the OS's buffers. So
109    /// discarding it on a failure is honest — and keeping it would let a
110    /// record the caller was told had failed reach the disk on a later flush.
111    synced_offset: u64,
112    /// Highest sequence number covered by a **successful** `fsync`.
113    ///
114    /// The value `max_written_seq` rewinds to, so group commit's watermark
115    /// can never claim a discarded record.
116    synced_seq: u64,
117    /// Highest sequence number that has been fully written to the buffer.
118    /// Used by group_sync to avoid marking un-written records as durable.
119    max_written_seq: u64,
120    /// Set when the file's state is **unknown**; every later write is
121    /// rejected.
122    ///
123    /// Two things poison. A failed `fsync`, because the kernel may discard the
124    /// dirty pages *and clear the error*, so a retry can succeed over data
125    /// that is gone — there is no state to return to. And a failed rewind.
126    ///
127    /// A failed *write* does not: `ENOSPC` is usually transient and the file
128    /// can be restored exactly, so it rewinds
129    /// ([`rewind_to_durable`](WalWriter::rewind_to_durable)).
130    ///
131    /// Recovery is to drop the writer and re-open via [`WalWriter::open`],
132    /// which validates existing records first;
133    /// [`clear_poison`](WalWriter::clear_poison) is the in-place version, for
134    /// an operator who has verified the WAL replays.
135    poisoned: bool,
136    /// Cached count of WAL files in the directory. Maintained
137    /// incrementally during rotation and truncation to avoid `readdir`
138    /// on every rotation.
139    cached_file_count: usize,
140}
141
142impl WalWriter {
143    /// Open or create a WAL writer in the given directory.
144    ///
145    /// If existing WAL files are present, the writer resumes from the last
146    /// sequence number. Otherwise, a new WAL file is created starting at
147    /// sequence 1.
148    ///
149    /// # Errors
150    ///
151    /// Returns [`WalError`] if the directory cannot be created or files
152    /// cannot be opened.
153    #[allow(clippy::needless_pass_by_value)] // config is cheap and owned by the writer conceptually
154    pub fn open(dir: impl Into<PathBuf>, config: WalConfig) -> Result<Self, WalError> {
155        let dir = dir.into();
156        fs::create_dir_all(&dir)?;
157
158        // Scan existing WAL files to find the highest sequence number
159        let (start_seq, max_seq) = Self::scan_existing_files(&dir)?;
160        // Seed the cached file count from disk at startup
161        let initial_file_count = list_wal_files(&dir)?.len();
162
163        let next_seq = if max_seq > 0 { max_seq + 1 } else { 1 };
164        let file_start_seq = if start_seq > 0 { next_seq } else { 1 };
165
166        let path = dir.join(wal_filename(file_start_seq));
167        let (writer, file_size) = Self::create_wal_file(&path)?;
168        // The new file counts too
169        let cached_file_count = if initial_file_count > 0 && start_seq > 0 {
170            // Existing files + the new file being created now
171            initial_file_count + 1
172        } else {
173            // Fresh start — just the initial file
174            1
175        };
176
177        let periodic_shutdown = Arc::new(AtomicBool::new(false));
178
179        let wal = Self {
180            dir,
181            config: config.clone(),
182            inner: Mutex::new(WalWriterInner {
183                writer,
184                current_path: path,
185                file_start_seq,
186                file_size,
187                synced_offset: file_size,
188                synced_seq: if max_seq > 0 { max_seq } else { 0 },
189                max_written_seq: if max_seq > 0 { max_seq } else { 0 },
190                poisoned: false,
191                cached_file_count,
192            }),
193            next_sequence: AtomicU64::new(next_seq),
194            group_sync: Mutex::new(GroupSyncState {
195                synced_up_to: 0,
196                sync_in_progress: false,
197            }),
198            group_sync_cvar: Condvar::new(),
199            periodic_shutdown: periodic_shutdown.clone(),
200            periodic_handle: Mutex::new(None),
201            fsync_count: AtomicU64::new(0),
202            rewind_epoch: AtomicU64::new(0),
203        };
204
205        Ok(wal)
206    }
207
208    /// Returns `true` if the WAL writer has been poisoned due to an
209    /// unrecoverable I/O error.
210    pub fn is_poisoned(&self) -> bool {
211        self.inner.lock().poisoned
212    }
213
214    /// Clear the poison flag after operator-verified recovery.
215    ///
216    /// Re-opens the current WAL file for appending to ensure the
217    /// underlying file descriptor is in a known-good state. Returns
218    /// an error if the WAL file cannot be reopened.
219    ///
220    /// # Safety (logical)
221    ///
222    /// Call this only after verifying the WAL directory is intact
223    /// (e.g. via `WalReader` replay). Clearing poison on a
224    /// genuinely corrupt WAL risks silent data loss.
225    pub fn clear_poison(&self) -> Result<(), WalError> {
226        let mut inner = self.inner.lock();
227        if !inner.poisoned {
228            return Ok(());
229        }
230        // Re-open the file to get a fresh fd in a known state
231        let file = std::fs::OpenOptions::new()
232            .create(true)
233            .append(true)
234            .open(&inner.current_path)?;
235        let file_size = file.metadata()?.len();
236        inner.writer = BufWriter::with_capacity(WAL_BUF_CAPACITY, Self::sink(file));
237        inner.file_size = file_size;
238        inner.synced_offset = file_size;
239        inner.synced_seq = inner.max_written_seq;
240        inner.poisoned = false;
241        tracing::warn!("WAL poison flag cleared — writer resumed");
242        Ok(())
243    }
244
245    /// Append a single payload to the WAL.
246    ///
247    /// Returns the assigned sequence number.
248    ///
249    /// # Errors
250    ///
251    /// Returns [`WalError`] on I/O failure, if the WAL is full, or if the
252    /// internal lock was poisoned.
253    pub fn append(&self, payload: &[u8]) -> Result<u64, WalError> {
254        // Pre-compress outside the lock to reduce critical section duration.
255        // CRC32c stays under the lock (hardware-accelerated, negligible cost).
256        let prepared: std::borrow::Cow<'_, [u8]> = if self.config.compress {
257            std::borrow::Cow::Owned(crate::wal::compress_wal_payload(payload))
258        } else {
259            std::borrow::Cow::Borrowed(payload)
260        };
261
262        let mut inner = self.inner.lock();
263
264        let seq = self.next_sequence.fetch_add(1, Ordering::Relaxed);
265        self.maybe_rotate(&mut inner, seq)?;
266        Self::write_record(
267            &mut inner,
268            seq,
269            &prepared,
270            false,
271            WalRecordType::Data,
272            &self.rewind_epoch,
273        )?;
274
275        if self.config.fsync_policy == FsyncPolicy::PerWrite {
276            Self::flush_or_rewind(&mut inner, &self.rewind_epoch)?;
277            self.sync_locked(&mut inner)?;
278        }
279
280        // Emit WAL metrics after state change.
281        metrics::gauge!("chronix_wal_sequence_number").set(seq as f64);
282        metrics::gauge!("chronix_wal_file_count").set(inner.cached_file_count as f64);
283        metrics::gauge!("chronix_wal_current_file_bytes").set(inner.file_size as f64);
284
285        Ok(seq)
286    }
287
288    /// Append a batch of payloads as a single atomic WAL record, followed by
289    /// a single fsync (when using `PerBatch` policy).
290    ///
291    /// All payloads are serialized into a batch-framed compound record whose
292    /// CRC covers the entire batch. On crash recovery, either all
293    /// sub-payloads survive (CRC valid) or none (CRC mismatch / truncation),
294    /// guaranteeing all-or-nothing batch atomicity.
295    ///
296    /// Uses the same group-commit pattern as `append_durable`:
297    /// the write lock is released before fsync, allowing concurrent writers to
298    /// proceed while the sync leader fsyncs for all pending records.
299    ///
300    /// Returns the sequence number of the batch record.
301    ///
302    /// # Errors
303    ///
304    /// Returns [`WalError`] on I/O failure or if the internal lock was
305    /// poisoned.
306    pub fn append_batch(&self, payloads: &[&[u8]]) -> Result<u64, WalError> {
307        if payloads.is_empty() {
308            return Ok(self.next_sequence.load(Ordering::Relaxed).saturating_sub(1));
309        }
310
311        // Encode + compress outside the lock to reduce critical section
312        // duration.  Only the sequential buffer write + CRC (hardware-accelerated)
313        // remain under the mutex.
314        let batch_payload = crate::wal::encode_batch_payload(payloads);
315        let prepared = if self.config.compress {
316            crate::wal::compress_wal_payload(&batch_payload)
317        } else {
318            batch_payload
319        };
320
321        // Phase 1: Write under lock, then release.
322        let seq;
323        let epoch_at_write;
324        {
325            let mut inner = self.inner.lock();
326
327            seq = self.next_sequence.fetch_add(1, Ordering::Relaxed);
328
329            self.maybe_rotate(&mut inner, seq)?;
330            Self::write_record(
331                &mut inner,
332                seq,
333                &prepared,
334                false,
335                WalRecordType::Batch,
336                &self.rewind_epoch,
337            )?;
338
339            // `PerWrite` syncs inline; `Periodic` deliberately does not —
340            // its syncs are coalesced onto the background thread. Syncing
341            // here made `Periodic` behave exactly like `PerWrite`, so the
342            // flash-wear preset bought nothing, and this path disagreed with
343            // `append` about what one policy meant.
344            if self.config.fsync_policy == FsyncPolicy::PerWrite {
345                Self::flush_or_rewind(&mut inner, &self.rewind_epoch)?;
346                self.sync_locked(&mut inner)?;
347
348                metrics::gauge!("chronix_wal_sequence_number").set(seq as f64);
349                metrics::gauge!("chronix_wal_file_count").set(inner.cached_file_count as f64);
350                metrics::gauge!("chronix_wal_current_file_bytes").set(inner.file_size as f64);
351
352                return Ok(seq);
353            }
354
355            // Emit WAL metrics (lock still held, before release).
356            metrics::gauge!("chronix_wal_sequence_number").set(seq as f64);
357            metrics::gauge!("chronix_wal_file_count").set(inner.cached_file_count as f64);
358            metrics::gauge!("chronix_wal_current_file_bytes").set(inner.file_size as f64);
359            // Read under `inner`, which a rewind also holds, so this cannot
360            // miss a rewind that has already discarded this record.
361            epoch_at_write = self.rewind_epoch.load(Ordering::SeqCst);
362        }
363        // Write lock released — other writers can proceed.
364
365        // Phase 2: Group sync (PerBatch only) — amortises fsync across
366        // concurrent callers instead of serializing through the mutex.
367        if self.config.fsync_policy == FsyncPolicy::PerBatch {
368            self.group_sync(seq, epoch_at_write)?;
369        }
370
371        Ok(seq)
372    }
373
374    /// Append a single payload and ensure it is durably committed.
375    ///
376    /// With [`FsyncPolicy::PerBatch`] this participates in **group commit**:
377    /// the calling thread writes its record and then either becomes the *sync
378    /// leader* (flushing and calling `fsync` for all pending records) or waits
379    /// for an in-progress sync to complete. This amortises the cost of `fsync`
380    /// across concurrent callers.
381    ///
382    /// With [`FsyncPolicy::PerWrite`] this behaves identically to
383    /// [`append`](Self::append).
384    ///
385    /// With [`FsyncPolicy::Periodic`] the record is written and then
386    /// **unconditionally** flushed + synced, because "durable" means the
387    /// caller requires the data to be on stable storage before returning
388    ///.
389    ///
390    /// # Errors
391    ///
392    /// Returns [`WalError`] on I/O failure, if the WAL is full, or if the
393    /// internal lock was poisoned.
394    pub fn append_durable(&self, payload: &[u8]) -> Result<u64, WalError> {
395        // Pre-compress outside the lock to reduce critical section duration.
396        let prepared: std::borrow::Cow<'_, [u8]> = if self.config.compress {
397            std::borrow::Cow::Owned(crate::wal::compress_wal_payload(payload))
398        } else {
399            std::borrow::Cow::Borrowed(payload)
400        };
401
402        // Phase 1: assign sequence AND write under the same lock to prevent
403        // out-of-order writes that would violate group_sync durability.
404        let seq;
405        let epoch_at_write;
406        {
407            let mut inner = self.inner.lock();
408
409            seq = self.next_sequence.fetch_add(1, Ordering::Relaxed);
410            self.maybe_rotate(&mut inner, seq)?;
411            Self::write_record(
412                &mut inner,
413                seq,
414                &prepared,
415                false,
416                WalRecordType::Data,
417                &self.rewind_epoch,
418            )?;
419            // Read under `inner`, which a rewind also holds.
420            epoch_at_write = self.rewind_epoch.load(Ordering::SeqCst);
421
422            // PerWrite and Periodic: sync immediately while we still hold
423            // the lock. "Durable" must mean durable regardless of
424            // the fsync policy — this is the path a delete takes, where the
425            // caller has been told the record is on stable storage before it
426            // returns. The ordinary batch path above honours `Periodic`.
427            if matches!(
428                self.config.fsync_policy,
429                FsyncPolicy::PerWrite | FsyncPolicy::Periodic(_)
430            ) {
431                Self::flush_or_rewind(&mut inner, &self.rewind_epoch)?;
432                self.sync_locked(&mut inner)?;
433                return Ok(seq);
434            }
435        }
436        // Write lock released — other writers can proceed.
437
438        // Phase 2: group sync (PerBatch only).
439        if self.config.fsync_policy == FsyncPolicy::PerBatch {
440            self.group_sync(seq, epoch_at_write)?;
441        }
442
443        Ok(seq)
444    }
445
446    /// Coordinate a group sync for the given sequence number.
447    ///
448    /// If no sync is in progress, the caller becomes the sync leader and
449    /// performs the flush + fsync. Concurrent callers wait on the condition
450    /// variable and piggy-back on the leader's sync.
451    ///
452    /// Waiters use a bounded wait (5 s) to avoid indefinite hangs when the
453    /// sync leader stalls on disk I/O.  On timeout the waiter promotes
454    /// itself to sync leader and retries.
455    fn group_sync(&self, need_seq: u64, epoch_at_write: u64) -> Result<(), WalError> {
456        use std::time::Duration;
457
458        // Every early return below must check the epoch as well as the
459        // watermark. `synced_up_to` assumes a hole-free sequence space, and a
460        // rewind puts a hole in it.
461        let still_ours = || {
462            if self.rewind_epoch.load(Ordering::SeqCst) == epoch_at_write {
463                Ok(())
464            } else {
465                Err(WalError::Io(io::Error::new(
466                    io::ErrorKind::StorageFull,
467                    "the WAL rewound after an I/O failure; this record was \
468                     discarded before it could be made durable",
469                )))
470            }
471        };
472        // Use configurable timeout instead of hardcoded constant.
473        let group_sync_timeout = Duration::from_secs(self.config.group_sync_timeout_secs);
474
475        let mut state = self.group_sync.lock();
476
477        // Another leader already synced past our sequence: nothing to do.
478        if state.synced_up_to >= need_seq {
479            return still_ours();
480        }
481
482        // Wait for an in-progress sync — it might cover our sequence.
483        while state.sync_in_progress {
484            let timed_out = self
485                .group_sync_cvar
486                .wait_for(&mut state, group_sync_timeout);
487
488            if state.synced_up_to >= need_seq {
489                return still_ours();
490            }
491
492            // On timeout, break out and become the new sync leader. The
493            // previous leader is presumed stalled.
494            if timed_out.timed_out() {
495                break;
496            }
497        }
498
499        // We are the sync leader.
500        state.sync_in_progress = true;
501        drop(state);
502
503        // Perform the actual flush + fsync under the write lock.
504        let result = (|| -> Result<u64, WalError> {
505            let mut inner = self.inner.lock();
506            Self::flush_or_rewind(&mut inner, &self.rewind_epoch)?;
507            self.sync_locked(&mut inner)?;
508            // Return the max sequence that was actually written to the buffer,
509            // NOT next_sequence which may include records not yet written.
510            Ok(inner.max_written_seq)
511        })();
512
513        // Update state and wake all waiters.
514        let mut state = self.group_sync.lock();
515        state.sync_in_progress = false;
516        if let Ok(synced_seq) = result {
517            // Only mark sequences as synced that were actually written + fsynced.
518            state.synced_up_to = state.synced_up_to.max(synced_seq);
519        }
520
521        // `notify_all` is intentional here — not a thundering-herd bug.
522        // Every waiting writer needs to re-check whether its *own* sequence
523        // number was covered by the sync that just completed.  Writers whose
524        // sequence exceeds `synced_up_to` will loop back and become the next
525        // sync leader.  `notify_one` would risk leaving satisfied waiters
526        // unaware while unsatisfied ones remain blocked.
527        self.group_sync_cvar.notify_all();
528
529        result.map(|_| ())
530    }
531
532    /// Flush the WAL buffer and sync to disk.
533    ///
534    /// # Errors
535    ///
536    /// Returns [`WalError`] on I/O failure or if the internal lock was
537    /// poisoned.
538    pub fn sync(&self) -> Result<(), WalError> {
539        let mut inner = self.inner.lock();
540        Self::flush_or_rewind(&mut inner, &self.rewind_epoch)?;
541        self.sync_locked(&mut inner)
542    }
543
544    /// `fsync` the data file and count it. Caller holds `inner`.
545    ///
546    /// A failed `fsync` poisons the writer. After one, the kernel may have
547    /// discarded dirty pages, so the file's contents are unknown — and the
548    /// records the failed call covered are still in the file, where a
549    /// *later* successful sync (by a group-commit follower, or the periodic
550    /// thread) would make them durable after the caller was told they were
551    /// not. Refusing further writes is the only honest state.
552    fn sync_locked(&self, inner: &mut WalWriterInner) -> Result<(), WalError> {
553        let started = std::time::Instant::now();
554        if let Err(e) = inner.writer.get_ref().sync_data() {
555            inner.poisoned = true;
556            tracing::error!(error = %e, "WAL fsync failed — writer poisoned");
557            return Err(e.into());
558        }
559        // Everything buffered was flushed immediately before this call, so a
560        // successful sync makes the whole file durable up to `file_size`.
561        inner.synced_offset = inner.file_size;
562        inner.synced_seq = inner.max_written_seq;
563        self.fsync_count.fetch_add(1, Ordering::Relaxed);
564        metrics::counter!("chronix_wal_fsync_total").increment(1);
565        metrics::histogram!("chronix_wal_write_duration_seconds")
566            .record(started.elapsed().as_secs_f64());
567        Ok(())
568    }
569
570    /// Number of `fsync` calls issued against the WAL data file.
571    ///
572    /// The observable form of the fsync policy: with
573    /// [`FsyncPolicy::Periodic`] this grows with elapsed time, not with the
574    /// number of appends.
575    #[must_use]
576    pub fn fsync_count(&self) -> u64 {
577        self.fsync_count.load(Ordering::Relaxed)
578    }
579
580    /// Start the background periodic sync thread.
581    ///
582    /// When the WAL is configured with `FsyncPolicy::Periodic(interval)`,
583    /// this spawns a dedicated thread that calls `flush` + `fsync` at the
584    /// configured interval. The thread runs until `shutdown_periodic_sync`
585    /// is called or the `WalWriter` is dropped.
586    ///
587    /// No-op if the policy is not `Periodic`.
588    pub fn start_periodic_sync(self: &Arc<Self>) {
589        if let FsyncPolicy::Periodic(interval) = self.config.fsync_policy {
590            // Reject zero-duration intervals, which would
591            // create a CPU-burning busy loop.
592            let interval = if interval.is_zero() {
593                tracing::warn!(
594                    "FsyncPolicy::Periodic(Duration::ZERO) would cause a busy loop, \
595                     defaulting to 100ms"
596                );
597                std::time::Duration::from_millis(100)
598            } else {
599                interval
600            };
601
602            let shutdown = Arc::clone(&self.periodic_shutdown);
603            let writer = Arc::clone(self);
604            let handle = std::thread::Builder::new()
605                .name("wal-periodic-sync".into())
606                .spawn(move || {
607                    while !shutdown.load(Ordering::Relaxed) {
608                        std::thread::sleep(interval);
609                        if shutdown.load(Ordering::Relaxed) {
610                            break;
611                        }
612                        if let Err(e) = writer.sync() {
613                            tracing::warn!(error = %e, "periodic WAL fsync failed");
614                        }
615                    }
616                });
617            // Log error instead of panicking on thread spawn failure.
618            match handle {
619                Ok(h) => {
620                    let mut guard = self.periodic_handle.lock();
621                    *guard = Some(h);
622                }
623                Err(e) => {
624                    tracing::error!(
625                        error = %e,
626                        "Failed to spawn WAL periodic sync thread — \
627                         periodic fsync disabled for this writer"
628                    );
629                }
630            }
631        }
632    }
633
634    /// Stop the periodic sync thread and wait for it to finish.
635    pub fn shutdown_periodic_sync(&self) {
636        self.periodic_shutdown.store(true, Ordering::Relaxed);
637        let mut guard = self.periodic_handle.lock();
638        if let Some(handle) = guard.take() {
639            let _ = handle.join();
640        }
641    }
642
643    /// Force rotation to a new WAL file.
644    ///
645    /// # Errors
646    ///
647    /// Returns [`WalError`] on I/O failure or if the internal lock was
648    /// poisoned.
649    pub fn rotate(&self) -> Result<(), WalError> {
650        let mut inner = self.inner.lock();
651        let next_seq = self.next_sequence.load(Ordering::Acquire);
652        self.force_rotate(&mut inner, next_seq)?;
653        Ok(())
654    }
655
656    /// Delete WAL files whose maximum sequence number is ≤ the threshold.
657    ///
658    /// Never deletes the active WAL file. Idempotent.
659    ///
660    /// # Errors
661    ///
662    /// Returns [`WalError`] on I/O failure or if the internal lock was
663    /// poisoned.
664    pub fn truncate_before(&self, sequence_no: u64) -> Result<usize, WalError> {
665        let inner = self.inner.lock();
666        let current_path = inner.current_path.clone();
667        drop(inner);
668
669        let mut entries = list_wal_files(&self.dir)?;
670        entries.sort();
671
672        let mut deleted = 0;
673        // Use windows(2) to get each file's max seq from the next file's start
674        // sequence, avoiding the O(n²) position lookup.
675        for pair in entries.windows(2) {
676            let (file_start, path) = &pair[0];
677            let (next_start, _) = &pair[1];
678
679            // Never delete the current active file.
680            if *path == current_path {
681                continue;
682            }
683
684            let max_seq_in_file = next_start.saturating_sub(1);
685
686            if max_seq_in_file <= sequence_no {
687                tracing::info!(
688                    path = %path.display(),
689                    seq_range = %format!("{file_start}..={max_seq_in_file}"),
690                    "truncating WAL file"
691                );
692                fs::remove_file(path)?;
693                deleted += 1;
694            }
695        }
696
697        // Fsync the WAL directory so that file removals are durable.
698        // Without this, a crash could resurrect deleted WAL files.
699        if deleted > 0 {
700            let dir_file = fs::File::open(&self.dir)?;
701            dir_file.sync_all()?;
702
703            // Update cached file count
704            let mut inner = self.inner.lock();
705            inner.cached_file_count = inner.cached_file_count.saturating_sub(deleted);
706
707            // Emit WAL metrics after truncation.
708            metrics::gauge!("chronix_wal_file_count").set(inner.cached_file_count as f64);
709        }
710
711        Ok(deleted)
712    }
713
714    /// Approximate heap bytes held by the writer's own buffers.
715    ///
716    /// The `BufWriter`'s capacity, which is what stands between an append and
717    /// an `fsync`. It is one of the three terms the memtable budget does not
718    /// count, and unlike the other two it is fixed rather than a function of
719    /// the workload — so it is worth having as a number precisely because it
720    /// is easy to forget.
721    #[must_use]
722    pub fn memory_bytes(&self) -> usize {
723        self.inner.lock().writer.capacity()
724    }
725
726    /// Returns the current sequence number (the last assigned).
727    #[inline]
728    #[must_use]
729    pub fn current_sequence(&self) -> u64 {
730        self.next_sequence.load(Ordering::Acquire).saturating_sub(1)
731    }
732
733    /// Returns the path to the WAL directory.
734    #[inline]
735    #[must_use]
736    pub fn dir(&self) -> &Path {
737        &self.dir
738    }
739
740    /// Returns the number of WAL files in the directory.
741    ///
742    /// # Errors
743    ///
744    /// Returns [`WalError`] if the directory cannot be read.
745    pub fn file_count(&self) -> Result<usize, WalError> {
746        // The writer maintains this count across rotation and truncation;
747        // the directory is listed once, at open. Listing it here made every
748        // `insert()` pay a `readdir` for its admission check.
749        Ok(self.inner.lock().cached_file_count)
750    }
751
752    /// Returns the total size in bytes of all WAL files on disk.
753    ///
754    /// # Errors
755    ///
756    /// Returns [`WalError`] if the directory cannot be read.
757    pub fn total_bytes(&self) -> Result<u64, WalError> {
758        let files = list_wal_files(&self.dir)?;
759        let mut total: u64 = 0;
760        for (_, path) in &files {
761            if let Ok(meta) = fs::metadata(path) {
762                total += meta.len();
763            }
764        }
765        Ok(total)
766    }
767
768    /// Emit WAL gauge metrics for monitoring lag and resource usage.
769    ///
770    /// Emits `chronix_wal_file_count`, `chronix_wal_total_bytes`, and
771    /// `chronix_wal_sequence_number` gauges.
772    pub fn emit_metrics(&self) {
773        let seq = self.current_sequence();
774        metrics::gauge!("chronix_wal_sequence_number").set(seq as f64);
775
776        let inner = self.inner.lock();
777        metrics::gauge!("chronix_wal_file_count").set(inner.cached_file_count as f64);
778        metrics::gauge!("chronix_wal_current_file_bytes").set(inner.file_size as f64);
779    }
780
781    /// Explicitly close the WAL writer, flushing all data to stable storage.
782    ///
783    /// Unlike the implicit [`Drop`] cleanup, this method returns I/O errors
784    /// so close failures are not silently ignored.
785    ///
786    /// The shutdown sequence is:
787    /// 1. Stop the periodic sync thread (if running).
788    /// 2. Flush all buffered data to the operating system.
789    /// 3. Issue `sync_all` to commit data and metadata to stable storage.
790    ///
791    /// # Close-path efficiency
792    ///
793    /// This method takes ownership of `self` (`mut self`), which guarantees
794    /// exclusive access.  The inner state is obtained via
795    /// [`Mutex::get_mut`](parking_lot::Mutex::get_mut) (no-lock fast path),
796    /// so flush and fsync execute **outside any lock scope**.  This prevents
797    /// mutex contention with concurrent readers or writers during shutdown.
798    ///
799    /// # Errors
800    ///
801    /// Returns [`WalError`] if the flush or fsync fails.
802    pub fn close(mut self) -> Result<(), WalError> {
803        // Phase 1: stop background threads so no further syncs can race.
804        self.shutdown_periodic_sync();
805        // Phase 2: flush + sync without locking (get_mut requires &mut self,
806        // which we have since close() took ownership).
807        let inner = self.inner.get_mut();
808        inner.writer.flush()?;
809        inner.writer.get_ref().sync_all()?;
810        Ok(())
811    }
812
813    /// Rewind to the last durable offset, discarding whatever the buffer
814    /// still holds.
815    ///
816    /// It **rebuilds** the `BufWriter` rather than flushing it: a `BufWriter`
817    /// whose underlying write failed keeps the unwritten bytes, and there is
818    /// no API to drop them — so flushing before truncating fails for the same
819    /// reason the write did, and any later flush that succeeds writes a record
820    /// the caller was told had failed. `into_parts` takes the file back
821    /// *without* flushing, so the old writer's `Drop` cannot do that either.
822    ///
823    /// Returns `false` when the rewind itself failed, in which case the file
824    /// is in an unknown state and the caller must poison the writer.
825    fn rewind_to_durable(inner: &mut WalWriterInner, epoch: &AtomicU64) -> bool {
826        let good = inner.synced_offset;
827        let rebuilt = (|| -> io::Result<SinkWriter> {
828            let file = OpenOptions::new()
829                .write(true)
830                .create(false)
831                .open(&inner.current_path)?;
832            DurableFile::set_len(&file, good)?;
833            let mut w = BufWriter::with_capacity(WAL_BUF_CAPACITY, Self::sink(file));
834            w.seek(io::SeekFrom::Start(good))?;
835            Ok(w)
836        })();
837
838        match rebuilt {
839            Ok(fresh) => {
840                // `into_parts` rather than a plain drop: dropping a
841                // `BufWriter` flushes it, which would write the bytes we just
842                // truncated away, at the wrong offset.
843                let (_file, _discarded) = std::mem::replace(&mut inner.writer, fresh).into_parts();
844                inner.file_size = good;
845                // Records past `good` are gone. Anything waiting on one of
846                // them must be refused rather than satisfied by a later
847                // sync's watermark.
848                inner.max_written_seq = inner.synced_seq;
849                epoch.fetch_add(1, Ordering::SeqCst);
850                true
851            }
852            Err(e) => {
853                tracing::error!(error = %e, offset = good, "WAL rewind failed");
854                false
855            }
856        }
857    }
858
859    /// Flush the buffer, rewinding to the last durable offset if it fails.
860    ///
861    /// Every `flush()` on the write path goes through here: a bare `flush()?`
862    /// leaves the failed bytes in the buffer, where the next successful flush
863    /// would write them.
864    fn flush_or_rewind(inner: &mut WalWriterInner, epoch: &AtomicU64) -> Result<(), WalError> {
865        match inner.writer.flush() {
866            Ok(()) => Ok(()),
867            Err(e) => {
868                if !Self::rewind_to_durable(inner, epoch) {
869                    inner.poisoned = true;
870                    tracing::error!("WAL rewind failed after a flush error — writer poisoned");
871                }
872                Err(WalError::Io(e))
873            }
874        }
875    }
876
877    // ── Internal ──────────────────────────────────────────────────────
878
879    /// Wrap a freshly opened WAL file as the sink the writer appends to.
880    ///
881    /// The single place a test can substitute a failing file, so every other
882    /// path — rotation, reopen after poison, initial creation — gets the same
883    /// treatment without each having to remember.
884    #[cfg(not(test))]
885    fn sink(file: File) -> Box<dyn WalSink> {
886        Box::new(file)
887    }
888
889    #[cfg(test)]
890    fn sink(file: File) -> Box<dyn WalSink> {
891        match tests::write_budget() {
892            Some(budget) => Box::new(crate::wal::sink::FullDiskSink::new(file, budget)),
893            None => Box::new(file),
894        }
895    }
896
897    fn create_wal_file(path: &Path) -> Result<(SinkWriter, u64), WalError> {
898        let file = OpenOptions::new()
899            .create(true)
900            .write(true)
901            .truncate(true)
902            .open(path)?;
903
904        let mut writer = BufWriter::with_capacity(WAL_BUF_CAPACITY, Self::sink(file));
905
906        // Write header
907        writer.write_all(WAL_MAGIC)?;
908        writer.write_all(&WAL_VERSION.to_le_bytes())?;
909        writer.flush()?;
910        // Ensure the header is durable before any records are appended.
911        writer.get_ref().sync_data()?;
912
913        // Fsync parent directory so the new file's directory entry is durable.
914        if let Some(parent) = path.parent()
915            && let Ok(dir) = std::fs::File::open(parent)
916        {
917            dir.sync_all().map_err(|e| {
918                WalError::Io(std::io::Error::new(
919                    e.kind(),
920                    format!(
921                        "failed to fsync WAL parent directory {}: {e}",
922                        parent.display()
923                    ),
924                ))
925            })?;
926        }
927
928        Ok((writer, WAL_HEADER_SIZE as u64))
929    }
930
931    /// Maximum payload size per WAL record (256 MiB, matching reader limit).
932    const MAX_PAYLOAD_SIZE: usize = 256 * 1024 * 1024;
933
934    fn write_record(
935        inner: &mut WalWriterInner,
936        seq: u64,
937        payload: &[u8],
938        compress: bool,
939        record_type: WalRecordType,
940        epoch: &AtomicU64,
941    ) -> Result<(), WalError> {
942        // Reject writes after an unrecoverable I/O error.
943        if inner.poisoned {
944            return Err(WalError::Poisoned);
945        }
946
947        if payload.len() > Self::MAX_PAYLOAD_SIZE {
948            return Err(WalError::PayloadTooLarge {
949                size: payload.len(),
950                limit: Self::MAX_PAYLOAD_SIZE,
951            });
952        }
953
954        // Optionally LZ4-compress the payload before writing.
955        let payload: std::borrow::Cow<'_, [u8]> = if compress {
956            std::borrow::Cow::Owned(crate::wal::compress_wal_payload(payload))
957        } else {
958            std::borrow::Cow::Borrowed(payload)
959        };
960
961        #[allow(clippy::cast_possible_truncation)]
962        let length = payload.len() as u32;
963
964        // Compute CRC incrementally over [length, sequence_no, record_type,
965        // payload_version, payload] without allocating a temporary Vec.
966        let type_ver_bytes = [record_type as u8, WAL_PAYLOAD_VERSION];
967        let crc = crc32c::crc32c(&length.to_le_bytes());
968        let crc = crc32c::crc32c_append(crc, &seq.to_le_bytes());
969        let crc = crc32c::crc32c_append(crc, &type_ver_bytes);
970        let crc = crc32c::crc32c_append(crc, &payload);
971
972        // Save pre-write position so we can truncate on partial failure.
973        // This prevents corrupted half-records from poisoning subsequent writes.
974        let saved_pos = inner.file_size;
975
976        let write_result = (|| -> Result<(), io::Error> {
977            inner.writer.write_all(&crc.to_le_bytes())?;
978            inner.writer.write_all(&length.to_le_bytes())?;
979            inner.writer.write_all(&seq.to_le_bytes())?;
980            inner.writer.write_all(&type_ver_bytes)?;
981            inner.writer.write_all(&payload)?;
982            Ok(())
983        })();
984
985        if let Err(e) = write_result {
986            tracing::error!(
987                seq = seq,
988                offset = saved_pos,
989                durable_offset = inner.synced_offset,
990                error = %e,
991                "WAL record write failed — rewinding to the last durable offset"
992            );
993            // To the last *durable* offset, not `saved_pos`: the records
994            // between the last successful `fsync` and here sit in the same
995            // buffer that just failed, and none of them was acknowledged.
996            if !Self::rewind_to_durable(inner, epoch) {
997                tracing::error!(
998                    "WAL rewind failed after a write error — writer poisoned to \
999                     prevent corruption"
1000                );
1001                inner.poisoned = true;
1002            }
1003
1004            return Err(WalError::Io(e));
1005        }
1006
1007        inner.file_size += WAL_RECORD_HEADER_SIZE as u64 + payload.len() as u64;
1008        // Track the highest fully-written sequence for group_sync correctness.
1009        if seq > inner.max_written_seq {
1010            inner.max_written_seq = seq;
1011        }
1012
1013        Ok(())
1014    }
1015
1016    fn maybe_rotate(&self, inner: &mut WalWriterInner, next_seq: u64) -> Result<(), WalError> {
1017        if inner.file_size >= self.config.max_file_size as u64 {
1018            self.force_rotate(inner, next_seq)?;
1019        }
1020        Ok(())
1021    }
1022
1023    fn force_rotate(&self, inner: &mut WalWriterInner, next_seq: u64) -> Result<(), WalError> {
1024        // Use cached file count instead of readdir on every rotation
1025        if inner.cached_file_count >= self.config.max_unflushed_wals {
1026            return Err(WalError::Full {
1027                count: inner.cached_file_count,
1028                limit: self.config.max_unflushed_wals,
1029            });
1030        }
1031
1032        // Flush and sync the current file. Through `flush_or_rewind` like
1033        // every other write-path flush: a rotation that fails on a full disk
1034        // must leave the writer in a known state, not with a dirty buffer for
1035        // the next record to trip over.
1036        Self::flush_or_rewind(inner, &self.rewind_epoch)?;
1037        self.sync_locked(inner)?;
1038
1039        // Create new file
1040        let new_path = self.dir.join(wal_filename(next_seq));
1041        let (writer, file_size) = Self::create_wal_file(&new_path)?;
1042
1043        tracing::info!(
1044            old_path = %inner.current_path.display(),
1045            new_path = %new_path.display(),
1046            next_seq = next_seq,
1047            "WAL rotated"
1048        );
1049
1050        inner.writer = writer;
1051        inner.current_path = new_path;
1052        inner.file_start_seq = next_seq;
1053        inner.file_size = file_size;
1054        // `create_wal_file` fsyncs the header before returning, so the new
1055        // file is durable up to exactly that point.
1056        inner.synced_offset = file_size;
1057        inner.synced_seq = inner.max_written_seq;
1058        inner.cached_file_count += 1;
1059
1060        Ok(())
1061    }
1062
1063    fn scan_existing_files(dir: &Path) -> Result<(u64, u64), WalError> {
1064        let files = list_wal_files(dir)?;
1065        if files.is_empty() {
1066            return Ok((0, 0));
1067        }
1068
1069        let min_start = files.iter().map(|(s, _)| *s).min().unwrap_or(0);
1070
1071        // Use the maximum file-start sequence as the lower bound for max_seq.
1072        // This prevents sequence reuse when the last WAL file is empty (e.g.
1073        // crash during rotation): even without readable records, we know
1074        // sequences up to at least max_file_start - 1 were issued.
1075        let max_file_start = files.iter().map(|(s, _)| *s).max().unwrap_or(0);
1076        let mut max_seq = max_file_start.max(min_start);
1077
1078        // Scan backward through files to find the highest sequence number
1079        // from actual records (handles empty/corrupt last file gracefully).
1080        // Only track the last valid record per file since sequences
1081        // are monotonically increasing — no need to scan all records.
1082        for (_, path) in files.iter().rev() {
1083            let reader = match crate::wal::reader::WalReader::open(path) {
1084                Ok(r) => r,
1085                Err(_) => continue,
1086            };
1087            let mut found_any = false;
1088            let mut last_seq = 0u64;
1089            for record in reader {
1090                match record {
1091                    Ok(r) => {
1092                        last_seq = r.sequence_no;
1093                        found_any = true;
1094                    }
1095                    Err(_) => break, // truncated tail
1096                }
1097            }
1098            if found_any {
1099                max_seq = max_seq.max(last_seq);
1100                break; // highest records are in the last readable file
1101            }
1102        }
1103
1104        Ok((min_start, max_seq))
1105    }
1106}
1107
1108impl Drop for WalWriter {
1109    fn drop(&mut self) {
1110        // Flush buffered data and sync to disk on orderly
1111        // shutdown.  Uses `get_mut()` (not `lock()`) because `&mut self`
1112        // guarantees exclusive access — no mutex contention during Drop.
1113        // Without this, up to 64 KiB of buffered writes can be silently lost.
1114        //
1115        // Wrap in catch_unwind so a panic here (e.g. poisoned mutex,
1116        // unexpected I/O failure) does not trigger a double-panic abort if
1117        // Drop runs during stack unwinding.
1118        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1119            let inner = self.inner.get_mut();
1120            let _ = inner.writer.flush();
1121            let _ = inner.writer.get_ref().sync_all();
1122            self.periodic_shutdown.store(true, Ordering::Relaxed);
1123            let mut guard = self.periodic_handle.lock();
1124            if let Some(handle) = guard.take() {
1125                let _ = handle.join();
1126            }
1127        }));
1128        if result.is_err() {
1129            eprintln!("chronix: panic during WalWriter::drop suppressed to avoid abort");
1130        }
1131    }
1132}
1133
1134/// List all WAL files in a directory, sorted by starting sequence number.
1135///
1136/// Returns `(starting_sequence, path)` pairs.
1137pub(crate) fn list_wal_files(dir: &Path) -> Result<Vec<(u64, PathBuf)>, WalError> {
1138    let mut files = Vec::new();
1139
1140    if !dir.exists() {
1141        return Ok(files);
1142    }
1143
1144    for entry in fs::read_dir(dir)? {
1145        let entry = entry?;
1146        let path = entry.path();
1147        if let Some(name) = path.file_name().and_then(|n| n.to_str())
1148            && name.starts_with("wal_")
1149            && path
1150                .extension()
1151                .is_some_and(|ext| ext.eq_ignore_ascii_case("cxwl"))
1152            && let Some(seq_str) = name
1153                .strip_prefix("wal_")
1154                .and_then(|s| s.strip_suffix(".cxwl"))
1155            && let Ok(seq) = seq_str.parse::<u64>()
1156        {
1157            files.push((seq, path));
1158        }
1159    }
1160
1161    files.sort_by_key(|(seq, _)| *seq);
1162    Ok(files)
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167    use super::*;
1168    use std::sync::atomic::AtomicI64;
1169    use std::time::Duration;
1170    use tempfile::TempDir;
1171
1172    thread_local! {
1173        /// Bytes the next WAL file opened on this thread will accept before
1174        /// reporting the disk full. `None` — the default and every other
1175        /// test — opens an ordinary file.
1176        ///
1177        /// Thread-local rather than global so a `--test-threads=N` run cannot
1178        /// have one test's full disk reach another's writer.
1179        static WRITE_BUDGET: std::cell::RefCell<Option<std::sync::Arc<AtomicI64>>> =
1180            const { std::cell::RefCell::new(None) };
1181    }
1182
1183    pub(super) fn write_budget() -> Option<std::sync::Arc<AtomicI64>> {
1184        WRITE_BUDGET.with(|b| b.borrow().clone())
1185    }
1186
1187    /// Open a writer whose file accepts `bytes` more bytes and then reports
1188    /// `ENOSPC`, and hand back the budget so the test can refill it.
1189    fn wal_with_full_disk_after(dir: &Path, bytes: i64) -> (WalWriter, std::sync::Arc<AtomicI64>) {
1190        let budget = std::sync::Arc::new(AtomicI64::new(bytes));
1191        WRITE_BUDGET.with(|b| *b.borrow_mut() = Some(budget.clone()));
1192        let wal = WalWriter::open(dir, test_config()).expect("open");
1193        WRITE_BUDGET.with(|b| *b.borrow_mut() = None);
1194        (wal, budget)
1195    }
1196
1197    // ── A full disk ───────────────────────────────────────────────────
1198    //
1199    // `ENOSPC` is the failure an embedded gateway on an SD card actually
1200    // meets, and nothing had ever driven it: every WAL test here was the
1201    // happy path plus a process `abort()`. `chronix-chaos` published a
1202    // `Fault::DiskFull` and injected nothing.
1203
1204    /// A caller is never told a record is durable when the disk is full —
1205    /// and the writer works again once space comes back.
1206    ///
1207    /// The second half is the defect. `ENOSPC` cannot surface at `append`:
1208    /// the record goes into a 64 KiB `BufWriter` and the syscall happens at
1209    /// the flush. So the failure lands in `group_sync`'s flush, which left
1210    /// the buffer holding bytes no write could place. Every later flush
1211    /// retried them and failed, and the next record's `write_all` drove the
1212    /// partial-write recovery, which flushes before truncating — failing for
1213    /// the same reason — and **poisoned the writer**. From then on every
1214    /// append returned `Poisoned`, permanently: nothing in the tree calls
1215    /// `clear_poison`. A log rotation that freed a megabyte a second later
1216    /// did not help; the database needed a restart to store another point.
1217    #[test]
1218    fn a_full_disk_is_recoverable_without_a_restart() {
1219        let dir = TempDir::new().expect("tempdir");
1220        // Room for the file header and one record, not two.
1221        let (wal, budget) = wal_with_full_disk_after(dir.path(), 128);
1222
1223        let payload = vec![7u8; 32];
1224        let first = wal.append_durable(&payload).expect("the first record fits");
1225
1226        // The disk fills.
1227        budget.store(0, std::sync::atomic::Ordering::SeqCst);
1228        assert!(
1229            wal.append_durable(&payload).is_err(),
1230            "a record that cannot be made durable must not be acknowledged"
1231        );
1232
1233        // Space comes back — a log rotated, an old segment was archived.
1234        budget.store(1 << 20, std::sync::atomic::Ordering::SeqCst);
1235        let recovered = wal
1236            .append_durable(&payload)
1237            .expect("the writer must accept records again once space returns");
1238        assert!(recovered > first);
1239
1240        // And what is on disk is exactly what was acknowledged: no torn tail,
1241        // and no resurrection of the record the caller was told had failed.
1242        wal.close().expect("close");
1243        let seqs: Vec<u64> = crate::wal::replay_all(dir.path())
1244            .expect("replay")
1245            .iter()
1246            .map(|r| r.sequence_no)
1247            .collect();
1248        assert_eq!(seqs, vec![first, recovered], "replay: {seqs:?}");
1249    }
1250
1251    /// A rewind is never reported to a waiter as a successful sync.
1252    ///
1253    /// Group commit's `synced_up_to` is a **watermark**, so it assumes the
1254    /// sequence space has no holes: a waiter on sequence 5 returns `Ok` the
1255    /// moment anything ≥ 5 is durable. A rewind puts a hole in it — 5 and 6
1256    /// can be discarded while 7 is written and synced afterwards — so without
1257    /// the epoch check the waiter on 5 would be told its record was made
1258    /// durable after it had been thrown away. That is the same defect the
1259    /// rewind exists to remove, reintroduced one level up.
1260    #[test]
1261    fn a_rewind_refuses_the_records_it_discarded() {
1262        let dir = TempDir::new().expect("tempdir");
1263        let (wal, budget) = wal_with_full_disk_after(dir.path(), 128);
1264        let payload = vec![7u8; 32];
1265
1266        let first = wal.append_durable(&payload).expect("the first fits");
1267
1268        // Buffer a record without syncing it, then fill the disk and force
1269        // the rewind that discards it.
1270        let orphan = wal.append(&payload).expect("buffered, not yet durable");
1271        budget.store(0, std::sync::atomic::Ordering::SeqCst);
1272        assert!(wal.sync().is_err(), "the flush must fail on a full disk");
1273        assert!(orphan > first);
1274
1275        // Space returns and a later record is made durable, moving the
1276        // watermark past the discarded sequence.
1277        budget.store(1 << 20, std::sync::atomic::Ordering::SeqCst);
1278        let later = wal.append_durable(&payload).expect("a later write");
1279        assert!(later > orphan);
1280
1281        // The discarded record must not be in the file.
1282        wal.close().expect("close");
1283        let seqs: Vec<u64> = crate::wal::replay_all(dir.path())
1284            .expect("replay")
1285            .iter()
1286            .map(|r| r.sequence_no)
1287            .collect();
1288        assert!(
1289            !seqs.contains(&orphan),
1290            "sequence {orphan} was discarded by the rewind but replayed: {seqs:?}"
1291        );
1292        assert_eq!(seqs, vec![first, later]);
1293    }
1294
1295    /// A failed `fsync` still poisons, and that is deliberate.
1296    ///
1297    /// The two failures are not the same. A write that fails leaves the file
1298    /// in a state we can restore exactly — truncate to the offset before the
1299    /// record — so it is an ordinary error. A `fsync` that fails does not:
1300    /// the kernel may drop the dirty pages *and clear the error*, so a second
1301    /// `fsync` can succeed over data that is gone. Refusing further writes is
1302    /// the only honest state, and this pins that the distinction survives.
1303    #[test]
1304    fn a_failed_fsync_still_poisons() {
1305        let dir = TempDir::new().expect("tempdir");
1306        let wal = WalWriter::open(dir.path(), test_config()).expect("open");
1307        assert!(!wal.is_poisoned());
1308        wal.inner.lock().poisoned = true;
1309        assert!(matches!(wal.append(b"x"), Err(WalError::Poisoned)));
1310        // …and the recovery path exists and is reachable.
1311        wal.clear_poison().expect("clear_poison reopens the file");
1312        assert!(!wal.is_poisoned());
1313        wal.append(b"x").expect("writes resume after recovery");
1314    }
1315
1316    fn test_config() -> WalConfig {
1317        WalConfig {
1318            fsync_policy: FsyncPolicy::PerBatch,
1319            max_file_size: 32 * 1024 * 1024,
1320            max_unflushed_wals: 10,
1321            compress: true,
1322            ..WalConfig::default()
1323        }
1324    }
1325
1326    fn small_config() -> WalConfig {
1327        WalConfig {
1328            fsync_policy: FsyncPolicy::PerBatch,
1329            max_file_size: 200, // Very small to trigger rotation
1330            max_unflushed_wals: 10,
1331            compress: false, // Disable compression so file sizes are predictable
1332            ..WalConfig::default()
1333        }
1334    }
1335
1336    #[test]
1337    fn writer_creates_directory() {
1338        let dir = TempDir::new().unwrap();
1339        let wal_dir = dir.path().join("subdir").join("wal");
1340        let _writer = WalWriter::open(&wal_dir, test_config()).unwrap();
1341        assert!(wal_dir.exists());
1342    }
1343
1344    #[test]
1345    fn writer_creates_initial_file() {
1346        let dir = TempDir::new().unwrap();
1347        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1348        assert_eq!(writer.file_count().unwrap(), 1);
1349    }
1350
1351    #[test]
1352    fn append_single_record() {
1353        let dir = TempDir::new().unwrap();
1354        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1355
1356        let seq = writer.append(b"hello world").unwrap();
1357        assert_eq!(seq, 1);
1358
1359        let seq = writer.append(b"second").unwrap();
1360        assert_eq!(seq, 2);
1361
1362        assert_eq!(writer.current_sequence(), 2);
1363    }
1364
1365    /// `Periodic` must coalesce syncs onto the background thread, not issue
1366    /// one per append.
1367    ///
1368    /// It did the opposite on the batch path — the path every write to the
1369    /// database takes — which made `FsyncPolicy::Periodic(5s)` identical to
1370    /// `PerWrite`. The preset exists for flash-backed embedded storage, where
1371    /// the fsync rate is the wear rate, so "identical to `PerWrite`" is the
1372    /// whole cost the setting was chosen to avoid. The single-record `append`
1373    /// honoured the policy, so the two paths disagreed about what one policy
1374    /// meant, and nothing compared them.
1375    #[test]
1376    fn periodic_fsync_does_not_sync_on_every_batch() {
1377        let dir = tempfile::tempdir().unwrap();
1378        let config = WalConfig {
1379            // Long enough that the background thread cannot fire during the run.
1380            fsync_policy: FsyncPolicy::Periodic(Duration::from_secs(3600)),
1381            ..Default::default()
1382        };
1383        let writer = Arc::new(WalWriter::open(dir.path(), config).unwrap());
1384
1385        let payload = b"periodic-batch";
1386        for _ in 0..64 {
1387            writer.append_batch(&[payload]).unwrap();
1388        }
1389
1390        assert_eq!(
1391            writer.fsync_count(),
1392            0,
1393            "Periodic must not fsync per batch — that is PerWrite's contract"
1394        );
1395    }
1396
1397    /// The other half: the background thread has to actually run, or
1398    /// `Periodic` means "never sync" rather than "sync on an interval".
1399    #[test]
1400    fn periodic_fsync_thread_syncs_on_its_interval() {
1401        let dir = tempfile::tempdir().unwrap();
1402        let config = WalConfig {
1403            fsync_policy: FsyncPolicy::Periodic(Duration::from_millis(20)),
1404            ..Default::default()
1405        };
1406        let writer = Arc::new(WalWriter::open(dir.path(), config).unwrap());
1407        writer.start_periodic_sync();
1408
1409        writer.append_batch(&[b"x".as_slice()]).unwrap();
1410
1411        let deadline = std::time::Instant::now() + Duration::from_secs(5);
1412        while writer.fsync_count() == 0 && std::time::Instant::now() < deadline {
1413            std::thread::sleep(Duration::from_millis(10));
1414        }
1415        writer.shutdown_periodic_sync();
1416
1417        assert!(
1418            writer.fsync_count() > 0,
1419            "the periodic sync thread never fsynced"
1420        );
1421    }
1422
1423    /// `PerWrite` still syncs every append — the corroborating half, so that
1424    /// "Periodic does not sync" cannot pass by the counter being broken.
1425    #[test]
1426    fn per_write_fsyncs_every_batch() {
1427        let dir = tempfile::tempdir().unwrap();
1428        let config = WalConfig {
1429            fsync_policy: FsyncPolicy::PerWrite,
1430            ..Default::default()
1431        };
1432        let writer = Arc::new(WalWriter::open(dir.path(), config).unwrap());
1433        for _ in 0..8 {
1434            writer.append_batch(&[b"y".as_slice()]).unwrap();
1435        }
1436        assert_eq!(writer.fsync_count(), 8, "PerWrite must fsync per append");
1437    }
1438
1439    #[test]
1440    fn append_batch() {
1441        let dir = TempDir::new().unwrap();
1442        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1443
1444        let payloads: Vec<&[u8]> = vec![b"one", b"two", b"three"];
1445        let batch_seq = writer.append_batch(&payloads).unwrap();
1446        // Batch is written as a single atomic record.
1447        assert_eq!(batch_seq, 1);
1448        assert_eq!(writer.current_sequence(), 1);
1449
1450        // Verify the batch record can be read back and decoded.
1451        let records: Vec<_> = crate::wal::WalReader::open(
1452            &crate::wal::writer::list_wal_files(dir.path()).unwrap()[0].1,
1453        )
1454        .unwrap()
1455        .collect::<std::result::Result<Vec<_>, _>>()
1456        .unwrap();
1457        assert_eq!(records.len(), 1);
1458        let sub = crate::wal::decode_batch_payload(&records[0].payload).unwrap();
1459        assert_eq!(sub.len(), 3);
1460        assert_eq!(sub[0], b"one");
1461        assert_eq!(sub[1], b"two");
1462        assert_eq!(sub[2], b"three");
1463    }
1464
1465    #[test]
1466    fn append_batch_empty() {
1467        let dir = TempDir::new().unwrap();
1468        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1469        let last_seq = writer.append_batch(&[]).unwrap();
1470        assert_eq!(last_seq, 0);
1471    }
1472
1473    #[test]
1474    fn rotation_on_size() {
1475        let dir = TempDir::new().unwrap();
1476        let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1477
1478        // max_file_size=200; header=6, each record = 16 + 100 = 116 bytes.
1479        // After 1st append: 6 + 116 = 122 (< 200).
1480        // After 2nd append: 122 + 116 = 238 (>= 200 but rotation checks *before* write).
1481        // 3rd append sees file_size=238 >= 200 → triggers rotation.
1482        let payload = vec![0u8; 100];
1483        writer.append(&payload).unwrap();
1484        writer.append(&payload).unwrap();
1485        writer.append(&payload).unwrap(); // Triggers rotation
1486
1487        assert!(writer.file_count().unwrap() >= 2);
1488    }
1489
1490    #[test]
1491    fn manual_rotation() {
1492        let dir = TempDir::new().unwrap();
1493        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1494
1495        writer.append(b"before").unwrap();
1496        writer.rotate().unwrap();
1497        writer.append(b"after").unwrap();
1498
1499        assert_eq!(writer.file_count().unwrap(), 2);
1500    }
1501
1502    #[test]
1503    fn truncation_removes_old_files() {
1504        let dir = TempDir::new().unwrap();
1505        let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1506
1507        // Write enough to create multiple files
1508        let payload = vec![0u8; 100];
1509        for _ in 0..5 {
1510            writer.append(&payload).unwrap();
1511        }
1512
1513        let initial_count = writer.file_count().unwrap();
1514        assert!(
1515            initial_count >= 2,
1516            "Expected multiple files, got {initial_count}"
1517        );
1518
1519        // Truncate up to sequence 2
1520        let deleted = writer.truncate_before(2).unwrap();
1521        assert!(deleted > 0, "Expected some files deleted");
1522
1523        let final_count = writer.file_count().unwrap();
1524        assert!(final_count < initial_count);
1525    }
1526
1527    #[test]
1528    fn truncation_never_removes_active_file() {
1529        let dir = TempDir::new().unwrap();
1530        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1531
1532        writer.append(b"data").unwrap();
1533        writer.truncate_before(u64::MAX).unwrap();
1534
1535        // Active file must remain
1536        assert_eq!(writer.file_count().unwrap(), 1);
1537    }
1538
1539    #[test]
1540    fn truncation_idempotent() {
1541        let dir = TempDir::new().unwrap();
1542        let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1543
1544        let payload = vec![0u8; 100];
1545        for _ in 0..5 {
1546            writer.append(&payload).unwrap();
1547        }
1548
1549        let count1 = writer.truncate_before(2).unwrap();
1550        let count2 = writer.truncate_before(2).unwrap();
1551        assert_eq!(count2, 0, "Second truncation should be a no-op");
1552        assert!(count1 > 0 || count2 == 0);
1553    }
1554
1555    #[test]
1556    fn resume_from_existing_files() {
1557        let dir = TempDir::new().unwrap();
1558        let dir_path = dir.path().to_path_buf();
1559
1560        // Write some records
1561        {
1562            let writer = WalWriter::open(&dir_path, test_config()).unwrap();
1563            writer.append(b"one").unwrap();
1564            writer.append(b"two").unwrap();
1565            writer.append(b"three").unwrap();
1566            writer.sync().unwrap();
1567        }
1568
1569        // Reopen — should resume from sequence 4
1570        {
1571            let writer = WalWriter::open(&dir_path, test_config()).unwrap();
1572            let seq = writer.append(b"four").unwrap();
1573            assert_eq!(seq, 4);
1574        }
1575    }
1576
1577    #[test]
1578    fn per_write_fsync() {
1579        let dir = TempDir::new().unwrap();
1580        let config = WalConfig {
1581            fsync_policy: FsyncPolicy::PerWrite,
1582            max_file_size: 32 * 1024 * 1024,
1583            max_unflushed_wals: 10,
1584            compress: true,
1585            ..WalConfig::default()
1586        };
1587        let writer = WalWriter::open(dir.path(), config).unwrap();
1588        let seq = writer.append(b"important data").unwrap();
1589        assert_eq!(seq, 1);
1590    }
1591
1592    #[test]
1593    fn wal_full_error() {
1594        let dir = TempDir::new().unwrap();
1595        let config = WalConfig {
1596            fsync_policy: FsyncPolicy::PerBatch,
1597            max_file_size: 50,     // Very small
1598            max_unflushed_wals: 2, // Very low limit
1599            compress: true,
1600            ..WalConfig::default()
1601        };
1602        let writer = WalWriter::open(dir.path(), config).unwrap();
1603
1604        // Fill up WAL files
1605        let payload = vec![0u8; 30];
1606        let mut hit_full = false;
1607        for _ in 0..20 {
1608            match writer.append(&payload) {
1609                Err(WalError::Full { .. }) => {
1610                    hit_full = true;
1611                    break;
1612                }
1613                Ok(_) => {}
1614                Err(e) => panic!("Unexpected error: {e}"),
1615            }
1616        }
1617        assert!(hit_full, "Expected WalError::Full");
1618    }
1619
1620    #[test]
1621    fn wal_filename_format() {
1622        assert_eq!(wal_filename(1), "wal_00000000000000000001.cxwl");
1623        assert_eq!(wal_filename(42), "wal_00000000000000000042.cxwl");
1624    }
1625
1626    #[test]
1627    fn append_durable_single_thread() {
1628        let dir = TempDir::new().unwrap();
1629        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1630
1631        let seq1 = writer.append_durable(b"alpha").unwrap();
1632        let seq2 = writer.append_durable(b"beta").unwrap();
1633        assert_eq!(seq1, 1);
1634        assert_eq!(seq2, 2);
1635
1636        // Records should be durable — readable immediately after replay.
1637        let records = crate::wal::replay_all(dir.path()).unwrap();
1638        assert_eq!(records.len(), 2);
1639        assert_eq!(records[0].payload, b"alpha");
1640        assert_eq!(records[1].payload, b"beta");
1641    }
1642
1643    #[test]
1644    fn append_durable_per_write() {
1645        let dir = TempDir::new().unwrap();
1646        let config = WalConfig {
1647            fsync_policy: FsyncPolicy::PerWrite,
1648            ..test_config()
1649        };
1650        let writer = WalWriter::open(dir.path(), config).unwrap();
1651
1652        let seq = writer.append_durable(b"important").unwrap();
1653        assert_eq!(seq, 1);
1654
1655        let records = crate::wal::replay_all(dir.path()).unwrap();
1656        assert_eq!(records.len(), 1);
1657    }
1658
1659    #[test]
1660    fn group_commit_concurrent() {
1661        use std::sync::Arc;
1662        use std::thread;
1663
1664        let dir = TempDir::new().unwrap();
1665        let writer = Arc::new(WalWriter::open(dir.path(), test_config()).unwrap());
1666
1667        let num_threads = 8;
1668        let writes_per_thread = 50;
1669
1670        let handles: Vec<_> = (0..num_threads)
1671            .map(|t| {
1672                let w = Arc::clone(&writer);
1673                thread::spawn(move || {
1674                    for i in 0..writes_per_thread {
1675                        let payload = format!("t{t}_w{i}");
1676                        w.append_durable(payload.as_bytes()).unwrap();
1677                    }
1678                })
1679            })
1680            .collect();
1681
1682        for h in handles {
1683            h.join().unwrap();
1684        }
1685
1686        // Ensure all buffered data is flushed to disk before replay.
1687        writer.sync().unwrap();
1688
1689        // All records should be present and durable.
1690        let records = crate::wal::replay_all(dir.path()).unwrap();
1691        assert_eq!(records.len(), num_threads * writes_per_thread);
1692
1693        // Sequence numbers should be unique and contiguous.
1694        let mut seqs: Vec<u64> = records.iter().map(|r| r.sequence_no).collect();
1695        seqs.sort_unstable();
1696        seqs.dedup();
1697        assert_eq!(seqs.len(), num_threads * writes_per_thread);
1698    }
1699
1700    #[test]
1701    fn periodic_sync_background_thread() {
1702        use std::sync::Arc;
1703        use std::time::Duration;
1704
1705        let dir = TempDir::new().unwrap();
1706        let config = WalConfig {
1707            fsync_policy: FsyncPolicy::Periodic(Duration::from_millis(50)),
1708            ..test_config()
1709        };
1710        let writer = Arc::new(WalWriter::open(dir.path(), config).unwrap());
1711        writer.start_periodic_sync();
1712
1713        // Write records without explicit sync
1714        writer.append(b"periodic-1").unwrap();
1715        writer.append(b"periodic-2").unwrap();
1716
1717        // Poll rather than sleep for a fixed span. The property is that the
1718        // background thread *eventually* makes the records durable, not that
1719        // it does so within one interval: a 50 ms interval read after an 80 ms
1720        // sleep leaves 30 ms of margin, and a loaded runner does not provide
1721        // it. This failed on macOS with zero records replayed.
1722        let deadline = std::time::Instant::now() + Duration::from_secs(5);
1723        let records = loop {
1724            let records = crate::wal::replay_all(dir.path()).unwrap();
1725            if records.len() == 2 || std::time::Instant::now() >= deadline {
1726                break records;
1727            }
1728            std::thread::sleep(Duration::from_millis(10));
1729        };
1730        assert_eq!(
1731            records.len(),
1732            2,
1733            "the periodic sync thread never made the records durable"
1734        );
1735
1736        // Clean shutdown
1737        writer.shutdown_periodic_sync();
1738    }
1739
1740    // ── Poison flag tests ─────────────────────────────────
1741
1742    #[test]
1743    fn is_poisoned_starts_false() {
1744        let dir = TempDir::new().unwrap();
1745        let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1746        assert!(!writer.is_poisoned());
1747    }
1748
1749    #[test]
1750    fn clear_poison_noop_when_not_poisoned() {
1751        let dir = TempDir::new().unwrap();
1752        let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1753        writer.clear_poison().unwrap(); // should be no-op
1754        assert!(!writer.is_poisoned());
1755    }
1756
1757    #[test]
1758    fn poisoned_writer_rejects_writes() {
1759        let dir = TempDir::new().unwrap();
1760        let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1761        // Write once to make sure writer is functional
1762        writer.append(b"hello").unwrap();
1763
1764        // Manually set poison flag
1765        writer.inner.lock().poisoned = true;
1766        assert!(writer.is_poisoned());
1767
1768        // All write paths should fail with Poisoned
1769        let err = writer.append(b"world").unwrap_err();
1770        assert!(matches!(err, WalError::Poisoned));
1771
1772        let err = writer.append_batch(&[b"a" as &[u8]]).unwrap_err();
1773        assert!(matches!(err, WalError::Poisoned));
1774    }
1775
1776    #[test]
1777    fn clear_poison_restores_writes() {
1778        let dir = TempDir::new().unwrap();
1779        let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1780        writer.append(b"before").unwrap();
1781
1782        // Poison the writer
1783        writer.inner.lock().poisoned = true;
1784        assert!(writer.is_poisoned());
1785        assert!(writer.append(b"fail").is_err());
1786
1787        // Clear poison
1788        writer.clear_poison().unwrap();
1789        assert!(!writer.is_poisoned());
1790
1791        // Writes should work again
1792        writer.append(b"after").unwrap();
1793    }
1794
1795    #[test]
1796    fn clear_poison_data_survives_replay() {
1797        let dir = TempDir::new().unwrap();
1798        let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1799        writer.append(b"record-1").unwrap();
1800        writer.sync().unwrap();
1801
1802        // Poison + clear cycle
1803        writer.inner.lock().poisoned = true;
1804        writer.clear_poison().unwrap();
1805        writer.append(b"record-2").unwrap();
1806        writer.sync().unwrap();
1807
1808        // Replay and verify both records are intact
1809        let records = crate::wal::replay_all(dir.path()).unwrap();
1810        assert!(records.len() >= 2);
1811        assert_eq!(&records[0].payload, b"record-1");
1812        assert_eq!(&records[1].payload, b"record-2");
1813    }
1814}