1use std::fs::{self, File, OpenOptions};
4use std::io::{self, BufWriter, Seek, Write};
5use std::path::{Path, PathBuf};
6use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
7use std::sync::Arc;
8use std::time::Duration;
9
10use parking_lot::{Condvar, Mutex};
11
12use chronix_core::{FsyncPolicy, WalConfig, WalError};
13
14use crate::wal::sink::WalSink;
15use crate::wal::{
16 WalRecordType, WAL_HEADER_SIZE, WAL_MAGIC, WAL_PAYLOAD_VERSION, WAL_RECORD_HEADER_SIZE,
17 WAL_VERSION,
18};
19
20type SinkWriter = BufWriter<Box<dyn WalSink>>;
26
27const WAL_BUF_CAPACITY: usize = 64 * 1024;
30
31pub(crate) fn wal_filename(seq_start: u64) -> String {
33 format!("wal_{seq_start:020}.cxwl")
34}
35
36pub struct WalWriter {
49 dir: PathBuf,
51 config: WalConfig,
53 inner: Mutex<WalWriterInner>,
55 next_sequence: AtomicU64,
57 group_sync: Mutex<GroupSyncState>,
59 group_sync_cvar: Condvar,
61 periodic_shutdown: Arc<AtomicBool>,
63 periodic_handle: Mutex<Option<std::thread::JoinHandle<()>>>,
65 fsync_count: AtomicU64,
71 rewind_epoch: AtomicU64,
85}
86
87struct GroupSyncState {
89 synced_up_to: u64,
91 sync_in_progress: bool,
93}
94
95struct WalWriterInner {
96 writer: SinkWriter,
98 current_path: PathBuf,
100 file_start_seq: u64,
102 file_size: u64,
105 synced_offset: u64,
113 synced_seq: u64,
118 max_written_seq: u64,
121 poisoned: bool,
137 cached_file_count: usize,
141}
142
143impl WalWriter {
144 #[allow(clippy::needless_pass_by_value)] pub fn open(dir: impl Into<PathBuf>, config: WalConfig) -> Result<Self, WalError> {
156 let dir = dir.into();
157 fs::create_dir_all(&dir)?;
158
159 let (start_seq, max_seq) = Self::scan_existing_files(&dir)?;
161 let initial_file_count = list_wal_files(&dir)?.len();
163
164 let next_seq = if max_seq > 0 { max_seq + 1 } else { 1 };
165 let file_start_seq = if start_seq > 0 { next_seq } else { 1 };
166
167 let path = dir.join(wal_filename(file_start_seq));
168 let (writer, file_size) = Self::create_wal_file(&path)?;
169 let cached_file_count = if initial_file_count > 0 && start_seq > 0 {
171 initial_file_count + 1
173 } else {
174 1
176 };
177
178 let periodic_shutdown = Arc::new(AtomicBool::new(false));
179
180 let wal = Self {
181 dir,
182 config: config.clone(),
183 inner: Mutex::new(WalWriterInner {
184 writer,
185 current_path: path,
186 file_start_seq,
187 file_size,
188 synced_offset: file_size,
189 synced_seq: if max_seq > 0 { max_seq } else { 0 },
190 max_written_seq: if max_seq > 0 { max_seq } else { 0 },
191 poisoned: false,
192 cached_file_count,
193 }),
194 next_sequence: AtomicU64::new(next_seq),
195 group_sync: Mutex::new(GroupSyncState {
196 synced_up_to: 0,
197 sync_in_progress: false,
198 }),
199 group_sync_cvar: Condvar::new(),
200 periodic_shutdown: periodic_shutdown.clone(),
201 periodic_handle: Mutex::new(None),
202 fsync_count: AtomicU64::new(0),
203 rewind_epoch: AtomicU64::new(0),
204 };
205
206 Ok(wal)
207 }
208
209 pub fn is_poisoned(&self) -> bool {
212 self.inner.lock().poisoned
213 }
214
215 pub fn clear_poison(&self) -> Result<(), WalError> {
227 let mut inner = self.inner.lock();
228 if !inner.poisoned {
229 return Ok(());
230 }
231 let file = std::fs::OpenOptions::new()
233 .create(true)
234 .append(true)
235 .open(&inner.current_path)?;
236 let file_size = file.metadata()?.len();
237 inner.writer = BufWriter::with_capacity(WAL_BUF_CAPACITY, Self::sink(file));
238 inner.file_size = file_size;
239 inner.synced_offset = file_size;
240 inner.synced_seq = inner.max_written_seq;
241 inner.poisoned = false;
242 tracing::warn!("WAL poison flag cleared — writer resumed");
243 Ok(())
244 }
245
246 pub fn append(&self, payload: &[u8]) -> Result<u64, WalError> {
255 let prepared: std::borrow::Cow<'_, [u8]> = if self.config.compress {
258 std::borrow::Cow::Owned(crate::wal::compress_wal_payload(payload))
259 } else {
260 std::borrow::Cow::Borrowed(payload)
261 };
262
263 let mut inner = self.inner.lock();
264
265 let seq = self.next_sequence.fetch_add(1, Ordering::Relaxed);
266 self.maybe_rotate(&mut inner, seq)?;
267 Self::write_record(
268 &mut inner,
269 seq,
270 &prepared,
271 false,
272 WalRecordType::Data,
273 &self.rewind_epoch,
274 )?;
275
276 if self.config.fsync_policy == FsyncPolicy::PerWrite {
277 Self::flush_or_rewind(&mut inner, &self.rewind_epoch)?;
278 self.sync_locked(&mut inner)?;
279 }
280
281 metrics::gauge!("chronix_wal_sequence_number").set(seq as f64);
283 metrics::gauge!("chronix_wal_file_count").set(inner.cached_file_count as f64);
284 metrics::gauge!("chronix_wal_current_file_bytes").set(inner.file_size as f64);
285
286 Ok(seq)
287 }
288
289 pub fn append_batch(&self, payloads: &[&[u8]]) -> Result<u64, WalError> {
308 if payloads.is_empty() {
309 return Ok(self.next_sequence.load(Ordering::Relaxed).saturating_sub(1));
310 }
311
312 let batch_payload = crate::wal::encode_batch_payload(payloads);
316 let prepared = if self.config.compress {
317 crate::wal::compress_wal_payload(&batch_payload)
318 } else {
319 batch_payload
320 };
321
322 let seq;
324 let epoch_at_write;
325 {
326 let mut inner = self.inner.lock();
327
328 seq = self.next_sequence.fetch_add(1, Ordering::Relaxed);
329
330 self.maybe_rotate(&mut inner, seq)?;
331 Self::write_record(
332 &mut inner,
333 seq,
334 &prepared,
335 false,
336 WalRecordType::Batch,
337 &self.rewind_epoch,
338 )?;
339
340 if self.config.fsync_policy == FsyncPolicy::PerWrite {
346 Self::flush_or_rewind(&mut inner, &self.rewind_epoch)?;
347 self.sync_locked(&mut inner)?;
348
349 metrics::gauge!("chronix_wal_sequence_number").set(seq as f64);
350 metrics::gauge!("chronix_wal_file_count").set(inner.cached_file_count as f64);
351 metrics::gauge!("chronix_wal_current_file_bytes").set(inner.file_size as f64);
352
353 return Ok(seq);
354 }
355
356 metrics::gauge!("chronix_wal_sequence_number").set(seq as f64);
358 metrics::gauge!("chronix_wal_file_count").set(inner.cached_file_count as f64);
359 metrics::gauge!("chronix_wal_current_file_bytes").set(inner.file_size as f64);
360 epoch_at_write = self.rewind_epoch.load(Ordering::SeqCst);
363 }
364 if self.config.fsync_policy == FsyncPolicy::PerBatch {
369 self.group_sync(seq, epoch_at_write)?;
370 }
371
372 Ok(seq)
373 }
374
375 pub fn append_durable(&self, payload: &[u8]) -> Result<u64, WalError> {
396 let prepared: std::borrow::Cow<'_, [u8]> = if self.config.compress {
398 std::borrow::Cow::Owned(crate::wal::compress_wal_payload(payload))
399 } else {
400 std::borrow::Cow::Borrowed(payload)
401 };
402
403 let seq;
406 let epoch_at_write;
407 {
408 let mut inner = self.inner.lock();
409
410 seq = self.next_sequence.fetch_add(1, Ordering::Relaxed);
411 self.maybe_rotate(&mut inner, seq)?;
412 Self::write_record(
413 &mut inner,
414 seq,
415 &prepared,
416 false,
417 WalRecordType::Data,
418 &self.rewind_epoch,
419 )?;
420 epoch_at_write = self.rewind_epoch.load(Ordering::SeqCst);
422
423 if matches!(
429 self.config.fsync_policy,
430 FsyncPolicy::PerWrite | FsyncPolicy::Periodic(_)
431 ) {
432 Self::flush_or_rewind(&mut inner, &self.rewind_epoch)?;
433 self.sync_locked(&mut inner)?;
434 return Ok(seq);
435 }
436 }
437 if self.config.fsync_policy == FsyncPolicy::PerBatch {
441 self.group_sync(seq, epoch_at_write)?;
442 }
443
444 Ok(seq)
445 }
446
447 fn group_sync(&self, need_seq: u64, epoch_at_write: u64) -> Result<(), WalError> {
457 use std::time::Duration;
458
459 let still_ours = || {
463 if self.rewind_epoch.load(Ordering::SeqCst) == epoch_at_write {
464 Ok(())
465 } else {
466 Err(WalError::Io(io::Error::new(
467 io::ErrorKind::StorageFull,
468 "the WAL rewound after an I/O failure; this record was \
469 discarded before it could be made durable",
470 )))
471 }
472 };
473 let group_sync_timeout = Duration::from_secs(self.config.group_sync_timeout_secs);
475
476 let mut state = self.group_sync.lock();
477
478 if state.synced_up_to >= need_seq {
480 return still_ours();
481 }
482
483 while state.sync_in_progress {
485 let timed_out = self
486 .group_sync_cvar
487 .wait_for(&mut state, group_sync_timeout);
488
489 if state.synced_up_to >= need_seq {
490 return still_ours();
491 }
492
493 if timed_out.timed_out() {
496 break;
497 }
498 }
499
500 state.sync_in_progress = true;
502 drop(state);
503
504 let result = (|| -> Result<u64, WalError> {
506 let mut inner = self.inner.lock();
507 Self::flush_or_rewind(&mut inner, &self.rewind_epoch)?;
508 self.sync_locked(&mut inner)?;
509 Ok(inner.max_written_seq)
512 })();
513
514 let mut state = self.group_sync.lock();
516 state.sync_in_progress = false;
517 if let Ok(synced_seq) = result {
518 state.synced_up_to = state.synced_up_to.max(synced_seq);
520 }
521
522 self.group_sync_cvar.notify_all();
529
530 result.map(|_| ())
531 }
532
533 pub fn sync(&self) -> Result<(), WalError> {
540 let mut inner = self.inner.lock();
541 Self::flush_or_rewind(&mut inner, &self.rewind_epoch)?;
542 self.sync_locked(&mut inner)
543 }
544
545 fn sync_locked(&self, inner: &mut WalWriterInner) -> Result<(), WalError> {
554 let started = std::time::Instant::now();
555 if let Err(e) = inner.writer.get_ref().sync_data() {
556 inner.poisoned = true;
557 tracing::error!(error = %e, "WAL fsync failed — writer poisoned");
558 return Err(e.into());
559 }
560 inner.synced_offset = inner.file_size;
563 inner.synced_seq = inner.max_written_seq;
564 self.fsync_count.fetch_add(1, Ordering::Relaxed);
565 metrics::counter!("chronix_wal_fsync_total").increment(1);
566 metrics::histogram!("chronix_wal_write_duration_seconds")
567 .record(started.elapsed().as_secs_f64());
568 Ok(())
569 }
570
571 #[must_use]
577 pub fn fsync_count(&self) -> u64 {
578 self.fsync_count.load(Ordering::Relaxed)
579 }
580
581 pub fn start_periodic_sync(self: &Arc<Self>) {
590 if let FsyncPolicy::Periodic(interval) = self.config.fsync_policy {
591 let interval = if interval.is_zero() {
594 tracing::warn!(
595 "FsyncPolicy::Periodic(Duration::ZERO) would cause a busy loop, \
596 defaulting to 100ms"
597 );
598 std::time::Duration::from_millis(100)
599 } else {
600 interval
601 };
602
603 let shutdown = Arc::clone(&self.periodic_shutdown);
604 let writer = Arc::clone(self);
605 let handle = std::thread::Builder::new()
606 .name("wal-periodic-sync".into())
607 .spawn(move || {
608 while !shutdown.load(Ordering::Relaxed) {
609 std::thread::sleep(interval);
610 if shutdown.load(Ordering::Relaxed) {
611 break;
612 }
613 if let Err(e) = writer.sync() {
614 tracing::warn!(error = %e, "periodic WAL fsync failed");
615 }
616 }
617 });
618 match handle {
620 Ok(h) => {
621 let mut guard = self.periodic_handle.lock();
622 *guard = Some(h);
623 }
624 Err(e) => {
625 tracing::error!(
626 error = %e,
627 "Failed to spawn WAL periodic sync thread — \
628 periodic fsync disabled for this writer"
629 );
630 }
631 }
632 }
633 }
634
635 pub fn shutdown_periodic_sync(&self) {
637 self.periodic_shutdown.store(true, Ordering::Relaxed);
638 let mut guard = self.periodic_handle.lock();
639 if let Some(handle) = guard.take() {
640 let _ = handle.join();
641 }
642 }
643
644 pub fn rotate(&self) -> Result<(), WalError> {
651 let mut inner = self.inner.lock();
652 let next_seq = self.next_sequence.load(Ordering::Acquire);
653 self.force_rotate(&mut inner, next_seq)?;
654 Ok(())
655 }
656
657 pub fn truncate_before(&self, sequence_no: u64) -> Result<usize, WalError> {
666 let inner = self.inner.lock();
667 let current_path = inner.current_path.clone();
668 drop(inner);
669
670 let mut entries = list_wal_files(&self.dir)?;
671 entries.sort();
672
673 let mut deleted = 0;
674 for pair in entries.windows(2) {
677 let (file_start, path) = &pair[0];
678 let (next_start, _) = &pair[1];
679
680 if *path == current_path {
682 continue;
683 }
684
685 let max_seq_in_file = next_start.saturating_sub(1);
686
687 if max_seq_in_file <= sequence_no {
688 tracing::info!(
689 path = %path.display(),
690 seq_range = %format!("{file_start}..={max_seq_in_file}"),
691 "truncating WAL file"
692 );
693 fs::remove_file(path)?;
694 deleted += 1;
695 }
696 }
697
698 if deleted > 0 {
701 let dir_file = fs::File::open(&self.dir)?;
702 dir_file.sync_all()?;
703
704 let mut inner = self.inner.lock();
706 inner.cached_file_count = inner.cached_file_count.saturating_sub(deleted);
707
708 metrics::gauge!("chronix_wal_file_count").set(inner.cached_file_count as f64);
710 }
711
712 Ok(deleted)
713 }
714
715 pub fn truncate_before_age(&self, max_age: Duration) -> Result<usize, WalError> {
728 let inner = self.inner.lock();
729 let current_path = inner.current_path.clone();
730 drop(inner);
731
732 let entries = list_wal_files(&self.dir)?;
733 let now = std::time::SystemTime::now();
734
735 let mut deleted = 0;
736 for (_file_start, path) in &entries {
737 if *path == current_path {
739 continue;
740 }
741
742 let metadata = fs::metadata(path)?;
743 let modified = metadata.modified().map_err(|e| {
744 WalError::Io(io::Error::other(format!(
745 "cannot read mtime of {}: {e}",
746 path.display()
747 )))
748 })?;
749
750 let age = now.duration_since(modified).unwrap_or(Duration::ZERO);
751 if age >= max_age {
752 tracing::info!(
753 path = %path.display(),
754 age_secs = age.as_secs(),
755 max_age_secs = max_age.as_secs(),
756 "truncating WAL file (age-based)"
757 );
758 fs::remove_file(path)?;
759 deleted += 1;
760 }
761 }
762
763 if deleted > 0 {
765 let dir_file = fs::File::open(&self.dir)?;
766 dir_file.sync_all()?;
767
768 let mut inner = self.inner.lock();
770 inner.cached_file_count = inner.cached_file_count.saturating_sub(deleted);
771 }
772
773 Ok(deleted)
774 }
775
776 pub fn archive_before(&self, sequence_no: u64, archive_dir: &Path) -> Result<usize, WalError> {
791 let inner = self.inner.lock();
792 let current_path = inner.current_path.clone();
793 drop(inner);
794
795 fs::create_dir_all(archive_dir)?;
796
797 let mut entries = list_wal_files(&self.dir)?;
798 entries.sort();
799
800 let mut archived = 0;
801
802 for pair in entries.windows(2) {
803 let (file_start, path) = &pair[0];
804 let (next_start, _) = &pair[1];
805
806 if *path == current_path {
807 continue;
808 }
809
810 let max_seq_in_file = next_start.saturating_sub(1);
811
812 if max_seq_in_file <= sequence_no {
813 let file_name = path.file_name().ok_or_else(|| {
814 WalError::Io(io::Error::new(
815 io::ErrorKind::InvalidInput,
816 "WAL file has no filename",
817 ))
818 })?;
819 let archive_path = archive_dir.join(file_name);
820
821 fs::copy(path, &archive_path)?;
823
824 tracing::info!(
825 path = %path.display(),
826 archive = %archive_path.display(),
827 seq_range = %format!("{file_start}..={max_seq_in_file}"),
828 "archived WAL file"
829 );
830
831 fs::remove_file(path)?;
833 archived += 1;
834 }
835 }
836
837 if archived > 0 {
838 let dir_file = fs::File::open(&self.dir)?;
840 dir_file.sync_all()?;
841 let archive_file = fs::File::open(archive_dir)?;
842 archive_file.sync_all()?;
843
844 let mut inner = self.inner.lock();
845 inner.cached_file_count = inner.cached_file_count.saturating_sub(archived);
846 metrics::gauge!("chronix_wal_file_count").set(inner.cached_file_count as f64);
847 metrics::counter!("chronix_wal_archived_total").increment(archived as u64);
848 }
849
850 Ok(archived)
851 }
852
853 #[must_use]
861 pub fn memory_bytes(&self) -> usize {
862 self.inner.lock().writer.capacity()
863 }
864
865 #[inline]
867 #[must_use]
868 pub fn current_sequence(&self) -> u64 {
869 self.next_sequence.load(Ordering::Acquire).saturating_sub(1)
870 }
871
872 #[inline]
874 #[must_use]
875 pub fn dir(&self) -> &Path {
876 &self.dir
877 }
878
879 pub fn file_count(&self) -> Result<usize, WalError> {
885 Ok(self.inner.lock().cached_file_count)
889 }
890
891 pub fn total_bytes(&self) -> Result<u64, WalError> {
897 let files = list_wal_files(&self.dir)?;
898 let mut total: u64 = 0;
899 for (_, path) in &files {
900 if let Ok(meta) = fs::metadata(path) {
901 total += meta.len();
902 }
903 }
904 Ok(total)
905 }
906
907 pub fn emit_metrics(&self) {
912 let seq = self.current_sequence();
913 metrics::gauge!("chronix_wal_sequence_number").set(seq as f64);
914
915 let inner = self.inner.lock();
916 metrics::gauge!("chronix_wal_file_count").set(inner.cached_file_count as f64);
917 metrics::gauge!("chronix_wal_current_file_bytes").set(inner.file_size as f64);
918 }
919
920 pub fn close(mut self) -> Result<(), WalError> {
942 self.shutdown_periodic_sync();
944 let inner = self.inner.get_mut();
947 inner.writer.flush()?;
948 inner.writer.get_ref().sync_all()?;
949 Ok(())
950 }
951
952 fn rewind_to_durable(inner: &mut WalWriterInner, epoch: &AtomicU64) -> bool {
965 let good = inner.synced_offset;
966 let rebuilt = (|| -> io::Result<SinkWriter> {
967 let file = OpenOptions::new()
968 .write(true)
969 .create(false)
970 .open(&inner.current_path)?;
971 WalSink::set_len(&file, good)?;
972 let mut w = BufWriter::with_capacity(WAL_BUF_CAPACITY, Self::sink(file));
973 w.seek(io::SeekFrom::Start(good))?;
974 Ok(w)
975 })();
976
977 match rebuilt {
978 Ok(fresh) => {
979 let (_file, _discarded) = std::mem::replace(&mut inner.writer, fresh).into_parts();
983 inner.file_size = good;
984 inner.max_written_seq = inner.synced_seq;
988 epoch.fetch_add(1, Ordering::SeqCst);
989 true
990 }
991 Err(e) => {
992 tracing::error!(error = %e, offset = good, "WAL rewind failed");
993 false
994 }
995 }
996 }
997
998 fn flush_or_rewind(inner: &mut WalWriterInner, epoch: &AtomicU64) -> Result<(), WalError> {
1004 match inner.writer.flush() {
1005 Ok(()) => Ok(()),
1006 Err(e) => {
1007 if !Self::rewind_to_durable(inner, epoch) {
1008 inner.poisoned = true;
1009 tracing::error!("WAL rewind failed after a flush error — writer poisoned");
1010 }
1011 Err(WalError::Io(e))
1012 }
1013 }
1014 }
1015
1016 #[cfg(not(test))]
1024 fn sink(file: File) -> Box<dyn WalSink> {
1025 Box::new(file)
1026 }
1027
1028 #[cfg(test)]
1029 fn sink(file: File) -> Box<dyn WalSink> {
1030 match tests::write_budget() {
1031 Some(budget) => Box::new(crate::wal::sink::FullDiskSink::new(file, budget)),
1032 None => Box::new(file),
1033 }
1034 }
1035
1036 fn create_wal_file(path: &Path) -> Result<(SinkWriter, u64), WalError> {
1037 let file = OpenOptions::new()
1038 .create(true)
1039 .write(true)
1040 .truncate(true)
1041 .open(path)?;
1042
1043 let mut writer = BufWriter::with_capacity(WAL_BUF_CAPACITY, Self::sink(file));
1044
1045 writer.write_all(WAL_MAGIC)?;
1047 writer.write_all(&WAL_VERSION.to_le_bytes())?;
1048 writer.flush()?;
1049 writer.get_ref().sync_data()?;
1051
1052 if let Some(parent) = path.parent() {
1054 if let Ok(dir) = std::fs::File::open(parent) {
1055 dir.sync_all().map_err(|e| {
1056 WalError::Io(std::io::Error::new(
1057 e.kind(),
1058 format!(
1059 "failed to fsync WAL parent directory {}: {e}",
1060 parent.display()
1061 ),
1062 ))
1063 })?;
1064 }
1065 }
1066
1067 Ok((writer, WAL_HEADER_SIZE as u64))
1068 }
1069
1070 const MAX_PAYLOAD_SIZE: usize = 256 * 1024 * 1024;
1072
1073 fn write_record(
1074 inner: &mut WalWriterInner,
1075 seq: u64,
1076 payload: &[u8],
1077 compress: bool,
1078 record_type: WalRecordType,
1079 epoch: &AtomicU64,
1080 ) -> Result<(), WalError> {
1081 if inner.poisoned {
1083 return Err(WalError::Poisoned);
1084 }
1085
1086 if payload.len() > Self::MAX_PAYLOAD_SIZE {
1087 return Err(WalError::PayloadTooLarge {
1088 size: payload.len(),
1089 limit: Self::MAX_PAYLOAD_SIZE,
1090 });
1091 }
1092
1093 let payload: std::borrow::Cow<'_, [u8]> = if compress {
1095 std::borrow::Cow::Owned(crate::wal::compress_wal_payload(payload))
1096 } else {
1097 std::borrow::Cow::Borrowed(payload)
1098 };
1099
1100 #[allow(clippy::cast_possible_truncation)]
1101 let length = payload.len() as u32;
1102
1103 let type_ver_bytes = [record_type as u8, WAL_PAYLOAD_VERSION];
1106 let crc = crc32c::crc32c(&length.to_le_bytes());
1107 let crc = crc32c::crc32c_append(crc, &seq.to_le_bytes());
1108 let crc = crc32c::crc32c_append(crc, &type_ver_bytes);
1109 let crc = crc32c::crc32c_append(crc, &payload);
1110
1111 let saved_pos = inner.file_size;
1114
1115 let write_result = (|| -> Result<(), io::Error> {
1116 inner.writer.write_all(&crc.to_le_bytes())?;
1117 inner.writer.write_all(&length.to_le_bytes())?;
1118 inner.writer.write_all(&seq.to_le_bytes())?;
1119 inner.writer.write_all(&type_ver_bytes)?;
1120 inner.writer.write_all(&payload)?;
1121 Ok(())
1122 })();
1123
1124 if let Err(e) = write_result {
1125 tracing::error!(
1126 seq = seq,
1127 offset = saved_pos,
1128 durable_offset = inner.synced_offset,
1129 error = %e,
1130 "WAL record write failed — rewinding to the last durable offset"
1131 );
1132 if !Self::rewind_to_durable(inner, epoch) {
1136 tracing::error!(
1137 "WAL rewind failed after a write error — writer poisoned to \
1138 prevent corruption"
1139 );
1140 inner.poisoned = true;
1141 }
1142
1143 return Err(WalError::Io(e));
1144 }
1145
1146 inner.file_size += WAL_RECORD_HEADER_SIZE as u64 + payload.len() as u64;
1147 if seq > inner.max_written_seq {
1149 inner.max_written_seq = seq;
1150 }
1151
1152 Ok(())
1153 }
1154
1155 fn maybe_rotate(&self, inner: &mut WalWriterInner, next_seq: u64) -> Result<(), WalError> {
1156 if inner.file_size >= self.config.max_file_size as u64 {
1157 self.force_rotate(inner, next_seq)?;
1158 }
1159 Ok(())
1160 }
1161
1162 fn force_rotate(&self, inner: &mut WalWriterInner, next_seq: u64) -> Result<(), WalError> {
1163 if inner.cached_file_count >= self.config.max_unflushed_wals {
1165 return Err(WalError::Full {
1166 count: inner.cached_file_count,
1167 limit: self.config.max_unflushed_wals,
1168 });
1169 }
1170
1171 Self::flush_or_rewind(inner, &self.rewind_epoch)?;
1176 self.sync_locked(inner)?;
1177
1178 let new_path = self.dir.join(wal_filename(next_seq));
1180 let (writer, file_size) = Self::create_wal_file(&new_path)?;
1181
1182 tracing::info!(
1183 old_path = %inner.current_path.display(),
1184 new_path = %new_path.display(),
1185 next_seq = next_seq,
1186 "WAL rotated"
1187 );
1188
1189 inner.writer = writer;
1190 inner.current_path = new_path;
1191 inner.file_start_seq = next_seq;
1192 inner.file_size = file_size;
1193 inner.synced_offset = file_size;
1196 inner.synced_seq = inner.max_written_seq;
1197 inner.cached_file_count += 1;
1198
1199 Ok(())
1200 }
1201
1202 fn scan_existing_files(dir: &Path) -> Result<(u64, u64), WalError> {
1203 let files = list_wal_files(dir)?;
1204 if files.is_empty() {
1205 return Ok((0, 0));
1206 }
1207
1208 let min_start = files.iter().map(|(s, _)| *s).min().unwrap_or(0);
1209
1210 let max_file_start = files.iter().map(|(s, _)| *s).max().unwrap_or(0);
1215 let mut max_seq = max_file_start.max(min_start);
1216
1217 for (_, path) in files.iter().rev() {
1222 let reader = match crate::wal::reader::WalReader::open(path) {
1223 Ok(r) => r,
1224 Err(_) => continue,
1225 };
1226 let mut found_any = false;
1227 let mut last_seq = 0u64;
1228 for record in reader {
1229 match record {
1230 Ok(r) => {
1231 last_seq = r.sequence_no;
1232 found_any = true;
1233 }
1234 Err(_) => break, }
1236 }
1237 if found_any {
1238 max_seq = max_seq.max(last_seq);
1239 break; }
1241 }
1242
1243 Ok((min_start, max_seq))
1244 }
1245}
1246
1247impl Drop for WalWriter {
1248 fn drop(&mut self) {
1249 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1258 let inner = self.inner.get_mut();
1259 let _ = inner.writer.flush();
1260 let _ = inner.writer.get_ref().sync_all();
1261 self.periodic_shutdown.store(true, Ordering::Relaxed);
1262 let mut guard = self.periodic_handle.lock();
1263 if let Some(handle) = guard.take() {
1264 let _ = handle.join();
1265 }
1266 }));
1267 if result.is_err() {
1268 eprintln!("chronix: panic during WalWriter::drop suppressed to avoid abort");
1269 }
1270 }
1271}
1272
1273pub(crate) fn list_wal_files(dir: &Path) -> Result<Vec<(u64, PathBuf)>, WalError> {
1277 let mut files = Vec::new();
1278
1279 if !dir.exists() {
1280 return Ok(files);
1281 }
1282
1283 for entry in fs::read_dir(dir)? {
1284 let entry = entry?;
1285 let path = entry.path();
1286 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
1287 if name.starts_with("wal_")
1288 && path
1289 .extension()
1290 .is_some_and(|ext| ext.eq_ignore_ascii_case("cxwl"))
1291 {
1292 if let Some(seq_str) = name
1293 .strip_prefix("wal_")
1294 .and_then(|s| s.strip_suffix(".cxwl"))
1295 {
1296 if let Ok(seq) = seq_str.parse::<u64>() {
1297 files.push((seq, path));
1298 }
1299 }
1300 }
1301 }
1302 }
1303
1304 files.sort_by_key(|(seq, _)| *seq);
1305 Ok(files)
1306}
1307
1308#[cfg(test)]
1309mod tests {
1310 use super::*;
1311 use std::sync::atomic::AtomicI64;
1312 use tempfile::TempDir;
1313
1314 thread_local! {
1315 static WRITE_BUDGET: std::cell::RefCell<Option<std::sync::Arc<AtomicI64>>> =
1322 const { std::cell::RefCell::new(None) };
1323 }
1324
1325 pub(super) fn write_budget() -> Option<std::sync::Arc<AtomicI64>> {
1326 WRITE_BUDGET.with(|b| b.borrow().clone())
1327 }
1328
1329 fn wal_with_full_disk_after(dir: &Path, bytes: i64) -> (WalWriter, std::sync::Arc<AtomicI64>) {
1332 let budget = std::sync::Arc::new(AtomicI64::new(bytes));
1333 WRITE_BUDGET.with(|b| *b.borrow_mut() = Some(budget.clone()));
1334 let wal = WalWriter::open(dir, test_config()).expect("open");
1335 WRITE_BUDGET.with(|b| *b.borrow_mut() = None);
1336 (wal, budget)
1337 }
1338
1339 #[test]
1360 fn a_full_disk_is_recoverable_without_a_restart() {
1361 let dir = TempDir::new().expect("tempdir");
1362 let (wal, budget) = wal_with_full_disk_after(dir.path(), 128);
1364
1365 let payload = vec![7u8; 32];
1366 let first = wal.append_durable(&payload).expect("the first record fits");
1367
1368 budget.store(0, std::sync::atomic::Ordering::SeqCst);
1370 assert!(
1371 wal.append_durable(&payload).is_err(),
1372 "a record that cannot be made durable must not be acknowledged"
1373 );
1374
1375 budget.store(1 << 20, std::sync::atomic::Ordering::SeqCst);
1377 let recovered = wal
1378 .append_durable(&payload)
1379 .expect("the writer must accept records again once space returns");
1380 assert!(recovered > first);
1381
1382 wal.close().expect("close");
1385 let seqs: Vec<u64> = crate::wal::replay_all(dir.path())
1386 .expect("replay")
1387 .iter()
1388 .map(|r| r.sequence_no)
1389 .collect();
1390 assert_eq!(seqs, vec![first, recovered], "replay: {seqs:?}");
1391 }
1392
1393 #[test]
1403 fn a_rewind_refuses_the_records_it_discarded() {
1404 let dir = TempDir::new().expect("tempdir");
1405 let (wal, budget) = wal_with_full_disk_after(dir.path(), 128);
1406 let payload = vec![7u8; 32];
1407
1408 let first = wal.append_durable(&payload).expect("the first fits");
1409
1410 let orphan = wal.append(&payload).expect("buffered, not yet durable");
1413 budget.store(0, std::sync::atomic::Ordering::SeqCst);
1414 assert!(wal.sync().is_err(), "the flush must fail on a full disk");
1415 assert!(orphan > first);
1416
1417 budget.store(1 << 20, std::sync::atomic::Ordering::SeqCst);
1420 let later = wal.append_durable(&payload).expect("a later write");
1421 assert!(later > orphan);
1422
1423 wal.close().expect("close");
1425 let seqs: Vec<u64> = crate::wal::replay_all(dir.path())
1426 .expect("replay")
1427 .iter()
1428 .map(|r| r.sequence_no)
1429 .collect();
1430 assert!(
1431 !seqs.contains(&orphan),
1432 "sequence {orphan} was discarded by the rewind but replayed: {seqs:?}"
1433 );
1434 assert_eq!(seqs, vec![first, later]);
1435 }
1436
1437 #[test]
1446 fn a_failed_fsync_still_poisons() {
1447 let dir = TempDir::new().expect("tempdir");
1448 let wal = WalWriter::open(dir.path(), test_config()).expect("open");
1449 assert!(!wal.is_poisoned());
1450 wal.inner.lock().poisoned = true;
1451 assert!(matches!(wal.append(b"x"), Err(WalError::Poisoned)));
1452 wal.clear_poison().expect("clear_poison reopens the file");
1454 assert!(!wal.is_poisoned());
1455 wal.append(b"x").expect("writes resume after recovery");
1456 }
1457
1458 fn test_config() -> WalConfig {
1459 WalConfig {
1460 fsync_policy: FsyncPolicy::PerBatch,
1461 max_file_size: 32 * 1024 * 1024,
1462 max_unflushed_wals: 10,
1463 compress: true,
1464 ..WalConfig::default()
1465 }
1466 }
1467
1468 fn small_config() -> WalConfig {
1469 WalConfig {
1470 fsync_policy: FsyncPolicy::PerBatch,
1471 max_file_size: 200, max_unflushed_wals: 10,
1473 compress: false, ..WalConfig::default()
1475 }
1476 }
1477
1478 #[test]
1479 fn writer_creates_directory() {
1480 let dir = TempDir::new().unwrap();
1481 let wal_dir = dir.path().join("subdir").join("wal");
1482 let _writer = WalWriter::open(&wal_dir, test_config()).unwrap();
1483 assert!(wal_dir.exists());
1484 }
1485
1486 #[test]
1487 fn writer_creates_initial_file() {
1488 let dir = TempDir::new().unwrap();
1489 let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1490 assert_eq!(writer.file_count().unwrap(), 1);
1491 }
1492
1493 #[test]
1494 fn append_single_record() {
1495 let dir = TempDir::new().unwrap();
1496 let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1497
1498 let seq = writer.append(b"hello world").unwrap();
1499 assert_eq!(seq, 1);
1500
1501 let seq = writer.append(b"second").unwrap();
1502 assert_eq!(seq, 2);
1503
1504 assert_eq!(writer.current_sequence(), 2);
1505 }
1506
1507 #[test]
1518 fn periodic_fsync_does_not_sync_on_every_batch() {
1519 let dir = tempfile::tempdir().unwrap();
1520 let config = WalConfig {
1521 fsync_policy: FsyncPolicy::Periodic(Duration::from_secs(3600)),
1523 ..Default::default()
1524 };
1525 let writer = Arc::new(WalWriter::open(dir.path(), config).unwrap());
1526
1527 let payload = b"periodic-batch";
1528 for _ in 0..64 {
1529 writer.append_batch(&[payload]).unwrap();
1530 }
1531
1532 assert_eq!(
1533 writer.fsync_count(),
1534 0,
1535 "Periodic must not fsync per batch — that is PerWrite's contract"
1536 );
1537 }
1538
1539 #[test]
1542 fn periodic_fsync_thread_syncs_on_its_interval() {
1543 let dir = tempfile::tempdir().unwrap();
1544 let config = WalConfig {
1545 fsync_policy: FsyncPolicy::Periodic(Duration::from_millis(20)),
1546 ..Default::default()
1547 };
1548 let writer = Arc::new(WalWriter::open(dir.path(), config).unwrap());
1549 writer.start_periodic_sync();
1550
1551 writer.append_batch(&[b"x".as_slice()]).unwrap();
1552
1553 let deadline = std::time::Instant::now() + Duration::from_secs(5);
1554 while writer.fsync_count() == 0 && std::time::Instant::now() < deadline {
1555 std::thread::sleep(Duration::from_millis(10));
1556 }
1557 writer.shutdown_periodic_sync();
1558
1559 assert!(
1560 writer.fsync_count() > 0,
1561 "the periodic sync thread never fsynced"
1562 );
1563 }
1564
1565 #[test]
1568 fn per_write_fsyncs_every_batch() {
1569 let dir = tempfile::tempdir().unwrap();
1570 let config = WalConfig {
1571 fsync_policy: FsyncPolicy::PerWrite,
1572 ..Default::default()
1573 };
1574 let writer = Arc::new(WalWriter::open(dir.path(), config).unwrap());
1575 for _ in 0..8 {
1576 writer.append_batch(&[b"y".as_slice()]).unwrap();
1577 }
1578 assert_eq!(writer.fsync_count(), 8, "PerWrite must fsync per append");
1579 }
1580
1581 #[test]
1582 fn append_batch() {
1583 let dir = TempDir::new().unwrap();
1584 let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1585
1586 let payloads: Vec<&[u8]> = vec![b"one", b"two", b"three"];
1587 let batch_seq = writer.append_batch(&payloads).unwrap();
1588 assert_eq!(batch_seq, 1);
1590 assert_eq!(writer.current_sequence(), 1);
1591
1592 let records: Vec<_> = crate::wal::WalReader::open(
1594 &crate::wal::writer::list_wal_files(dir.path()).unwrap()[0].1,
1595 )
1596 .unwrap()
1597 .collect::<std::result::Result<Vec<_>, _>>()
1598 .unwrap();
1599 assert_eq!(records.len(), 1);
1600 let sub = crate::wal::decode_batch_payload(&records[0].payload).unwrap();
1601 assert_eq!(sub.len(), 3);
1602 assert_eq!(sub[0], b"one");
1603 assert_eq!(sub[1], b"two");
1604 assert_eq!(sub[2], b"three");
1605 }
1606
1607 #[test]
1608 fn append_batch_empty() {
1609 let dir = TempDir::new().unwrap();
1610 let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1611 let last_seq = writer.append_batch(&[]).unwrap();
1612 assert_eq!(last_seq, 0);
1613 }
1614
1615 #[test]
1616 fn rotation_on_size() {
1617 let dir = TempDir::new().unwrap();
1618 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1619
1620 let payload = vec![0u8; 100];
1625 writer.append(&payload).unwrap();
1626 writer.append(&payload).unwrap();
1627 writer.append(&payload).unwrap(); assert!(writer.file_count().unwrap() >= 2);
1630 }
1631
1632 #[test]
1633 fn manual_rotation() {
1634 let dir = TempDir::new().unwrap();
1635 let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1636
1637 writer.append(b"before").unwrap();
1638 writer.rotate().unwrap();
1639 writer.append(b"after").unwrap();
1640
1641 assert_eq!(writer.file_count().unwrap(), 2);
1642 }
1643
1644 #[test]
1645 fn truncation_removes_old_files() {
1646 let dir = TempDir::new().unwrap();
1647 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1648
1649 let payload = vec![0u8; 100];
1651 for _ in 0..5 {
1652 writer.append(&payload).unwrap();
1653 }
1654
1655 let initial_count = writer.file_count().unwrap();
1656 assert!(
1657 initial_count >= 2,
1658 "Expected multiple files, got {initial_count}"
1659 );
1660
1661 let deleted = writer.truncate_before(2).unwrap();
1663 assert!(deleted > 0, "Expected some files deleted");
1664
1665 let final_count = writer.file_count().unwrap();
1666 assert!(final_count < initial_count);
1667 }
1668
1669 #[test]
1670 fn truncation_never_removes_active_file() {
1671 let dir = TempDir::new().unwrap();
1672 let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1673
1674 writer.append(b"data").unwrap();
1675 writer.truncate_before(u64::MAX).unwrap();
1676
1677 assert_eq!(writer.file_count().unwrap(), 1);
1679 }
1680
1681 #[test]
1682 fn truncation_idempotent() {
1683 let dir = TempDir::new().unwrap();
1684 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1685
1686 let payload = vec![0u8; 100];
1687 for _ in 0..5 {
1688 writer.append(&payload).unwrap();
1689 }
1690
1691 let count1 = writer.truncate_before(2).unwrap();
1692 let count2 = writer.truncate_before(2).unwrap();
1693 assert_eq!(count2, 0, "Second truncation should be a no-op");
1694 assert!(count1 > 0 || count2 == 0);
1695 }
1696
1697 #[test]
1698 fn truncation_by_age_removes_old_files() {
1699 let dir = TempDir::new().unwrap();
1700 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1701
1702 let payload = vec![0u8; 100];
1703 for _ in 0..5 {
1704 writer.append(&payload).unwrap();
1705 }
1706
1707 let initial_count = writer.file_count().unwrap();
1708 assert!(
1709 initial_count >= 2,
1710 "Expected multiple files, got {initial_count}"
1711 );
1712
1713 let deleted = writer.truncate_before_age(Duration::from_secs(0)).unwrap();
1716 assert!(deleted > 0, "Expected some files deleted by age");
1717
1718 let final_count = writer.file_count().unwrap();
1719 assert!(final_count < initial_count);
1720 assert!(final_count >= 1);
1722 }
1723
1724 #[test]
1725 fn truncation_by_age_preserves_recent_files() {
1726 let dir = TempDir::new().unwrap();
1727 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1728
1729 let payload = vec![0u8; 100];
1730 for _ in 0..5 {
1731 writer.append(&payload).unwrap();
1732 }
1733
1734 let initial_count = writer.file_count().unwrap();
1735 assert!(initial_count >= 2);
1736
1737 let deleted = writer
1739 .truncate_before_age(Duration::from_secs(3600))
1740 .unwrap();
1741 assert_eq!(deleted, 0, "No files should be old enough");
1742
1743 assert_eq!(writer.file_count().unwrap(), initial_count);
1744 }
1745
1746 #[test]
1747 fn resume_from_existing_files() {
1748 let dir = TempDir::new().unwrap();
1749 let dir_path = dir.path().to_path_buf();
1750
1751 {
1753 let writer = WalWriter::open(&dir_path, test_config()).unwrap();
1754 writer.append(b"one").unwrap();
1755 writer.append(b"two").unwrap();
1756 writer.append(b"three").unwrap();
1757 writer.sync().unwrap();
1758 }
1759
1760 {
1762 let writer = WalWriter::open(&dir_path, test_config()).unwrap();
1763 let seq = writer.append(b"four").unwrap();
1764 assert_eq!(seq, 4);
1765 }
1766 }
1767
1768 #[test]
1769 fn per_write_fsync() {
1770 let dir = TempDir::new().unwrap();
1771 let config = WalConfig {
1772 fsync_policy: FsyncPolicy::PerWrite,
1773 max_file_size: 32 * 1024 * 1024,
1774 max_unflushed_wals: 10,
1775 compress: true,
1776 ..WalConfig::default()
1777 };
1778 let writer = WalWriter::open(dir.path(), config).unwrap();
1779 let seq = writer.append(b"important data").unwrap();
1780 assert_eq!(seq, 1);
1781 }
1782
1783 #[test]
1784 fn wal_full_error() {
1785 let dir = TempDir::new().unwrap();
1786 let config = WalConfig {
1787 fsync_policy: FsyncPolicy::PerBatch,
1788 max_file_size: 50, max_unflushed_wals: 2, compress: true,
1791 ..WalConfig::default()
1792 };
1793 let writer = WalWriter::open(dir.path(), config).unwrap();
1794
1795 let payload = vec![0u8; 30];
1797 let mut hit_full = false;
1798 for _ in 0..20 {
1799 match writer.append(&payload) {
1800 Err(WalError::Full { .. }) => {
1801 hit_full = true;
1802 break;
1803 }
1804 Ok(_) => {}
1805 Err(e) => panic!("Unexpected error: {e}"),
1806 }
1807 }
1808 assert!(hit_full, "Expected WalError::Full");
1809 }
1810
1811 #[test]
1812 fn wal_filename_format() {
1813 assert_eq!(wal_filename(1), "wal_00000000000000000001.cxwl");
1814 assert_eq!(wal_filename(42), "wal_00000000000000000042.cxwl");
1815 }
1816
1817 #[test]
1818 fn append_durable_single_thread() {
1819 let dir = TempDir::new().unwrap();
1820 let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1821
1822 let seq1 = writer.append_durable(b"alpha").unwrap();
1823 let seq2 = writer.append_durable(b"beta").unwrap();
1824 assert_eq!(seq1, 1);
1825 assert_eq!(seq2, 2);
1826
1827 let records = crate::wal::replay_all(dir.path()).unwrap();
1829 assert_eq!(records.len(), 2);
1830 assert_eq!(records[0].payload, b"alpha");
1831 assert_eq!(records[1].payload, b"beta");
1832 }
1833
1834 #[test]
1835 fn append_durable_per_write() {
1836 let dir = TempDir::new().unwrap();
1837 let config = WalConfig {
1838 fsync_policy: FsyncPolicy::PerWrite,
1839 ..test_config()
1840 };
1841 let writer = WalWriter::open(dir.path(), config).unwrap();
1842
1843 let seq = writer.append_durable(b"important").unwrap();
1844 assert_eq!(seq, 1);
1845
1846 let records = crate::wal::replay_all(dir.path()).unwrap();
1847 assert_eq!(records.len(), 1);
1848 }
1849
1850 #[test]
1851 fn group_commit_concurrent() {
1852 use std::sync::Arc;
1853 use std::thread;
1854
1855 let dir = TempDir::new().unwrap();
1856 let writer = Arc::new(WalWriter::open(dir.path(), test_config()).unwrap());
1857
1858 let num_threads = 8;
1859 let writes_per_thread = 50;
1860
1861 let handles: Vec<_> = (0..num_threads)
1862 .map(|t| {
1863 let w = Arc::clone(&writer);
1864 thread::spawn(move || {
1865 for i in 0..writes_per_thread {
1866 let payload = format!("t{t}_w{i}");
1867 w.append_durable(payload.as_bytes()).unwrap();
1868 }
1869 })
1870 })
1871 .collect();
1872
1873 for h in handles {
1874 h.join().unwrap();
1875 }
1876
1877 writer.sync().unwrap();
1879
1880 let records = crate::wal::replay_all(dir.path()).unwrap();
1882 assert_eq!(records.len(), num_threads * writes_per_thread);
1883
1884 let mut seqs: Vec<u64> = records.iter().map(|r| r.sequence_no).collect();
1886 seqs.sort_unstable();
1887 seqs.dedup();
1888 assert_eq!(seqs.len(), num_threads * writes_per_thread);
1889 }
1890
1891 #[test]
1892 fn periodic_sync_background_thread() {
1893 use std::sync::Arc;
1894 use std::time::Duration;
1895
1896 let dir = TempDir::new().unwrap();
1897 let config = WalConfig {
1898 fsync_policy: FsyncPolicy::Periodic(Duration::from_millis(50)),
1899 ..test_config()
1900 };
1901 let writer = Arc::new(WalWriter::open(dir.path(), config).unwrap());
1902 writer.start_periodic_sync();
1903
1904 writer.append(b"periodic-1").unwrap();
1906 writer.append(b"periodic-2").unwrap();
1907
1908 let deadline = std::time::Instant::now() + Duration::from_secs(5);
1914 let records = loop {
1915 let records = crate::wal::replay_all(dir.path()).unwrap();
1916 if records.len() == 2 || std::time::Instant::now() >= deadline {
1917 break records;
1918 }
1919 std::thread::sleep(Duration::from_millis(10));
1920 };
1921 assert_eq!(
1922 records.len(),
1923 2,
1924 "the periodic sync thread never made the records durable"
1925 );
1926
1927 writer.shutdown_periodic_sync();
1929 }
1930
1931 #[test]
1932 fn archive_before_copies_and_deletes() {
1933 let dir = TempDir::new().unwrap();
1934 let archive_dir = TempDir::new().unwrap();
1935 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1936
1937 let payload = vec![0u8; 100];
1939 for _ in 0..6 {
1940 writer.append(&payload).unwrap();
1941 }
1942 writer.sync().unwrap();
1943
1944 let files_before = writer.file_count().unwrap();
1945 assert!(files_before >= 2, "Need multiple files, got {files_before}");
1946
1947 let archived = writer.archive_before(3, archive_dir.path()).unwrap();
1949 assert!(archived >= 1, "Should archive at least 1 file");
1950
1951 let archive_files = list_wal_files(archive_dir.path()).unwrap();
1953 assert_eq!(archive_files.len(), archived);
1954
1955 let files_after = writer.file_count().unwrap();
1957 assert_eq!(files_after, files_before - archived);
1958
1959 let archived_records = crate::wal::replay_all(archive_dir.path()).unwrap();
1961 assert!(!archived_records.is_empty());
1962 for r in &archived_records {
1963 assert!(r.sequence_no <= 3);
1964 }
1965 }
1966
1967 #[test]
1968 fn archive_before_never_archives_active() {
1969 let dir = TempDir::new().unwrap();
1970 let archive_dir = TempDir::new().unwrap();
1971 let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1972
1973 writer.append(b"data").unwrap();
1975 writer.sync().unwrap();
1976 assert_eq!(writer.file_count().unwrap(), 1);
1977
1978 let archived = writer.archive_before(u64::MAX, archive_dir.path()).unwrap();
1979 assert_eq!(archived, 0, "Should not archive the active file");
1980 }
1981
1982 #[test]
1983 fn archive_creates_dir_if_missing() {
1984 let dir = TempDir::new().unwrap();
1985 let archive_base = TempDir::new().unwrap();
1986 let archive_dir = archive_base.path().join("nested").join("archive");
1987
1988 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1989 let payload = vec![0u8; 100];
1990 for _ in 0..4 {
1991 writer.append(&payload).unwrap();
1992 }
1993 writer.sync().unwrap();
1994
1995 let _archived = writer.archive_before(2, &archive_dir).unwrap();
1997 assert!(archive_dir.exists());
1998 }
1999
2000 #[test]
2001 fn archive_then_pitr_replay() {
2002 let dir = TempDir::new().unwrap();
2003 let archive_dir = TempDir::new().unwrap();
2004 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
2005
2006 let payload = vec![0u8; 100];
2007 for _ in 0..8 {
2008 writer.append(&payload).unwrap();
2009 }
2010 writer.sync().unwrap();
2011
2012 let _archived = writer.archive_before(4, archive_dir.path()).unwrap();
2014
2015 let records = crate::wal::replay_range(archive_dir.path(), 1, 4).unwrap();
2017 assert!(!records.is_empty());
2018 for r in &records {
2019 assert!(r.sequence_no > 1 && r.sequence_no <= 4);
2020 }
2021 }
2022
2023 #[test]
2026 fn is_poisoned_starts_false() {
2027 let dir = TempDir::new().unwrap();
2028 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
2029 assert!(!writer.is_poisoned());
2030 }
2031
2032 #[test]
2033 fn clear_poison_noop_when_not_poisoned() {
2034 let dir = TempDir::new().unwrap();
2035 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
2036 writer.clear_poison().unwrap(); assert!(!writer.is_poisoned());
2038 }
2039
2040 #[test]
2041 fn poisoned_writer_rejects_writes() {
2042 let dir = TempDir::new().unwrap();
2043 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
2044 writer.append(b"hello").unwrap();
2046
2047 writer.inner.lock().poisoned = true;
2049 assert!(writer.is_poisoned());
2050
2051 let err = writer.append(b"world").unwrap_err();
2053 assert!(matches!(err, WalError::Poisoned));
2054
2055 let err = writer.append_batch(&[b"a" as &[u8]]).unwrap_err();
2056 assert!(matches!(err, WalError::Poisoned));
2057 }
2058
2059 #[test]
2060 fn clear_poison_restores_writes() {
2061 let dir = TempDir::new().unwrap();
2062 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
2063 writer.append(b"before").unwrap();
2064
2065 writer.inner.lock().poisoned = true;
2067 assert!(writer.is_poisoned());
2068 assert!(writer.append(b"fail").is_err());
2069
2070 writer.clear_poison().unwrap();
2072 assert!(!writer.is_poisoned());
2073
2074 writer.append(b"after").unwrap();
2076 }
2077
2078 #[test]
2079 fn clear_poison_data_survives_replay() {
2080 let dir = TempDir::new().unwrap();
2081 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
2082 writer.append(b"record-1").unwrap();
2083 writer.sync().unwrap();
2084
2085 writer.inner.lock().poisoned = true;
2087 writer.clear_poison().unwrap();
2088 writer.append(b"record-2").unwrap();
2089 writer.sync().unwrap();
2090
2091 let records = crate::wal::replay_all(dir.path()).unwrap();
2093 assert!(records.len() >= 2);
2094 assert_eq!(&records[0].payload, b"record-1");
2095 assert_eq!(&records[1].payload, b"record-2");
2096 }
2097}