Skip to main content

krabka_protocol/records/
header.rs

1//! Record-batch v2 header types: `RecordBatchHeader` (zerocopy),
2//! `Attributes`, `TimestampType`.
3
4use std::ops::Range;
5
6use krabka_compression::CompressionType;
7
8/// Timestamp-type bit in the attributes word.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum TimestampType {
11    CreateTime,
12    LogAppendTime,
13}
14
15/// Packed batch-level attributes, encoded as a 16-bit big-endian field
16/// in the wire header.
17///
18/// - bits 0-2: compression type (matches `CompressionType::as_attribute_bits`)
19/// - bit 3:    timestamp type (0 = `CreateTime`, 1 = `LogAppendTime`)
20/// - bit 4:    `is_transactional`
21/// - bit 5:    `is_control_batch`
22/// - bit 6:    `has_delete_horizon` (KIP-534; `base_timestamp` carries the horizon)
23/// - bits 7-15: reserved
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub struct Attributes(pub i16);
26
27impl Attributes {
28    pub const TIMESTAMP_TYPE_BIT: i16 = 1 << 3;
29    pub const TRANSACTIONAL_BIT: i16 = 1 << 4;
30    pub const CONTROL_BIT: i16 = 1 << 5;
31    pub const DELETE_HORIZON_BIT: i16 = 1 << 6; // 0x40
32
33    #[must_use]
34    pub fn compression(self) -> CompressionType {
35        // The low 3 bits are the codec id. Wider attribute bits are ignored.
36        let byte = (self.0 & 0x07).to_le_bytes()[0];
37        CompressionType::from_attribute_bits(byte).unwrap_or(CompressionType::None)
38    }
39
40    #[must_use]
41    pub fn timestamp_type(self) -> TimestampType {
42        if self.0 & Self::TIMESTAMP_TYPE_BIT != 0 {
43            TimestampType::LogAppendTime
44        } else {
45            TimestampType::CreateTime
46        }
47    }
48
49    #[must_use]
50    pub fn is_transactional(self) -> bool {
51        self.0 & Self::TRANSACTIONAL_BIT != 0
52    }
53
54    #[must_use]
55    pub fn is_control_batch(self) -> bool {
56        self.0 & Self::CONTROL_BIT != 0
57    }
58
59    #[must_use]
60    pub fn has_delete_horizon(self) -> bool {
61        self.0 & Self::DELETE_HORIZON_BIT != 0
62    }
63
64    #[must_use]
65    pub fn with_compression(self, c: CompressionType) -> Self {
66        let cleared = self.0 & !0x07;
67        Self(cleared | i16::from(c.as_attribute_bits()))
68    }
69
70    #[must_use]
71    pub fn with_timestamp_type(self, t: TimestampType) -> Self {
72        match t {
73            TimestampType::CreateTime => Self(self.0 & !Self::TIMESTAMP_TYPE_BIT),
74            TimestampType::LogAppendTime => Self(self.0 | Self::TIMESTAMP_TYPE_BIT),
75        }
76    }
77
78    #[must_use]
79    pub fn with_transactional(self, b: bool) -> Self {
80        if b {
81            Self(self.0 | Self::TRANSACTIONAL_BIT)
82        } else {
83            Self(self.0 & !Self::TRANSACTIONAL_BIT)
84        }
85    }
86
87    #[must_use]
88    pub fn with_control(self, b: bool) -> Self {
89        if b {
90            Self(self.0 | Self::CONTROL_BIT)
91        } else {
92            Self(self.0 & !Self::CONTROL_BIT)
93        }
94    }
95
96    #[must_use]
97    pub fn with_delete_horizon(self, set: bool) -> Self {
98        if set {
99            Self(self.0 | Self::DELETE_HORIZON_BIT)
100        } else {
101            Self(self.0 & !Self::DELETE_HORIZON_BIT)
102        }
103    }
104}
105
106use std::mem::size_of;
107
108use zerocopy::{
109    BigEndian, FromBytes, Immutable, KnownLayout, Unaligned,
110    byteorder::{I16, I32, I64, U32},
111};
112
113/// The fixed 61-byte v2 record-batch header, reinterpreted in place from
114/// the wire bytes with `zerocopy`.
115#[derive(Debug, Clone, Copy, FromBytes, KnownLayout, Immutable, Unaligned)]
116#[repr(C)]
117pub struct RecordBatchHeader {
118    pub base_offset: I64<BigEndian>,
119    pub batch_length: I32<BigEndian>,
120    pub partition_leader_epoch: I32<BigEndian>,
121    pub magic: i8,
122    pub crc: U32<BigEndian>,
123    pub attributes: I16<BigEndian>,
124    pub last_offset_delta: I32<BigEndian>,
125    pub base_timestamp: I64<BigEndian>,
126    pub max_timestamp: I64<BigEndian>,
127    pub producer_id: I64<BigEndian>,
128    pub producer_epoch: I16<BigEndian>,
129    pub base_sequence: I32<BigEndian>,
130    pub records_count: I32<BigEndian>,
131}
132
133/// Size of the v2 record-batch header in bytes.
134pub const HEADER_LEN: usize = 61;
135
136// Compile-time assertion that the layout is exactly 61 bytes.
137const _: [(); HEADER_LEN] = [(); size_of::<RecordBatchHeader>()];
138
139/// Byte offset of the `base_offset` field (i64 BE, 8 bytes) within a v2
140/// record-batch header.
141pub const BASE_OFFSET_RANGE: Range<usize> = 0..8;
142
143/// Byte offset of the `partition_leader_epoch` field (i32 BE, 4 bytes)
144/// within a v2 record-batch header.
145pub const LEADER_EPOCH_RANGE: Range<usize> = 12..16;
146
147/// First byte covered by the v2 batch CRC, which starts at `attributes`.
148///
149/// Bytes `0..21` are outside the CRC region. Those bytes are `base_offset`,
150/// `batch_length`, `partition_leader_epoch`, `magic`, and the `crc` field
151/// itself.
152pub const CRC_COVERAGE_START: usize = 21;
153
154/// Patches `base_offset` and `partition_leader_epoch` in place in a writable
155/// copy of the verbatim batch bytes.
156///
157/// `base_offset` is bytes 0..8 and `partition_leader_epoch` is bytes 12..16.
158/// This function writes both as big-endian.
159///
160/// Both fields lie before [`CRC_COVERAGE_START`], so this function never
161/// invalidates the producer's CRC. The broker can stamp the assigned offset
162/// and leader epoch without a CRC recomputation and without a change to the
163/// body.
164///
165/// `buf` is exactly one header, so the caller copies the header out of the
166/// batch first, which also validates its length.
167pub fn patch_base_offset_and_leader_epoch(
168    buf: &mut [u8; HEADER_LEN],
169    base_offset: i64,
170    leader_epoch: i32,
171) {
172    buf[BASE_OFFSET_RANGE].copy_from_slice(&base_offset.to_be_bytes());
173    buf[LEADER_EPOCH_RANGE].copy_from_slice(&leader_epoch.to_be_bytes());
174}
175
176#[cfg(test)]
177mod tests {
178    use assert2::check;
179    use krabka_compression::CompressionType;
180
181    use super::*;
182
183    struct AttributeCase {
184        bits: i16,
185        compression: CompressionType,
186        timestamp_type: TimestampType,
187        transactional: bool,
188        control_batch: bool,
189        delete_horizon: bool,
190    }
191
192    fn assert_attribute_case(case: &AttributeCase) {
193        let attributes = Attributes(case.bits);
194        assert2::assert!(attributes.compression() == case.compression);
195        assert2::assert!(attributes.timestamp_type() == case.timestamp_type);
196        assert2::assert!(attributes.is_transactional() == case.transactional);
197        assert2::assert!(attributes.is_control_batch() == case.control_batch);
198        assert2::assert!(attributes.has_delete_horizon() == case.delete_horizon);
199    }
200
201    #[test]
202    fn attribute_compression_cases() {
203        for case in [
204            AttributeCase {
205                bits: 0,
206                compression: CompressionType::None,
207                timestamp_type: TimestampType::CreateTime,
208                transactional: false,
209                control_batch: false,
210                delete_horizon: false,
211            },
212            AttributeCase {
213                bits: 0x01,
214                compression: CompressionType::Gzip,
215                timestamp_type: TimestampType::CreateTime,
216                transactional: false,
217                control_batch: false,
218                delete_horizon: false,
219            },
220            AttributeCase {
221                bits: 0x02,
222                compression: CompressionType::Snappy,
223                timestamp_type: TimestampType::CreateTime,
224                transactional: false,
225                control_batch: false,
226                delete_horizon: false,
227            },
228            AttributeCase {
229                bits: 0x03,
230                compression: CompressionType::Lz4,
231                timestamp_type: TimestampType::CreateTime,
232                transactional: false,
233                control_batch: false,
234                delete_horizon: false,
235            },
236            AttributeCase {
237                bits: 0x04,
238                compression: CompressionType::Zstd,
239                timestamp_type: TimestampType::CreateTime,
240                transactional: false,
241                control_batch: false,
242                delete_horizon: false,
243            },
244        ] {
245            assert_attribute_case(&case);
246        }
247    }
248
249    #[test]
250    fn attribute_flag_cases() {
251        for case in [
252            AttributeCase {
253                bits: 0x08,
254                compression: CompressionType::None,
255                timestamp_type: TimestampType::LogAppendTime,
256                transactional: false,
257                control_batch: false,
258                delete_horizon: false,
259            },
260            AttributeCase {
261                bits: 0x10,
262                compression: CompressionType::None,
263                timestamp_type: TimestampType::CreateTime,
264                transactional: true,
265                control_batch: false,
266                delete_horizon: false,
267            },
268            AttributeCase {
269                bits: 0x20,
270                compression: CompressionType::None,
271                timestamp_type: TimestampType::CreateTime,
272                transactional: false,
273                control_batch: true,
274                delete_horizon: false,
275            },
276            AttributeCase {
277                bits: 0x40,
278                compression: CompressionType::None,
279                timestamp_type: TimestampType::CreateTime,
280                transactional: false,
281                control_batch: false,
282                delete_horizon: true,
283            },
284            AttributeCase {
285                bits: 0x7c,
286                compression: CompressionType::Zstd,
287                timestamp_type: TimestampType::LogAppendTime,
288                transactional: true,
289                control_batch: true,
290                delete_horizon: true,
291            },
292        ] {
293            assert_attribute_case(&case);
294        }
295    }
296
297    #[test]
298    fn builder_round_trip() {
299        let a = Attributes::default()
300            .with_compression(CompressionType::Snappy)
301            .with_timestamp_type(TimestampType::LogAppendTime)
302            .with_transactional(true)
303            .with_control(false);
304
305        // Snappy = bits 0-2 = 010, LogAppendTime = bit 3, transactional = bit 4.
306        assert2::assert!(a == Attributes(0b0000_0000_0001_1010));
307    }
308
309    #[test]
310    fn replacing_compression_clears_old_bits() {
311        // Starting with Lz4 (bits 0-2 = 011), switching to Gzip (= 001)
312        // must clear bit 1, not OR over it.
313        let a = Attributes::default().with_compression(CompressionType::Lz4);
314        let b = a.with_compression(CompressionType::Gzip);
315        assert2::assert!(b.compression() == CompressionType::Gzip);
316        assert2::assert!(b.0 & 0x07 == 1);
317    }
318
319    #[test]
320    fn delete_horizon_bit_round_trips() {
321        // Default has no delete horizon.
322        let base = Attributes::default();
323        assert2::assert!(!base.has_delete_horizon());
324
325        // Setting it flips exactly bit 6 (mask 0x40).
326        let set = base.with_delete_horizon(true);
327        assert2::assert!(set.has_delete_horizon());
328        assert2::assert!(set.0 & Attributes::DELETE_HORIZON_BIT == 0x40);
329
330        // Orthogonal to control / transactional: setting those does not touch
331        // bit 6, and bit 6 does not touch them.
332        let combo = Attributes::default()
333            .with_control(true)
334            .with_transactional(true)
335            .with_delete_horizon(true);
336        // control = bit 5, transactional = bit 4, delete horizon = bit 6.
337        assert2::assert!(combo == Attributes(0b0000_0000_0111_0000));
338
339        // Clearing bit 6 leaves the others intact.
340        let cleared = combo.with_delete_horizon(false);
341        assert2::assert!(cleared == Attributes(0b0000_0000_0011_0000));
342    }
343
344    /// Builds a sample 61-byte header with known values. The header table
345    /// tests below reuse it.
346    fn sample_header_bytes() -> [u8; HEADER_LEN] {
347        let mut buf = [0u8; HEADER_LEN];
348        buf[0..8].copy_from_slice(&100i64.to_be_bytes()); // base_offset
349        buf[8..12].copy_from_slice(&77i32.to_be_bytes()); // batch_length
350        buf[12..16].copy_from_slice(&1i32.to_be_bytes()); // partition_leader_epoch
351        buf[16] = 2; // magic
352        buf[17..21].copy_from_slice(&0x1234_5678u32.to_be_bytes()); // crc
353        buf[21..23].copy_from_slice(&0x1234i16.to_be_bytes()); // attributes
354        buf[23..27].copy_from_slice(&3i32.to_be_bytes()); // last_offset_delta
355        buf[27..35].copy_from_slice(&111i64.to_be_bytes()); // base_timestamp
356        buf[35..43].copy_from_slice(&222i64.to_be_bytes()); // max_timestamp
357        buf[43..51].copy_from_slice(&(-1i64).to_be_bytes()); // producer_id
358        buf[51..53].copy_from_slice(&7i16.to_be_bytes()); // producer_epoch
359        buf[53..57].copy_from_slice(&(-1i32).to_be_bytes()); // base_sequence
360        buf[57..61].copy_from_slice(&4i32.to_be_bytes()); // records_count
361        buf
362    }
363
364    #[derive(Debug, PartialEq)]
365    struct HeaderProjection {
366        base_offset: i64,
367        batch_length: i32,
368        partition_leader_epoch: i32,
369        magic: i8,
370        crc: u32,
371        attributes: i16,
372        last_offset_delta: i32,
373        base_timestamp: i64,
374        max_timestamp: i64,
375        producer_id: i64,
376        producer_epoch: i16,
377        base_sequence: i32,
378        records_count: i32,
379    }
380
381    #[test]
382    fn reads_complete_header() {
383        let buf = sample_header_bytes();
384        let h = RecordBatchHeader::ref_from_bytes(&buf[..]).unwrap();
385        let actual = HeaderProjection {
386            base_offset: h.base_offset.get(),
387            batch_length: h.batch_length.get(),
388            partition_leader_epoch: h.partition_leader_epoch.get(),
389            magic: h.magic,
390            crc: h.crc.get(),
391            attributes: h.attributes.get(),
392            last_offset_delta: h.last_offset_delta.get(),
393            base_timestamp: h.base_timestamp.get(),
394            max_timestamp: h.max_timestamp.get(),
395            producer_id: h.producer_id.get(),
396            producer_epoch: h.producer_epoch.get(),
397            base_sequence: h.base_sequence.get(),
398            records_count: h.records_count.get(),
399        };
400        let expected = HeaderProjection {
401            base_offset: 100,
402            batch_length: 77,
403            partition_leader_epoch: 1,
404            magic: 2,
405            crc: 0x1234_5678,
406            attributes: 0x1234,
407            last_offset_delta: 3,
408            base_timestamp: 111,
409            max_timestamp: 222,
410            producer_id: -1,
411            producer_epoch: 7,
412            base_sequence: -1,
413            records_count: 4,
414        };
415        assert2::assert!(actual == expected);
416    }
417
418    #[test]
419    fn header_is_exactly_61_bytes() {
420        assert2::assert!(size_of::<RecordBatchHeader>() == HEADER_LEN);
421    }
422
423    #[test]
424    fn too_short_buffer_errors() {
425        let buf = [0u8; HEADER_LEN - 1];
426        assert2::assert!(RecordBatchHeader::ref_from_bytes(&buf[..]).is_err());
427    }
428
429    #[test]
430    fn patch_writes_only_pre_crc_fields() {
431        let mut buf = sample_header_bytes();
432        let crc_region_before = buf[CRC_COVERAGE_START..].to_vec();
433        let crc_field_before = buf[17..21].to_vec();
434
435        patch_base_offset_and_leader_epoch(&mut buf, 9_001, 42);
436
437        // The two stamped fields changed to the expected big-endian values.
438        let h = RecordBatchHeader::ref_from_bytes(&buf[..]).unwrap();
439        check!(h.base_offset.get() == 9_001);
440        check!(h.partition_leader_epoch.get() == 42);
441
442        // The CRC field itself is untouched (no recompute).
443        check!(&buf[17..21] == &crc_field_before[..]);
444        // Everything in the CRC-covered region is byte-identical.
445        check!(&buf[CRC_COVERAGE_START..] == &crc_region_before[..]);
446    }
447
448    #[test]
449    fn crc_coverage_constants_match_field_layout() {
450        // base_offset and leader_epoch are entirely below the CRC start;
451        // attributes (the first CRC-covered field) begins exactly at 21.
452        check!(BASE_OFFSET_RANGE.end <= CRC_COVERAGE_START);
453        check!(LEADER_EPOCH_RANGE.end <= CRC_COVERAGE_START);
454        check!(CRC_COVERAGE_START == 21);
455    }
456}