1use {
8 crate::{
9 config::MetadataLogConfig,
10 error::{PersistedFormatError, RaftError},
11 kraft::types::{Epoch, LogOffsetMetadata, LogView},
12 },
13 krabka_ids::{LeaderEpoch, Offset},
14 krabka_log::{FailedDeletion, Log, LogConfig, RawRead},
15 krabka_protocol::{Decode as _, records::RecordBatch},
16 krabka_units::{prelude, prelude::ByteSize},
17 krabka_verified::raft,
18 std::{
19 fs,
20 io::{Error, ErrorKind},
21 path::{Path, PathBuf},
22 str,
23 thread::{self, JoinHandle},
24 time::SystemTime,
25 },
26};
27
28pub struct KraftLog {
29 log: Log,
30 hwm: Offset,
33 hwm_path: PathBuf,
34 failure: Option<String>,
38 reaping: Option<JoinHandle<Result<(), FailedDeletion>>>,
41}
42
43const TIMESTAMP_READ_WINDOW: ByteSize = prelude::kibibytes(64);
47
48const HIGH_WATERMARK_FILE: &str = "high-watermark";
54
55const HIGH_WATERMARK_TMP_FILE: &str = "high-watermark.tmp";
58
59pub(crate) const HIGH_WATERMARK_FILE_VERSION: i16 = 0;
65
66fn encode_high_watermark(hwm: Offset) -> String {
68 format!("{HIGH_WATERMARK_FILE_VERSION}\n{}\n", hwm.0)
69}
70
71fn decode_high_watermark(text: &str) -> Result<Offset, PersistedFormatError> {
79 let malformed = || PersistedFormatError::Malformed(format!("{text:?}"));
80 let Some((version, rest)) = text.split_once('\n') else {
81 return Err(if text.trim().parse::<i64>().is_ok() {
82 PersistedFormatError::MissingVersion
83 } else {
84 malformed()
85 });
86 };
87 let version = version.parse::<i64>().map_err(|_| malformed())?;
88 if version != i64::from(HIGH_WATERMARK_FILE_VERSION) {
89 return Err(PersistedFormatError::UnsupportedVersion {
90 found: version,
91 min: i64::from(HIGH_WATERMARK_FILE_VERSION),
92 max: i64::from(HIGH_WATERMARK_FILE_VERSION),
93 });
94 }
95 rest.strip_suffix('\n')
96 .and_then(|offset| offset.parse::<i64>().ok())
97 .filter(|offset| *offset >= 0)
98 .map(Offset)
99 .ok_or_else(malformed)
100}
101
102fn recover_high_watermark(hwm_path: &Path, log_start: Offset) -> Result<Offset, RaftError> {
119 let bytes = match fs::read(hwm_path) {
120 Ok(bytes) => bytes,
121 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(log_start),
122 Err(error) => {
123 tracing::warn!(?error, path = %hwm_path.display(), "kraft: high watermark unreadable; starting from the log start");
124 return Ok(log_start);
125 }
126 };
127 let decoded = str::from_utf8(&bytes)
128 .map_err(|error| PersistedFormatError::Malformed(error.to_string()))
129 .and_then(decode_high_watermark);
130 match decoded {
131 Ok(hwm) => Ok(hwm),
132 Err(PersistedFormatError::Malformed(reason)) => {
133 tracing::warn!(%reason, path = %hwm_path.display(), "kraft: high watermark does not parse; starting from the log start");
134 Ok(log_start)
135 }
136 Err(problem) => Err(RaftError::PersistedFormat {
137 artifact: HIGH_WATERMARK_FILE,
138 path: hwm_path.to_path_buf(),
139 problem,
140 }),
141 }
142}
143
144fn metadata_log_config(config: &MetadataLogConfig) -> LogConfig {
150 LogConfig {
151 segment_size: config.segment_size,
152 segment_roll_interval: config.segment_roll_interval,
153 retention: None,
154 retention_size: None,
155 ..LogConfig::default()
156 }
157}
158
159impl KraftLog {
160 pub fn open(dir: impl AsRef<Path>, config: &MetadataLogConfig) -> Result<Self, RaftError> {
169 let log_dir = dir.as_ref();
170 let hwm_path = log_dir.join(HIGH_WATERMARK_FILE);
171 fs::create_dir_all(log_dir).map_err(krabka_log::LogError::Io)?;
172 let log = Log::open(log_dir, metadata_log_config(config))?;
175 let hwm = recover_high_watermark(&hwm_path, log.log_start_offset())?
176 .max(log.log_start_offset())
177 .min(log.log_end_offset());
178 Ok(Self {
179 log,
180 hwm,
181 hwm_path,
182 failure: None,
183 reaping: None,
184 })
185 }
186
187 #[must_use]
190 pub fn failure(&self) -> Option<&str> { self.failure.as_deref() }
191
192 fn checked<T>(&mut self, result: Result<T, krabka_log::LogError>) -> Result<T, RaftError> {
195 if let Err(krabka_log::LogError::Io(error)) = &result {
196 self.note_failure(error);
197 }
198 Ok(result?)
199 }
200
201 fn note_failure(&mut self, error: &Error) {
202 if self.failure.is_none() {
203 self.failure = Some(error.to_string());
204 }
205 }
206
207 #[must_use]
208 pub fn log_start_offset(&self) -> Offset { self.log.log_start_offset() }
209
210 #[must_use]
211 pub fn log_end_offset(&self) -> Offset { self.log.log_end_offset() }
212
213 #[must_use]
214 pub fn hwm(&self) -> Offset { self.hwm }
215
216 #[must_use]
219 pub fn size(&self) -> ByteSize { self.log.size() }
220
221 pub fn append(
234 &mut self,
235 batch: &mut RecordBatch,
236 append_timestamp_ms: i64,
237 ) -> Result<Offset, RaftError> {
238 batch.base_timestamp = append_timestamp_ms;
241 batch.max_timestamp = append_timestamp_ms;
242 let appended = self.log.append(batch);
245 let (base_offset, _log_append_time_ms) = self.checked(appended)?;
246 Ok(base_offset)
247 }
248
249 pub fn append_at(&mut self, batch: &mut RecordBatch, offset: Offset) -> Result<(), RaftError> {
255 let appended = self.log.append_at(batch, offset);
256 self.checked(appended)?;
257 Ok(())
258 }
259
260 pub fn read_decoded(
266 &self,
267 offset: Offset,
268 max_size: ByteSize,
269 ) -> Result<Vec<RecordBatch>, RaftError> {
270 let log_end = self.log.log_end_offset();
271 if offset >= log_end {
272 return Ok(Vec::new());
273 }
274 let mut window = max_size;
275 loop {
276 let raw = self.log.read_raw(offset, log_end, window)?;
277 let mut bytes = raw.bytes.as_ref();
278 let mut batches = Vec::new();
279 while !bytes.is_empty() {
280 batches.push(
281 RecordBatch::decode(&mut bytes)
282 .map_err(|error| RaftError::ChangeRejected(error.to_string()))?,
283 );
284 }
285 if !batches.is_empty() {
286 return Ok(batches);
287 }
288 window *= 2.0;
293 }
294 }
295
296 #[must_use]
306 pub fn timestamp_below(&self, end_offset: Offset) -> Option<i64> {
307 let last = Offset(end_offset.0.checked_sub(1)?);
308 if last < self.log.log_start_offset() || last >= self.log.log_end_offset() {
309 return None;
310 }
311 let batches = self.read_decoded(last, TIMESTAMP_READ_WINDOW).ok()?;
312 batches
316 .iter()
317 .take_while(|batch| batch.base_offset <= last.0)
318 .last()
319 .map(|batch| batch.max_timestamp)
320 }
321
322 #[must_use]
325 pub fn last_committed_timestamp_ms(&self) -> Option<i64> { self.timestamp_below(self.hwm) }
326
327 pub fn read_committed(&self, offset: Offset, max_size: ByteSize) -> Result<RawRead, RaftError> {
333 let limit = self.hwm.min(self.log.log_end_offset());
334 Ok(self.log.read_raw(offset, limit, max_size)?)
335 }
336
337 pub fn advance_hwm(&mut self, new_hwm: Offset) {
340 let log_end = self.log.log_end_offset();
341 let next = Offset(raft::advance_high_watermark(
342 self.hwm.0, new_hwm.0, log_end.0,
343 ));
344 if next > self.hwm {
345 self.hwm = next;
346 self.persist_hwm();
347 }
348 assert2::assert!(self.hwm <= log_end);
349 }
350
351 pub fn truncate_to(&mut self, offset: Offset) -> Result<(), RaftError> {
357 let truncated = self.log.truncate_to(offset);
358 self.checked(truncated)?;
359 self.hwm = Offset(krabka_verified::truncation_frontier(
361 self.hwm.0,
362 self.log.log_end_offset().0,
363 ));
364 self.persist_hwm();
365 Ok(())
366 }
367
368 pub fn prune_to(&mut self, end_offset: Offset) -> Result<(), RaftError> {
376 if end_offset <= self.log.log_start_offset() {
377 return Ok(());
378 }
379 let trimmed = self.log.trim_to_offset(end_offset);
384 self.checked(trimmed)?;
385 Ok(())
386 }
387
388 pub fn reap_retired_files(&mut self, now: SystemTime) -> Result<(), RaftError> {
411 if let Some(reaping) = self.reaping.take_if(|reaping| reaping.is_finished()) {
412 let deleted = reaping.join().map_err(|_| {
413 RaftError::Storage(krabka_log::LogError::Corrupt(
414 "the retired-file deletion panicked".into(),
415 ))
416 })?;
417 if let Err(FailedDeletion { error, remaining }) = deleted {
418 self.log.restore_deletions(remaining, now);
419 return self.checked(Err(error));
420 }
421 }
422 if self.reaping.is_some() {
423 return Ok(());
424 }
425 let due = self.log.maintain_deferring_deletions(now);
426 let due = self.checked(due)?;
427 if !due.is_empty() {
428 self.reaping = Some(thread::spawn(move || due.delete()));
429 }
430 Ok(())
431 }
432
433 pub fn install_snapshot(&mut self, end_offset: Offset) -> Result<(), RaftError> {
441 let reset = self.log.reset_to(end_offset);
442 self.checked(reset)?;
443 self.hwm = end_offset;
444 self.persist_hwm();
445 Ok(())
446 }
447
448 fn persist_hwm(&mut self) {
454 let tmp = self.hwm_path.with_file_name(HIGH_WATERMARK_TMP_FILE);
455 let written = fs::write(&tmp, encode_high_watermark(self.hwm))
456 .and_then(|()| fs::rename(&tmp, &self.hwm_path));
457 if let Err(error) = written {
458 tracing::error!(?error, path = %self.hwm_path.display(), "kraft: persist high watermark failed");
459 self.note_failure(&error);
460 }
461 }
462}
463
464#[must_use]
472pub fn end_offset_for_epoch_in(
473 checkpoint: &krabka_log::LeaderEpochCheckpoint,
474 log_end: Offset,
475 epoch: Epoch,
476) -> LogOffsetMetadata {
477 let latest = u32::try_from(checkpoint.latest_epoch().unwrap_or(LeaderEpoch(0)).0).unwrap_or(0);
478 let past_every_epoch = LogOffsetMetadata {
479 offset: log_end.0,
480 epoch: latest,
481 };
482 let Ok(requested) = i32::try_from(epoch) else {
483 return past_every_epoch;
484 };
485 let (found, end) = checkpoint.epoch_and_offset_for(LeaderEpoch(requested), log_end);
486 u32::try_from(found.0).map_or(past_every_epoch, |found| LogOffsetMetadata {
487 offset: end.0,
488 epoch: found,
489 })
490}
491
492impl LogView for KraftLog {
493 fn end_offset(&self) -> i64 { self.log.log_end_offset().0 }
497
498 fn last_epoch(&self) -> Epoch {
499 latest_epoch_in(self.log.epoch_checkpoint())
503 }
504
505 fn end_offset_for_epoch(&self, epoch: Epoch) -> LogOffsetMetadata {
506 end_offset_for_epoch_in(
507 self.log.epoch_checkpoint(),
508 self.log.log_end_offset(),
509 epoch,
510 )
511 }
512}
513
514#[must_use]
516pub fn latest_epoch_in(checkpoint: &krabka_log::LeaderEpochCheckpoint) -> Epoch {
517 let latest = checkpoint.latest_epoch().unwrap_or(LeaderEpoch(0));
518 u32::try_from(latest.0).unwrap_or(0)
519}
520
521#[cfg(test)]
522mod tests {
523 use {
524 super::*,
525 assert2::{assert, check},
526 krabka_units::{prelude, prelude::mebibytes},
527 std::{
528 fs, thread,
529 time::{Duration, Instant},
530 },
531 };
532
533 const TEST_READ_BUDGET: ByteSize = mebibytes(1);
536
537 #[derive(derive_more::From, derive_more::Into, Clone, Copy)]
538 struct KraftSeedCount(usize);
539
540 #[derive(Clone, Copy, krabka_macros::FieldDefaults)]
541 struct KraftLogSeedSetup {
542 #[default(KraftSeedCount(5))]
543 records: KraftSeedCount,
544 }
545
546 #[bon::builder]
547 fn append_seeded_records(
548 log: &mut KraftLog,
549 #[builder(into, default = KraftSeedCount (5))] records: KraftSeedCount,
550 ) {
551 let setup = KraftLogSeedSetup { records };
552 for _ in 0..setup.records.0 {
553 log.append(&mut batch(0, 1, b"x"), 0).unwrap();
554 }
555 }
556
557 struct LogInDir {
559 log: KraftLog,
560 dir: tempfile::TempDir,
561 }
562
563 #[bon::builder]
564 fn seeded_log(
565 #[builder(into, default = KraftSeedCount (5))] records: KraftSeedCount,
566 ) -> LogInDir {
567 let setup = KraftLogSeedSetup { records };
568 let LogInDir { mut log, dir } = open_tmp();
569 append_seeded_records()
570 .log(&mut log)
571 .records(setup.records)
572 .call();
573 LogInDir { log, dir }
574 }
575
576 fn open_tmp() -> LogInDir {
577 let dir = tempfile::tempdir().expect("tempdir");
578 let log = KraftLog::open(dir.path(), &MetadataLogConfig::default()).expect("open");
579 LogInDir { log, dir }
580 }
581
582 krabka_macros::epoch_record_batch_fixture!(batch);
583
584 #[test]
585 fn opens_empty_at_offset_zero() {
586 let LogInDir { log, dir: _dir } = open_tmp();
587 check!(
588 (
589 log.log_start_offset().0,
590 log.log_end_offset().0,
591 log.hwm().0,
592 ) == (0, 0, 0)
593 );
594 }
595
596 #[test]
597 fn append_assigns_sequential_offsets_and_reads_back() {
598 let LogInDir { mut log, dir: _dir } = open_tmp();
599 let off0 = log.append(&mut batch(0, 1, b"a"), 0).unwrap();
600 let off1 = log.append(&mut batch(0, 1, b"b"), 0).unwrap();
601 assert2::assert!((off0, off1, log.log_end_offset()) == (Offset(0), Offset(1), Offset(2)));
602 let out = log.read_decoded(Offset(0), TEST_READ_BUDGET).unwrap();
604 assert2::assert!(
605 out.iter()
606 .map(|batch| batch.partition_leader_epoch)
607 .collect::<Vec<_>>()
608 == vec![1, 1]
609 );
610 }
611
612 #[test]
613 fn append_stamps_the_batch_create_time_the_snapshot_header_reads_back() {
614 let LogInDir { mut log, dir: _dir } = open_tmp();
615 let older = 1_700_000_000_000;
619 let newer = 1_700_000_111_222;
620 log.append(&mut batch(0, 1, b"a"), older).unwrap();
621 log.append(&mut batch(0, 1, b"b"), newer).unwrap();
622
623 log.advance_hwm(Offset(1));
624 let at_first = log.last_committed_timestamp_ms();
625 log.advance_hwm(Offset(2));
626
627 assert2::assert!(
628 (
629 at_first,
630 log.last_committed_timestamp_ms(),
631 log.timestamp_below(Offset(1)),
632 log.read_decoded(Offset(0), TEST_READ_BUDGET)
633 .unwrap()
634 .iter()
635 .map(|batch| (batch.base_timestamp, batch.max_timestamp))
636 .collect::<Vec<_>>(),
637 ) == (Some(older), Some(newer), Some(older), vec![
638 (older, older),
639 (newer, newer)
640 ],)
641 );
642 }
643
644 #[test]
645 fn no_contained_record_has_no_timestamp() {
646 let LogInDir { mut log, dir: _dir } = open_tmp();
647 let empty = log.last_committed_timestamp_ms();
650 log.append(&mut batch(0, 1, b"a"), 1_700_000_000_000)
651 .unwrap();
652 let uncommitted = log.last_committed_timestamp_ms();
653
654 log.advance_hwm(Offset(1));
657 log.prune_to(Offset(1)).unwrap();
658
659 assert2::assert!(
660 (
661 empty,
662 uncommitted,
663 log.last_committed_timestamp_ms(),
664 log.timestamp_below(Offset(0)),
665 log.timestamp_below(Offset(9)),
666 ) == (None, None, None, None, None)
667 );
668 }
669
670 #[test]
671 fn append_return_matches_assigned_base_and_advances_public_end_offset() {
672 let LogInDir { mut log, dir: _dir } = open_tmp();
673
674 let first = log.append(&mut batch(0, 1, b"a"), 0).unwrap();
675 let second = log.append(&mut batch(0, 1, b"b"), 0).unwrap();
676 let decoded = log.read_decoded(Offset(0), TEST_READ_BUDGET).unwrap();
677
678 check!(
679 (
680 first.0,
681 second.0,
682 decoded
683 .iter()
684 .map(|batch| batch.base_offset)
685 .collect::<Vec<_>>(),
686 log.log_end_offset().0,
687 LogView::end_offset(&log),
688 ) == (0, 1, vec![first.0, second.0], 2, 2)
689 );
690 }
691
692 #[test]
693 fn public_hwm_accessor_tracks_committed_offset_after_advance_and_snapshot() {
694 let LogInDir { mut log, dir: _dir } = seeded_log().records(3).call();
695 log.advance_hwm(Offset(2));
696 assert2::assert!(log.hwm() == 2);
697
698 log.install_snapshot(Offset(9)).unwrap();
699 check!(
700 (
701 log.hwm().0,
702 log.log_start_offset().0,
703 log.log_end_offset().0
704 ) == (9, 9, 9)
705 );
706 }
707
708 #[test]
709 fn append_at_preserves_leader_offset() {
710 let LogInDir { mut log, dir: _dir } = open_tmp();
711 log.append_at(&mut batch(0, 2, b"x"), Offset(0)).unwrap();
713 assert2::assert!(log.log_end_offset().0 == 1);
714 assert2::assert!(
715 log.read_decoded(Offset(0), TEST_READ_BUDGET).unwrap()[0].partition_leader_epoch == 2
716 );
717 }
718
719 #[test]
720 fn logview_reports_end_offset_and_last_epoch() {
721 let LogInDir { mut log, dir: _dir } = open_tmp();
722 log.append(&mut batch(0, 1, b"a"), 0).unwrap();
723 log.append(&mut batch(0, 3, b"b"), 0).unwrap(); assert2::assert!(LogView::end_offset(&log) == 2);
725 assert2::assert!(LogView::last_epoch(&log) == 3);
726 }
727
728 #[yare::parameterized(
729 epoch_older_than_log = {
730 "an epoch older than the log",
731 1,
732 LogOffsetMetadata { offset: 0, epoch: 1 },
733 },
734 completed_prior_epoch = {
735 "a completed prior epoch",
736 2,
737 LogOffsetMetadata { offset: 1, epoch: 2 },
738 },
739 between_held_epochs = {
740 "an epoch between two the log holds",
741 3,
742 LogOffsetMetadata { offset: 1, epoch: 2 },
743 },
744 current_epoch = { "the current epoch", 4, LogOffsetMetadata { offset: 2, epoch: 4 } },
745 newer_than_log = {
746 "an epoch newer than the log",
747 9,
748 LogOffsetMetadata { offset: 2, epoch: 4 },
749 },
750 )]
751 fn logview_end_offset_for_epoch_follows_kafka(
752 case: &str,
753 epoch: u32,
754 expected: LogOffsetMetadata,
755 ) {
756 let LogInDir { mut log, dir: _dir } = open_tmp();
757 log.append(&mut batch(0, 2, b"a"), 0).unwrap(); log.append(&mut batch(0, 4, b"b"), 0).unwrap(); check!(
760 LogView::end_offset_for_epoch(&log, epoch) == expected,
761 "{case}"
762 );
763 }
764
765 #[test]
766 fn empty_log_last_epoch_is_zero() {
767 let LogInDir { log, dir: _dir } = open_tmp();
768 assert2::assert!(LogView::last_epoch(&log) == 0);
769 }
770
771 #[test]
772 fn read_committed_never_returns_bytes_past_hwm() {
773 let LogInDir { mut log, dir: _dir } = seeded_log().call(); log.advance_hwm(Offset(3));
775 let r = log.read_committed(Offset(0), TEST_READ_BUDGET).unwrap();
776 let decoded = log.read_decoded(Offset(0), TEST_READ_BUDGET).unwrap();
778 let committed: Vec<_> = decoded.into_iter().filter(|b| b.base_offset < 3).collect();
779 check!(committed.len() == 3);
780 check!((r.start_offset.0, r.bytes.is_empty()) == (0, false));
782 }
783
784 #[test]
785 fn advance_hwm_is_monotonic_and_clamped_to_log_end() {
786 let LogInDir { mut log, dir: _dir } = open_tmp();
787 log.append(&mut batch(0, 1, b"x"), 0).unwrap(); log.advance_hwm(Offset(5)); assert2::assert!(log.hwm() == 1);
790 log.advance_hwm(Offset(0)); assert2::assert!(log.hwm() == 1);
792 }
793
794 #[test]
797 fn advance_hwm_to_the_current_value_does_not_persist() {
798 let LogInDir { mut log, dir } = open_tmp();
799 log.append(&mut batch(0, 1, b"x"), 0).unwrap();
800 log.advance_hwm(Offset(1));
801 fs::create_dir(dir.path().join(HIGH_WATERMARK_TMP_FILE)).expect("block the tmp file");
802 log.advance_hwm(Offset(1));
803 assert2::assert!(log.hwm() == 1);
804 assert2::assert!(log.failure() == None);
805 }
806
807 #[test]
808 fn prune_to_advances_log_start_and_is_noop_when_behind() {
809 let LogInDir { mut log, dir: _dir } = seeded_log().call();
810 log.advance_hwm(log.log_end_offset());
811 assert2::assert!(log.log_start_offset() == 0);
812 log.prune_to(Offset(3)).unwrap();
813 assert2::assert!(log.log_start_offset() == 3);
814 log.prune_to(Offset(2)).unwrap(); assert2::assert!(log.log_start_offset() == 3);
816 }
817
818 #[test]
819 fn timestamp_below_returns_none_for_out_of_bounds() {
820 let LogInDir { mut log, dir: _dir } = seeded_log().call();
821 log.advance_hwm(log.log_end_offset());
822 log.prune_to(Offset(3)).unwrap();
823
824 assert2::assert!(log.timestamp_below(Offset(3)) == None);
825 assert2::assert!(log.timestamp_below(Offset(2)) == None);
826 assert2::assert!(log.timestamp_below(Offset(10)) == None);
827 assert2::assert!(log.timestamp_below(Offset(4)).is_some());
828 assert2::assert!(log.timestamp_below(Offset(5)).is_some());
829 }
830
831 #[test]
832 fn prune_inside_the_active_segment_survives_a_reopen() {
833 let dir = tempfile::tempdir().expect("tempdir");
834 {
835 let mut log = KraftLog::open(dir.path(), &MetadataLogConfig::default()).expect("open");
836 append_seeded_records().log(&mut log).call();
837 log.advance_hwm(log.log_end_offset());
838 log.prune_to(Offset(3)).unwrap();
841 }
842
843 let log = KraftLog::open(dir.path(), &MetadataLogConfig::default()).expect("reopen");
844
845 check!((log.log_start_offset().0, log.log_end_offset().0) == (3, 5));
846 }
847
848 #[test]
849 fn install_snapshot_resets_log_to_empty_at_offset() {
850 let LogInDir { mut log, dir: _dir } = seeded_log().records(4).call();
851 log.install_snapshot(Offset(100)).unwrap();
852 check!(
853 (
854 log.log_start_offset().0,
855 log.log_end_offset().0,
856 log.hwm().0,
857 ) == (100, 100, 100)
858 );
859 let base = log.append(&mut batch(0, 1, b"x"), 0).unwrap();
860 assert2::assert!(base == 100);
861 }
862
863 #[test]
864 fn truncate_to_drops_log_end_and_hwm() {
865 let LogInDir { mut log, dir: _dir } = seeded_log().call();
866 log.advance_hwm(Offset(5));
867 log.truncate_to(Offset(2)).unwrap();
868 assert2::assert!(log.log_end_offset().0 == 2);
869 assert2::assert!(log.hwm().0 == 2);
870 }
871
872 #[test]
873 fn truncation_inside_a_batch_clamps_hwm_to_the_retained_end() {
874 for hwm in [0, 1, 2, 3] {
875 let LogInDir { mut log, dir } = open_tmp();
876 log.append(&mut batch(0, 1, b"retained"), 0).unwrap();
877 let mut tail = batch(0, 1, b"discarded");
878 tail.last_offset_delta = 1;
879 let mut second = tail.records[0].clone();
880 second.offset_delta = 1;
881 tail.records.push(second);
882 log.append(&mut tail, 0).unwrap();
883 log.advance_hwm(Offset(hwm));
884 log.truncate_to(Offset(2)).unwrap();
885 assert2::assert!(log.log_end_offset() == Offset(1));
886 assert2::assert!(log.hwm() == Offset(hwm.min(1)));
887 log.advance_hwm(Offset(2));
888 assert2::assert!(log.hwm() == Offset(1));
889 drop(log);
890 let reopened = KraftLog::open(dir.path(), &MetadataLogConfig::default()).unwrap();
891 assert2::assert!(reopened.log_end_offset() == Offset(1));
892 assert2::assert!(reopened.hwm() == Offset(1));
893 }
894 }
895
896 #[test]
900 fn truncate_below_log_start_lowers_the_log_start() {
901 let LogInDir { mut log, dir: _dir } = seeded_log().records(4).call();
902 log.prune_to(Offset(2)).unwrap();
903
904 log.truncate_to(Offset(1)).unwrap();
905 check!(
906 (
907 log.log_start_offset().0,
908 log.log_end_offset().0,
909 log.hwm().0
910 ) == (1, 1, 0)
911 );
912 }
913
914 fn segment_files(dir: &Path) -> Vec<String> {
916 let mut names: Vec<String> = fs::read_dir(dir)
917 .expect("list the partition directory")
918 .flatten()
919 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "log"))
920 .filter_map(|entry| entry.file_name().into_string().ok())
921 .collect();
922 names.sort();
923 names
924 }
925
926 #[test]
930 fn segments_roll_by_the_metadata_log_limits() {
931 let cases = [
933 (
934 "inside both limits",
935 mebibytes(1),
936 prelude::secs(10),
937 vec![0, 5_000, 10_000],
938 vec!["00000000000000000000.log"],
939 ),
940 (
941 "the size limit",
942 prelude::bytes(1),
943 prelude::secs(10),
944 vec![0, 1, 2],
945 vec![
946 "00000000000000000000.log",
947 "00000000000000000001.log",
948 "00000000000000000002.log",
949 ],
950 ),
951 (
952 "the roll interval",
953 mebibytes(1),
954 prelude::secs(10),
955 vec![0, 10_000, 10_001],
956 vec!["00000000000000000000.log", "00000000000000000002.log"],
957 ),
958 ];
959 for (what, segment_size, segment_roll_interval, timestamps, segments) in cases {
960 let dir = tempfile::tempdir().expect("tempdir");
961 let config = MetadataLogConfig {
962 segment_size,
963 segment_roll_interval,
964 ..MetadataLogConfig::default()
965 };
966 let mut log = KraftLog::open(dir.path(), &config).expect("open");
967 for timestamp in timestamps {
968 log.append(&mut batch(0, 1, b"x"), timestamp)
969 .expect("append");
970 }
971 check!(segment_files(dir.path()) == segments, "{what}");
972 }
973 }
974
975 fn four_committed_segments() -> LogInDir {
977 let dir = tempfile::tempdir().expect("tempdir");
978 let config = MetadataLogConfig {
979 segment_size: prelude::bytes(1),
980 ..MetadataLogConfig::default()
981 };
982 let mut log = KraftLog::open(dir.path(), &config).expect("open");
983 for _ in 0..4 {
984 log.append(&mut batch(0, 1, b"x"), 0).expect("append");
985 }
986 log.advance_hwm(Offset(4));
987 LogInDir { log, dir }
988 }
989
990 #[test]
994 fn a_prune_deletes_the_segments_wholly_below_the_new_log_start() {
995 let LogInDir { mut log, dir } = four_committed_segments();
996
997 log.prune_to(Offset(2)).expect("prune");
998
999 check!(
1000 (log.log_start_offset(), segment_files(dir.path()))
1001 == (Offset(2), vec![
1002 "00000000000000000002.log".to_owned(),
1003 "00000000000000000003.log".to_owned(),
1004 ])
1005 );
1006 }
1007
1008 #[test]
1013 fn a_reap_deletes_the_pruned_segments_once_their_delay_has_passed() {
1014 let retired = |dir: &Path| -> Vec<String> {
1015 let mut names: Vec<String> = fs::read_dir(dir)
1016 .expect("list the partition directory")
1017 .flatten()
1018 .filter_map(|entry| entry.file_name().into_string().ok())
1019 .filter(|name| name.ends_with(".deleted"))
1020 .collect();
1021 names.sort();
1022 names
1023 };
1024 let LogInDir { mut log, dir } = four_committed_segments();
1025 let pruned_at = SystemTime::now();
1026 log.prune_to(Offset(2)).expect("prune");
1027 let after_prune_at = SystemTime::now();
1031 let after_prune = retired(dir.path());
1032 check!(
1033 after_prune
1034 .iter()
1035 .any(|name| name.starts_with("00000000000000000000.log")),
1036 "{after_prune:?}"
1037 );
1038
1039 for (when, now, left) in [
1040 ("inside the delay", pruned_at, after_prune),
1041 (
1042 "past the delay",
1043 after_prune_at + Duration::from_secs(61),
1044 Vec::new(),
1045 ),
1046 ] {
1047 log.reap_retired_files(now).expect("reap");
1048 let deadline = Instant::now() + Duration::from_secs(10);
1051 while retired(dir.path()) != left && Instant::now() < deadline {
1052 thread::sleep(Duration::from_millis(10));
1053 log.reap_retired_files(now).expect("reap");
1054 }
1055 check!(retired(dir.path()) == left, "{when}");
1056 }
1057 }
1058
1059 const HIGH_WATERMARK_V0_FIXTURE: &[u8] = b"0\n3\n";
1062
1063 fn log_with_hwm_three() -> LogInDir {
1065 let LogInDir { mut log, dir } = open_tmp();
1066 for _ in 0..4 {
1067 log.append(&mut batch(0, 1, b"x"), 0).expect("append");
1068 }
1069 log.advance_hwm(Offset(3));
1070 LogInDir { log, dir }
1071 }
1072
1073 #[test]
1074 fn the_high_watermark_file_matches_the_version_zero_fixture() {
1075 let LogInDir { log, dir } = log_with_hwm_three();
1076 drop(log);
1077
1078 let written = fs::read(dir.path().join(HIGH_WATERMARK_FILE)).expect("read");
1079 check!(written == HIGH_WATERMARK_V0_FIXTURE);
1080 check!(!dir.path().join(HIGH_WATERMARK_TMP_FILE).exists());
1081 check!(decode_high_watermark("0\n3\n") == Ok(Offset(3)));
1082 }
1083
1084 #[test]
1085 fn decode_high_watermark_refuses_every_other_layout() {
1086 let unsupported = |found| PersistedFormatError::UnsupportedVersion {
1087 found,
1088 min: 0,
1089 max: 0,
1090 };
1091 let malformed = |text: &str| PersistedFormatError::Malformed(format!("{text:?}"));
1092 let cases = [
1093 ("0.x layout", "3", PersistedFormatError::MissingVersion),
1094 (
1095 "0.x layout, padded",
1096 " 3 ",
1097 PersistedFormatError::MissingVersion,
1098 ),
1099 ("future version", "1\n3\n", unsupported(1)),
1100 ("negative version", "-1\n3\n", unsupported(-1)),
1101 ("empty", "", malformed("")),
1102 ("version only", "0\n", malformed("0\n")),
1103 ("no final newline", "0\n3", malformed("0\n3")),
1104 ("trailing line", "0\n3\n4\n", malformed("0\n3\n4\n")),
1105 ("negative offset", "0\n-3\n", malformed("0\n-3\n")),
1106 ("not a number", "0\nx\n", malformed("0\nx\n")),
1107 ("version not a number", "v\n3\n", malformed("v\n3\n")),
1108 ];
1109 for (case, text, want) in cases {
1110 check!(decode_high_watermark(text) == Err(want), "{case}");
1111 }
1112 }
1113
1114 #[test]
1117 fn open_refuses_an_unknown_high_watermark_version_and_drops_a_damaged_one() {
1118 struct Scenario {
1119 case: &'static str,
1120 contents: &'static [u8],
1121 want: Result<Offset, PersistedFormatError>,
1122 }
1123 let cases = [
1124 Scenario {
1125 case: "current version",
1126 contents: HIGH_WATERMARK_V0_FIXTURE,
1127 want: Ok(Offset(3)),
1128 },
1129 Scenario {
1130 case: "0.x layout",
1131 contents: b"3",
1132 want: Err(PersistedFormatError::MissingVersion),
1133 },
1134 Scenario {
1135 case: "future version",
1136 contents: b"7\n3\n",
1137 want: Err(PersistedFormatError::UnsupportedVersion {
1138 found: 7,
1139 min: 0,
1140 max: 0,
1141 }),
1142 },
1143 Scenario {
1144 case: "empty after a crash",
1145 contents: b"",
1146 want: Ok(Offset(0)),
1147 },
1148 Scenario {
1149 case: "not utf-8",
1150 contents: b"0\n\xff\n",
1151 want: Ok(Offset(0)),
1152 },
1153 ];
1154 for Scenario {
1155 case,
1156 contents,
1157 want,
1158 } in cases
1159 {
1160 let LogInDir { log, dir } = log_with_hwm_three();
1161 drop(log);
1162 let path = dir.path().join(HIGH_WATERMARK_FILE);
1163 fs::write(&path, contents).expect("write the high-watermark file");
1164
1165 let opened = KraftLog::open(dir.path(), &MetadataLogConfig::default());
1166
1167 let got = match opened {
1168 Ok(log) => Ok(log.hwm()),
1169 Err(RaftError::PersistedFormat {
1170 artifact,
1171 path: reported,
1172 problem,
1173 }) => {
1174 check!(
1175 (artifact, reported) == (HIGH_WATERMARK_FILE, path.clone()),
1176 "{case}"
1177 );
1178 Err(problem)
1179 }
1180 Err(other) => panic!("{case}: unexpected error {other}"),
1181 };
1182 check!(got == want, "{case}");
1183 }
1184 }
1185}