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