Skip to main content

krabka_log/
producer_snapshot.rs

1//! Kafka-compatible producer-state snapshot encoding and recovery.
2
3#[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/// A producer snapshot reloaded from disk: the offset of the file it came from
32/// and the state it held.
33#[derive(Debug, PartialEq, Eq)]
34pub struct LoadedSnapshot {
35    pub(crate) offset: Offset,
36    pub(crate) state: SnapshotState,
37}
38
39/// A `.snapshot` sidecar in a log directory.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct SnapshotFile {
42    pub(crate) offset: Offset,
43    pub(crate) path: PathBuf,
44}
45
46/// Durable producer metadata stored in Kafka's `.snapshot` sidecar format.
47#[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
59/// Kafka's `ProducerStateEntry.NUM_BATCHES_TO_RETAIN`: the number of a
60/// producer's most recent batches whose retry answers as a duplicate.
61pub const NUM_BATCHES_TO_RETAIN: usize = 5;
62
63/// One data batch a producer appended: Kafka's `BatchMetadata`.
64#[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/// One producer's state as the log holds it: its snapshot entry, whose last
73/// batch is the producer's last one, and the batches the producer appended
74/// before that one at the same epoch, oldest first.
75///
76/// Kafka's `ProducerStateEntry` retains up to [`NUM_BATCHES_TO_RETAIN`]
77/// batches. Its `.snapshot` file stores only the last, so a reopen rebuilds
78/// the earlier ones only from the tail it replays past the snapshot, as
79/// `UnifiedLog.rebuildProducerState` does.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct RecoveredProducer {
82    pub entry: ProducerSnapshotEntry,
83    pub earlier: Vec<ProducerBatchMetadata>,
84}
85
86impl ProducerSnapshotEntry {
87    /// The entry's last data batch, or `None` when it holds none.
88    #[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
139/// Reload producer state the way Kafka's
140/// `ProducerStateManager.truncateAndReload` does.
141///
142/// Every snapshot outside `(range.log_start, range.log_end]` is deleted
143/// first. The newest one left is then loaded; a corrupt one is deleted and
144/// the next older one tried, as Kafka's `loadFromSnapshot` does.
145pub 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                // Match Kafka recovery: discard a corrupt newest snapshot and
159                // retry the preceding one instead of failing the partition.
160                let _ = fs::remove_file(path);
161            }
162            Err(error) => return Err(error),
163        }
164    }
165    Ok(None)
166}
167
168/// Delete every snapshot Kafka's `truncateAndReload` deletes: those at or
169/// below `range.log_start` and those above `range.log_end`. Returns the
170/// snapshots left, oldest first.
171pub 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
186/// Delete every snapshot Kafka's `removeStraySnapshots` deletes, given the
187/// base offset of every local segment: each one no segment starts at, except
188/// the newest snapshot when it lies above every segment.
189pub 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
201/// Delete the snapshot at a deleted segment's base offset, as Kafka's
202/// `UnifiedLog.deleteProducerSnapshots` does for every segment it deletes.
203pub 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        // A prior rename may have succeeded while its directory sync failed.
235        io.sync_dir(dir)?;
236        return Ok(destination);
237    }
238    Ok(prepare()?.write(io)?)
239}
240
241/// Captured at the roll boundary, before subsequent appends change the state.
242#[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    // The count and the entries, which the CRC covers.
321    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/// Why [`decode`] rejected a producer-state `.snapshot`. Each variant names
345/// the check that failed and carries what that check saw; its `Display` is
346/// the reason the log reports in [`LogError::Corrupt`].
347#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
348pub enum SnapshotDecodeError {
349    /// The input is shorter than the version, CRC and entry-count header.
350    #[error("file is shorter than the snapshot header")]
351    ShortHeader { required: usize, available: usize },
352    /// The header declares a version other than 1, the only one Kafka writes.
353    #[error("unsupported version {version}")]
354    UnsupportedVersion { version: i16 },
355    /// The header declares a negative entry count.
356    #[error("negative entry count")]
357    NegativeEntryCount { count: i32 },
358    /// The declared entry count's byte length overflows `usize`.
359    #[error("entry count overflows file size")]
360    EntryCountOverflow { count: usize },
361    /// The input length is not the header plus `count` whole entries.
362    #[error("entry count does not match file length")]
363    LengthMismatch { expected: usize, available: usize },
364    /// The stored CRC32C does not match the one computed over the entry count
365    /// and every entry.
366    #[error("CRC32C mismatch")]
367    ChecksumMismatch { stored: u32, computed: u32 },
368    /// The entry at `index`, starting at byte `position`, is not a legal
369    /// producer state strictly before the snapshot offset.
370    #[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    /// The entry at `index`, starting at byte `position`, repeats a producer
378    /// ID an earlier entry already holds.
379    #[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
395/// Decode a Kafka v1 producer-state `.snapshot` taken at `snapshot_offset`,
396/// the exclusive log frontier its entries must lie strictly before.
397///
398/// The checks run in Kafka's `ProducerStateManager.readSnapshot` order:
399/// framing (header, version, entry count against the input length), then the
400/// CRC32C, then each entry's legality and producer-ID uniqueness.
401///
402/// # Errors
403///
404/// Returns the [`SnapshotDecodeError`] of the first check that fails.
405pub 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
507/// The `N` bytes at `cursor`, advancing it past them. `None` when fewer
508/// than `N` remain.
509fn 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    /// Kafka's `truncateAndReload` deletes every snapshot outside
688    /// `(logStartOffset, logEndOffset]` -- the one at the log start included
689    /// -- and `loadFromSnapshot` loads the newest one left.
690    #[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    /// The stored checksum is CRC-32C as the `crc32c` crate computes it, over
772    /// a snapshot large enough (well past 16 KiB) to take `crc_fast`'s wide
773    /// SIMD path rather than its short-input tail.
774    #[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            // The rename succeeded, but this name still owes directory durability.
933            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    /// Kafka's `removeStraySnapshots`, run at log load with every local
997    /// segment's base offset.
998    #[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}