1use crate::wal::{decode_wal_record, WAL_FORMAT_VERSION};
21use std::fs::{File, OpenOptions};
22use std::io::{self, Read, Write};
23use std::path::{Path, PathBuf};
24
25const LOG_SEGMENT_MAGIC: [u8; 8] = *b"RDBWSEG1";
27
28const LOG_SEGMENT_HEADER_LEN: usize = 8 + 4 + 4 + 8;
30
31const LOG_RECORD_HEADER_LEN: usize = 32;
35
36pub(crate) const MAX_RECORD_BYTES: usize = 64 * 1024 * 1024;
40
41#[derive(Debug, thiserror::Error)]
43pub enum LogError {
44 #[error("log I/O error: {0}")]
46 Io(#[from] io::Error),
47 #[error("corrupt log segment {path}: {detail}")]
49 Corrupt {
50 path: String,
52 detail: String,
54 },
55 #[error("log append out of order: expected LSN {expected}, got {actual}")]
57 OutOfOrder {
58 expected: u64,
60 actual: u64,
62 },
63 #[error("log record {0} bytes exceeds the 64 MiB ceiling")]
65 Oversized(usize),
66}
67
68#[derive(Debug, Clone)]
70pub struct SegmentInfo {
71 pub first_lsn: u64,
73 pub last_lsn: u64,
75 pub path: PathBuf,
77 pub bytes: u64,
79}
80
81#[derive(Debug)]
83struct ActiveSegment {
84 file: File,
85 first_lsn: u64,
86 bytes: u64,
87}
88
89#[derive(Debug)]
91pub struct LogStore {
92 dir: PathBuf,
93 shard_id: u32,
94 segment_bytes: u64,
95 segments: Vec<SegmentInfo>,
96 next_lsn: u64,
97 active: Option<ActiveSegment>,
98}
99
100impl LogStore {
101 pub fn open(dir: &Path, shard_id: u32, segment_bytes: u64) -> Result<Self, LogError> {
112 std::fs::create_dir_all(dir)?;
113 let mut paths: Vec<(u64, PathBuf)> = std::fs::read_dir(dir)?
114 .filter_map(Result::ok)
115 .filter_map(|entry| {
116 let name = entry.file_name().into_string().ok()?;
117 let first_lsn = name.strip_suffix(".seg")?.parse::<u64>().ok()?;
118 Some((first_lsn, entry.path()))
119 })
120 .collect();
121 paths.sort_by_key(|(first_lsn, _)| *first_lsn);
122
123 if let Some((_, newest)) = paths.last() {
129 if std::fs::metadata(newest)?.len() < LOG_SEGMENT_HEADER_LEN as u64 {
130 std::fs::remove_file(newest)?;
131 File::open(dir)?.sync_all()?;
132 paths.pop();
133 }
134 }
135
136 let mut segments = Vec::with_capacity(paths.len());
137 let mut next_lsn = 1u64;
138 let last_index = paths.len().saturating_sub(1);
139 for (index, (first_lsn, path)) in paths.iter().enumerate() {
140 let is_active = index == last_index;
141 let (records, valid_bytes, file_len) = read_segment(path, shard_id, is_active)?;
142 if valid_bytes != file_len {
143 if !is_active {
144 return Err(LogError::Corrupt {
145 path: path.display().to_string(),
146 detail: "sealed segment ends with a torn record".into(),
147 });
148 }
149 let file = OpenOptions::new().write(true).open(path)?;
152 file.set_len(valid_bytes)?;
153 file.sync_all()?;
154 }
155 let last_lsn = records
156 .last()
157 .map_or(first_lsn.saturating_sub(1), |(lsn, _)| *lsn);
158 next_lsn = last_lsn.saturating_add(1);
159 segments.push(SegmentInfo {
160 first_lsn: *first_lsn,
161 last_lsn,
162 path: path.clone(),
163 bytes: valid_bytes,
164 });
165 }
166
167 let active = match segments.last() {
170 Some(last) if last.bytes < segment_bytes => {
171 let file = OpenOptions::new().append(true).open(&last.path)?;
172 Some(ActiveSegment {
173 file,
174 first_lsn: last.first_lsn,
175 bytes: last.bytes,
176 })
177 }
178 _ => None,
179 };
180
181 Ok(Self {
182 dir: dir.to_path_buf(),
183 shard_id,
184 segment_bytes,
185 segments,
186 next_lsn,
187 active,
188 })
189 }
190
191 pub(crate) fn seed_next_lsn_if_empty(&mut self, floor: u64) {
201 if self.segments.is_empty() && self.active.is_none() && floor > self.next_lsn {
202 self.next_lsn = floor;
203 }
204 }
205
206 #[must_use]
208 pub fn shard_id(&self) -> u32 {
209 self.shard_id
210 }
211
212 #[must_use]
214 pub fn next_lsn(&self) -> u64 {
215 self.next_lsn
216 }
217
218 #[must_use]
221 pub fn durable_lsn(&self) -> u64 {
222 self.segments.last().map_or(0, |segment| segment.last_lsn)
223 }
224
225 #[must_use]
227 pub fn segments(&self) -> &[SegmentInfo] {
228 &self.segments
229 }
230
231 pub fn append_batch(&mut self, records: &[(u64, Vec<u8>)]) -> Result<(), LogError> {
251 let mut i = 0;
252 while i < records.len() {
253 let lsn = records[i].0;
254 if lsn != self.next_lsn {
255 return Err(LogError::OutOfOrder {
256 expected: self.next_lsn,
257 actual: lsn,
258 });
259 }
260 let mut group_end = i + 1;
261 while group_end < records.len() && records[group_end].0 == lsn {
262 group_end += 1;
263 }
264 let group = &records[i..group_end];
265 for (_, framed) in group {
266 if framed.len() > MAX_RECORD_BYTES {
267 return Err(LogError::Oversized(framed.len()));
268 }
269 }
270 let group_bytes: u64 = group.iter().map(|(_, framed)| framed.len() as u64).sum();
271 let would_overflow = self.active.as_ref().is_some_and(|active| {
272 active.bytes > 0 && active.bytes + group_bytes > self.segment_bytes
273 });
274 if would_overflow {
275 self.seal_active()?;
276 }
277 if self.active.is_none() {
278 self.create_active(lsn)?;
279 }
280 for (_, framed) in group {
281 let active = self.active.as_mut().ok_or_else(|| LogError::Corrupt {
282 path: self.dir.display().to_string(),
283 detail: "active segment missing after creation".into(),
284 })?;
285 active.file.write_all(framed)?;
286 active.bytes += framed.len() as u64;
287 if let Some(last) = self
288 .segments
289 .last_mut()
290 .filter(|last| last.first_lsn == active.first_lsn)
291 {
292 last.last_lsn = lsn;
293 last.bytes = active.bytes;
294 }
295 }
296 self.next_lsn = lsn.saturating_add(1);
297 i = group_end;
298 }
299 Ok(())
300 }
301
302 pub fn sync(&mut self) -> Result<(), LogError> {
308 if let Some(active) = self.active.as_mut() {
309 active.file.sync_data()?;
310 }
311 sync_dir(&self.dir)?;
312 Ok(())
313 }
314
315 pub fn read_from(&self, from_lsn: u64) -> Result<Vec<(u64, Vec<u8>)>, LogError> {
320 let last_index = self.segments.len().saturating_sub(1);
321 let mut out = Vec::new();
322 for (index, segment) in self.segments.iter().enumerate() {
323 if segment.last_lsn < from_lsn {
324 continue;
325 }
326 let (records, _, _) = read_segment(&segment.path, self.shard_id, index == last_index)?;
327 for (lsn, framed) in records {
328 if lsn >= from_lsn {
329 out.push((lsn, framed));
330 }
331 }
332 }
333 Ok(out)
334 }
335
336 #[must_use]
341 pub fn segments_in(&self, from_lsn: u64, until_lsn: u64) -> Vec<(SegmentInfo, bool)> {
342 let last_index = self.segments.len().saturating_sub(1);
343 self.segments
344 .iter()
345 .enumerate()
346 .filter(|(_, segment)| segment.last_lsn >= from_lsn && segment.first_lsn < until_lsn)
347 .map(|(index, segment)| (segment.clone(), index == last_index))
348 .collect()
349 }
350
351 pub fn truncate_before(&mut self, lsn: u64) -> Result<usize, LogError> {
358 let active_first = self.active.as_ref().map(|active| active.first_lsn);
359 let mut removed = 0;
360 let mut keep = Vec::with_capacity(self.segments.len());
361 for segment in &self.segments {
362 let is_active = active_first == Some(segment.first_lsn);
363 if !is_active && segment.last_lsn < lsn {
364 std::fs::remove_file(&segment.path)?;
365 removed += 1;
366 } else {
367 keep.push(segment.clone());
368 }
369 }
370 self.segments = keep;
371 Ok(removed)
372 }
373
374 fn seal_active(&mut self) -> Result<(), LogError> {
376 if let Some(active) = self.active.take() {
377 active.file.sync_all()?;
378 }
379 sync_dir(&self.dir)?;
380 Ok(())
381 }
382
383 fn create_active(&mut self, first_lsn: u64) -> Result<(), LogError> {
385 let path = self.dir.join(segment_file_name(first_lsn));
386 let mut file = OpenOptions::new()
387 .create(true)
388 .write(true)
389 .truncate(true)
390 .open(&path)?;
391 let mut header = Vec::with_capacity(LOG_SEGMENT_HEADER_LEN);
392 header.extend_from_slice(&LOG_SEGMENT_MAGIC);
393 header.extend_from_slice(&WAL_FORMAT_VERSION.to_le_bytes());
394 header.extend_from_slice(&self.shard_id.to_le_bytes());
395 header.extend_from_slice(&first_lsn.to_le_bytes());
396 file.write_all(&header)?;
397 self.segments.push(SegmentInfo {
398 first_lsn,
399 last_lsn: first_lsn.saturating_sub(1),
400 path,
401 bytes: LOG_SEGMENT_HEADER_LEN as u64,
402 });
403 self.active = Some(ActiveSegment {
404 file,
405 first_lsn,
406 bytes: LOG_SEGMENT_HEADER_LEN as u64,
407 });
408 sync_dir(&self.dir)?;
409 Ok(())
410 }
411}
412
413fn segment_file_name(first_lsn: u64) -> String {
415 format!("{first_lsn:020}.seg")
416}
417
418fn sync_dir(dir: &Path) -> io::Result<()> {
420 File::open(dir)?.sync_all()
421}
422
423enum ReadFull {
425 Full,
427 CleanEof,
429 Partial,
431}
432
433fn read_full<R: Read>(reader: &mut R, buf: &mut [u8]) -> io::Result<ReadFull> {
434 let mut filled = 0;
435 while filled < buf.len() {
436 match reader.read(&mut buf[filled..]) {
437 Ok(0) => {
438 return Ok(if filled == 0 {
439 ReadFull::CleanEof
440 } else {
441 ReadFull::Partial
442 });
443 }
444 Ok(n) => filled += n,
445 Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
446 Err(error) => return Err(error),
447 }
448 }
449 Ok(ReadFull::Full)
450}
451
452pub fn read_segment_records(
470 path: &Path,
471 shard_id: u32,
472 allow_tail: bool,
473) -> Result<Vec<(u64, Vec<u8>)>, LogError> {
474 read_segment(path, shard_id, allow_tail).map(|(records, _, _)| records)
475}
476
477fn read_segment(
478 path: &Path,
479 shard_id: u32,
480 allow_tail: bool,
481) -> Result<(Vec<(u64, Vec<u8>)>, u64, u64), LogError> {
482 let mut file = File::open(path)?;
483 let file_len = file.metadata()?.len();
484 let corrupt = |detail: String| LogError::Corrupt {
485 path: path.display().to_string(),
486 detail,
487 };
488
489 let mut header = [0u8; LOG_SEGMENT_HEADER_LEN];
490 if !matches!(read_full(&mut file, &mut header)?, ReadFull::Full) {
491 return Err(corrupt("segment shorter than its header".into()));
492 }
493 if header[0..8] != LOG_SEGMENT_MAGIC {
494 return Err(corrupt("invalid segment magic".into()));
495 }
496 let version = u32::from_le_bytes(header[8..12].try_into().expect("slice"));
497 if version != WAL_FORMAT_VERSION {
498 return Err(corrupt(format!("unsupported segment version {version}")));
499 }
500 let segment_shard = u32::from_le_bytes(header[12..16].try_into().expect("slice"));
501 if segment_shard != shard_id {
502 return Err(corrupt(format!(
503 "segment shard {segment_shard} does not match {shard_id}"
504 )));
505 }
506
507 let mut offset = LOG_SEGMENT_HEADER_LEN as u64;
508 let mut records = Vec::new();
509 let mut record_offsets = Vec::new();
510 loop {
511 let record_start = offset;
512 let mut record_header = [0u8; LOG_RECORD_HEADER_LEN];
513 match read_full(&mut file, &mut record_header)? {
514 ReadFull::Full => {}
515 ReadFull::CleanEof => break,
516 ReadFull::Partial => {
517 if allow_tail {
518 break;
519 }
520 return Err(corrupt("torn record header".into()));
521 }
522 }
523 let payload_len =
524 u32::from_le_bytes(record_header[24..28].try_into().expect("slice")) as usize;
525 if payload_len > MAX_RECORD_BYTES {
526 if allow_tail {
527 break;
528 }
529 return Err(corrupt("record payload exceeds 64 MiB".into()));
530 }
531 let mut raw = Vec::with_capacity(LOG_RECORD_HEADER_LEN + payload_len);
532 raw.extend_from_slice(&record_header);
533 raw.resize(LOG_RECORD_HEADER_LEN + payload_len, 0);
534 if !matches!(
535 read_full(&mut file, &mut raw[LOG_RECORD_HEADER_LEN..])?,
536 ReadFull::Full
537 ) {
538 if allow_tail {
539 break;
540 }
541 return Err(corrupt("torn record payload".into()));
542 }
543 match decode_wal_record(&raw) {
544 Ok((record, _)) => {
545 records.push((record.lsn, raw));
546 record_offsets.push(record_start);
547 offset += (LOG_RECORD_HEADER_LEN + payload_len) as u64;
548 }
549 Err(error) => {
550 if allow_tail {
551 break;
552 }
553 return Err(corrupt(format!("record checksum/format: {error}")));
554 }
555 }
556 }
557
558 let mut assembler = crate::wal::ChainAssembler::default();
564 let mut valid_count = records.len();
565 for (i, (_, raw)) in records.iter().enumerate() {
566 if let Err(detail) = assembler.feed(raw, record_offsets[i]) {
567 if !allow_tail {
568 return Err(corrupt(detail));
569 }
570 valid_count = i;
571 break;
572 }
573 }
574 if valid_count == records.len() {
575 if let Some(open_start) = assembler.open_start_offset() {
576 if !allow_tail {
577 return Err(corrupt(
578 "sealed segment ends with an incomplete chained commit".into(),
579 ));
580 }
581 valid_count = record_offsets.partition_point(|&o| o < open_start);
582 }
583 }
584 let valid_bytes = record_offsets.get(valid_count).copied().unwrap_or(offset);
585 records.truncate(valid_count);
586
587 Ok((records, valid_bytes, file_len))
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593 use crate::wal::{encode_chained_ops_record, encode_wal_record};
594
595 fn temp_dir() -> crate::TempPath {
596 crate::TempPath::dir("logstore")
597 }
598
599 fn chained_records(
602 lsn: u64,
603 ops: &[crate::Op],
604 max_record_bytes: usize,
605 ) -> Vec<(u64, Vec<u8>)> {
606 encode_chained_ops_record(0, lsn, ops, None, max_record_bytes)
607 .into_iter()
608 .map(|framed| (lsn, framed))
609 .collect()
610 }
611
612 fn big_ops(n: usize, value_len: usize) -> Vec<crate::Op> {
613 (0..n)
614 .map(|i| crate::Op::Put(format!("k{i}").into_bytes(), vec![b'x'; value_len]))
615 .collect()
616 }
617
618 #[test]
619 fn a_newest_segment_torn_during_creation_holds_nothing_and_is_dropped() {
620 let dir = temp_dir();
621 {
622 let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
623 log.append_batch(&[record(1, b"one"), record(2, b"two")])
624 .unwrap();
625 log.sync().unwrap();
626 }
627 std::fs::write(dir.join(format!("{:020}.seg", 3)), [0x52, 0x44]).unwrap();
630 let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
631 assert_eq!(log.next_lsn(), 3, "the durable records are all there");
632 assert!(!dir.join(format!("{:020}.seg", 3)).exists());
633 log.append_batch(&[record(3, b"three")]).unwrap();
634 log.sync().unwrap();
635 drop(log);
636 let log = LogStore::open(&dir, 0, 1 << 30).unwrap();
637 assert_eq!(log.read_from(1).unwrap().len(), 3);
638
639 let fresh = temp_dir();
641 std::fs::create_dir_all(&fresh).unwrap();
642 std::fs::write(fresh.join(format!("{:020}.seg", 1)), []).unwrap();
643 let log = LogStore::open(&fresh, 0, 1 << 30).unwrap();
644 assert_eq!(log.next_lsn(), 1);
645 }
646
647 #[test]
648 fn an_older_segment_shorter_than_its_header_is_corrupt() {
649 let dir = temp_dir();
650 {
651 let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
652 log.append_batch(&[record(1, b"one")]).unwrap();
653 log.sync().unwrap();
654 }
655 std::fs::write(dir.join(format!("{:020}.seg", 0)), [0x52]).unwrap();
657 assert!(matches!(
658 LogStore::open(&dir, 0, 1 << 30),
659 Err(LogError::Corrupt { .. })
660 ));
661 }
662
663 #[test]
664 fn chained_commit_advances_next_lsn_once_not_per_fragment() {
665 let dir = temp_dir();
666 let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
667 let ops = big_ops(20, 200);
668 let fragments = chained_records(1, &ops, 1024);
669 assert!(fragments.len() > 1, "expected this commit to need chaining");
670 log.append_batch(&fragments).unwrap();
671 assert_eq!(
672 log.next_lsn(),
673 2,
674 "a multi-fragment commit must advance next_lsn exactly once"
675 );
676 log.sync().unwrap();
677 let all = log.read_from(1).unwrap();
678 assert_eq!(
679 all.len(),
680 fragments.len(),
681 "every physical fragment is durable"
682 );
683
684 log.append_batch(&[record(2, b"small")]).unwrap();
687 assert_eq!(log.next_lsn(), 3);
688 }
689
690 #[test]
691 fn chained_commit_never_splits_across_a_segment_boundary() {
692 let dir = temp_dir();
693 let mut log = LogStore::open(&dir, 0, 512).unwrap();
697 let ops = big_ops(30, 100);
698 let fragments = chained_records(1, &ops, 1024);
699 assert!(fragments.len() > 1);
700 log.append_batch(&fragments).unwrap();
701 log.sync().unwrap();
702
703 let owning_segments: std::collections::HashSet<u64> = log
706 .segments()
707 .iter()
708 .filter(|s| s.first_lsn <= 1 && s.last_lsn >= 1)
709 .map(|s| s.first_lsn)
710 .collect();
711 assert_eq!(
712 owning_segments.len(),
713 1,
714 "a chained commit must not be split across segment files"
715 );
716 }
717
718 #[test]
719 fn reopen_discards_an_incomplete_trailing_chain_and_retry_at_the_same_lsn_succeeds() {
720 let dir = temp_dir();
721 {
722 let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
723 log.append_batch(&[record(1, b"first")]).unwrap();
724 log.sync().unwrap();
725 }
726 let ops = big_ops(10, 200);
730 let fragments = encode_chained_ops_record(0, 2, &ops, None, 512);
731 assert!(
732 fragments.len() > 2,
733 "need at least one continue + the final"
734 );
735 {
736 let mut file = OpenOptions::new()
737 .append(true)
738 .open(dir.join(segment_file_name(1)))
739 .unwrap();
740 for framed in &fragments[..fragments.len() - 1] {
741 file.write_all(framed).unwrap();
742 }
743 file.sync_all().unwrap();
744 }
745 let before_len = std::fs::metadata(dir.join(segment_file_name(1)))
746 .unwrap()
747 .len();
748
749 let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
750 assert_eq!(
751 log.next_lsn(),
752 2,
753 "the incomplete chain must not advance past its own lsn"
754 );
755 assert_eq!(
756 log.read_from(1).unwrap().len(),
757 1,
758 "only the first, complete commit survives"
759 );
760 let after_len = std::fs::metadata(dir.join(segment_file_name(1)))
761 .unwrap()
762 .len();
763 assert!(
764 after_len < before_len,
765 "the incomplete chain's bytes must be truncated away"
766 );
767
768 log.append_batch(&[record(2, b"retry")]).unwrap();
773 log.sync().unwrap();
774 assert_eq!(log.next_lsn(), 3);
775 let all = log.read_from(1).unwrap();
776 assert_eq!(all.len(), 2);
777 let (_, ops, _) = crate::wal::decode_ops_record(&all[1].1).unwrap();
778 assert_eq!(ops, vec![crate::Op::Put(Vec::new(), b"retry".to_vec())]);
779 }
780
781 #[test]
790 fn reopen_never_truncates_a_structurally_complete_record_with_corrupt_content() {
791 let dir = temp_dir();
792 {
793 let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
794 let framed = crate::wal::encode_ops_record(0, 1, &[], Some(b"not-json"));
795 log.append_batch(&[(1, framed)]).unwrap();
796 log.sync().unwrap();
797 }
798 let log = LogStore::open(&dir, 0, 1 << 30).unwrap();
799 assert_eq!(
800 log.next_lsn(),
801 2,
802 "a structurally complete record must not be discarded"
803 );
804 let all = log.read_from(1).unwrap();
805 assert_eq!(
806 all.len(),
807 1,
808 "the corrupt-content record must survive reopen"
809 );
810 assert!(crate::wal::decode_ops_record(&all[0].1).is_err());
814 }
815
816 fn record(lsn: u64, payload: &[u8]) -> (u64, Vec<u8>) {
821 let ops = vec![crate::Op::Put(Vec::new(), payload.to_vec())];
822 (lsn, crate::wal::encode_ops_record(0, lsn, &ops, None))
823 }
824
825 #[test]
826 fn append_sync_read_round_trip() {
827 let dir = temp_dir();
828 let mut log = LogStore::open(&dir, 0, 1 << 20).unwrap();
829 assert_eq!(log.next_lsn(), 1);
830 assert_eq!(log.durable_lsn(), 0);
831 log.append_batch(&[record(1, b"a"), record(2, b"bb"), record(3, b"ccc")])
832 .unwrap();
833 log.sync().unwrap();
834 assert_eq!(log.durable_lsn(), 3);
835 assert_eq!(log.next_lsn(), 4);
836
837 let all = log.read_from(1).unwrap();
838 assert_eq!(
839 all.iter().map(|(lsn, _)| *lsn).collect::<Vec<_>>(),
840 [1, 2, 3]
841 );
842 let tail = log.read_from(2).unwrap();
843 assert_eq!(tail.iter().map(|(lsn, _)| *lsn).collect::<Vec<_>>(), [2, 3]);
844 assert!(log.read_from(4).unwrap().is_empty());
845 }
846
847 #[test]
854 fn seed_next_lsn_if_empty_continues_past_a_restored_checkpoint() {
855 let dir = temp_dir();
856 let mut log = LogStore::open(&dir, 0, 1 << 20).unwrap();
857 assert_eq!(log.next_lsn(), 1);
858
859 log.seed_next_lsn_if_empty(51);
860 assert_eq!(
861 log.next_lsn(),
862 51,
863 "an empty log must adopt the materialization's checkpoint + 1"
864 );
865 log.append_batch(&[record(51, b"a")]).unwrap();
866 assert_eq!(log.next_lsn(), 52);
867
868 let fresh_dir = temp_dir();
871 let mut fresh = LogStore::open(&fresh_dir, 0, 1 << 20).unwrap();
872 fresh.seed_next_lsn_if_empty(1);
873 assert_eq!(fresh.next_lsn(), 1);
874
875 log.seed_next_lsn_if_empty(1_000);
878 assert_eq!(
879 log.next_lsn(),
880 52,
881 "seeding must not touch a log with real segments"
882 );
883 }
884
885 #[test]
886 fn rotates_segments_and_reads_across_them() {
887 let dir = temp_dir();
888 let mut log = LogStore::open(&dir, 0, 120).unwrap();
889 for lsn in 1..=6 {
890 log.append_batch(&[record(lsn, &[0xABu8; 40])]).unwrap();
891 }
892 log.sync().unwrap();
893 assert!(
894 log.segments().len() > 1,
895 "expected rotation, got {} segment(s)",
896 log.segments().len()
897 );
898 let all = log.read_from(1).unwrap();
899 assert_eq!(
900 all.iter().map(|(lsn, _)| *lsn).collect::<Vec<_>>(),
901 [1, 2, 3, 4, 5, 6]
902 );
903 }
904
905 #[test]
906 fn reopen_recovers_state() {
907 let dir = temp_dir();
908 {
909 let mut log = LogStore::open(&dir, 0, 1 << 20).unwrap();
910 log.append_batch(&[record(1, b"x"), record(2, b"y")])
911 .unwrap();
912 log.sync().unwrap();
913 }
914 let log = LogStore::open(&dir, 0, 1 << 20).unwrap();
915 assert_eq!(log.next_lsn(), 3);
916 assert_eq!(log.durable_lsn(), 2);
917 assert_eq!(log.read_from(1).unwrap().len(), 2);
918 }
919
920 #[test]
921 fn torn_tail_is_truncated_on_open() {
922 let dir = temp_dir();
923 {
924 let mut log = LogStore::open(&dir, 0, 1 << 20).unwrap();
925 log.append_batch(&[record(1, b"x"), record(2, b"y")])
926 .unwrap();
927 log.sync().unwrap();
928 }
929 let segment = dir.join(segment_file_name(1));
932 {
933 let mut file = OpenOptions::new().append(true).open(&segment).unwrap();
934 let framed = encode_wal_record(0, 3, b"a much longer payload");
935 file.write_all(&framed[..framed.len() - 5]).unwrap();
936 }
937 let before = std::fs::metadata(&segment).unwrap().len();
938 let log = LogStore::open(&dir, 0, 1 << 20).unwrap();
939 assert_eq!(log.next_lsn(), 3, "torn record must not advance the LSN");
940 assert_eq!(log.read_from(1).unwrap().len(), 2);
941 let after = std::fs::metadata(&segment).unwrap().len();
942 assert!(after < before, "torn tail must be truncated");
943 }
944
945 #[test]
946 fn sealed_torn_tail_is_corruption() {
947 let dir = temp_dir();
948 {
949 let mut log = LogStore::open(&dir, 0, 120).unwrap();
950 for lsn in 1..=4 {
951 log.append_batch(&[record(lsn, &[0xABu8; 40])]).unwrap();
952 }
953 log.sync().unwrap();
954 }
955 let first = dir.join(segment_file_name(1));
957 let mut bytes = std::fs::read(&first).unwrap();
958 let last = bytes.len() - 1;
959 bytes[last] ^= 0xFF;
960 std::fs::write(&first, &bytes).unwrap();
961 let error = LogStore::open(&dir, 0, 120).unwrap_err();
962 assert!(matches!(error, LogError::Corrupt { .. }), "got: {error}");
963 }
964
965 #[test]
966 fn out_of_order_append_is_rejected() {
967 let dir = temp_dir();
968 let mut log = LogStore::open(&dir, 0, 1 << 20).unwrap();
969 log.append_batch(&[record(1, b"a")]).unwrap();
970 let error = log.append_batch(&[record(3, b"c")]).unwrap_err();
971 assert!(
972 matches!(
973 error,
974 LogError::OutOfOrder {
975 expected: 2,
976 actual: 3
977 }
978 ),
979 "got: {error}"
980 );
981 }
982
983 #[test]
984 fn truncate_before_removes_only_sealed_segments() {
985 let dir = temp_dir();
986 let mut log = LogStore::open(&dir, 0, 120).unwrap();
987 for lsn in 1..=6 {
988 log.append_batch(&[record(lsn, &[0xABu8; 40])]).unwrap();
989 }
990 log.sync().unwrap();
991 let before = log.segments().len();
992 assert!(before > 1);
993 let active_first = log.segments().last().unwrap().first_lsn;
994 let removed = log.truncate_before(u64::MAX).unwrap();
995 assert_eq!(removed, before - 1, "all sealed segments must go");
996 assert_eq!(log.segments().len(), 1);
997 assert_eq!(log.segments()[0].first_lsn, active_first);
998 assert!(!log.read_from(active_first).unwrap().is_empty());
1000 }
1001
1002 #[test]
1003 fn rejects_foreign_shard_segment() {
1004 let dir = temp_dir();
1005 {
1006 let mut log = LogStore::open(&dir, 7, 1 << 20).unwrap();
1007 log.append_batch(&[record(1, b"a")]).unwrap();
1008 log.sync().unwrap();
1009 }
1010 let error = LogStore::open(&dir, 0, 1 << 20).unwrap_err();
1011 assert!(matches!(error, LogError::Corrupt { .. }), "got: {error}");
1012 }
1013}