1#[cfg(not(target_os = "wasi"))]
4use std::mem;
5#[cfg(all(test, not(target_os = "wasi")))]
6use std::{cell::RefCell, sync::mpsc};
7use {
8 crate::{
9 LogError,
10 io::{IoTarget, LogIo, write_atomic},
11 name,
12 },
13 bytes::BufMut as _,
14 krabka_ids::{Offset, ProducerId},
15 krabka_protocol::records::crc32c,
16 krabka_verified::producer_snapshot::{self as kernel, ProducerReloadRange},
17 std::{
18 collections::HashMap,
19 fs,
20 io::{self, ErrorKind},
21 path::{Path, PathBuf},
22 },
23};
24
25const VERSION: i16 = 1;
26const HEADER_LEN: usize = 6;
27const ENTRY_LEN: usize = 46;
28
29type SnapshotState = HashMap<ProducerId, ProducerSnapshotEntry>;
30
31#[derive(Debug, PartialEq, Eq)]
34pub struct LoadedSnapshot {
35 pub(crate) offset: Offset,
36 pub(crate) state: SnapshotState,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct SnapshotFile {
42 pub(crate) offset: Offset,
43 pub(crate) path: PathBuf,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct ProducerSnapshotEntry {
49 pub producer_id: ProducerId,
50 pub producer_epoch: i16,
51 pub last_sequence: i32,
52 pub last_offset: Offset,
53 pub offset_delta: i32,
54 pub timestamp: i64,
55 pub coordinator_epoch: i32,
56 pub current_txn_first_offset: Option<Offset>,
57}
58
59pub const NUM_BATCHES_TO_RETAIN: usize = 5;
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub struct ProducerBatchMetadata {
66 pub last_sequence: i32,
67 pub last_offset: Offset,
68 pub offset_delta: i32,
69 pub timestamp: i64,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct RecoveredProducer {
82 pub entry: ProducerSnapshotEntry,
83 pub earlier: Vec<ProducerBatchMetadata>,
84}
85
86impl ProducerSnapshotEntry {
87 #[must_use]
89 pub fn last_batch(&self) -> Option<ProducerBatchMetadata> {
90 (self.last_offset.0 >= 0).then_some(ProducerBatchMetadata {
91 last_sequence: self.last_sequence,
92 last_offset: self.last_offset,
93 offset_delta: self.offset_delta,
94 timestamp: self.timestamp,
95 })
96 }
97
98 pub(crate) fn empty(producer_id: ProducerId, producer_epoch: i16) -> Self {
99 Self {
100 producer_id,
101 producer_epoch,
102 last_sequence: -1,
103 last_offset: Offset(-1),
104 offset_delta: 0,
105 timestamp: -1,
106 coordinator_epoch: -1,
107 current_txn_first_offset: None,
108 }
109 }
110}
111
112pub fn path(dir: &Path, offset: Offset) -> PathBuf { name::producer_snapshot_path(dir, offset.0) }
113
114pub fn list(dir: &Path) -> Result<Vec<SnapshotFile>, LogError> {
115 let mut snapshots = Vec::new();
116 for entry in fs::read_dir(dir)? {
117 let entry = entry?;
118 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
119 continue;
120 };
121 let Some(stem) = name.strip_suffix(".snapshot") else {
122 continue;
123 };
124 if stem.len() != name::FILENAME_DIGITS {
125 continue;
126 }
127 let Ok(offset) = stem.parse::<i64>() else {
128 continue;
129 };
130 snapshots.push(SnapshotFile {
131 offset: Offset(offset),
132 path: entry.path(),
133 });
134 }
135 snapshots.sort_unstable_by_key(|snapshot| snapshot.offset);
136 Ok(snapshots)
137}
138
139pub fn reload(dir: &Path, range: ProducerReloadRange) -> Result<Option<LoadedSnapshot>, LogError> {
146 let mut eligible = retain_reload_range(dir, range)?;
147 while !eligible.is_empty() {
148 let offsets: Vec<i64> = eligible.iter().map(|snapshot| snapshot.offset.0).collect();
149 let Some(selected) = kernel::producer_snapshot_latest_index(&offsets, range) else {
150 return Err(LogError::Corrupt(
151 "retained producer snapshots have no reloadable offset".into(),
152 ));
153 };
154 let SnapshotFile { offset, path } = eligible.swap_remove(selected);
155 match read(&path, offset) {
156 Ok(state) => return Ok(Some(LoadedSnapshot { offset, state })),
157 Err(LogError::Corrupt(_)) => {
158 let _ = fs::remove_file(path);
161 }
162 Err(error) => return Err(error),
163 }
164 }
165 Ok(None)
166}
167
168pub fn retain_reload_range(
172 dir: &Path,
173 range: ProducerReloadRange,
174) -> Result<Vec<SnapshotFile>, LogError> {
175 let mut retained = Vec::new();
176 for snapshot in list(dir)? {
177 if kernel::producer_snapshot_reload_keeps(snapshot.offset.0, range) {
178 retained.push(snapshot);
179 } else {
180 fs::remove_file(snapshot.path)?;
181 }
182 }
183 Ok(retained)
184}
185
186pub fn remove_strays(dir: &Path, segment_bases: &[i64]) -> Result<(), LogError> {
190 let snapshots = list(dir)?;
191 let offsets: Vec<i64> = snapshots.iter().map(|snapshot| snapshot.offset.0).collect();
192 let stray = kernel::producer_snapshot_strays(&offsets, segment_bases);
193 for (snapshot, stray) in snapshots.into_iter().zip(stray) {
194 if stray {
195 fs::remove_file(snapshot.path)?;
196 }
197 }
198 Ok(())
199}
200
201pub fn remove_at(dir: &Path, offset: Offset) -> Result<(), LogError> {
204 match fs::remove_file(path(dir, offset)) {
205 Err(error) if error.kind() != ErrorKind::NotFound => Err(error.into()),
206 _ => Ok(()),
207 }
208}
209
210pub fn remove_all(dir: &Path) -> Result<(), LogError> {
211 for snapshot in list(dir)? {
212 fs::remove_file(snapshot.path)?;
213 }
214 Ok(())
215}
216
217pub fn write(
218 io: &dyn LogIo,
219 dir: &Path,
220 offset: Offset,
221 entries: &HashMap<ProducerId, ProducerSnapshotEntry>,
222) -> Result<PathBuf, LogError> {
223 write_if_missing(io, dir, offset, || prepare(dir, offset, entries))
224}
225
226fn write_if_missing(
227 io: &dyn LogIo,
228 dir: &Path,
229 offset: Offset,
230 prepare: impl FnOnce() -> Result<PreparedSnapshot, LogError>,
231) -> Result<PathBuf, LogError> {
232 let destination = path(dir, offset);
233 if destination.exists() {
234 io.sync_dir(dir)?;
236 return Ok(destination);
237 }
238 Ok(prepare()?.write(io)?)
239}
240
241#[derive(Debug)]
243pub struct PreparedSnapshot {
244 dir: PathBuf,
245 offset: Offset,
246 bytes: Vec<u8>,
247}
248
249#[cfg(all(test, not(target_os = "wasi")))]
250std::thread_local! {
251 static PREPARING: RefCell<Option<mpsc::Sender<()>>> = const { RefCell::new(None) };
252}
253
254#[cfg(all(test, not(target_os = "wasi")))]
255pub fn test_observe_prepare(observer: mpsc::Sender<()>) { PREPARING.set(Some(observer)); }
256
257pub fn prepare(
258 dir: &Path,
259 offset: Offset,
260 entries: &HashMap<ProducerId, ProducerSnapshotEntry>,
261) -> Result<PreparedSnapshot, LogError> {
262 #[cfg(all(test, not(target_os = "wasi")))]
263 PREPARING.with_borrow(|observer| {
264 if let Some(observer) = observer {
265 let _ = observer.send(());
266 }
267 });
268 Ok(PreparedSnapshot {
269 dir: dir.to_path_buf(),
270 offset,
271 bytes: encode(entries)?,
272 })
273}
274
275impl PreparedSnapshot {
276 pub(crate) fn write(&self, io: &dyn LogIo) -> io::Result<PathBuf> {
277 let destination = path(&self.dir, self.offset);
278 if destination.exists() {
279 io.sync_dir(&self.dir)?;
280 return Ok(destination);
281 }
282
283 let temporary = destination.with_extension("snapshot.tmp");
284 write_atomic(
285 io,
286 IoTarget::ProducerSnapshot,
287 &temporary,
288 &destination,
289 &self.bytes,
290 true,
291 )?;
292 Ok(destination)
293 }
294}
295
296fn encoded_size(entries: usize) -> Result<usize, LogError> {
297 HEADER_LEN
298 .checked_add(4)
299 .and_then(|size| size.checked_add(entries.checked_mul(ENTRY_LEN)?))
300 .ok_or_else(|| LogError::InvalidArgument("producer snapshot size overflow".into()))
301}
302
303#[cfg(not(target_os = "wasi"))]
304pub fn allocation_size(entries: usize) -> Result<usize, LogError> {
305 encoded_size(entries)?
306 .checked_add(
307 entries
308 .checked_mul(mem::size_of::<ProducerSnapshotEntry>())
309 .ok_or_else(|| {
310 LogError::InvalidArgument("producer snapshot size overflow".into())
311 })?,
312 )
313 .ok_or_else(|| LogError::InvalidArgument("producer snapshot size overflow".into()))
314}
315
316fn encode(entries: &HashMap<ProducerId, ProducerSnapshotEntry>) -> Result<Vec<u8>, LogError> {
317 let count = i32::try_from(entries.len())
318 .map_err(|_| LogError::InvalidArgument("too many producer snapshot entries".into()))?;
319 let size = encoded_size(entries.len())?;
320 let mut body = Vec::with_capacity(size - HEADER_LEN);
322 body.put_i32(count);
323
324 let mut ordered: Vec<_> = entries.values().copied().collect();
325 ordered.sort_unstable_by_key(|entry| entry.producer_id);
326 for entry in ordered {
327 body.put_i64(entry.producer_id.get());
328 body.put_i16(entry.producer_epoch);
329 body.put_i32(entry.last_sequence);
330 body.put_i64(entry.last_offset.0);
331 body.put_i32(entry.offset_delta);
332 body.put_i64(entry.timestamp);
333 body.put_i32(entry.coordinator_epoch);
334 body.put_i64(entry.current_txn_first_offset.map_or(-1, |offset| offset.0));
335 }
336
337 let mut buffer = Vec::with_capacity(size);
338 buffer.put_i16(VERSION);
339 buffer.put_u32(crc32c(&body));
340 buffer.put_slice(&body);
341 Ok(buffer)
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
348pub enum SnapshotDecodeError {
349 #[error("file is shorter than the snapshot header")]
351 ShortHeader { required: usize, available: usize },
352 #[error("unsupported version {version}")]
354 UnsupportedVersion { version: i16 },
355 #[error("negative entry count")]
357 NegativeEntryCount { count: i32 },
358 #[error("entry count overflows file size")]
360 EntryCountOverflow { count: usize },
361 #[error("entry count does not match file length")]
363 LengthMismatch { expected: usize, available: usize },
364 #[error("CRC32C mismatch")]
367 ChecksumMismatch { stored: u32, computed: u32 },
368 #[error("entry contains an invalid producer state")]
371 InvalidEntry {
372 index: usize,
373 position: usize,
374 last_offset: i64,
375 current_txn_first_offset: i64,
376 },
377 #[error("duplicate producer id")]
380 DuplicateProducerId {
381 index: usize,
382 position: usize,
383 producer_id: ProducerId,
384 },
385}
386
387fn read(
388 path: &Path,
389 snapshot_offset: Offset,
390) -> Result<HashMap<ProducerId, ProducerSnapshotEntry>, LogError> {
391 let bytes = fs::read(path)?;
392 decode(&bytes, snapshot_offset).map_err(|error| corrupt(path, &error.to_string()))
393}
394
395pub fn decode(
406 bytes: &[u8],
407 snapshot_offset: Offset,
408) -> Result<HashMap<ProducerId, ProducerSnapshotEntry>, SnapshotDecodeError> {
409 const ENTRIES_START: usize = HEADER_LEN + 4;
410 let Some(&[v0, v1, c0, c1, c2, c3, n0, n1, n2, n3]) = bytes.first_chunk::<ENTRIES_START>()
411 else {
412 return Err(SnapshotDecodeError::ShortHeader {
413 required: ENTRIES_START,
414 available: bytes.len(),
415 });
416 };
417 let version = i16::from_be_bytes([v0, v1]);
418 if version != VERSION {
419 return Err(SnapshotDecodeError::UnsupportedVersion { version });
420 }
421
422 let count = i32::from_be_bytes([n0, n1, n2, n3]);
423 let count =
424 usize::try_from(count).map_err(|_| SnapshotDecodeError::NegativeEntryCount { count })?;
425 let expected = ENTRIES_START
426 .checked_add(
427 count
428 .checked_mul(ENTRY_LEN)
429 .ok_or(SnapshotDecodeError::EntryCountOverflow { count })?,
430 )
431 .ok_or(SnapshotDecodeError::EntryCountOverflow { count })?;
432 let truncated = SnapshotDecodeError::LengthMismatch {
433 expected,
434 available: bytes.len(),
435 };
436 if bytes.len() != expected {
437 return Err(truncated);
438 }
439
440 let stored = u32::from_be_bytes([c0, c1, c2, c3]);
441 let computed = crc32c(bytes.get(HEADER_LEN..).ok_or(truncated)?);
442 if stored != computed {
443 return Err(SnapshotDecodeError::ChecksumMismatch { stored, computed });
444 }
445
446 let mut entries = HashMap::with_capacity(count);
447 let mut cursor = ENTRIES_START;
448 for index in 0..count {
449 let position = cursor;
450 let producer_id = ProducerId(i64::from_be_bytes(
451 take(bytes, &mut cursor).ok_or(truncated)?,
452 ));
453 let producer_epoch = i16::from_be_bytes(take(bytes, &mut cursor).ok_or(truncated)?);
454 let last_sequence = i32::from_be_bytes(take(bytes, &mut cursor).ok_or(truncated)?);
455 let last_offset = Offset(i64::from_be_bytes(
456 take(bytes, &mut cursor).ok_or(truncated)?,
457 ));
458 let offset_delta = i32::from_be_bytes(take(bytes, &mut cursor).ok_or(truncated)?);
459 let timestamp = i64::from_be_bytes(take(bytes, &mut cursor).ok_or(truncated)?);
460 let coordinator_epoch = i32::from_be_bytes(take(bytes, &mut cursor).ok_or(truncated)?);
461 let txn_offset = i64::from_be_bytes(take(bytes, &mut cursor).ok_or(truncated)?);
462 if !kernel::producer_snapshot_entry_valid(
463 snapshot_offset.0,
464 kernel::ProducerSnapshotEntryFacts {
465 producer_id: producer_id.get(),
466 producer_epoch,
467 last_sequence,
468 last_offset: last_offset.0,
469 offset_delta,
470 coordinator_epoch,
471 current_txn_first_offset: txn_offset,
472 },
473 ) {
474 return Err(SnapshotDecodeError::InvalidEntry {
475 index,
476 position,
477 last_offset: last_offset.0,
478 current_txn_first_offset: txn_offset,
479 });
480 }
481 let current_txn_first_offset = (txn_offset >= 0).then_some(Offset(txn_offset));
482 let entry = ProducerSnapshotEntry {
483 producer_id,
484 producer_epoch,
485 last_sequence,
486 last_offset,
487 offset_delta,
488 timestamp,
489 coordinator_epoch,
490 current_txn_first_offset,
491 };
492 if entries.insert(producer_id, entry).is_some() {
493 return Err(SnapshotDecodeError::DuplicateProducerId {
494 index,
495 position,
496 producer_id,
497 });
498 }
499 }
500 Ok(entries)
501}
502
503fn corrupt(path: &Path, reason: &str) -> LogError {
504 LogError::Corrupt(format!("producer snapshot {}: {reason}", path.display()))
505}
506
507fn take<const N: usize>(bytes: &[u8], cursor: &mut usize) -> Option<[u8; N]> {
510 let value = *bytes.get(*cursor..)?.first_chunk::<N>()?;
511 *cursor += N;
512 Some(value)
513}
514
515#[cfg(test)]
516mod tests {
517 use {
518 super::*,
519 crate::{io::FileIo, test_support::TempPath},
520 std::{
521 io,
522 io::ErrorKind,
523 sync::atomic::{AtomicBool, AtomicUsize},
524 },
525 };
526
527 fn sample_entry() -> ProducerSnapshotEntry {
528 ProducerSnapshotEntry {
529 producer_id: ProducerId(42),
530 producer_epoch: 3,
531 last_sequence: 9,
532 last_offset: Offset(101),
533 offset_delta: 4,
534 timestamp: 1_234_567,
535 coordinator_epoch: 8,
536 current_txn_first_offset: Some(Offset(99)),
537 }
538 }
539
540 fn sample() -> HashMap<ProducerId, ProducerSnapshotEntry> {
541 let entry = sample_entry();
542 maplit::hashmap! {entry.producer_id => entry}
543 }
544
545 fn snapshot_file(
546 entries: &HashMap<ProducerId, ProducerSnapshotEntry>,
547 offset: i64,
548 ) -> TempPath {
549 let dir = tempfile::tempdir().unwrap();
550 let path = write(&FileIo, dir.path(), Offset(offset), entries).unwrap();
551 TempPath { dir, path }
552 }
553
554 fn sample_file() -> TempPath { snapshot_file(&sample(), 102) }
555
556 fn sample_files(offsets: impl IntoIterator<Item = i64>) -> tempfile::TempDir {
557 let dir = tempfile::tempdir().unwrap();
558 for offset in offsets {
559 write(&FileIo, dir.path(), Offset(offset), &sample()).unwrap();
560 }
561 dir
562 }
563
564 fn assert_corrupt(path: &Path, offset: i64) {
565 assert2::assert!(matches!(
566 read(path, Offset(offset)),
567 Err(LogError::Corrupt(_))
568 ));
569 }
570
571 fn assert_rejected(entry: ProducerSnapshotEntry) {
572 let entries = maplit::hashmap! {entry.producer_id => entry};
573 let TempPath { dir: _dir, path } = snapshot_file(&entries, 102);
574 assert_corrupt(&path, 102);
575 }
576
577 #[test]
578 fn kafka_v1_snapshot_round_trips() {
579 let TempPath { dir: _dir, path } = sample_file();
580 assert2::assert!(read(&path, Offset(102)).unwrap() == sample());
581 }
582
583 #[test]
584 fn zero_producer_id_snapshot_round_trips() {
585 let mut entry = sample_entry();
586 entry.producer_id = ProducerId(0);
587 let entries = maplit::hashmap! {entry.producer_id => entry};
588 let TempPath { dir: _dir, path } = snapshot_file(&entries, 102);
589 assert2::assert!(read(&path, Offset(102)).unwrap() == entries);
590 }
591
592 #[test]
593 fn empty_entry_uses_kafka_marker_sentinels() {
594 let entry = ProducerSnapshotEntry::empty(ProducerId(7), 0);
595 assert2::assert!(
596 entry
597 == ProducerSnapshotEntry {
598 producer_id: ProducerId(7),
599 producer_epoch: 0,
600 last_sequence: -1,
601 last_offset: Offset(-1),
602 offset_delta: 0,
603 timestamp: -1,
604 coordinator_epoch: -1,
605 current_txn_first_offset: None,
606 }
607 );
608 }
609
610 #[test]
611 fn encoding_is_ordered_and_uses_minus_one_for_no_transaction() {
612 let mut entries = sample();
613 let marker = ProducerSnapshotEntry::empty(ProducerId(7), 0);
614 entries.insert(marker.producer_id, marker);
615
616 let bytes = encode(&entries).unwrap();
617 assert2::assert!(bytes.len() == HEADER_LEN + 4 + 2 * ENTRY_LEN);
618 assert2::assert!(i32::from_be_bytes(bytes[6..10].try_into().unwrap()) == 2);
619 assert2::assert!(i64::from_be_bytes(bytes[10..18].try_into().unwrap()) == 7);
620 assert2::assert!(i64::from_be_bytes(bytes[48..56].try_into().unwrap()) == -1);
621 assert2::assert!(i64::from_be_bytes(bytes[56..64].try_into().unwrap()) == 42);
622 assert2::assert!(i64::from_be_bytes(bytes[94..102].try_into().unwrap()) == 99);
623 }
624
625 #[test]
626 fn empty_snapshot_has_exact_header_length_and_round_trips() {
627 let TempPath { dir: _dir, path } = snapshot_file(&HashMap::new(), 0);
628 assert2::assert!(fs::metadata(&path).unwrap().len() == 10);
629 assert2::assert!(read(&path, Offset(0)).unwrap().is_empty());
630 }
631
632 #[test]
633 fn every_short_header_length_is_rejected_without_panicking() {
634 let dir = tempfile::tempdir().unwrap();
635 let path = dir.path().join("short.snapshot");
636 for length in 0..10 {
637 let mut bytes = vec![0; length];
638 if length >= 2 {
639 bytes[..2].copy_from_slice(&VERSION.to_be_bytes());
640 }
641 fs::write(&path, bytes).unwrap();
642 assert_corrupt(&path, 0);
643 }
644 }
645
646 #[test]
647 fn crc_covers_entry_count_and_entries() {
648 let TempPath { dir: _dir, path } = sample_file();
649 let mut bytes = fs::read(&path).unwrap();
650 bytes[9] ^= 1;
651 fs::write(&path, bytes).unwrap();
652 assert_corrupt(&path, 102);
653 }
654
655 #[test]
656 fn rejects_unknown_version_and_truncated_entry() {
657 let TempPath { dir, path } = sample_file();
658 let mut unknown = fs::read(&path).unwrap();
659 unknown[1] = 2;
660 fs::write(&path, unknown).unwrap();
661 assert_corrupt(&path, 102);
662
663 fs::remove_file(&path).unwrap();
664 let path = write(&FileIo, dir.path(), Offset(102), &sample()).unwrap();
665 let mut truncated = fs::read(&path).unwrap();
666 truncated.pop();
667 fs::write(&path, truncated).unwrap();
668 assert_corrupt(&path, 102);
669 }
670
671 fn range(log_start: i64, log_end: i64) -> ProducerReloadRange {
672 ProducerReloadRange {
673 log_start,
674 local_start: log_start,
675 log_end,
676 }
677 }
678
679 fn offsets(dir: &Path) -> Vec<i64> {
680 list(dir)
681 .unwrap()
682 .into_iter()
683 .map(|snapshot| snapshot.offset.0)
684 .collect()
685 }
686
687 #[yare::parameterized(
691 end_before_future_snapshot = { 0, 103, Some(103), vec![100, 101, 102, 103] },
692 start_after_first_snapshot = { 100, 103, Some(103), vec![101, 102, 103] },
693 start_snapshot_is_deleted = { 102, 104, Some(104), vec![103, 104] },
694 one_snapshot_in_range = { 102, 103, Some(103), vec![103] },
695 only_start_snapshot_in_range = { 103, 103, None, vec![] },
696 all_snapshots_at_or_below_start = { 104, 110, None, vec![] },
697 )]
698 fn reload_deletes_snapshots_outside_the_range_and_loads_the_newest_left(
699 log_start: i64,
700 log_end: i64,
701 loaded: Option<i64>,
702 left: Vec<i64>,
703 ) {
704 let dir = sample_files([100, 101, 102, 103, 104]);
705
706 let reloaded = reload(dir.path(), range(log_start, log_end)).unwrap();
707
708 assert2::check!(
709 reloaded
710 == loaded.map(|offset| LoadedSnapshot {
711 offset: Offset(offset),
712 state: sample()
713 }),
714 "({log_start}, {log_end}]"
715 );
716 assert2::check!(offsets(dir.path()) == left, "({log_start}, {log_end}]");
717 }
718
719 #[test]
720 fn corrupt_latest_snapshot_is_removed_and_previous_snapshot_is_loaded() {
721 for corrupt_count in 0..=4 {
722 let dir = sample_files(102..=105);
723 for offset in (106 - corrupt_count)..=105 {
724 fs::write(path(dir.path(), Offset(offset)), b"broken").unwrap();
725 }
726 let loaded = reload(dir.path(), range(0, 105)).unwrap();
727 if corrupt_count == 4 {
728 assert2::assert!(loaded == None);
729 } else {
730 let LoadedSnapshot {
731 offset,
732 state: entries,
733 } = loaded.unwrap();
734 assert2::assert!(offset == Offset(105 - corrupt_count));
735 assert2::assert!(entries == sample());
736 assert2::assert!(path(dir.path(), offset).exists());
737 }
738 for offset in (106 - corrupt_count)..=105 {
739 assert2::assert!(!path(dir.path(), Offset(offset)).exists());
740 }
741 }
742 }
743
744 #[test]
745 fn future_entry_state_is_removed_and_previous_snapshot_is_loaded() {
746 let TempPath {
747 dir,
748 path: previous,
749 } = sample_file();
750 let future_state = write(&FileIo, dir.path(), Offset(103), &sample()).unwrap();
751 let mut bytes = fs::read(&future_state).unwrap();
752 bytes[24..32].copy_from_slice(&103_i64.to_be_bytes());
753 fs::write(&future_state, with_crc(bytes)).unwrap();
754
755 let LoadedSnapshot {
756 offset,
757 state: entries,
758 } = reload(dir.path(), range(0, 103)).unwrap().unwrap();
759 assert2::assert!(offset == Offset(102));
760 assert2::assert!(entries == sample());
761 assert2::assert!(previous.exists());
762 assert2::assert!(!future_state.exists());
763 }
764
765 fn with_crc(mut bytes: Vec<u8>) -> Vec<u8> {
766 let crc = crc32c::crc32c(&bytes[HEADER_LEN..]);
767 bytes[2..6].copy_from_slice(&crc.to_be_bytes());
768 bytes
769 }
770
771 #[test]
775 fn stored_crc_matches_the_crc32c_oracle_over_a_large_snapshot() {
776 let entries: HashMap<ProducerId, ProducerSnapshotEntry> = (0..1_000_i64)
777 .map(|id| {
778 let entry = ProducerSnapshotEntry {
779 producer_id: ProducerId(id),
780 timestamp: 1_234_567 + id,
781 ..sample_entry()
782 };
783 (entry.producer_id, entry)
784 })
785 .collect();
786
787 let bytes = encode(&entries).unwrap();
788
789 assert2::assert!(bytes.len() > 16 * 1024);
790 let stored = u32::from_be_bytes(bytes[2..6].try_into().unwrap());
791 assert2::assert!(stored == crc32c::crc32c(&bytes[HEADER_LEN..]));
792 assert2::assert!(decode(&bytes, Offset(102)).unwrap() == entries);
793 }
794
795 #[test]
796 fn decode_names_the_check_that_failed() {
797 let valid = encode(&sample()).unwrap();
798 let entries_start = HEADER_LEN + 4;
799
800 let mut bad_version = valid.clone();
801 bad_version[..2].copy_from_slice(&2_i16.to_be_bytes());
802 let mut negative_count = valid.clone();
803 negative_count[6..10].copy_from_slice(&(-1_i32).to_be_bytes());
804 let mut truncated = valid.clone();
805 truncated.pop();
806 let mut bad_crc = valid.clone();
807 bad_crc[2] ^= 0xFF;
808 let stored = u32::from_be_bytes(bad_crc[2..6].try_into().unwrap());
809 let computed = crc32c::crc32c(&valid[HEADER_LEN..]);
810 let mut duplicate = valid.clone();
811 duplicate.extend_from_slice(&valid[entries_start..]);
812 duplicate[6..10].copy_from_slice(&2_i32.to_be_bytes());
813
814 let cases = [
815 (
816 valid[..9].to_vec(),
817 Offset(102),
818 SnapshotDecodeError::ShortHeader {
819 required: 10,
820 available: 9,
821 },
822 ),
823 (
824 bad_version,
825 Offset(102),
826 SnapshotDecodeError::UnsupportedVersion { version: 2 },
827 ),
828 (
829 negative_count,
830 Offset(102),
831 SnapshotDecodeError::NegativeEntryCount { count: -1 },
832 ),
833 (
834 truncated,
835 Offset(102),
836 SnapshotDecodeError::LengthMismatch {
837 expected: 10 + ENTRY_LEN,
838 available: 9 + ENTRY_LEN,
839 },
840 ),
841 (
842 bad_crc,
843 Offset(102),
844 SnapshotDecodeError::ChecksumMismatch { stored, computed },
845 ),
846 (
847 valid.clone(),
848 Offset(101),
849 SnapshotDecodeError::InvalidEntry {
850 index: 0,
851 position: 10,
852 last_offset: 101,
853 current_txn_first_offset: 99,
854 },
855 ),
856 (
857 with_crc(duplicate),
858 Offset(102),
859 SnapshotDecodeError::DuplicateProducerId {
860 index: 1,
861 position: 10 + ENTRY_LEN,
862 producer_id: ProducerId(42),
863 },
864 ),
865 ];
866 for (bytes, snapshot_offset, expected) in cases {
867 assert2::assert!(decode(&bytes, snapshot_offset) == Err(expected));
868 }
869 }
870
871 #[test]
872 fn duplicate_producer_ids_are_rejected() {
873 let TempPath { dir: _dir, path } = sample_file();
874 let mut bytes = fs::read(&path).unwrap();
875 let duplicate = bytes[HEADER_LEN + 4..].to_vec();
876 bytes.extend_from_slice(&duplicate);
877 bytes[HEADER_LEN..HEADER_LEN + 4].copy_from_slice(&2_i32.to_be_bytes());
878 fs::write(&path, with_crc(bytes)).unwrap();
879
880 assert2::assert!(matches!(
881 read(&path, Offset(102)),
882 Err(LogError::Corrupt(message)) if message.contains("duplicate producer id")
883 ));
884 }
885
886 #[test]
887 fn snapshot_write_retry_preserves_the_first_durable_state() {
888 let dir = tempfile::tempdir().unwrap();
889 let first = sample();
890 let path = write(&FileIo, dir.path(), Offset(102), &first).unwrap();
891
892 assert2::assert!(write(&FileIo, dir.path(), Offset(102), &HashMap::new()).unwrap() == path);
893 assert2::assert!(read(&path, Offset(102)).unwrap() == first);
894 }
895
896 #[derive(Debug, Default)]
897 struct DirectoryDebt {
898 fail: AtomicBool,
899 syncs: AtomicUsize,
900 }
901
902 impl LogIo for DirectoryDebt {
903 fn sync_dir(&self, dir: &Path) -> io::Result<()> {
904 use std::sync::atomic::Ordering::SeqCst;
905 self.syncs.fetch_add(1, SeqCst);
906 if self.fail.load(SeqCst) {
907 Err(ErrorKind::StorageFull.into())
908 } else {
909 FileIo.sync_dir(dir)
910 }
911 }
912 }
913
914 #[test]
915 fn snapshot_retry_pays_directory_sync_debt_without_overwriting_state() {
916 use std::sync::atomic::Ordering::SeqCst;
917 for prepared in [false, true] {
918 let dir = tempfile::tempdir().unwrap();
919 let io = DirectoryDebt::default();
920 io.fail.store(true, SeqCst);
921 let snapshot = prepare(dir.path(), Offset(102), &sample()).unwrap();
922 let attempt = || {
923 if prepared {
924 snapshot.write(&io).map_err(LogError::from)
925 } else {
926 write(&io, dir.path(), Offset(102), &sample())
927 }
928 };
929 assert2::assert!(
930 matches!(attempt(), Err(LogError::Io(e)) if e.kind() == ErrorKind::StorageFull)
931 );
932 assert2::assert!(path(dir.path(), Offset(102)).exists());
934 assert2::assert!(
935 matches!(attempt(), Err(LogError::Io(e)) if e.kind() == ErrorKind::StorageFull)
936 );
937 io.fail.store(false, SeqCst);
938 let destination = attempt().unwrap();
939 assert2::assert!(io.syncs.load(SeqCst) == 3);
940 assert2::assert!(read(&destination, Offset(102)).unwrap() == sample());
941 }
942 }
943
944 #[test]
945 fn existing_snapshot_does_not_prepare_state_but_still_syncs_the_directory() {
946 use std::sync::atomic::Ordering::SeqCst;
947 let TempPath {
948 dir,
949 path: destination,
950 } = sample_file();
951 let io = DirectoryDebt::default();
952 let result = write_if_missing(&io, dir.path(), Offset(102), || {
953 panic!("an existing snapshot must not encode or sort producer state")
954 })
955 .unwrap();
956 assert2::assert!(result == destination);
957 assert2::assert!(io.syncs.load(SeqCst) == 1);
958 assert2::assert!(read(&destination, Offset(102)).unwrap() == sample());
959 }
960
961 #[test]
962 fn snapshot_read_io_failure_is_not_treated_as_corruption() {
963 let TempPath {
964 dir,
965 path: previous,
966 } = sample_file();
967 let unreadable = path(dir.path(), Offset(103));
968 fs::create_dir(&unreadable).unwrap();
969 let corrupt = write(&FileIo, dir.path(), Offset(104), &sample()).unwrap();
970 fs::write(&corrupt, b"broken").unwrap();
971
972 assert2::assert!(matches!(
973 reload(dir.path(), range(0, 104)),
974 Err(LogError::Io(_))
975 ));
976 assert2::assert!(previous.exists() && unreadable.exists());
977 assert2::assert!(!corrupt.exists());
978 }
979
980 #[test]
981 fn retain_reload_range_keeps_the_log_end_and_drops_the_log_start() {
982 let dir = sample_files([1, 2, 3]);
983
984 let retained = retain_reload_range(dir.path(), range(1, 2)).unwrap();
985
986 assert2::check!(
987 retained
988 == vec![SnapshotFile {
989 offset: Offset(2),
990 path: path(dir.path(), Offset(2))
991 }]
992 );
993 assert2::check!(offsets(dir.path()) == vec![2]);
994 }
995
996 #[yare::parameterized(
999 no_snapshots = { vec![], vec![0, 4, 8], vec![] },
1000 snapshots_at_every_base = { vec![0, 4, 8], vec![0, 4, 8], vec![0, 4, 8] },
1001 only_a_nonnewest_stray = { vec![2, 4, 6], vec![0, 4, 8], vec![4] },
1002 strays_below_and_between_bases = { vec![1, 3, 5, 7], vec![2, 4, 6, 8], vec![] },
1003 preserve_newest_snapshot_above_bases = { vec![4, 9, 11], vec![0, 4, 8], vec![4, 11] },
1004 newest_at_greatest_base_is_not_exceptional = { vec![3, 8], vec![0, 4, 8], vec![8] },
1005 no_segments_keeps_newest_snapshot = { vec![3, 7], vec![], vec![7] },
1006 )]
1007 fn remove_strays_keeps_segment_bases_and_the_newest_snapshot_above_them(
1008 on_disk: Vec<i64>,
1009 bases: Vec<i64>,
1010 left: Vec<i64>,
1011 ) {
1012 let dir = sample_files(on_disk.iter().copied());
1013
1014 remove_strays(dir.path(), &bases).unwrap();
1015
1016 assert2::check!(offsets(dir.path()) == left, "{on_disk:?} {bases:?}");
1017 }
1018
1019 #[test]
1020 fn remove_at_deletes_one_snapshot_and_tolerates_a_missing_one() {
1021 let dir = sample_files([1, 2]);
1022
1023 remove_at(dir.path(), Offset(1)).unwrap();
1024 remove_at(dir.path(), Offset(5)).unwrap();
1025
1026 assert2::check!(offsets(dir.path()) == vec![2]);
1027 }
1028
1029 #[test]
1030 fn remove_all_removes_every_snapshot() {
1031 let dir = sample_files([1, 2]);
1032
1033 remove_all(dir.path()).unwrap();
1034 assert2::assert!(list(dir.path()).unwrap().is_empty());
1035 }
1036
1037 #[yare::parameterized(
1038 producer_id_below_sentinel = { ProducerSnapshotEntry {
1039 producer_id: ProducerId(-2), ..sample_entry()
1040 } },
1041 negative_sentinel_id = { ProducerSnapshotEntry {
1042 producer_id: ProducerId(-1), ..sample_entry()
1043 } },
1044 negative_epoch = { ProducerSnapshotEntry {
1045 producer_epoch: -1, ..sample_entry()
1046 } },
1047 negative_offset_delta = { ProducerSnapshotEntry {
1048 offset_delta: -1, ..sample_entry()
1049 } },
1050 negative_transaction_start = { ProducerSnapshotEntry {
1051 current_txn_first_offset: Some(Offset(-2)), ..sample_entry()
1052 } },
1053 negative_last_offset = { ProducerSnapshotEntry {
1054 last_offset: Offset(-1), last_sequence: 0, offset_delta: 0, ..sample_entry()
1055 } },
1056 negative_sequence = { ProducerSnapshotEntry {
1057 last_offset: Offset(0), last_sequence: -1, offset_delta: 0, ..sample_entry()
1058 } },
1059 delta_after_last_offset = { ProducerSnapshotEntry {
1060 last_offset: Offset(0), last_sequence: 0, offset_delta: 1, ..sample_entry()
1061 } },
1062 )]
1063 fn rejects_each_invalid_entry_field_independently(entry: ProducerSnapshotEntry) {
1064 assert_rejected(entry);
1065 }
1066
1067 #[test]
1068 fn accepts_every_valid_entry_boundary() {
1069 let dir = tempfile::tempdir().unwrap();
1070 let marker = ProducerSnapshotEntry::empty(ProducerId(7), 0);
1071 let data = ProducerSnapshotEntry {
1072 producer_id: ProducerId(8),
1073 producer_epoch: 0,
1074 last_sequence: 0,
1075 last_offset: Offset(0),
1076 offset_delta: 0,
1077 timestamp: 0,
1078 coordinator_epoch: 0,
1079 current_txn_first_offset: None,
1080 };
1081 let entries = maplit::hashmap! {marker.producer_id => marker, data.producer_id => data};
1082 let path = write(&FileIo, dir.path(), Offset(1), &entries).unwrap();
1083 assert2::assert!(read(&path, Offset(1)).unwrap() == entries);
1084 }
1085}