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 let mut segments = Vec::with_capacity(paths.len());
124 let mut next_lsn = 1u64;
125 let last_index = paths.len().saturating_sub(1);
126 for (index, (first_lsn, path)) in paths.iter().enumerate() {
127 let is_active = index == last_index;
128 let (records, valid_bytes, file_len) = read_segment(path, shard_id, is_active)?;
129 if valid_bytes != file_len {
130 if !is_active {
131 return Err(LogError::Corrupt {
132 path: path.display().to_string(),
133 detail: "sealed segment ends with a torn record".into(),
134 });
135 }
136 let file = OpenOptions::new().write(true).open(path)?;
139 file.set_len(valid_bytes)?;
140 file.sync_all()?;
141 }
142 let last_lsn = records
143 .last()
144 .map_or(first_lsn.saturating_sub(1), |(lsn, _)| *lsn);
145 next_lsn = last_lsn.saturating_add(1);
146 segments.push(SegmentInfo {
147 first_lsn: *first_lsn,
148 last_lsn,
149 path: path.clone(),
150 bytes: valid_bytes,
151 });
152 }
153
154 let active = match segments.last() {
157 Some(last) if last.bytes < segment_bytes => {
158 let file = OpenOptions::new().append(true).open(&last.path)?;
159 Some(ActiveSegment {
160 file,
161 first_lsn: last.first_lsn,
162 bytes: last.bytes,
163 })
164 }
165 _ => None,
166 };
167
168 Ok(Self {
169 dir: dir.to_path_buf(),
170 shard_id,
171 segment_bytes,
172 segments,
173 next_lsn,
174 active,
175 })
176 }
177
178 pub(crate) fn seed_next_lsn_if_empty(&mut self, floor: u64) {
188 if self.segments.is_empty() && self.active.is_none() && floor > self.next_lsn {
189 self.next_lsn = floor;
190 }
191 }
192
193 #[must_use]
195 pub fn shard_id(&self) -> u32 {
196 self.shard_id
197 }
198
199 #[must_use]
201 pub fn next_lsn(&self) -> u64 {
202 self.next_lsn
203 }
204
205 #[must_use]
208 pub fn durable_lsn(&self) -> u64 {
209 self.segments.last().map_or(0, |segment| segment.last_lsn)
210 }
211
212 #[must_use]
214 pub fn segments(&self) -> &[SegmentInfo] {
215 &self.segments
216 }
217
218 pub fn append_batch(&mut self, records: &[(u64, Vec<u8>)]) -> Result<(), LogError> {
238 let mut i = 0;
239 while i < records.len() {
240 let lsn = records[i].0;
241 if lsn != self.next_lsn {
242 return Err(LogError::OutOfOrder {
243 expected: self.next_lsn,
244 actual: lsn,
245 });
246 }
247 let mut group_end = i + 1;
248 while group_end < records.len() && records[group_end].0 == lsn {
249 group_end += 1;
250 }
251 let group = &records[i..group_end];
252 for (_, framed) in group {
253 if framed.len() > MAX_RECORD_BYTES {
254 return Err(LogError::Oversized(framed.len()));
255 }
256 }
257 let group_bytes: u64 = group.iter().map(|(_, framed)| framed.len() as u64).sum();
258 let would_overflow = self.active.as_ref().is_some_and(|active| {
259 active.bytes > 0 && active.bytes + group_bytes > self.segment_bytes
260 });
261 if would_overflow {
262 self.seal_active()?;
263 }
264 if self.active.is_none() {
265 self.create_active(lsn)?;
266 }
267 for (_, framed) in group {
268 let active = self.active.as_mut().ok_or_else(|| LogError::Corrupt {
269 path: self.dir.display().to_string(),
270 detail: "active segment missing after creation".into(),
271 })?;
272 active.file.write_all(framed)?;
273 active.bytes += framed.len() as u64;
274 if let Some(last) = self
275 .segments
276 .last_mut()
277 .filter(|last| last.first_lsn == active.first_lsn)
278 {
279 last.last_lsn = lsn;
280 last.bytes = active.bytes;
281 }
282 }
283 self.next_lsn = lsn.saturating_add(1);
284 i = group_end;
285 }
286 Ok(())
287 }
288
289 pub fn sync(&mut self) -> Result<(), LogError> {
295 if let Some(active) = self.active.as_mut() {
296 active.file.sync_data()?;
297 }
298 sync_dir(&self.dir)?;
299 Ok(())
300 }
301
302 pub fn read_from(&self, from_lsn: u64) -> Result<Vec<(u64, Vec<u8>)>, LogError> {
307 let last_index = self.segments.len().saturating_sub(1);
308 let mut out = Vec::new();
309 for (index, segment) in self.segments.iter().enumerate() {
310 if segment.last_lsn < from_lsn {
311 continue;
312 }
313 let (records, _, _) = read_segment(&segment.path, self.shard_id, index == last_index)?;
314 for (lsn, framed) in records {
315 if lsn >= from_lsn {
316 out.push((lsn, framed));
317 }
318 }
319 }
320 Ok(out)
321 }
322
323 #[must_use]
328 pub fn segments_in(&self, from_lsn: u64, until_lsn: u64) -> Vec<(SegmentInfo, bool)> {
329 let last_index = self.segments.len().saturating_sub(1);
330 self.segments
331 .iter()
332 .enumerate()
333 .filter(|(_, segment)| segment.last_lsn >= from_lsn && segment.first_lsn < until_lsn)
334 .map(|(index, segment)| (segment.clone(), index == last_index))
335 .collect()
336 }
337
338 pub fn truncate_before(&mut self, lsn: u64) -> Result<usize, LogError> {
345 let active_first = self.active.as_ref().map(|active| active.first_lsn);
346 let mut removed = 0;
347 let mut keep = Vec::with_capacity(self.segments.len());
348 for segment in &self.segments {
349 let is_active = active_first == Some(segment.first_lsn);
350 if !is_active && segment.last_lsn < lsn {
351 std::fs::remove_file(&segment.path)?;
352 removed += 1;
353 } else {
354 keep.push(segment.clone());
355 }
356 }
357 self.segments = keep;
358 Ok(removed)
359 }
360
361 fn seal_active(&mut self) -> Result<(), LogError> {
363 if let Some(active) = self.active.take() {
364 active.file.sync_all()?;
365 }
366 sync_dir(&self.dir)?;
367 Ok(())
368 }
369
370 fn create_active(&mut self, first_lsn: u64) -> Result<(), LogError> {
372 let path = self.dir.join(segment_file_name(first_lsn));
373 let mut file = OpenOptions::new()
374 .create(true)
375 .write(true)
376 .truncate(true)
377 .open(&path)?;
378 let mut header = Vec::with_capacity(LOG_SEGMENT_HEADER_LEN);
379 header.extend_from_slice(&LOG_SEGMENT_MAGIC);
380 header.extend_from_slice(&WAL_FORMAT_VERSION.to_le_bytes());
381 header.extend_from_slice(&self.shard_id.to_le_bytes());
382 header.extend_from_slice(&first_lsn.to_le_bytes());
383 file.write_all(&header)?;
384 self.segments.push(SegmentInfo {
385 first_lsn,
386 last_lsn: first_lsn.saturating_sub(1),
387 path,
388 bytes: LOG_SEGMENT_HEADER_LEN as u64,
389 });
390 self.active = Some(ActiveSegment {
391 file,
392 first_lsn,
393 bytes: LOG_SEGMENT_HEADER_LEN as u64,
394 });
395 sync_dir(&self.dir)?;
396 Ok(())
397 }
398}
399
400fn segment_file_name(first_lsn: u64) -> String {
402 format!("{first_lsn:020}.seg")
403}
404
405fn sync_dir(dir: &Path) -> io::Result<()> {
407 File::open(dir)?.sync_all()
408}
409
410enum ReadFull {
412 Full,
414 CleanEof,
416 Partial,
418}
419
420fn read_full<R: Read>(reader: &mut R, buf: &mut [u8]) -> io::Result<ReadFull> {
421 let mut filled = 0;
422 while filled < buf.len() {
423 match reader.read(&mut buf[filled..]) {
424 Ok(0) => {
425 return Ok(if filled == 0 {
426 ReadFull::CleanEof
427 } else {
428 ReadFull::Partial
429 });
430 }
431 Ok(n) => filled += n,
432 Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
433 Err(error) => return Err(error),
434 }
435 }
436 Ok(ReadFull::Full)
437}
438
439pub fn read_segment_records(
457 path: &Path,
458 shard_id: u32,
459 allow_tail: bool,
460) -> Result<Vec<(u64, Vec<u8>)>, LogError> {
461 read_segment(path, shard_id, allow_tail).map(|(records, _, _)| records)
462}
463
464fn read_segment(
465 path: &Path,
466 shard_id: u32,
467 allow_tail: bool,
468) -> Result<(Vec<(u64, Vec<u8>)>, u64, u64), LogError> {
469 let mut file = File::open(path)?;
470 let file_len = file.metadata()?.len();
471 let corrupt = |detail: String| LogError::Corrupt {
472 path: path.display().to_string(),
473 detail,
474 };
475
476 let mut header = [0u8; LOG_SEGMENT_HEADER_LEN];
477 if !matches!(read_full(&mut file, &mut header)?, ReadFull::Full) {
478 return Err(corrupt("segment shorter than its header".into()));
479 }
480 if header[0..8] != LOG_SEGMENT_MAGIC {
481 return Err(corrupt("invalid segment magic".into()));
482 }
483 let version = u32::from_le_bytes(header[8..12].try_into().expect("slice"));
484 if version != WAL_FORMAT_VERSION {
485 return Err(corrupt(format!("unsupported segment version {version}")));
486 }
487 let segment_shard = u32::from_le_bytes(header[12..16].try_into().expect("slice"));
488 if segment_shard != shard_id {
489 return Err(corrupt(format!(
490 "segment shard {segment_shard} does not match {shard_id}"
491 )));
492 }
493
494 let mut offset = LOG_SEGMENT_HEADER_LEN as u64;
495 let mut records = Vec::new();
496 let mut record_offsets = Vec::new();
497 loop {
498 let record_start = offset;
499 let mut record_header = [0u8; LOG_RECORD_HEADER_LEN];
500 match read_full(&mut file, &mut record_header)? {
501 ReadFull::Full => {}
502 ReadFull::CleanEof => break,
503 ReadFull::Partial => {
504 if allow_tail {
505 break;
506 }
507 return Err(corrupt("torn record header".into()));
508 }
509 }
510 let payload_len =
511 u32::from_le_bytes(record_header[24..28].try_into().expect("slice")) as usize;
512 if payload_len > MAX_RECORD_BYTES {
513 if allow_tail {
514 break;
515 }
516 return Err(corrupt("record payload exceeds 64 MiB".into()));
517 }
518 let mut raw = Vec::with_capacity(LOG_RECORD_HEADER_LEN + payload_len);
519 raw.extend_from_slice(&record_header);
520 raw.resize(LOG_RECORD_HEADER_LEN + payload_len, 0);
521 if !matches!(
522 read_full(&mut file, &mut raw[LOG_RECORD_HEADER_LEN..])?,
523 ReadFull::Full
524 ) {
525 if allow_tail {
526 break;
527 }
528 return Err(corrupt("torn record payload".into()));
529 }
530 match decode_wal_record(&raw) {
531 Ok((record, _)) => {
532 records.push((record.lsn, raw));
533 record_offsets.push(record_start);
534 offset += (LOG_RECORD_HEADER_LEN + payload_len) as u64;
535 }
536 Err(error) => {
537 if allow_tail {
538 break;
539 }
540 return Err(corrupt(format!("record checksum/format: {error}")));
541 }
542 }
543 }
544
545 let mut assembler = crate::wal::ChainAssembler::default();
551 let mut valid_count = records.len();
552 for (i, (_, raw)) in records.iter().enumerate() {
553 if let Err(detail) = assembler.feed(raw, record_offsets[i]) {
554 if !allow_tail {
555 return Err(corrupt(detail));
556 }
557 valid_count = i;
558 break;
559 }
560 }
561 if valid_count == records.len() {
562 if let Some(open_start) = assembler.open_start_offset() {
563 if !allow_tail {
564 return Err(corrupt(
565 "sealed segment ends with an incomplete chained commit".into(),
566 ));
567 }
568 valid_count = record_offsets.partition_point(|&o| o < open_start);
569 }
570 }
571 let valid_bytes = record_offsets.get(valid_count).copied().unwrap_or(offset);
572 records.truncate(valid_count);
573
574 Ok((records, valid_bytes, file_len))
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580 use crate::wal::{encode_chained_ops_record, encode_wal_record};
581
582 fn temp_dir() -> crate::TempPath {
583 crate::TempPath::dir("logstore")
584 }
585
586 fn chained_records(
589 lsn: u64,
590 ops: &[crate::Op],
591 max_record_bytes: usize,
592 ) -> Vec<(u64, Vec<u8>)> {
593 encode_chained_ops_record(0, lsn, ops, None, max_record_bytes)
594 .into_iter()
595 .map(|framed| (lsn, framed))
596 .collect()
597 }
598
599 fn big_ops(n: usize, value_len: usize) -> Vec<crate::Op> {
600 (0..n)
601 .map(|i| crate::Op::Put(format!("k{i}").into_bytes(), vec![b'x'; value_len]))
602 .collect()
603 }
604
605 #[test]
606 fn chained_commit_advances_next_lsn_once_not_per_fragment() {
607 let dir = temp_dir();
608 let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
609 let ops = big_ops(20, 200);
610 let fragments = chained_records(1, &ops, 1024);
611 assert!(fragments.len() > 1, "expected this commit to need chaining");
612 log.append_batch(&fragments).unwrap();
613 assert_eq!(
614 log.next_lsn(),
615 2,
616 "a multi-fragment commit must advance next_lsn exactly once"
617 );
618 log.sync().unwrap();
619 let all = log.read_from(1).unwrap();
620 assert_eq!(
621 all.len(),
622 fragments.len(),
623 "every physical fragment is durable"
624 );
625
626 log.append_batch(&[record(2, b"small")]).unwrap();
629 assert_eq!(log.next_lsn(), 3);
630 }
631
632 #[test]
633 fn chained_commit_never_splits_across_a_segment_boundary() {
634 let dir = temp_dir();
635 let mut log = LogStore::open(&dir, 0, 512).unwrap();
639 let ops = big_ops(30, 100);
640 let fragments = chained_records(1, &ops, 1024);
641 assert!(fragments.len() > 1);
642 log.append_batch(&fragments).unwrap();
643 log.sync().unwrap();
644
645 let owning_segments: std::collections::HashSet<u64> = log
648 .segments()
649 .iter()
650 .filter(|s| s.first_lsn <= 1 && s.last_lsn >= 1)
651 .map(|s| s.first_lsn)
652 .collect();
653 assert_eq!(
654 owning_segments.len(),
655 1,
656 "a chained commit must not be split across segment files"
657 );
658 }
659
660 #[test]
661 fn reopen_discards_an_incomplete_trailing_chain_and_retry_at_the_same_lsn_succeeds() {
662 let dir = temp_dir();
663 {
664 let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
665 log.append_batch(&[record(1, b"first")]).unwrap();
666 log.sync().unwrap();
667 }
668 let ops = big_ops(10, 200);
672 let fragments = encode_chained_ops_record(0, 2, &ops, None, 512);
673 assert!(
674 fragments.len() > 2,
675 "need at least one continue + the final"
676 );
677 {
678 let mut file = OpenOptions::new()
679 .append(true)
680 .open(dir.join(segment_file_name(1)))
681 .unwrap();
682 for framed in &fragments[..fragments.len() - 1] {
683 file.write_all(framed).unwrap();
684 }
685 file.sync_all().unwrap();
686 }
687 let before_len = std::fs::metadata(dir.join(segment_file_name(1)))
688 .unwrap()
689 .len();
690
691 let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
692 assert_eq!(
693 log.next_lsn(),
694 2,
695 "the incomplete chain must not advance past its own lsn"
696 );
697 assert_eq!(
698 log.read_from(1).unwrap().len(),
699 1,
700 "only the first, complete commit survives"
701 );
702 let after_len = std::fs::metadata(dir.join(segment_file_name(1)))
703 .unwrap()
704 .len();
705 assert!(
706 after_len < before_len,
707 "the incomplete chain's bytes must be truncated away"
708 );
709
710 log.append_batch(&[record(2, b"retry")]).unwrap();
715 log.sync().unwrap();
716 assert_eq!(log.next_lsn(), 3);
717 let all = log.read_from(1).unwrap();
718 assert_eq!(all.len(), 2);
719 let (_, ops, _) = crate::wal::decode_ops_record(&all[1].1).unwrap();
720 assert_eq!(ops, vec![crate::Op::Put(Vec::new(), b"retry".to_vec())]);
721 }
722
723 #[test]
732 fn reopen_never_truncates_a_structurally_complete_record_with_corrupt_content() {
733 let dir = temp_dir();
734 {
735 let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
736 let framed = crate::wal::encode_ops_record(0, 1, &[], Some(b"not-json"));
737 log.append_batch(&[(1, framed)]).unwrap();
738 log.sync().unwrap();
739 }
740 let log = LogStore::open(&dir, 0, 1 << 30).unwrap();
741 assert_eq!(
742 log.next_lsn(),
743 2,
744 "a structurally complete record must not be discarded"
745 );
746 let all = log.read_from(1).unwrap();
747 assert_eq!(
748 all.len(),
749 1,
750 "the corrupt-content record must survive reopen"
751 );
752 assert!(crate::wal::decode_ops_record(&all[0].1).is_err());
756 }
757
758 fn record(lsn: u64, payload: &[u8]) -> (u64, Vec<u8>) {
763 let ops = vec![crate::Op::Put(Vec::new(), payload.to_vec())];
764 (lsn, crate::wal::encode_ops_record(0, lsn, &ops, None))
765 }
766
767 #[test]
768 fn append_sync_read_round_trip() {
769 let dir = temp_dir();
770 let mut log = LogStore::open(&dir, 0, 1 << 20).unwrap();
771 assert_eq!(log.next_lsn(), 1);
772 assert_eq!(log.durable_lsn(), 0);
773 log.append_batch(&[record(1, b"a"), record(2, b"bb"), record(3, b"ccc")])
774 .unwrap();
775 log.sync().unwrap();
776 assert_eq!(log.durable_lsn(), 3);
777 assert_eq!(log.next_lsn(), 4);
778
779 let all = log.read_from(1).unwrap();
780 assert_eq!(
781 all.iter().map(|(lsn, _)| *lsn).collect::<Vec<_>>(),
782 [1, 2, 3]
783 );
784 let tail = log.read_from(2).unwrap();
785 assert_eq!(tail.iter().map(|(lsn, _)| *lsn).collect::<Vec<_>>(), [2, 3]);
786 assert!(log.read_from(4).unwrap().is_empty());
787 }
788
789 #[test]
796 fn seed_next_lsn_if_empty_continues_past_a_restored_checkpoint() {
797 let dir = temp_dir();
798 let mut log = LogStore::open(&dir, 0, 1 << 20).unwrap();
799 assert_eq!(log.next_lsn(), 1);
800
801 log.seed_next_lsn_if_empty(51);
802 assert_eq!(
803 log.next_lsn(),
804 51,
805 "an empty log must adopt the materialization's checkpoint + 1"
806 );
807 log.append_batch(&[record(51, b"a")]).unwrap();
808 assert_eq!(log.next_lsn(), 52);
809
810 let fresh_dir = temp_dir();
813 let mut fresh = LogStore::open(&fresh_dir, 0, 1 << 20).unwrap();
814 fresh.seed_next_lsn_if_empty(1);
815 assert_eq!(fresh.next_lsn(), 1);
816
817 log.seed_next_lsn_if_empty(1_000);
820 assert_eq!(
821 log.next_lsn(),
822 52,
823 "seeding must not touch a log with real segments"
824 );
825 }
826
827 #[test]
828 fn rotates_segments_and_reads_across_them() {
829 let dir = temp_dir();
830 let mut log = LogStore::open(&dir, 0, 120).unwrap();
831 for lsn in 1..=6 {
832 log.append_batch(&[record(lsn, &[0xABu8; 40])]).unwrap();
833 }
834 log.sync().unwrap();
835 assert!(
836 log.segments().len() > 1,
837 "expected rotation, got {} segment(s)",
838 log.segments().len()
839 );
840 let all = log.read_from(1).unwrap();
841 assert_eq!(
842 all.iter().map(|(lsn, _)| *lsn).collect::<Vec<_>>(),
843 [1, 2, 3, 4, 5, 6]
844 );
845 }
846
847 #[test]
848 fn reopen_recovers_state() {
849 let dir = temp_dir();
850 {
851 let mut log = LogStore::open(&dir, 0, 1 << 20).unwrap();
852 log.append_batch(&[record(1, b"x"), record(2, b"y")])
853 .unwrap();
854 log.sync().unwrap();
855 }
856 let log = LogStore::open(&dir, 0, 1 << 20).unwrap();
857 assert_eq!(log.next_lsn(), 3);
858 assert_eq!(log.durable_lsn(), 2);
859 assert_eq!(log.read_from(1).unwrap().len(), 2);
860 }
861
862 #[test]
863 fn torn_tail_is_truncated_on_open() {
864 let dir = temp_dir();
865 {
866 let mut log = LogStore::open(&dir, 0, 1 << 20).unwrap();
867 log.append_batch(&[record(1, b"x"), record(2, b"y")])
868 .unwrap();
869 log.sync().unwrap();
870 }
871 let segment = dir.join(segment_file_name(1));
874 {
875 let mut file = OpenOptions::new().append(true).open(&segment).unwrap();
876 let framed = encode_wal_record(0, 3, b"a much longer payload");
877 file.write_all(&framed[..framed.len() - 5]).unwrap();
878 }
879 let before = std::fs::metadata(&segment).unwrap().len();
880 let log = LogStore::open(&dir, 0, 1 << 20).unwrap();
881 assert_eq!(log.next_lsn(), 3, "torn record must not advance the LSN");
882 assert_eq!(log.read_from(1).unwrap().len(), 2);
883 let after = std::fs::metadata(&segment).unwrap().len();
884 assert!(after < before, "torn tail must be truncated");
885 }
886
887 #[test]
888 fn sealed_torn_tail_is_corruption() {
889 let dir = temp_dir();
890 {
891 let mut log = LogStore::open(&dir, 0, 120).unwrap();
892 for lsn in 1..=4 {
893 log.append_batch(&[record(lsn, &[0xABu8; 40])]).unwrap();
894 }
895 log.sync().unwrap();
896 }
897 let first = dir.join(segment_file_name(1));
899 let mut bytes = std::fs::read(&first).unwrap();
900 let last = bytes.len() - 1;
901 bytes[last] ^= 0xFF;
902 std::fs::write(&first, &bytes).unwrap();
903 let error = LogStore::open(&dir, 0, 120).unwrap_err();
904 assert!(matches!(error, LogError::Corrupt { .. }), "got: {error}");
905 }
906
907 #[test]
908 fn out_of_order_append_is_rejected() {
909 let dir = temp_dir();
910 let mut log = LogStore::open(&dir, 0, 1 << 20).unwrap();
911 log.append_batch(&[record(1, b"a")]).unwrap();
912 let error = log.append_batch(&[record(3, b"c")]).unwrap_err();
913 assert!(
914 matches!(
915 error,
916 LogError::OutOfOrder {
917 expected: 2,
918 actual: 3
919 }
920 ),
921 "got: {error}"
922 );
923 }
924
925 #[test]
926 fn truncate_before_removes_only_sealed_segments() {
927 let dir = temp_dir();
928 let mut log = LogStore::open(&dir, 0, 120).unwrap();
929 for lsn in 1..=6 {
930 log.append_batch(&[record(lsn, &[0xABu8; 40])]).unwrap();
931 }
932 log.sync().unwrap();
933 let before = log.segments().len();
934 assert!(before > 1);
935 let active_first = log.segments().last().unwrap().first_lsn;
936 let removed = log.truncate_before(u64::MAX).unwrap();
937 assert_eq!(removed, before - 1, "all sealed segments must go");
938 assert_eq!(log.segments().len(), 1);
939 assert_eq!(log.segments()[0].first_lsn, active_first);
940 assert!(!log.read_from(active_first).unwrap().is_empty());
942 }
943
944 #[test]
945 fn rejects_foreign_shard_segment() {
946 let dir = temp_dir();
947 {
948 let mut log = LogStore::open(&dir, 7, 1 << 20).unwrap();
949 log.append_batch(&[record(1, b"a")]).unwrap();
950 log.sync().unwrap();
951 }
952 let error = LogStore::open(&dir, 0, 1 << 20).unwrap_err();
953 assert!(matches!(error, LogError::Corrupt { .. }), "got: {error}");
954 }
955}