Skip to main content

kacrab_protocol/record/
batch.rs

1//! Record-batch v2 container.
2//!
3//! Layout (after the 12-byte log overhead):
4//!
5//! ```text
6//! baseOffset (8) | batchLength (4)
7//!   ─────── log overhead ────────
8//! partitionLeaderEpoch (4) | magic (1) | crc (4)
9//!   ─────── CRC-covered region ────────
10//!     attributes (2) | lastOffsetDelta (4) | firstTimestamp (8)
11//!     maxTimestamp (8) | producerId (8) | producerEpoch (2)
12//!     baseSequence (4) | recordCount (4) | records[…]
13//! ```
14
15use bytes::{Buf, BufMut, Bytes, BytesMut};
16
17use super::{MAX_RECORDS_PER_BATCH, Record, RecordError, RecordErrorKind, Result};
18use crate::{
19    compression::Compression,
20    crc,
21    primitives::{PrimitiveError, PrimitiveErrorKind},
22};
23
24const MAGIC_V2: i8 = 2;
25const LOG_OVERHEAD: usize = 12;
26const BATCH_HEADER_SIZE: i32 = 49;
27const BATCH_HEADER_SIZE_USIZE: usize = 49;
28
29/// Kafka timestamp type — derived from bit 3 of the batch `attributes` field.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31#[non_exhaustive]
32pub enum TimestampType {
33    /// Producer-assigned timestamp (default).
34    CreateTime,
35    /// Broker-assigned timestamp at log append.
36    LogAppendTime,
37}
38
39/// A Kafka record batch (message format v2).
40///
41/// `batchLength` and `crc` are not stored — they are derived during encode
42/// and validated during decode.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct RecordBatch {
45    /// Offset of the first record in the batch.
46    pub base_offset: i64,
47    /// Partition leader epoch (KIP-101).
48    pub partition_leader_epoch: i32,
49    /// Format version. Always 2 for v2 batches.
50    pub magic: i8,
51    /// Bit-packed flags: compression (bits 0–2), timestamp type (bit 3),
52    /// transactional (bit 4), control (bit 5).
53    pub attributes: i16,
54    /// Difference between the first and last record offsets.
55    pub last_offset_delta: i32,
56    /// Wall-clock timestamp of the first record.
57    pub first_timestamp: i64,
58    /// Wall-clock timestamp of the record with the highest timestamp.
59    pub max_timestamp: i64,
60    /// Producer ID (idempotent / transactional producer).
61    pub producer_id: i64,
62    /// Producer epoch (idempotent / transactional producer).
63    pub producer_epoch: i16,
64    /// First sequence number in the batch (idempotent producer).
65    pub base_sequence: i32,
66    /// Records in the batch.
67    pub records: Vec<Record>,
68}
69
70impl RecordBatch {
71    /// Encode this batch into `buf`, including the 12-byte log overhead,
72    /// CRC32C, and any compression dictated by `self.attributes`.
73    pub fn encode(&self, buf: &mut BytesMut) -> Result<()> {
74        self.encode_with_compression_level(buf, None)
75    }
76
77    /// Encode this batch, passing an optional codec-specific compression level.
78    pub fn encode_with_compression_level(
79        &self,
80        buf: &mut BytesMut,
81        compression_level: Option<i32>,
82    ) -> Result<()> {
83        let codec =
84            Compression::from_attributes(self.attributes).map_err(|e| self.lift_compression(e))?;
85        if codec == Compression::None {
86            let records_payload_len = self.records_encoded_len()?;
87            return self.write_batch(buf, records_payload_len, |buf| self.write_records(buf));
88        }
89
90        let mut records_buf = BytesMut::with_capacity(self.records_encoded_len()?);
91        self.write_records(&mut records_buf)?;
92        let records_payload = Bytes::from(
93            codec
94                .compress_with_level(&records_buf, compression_level)
95                .map_err(|e| self.lift_compression(e))?,
96        );
97        self.write_batch(buf, records_payload.len(), |buf| {
98            buf.extend_from_slice(&records_payload);
99            Ok(())
100        })
101    }
102
103    /// Return the exact encoded length when records are written without
104    /// compression.
105    pub fn uncompressed_encoded_len(&self) -> Result<usize> {
106        let len = super::add_encoded_len("record batch", LOG_OVERHEAD, BATCH_HEADER_SIZE_USIZE)?;
107        super::add_encoded_len("record batch", len, self.records_encoded_len()?)
108    }
109
110    fn records_encoded_len(&self) -> Result<usize> {
111        let mut len = 0;
112        for record in &self.records {
113            len = super::add_encoded_len(
114                "record batch records",
115                len,
116                record
117                    .encoded_len()
118                    .map_err(|e| RecordError::at_offset(self.base_offset, e.kind))?,
119            )?;
120        }
121        Ok(len)
122    }
123
124    fn write_records(&self, buf: &mut BytesMut) -> Result<()> {
125        for record in &self.records {
126            record
127                .encode(buf)
128                .map_err(|e| RecordError::at_offset(self.base_offset, e.kind))?;
129        }
130        Ok(())
131    }
132
133    fn write_batch<F>(
134        &self,
135        buf: &mut BytesMut,
136        records_payload_len: usize,
137        write_records_payload: F,
138    ) -> Result<()>
139    where
140        F: FnOnce(&mut BytesMut) -> Result<()>,
141    {
142        buf.put_i64(self.base_offset);
143
144        buf.put_i32(self.batch_length(records_payload_len)?);
145
146        buf.put_i32(self.partition_leader_epoch);
147        buf.put_i8(self.magic);
148
149        let crc_field_pos = buf.len();
150        buf.put_u32(0);
151
152        let crc_start = buf.len();
153        buf.put_i16(self.attributes);
154        buf.put_i32(self.last_offset_delta);
155        buf.put_i64(self.first_timestamp);
156        buf.put_i64(self.max_timestamp);
157        buf.put_i64(self.producer_id);
158        buf.put_i16(self.producer_epoch);
159        buf.put_i32(self.base_sequence);
160        buf.put_i32(self.record_count()?);
161        write_records_payload(buf)?;
162
163        let crc = crc::crc32c(buf.get(crc_start..).unwrap_or(&[]));
164        let crc_bytes = crc.to_be_bytes();
165        let crc_field_end = crc_field_pos.checked_add(4).ok_or_else(|| {
166            RecordError::at_offset(
167                self.base_offset,
168                RecordErrorKind::LengthOverflow {
169                    field: "crc field",
170                    got: crc_field_pos,
171                    remaining: buf.len(),
172                },
173            )
174        })?;
175        let Some(slot) = buf.get_mut(crc_field_pos..crc_field_end) else {
176            return Err(RecordError::at_offset(
177                self.base_offset,
178                RecordErrorKind::LengthOverflow {
179                    field: "crc field",
180                    got: crc_field_end,
181                    remaining: buf.len(),
182                },
183            ));
184        };
185        slot.copy_from_slice(&crc_bytes);
186
187        Ok(())
188    }
189
190    fn batch_length(&self, records_payload_len: usize) -> Result<i32> {
191        let payload_len = i32::try_from(records_payload_len).map_err(|_| {
192            RecordError::at_offset(
193                self.base_offset,
194                RecordErrorKind::LengthOverflow {
195                    field: "compressed records",
196                    got: records_payload_len,
197                    remaining: usize::try_from(i32::MAX).unwrap_or(usize::MAX),
198                },
199            )
200        })?;
201        BATCH_HEADER_SIZE.checked_add(payload_len).ok_or_else(|| {
202            RecordError::at_offset(
203                self.base_offset,
204                RecordErrorKind::LengthOverflow {
205                    field: "batch length",
206                    got: records_payload_len,
207                    remaining: usize::try_from(i32::MAX).unwrap_or(usize::MAX),
208                },
209            )
210        })
211    }
212
213    fn record_count(&self) -> Result<i32> {
214        i32::try_from(self.records.len()).map_err(|_| {
215            RecordError::at_offset(
216                self.base_offset,
217                RecordErrorKind::RecordCountTooLarge {
218                    got: i32::MAX,
219                    max: MAX_RECORDS_PER_BATCH,
220                },
221            )
222        })
223    }
224
225    /// Decode one batch from `buf`. Validates CRC32C, decompresses if needed,
226    /// and rejects a `record_count` above [`super::MAX_RECORDS_PER_BATCH`].
227    #[expect(
228        clippy::too_many_lines,
229        reason = "Record-batch decoding mirrors the Kafka wire layout step-by-step; splitting it \
230                  before generated protocol decode is stable would obscure validation order."
231    )]
232    pub fn decode(buf: &mut Bytes) -> Result<Self> {
233        let available = buf.remaining();
234        if available < LOG_OVERHEAD {
235            return Err(RecordError::unknown_offset(RecordErrorKind::Primitive(
236                PrimitiveError::from(PrimitiveErrorKind::InsufficientData {
237                    needed: LOG_OVERHEAD,
238                    available,
239                }),
240            )));
241        }
242        let base_offset = buf.get_i64();
243        let batch_length = buf.get_i32();
244
245        if batch_length < BATCH_HEADER_SIZE {
246            return Err(RecordError::at_offset(
247                base_offset,
248                RecordErrorKind::BatchTooSmall {
249                    got: batch_length,
250                    min: BATCH_HEADER_SIZE,
251                },
252            ));
253        }
254        let batch_len = usize::try_from(batch_length).map_err(|_| {
255            RecordError::at_offset(
256                base_offset,
257                RecordErrorKind::LengthOverflow {
258                    field: "batch payload",
259                    got: usize::MAX,
260                    remaining: buf.remaining(),
261                },
262            )
263        })?;
264        let remaining = buf.remaining();
265        if batch_len > remaining {
266            return Err(RecordError::at_offset(
267                base_offset,
268                RecordErrorKind::LengthOverflow {
269                    field: "batch payload",
270                    got: batch_len,
271                    remaining,
272                },
273            ));
274        }
275
276        let mut batch_data = buf.split_to(batch_len);
277
278        let crc_slice = batch_data.get(5..9).ok_or_else(|| {
279            RecordError::at_offset(
280                base_offset,
281                RecordErrorKind::LengthOverflow {
282                    field: "crc field",
283                    got: 9,
284                    remaining: batch_data.len(),
285                },
286            )
287        })?;
288        let crc_bytes: [u8; 4] = crc_slice.try_into().map_err(|_| {
289            RecordError::at_offset(
290                base_offset,
291                RecordErrorKind::LengthOverflow {
292                    field: "crc field",
293                    got: 4,
294                    remaining: crc_slice.len(),
295                },
296            )
297        })?;
298        let stored_crc = u32::from_be_bytes(crc_bytes);
299        let crc_payload = batch_data.get(9..).unwrap_or(&[]);
300        crc::validate_crc32c(crc_payload, stored_crc)
301            .map_err(|e| RecordError::at_offset(base_offset, RecordErrorKind::Crc(e)))?;
302
303        let partition_leader_epoch = batch_data.get_i32();
304        let magic = batch_data.get_i8();
305        if magic != MAGIC_V2 {
306            return Err(RecordError::at_offset(
307                base_offset,
308                RecordErrorKind::UnsupportedMagic(magic),
309            ));
310        }
311        let _crc = batch_data.get_u32();
312        let attributes = batch_data.get_i16();
313        let last_offset_delta = batch_data.get_i32();
314        let first_timestamp = batch_data.get_i64();
315        let max_timestamp = batch_data.get_i64();
316        let producer_id = batch_data.get_i64();
317        let producer_epoch = batch_data.get_i16();
318        let base_sequence = batch_data.get_i32();
319        let record_count = batch_data.get_i32();
320
321        if record_count < 0 {
322            return Err(RecordError::at_offset(
323                base_offset,
324                RecordErrorKind::NegativeLength {
325                    field: "record count",
326                    length: record_count,
327                },
328            ));
329        }
330        let record_count_usize = usize::try_from(record_count).map_err(|_| {
331            RecordError::at_offset(
332                base_offset,
333                RecordErrorKind::LengthOverflow {
334                    field: "record count",
335                    got: usize::MAX,
336                    remaining: MAX_RECORDS_PER_BATCH,
337                },
338            )
339        })?;
340        if record_count_usize > MAX_RECORDS_PER_BATCH {
341            return Err(RecordError::at_offset(
342                base_offset,
343                RecordErrorKind::RecordCountTooLarge {
344                    got: record_count,
345                    max: MAX_RECORDS_PER_BATCH,
346                },
347            ));
348        }
349
350        let codec = Compression::from_attributes(attributes)
351            .map_err(|e| RecordError::at_offset(base_offset, RecordErrorKind::Compression(e)))?;
352        let mut records_data = if codec == Compression::None {
353            batch_data
354        } else {
355            let decompressed = codec.decompress(&batch_data).map_err(|e| {
356                RecordError::at_offset(base_offset, RecordErrorKind::Compression(e))
357            })?;
358            Bytes::from(decompressed)
359        };
360
361        let mut records = Vec::with_capacity(record_count_usize);
362        for _ in 0..record_count {
363            let rec = Record::decode(&mut records_data)
364                .map_err(|e| RecordError::at_offset(base_offset, e.kind))?;
365            records.push(rec);
366        }
367
368        Ok(Self {
369            base_offset,
370            partition_leader_epoch,
371            magic,
372            attributes,
373            last_offset_delta,
374            first_timestamp,
375            max_timestamp,
376            producer_id,
377            producer_epoch,
378            base_sequence,
379            records,
380        })
381    }
382
383    /// Compression codec selected by bits 0–2 of `attributes`.
384    pub fn compression(&self) -> Result<Compression> {
385        Compression::from_attributes(self.attributes).map_err(|e| self.lift_compression(e))
386    }
387
388    /// Timestamp type from bit 3 of `attributes`.
389    #[must_use]
390    pub const fn timestamp_type(&self) -> TimestampType {
391        if self.attributes & 0x08 != 0 {
392            TimestampType::LogAppendTime
393        } else {
394            TimestampType::CreateTime
395        }
396    }
397
398    /// `true` if bit 4 of `attributes` is set.
399    #[must_use]
400    pub const fn is_transactional(&self) -> bool {
401        self.attributes & 0x10 != 0
402    }
403
404    /// `true` if bit 5 of `attributes` is set.
405    #[must_use]
406    pub const fn is_control_batch(&self) -> bool {
407        self.attributes & 0x20 != 0
408    }
409
410    const fn lift_compression(&self, err: crate::compression::CompressionError) -> RecordError {
411        RecordError::at_offset(self.base_offset, RecordErrorKind::Compression(err))
412    }
413}
414
415/// Decode the next complete batch from `buf`, when one is present.
416///
417/// Returns `Ok(None)` when the buffer is empty or ends in a truncated trailing
418/// batch (fetch responses may cut the last batch short) — an error only if a
419/// batch is *malformed*, not just incomplete. Lets a consumer decode a fetched
420/// blob one batch at a time instead of materializing every record up front.
421pub fn decode_next_batch(buf: &mut Bytes) -> Result<Option<RecordBatch>> {
422    if buf.remaining() < LOG_OVERHEAD {
423        return Ok(None);
424    }
425    let Some(len_slice) = buf.get(8..12) else {
426        return Ok(None);
427    };
428    let len_bytes: [u8; 4] = match len_slice.try_into() {
429        Ok(arr) => arr,
430        Err(_) => return Ok(None),
431    };
432    let batch_length = i32::from_be_bytes(len_bytes);
433    if batch_length < 0 {
434        return Ok(None);
435    }
436    let Ok(batch_len) = usize::try_from(batch_length) else {
437        return Ok(None);
438    };
439    let needed = LOG_OVERHEAD.saturating_add(batch_len);
440    if buf.remaining() < needed {
441        return Ok(None);
442    }
443    RecordBatch::decode(buf).map(Some)
444}
445
446/// Decode every batch in a contiguous buffer.
447///
448/// Stops cleanly on a truncated trailing batch (returns the batches decoded so
449/// far). Returns an error only if a batch is *malformed* — not just incomplete.
450pub fn decode_batches(buf: &mut Bytes) -> Result<Vec<RecordBatch>> {
451    let mut batches = Vec::new();
452    while let Some(batch) = decode_next_batch(buf)? {
453        batches.push(batch);
454    }
455    Ok(batches)
456}
457
458#[cfg(test)]
459mod tests {
460    #![allow(
461        clippy::expect_used,
462        clippy::missing_assert_message,
463        reason = "Record-batch encoding tests fail fastest with contextual expect calls."
464    )]
465
466    use bytes::{Bytes, BytesMut};
467
468    use super::{Record, RecordBatch};
469    use crate::record::RecordHeader;
470
471    #[test]
472    fn batch_uncompressed_encoded_len_matches_encoded_bytes() {
473        let batch = RecordBatch {
474            base_offset: 0,
475            partition_leader_epoch: -1,
476            magic: 2,
477            attributes: 0,
478            last_offset_delta: 1,
479            first_timestamp: 10,
480            max_timestamp: 11,
481            producer_id: -1,
482            producer_epoch: -1,
483            base_sequence: -1,
484            records: vec![
485                Record {
486                    attributes: 0,
487                    timestamp_delta: 0,
488                    offset_delta: 0,
489                    key: Some(Bytes::from_static(b"key-0")),
490                    value: Some(Bytes::from_static(b"value-0")),
491                    headers: Vec::new(),
492                },
493                Record {
494                    attributes: 0,
495                    timestamp_delta: 1,
496                    offset_delta: 1,
497                    key: None,
498                    value: Some(Bytes::from_static(b"value-1")),
499                    headers: vec![RecordHeader {
500                        key: Bytes::from_static(b"h"),
501                        value: None,
502                    }],
503                },
504            ],
505        };
506        let encoded_len = batch.uncompressed_encoded_len().expect("batch encoded len");
507        let mut bytes = BytesMut::with_capacity(encoded_len);
508
509        batch.encode(&mut bytes).expect("batch encode");
510
511        assert_eq!(encoded_len, bytes.len());
512        assert_eq!(encoded_len, bytes.capacity());
513        let decoded = RecordBatch::decode(&mut bytes.freeze()).expect("record batch decode");
514        assert_eq!(decoded.records.len(), 2);
515    }
516}