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;
8
9use parking_lot::{Condvar, Mutex};
10
11use chronix_core::{FsyncPolicy, WalConfig, WalError};
12
13use crate::wal::sink::WalSink;
14use crate::wal::{
15 WalRecordType, WAL_HEADER_SIZE, WAL_MAGIC, WAL_PAYLOAD_VERSION, WAL_RECORD_HEADER_SIZE,
16 WAL_VERSION,
17};
18
19type SinkWriter = BufWriter<Box<dyn WalSink>>;
25
26const WAL_BUF_CAPACITY: usize = 64 * 1024;
29
30pub(crate) fn wal_filename(seq_start: u64) -> String {
32 format!("wal_{seq_start:020}.cxwl")
33}
34
35pub struct WalWriter {
48 dir: PathBuf,
50 config: WalConfig,
52 inner: Mutex<WalWriterInner>,
54 next_sequence: AtomicU64,
56 group_sync: Mutex<GroupSyncState>,
58 group_sync_cvar: Condvar,
60 periodic_shutdown: Arc<AtomicBool>,
62 periodic_handle: Mutex<Option<std::thread::JoinHandle<()>>>,
64 fsync_count: AtomicU64,
70 rewind_epoch: AtomicU64,
84}
85
86struct GroupSyncState {
88 synced_up_to: u64,
90 sync_in_progress: bool,
92}
93
94struct WalWriterInner {
95 writer: SinkWriter,
97 current_path: PathBuf,
99 file_start_seq: u64,
101 file_size: u64,
104 synced_offset: u64,
112 synced_seq: u64,
117 max_written_seq: u64,
120 poisoned: bool,
136 cached_file_count: usize,
140}
141
142impl WalWriter {
143 #[allow(clippy::needless_pass_by_value)] 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 let (start_seq, max_seq) = Self::scan_existing_files(&dir)?;
160 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 let cached_file_count = if initial_file_count > 0 && start_seq > 0 {
170 initial_file_count + 1
172 } else {
173 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 pub fn is_poisoned(&self) -> bool {
211 self.inner.lock().poisoned
212 }
213
214 pub fn clear_poison(&self) -> Result<(), WalError> {
226 let mut inner = self.inner.lock();
227 if !inner.poisoned {
228 return Ok(());
229 }
230 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 pub fn append(&self, payload: &[u8]) -> Result<u64, WalError> {
254 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 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 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 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 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 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 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 epoch_at_write = self.rewind_epoch.load(Ordering::SeqCst);
362 }
363 if self.config.fsync_policy == FsyncPolicy::PerBatch {
368 self.group_sync(seq, epoch_at_write)?;
369 }
370
371 Ok(seq)
372 }
373
374 pub fn append_durable(&self, payload: &[u8]) -> Result<u64, WalError> {
395 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 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 epoch_at_write = self.rewind_epoch.load(Ordering::SeqCst);
421
422 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 if self.config.fsync_policy == FsyncPolicy::PerBatch {
440 self.group_sync(seq, epoch_at_write)?;
441 }
442
443 Ok(seq)
444 }
445
446 fn group_sync(&self, need_seq: u64, epoch_at_write: u64) -> Result<(), WalError> {
456 use std::time::Duration;
457
458 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 let group_sync_timeout = Duration::from_secs(self.config.group_sync_timeout_secs);
474
475 let mut state = self.group_sync.lock();
476
477 if state.synced_up_to >= need_seq {
479 return still_ours();
480 }
481
482 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 if timed_out.timed_out() {
495 break;
496 }
497 }
498
499 state.sync_in_progress = true;
501 drop(state);
502
503 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 Ok(inner.max_written_seq)
511 })();
512
513 let mut state = self.group_sync.lock();
515 state.sync_in_progress = false;
516 if let Ok(synced_seq) = result {
517 state.synced_up_to = state.synced_up_to.max(synced_seq);
519 }
520
521 self.group_sync_cvar.notify_all();
528
529 result.map(|_| ())
530 }
531
532 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 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 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 #[must_use]
576 pub fn fsync_count(&self) -> u64 {
577 self.fsync_count.load(Ordering::Relaxed)
578 }
579
580 pub fn start_periodic_sync(self: &Arc<Self>) {
589 if let FsyncPolicy::Periodic(interval) = self.config.fsync_policy {
590 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 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 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 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 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 for pair in entries.windows(2) {
676 let (file_start, path) = &pair[0];
677 let (next_start, _) = &pair[1];
678
679 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 if deleted > 0 {
700 let dir_file = fs::File::open(&self.dir)?;
701 dir_file.sync_all()?;
702
703 let mut inner = self.inner.lock();
705 inner.cached_file_count = inner.cached_file_count.saturating_sub(deleted);
706
707 metrics::gauge!("chronix_wal_file_count").set(inner.cached_file_count as f64);
709 }
710
711 Ok(deleted)
712 }
713
714 pub fn archive_before(&self, sequence_no: u64, archive_dir: &Path) -> Result<usize, WalError> {
729 let inner = self.inner.lock();
730 let current_path = inner.current_path.clone();
731 drop(inner);
732
733 fs::create_dir_all(archive_dir)?;
734
735 let mut entries = list_wal_files(&self.dir)?;
736 entries.sort();
737
738 let mut archived = 0;
739
740 for pair in entries.windows(2) {
741 let (file_start, path) = &pair[0];
742 let (next_start, _) = &pair[1];
743
744 if *path == current_path {
745 continue;
746 }
747
748 let max_seq_in_file = next_start.saturating_sub(1);
749
750 if max_seq_in_file <= sequence_no {
751 let file_name = path.file_name().ok_or_else(|| {
752 WalError::Io(io::Error::new(
753 io::ErrorKind::InvalidInput,
754 "WAL file has no filename",
755 ))
756 })?;
757 let archive_path = archive_dir.join(file_name);
758
759 fs::copy(path, &archive_path)?;
761
762 tracing::info!(
763 path = %path.display(),
764 archive = %archive_path.display(),
765 seq_range = %format!("{file_start}..={max_seq_in_file}"),
766 "archived WAL file"
767 );
768
769 fs::remove_file(path)?;
771 archived += 1;
772 }
773 }
774
775 if archived > 0 {
776 let dir_file = fs::File::open(&self.dir)?;
778 dir_file.sync_all()?;
779 let archive_file = fs::File::open(archive_dir)?;
780 archive_file.sync_all()?;
781
782 let mut inner = self.inner.lock();
783 inner.cached_file_count = inner.cached_file_count.saturating_sub(archived);
784 metrics::gauge!("chronix_wal_file_count").set(inner.cached_file_count as f64);
785 metrics::counter!("chronix_wal_archived_total").increment(archived as u64);
786 }
787
788 Ok(archived)
789 }
790
791 #[must_use]
799 pub fn memory_bytes(&self) -> usize {
800 self.inner.lock().writer.capacity()
801 }
802
803 #[inline]
805 #[must_use]
806 pub fn current_sequence(&self) -> u64 {
807 self.next_sequence.load(Ordering::Acquire).saturating_sub(1)
808 }
809
810 #[inline]
812 #[must_use]
813 pub fn dir(&self) -> &Path {
814 &self.dir
815 }
816
817 pub fn file_count(&self) -> Result<usize, WalError> {
823 Ok(self.inner.lock().cached_file_count)
827 }
828
829 pub fn total_bytes(&self) -> Result<u64, WalError> {
835 let files = list_wal_files(&self.dir)?;
836 let mut total: u64 = 0;
837 for (_, path) in &files {
838 if let Ok(meta) = fs::metadata(path) {
839 total += meta.len();
840 }
841 }
842 Ok(total)
843 }
844
845 pub fn emit_metrics(&self) {
850 let seq = self.current_sequence();
851 metrics::gauge!("chronix_wal_sequence_number").set(seq as f64);
852
853 let inner = self.inner.lock();
854 metrics::gauge!("chronix_wal_file_count").set(inner.cached_file_count as f64);
855 metrics::gauge!("chronix_wal_current_file_bytes").set(inner.file_size as f64);
856 }
857
858 pub fn close(mut self) -> Result<(), WalError> {
880 self.shutdown_periodic_sync();
882 let inner = self.inner.get_mut();
885 inner.writer.flush()?;
886 inner.writer.get_ref().sync_all()?;
887 Ok(())
888 }
889
890 fn rewind_to_durable(inner: &mut WalWriterInner, epoch: &AtomicU64) -> bool {
903 let good = inner.synced_offset;
904 let rebuilt = (|| -> io::Result<SinkWriter> {
905 let file = OpenOptions::new()
906 .write(true)
907 .create(false)
908 .open(&inner.current_path)?;
909 WalSink::set_len(&file, good)?;
910 let mut w = BufWriter::with_capacity(WAL_BUF_CAPACITY, Self::sink(file));
911 w.seek(io::SeekFrom::Start(good))?;
912 Ok(w)
913 })();
914
915 match rebuilt {
916 Ok(fresh) => {
917 let (_file, _discarded) = std::mem::replace(&mut inner.writer, fresh).into_parts();
921 inner.file_size = good;
922 inner.max_written_seq = inner.synced_seq;
926 epoch.fetch_add(1, Ordering::SeqCst);
927 true
928 }
929 Err(e) => {
930 tracing::error!(error = %e, offset = good, "WAL rewind failed");
931 false
932 }
933 }
934 }
935
936 fn flush_or_rewind(inner: &mut WalWriterInner, epoch: &AtomicU64) -> Result<(), WalError> {
942 match inner.writer.flush() {
943 Ok(()) => Ok(()),
944 Err(e) => {
945 if !Self::rewind_to_durable(inner, epoch) {
946 inner.poisoned = true;
947 tracing::error!("WAL rewind failed after a flush error — writer poisoned");
948 }
949 Err(WalError::Io(e))
950 }
951 }
952 }
953
954 #[cfg(not(test))]
962 fn sink(file: File) -> Box<dyn WalSink> {
963 Box::new(file)
964 }
965
966 #[cfg(test)]
967 fn sink(file: File) -> Box<dyn WalSink> {
968 match tests::write_budget() {
969 Some(budget) => Box::new(crate::wal::sink::FullDiskSink::new(file, budget)),
970 None => Box::new(file),
971 }
972 }
973
974 fn create_wal_file(path: &Path) -> Result<(SinkWriter, u64), WalError> {
975 let file = OpenOptions::new()
976 .create(true)
977 .write(true)
978 .truncate(true)
979 .open(path)?;
980
981 let mut writer = BufWriter::with_capacity(WAL_BUF_CAPACITY, Self::sink(file));
982
983 writer.write_all(WAL_MAGIC)?;
985 writer.write_all(&WAL_VERSION.to_le_bytes())?;
986 writer.flush()?;
987 writer.get_ref().sync_data()?;
989
990 if let Some(parent) = path.parent() {
992 if let Ok(dir) = std::fs::File::open(parent) {
993 dir.sync_all().map_err(|e| {
994 WalError::Io(std::io::Error::new(
995 e.kind(),
996 format!(
997 "failed to fsync WAL parent directory {}: {e}",
998 parent.display()
999 ),
1000 ))
1001 })?;
1002 }
1003 }
1004
1005 Ok((writer, WAL_HEADER_SIZE as u64))
1006 }
1007
1008 const MAX_PAYLOAD_SIZE: usize = 256 * 1024 * 1024;
1010
1011 fn write_record(
1012 inner: &mut WalWriterInner,
1013 seq: u64,
1014 payload: &[u8],
1015 compress: bool,
1016 record_type: WalRecordType,
1017 epoch: &AtomicU64,
1018 ) -> Result<(), WalError> {
1019 if inner.poisoned {
1021 return Err(WalError::Poisoned);
1022 }
1023
1024 if payload.len() > Self::MAX_PAYLOAD_SIZE {
1025 return Err(WalError::PayloadTooLarge {
1026 size: payload.len(),
1027 limit: Self::MAX_PAYLOAD_SIZE,
1028 });
1029 }
1030
1031 let payload: std::borrow::Cow<'_, [u8]> = if compress {
1033 std::borrow::Cow::Owned(crate::wal::compress_wal_payload(payload))
1034 } else {
1035 std::borrow::Cow::Borrowed(payload)
1036 };
1037
1038 #[allow(clippy::cast_possible_truncation)]
1039 let length = payload.len() as u32;
1040
1041 let type_ver_bytes = [record_type as u8, WAL_PAYLOAD_VERSION];
1044 let crc = crc32c::crc32c(&length.to_le_bytes());
1045 let crc = crc32c::crc32c_append(crc, &seq.to_le_bytes());
1046 let crc = crc32c::crc32c_append(crc, &type_ver_bytes);
1047 let crc = crc32c::crc32c_append(crc, &payload);
1048
1049 let saved_pos = inner.file_size;
1052
1053 let write_result = (|| -> Result<(), io::Error> {
1054 inner.writer.write_all(&crc.to_le_bytes())?;
1055 inner.writer.write_all(&length.to_le_bytes())?;
1056 inner.writer.write_all(&seq.to_le_bytes())?;
1057 inner.writer.write_all(&type_ver_bytes)?;
1058 inner.writer.write_all(&payload)?;
1059 Ok(())
1060 })();
1061
1062 if let Err(e) = write_result {
1063 tracing::error!(
1064 seq = seq,
1065 offset = saved_pos,
1066 durable_offset = inner.synced_offset,
1067 error = %e,
1068 "WAL record write failed — rewinding to the last durable offset"
1069 );
1070 if !Self::rewind_to_durable(inner, epoch) {
1074 tracing::error!(
1075 "WAL rewind failed after a write error — writer poisoned to \
1076 prevent corruption"
1077 );
1078 inner.poisoned = true;
1079 }
1080
1081 return Err(WalError::Io(e));
1082 }
1083
1084 inner.file_size += WAL_RECORD_HEADER_SIZE as u64 + payload.len() as u64;
1085 if seq > inner.max_written_seq {
1087 inner.max_written_seq = seq;
1088 }
1089
1090 Ok(())
1091 }
1092
1093 fn maybe_rotate(&self, inner: &mut WalWriterInner, next_seq: u64) -> Result<(), WalError> {
1094 if inner.file_size >= self.config.max_file_size as u64 {
1095 self.force_rotate(inner, next_seq)?;
1096 }
1097 Ok(())
1098 }
1099
1100 fn force_rotate(&self, inner: &mut WalWriterInner, next_seq: u64) -> Result<(), WalError> {
1101 if inner.cached_file_count >= self.config.max_unflushed_wals {
1103 return Err(WalError::Full {
1104 count: inner.cached_file_count,
1105 limit: self.config.max_unflushed_wals,
1106 });
1107 }
1108
1109 Self::flush_or_rewind(inner, &self.rewind_epoch)?;
1114 self.sync_locked(inner)?;
1115
1116 let new_path = self.dir.join(wal_filename(next_seq));
1118 let (writer, file_size) = Self::create_wal_file(&new_path)?;
1119
1120 tracing::info!(
1121 old_path = %inner.current_path.display(),
1122 new_path = %new_path.display(),
1123 next_seq = next_seq,
1124 "WAL rotated"
1125 );
1126
1127 inner.writer = writer;
1128 inner.current_path = new_path;
1129 inner.file_start_seq = next_seq;
1130 inner.file_size = file_size;
1131 inner.synced_offset = file_size;
1134 inner.synced_seq = inner.max_written_seq;
1135 inner.cached_file_count += 1;
1136
1137 Ok(())
1138 }
1139
1140 fn scan_existing_files(dir: &Path) -> Result<(u64, u64), WalError> {
1141 let files = list_wal_files(dir)?;
1142 if files.is_empty() {
1143 return Ok((0, 0));
1144 }
1145
1146 let min_start = files.iter().map(|(s, _)| *s).min().unwrap_or(0);
1147
1148 let max_file_start = files.iter().map(|(s, _)| *s).max().unwrap_or(0);
1153 let mut max_seq = max_file_start.max(min_start);
1154
1155 for (_, path) in files.iter().rev() {
1160 let reader = match crate::wal::reader::WalReader::open(path) {
1161 Ok(r) => r,
1162 Err(_) => continue,
1163 };
1164 let mut found_any = false;
1165 let mut last_seq = 0u64;
1166 for record in reader {
1167 match record {
1168 Ok(r) => {
1169 last_seq = r.sequence_no;
1170 found_any = true;
1171 }
1172 Err(_) => break, }
1174 }
1175 if found_any {
1176 max_seq = max_seq.max(last_seq);
1177 break; }
1179 }
1180
1181 Ok((min_start, max_seq))
1182 }
1183}
1184
1185impl Drop for WalWriter {
1186 fn drop(&mut self) {
1187 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1196 let inner = self.inner.get_mut();
1197 let _ = inner.writer.flush();
1198 let _ = inner.writer.get_ref().sync_all();
1199 self.periodic_shutdown.store(true, Ordering::Relaxed);
1200 let mut guard = self.periodic_handle.lock();
1201 if let Some(handle) = guard.take() {
1202 let _ = handle.join();
1203 }
1204 }));
1205 if result.is_err() {
1206 eprintln!("chronix: panic during WalWriter::drop suppressed to avoid abort");
1207 }
1208 }
1209}
1210
1211pub(crate) fn list_wal_files(dir: &Path) -> Result<Vec<(u64, PathBuf)>, WalError> {
1215 let mut files = Vec::new();
1216
1217 if !dir.exists() {
1218 return Ok(files);
1219 }
1220
1221 for entry in fs::read_dir(dir)? {
1222 let entry = entry?;
1223 let path = entry.path();
1224 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
1225 if name.starts_with("wal_")
1226 && path
1227 .extension()
1228 .is_some_and(|ext| ext.eq_ignore_ascii_case("cxwl"))
1229 {
1230 if let Some(seq_str) = name
1231 .strip_prefix("wal_")
1232 .and_then(|s| s.strip_suffix(".cxwl"))
1233 {
1234 if let Ok(seq) = seq_str.parse::<u64>() {
1235 files.push((seq, path));
1236 }
1237 }
1238 }
1239 }
1240 }
1241
1242 files.sort_by_key(|(seq, _)| *seq);
1243 Ok(files)
1244}
1245
1246#[cfg(test)]
1247mod tests {
1248 use super::*;
1249 use std::sync::atomic::AtomicI64;
1250 use std::time::Duration;
1251 use tempfile::TempDir;
1252
1253 thread_local! {
1254 static WRITE_BUDGET: std::cell::RefCell<Option<std::sync::Arc<AtomicI64>>> =
1261 const { std::cell::RefCell::new(None) };
1262 }
1263
1264 pub(super) fn write_budget() -> Option<std::sync::Arc<AtomicI64>> {
1265 WRITE_BUDGET.with(|b| b.borrow().clone())
1266 }
1267
1268 fn wal_with_full_disk_after(dir: &Path, bytes: i64) -> (WalWriter, std::sync::Arc<AtomicI64>) {
1271 let budget = std::sync::Arc::new(AtomicI64::new(bytes));
1272 WRITE_BUDGET.with(|b| *b.borrow_mut() = Some(budget.clone()));
1273 let wal = WalWriter::open(dir, test_config()).expect("open");
1274 WRITE_BUDGET.with(|b| *b.borrow_mut() = None);
1275 (wal, budget)
1276 }
1277
1278 #[test]
1299 fn a_full_disk_is_recoverable_without_a_restart() {
1300 let dir = TempDir::new().expect("tempdir");
1301 let (wal, budget) = wal_with_full_disk_after(dir.path(), 128);
1303
1304 let payload = vec![7u8; 32];
1305 let first = wal.append_durable(&payload).expect("the first record fits");
1306
1307 budget.store(0, std::sync::atomic::Ordering::SeqCst);
1309 assert!(
1310 wal.append_durable(&payload).is_err(),
1311 "a record that cannot be made durable must not be acknowledged"
1312 );
1313
1314 budget.store(1 << 20, std::sync::atomic::Ordering::SeqCst);
1316 let recovered = wal
1317 .append_durable(&payload)
1318 .expect("the writer must accept records again once space returns");
1319 assert!(recovered > first);
1320
1321 wal.close().expect("close");
1324 let seqs: Vec<u64> = crate::wal::replay_all(dir.path())
1325 .expect("replay")
1326 .iter()
1327 .map(|r| r.sequence_no)
1328 .collect();
1329 assert_eq!(seqs, vec![first, recovered], "replay: {seqs:?}");
1330 }
1331
1332 #[test]
1342 fn a_rewind_refuses_the_records_it_discarded() {
1343 let dir = TempDir::new().expect("tempdir");
1344 let (wal, budget) = wal_with_full_disk_after(dir.path(), 128);
1345 let payload = vec![7u8; 32];
1346
1347 let first = wal.append_durable(&payload).expect("the first fits");
1348
1349 let orphan = wal.append(&payload).expect("buffered, not yet durable");
1352 budget.store(0, std::sync::atomic::Ordering::SeqCst);
1353 assert!(wal.sync().is_err(), "the flush must fail on a full disk");
1354 assert!(orphan > first);
1355
1356 budget.store(1 << 20, std::sync::atomic::Ordering::SeqCst);
1359 let later = wal.append_durable(&payload).expect("a later write");
1360 assert!(later > orphan);
1361
1362 wal.close().expect("close");
1364 let seqs: Vec<u64> = crate::wal::replay_all(dir.path())
1365 .expect("replay")
1366 .iter()
1367 .map(|r| r.sequence_no)
1368 .collect();
1369 assert!(
1370 !seqs.contains(&orphan),
1371 "sequence {orphan} was discarded by the rewind but replayed: {seqs:?}"
1372 );
1373 assert_eq!(seqs, vec![first, later]);
1374 }
1375
1376 #[test]
1385 fn a_failed_fsync_still_poisons() {
1386 let dir = TempDir::new().expect("tempdir");
1387 let wal = WalWriter::open(dir.path(), test_config()).expect("open");
1388 assert!(!wal.is_poisoned());
1389 wal.inner.lock().poisoned = true;
1390 assert!(matches!(wal.append(b"x"), Err(WalError::Poisoned)));
1391 wal.clear_poison().expect("clear_poison reopens the file");
1393 assert!(!wal.is_poisoned());
1394 wal.append(b"x").expect("writes resume after recovery");
1395 }
1396
1397 fn test_config() -> WalConfig {
1398 WalConfig {
1399 fsync_policy: FsyncPolicy::PerBatch,
1400 max_file_size: 32 * 1024 * 1024,
1401 max_unflushed_wals: 10,
1402 compress: true,
1403 ..WalConfig::default()
1404 }
1405 }
1406
1407 fn small_config() -> WalConfig {
1408 WalConfig {
1409 fsync_policy: FsyncPolicy::PerBatch,
1410 max_file_size: 200, max_unflushed_wals: 10,
1412 compress: false, ..WalConfig::default()
1414 }
1415 }
1416
1417 #[test]
1418 fn writer_creates_directory() {
1419 let dir = TempDir::new().unwrap();
1420 let wal_dir = dir.path().join("subdir").join("wal");
1421 let _writer = WalWriter::open(&wal_dir, test_config()).unwrap();
1422 assert!(wal_dir.exists());
1423 }
1424
1425 #[test]
1426 fn writer_creates_initial_file() {
1427 let dir = TempDir::new().unwrap();
1428 let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1429 assert_eq!(writer.file_count().unwrap(), 1);
1430 }
1431
1432 #[test]
1433 fn append_single_record() {
1434 let dir = TempDir::new().unwrap();
1435 let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1436
1437 let seq = writer.append(b"hello world").unwrap();
1438 assert_eq!(seq, 1);
1439
1440 let seq = writer.append(b"second").unwrap();
1441 assert_eq!(seq, 2);
1442
1443 assert_eq!(writer.current_sequence(), 2);
1444 }
1445
1446 #[test]
1457 fn periodic_fsync_does_not_sync_on_every_batch() {
1458 let dir = tempfile::tempdir().unwrap();
1459 let config = WalConfig {
1460 fsync_policy: FsyncPolicy::Periodic(Duration::from_secs(3600)),
1462 ..Default::default()
1463 };
1464 let writer = Arc::new(WalWriter::open(dir.path(), config).unwrap());
1465
1466 let payload = b"periodic-batch";
1467 for _ in 0..64 {
1468 writer.append_batch(&[payload]).unwrap();
1469 }
1470
1471 assert_eq!(
1472 writer.fsync_count(),
1473 0,
1474 "Periodic must not fsync per batch — that is PerWrite's contract"
1475 );
1476 }
1477
1478 #[test]
1481 fn periodic_fsync_thread_syncs_on_its_interval() {
1482 let dir = tempfile::tempdir().unwrap();
1483 let config = WalConfig {
1484 fsync_policy: FsyncPolicy::Periodic(Duration::from_millis(20)),
1485 ..Default::default()
1486 };
1487 let writer = Arc::new(WalWriter::open(dir.path(), config).unwrap());
1488 writer.start_periodic_sync();
1489
1490 writer.append_batch(&[b"x".as_slice()]).unwrap();
1491
1492 let deadline = std::time::Instant::now() + Duration::from_secs(5);
1493 while writer.fsync_count() == 0 && std::time::Instant::now() < deadline {
1494 std::thread::sleep(Duration::from_millis(10));
1495 }
1496 writer.shutdown_periodic_sync();
1497
1498 assert!(
1499 writer.fsync_count() > 0,
1500 "the periodic sync thread never fsynced"
1501 );
1502 }
1503
1504 #[test]
1507 fn per_write_fsyncs_every_batch() {
1508 let dir = tempfile::tempdir().unwrap();
1509 let config = WalConfig {
1510 fsync_policy: FsyncPolicy::PerWrite,
1511 ..Default::default()
1512 };
1513 let writer = Arc::new(WalWriter::open(dir.path(), config).unwrap());
1514 for _ in 0..8 {
1515 writer.append_batch(&[b"y".as_slice()]).unwrap();
1516 }
1517 assert_eq!(writer.fsync_count(), 8, "PerWrite must fsync per append");
1518 }
1519
1520 #[test]
1521 fn append_batch() {
1522 let dir = TempDir::new().unwrap();
1523 let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1524
1525 let payloads: Vec<&[u8]> = vec![b"one", b"two", b"three"];
1526 let batch_seq = writer.append_batch(&payloads).unwrap();
1527 assert_eq!(batch_seq, 1);
1529 assert_eq!(writer.current_sequence(), 1);
1530
1531 let records: Vec<_> = crate::wal::WalReader::open(
1533 &crate::wal::writer::list_wal_files(dir.path()).unwrap()[0].1,
1534 )
1535 .unwrap()
1536 .collect::<std::result::Result<Vec<_>, _>>()
1537 .unwrap();
1538 assert_eq!(records.len(), 1);
1539 let sub = crate::wal::decode_batch_payload(&records[0].payload).unwrap();
1540 assert_eq!(sub.len(), 3);
1541 assert_eq!(sub[0], b"one");
1542 assert_eq!(sub[1], b"two");
1543 assert_eq!(sub[2], b"three");
1544 }
1545
1546 #[test]
1547 fn append_batch_empty() {
1548 let dir = TempDir::new().unwrap();
1549 let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1550 let last_seq = writer.append_batch(&[]).unwrap();
1551 assert_eq!(last_seq, 0);
1552 }
1553
1554 #[test]
1555 fn rotation_on_size() {
1556 let dir = TempDir::new().unwrap();
1557 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1558
1559 let payload = vec![0u8; 100];
1564 writer.append(&payload).unwrap();
1565 writer.append(&payload).unwrap();
1566 writer.append(&payload).unwrap(); assert!(writer.file_count().unwrap() >= 2);
1569 }
1570
1571 #[test]
1572 fn manual_rotation() {
1573 let dir = TempDir::new().unwrap();
1574 let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1575
1576 writer.append(b"before").unwrap();
1577 writer.rotate().unwrap();
1578 writer.append(b"after").unwrap();
1579
1580 assert_eq!(writer.file_count().unwrap(), 2);
1581 }
1582
1583 #[test]
1584 fn truncation_removes_old_files() {
1585 let dir = TempDir::new().unwrap();
1586 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1587
1588 let payload = vec![0u8; 100];
1590 for _ in 0..5 {
1591 writer.append(&payload).unwrap();
1592 }
1593
1594 let initial_count = writer.file_count().unwrap();
1595 assert!(
1596 initial_count >= 2,
1597 "Expected multiple files, got {initial_count}"
1598 );
1599
1600 let deleted = writer.truncate_before(2).unwrap();
1602 assert!(deleted > 0, "Expected some files deleted");
1603
1604 let final_count = writer.file_count().unwrap();
1605 assert!(final_count < initial_count);
1606 }
1607
1608 #[test]
1609 fn truncation_never_removes_active_file() {
1610 let dir = TempDir::new().unwrap();
1611 let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1612
1613 writer.append(b"data").unwrap();
1614 writer.truncate_before(u64::MAX).unwrap();
1615
1616 assert_eq!(writer.file_count().unwrap(), 1);
1618 }
1619
1620 #[test]
1621 fn truncation_idempotent() {
1622 let dir = TempDir::new().unwrap();
1623 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1624
1625 let payload = vec![0u8; 100];
1626 for _ in 0..5 {
1627 writer.append(&payload).unwrap();
1628 }
1629
1630 let count1 = writer.truncate_before(2).unwrap();
1631 let count2 = writer.truncate_before(2).unwrap();
1632 assert_eq!(count2, 0, "Second truncation should be a no-op");
1633 assert!(count1 > 0 || count2 == 0);
1634 }
1635
1636 #[test]
1637 fn resume_from_existing_files() {
1638 let dir = TempDir::new().unwrap();
1639 let dir_path = dir.path().to_path_buf();
1640
1641 {
1643 let writer = WalWriter::open(&dir_path, test_config()).unwrap();
1644 writer.append(b"one").unwrap();
1645 writer.append(b"two").unwrap();
1646 writer.append(b"three").unwrap();
1647 writer.sync().unwrap();
1648 }
1649
1650 {
1652 let writer = WalWriter::open(&dir_path, test_config()).unwrap();
1653 let seq = writer.append(b"four").unwrap();
1654 assert_eq!(seq, 4);
1655 }
1656 }
1657
1658 #[test]
1659 fn per_write_fsync() {
1660 let dir = TempDir::new().unwrap();
1661 let config = WalConfig {
1662 fsync_policy: FsyncPolicy::PerWrite,
1663 max_file_size: 32 * 1024 * 1024,
1664 max_unflushed_wals: 10,
1665 compress: true,
1666 ..WalConfig::default()
1667 };
1668 let writer = WalWriter::open(dir.path(), config).unwrap();
1669 let seq = writer.append(b"important data").unwrap();
1670 assert_eq!(seq, 1);
1671 }
1672
1673 #[test]
1674 fn wal_full_error() {
1675 let dir = TempDir::new().unwrap();
1676 let config = WalConfig {
1677 fsync_policy: FsyncPolicy::PerBatch,
1678 max_file_size: 50, max_unflushed_wals: 2, compress: true,
1681 ..WalConfig::default()
1682 };
1683 let writer = WalWriter::open(dir.path(), config).unwrap();
1684
1685 let payload = vec![0u8; 30];
1687 let mut hit_full = false;
1688 for _ in 0..20 {
1689 match writer.append(&payload) {
1690 Err(WalError::Full { .. }) => {
1691 hit_full = true;
1692 break;
1693 }
1694 Ok(_) => {}
1695 Err(e) => panic!("Unexpected error: {e}"),
1696 }
1697 }
1698 assert!(hit_full, "Expected WalError::Full");
1699 }
1700
1701 #[test]
1702 fn wal_filename_format() {
1703 assert_eq!(wal_filename(1), "wal_00000000000000000001.cxwl");
1704 assert_eq!(wal_filename(42), "wal_00000000000000000042.cxwl");
1705 }
1706
1707 #[test]
1708 fn append_durable_single_thread() {
1709 let dir = TempDir::new().unwrap();
1710 let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1711
1712 let seq1 = writer.append_durable(b"alpha").unwrap();
1713 let seq2 = writer.append_durable(b"beta").unwrap();
1714 assert_eq!(seq1, 1);
1715 assert_eq!(seq2, 2);
1716
1717 let records = crate::wal::replay_all(dir.path()).unwrap();
1719 assert_eq!(records.len(), 2);
1720 assert_eq!(records[0].payload, b"alpha");
1721 assert_eq!(records[1].payload, b"beta");
1722 }
1723
1724 #[test]
1725 fn append_durable_per_write() {
1726 let dir = TempDir::new().unwrap();
1727 let config = WalConfig {
1728 fsync_policy: FsyncPolicy::PerWrite,
1729 ..test_config()
1730 };
1731 let writer = WalWriter::open(dir.path(), config).unwrap();
1732
1733 let seq = writer.append_durable(b"important").unwrap();
1734 assert_eq!(seq, 1);
1735
1736 let records = crate::wal::replay_all(dir.path()).unwrap();
1737 assert_eq!(records.len(), 1);
1738 }
1739
1740 #[test]
1741 fn group_commit_concurrent() {
1742 use std::sync::Arc;
1743 use std::thread;
1744
1745 let dir = TempDir::new().unwrap();
1746 let writer = Arc::new(WalWriter::open(dir.path(), test_config()).unwrap());
1747
1748 let num_threads = 8;
1749 let writes_per_thread = 50;
1750
1751 let handles: Vec<_> = (0..num_threads)
1752 .map(|t| {
1753 let w = Arc::clone(&writer);
1754 thread::spawn(move || {
1755 for i in 0..writes_per_thread {
1756 let payload = format!("t{t}_w{i}");
1757 w.append_durable(payload.as_bytes()).unwrap();
1758 }
1759 })
1760 })
1761 .collect();
1762
1763 for h in handles {
1764 h.join().unwrap();
1765 }
1766
1767 writer.sync().unwrap();
1769
1770 let records = crate::wal::replay_all(dir.path()).unwrap();
1772 assert_eq!(records.len(), num_threads * writes_per_thread);
1773
1774 let mut seqs: Vec<u64> = records.iter().map(|r| r.sequence_no).collect();
1776 seqs.sort_unstable();
1777 seqs.dedup();
1778 assert_eq!(seqs.len(), num_threads * writes_per_thread);
1779 }
1780
1781 #[test]
1782 fn periodic_sync_background_thread() {
1783 use std::sync::Arc;
1784 use std::time::Duration;
1785
1786 let dir = TempDir::new().unwrap();
1787 let config = WalConfig {
1788 fsync_policy: FsyncPolicy::Periodic(Duration::from_millis(50)),
1789 ..test_config()
1790 };
1791 let writer = Arc::new(WalWriter::open(dir.path(), config).unwrap());
1792 writer.start_periodic_sync();
1793
1794 writer.append(b"periodic-1").unwrap();
1796 writer.append(b"periodic-2").unwrap();
1797
1798 let deadline = std::time::Instant::now() + Duration::from_secs(5);
1804 let records = loop {
1805 let records = crate::wal::replay_all(dir.path()).unwrap();
1806 if records.len() == 2 || std::time::Instant::now() >= deadline {
1807 break records;
1808 }
1809 std::thread::sleep(Duration::from_millis(10));
1810 };
1811 assert_eq!(
1812 records.len(),
1813 2,
1814 "the periodic sync thread never made the records durable"
1815 );
1816
1817 writer.shutdown_periodic_sync();
1819 }
1820
1821 #[test]
1822 fn archive_before_copies_and_deletes() {
1823 let dir = TempDir::new().unwrap();
1824 let archive_dir = TempDir::new().unwrap();
1825 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1826
1827 let payload = vec![0u8; 100];
1829 for _ in 0..6 {
1830 writer.append(&payload).unwrap();
1831 }
1832 writer.sync().unwrap();
1833
1834 let files_before = writer.file_count().unwrap();
1835 assert!(files_before >= 2, "Need multiple files, got {files_before}");
1836
1837 let archived = writer.archive_before(3, archive_dir.path()).unwrap();
1839 assert!(archived >= 1, "Should archive at least 1 file");
1840
1841 let archive_files = list_wal_files(archive_dir.path()).unwrap();
1843 assert_eq!(archive_files.len(), archived);
1844
1845 let files_after = writer.file_count().unwrap();
1847 assert_eq!(files_after, files_before - archived);
1848
1849 let archived_records = crate::wal::replay_all(archive_dir.path()).unwrap();
1851 assert!(!archived_records.is_empty());
1852 for r in &archived_records {
1853 assert!(r.sequence_no <= 3);
1854 }
1855 }
1856
1857 #[test]
1858 fn archive_before_never_archives_active() {
1859 let dir = TempDir::new().unwrap();
1860 let archive_dir = TempDir::new().unwrap();
1861 let writer = WalWriter::open(dir.path(), test_config()).unwrap();
1862
1863 writer.append(b"data").unwrap();
1865 writer.sync().unwrap();
1866 assert_eq!(writer.file_count().unwrap(), 1);
1867
1868 let archived = writer.archive_before(u64::MAX, archive_dir.path()).unwrap();
1869 assert_eq!(archived, 0, "Should not archive the active file");
1870 }
1871
1872 #[test]
1873 fn archive_creates_dir_if_missing() {
1874 let dir = TempDir::new().unwrap();
1875 let archive_base = TempDir::new().unwrap();
1876 let archive_dir = archive_base.path().join("nested").join("archive");
1877
1878 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1879 let payload = vec![0u8; 100];
1880 for _ in 0..4 {
1881 writer.append(&payload).unwrap();
1882 }
1883 writer.sync().unwrap();
1884
1885 let _archived = writer.archive_before(2, &archive_dir).unwrap();
1887 assert!(archive_dir.exists());
1888 }
1889
1890 #[test]
1891 fn archive_then_pitr_replay() {
1892 let dir = TempDir::new().unwrap();
1893 let archive_dir = TempDir::new().unwrap();
1894 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1895
1896 let payload = vec![0u8; 100];
1897 for _ in 0..8 {
1898 writer.append(&payload).unwrap();
1899 }
1900 writer.sync().unwrap();
1901
1902 let _archived = writer.archive_before(4, archive_dir.path()).unwrap();
1904
1905 let records = crate::wal::replay_range(archive_dir.path(), 1, 4).unwrap();
1907 assert!(!records.is_empty());
1908 for r in &records {
1909 assert!(r.sequence_no > 1 && r.sequence_no <= 4);
1910 }
1911 }
1912
1913 #[test]
1916 fn is_poisoned_starts_false() {
1917 let dir = TempDir::new().unwrap();
1918 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1919 assert!(!writer.is_poisoned());
1920 }
1921
1922 #[test]
1923 fn clear_poison_noop_when_not_poisoned() {
1924 let dir = TempDir::new().unwrap();
1925 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1926 writer.clear_poison().unwrap(); assert!(!writer.is_poisoned());
1928 }
1929
1930 #[test]
1931 fn poisoned_writer_rejects_writes() {
1932 let dir = TempDir::new().unwrap();
1933 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1934 writer.append(b"hello").unwrap();
1936
1937 writer.inner.lock().poisoned = true;
1939 assert!(writer.is_poisoned());
1940
1941 let err = writer.append(b"world").unwrap_err();
1943 assert!(matches!(err, WalError::Poisoned));
1944
1945 let err = writer.append_batch(&[b"a" as &[u8]]).unwrap_err();
1946 assert!(matches!(err, WalError::Poisoned));
1947 }
1948
1949 #[test]
1950 fn clear_poison_restores_writes() {
1951 let dir = TempDir::new().unwrap();
1952 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1953 writer.append(b"before").unwrap();
1954
1955 writer.inner.lock().poisoned = true;
1957 assert!(writer.is_poisoned());
1958 assert!(writer.append(b"fail").is_err());
1959
1960 writer.clear_poison().unwrap();
1962 assert!(!writer.is_poisoned());
1963
1964 writer.append(b"after").unwrap();
1966 }
1967
1968 #[test]
1969 fn clear_poison_data_survives_replay() {
1970 let dir = TempDir::new().unwrap();
1971 let writer = WalWriter::open(dir.path(), small_config()).unwrap();
1972 writer.append(b"record-1").unwrap();
1973 writer.sync().unwrap();
1974
1975 writer.inner.lock().poisoned = true;
1977 writer.clear_poison().unwrap();
1978 writer.append(b"record-2").unwrap();
1979 writer.sync().unwrap();
1980
1981 let records = crate::wal::replay_all(dir.path()).unwrap();
1983 assert!(records.len() >= 2);
1984 assert_eq!(&records[0].payload, b"record-1");
1985 assert_eq!(&records[1].payload, b"record-2");
1986 }
1987}