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::atomic::{AtomicBool, AtomicU64, Ordering};
7use std::sync::Arc;
8
9use parking_lot::{Condvar, Mutex};
10
11use chronix_core::{FsyncPolicy, WalConfig, WalError};
12
13use crate::wal::sink::WalSink;
14use crate::wal::{
15    WalRecordType, WAL_HEADER_SIZE, WAL_MAGIC, WAL_PAYLOAD_VERSION, WAL_RECORD_HEADER_SIZE,
16    WAL_VERSION,
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    /// Archive WAL files whose maximum sequence ≤ `sequence_no` to `archive_dir`,
715    /// then delete the originals.
716    ///
717    /// This is the foundation for **Point-in-Time Recovery (PITR)**: WAL files
718    /// are safely copied to an archive before removal, enabling future replays
719    /// from the archive. The archive directory is created if it doesn't exist.
720    ///
721    /// Never archives the active WAL file. Returns the number of files archived.
722    ///
723    /// # Errors
724    ///
725    /// Returns [`WalError`] on I/O failure. If the copy succeeds but the
726    /// delete fails, the archive copy is retained (safe — duplicates are
727    /// tolerated during replay thanks to sequence monotonicity checks).
728    pub fn archive_before(&self, sequence_no: u64, archive_dir: &Path) -> Result<usize, WalError> {
729        let inner = self.inner.lock();
730        let current_path = inner.current_path.clone();
731        drop(inner);
732
733        fs::create_dir_all(archive_dir)?;
734
735        let mut entries = list_wal_files(&self.dir)?;
736        entries.sort();
737
738        let mut archived = 0;
739
740        for pair in entries.windows(2) {
741            let (file_start, path) = &pair[0];
742            let (next_start, _) = &pair[1];
743
744            if *path == current_path {
745                continue;
746            }
747
748            let max_seq_in_file = next_start.saturating_sub(1);
749
750            if max_seq_in_file <= sequence_no {
751                let file_name = path.file_name().ok_or_else(|| {
752                    WalError::Io(io::Error::new(
753                        io::ErrorKind::InvalidInput,
754                        "WAL file has no filename",
755                    ))
756                })?;
757                let archive_path = archive_dir.join(file_name);
758
759                // Copy to archive first — if this fails, no data is lost.
760                fs::copy(path, &archive_path)?;
761
762                tracing::info!(
763                    path = %path.display(),
764                    archive = %archive_path.display(),
765                    seq_range = %format!("{file_start}..={max_seq_in_file}"),
766                    "archived WAL file"
767                );
768
769                // Delete original — safe because archive copy succeeded.
770                fs::remove_file(path)?;
771                archived += 1;
772            }
773        }
774
775        if archived > 0 {
776            // Fsync both directories for durability.
777            let dir_file = fs::File::open(&self.dir)?;
778            dir_file.sync_all()?;
779            let archive_file = fs::File::open(archive_dir)?;
780            archive_file.sync_all()?;
781
782            let mut inner = self.inner.lock();
783            inner.cached_file_count = inner.cached_file_count.saturating_sub(archived);
784            metrics::gauge!("chronix_wal_file_count").set(inner.cached_file_count as f64);
785            metrics::counter!("chronix_wal_archived_total").increment(archived as u64);
786        }
787
788        Ok(archived)
789    }
790
791    /// Approximate heap bytes held by the writer's own buffers.
792    ///
793    /// The `BufWriter`'s capacity, which is what stands between an append and
794    /// an `fsync`. It is one of the three terms the memtable budget does not
795    /// count, and unlike the other two it is fixed rather than a function of
796    /// the workload — so it is worth having as a number precisely because it
797    /// is easy to forget.
798    #[must_use]
799    pub fn memory_bytes(&self) -> usize {
800        self.inner.lock().writer.capacity()
801    }
802
803    /// Returns the current sequence number (the last assigned).
804    #[inline]
805    #[must_use]
806    pub fn current_sequence(&self) -> u64 {
807        self.next_sequence.load(Ordering::Acquire).saturating_sub(1)
808    }
809
810    /// Returns the path to the WAL directory.
811    #[inline]
812    #[must_use]
813    pub fn dir(&self) -> &Path {
814        &self.dir
815    }
816
817    /// Returns the number of WAL files in the directory.
818    ///
819    /// # Errors
820    ///
821    /// Returns [`WalError`] if the directory cannot be read.
822    pub fn file_count(&self) -> Result<usize, WalError> {
823        // The writer maintains this count across rotation and truncation;
824        // the directory is listed once, at open. Listing it here made every
825        // `insert()` pay a `readdir` for its admission check.
826        Ok(self.inner.lock().cached_file_count)
827    }
828
829    /// Returns the total size in bytes of all WAL files on disk.
830    ///
831    /// # Errors
832    ///
833    /// Returns [`WalError`] if the directory cannot be read.
834    pub fn total_bytes(&self) -> Result<u64, WalError> {
835        let files = list_wal_files(&self.dir)?;
836        let mut total: u64 = 0;
837        for (_, path) in &files {
838            if let Ok(meta) = fs::metadata(path) {
839                total += meta.len();
840            }
841        }
842        Ok(total)
843    }
844
845    /// Emit WAL gauge metrics for monitoring lag and resource usage.
846    ///
847    /// Emits `chronix_wal_file_count`, `chronix_wal_total_bytes`, and
848    /// `chronix_wal_sequence_number` gauges.
849    pub fn emit_metrics(&self) {
850        let seq = self.current_sequence();
851        metrics::gauge!("chronix_wal_sequence_number").set(seq as f64);
852
853        let inner = self.inner.lock();
854        metrics::gauge!("chronix_wal_file_count").set(inner.cached_file_count as f64);
855        metrics::gauge!("chronix_wal_current_file_bytes").set(inner.file_size as f64);
856    }
857
858    /// Explicitly close the WAL writer, flushing all data to stable storage.
859    ///
860    /// Unlike the implicit [`Drop`] cleanup, this method returns I/O errors
861    /// so close failures are not silently ignored.
862    ///
863    /// The shutdown sequence is:
864    /// 1. Stop the periodic sync thread (if running).
865    /// 2. Flush all buffered data to the operating system.
866    /// 3. Issue `sync_all` to commit data and metadata to stable storage.
867    ///
868    /// # Close-path efficiency
869    ///
870    /// This method takes ownership of `self` (`mut self`), which guarantees
871    /// exclusive access.  The inner state is obtained via
872    /// [`Mutex::get_mut`](parking_lot::Mutex::get_mut) (no-lock fast path),
873    /// so flush and fsync execute **outside any lock scope**.  This prevents
874    /// mutex contention with concurrent readers or writers during shutdown.
875    ///
876    /// # Errors
877    ///
878    /// Returns [`WalError`] if the flush or fsync fails.
879    pub fn close(mut self) -> Result<(), WalError> {
880        // Phase 1: stop background threads so no further syncs can race.
881        self.shutdown_periodic_sync();
882        // Phase 2: flush + sync without locking (get_mut requires &mut self,
883        // which we have since close() took ownership).
884        let inner = self.inner.get_mut();
885        inner.writer.flush()?;
886        inner.writer.get_ref().sync_all()?;
887        Ok(())
888    }
889
890    /// Rewind to the last durable offset, discarding whatever the buffer
891    /// still holds.
892    ///
893    /// It **rebuilds** the `BufWriter` rather than flushing it: a `BufWriter`
894    /// whose underlying write failed keeps the unwritten bytes, and there is
895    /// no API to drop them — so flushing before truncating fails for the same
896    /// reason the write did, and any later flush that succeeds writes a record
897    /// the caller was told had failed. `into_parts` takes the file back
898    /// *without* flushing, so the old writer's `Drop` cannot do that either.
899    ///
900    /// Returns `false` when the rewind itself failed, in which case the file
901    /// is in an unknown state and the caller must poison the writer.
902    fn rewind_to_durable(inner: &mut WalWriterInner, epoch: &AtomicU64) -> bool {
903        let good = inner.synced_offset;
904        let rebuilt = (|| -> io::Result<SinkWriter> {
905            let file = OpenOptions::new()
906                .write(true)
907                .create(false)
908                .open(&inner.current_path)?;
909            WalSink::set_len(&file, good)?;
910            let mut w = BufWriter::with_capacity(WAL_BUF_CAPACITY, Self::sink(file));
911            w.seek(io::SeekFrom::Start(good))?;
912            Ok(w)
913        })();
914
915        match rebuilt {
916            Ok(fresh) => {
917                // `into_parts` rather than a plain drop: dropping a
918                // `BufWriter` flushes it, which would write the bytes we just
919                // truncated away, at the wrong offset.
920                let (_file, _discarded) = std::mem::replace(&mut inner.writer, fresh).into_parts();
921                inner.file_size = good;
922                // Records past `good` are gone. Anything waiting on one of
923                // them must be refused rather than satisfied by a later
924                // sync's watermark.
925                inner.max_written_seq = inner.synced_seq;
926                epoch.fetch_add(1, Ordering::SeqCst);
927                true
928            }
929            Err(e) => {
930                tracing::error!(error = %e, offset = good, "WAL rewind failed");
931                false
932            }
933        }
934    }
935
936    /// Flush the buffer, rewinding to the last durable offset if it fails.
937    ///
938    /// Every `flush()` on the write path goes through here: a bare `flush()?`
939    /// leaves the failed bytes in the buffer, where the next successful flush
940    /// would write them.
941    fn flush_or_rewind(inner: &mut WalWriterInner, epoch: &AtomicU64) -> Result<(), WalError> {
942        match inner.writer.flush() {
943            Ok(()) => Ok(()),
944            Err(e) => {
945                if !Self::rewind_to_durable(inner, epoch) {
946                    inner.poisoned = true;
947                    tracing::error!("WAL rewind failed after a flush error — writer poisoned");
948                }
949                Err(WalError::Io(e))
950            }
951        }
952    }
953
954    // ── Internal ──────────────────────────────────────────────────────
955
956    /// Wrap a freshly opened WAL file as the sink the writer appends to.
957    ///
958    /// The single place a test can substitute a failing file, so every other
959    /// path — rotation, reopen after poison, initial creation — gets the same
960    /// treatment without each having to remember.
961    #[cfg(not(test))]
962    fn sink(file: File) -> Box<dyn WalSink> {
963        Box::new(file)
964    }
965
966    #[cfg(test)]
967    fn sink(file: File) -> Box<dyn WalSink> {
968        match tests::write_budget() {
969            Some(budget) => Box::new(crate::wal::sink::FullDiskSink::new(file, budget)),
970            None => Box::new(file),
971        }
972    }
973
974    fn create_wal_file(path: &Path) -> Result<(SinkWriter, u64), WalError> {
975        let file = OpenOptions::new()
976            .create(true)
977            .write(true)
978            .truncate(true)
979            .open(path)?;
980
981        let mut writer = BufWriter::with_capacity(WAL_BUF_CAPACITY, Self::sink(file));
982
983        // Write header
984        writer.write_all(WAL_MAGIC)?;
985        writer.write_all(&WAL_VERSION.to_le_bytes())?;
986        writer.flush()?;
987        // Ensure the header is durable before any records are appended.
988        writer.get_ref().sync_data()?;
989
990        // Fsync parent directory so the new file's directory entry is durable.
991        if let Some(parent) = path.parent() {
992            if let Ok(dir) = std::fs::File::open(parent) {
993                dir.sync_all().map_err(|e| {
994                    WalError::Io(std::io::Error::new(
995                        e.kind(),
996                        format!(
997                            "failed to fsync WAL parent directory {}: {e}",
998                            parent.display()
999                        ),
1000                    ))
1001                })?;
1002            }
1003        }
1004
1005        Ok((writer, WAL_HEADER_SIZE as u64))
1006    }
1007
1008    /// Maximum payload size per WAL record (256 MiB, matching reader limit).
1009    const MAX_PAYLOAD_SIZE: usize = 256 * 1024 * 1024;
1010
1011    fn write_record(
1012        inner: &mut WalWriterInner,
1013        seq: u64,
1014        payload: &[u8],
1015        compress: bool,
1016        record_type: WalRecordType,
1017        epoch: &AtomicU64,
1018    ) -> Result<(), WalError> {
1019        // Reject writes after an unrecoverable I/O error.
1020        if inner.poisoned {
1021            return Err(WalError::Poisoned);
1022        }
1023
1024        if payload.len() > Self::MAX_PAYLOAD_SIZE {
1025            return Err(WalError::PayloadTooLarge {
1026                size: payload.len(),
1027                limit: Self::MAX_PAYLOAD_SIZE,
1028            });
1029        }
1030
1031        // Optionally LZ4-compress the payload before writing.
1032        let payload: std::borrow::Cow<'_, [u8]> = if compress {
1033            std::borrow::Cow::Owned(crate::wal::compress_wal_payload(payload))
1034        } else {
1035            std::borrow::Cow::Borrowed(payload)
1036        };
1037
1038        #[allow(clippy::cast_possible_truncation)]
1039        let length = payload.len() as u32;
1040
1041        // Compute CRC incrementally over [length, sequence_no, record_type,
1042        // payload_version, payload] without allocating a temporary Vec.
1043        let type_ver_bytes = [record_type as u8, WAL_PAYLOAD_VERSION];
1044        let crc = crc32c::crc32c(&length.to_le_bytes());
1045        let crc = crc32c::crc32c_append(crc, &seq.to_le_bytes());
1046        let crc = crc32c::crc32c_append(crc, &type_ver_bytes);
1047        let crc = crc32c::crc32c_append(crc, &payload);
1048
1049        // Save pre-write position so we can truncate on partial failure.
1050        // This prevents corrupted half-records from poisoning subsequent writes.
1051        let saved_pos = inner.file_size;
1052
1053        let write_result = (|| -> Result<(), io::Error> {
1054            inner.writer.write_all(&crc.to_le_bytes())?;
1055            inner.writer.write_all(&length.to_le_bytes())?;
1056            inner.writer.write_all(&seq.to_le_bytes())?;
1057            inner.writer.write_all(&type_ver_bytes)?;
1058            inner.writer.write_all(&payload)?;
1059            Ok(())
1060        })();
1061
1062        if let Err(e) = write_result {
1063            tracing::error!(
1064                seq = seq,
1065                offset = saved_pos,
1066                durable_offset = inner.synced_offset,
1067                error = %e,
1068                "WAL record write failed — rewinding to the last durable offset"
1069            );
1070            // To the last *durable* offset, not `saved_pos`: the records
1071            // between the last successful `fsync` and here sit in the same
1072            // buffer that just failed, and none of them was acknowledged.
1073            if !Self::rewind_to_durable(inner, epoch) {
1074                tracing::error!(
1075                    "WAL rewind failed after a write error — writer poisoned to \
1076                     prevent corruption"
1077                );
1078                inner.poisoned = true;
1079            }
1080
1081            return Err(WalError::Io(e));
1082        }
1083
1084        inner.file_size += WAL_RECORD_HEADER_SIZE as u64 + payload.len() as u64;
1085        // Track the highest fully-written sequence for group_sync correctness.
1086        if seq > inner.max_written_seq {
1087            inner.max_written_seq = seq;
1088        }
1089
1090        Ok(())
1091    }
1092
1093    fn maybe_rotate(&self, inner: &mut WalWriterInner, next_seq: u64) -> Result<(), WalError> {
1094        if inner.file_size >= self.config.max_file_size as u64 {
1095            self.force_rotate(inner, next_seq)?;
1096        }
1097        Ok(())
1098    }
1099
1100    fn force_rotate(&self, inner: &mut WalWriterInner, next_seq: u64) -> Result<(), WalError> {
1101        // Use cached file count instead of readdir on every rotation
1102        if inner.cached_file_count >= self.config.max_unflushed_wals {
1103            return Err(WalError::Full {
1104                count: inner.cached_file_count,
1105                limit: self.config.max_unflushed_wals,
1106            });
1107        }
1108
1109        // Flush and sync the current file. Through `flush_or_rewind` like
1110        // every other write-path flush: a rotation that fails on a full disk
1111        // must leave the writer in a known state, not with a dirty buffer for
1112        // the next record to trip over.
1113        Self::flush_or_rewind(inner, &self.rewind_epoch)?;
1114        self.sync_locked(inner)?;
1115
1116        // Create new file
1117        let new_path = self.dir.join(wal_filename(next_seq));
1118        let (writer, file_size) = Self::create_wal_file(&new_path)?;
1119
1120        tracing::info!(
1121            old_path = %inner.current_path.display(),
1122            new_path = %new_path.display(),
1123            next_seq = next_seq,
1124            "WAL rotated"
1125        );
1126
1127        inner.writer = writer;
1128        inner.current_path = new_path;
1129        inner.file_start_seq = next_seq;
1130        inner.file_size = file_size;
1131        // `create_wal_file` fsyncs the header before returning, so the new
1132        // file is durable up to exactly that point.
1133        inner.synced_offset = file_size;
1134        inner.synced_seq = inner.max_written_seq;
1135        inner.cached_file_count += 1;
1136
1137        Ok(())
1138    }
1139
1140    fn scan_existing_files(dir: &Path) -> Result<(u64, u64), WalError> {
1141        let files = list_wal_files(dir)?;
1142        if files.is_empty() {
1143            return Ok((0, 0));
1144        }
1145
1146        let min_start = files.iter().map(|(s, _)| *s).min().unwrap_or(0);
1147
1148        // Use the maximum file-start sequence as the lower bound for max_seq.
1149        // This prevents sequence reuse when the last WAL file is empty (e.g.
1150        // crash during rotation): even without readable records, we know
1151        // sequences up to at least max_file_start - 1 were issued.
1152        let max_file_start = files.iter().map(|(s, _)| *s).max().unwrap_or(0);
1153        let mut max_seq = max_file_start.max(min_start);
1154
1155        // Scan backward through files to find the highest sequence number
1156        // from actual records (handles empty/corrupt last file gracefully).
1157        // Only track the last valid record per file since sequences
1158        // are monotonically increasing — no need to scan all records.
1159        for (_, path) in files.iter().rev() {
1160            let reader = match crate::wal::reader::WalReader::open(path) {
1161                Ok(r) => r,
1162                Err(_) => continue,
1163            };
1164            let mut found_any = false;
1165            let mut last_seq = 0u64;
1166            for record in reader {
1167                match record {
1168                    Ok(r) => {
1169                        last_seq = r.sequence_no;
1170                        found_any = true;
1171                    }
1172                    Err(_) => break, // truncated tail
1173                }
1174            }
1175            if found_any {
1176                max_seq = max_seq.max(last_seq);
1177                break; // highest records are in the last readable file
1178            }
1179        }
1180
1181        Ok((min_start, max_seq))
1182    }
1183}
1184
1185impl Drop for WalWriter {
1186    fn drop(&mut self) {
1187        // Flush buffered data and sync to disk on orderly
1188        // shutdown.  Uses `get_mut()` (not `lock()`) because `&mut self`
1189        // guarantees exclusive access — no mutex contention during Drop.
1190        // Without this, up to 64 KiB of buffered writes can be silently lost.
1191        //
1192        // Wrap in catch_unwind so a panic here (e.g. poisoned mutex,
1193        // unexpected I/O failure) does not trigger a double-panic abort if
1194        // Drop runs during stack unwinding.
1195        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1196            let inner = self.inner.get_mut();
1197            let _ = inner.writer.flush();
1198            let _ = inner.writer.get_ref().sync_all();
1199            self.periodic_shutdown.store(true, Ordering::Relaxed);
1200            let mut guard = self.periodic_handle.lock();
1201            if let Some(handle) = guard.take() {
1202                let _ = handle.join();
1203            }
1204        }));
1205        if result.is_err() {
1206            eprintln!("chronix: panic during WalWriter::drop suppressed to avoid abort");
1207        }
1208    }
1209}
1210
1211/// List all WAL files in a directory, sorted by starting sequence number.
1212///
1213/// Returns `(starting_sequence, path)` pairs.
1214pub(crate) fn list_wal_files(dir: &Path) -> Result<Vec<(u64, PathBuf)>, WalError> {
1215    let mut files = Vec::new();
1216
1217    if !dir.exists() {
1218        return Ok(files);
1219    }
1220
1221    for entry in fs::read_dir(dir)? {
1222        let entry = entry?;
1223        let path = entry.path();
1224        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
1225            if name.starts_with("wal_")
1226                && path
1227                    .extension()
1228                    .is_some_and(|ext| ext.eq_ignore_ascii_case("cxwl"))
1229            {
1230                if let Some(seq_str) = name
1231                    .strip_prefix("wal_")
1232                    .and_then(|s| s.strip_suffix(".cxwl"))
1233                {
1234                    if let Ok(seq) = seq_str.parse::<u64>() {
1235                        files.push((seq, path));
1236                    }
1237                }
1238            }
1239        }
1240    }
1241
1242    files.sort_by_key(|(seq, _)| *seq);
1243    Ok(files)
1244}
1245
1246#[cfg(test)]
1247mod tests {
1248    use super::*;
1249    use std::sync::atomic::AtomicI64;
1250    use std::time::Duration;
1251    use tempfile::TempDir;
1252
1253    thread_local! {
1254        /// Bytes the next WAL file opened on this thread will accept before
1255        /// reporting the disk full. `None` — the default and every other
1256        /// test — opens an ordinary file.
1257        ///
1258        /// Thread-local rather than global so a `--test-threads=N` run cannot
1259        /// have one test's full disk reach another's writer.
1260        static WRITE_BUDGET: std::cell::RefCell<Option<std::sync::Arc<AtomicI64>>> =
1261            const { std::cell::RefCell::new(None) };
1262    }
1263
1264    pub(super) fn write_budget() -> Option<std::sync::Arc<AtomicI64>> {
1265        WRITE_BUDGET.with(|b| b.borrow().clone())
1266    }
1267
1268    /// Open a writer whose file accepts `bytes` more bytes and then reports
1269    /// `ENOSPC`, and hand back the budget so the test can refill it.
1270    fn wal_with_full_disk_after(dir: &Path, bytes: i64) -> (WalWriter, std::sync::Arc<AtomicI64>) {
1271        let budget = std::sync::Arc::new(AtomicI64::new(bytes));
1272        WRITE_BUDGET.with(|b| *b.borrow_mut() = Some(budget.clone()));
1273        let wal = WalWriter::open(dir, test_config()).expect("open");
1274        WRITE_BUDGET.with(|b| *b.borrow_mut() = None);
1275        (wal, budget)
1276    }
1277
1278    // ── A full disk ───────────────────────────────────────────────────
1279    //
1280    // `ENOSPC` is the failure an embedded gateway on an SD card actually
1281    // meets, and nothing had ever driven it: every WAL test here was the
1282    // happy path plus a process `abort()`. `chronix-chaos` published a
1283    // `Fault::DiskFull` and injected nothing.
1284
1285    /// A caller is never told a record is durable when the disk is full —
1286    /// and the writer works again once space comes back.
1287    ///
1288    /// The second half is the defect. `ENOSPC` cannot surface at `append`:
1289    /// the record goes into a 64 KiB `BufWriter` and the syscall happens at
1290    /// the flush. So the failure lands in `group_sync`'s flush, which left
1291    /// the buffer holding bytes no write could place. Every later flush
1292    /// retried them and failed, and the next record's `write_all` drove the
1293    /// partial-write recovery, which flushes before truncating — failing for
1294    /// the same reason — and **poisoned the writer**. From then on every
1295    /// append returned `Poisoned`, permanently: nothing in the tree calls
1296    /// `clear_poison`. A log rotation that freed a megabyte a second later
1297    /// did not help; the database needed a restart to store another point.
1298    #[test]
1299    fn a_full_disk_is_recoverable_without_a_restart() {
1300        let dir = TempDir::new().expect("tempdir");
1301        // Room for the file header and one record, not two.
1302        let (wal, budget) = wal_with_full_disk_after(dir.path(), 128);
1303
1304        let payload = vec![7u8; 32];
1305        let first = wal.append_durable(&payload).expect("the first record fits");
1306
1307        // The disk fills.
1308        budget.store(0, std::sync::atomic::Ordering::SeqCst);
1309        assert!(
1310            wal.append_durable(&payload).is_err(),
1311            "a record that cannot be made durable must not be acknowledged"
1312        );
1313
1314        // Space comes back — a log rotated, an old segment was archived.
1315        budget.store(1 << 20, std::sync::atomic::Ordering::SeqCst);
1316        let recovered = wal
1317            .append_durable(&payload)
1318            .expect("the writer must accept records again once space returns");
1319        assert!(recovered > first);
1320
1321        // And what is on disk is exactly what was acknowledged: no torn tail,
1322        // and no resurrection of the record the caller was told had failed.
1323        wal.close().expect("close");
1324        let seqs: Vec<u64> = crate::wal::replay_all(dir.path())
1325            .expect("replay")
1326            .iter()
1327            .map(|r| r.sequence_no)
1328            .collect();
1329        assert_eq!(seqs, vec![first, recovered], "replay: {seqs:?}");
1330    }
1331
1332    /// A rewind is never reported to a waiter as a successful sync.
1333    ///
1334    /// Group commit's `synced_up_to` is a **watermark**, so it assumes the
1335    /// sequence space has no holes: a waiter on sequence 5 returns `Ok` the
1336    /// moment anything ≥ 5 is durable. A rewind puts a hole in it — 5 and 6
1337    /// can be discarded while 7 is written and synced afterwards — so without
1338    /// the epoch check the waiter on 5 would be told its record was made
1339    /// durable after it had been thrown away. That is the same defect the
1340    /// rewind exists to remove, reintroduced one level up.
1341    #[test]
1342    fn a_rewind_refuses_the_records_it_discarded() {
1343        let dir = TempDir::new().expect("tempdir");
1344        let (wal, budget) = wal_with_full_disk_after(dir.path(), 128);
1345        let payload = vec![7u8; 32];
1346
1347        let first = wal.append_durable(&payload).expect("the first fits");
1348
1349        // Buffer a record without syncing it, then fill the disk and force
1350        // the rewind that discards it.
1351        let orphan = wal.append(&payload).expect("buffered, not yet durable");
1352        budget.store(0, std::sync::atomic::Ordering::SeqCst);
1353        assert!(wal.sync().is_err(), "the flush must fail on a full disk");
1354        assert!(orphan > first);
1355
1356        // Space returns and a later record is made durable, moving the
1357        // watermark past the discarded sequence.
1358        budget.store(1 << 20, std::sync::atomic::Ordering::SeqCst);
1359        let later = wal.append_durable(&payload).expect("a later write");
1360        assert!(later > orphan);
1361
1362        // The discarded record must not be in the file.
1363        wal.close().expect("close");
1364        let seqs: Vec<u64> = crate::wal::replay_all(dir.path())
1365            .expect("replay")
1366            .iter()
1367            .map(|r| r.sequence_no)
1368            .collect();
1369        assert!(
1370            !seqs.contains(&orphan),
1371            "sequence {orphan} was discarded by the rewind but replayed: {seqs:?}"
1372        );
1373        assert_eq!(seqs, vec![first, later]);
1374    }
1375
1376    /// A failed `fsync` still poisons, and that is deliberate.
1377    ///
1378    /// The two failures are not the same. A write that fails leaves the file
1379    /// in a state we can restore exactly — truncate to the offset before the
1380    /// record — so it is an ordinary error. A `fsync` that fails does not:
1381    /// the kernel may drop the dirty pages *and clear the error*, so a second
1382    /// `fsync` can succeed over data that is gone. Refusing further writes is
1383    /// the only honest state, and this pins that the distinction survives.
1384    #[test]
1385    fn a_failed_fsync_still_poisons() {
1386        let dir = TempDir::new().expect("tempdir");
1387        let wal = WalWriter::open(dir.path(), test_config()).expect("open");
1388        assert!(!wal.is_poisoned());
1389        wal.inner.lock().poisoned = true;
1390        assert!(matches!(wal.append(b"x"), Err(WalError::Poisoned)));
1391        // …and the recovery path exists and is reachable.
1392        wal.clear_poison().expect("clear_poison reopens the file");
1393        assert!(!wal.is_poisoned());
1394        wal.append(b"x").expect("writes resume after recovery");
1395    }
1396
1397    fn test_config() -> WalConfig {
1398        WalConfig {
1399            fsync_policy: FsyncPolicy::PerBatch,
1400            max_file_size: 32 * 1024 * 1024,
1401            max_unflushed_wals: 10,
1402            compress: true,
1403            ..WalConfig::default()
1404        }
1405    }
1406
1407    fn small_config() -> WalConfig {
1408        WalConfig {
1409            fsync_policy: FsyncPolicy::PerBatch,
1410            max_file_size: 200, // Very small to trigger rotation
1411            max_unflushed_wals: 10,
1412            compress: false, // Disable compression so file sizes are predictable
1413            ..WalConfig::default()
1414        }
1415    }
1416
1417    #[test]
1418    fn writer_creates_directory() {
1419        let dir = TempDir::new().unwrap();
1420        let wal_dir = dir.path().join("subdir").join("wal");
1421        let _writer = WalWriter::open(&wal_dir, test_config()).unwrap();
1422        assert!(wal_dir.exists());
1423    }
1424
1425    #[test]
1426    fn writer_creates_initial_file() {
1427        let dir = TempDir::new().unwrap();
1428        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1429        assert_eq!(writer.file_count().unwrap(), 1);
1430    }
1431
1432    #[test]
1433    fn append_single_record() {
1434        let dir = TempDir::new().unwrap();
1435        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1436
1437        let seq = writer.append(b"hello world").unwrap();
1438        assert_eq!(seq, 1);
1439
1440        let seq = writer.append(b"second").unwrap();
1441        assert_eq!(seq, 2);
1442
1443        assert_eq!(writer.current_sequence(), 2);
1444    }
1445
1446    /// `Periodic` must coalesce syncs onto the background thread, not issue
1447    /// one per append.
1448    ///
1449    /// It did the opposite on the batch path — the path every write to the
1450    /// database takes — which made `FsyncPolicy::Periodic(5s)` identical to
1451    /// `PerWrite`. The preset exists for flash-backed embedded storage, where
1452    /// the fsync rate is the wear rate, so "identical to `PerWrite`" is the
1453    /// whole cost the setting was chosen to avoid. The single-record `append`
1454    /// honoured the policy, so the two paths disagreed about what one policy
1455    /// meant, and nothing compared them.
1456    #[test]
1457    fn periodic_fsync_does_not_sync_on_every_batch() {
1458        let dir = tempfile::tempdir().unwrap();
1459        let config = WalConfig {
1460            // Long enough that the background thread cannot fire during the run.
1461            fsync_policy: FsyncPolicy::Periodic(Duration::from_secs(3600)),
1462            ..Default::default()
1463        };
1464        let writer = Arc::new(WalWriter::open(dir.path(), config).unwrap());
1465
1466        let payload = b"periodic-batch";
1467        for _ in 0..64 {
1468            writer.append_batch(&[payload]).unwrap();
1469        }
1470
1471        assert_eq!(
1472            writer.fsync_count(),
1473            0,
1474            "Periodic must not fsync per batch — that is PerWrite's contract"
1475        );
1476    }
1477
1478    /// The other half: the background thread has to actually run, or
1479    /// `Periodic` means "never sync" rather than "sync on an interval".
1480    #[test]
1481    fn periodic_fsync_thread_syncs_on_its_interval() {
1482        let dir = tempfile::tempdir().unwrap();
1483        let config = WalConfig {
1484            fsync_policy: FsyncPolicy::Periodic(Duration::from_millis(20)),
1485            ..Default::default()
1486        };
1487        let writer = Arc::new(WalWriter::open(dir.path(), config).unwrap());
1488        writer.start_periodic_sync();
1489
1490        writer.append_batch(&[b"x".as_slice()]).unwrap();
1491
1492        let deadline = std::time::Instant::now() + Duration::from_secs(5);
1493        while writer.fsync_count() == 0 && std::time::Instant::now() < deadline {
1494            std::thread::sleep(Duration::from_millis(10));
1495        }
1496        writer.shutdown_periodic_sync();
1497
1498        assert!(
1499            writer.fsync_count() > 0,
1500            "the periodic sync thread never fsynced"
1501        );
1502    }
1503
1504    /// `PerWrite` still syncs every append — the corroborating half, so that
1505    /// "Periodic does not sync" cannot pass by the counter being broken.
1506    #[test]
1507    fn per_write_fsyncs_every_batch() {
1508        let dir = tempfile::tempdir().unwrap();
1509        let config = WalConfig {
1510            fsync_policy: FsyncPolicy::PerWrite,
1511            ..Default::default()
1512        };
1513        let writer = Arc::new(WalWriter::open(dir.path(), config).unwrap());
1514        for _ in 0..8 {
1515            writer.append_batch(&[b"y".as_slice()]).unwrap();
1516        }
1517        assert_eq!(writer.fsync_count(), 8, "PerWrite must fsync per append");
1518    }
1519
1520    #[test]
1521    fn append_batch() {
1522        let dir = TempDir::new().unwrap();
1523        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1524
1525        let payloads: Vec<&[u8]> = vec![b"one", b"two", b"three"];
1526        let batch_seq = writer.append_batch(&payloads).unwrap();
1527        // Batch is written as a single atomic record.
1528        assert_eq!(batch_seq, 1);
1529        assert_eq!(writer.current_sequence(), 1);
1530
1531        // Verify the batch record can be read back and decoded.
1532        let records: Vec<_> = crate::wal::WalReader::open(
1533            &crate::wal::writer::list_wal_files(dir.path()).unwrap()[0].1,
1534        )
1535        .unwrap()
1536        .collect::<std::result::Result<Vec<_>, _>>()
1537        .unwrap();
1538        assert_eq!(records.len(), 1);
1539        let sub = crate::wal::decode_batch_payload(&records[0].payload).unwrap();
1540        assert_eq!(sub.len(), 3);
1541        assert_eq!(sub[0], b"one");
1542        assert_eq!(sub[1], b"two");
1543        assert_eq!(sub[2], b"three");
1544    }
1545
1546    #[test]
1547    fn append_batch_empty() {
1548        let dir = TempDir::new().unwrap();
1549        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1550        let last_seq = writer.append_batch(&[]).unwrap();
1551        assert_eq!(last_seq, 0);
1552    }
1553
1554    #[test]
1555    fn rotation_on_size() {
1556        let dir = TempDir::new().unwrap();
1557        let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1558
1559        // max_file_size=200; header=6, each record = 16 + 100 = 116 bytes.
1560        // After 1st append: 6 + 116 = 122 (< 200).
1561        // After 2nd append: 122 + 116 = 238 (>= 200 but rotation checks *before* write).
1562        // 3rd append sees file_size=238 >= 200 → triggers rotation.
1563        let payload = vec![0u8; 100];
1564        writer.append(&payload).unwrap();
1565        writer.append(&payload).unwrap();
1566        writer.append(&payload).unwrap(); // Triggers rotation
1567
1568        assert!(writer.file_count().unwrap() >= 2);
1569    }
1570
1571    #[test]
1572    fn manual_rotation() {
1573        let dir = TempDir::new().unwrap();
1574        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1575
1576        writer.append(b"before").unwrap();
1577        writer.rotate().unwrap();
1578        writer.append(b"after").unwrap();
1579
1580        assert_eq!(writer.file_count().unwrap(), 2);
1581    }
1582
1583    #[test]
1584    fn truncation_removes_old_files() {
1585        let dir = TempDir::new().unwrap();
1586        let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1587
1588        // Write enough to create multiple files
1589        let payload = vec![0u8; 100];
1590        for _ in 0..5 {
1591            writer.append(&payload).unwrap();
1592        }
1593
1594        let initial_count = writer.file_count().unwrap();
1595        assert!(
1596            initial_count >= 2,
1597            "Expected multiple files, got {initial_count}"
1598        );
1599
1600        // Truncate up to sequence 2
1601        let deleted = writer.truncate_before(2).unwrap();
1602        assert!(deleted > 0, "Expected some files deleted");
1603
1604        let final_count = writer.file_count().unwrap();
1605        assert!(final_count < initial_count);
1606    }
1607
1608    #[test]
1609    fn truncation_never_removes_active_file() {
1610        let dir = TempDir::new().unwrap();
1611        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1612
1613        writer.append(b"data").unwrap();
1614        writer.truncate_before(u64::MAX).unwrap();
1615
1616        // Active file must remain
1617        assert_eq!(writer.file_count().unwrap(), 1);
1618    }
1619
1620    #[test]
1621    fn truncation_idempotent() {
1622        let dir = TempDir::new().unwrap();
1623        let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1624
1625        let payload = vec![0u8; 100];
1626        for _ in 0..5 {
1627            writer.append(&payload).unwrap();
1628        }
1629
1630        let count1 = writer.truncate_before(2).unwrap();
1631        let count2 = writer.truncate_before(2).unwrap();
1632        assert_eq!(count2, 0, "Second truncation should be a no-op");
1633        assert!(count1 > 0 || count2 == 0);
1634    }
1635
1636    #[test]
1637    fn resume_from_existing_files() {
1638        let dir = TempDir::new().unwrap();
1639        let dir_path = dir.path().to_path_buf();
1640
1641        // Write some records
1642        {
1643            let writer = WalWriter::open(&dir_path, test_config()).unwrap();
1644            writer.append(b"one").unwrap();
1645            writer.append(b"two").unwrap();
1646            writer.append(b"three").unwrap();
1647            writer.sync().unwrap();
1648        }
1649
1650        // Reopen — should resume from sequence 4
1651        {
1652            let writer = WalWriter::open(&dir_path, test_config()).unwrap();
1653            let seq = writer.append(b"four").unwrap();
1654            assert_eq!(seq, 4);
1655        }
1656    }
1657
1658    #[test]
1659    fn per_write_fsync() {
1660        let dir = TempDir::new().unwrap();
1661        let config = WalConfig {
1662            fsync_policy: FsyncPolicy::PerWrite,
1663            max_file_size: 32 * 1024 * 1024,
1664            max_unflushed_wals: 10,
1665            compress: true,
1666            ..WalConfig::default()
1667        };
1668        let writer = WalWriter::open(dir.path(), config).unwrap();
1669        let seq = writer.append(b"important data").unwrap();
1670        assert_eq!(seq, 1);
1671    }
1672
1673    #[test]
1674    fn wal_full_error() {
1675        let dir = TempDir::new().unwrap();
1676        let config = WalConfig {
1677            fsync_policy: FsyncPolicy::PerBatch,
1678            max_file_size: 50,     // Very small
1679            max_unflushed_wals: 2, // Very low limit
1680            compress: true,
1681            ..WalConfig::default()
1682        };
1683        let writer = WalWriter::open(dir.path(), config).unwrap();
1684
1685        // Fill up WAL files
1686        let payload = vec![0u8; 30];
1687        let mut hit_full = false;
1688        for _ in 0..20 {
1689            match writer.append(&payload) {
1690                Err(WalError::Full { .. }) => {
1691                    hit_full = true;
1692                    break;
1693                }
1694                Ok(_) => {}
1695                Err(e) => panic!("Unexpected error: {e}"),
1696            }
1697        }
1698        assert!(hit_full, "Expected WalError::Full");
1699    }
1700
1701    #[test]
1702    fn wal_filename_format() {
1703        assert_eq!(wal_filename(1), "wal_00000000000000000001.cxwl");
1704        assert_eq!(wal_filename(42), "wal_00000000000000000042.cxwl");
1705    }
1706
1707    #[test]
1708    fn append_durable_single_thread() {
1709        let dir = TempDir::new().unwrap();
1710        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1711
1712        let seq1 = writer.append_durable(b"alpha").unwrap();
1713        let seq2 = writer.append_durable(b"beta").unwrap();
1714        assert_eq!(seq1, 1);
1715        assert_eq!(seq2, 2);
1716
1717        // Records should be durable — readable immediately after replay.
1718        let records = crate::wal::replay_all(dir.path()).unwrap();
1719        assert_eq!(records.len(), 2);
1720        assert_eq!(records[0].payload, b"alpha");
1721        assert_eq!(records[1].payload, b"beta");
1722    }
1723
1724    #[test]
1725    fn append_durable_per_write() {
1726        let dir = TempDir::new().unwrap();
1727        let config = WalConfig {
1728            fsync_policy: FsyncPolicy::PerWrite,
1729            ..test_config()
1730        };
1731        let writer = WalWriter::open(dir.path(), config).unwrap();
1732
1733        let seq = writer.append_durable(b"important").unwrap();
1734        assert_eq!(seq, 1);
1735
1736        let records = crate::wal::replay_all(dir.path()).unwrap();
1737        assert_eq!(records.len(), 1);
1738    }
1739
1740    #[test]
1741    fn group_commit_concurrent() {
1742        use std::sync::Arc;
1743        use std::thread;
1744
1745        let dir = TempDir::new().unwrap();
1746        let writer = Arc::new(WalWriter::open(dir.path(), test_config()).unwrap());
1747
1748        let num_threads = 8;
1749        let writes_per_thread = 50;
1750
1751        let handles: Vec<_> = (0..num_threads)
1752            .map(|t| {
1753                let w = Arc::clone(&writer);
1754                thread::spawn(move || {
1755                    for i in 0..writes_per_thread {
1756                        let payload = format!("t{t}_w{i}");
1757                        w.append_durable(payload.as_bytes()).unwrap();
1758                    }
1759                })
1760            })
1761            .collect();
1762
1763        for h in handles {
1764            h.join().unwrap();
1765        }
1766
1767        // Ensure all buffered data is flushed to disk before replay.
1768        writer.sync().unwrap();
1769
1770        // All records should be present and durable.
1771        let records = crate::wal::replay_all(dir.path()).unwrap();
1772        assert_eq!(records.len(), num_threads * writes_per_thread);
1773
1774        // Sequence numbers should be unique and contiguous.
1775        let mut seqs: Vec<u64> = records.iter().map(|r| r.sequence_no).collect();
1776        seqs.sort_unstable();
1777        seqs.dedup();
1778        assert_eq!(seqs.len(), num_threads * writes_per_thread);
1779    }
1780
1781    #[test]
1782    fn periodic_sync_background_thread() {
1783        use std::sync::Arc;
1784        use std::time::Duration;
1785
1786        let dir = TempDir::new().unwrap();
1787        let config = WalConfig {
1788            fsync_policy: FsyncPolicy::Periodic(Duration::from_millis(50)),
1789            ..test_config()
1790        };
1791        let writer = Arc::new(WalWriter::open(dir.path(), config).unwrap());
1792        writer.start_periodic_sync();
1793
1794        // Write records without explicit sync
1795        writer.append(b"periodic-1").unwrap();
1796        writer.append(b"periodic-2").unwrap();
1797
1798        // Poll rather than sleep for a fixed span. The property is that the
1799        // background thread *eventually* makes the records durable, not that
1800        // it does so within one interval: a 50 ms interval read after an 80 ms
1801        // sleep leaves 30 ms of margin, and a loaded runner does not provide
1802        // it. This failed on macOS with zero records replayed.
1803        let deadline = std::time::Instant::now() + Duration::from_secs(5);
1804        let records = loop {
1805            let records = crate::wal::replay_all(dir.path()).unwrap();
1806            if records.len() == 2 || std::time::Instant::now() >= deadline {
1807                break records;
1808            }
1809            std::thread::sleep(Duration::from_millis(10));
1810        };
1811        assert_eq!(
1812            records.len(),
1813            2,
1814            "the periodic sync thread never made the records durable"
1815        );
1816
1817        // Clean shutdown
1818        writer.shutdown_periodic_sync();
1819    }
1820
1821    #[test]
1822    fn archive_before_copies_and_deletes() {
1823        let dir = TempDir::new().unwrap();
1824        let archive_dir = TempDir::new().unwrap();
1825        let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1826
1827        // Write enough to create multiple files
1828        let payload = vec![0u8; 100];
1829        for _ in 0..6 {
1830            writer.append(&payload).unwrap();
1831        }
1832        writer.sync().unwrap();
1833
1834        let files_before = writer.file_count().unwrap();
1835        assert!(files_before >= 2, "Need multiple files, got {files_before}");
1836
1837        // Archive all files with max_seq <= 3
1838        let archived = writer.archive_before(3, archive_dir.path()).unwrap();
1839        assert!(archived >= 1, "Should archive at least 1 file");
1840
1841        // Verify archive dir has the files
1842        let archive_files = list_wal_files(archive_dir.path()).unwrap();
1843        assert_eq!(archive_files.len(), archived);
1844
1845        // Verify original files were removed
1846        let files_after = writer.file_count().unwrap();
1847        assert_eq!(files_after, files_before - archived);
1848
1849        // Verify archived records are replayable
1850        let archived_records = crate::wal::replay_all(archive_dir.path()).unwrap();
1851        assert!(!archived_records.is_empty());
1852        for r in &archived_records {
1853            assert!(r.sequence_no <= 3);
1854        }
1855    }
1856
1857    #[test]
1858    fn archive_before_never_archives_active() {
1859        let dir = TempDir::new().unwrap();
1860        let archive_dir = TempDir::new().unwrap();
1861        let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1862
1863        // Only 1 file (the active one)
1864        writer.append(b"data").unwrap();
1865        writer.sync().unwrap();
1866        assert_eq!(writer.file_count().unwrap(), 1);
1867
1868        let archived = writer.archive_before(u64::MAX, archive_dir.path()).unwrap();
1869        assert_eq!(archived, 0, "Should not archive the active file");
1870    }
1871
1872    #[test]
1873    fn archive_creates_dir_if_missing() {
1874        let dir = TempDir::new().unwrap();
1875        let archive_base = TempDir::new().unwrap();
1876        let archive_dir = archive_base.path().join("nested").join("archive");
1877
1878        let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1879        let payload = vec![0u8; 100];
1880        for _ in 0..4 {
1881            writer.append(&payload).unwrap();
1882        }
1883        writer.sync().unwrap();
1884
1885        // Should create the nested directory
1886        let _archived = writer.archive_before(2, &archive_dir).unwrap();
1887        assert!(archive_dir.exists());
1888    }
1889
1890    #[test]
1891    fn archive_then_pitr_replay() {
1892        let dir = TempDir::new().unwrap();
1893        let archive_dir = TempDir::new().unwrap();
1894        let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1895
1896        let payload = vec![0u8; 100];
1897        for _ in 0..8 {
1898            writer.append(&payload).unwrap();
1899        }
1900        writer.sync().unwrap();
1901
1902        // Archive files with seq <= 4
1903        let _archived = writer.archive_before(4, archive_dir.path()).unwrap();
1904
1905        // Replay range from archive: records 2..=4
1906        let records = crate::wal::replay_range(archive_dir.path(), 1, 4).unwrap();
1907        assert!(!records.is_empty());
1908        for r in &records {
1909            assert!(r.sequence_no > 1 && r.sequence_no <= 4);
1910        }
1911    }
1912
1913    // ── Poison flag tests ─────────────────────────────────
1914
1915    #[test]
1916    fn is_poisoned_starts_false() {
1917        let dir = TempDir::new().unwrap();
1918        let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1919        assert!(!writer.is_poisoned());
1920    }
1921
1922    #[test]
1923    fn clear_poison_noop_when_not_poisoned() {
1924        let dir = TempDir::new().unwrap();
1925        let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1926        writer.clear_poison().unwrap(); // should be no-op
1927        assert!(!writer.is_poisoned());
1928    }
1929
1930    #[test]
1931    fn poisoned_writer_rejects_writes() {
1932        let dir = TempDir::new().unwrap();
1933        let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1934        // Write once to make sure writer is functional
1935        writer.append(b"hello").unwrap();
1936
1937        // Manually set poison flag
1938        writer.inner.lock().poisoned = true;
1939        assert!(writer.is_poisoned());
1940
1941        // All write paths should fail with Poisoned
1942        let err = writer.append(b"world").unwrap_err();
1943        assert!(matches!(err, WalError::Poisoned));
1944
1945        let err = writer.append_batch(&[b"a" as &[u8]]).unwrap_err();
1946        assert!(matches!(err, WalError::Poisoned));
1947    }
1948
1949    #[test]
1950    fn clear_poison_restores_writes() {
1951        let dir = TempDir::new().unwrap();
1952        let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1953        writer.append(b"before").unwrap();
1954
1955        // Poison the writer
1956        writer.inner.lock().poisoned = true;
1957        assert!(writer.is_poisoned());
1958        assert!(writer.append(b"fail").is_err());
1959
1960        // Clear poison
1961        writer.clear_poison().unwrap();
1962        assert!(!writer.is_poisoned());
1963
1964        // Writes should work again
1965        writer.append(b"after").unwrap();
1966    }
1967
1968    #[test]
1969    fn clear_poison_data_survives_replay() {
1970        let dir = TempDir::new().unwrap();
1971        let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1972        writer.append(b"record-1").unwrap();
1973        writer.sync().unwrap();
1974
1975        // Poison + clear cycle
1976        writer.inner.lock().poisoned = true;
1977        writer.clear_poison().unwrap();
1978        writer.append(b"record-2").unwrap();
1979        writer.sync().unwrap();
1980
1981        // Replay and verify both records are intact
1982        let records = crate::wal::replay_all(dir.path()).unwrap();
1983        assert!(records.len() >= 2);
1984        assert_eq!(&records[0].payload, b"record-1");
1985        assert_eq!(&records[1].payload, b"record-2");
1986    }
1987}