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