Skip to main content

krabka_protocol/records/
header.rs

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