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