Skip to main content

krabka_protocol/records/
produce_passthrough.rs

1//! Zero-copy capture of a `ProduceRequest`'s per-partition `records` fields.
2//!
3//! The produce hot path normally decodes each partition's `records` field into
4//! an owned `RecordBatch`, which is a full copy and a full parse. On the
5//! verbatim passthrough fast path the broker instead needs the producer's exact
6//! wire bytes as a refcounted [`Bytes`] slice of the request frame, so that it
7//! can append them without re-encoding.
8//!
9//! [`produce_record_slices`] walks the `ProduceRequest` body over a [`Bytes`]
10//! cursor. For every `(topic, partition)` in wire order it returns the records
11//! field as a zero-copy `Bytes::slice`. It takes the slice with
12//! `Buf::copy_to_bytes`, which on `Bytes` is a refcount bump and not a copy.
13//!
14//! The walk mirrors the generated `ProduceRequest::decode` field order
15//! byte-for-byte. See `generated/ProduceRequest.owned.rs`. The tests below run
16//! the walk against the generated encoder, so the two cannot drift.
17
18use bytes::{Buf, Bytes};
19
20use crate::{
21    ProtocolError,
22    primitives::{
23        array::get_array_len,
24        fixed::{get_i16, get_i32},
25        string_bytes::{get_compact_nullable_string_owned, get_nullable_string_owned},
26        uuid::{Uuid, get_uuid},
27        varint::get_uvarint,
28    },
29    tagged_fields::read_tagged_fields,
30};
31
32/// `ProduceRequest` is flexible at version 9 and above, with KIP-482 tagged
33/// fields and compact encodings. This mirrors `is_flexible` in the generated
34/// code.
35const FLEXIBLE_MIN: i16 = 9;
36
37/// The verbatim `records` field bytes for one `(topic, partition)` slot,
38/// captured zero-copy from the request frame.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct PartitionRecordSlice {
41    /// Index of the topic within `ProduceRequest.topic_data` (wire order).
42    pub topic_index: usize,
43    /// Index of the partition within the topic's `partition_data`.
44    pub partition_index: usize,
45    /// The partition index (`PartitionProduceData.index`) on the wire.
46    pub partition: i32,
47    /// The records field as a zero-copy slice of the frame, or `None` when
48    /// the field was wire-null.
49    pub records: Option<Bytes>,
50}
51
52/// The request-level and per-topic framing of a `ProduceRequest`.
53///
54/// [`produce_framing`] captures this framing and decodes no record batch and
55/// decompresses no record batch. The broker's produce hot path uses this
56/// header-only view to choose verbatim passthrough or owned decode for each
57/// partition. Every field here comes straight off the wire framing. The record
58/// bytes themselves stay as zero-copy [`Bytes`] slices in
59/// [`ProduceFramingTopic::partitions`].
60#[derive(Debug, Clone, Default, PartialEq, Eq)]
61pub struct ProduceFraming {
62    /// `transactional_id` (v≥3, nullable). Drives the txn ACL preamble.
63    pub transactional_id: Option<String>,
64    /// `acks` (-1 / 0 / 1).
65    pub acks: i16,
66    /// `timeout_ms`.
67    pub timeout_ms: i32,
68    /// Per-topic framing in wire order.
69    pub topics: Vec<ProduceFramingTopic>,
70}
71
72/// One topic's framing within a [`ProduceFraming`].
73#[derive(Debug, Clone, Default, PartialEq, Eq)]
74pub struct ProduceFramingTopic {
75    /// Topic name. STRING for v≤12, and empty for v≥13, which is id-only.
76    pub name: String,
77    /// Topic id. A 16-byte UUID for v≥13, and [`Uuid::ZERO`] for v≤12.
78    pub topic_id: Uuid,
79    /// Per-partition records slices in wire order.
80    pub partitions: Vec<PartitionRecordSlice>,
81}
82
83/// Walks a `ProduceRequest` body and returns every partition's `records` field
84/// as a zero-copy `Bytes` slice.
85///
86/// The body must already sit at the start of the request body, that is after
87/// the request header. `body` must be a `Bytes` cursor over exactly the produce
88/// request body. `version` is the produce-request API version. The walk
89/// allocates nothing for the records payloads. Each slice is a refcount view
90/// into `body`'s backing buffer.
91///
92/// # Errors
93///
94/// Returns [`ProtocolError`] if the bytes do not parse as a `ProduceRequest`
95/// of the given version, for example a malformed or short frame.
96pub fn produce_record_slices(
97    mut body: Bytes,
98    version: i16,
99) -> Result<Vec<PartitionRecordSlice>, ProtocolError> {
100    let flex = version >= FLEXIBLE_MIN;
101    let buf = &mut body;
102
103    // transactional_id (v>=3, present for every version we serve).
104    if version >= 3 {
105        if flex {
106            get_compact_nullable_string_owned(buf)?;
107        } else {
108            get_nullable_string_owned(buf)?;
109        }
110    }
111    // acks (i16), timeout_ms (i32).
112    let _acks = get_i16(buf)?;
113    let _timeout_ms = get_i32(buf)?;
114
115    let mut out = Vec::new();
116
117    let topic_count = get_array_len(buf, flex)?;
118    for topic_index in 0..topic_count {
119        // name: STRING/COMPACT_STRING for v<=12; topic_id (16 bytes) for v>=13.
120        if version <= 12 {
121            if flex {
122                get_compact_nullable_string_owned(buf)?;
123            } else {
124                get_nullable_string_owned(buf)?;
125            }
126        }
127        if version >= 13 {
128            skip(buf, 16)?; // topic_id UUID
129        }
130
131        let partition_count = get_array_len(buf, flex)?;
132        for partition_index in 0..partition_count {
133            let partition = get_i32(buf)?;
134            // records: NULLABLE_BYTES (v<9) / COMPACT_NULLABLE_BYTES (v>=9).
135            let records = read_nullable_bytes_slice(buf, flex)?;
136            out.push(PartitionRecordSlice {
137                topic_index,
138                partition_index,
139                partition,
140                records,
141            });
142            // Per-partition tagged fields (flexible only).
143            if flex {
144                read_tagged_fields(buf, |_tag, _payload| Ok(false))?;
145            }
146        }
147        // Per-topic tagged fields (flexible only).
148        if flex {
149            read_tagged_fields(buf, |_tag, _payload| Ok(false))?;
150        }
151    }
152    // Request-level tagged fields (flexible only) — not needed, but consume
153    // them for totality so a trailing-byte assertion in callers holds.
154    if flex {
155        read_tagged_fields(buf, |_tag, _payload| Ok(false))?;
156    }
157
158    Ok(out)
159}
160
161/// Walks a `ProduceRequest` body (v≥3) and returns the full request framing
162/// and the per-topic framing.
163///
164/// This function decodes no record batch and decompresses no record batch. It
165/// captures each partition's `records` field as a zero-copy [`Bytes`] slice of
166/// `body`.
167///
168/// This is the header-only entry point for the produce hot path. It gives the
169/// handler everything it needs for the ACL preamble, topic resolution, and the
170/// verbatim-versus-owned dispatch, and it leaves the record bodies untouched.
171/// Those bodies can be LZ4-compressed and can expand 100×. The owned fallback
172/// re-decodes a single partition's slice only when the passthrough predicate
173/// fails.
174///
175/// The walk mirrors the generated `ProduceRequest::decode` field order
176/// byte-for-byte. The tests below run the walk against the generated encoder,
177/// so the two cannot drift.
178///
179/// # Errors
180///
181/// Returns [`ProtocolError`] if the bytes do not parse as a `ProduceRequest`
182/// of the given version, for example a malformed or short frame.
183pub fn produce_framing(mut body: Bytes, version: i16) -> Result<ProduceFraming, ProtocolError> {
184    let flex = version >= FLEXIBLE_MIN;
185    let buf = &mut body;
186
187    // transactional_id (v>=3, present for every version we serve).
188    let transactional_id = if version >= 3 {
189        if flex {
190            get_compact_nullable_string_owned(buf)?
191        } else {
192            get_nullable_string_owned(buf)?
193        }
194    } else {
195        None
196    };
197    let acks = get_i16(buf)?;
198    let timeout_ms = get_i32(buf)?;
199
200    let topic_count = get_array_len(buf, flex)?;
201    let mut topics = Vec::with_capacity(topic_count);
202    for topic_index in 0..topic_count {
203        // name: STRING/COMPACT_STRING for v<=12; topic_id (16 bytes) for v>=13.
204        let mut name = String::new();
205        if version <= 12 {
206            name = if flex {
207                get_compact_nullable_string_owned(buf)?.unwrap_or_default()
208            } else {
209                get_nullable_string_owned(buf)?.unwrap_or_default()
210            };
211        }
212        let mut topic_id = Uuid::ZERO;
213        if version >= 13 {
214            topic_id = get_uuid(buf)?;
215        }
216
217        let partition_count = get_array_len(buf, flex)?;
218        let mut partitions = Vec::with_capacity(partition_count);
219        for partition_index in 0..partition_count {
220            let partition = get_i32(buf)?;
221            let records = read_nullable_bytes_slice(buf, flex)?;
222            partitions.push(PartitionRecordSlice {
223                topic_index,
224                partition_index,
225                partition,
226                records,
227            });
228            // Per-partition tagged fields (flexible only).
229            if flex {
230                read_tagged_fields(buf, |_tag, _payload| Ok(false))?;
231            }
232        }
233        // Per-topic tagged fields (flexible only).
234        if flex {
235            read_tagged_fields(buf, |_tag, _payload| Ok(false))?;
236        }
237        topics.push(ProduceFramingTopic {
238            name,
239            topic_id,
240            partitions,
241        });
242    }
243    // Request-level tagged fields (flexible only).
244    if flex {
245        read_tagged_fields(buf, |_tag, _payload| Ok(false))?;
246    }
247
248    Ok(ProduceFraming {
249        transactional_id,
250        acks,
251        timeout_ms,
252        topics,
253    })
254}
255
256/// Reads a `NULLABLE_BYTES` or `COMPACT_NULLABLE_BYTES` length prefix and
257/// returns the payload as a zero-copy `Bytes` slice of `buf`.
258fn read_nullable_bytes_slice(buf: &mut Bytes, flex: bool) -> Result<Option<Bytes>, ProtocolError> {
259    let len = if flex {
260        let raw = get_uvarint(buf)?;
261        if raw == 0 {
262            return Ok(None);
263        }
264        (raw - 1) as usize
265    } else {
266        let n = get_i32(buf)?;
267        if n < 0 {
268            return Ok(None);
269        }
270        usize::try_from(n).expect("non-negative i32 fits usize")
271    };
272    if buf.remaining() < len {
273        return Err(ProtocolError::UnexpectedEof {
274            needed: len - buf.remaining(),
275        });
276    }
277    // `Bytes::copy_to_bytes` is a refcount split, not a memcpy.
278    Ok(Some(buf.copy_to_bytes(len)))
279}
280
281fn skip(buf: &mut Bytes, n: usize) -> Result<(), ProtocolError> {
282    if buf.remaining() < n {
283        return Err(ProtocolError::UnexpectedEof {
284            needed: n - buf.remaining(),
285        });
286    }
287    buf.advance(n);
288    Ok(())
289}
290
291#[cfg(test)]
292mod tests {
293
294    use bytes::BytesMut;
295
296    use super::*;
297    use crate::{
298        Encode,
299        owned::produce_request::{PartitionProduceData, ProduceRequest, TopicProduceData},
300        records::{Record, RecordBatch, RecordsPayload},
301    };
302
303    fn batch_with_value(v: &[u8], base_offset: i64) -> RecordBatch {
304        RecordBatch {
305            base_offset,
306            records: vec![Record {
307                value: Some(Bytes::copy_from_slice(v)),
308                ..Default::default()
309            }],
310            ..Default::default()
311        }
312    }
313
314    /// Encodes a produce request, then asserts that the captured slices match
315    /// the records the full decoder produces, byte-for-byte, for every
316    /// (topic, partition).
317    fn check_roundtrip(_case: &str, req: &ProduceRequest, version: i16) {
318        let mut buf = BytesMut::new();
319        req.encode(&mut buf, version).unwrap();
320        let body = buf.freeze();
321
322        let slices = produce_record_slices(body.clone(), version).unwrap();
323
324        // Flatten the request's (topic, partition) records in wire order.
325        let mut expected: Vec<Option<Bytes>> = Vec::new();
326        for t in &req.topic_data {
327            for p in &t.partition_data {
328                let enc = p.records.as_ref().map(|rp| {
329                    let mut b = BytesMut::new();
330                    <RecordsPayload as Encode>::encode(rp, &mut b, version).unwrap();
331                    b.freeze()
332                });
333                expected.push(enc);
334            }
335        }
336        assert2::assert!(slices.len() == expected.len());
337        for (got, want) in slices.iter().zip(expected.iter()) {
338            match (&got.records, want) {
339                (Some(g), Some(w)) => {
340                    assert2::assert!(&g[..] == &w[..]);
341                }
342                (None, None) => {}
343                _ => panic!("nullability mismatch"),
344            }
345        }
346    }
347
348    fn multi_partition_request(version: i16) -> ProduceRequest {
349        let topic = |name: &str, parts: usize| TopicProduceData {
350            name: if version <= 12 {
351                name.to_string()
352            } else {
353                String::new()
354            },
355            topic_id: if version >= 13 {
356                crate::primitives::uuid::Uuid([7u8; 16])
357            } else {
358                crate::primitives::uuid::Uuid::ZERO
359            },
360            partition_data: (0..parts)
361                .map(|i| PartitionProduceData {
362                    index: i32::try_from(i).unwrap(),
363                    records: Some(RecordsPayload::V2(vec![batch_with_value(
364                        format!("topic-{name}-part-{i}").as_bytes(),
365                        i64::try_from(i).unwrap(),
366                    )])),
367                    ..Default::default()
368                })
369                .collect(),
370            ..Default::default()
371        };
372        ProduceRequest {
373            transactional_id: None,
374            acks: -1,
375            timeout_ms: 1000,
376            topic_data: vec![topic("alpha", 2), topic("beta", 1)],
377            ..Default::default()
378        }
379    }
380
381    #[test]
382    fn captures_match_decoder_encoding_cases() {
383        for (case, versions) in [("non-flexible", 3..=8), ("flexible", 9..=13)] {
384            for version in versions {
385                check_roundtrip(case, &multi_partition_request(version), version);
386            }
387        }
388    }
389
390    #[test]
391    fn handles_null_records_field() {
392        let version = 9;
393        let req = ProduceRequest {
394            transactional_id: Some("txn".to_string()),
395            acks: 1,
396            timeout_ms: 0,
397            topic_data: vec![TopicProduceData {
398                name: "t".to_string(),
399                partition_data: vec![PartitionProduceData {
400                    index: 0,
401                    records: None,
402                    ..Default::default()
403                }],
404                ..Default::default()
405            }],
406            ..Default::default()
407        };
408        let mut buf = BytesMut::new();
409        req.encode(&mut buf, version).unwrap();
410        let slices = produce_record_slices(buf.freeze(), version).unwrap();
411        assert2::assert!(slices.len() == 1);
412        assert2::assert!(slices[0].records.is_none());
413    }
414
415    #[test]
416    fn captured_slice_is_zero_copy_view() {
417        let version = 9;
418        let req = multi_partition_request(version);
419        let mut buf = BytesMut::new();
420        req.encode(&mut buf, version).unwrap();
421        let body = buf.freeze();
422        let body_start = body.as_ptr() as usize;
423        let body_end = body_start + body.len();
424
425        let slices = produce_record_slices(body.clone(), version).unwrap();
426        let first = slices[0].records.as_ref().unwrap();
427        let ptr = first.as_ptr() as usize;
428        assert2::assert!(ptr >= body_start && ptr < body_end);
429    }
430
431    /// `produce_framing` must reproduce the request-level, per-topic, and
432    /// per-partition framing that the full owned decoder produces. It must
433    /// also capture the same zero-copy records bytes, for every served
434    /// version.
435    fn check_framing_roundtrip(req: &ProduceRequest, version: i16) {
436        let mut buf = BytesMut::new();
437        req.encode(&mut buf, version).unwrap();
438        let body = buf.freeze();
439
440        let framing = produce_framing(body.clone(), version).unwrap();
441
442        let expected = ProduceFraming {
443            transactional_id: req.transactional_id.clone(),
444            acks: req.acks,
445            timeout_ms: req.timeout_ms,
446            topics: req
447                .topic_data
448                .iter()
449                .enumerate()
450                .map(|(topic_index, topic)| ProduceFramingTopic {
451                    name: topic.name.clone(),
452                    topic_id: topic.topic_id,
453                    partitions: topic
454                        .partition_data
455                        .iter()
456                        .enumerate()
457                        .map(|(partition_index, partition)| PartitionRecordSlice {
458                            topic_index,
459                            partition_index,
460                            partition: partition.index,
461                            records: partition.records.as_ref().map(|payload| {
462                                let mut b = BytesMut::new();
463                                <RecordsPayload as Encode>::encode(payload, &mut b, version)
464                                    .unwrap();
465                                b.freeze()
466                            }),
467                        })
468                        .collect(),
469                })
470                .collect(),
471        };
472        assert2::assert!(framing == expected);
473    }
474
475    #[test]
476    fn framing_matches_decoder_all_versions() {
477        for version in 3..=13 {
478            check_framing_roundtrip(&multi_partition_request(version), version);
479        }
480    }
481
482    #[test]
483    fn framing_captures_transactional_id_and_acks() {
484        let version = 9;
485        let req = ProduceRequest {
486            transactional_id: Some("my-txn".to_string()),
487            acks: -1,
488            timeout_ms: 7777,
489            topic_data: vec![TopicProduceData {
490                name: "t".to_string(),
491                partition_data: vec![PartitionProduceData {
492                    index: 3,
493                    records: None,
494                    ..Default::default()
495                }],
496                ..Default::default()
497            }],
498            ..Default::default()
499        };
500        let mut buf = BytesMut::new();
501        req.encode(&mut buf, version).unwrap();
502        let framing = produce_framing(buf.freeze(), version).unwrap();
503        let expected = ProduceFraming {
504            transactional_id: Some("my-txn".to_string()),
505            acks: -1,
506            timeout_ms: 7777,
507            topics: vec![ProduceFramingTopic {
508                name: "t".to_string(),
509                topic_id: Uuid::ZERO,
510                partitions: vec![PartitionRecordSlice {
511                    topic_index: 0,
512                    partition_index: 0,
513                    partition: 3,
514                    records: None,
515                }],
516            }],
517        };
518        assert2::assert!(framing == expected);
519    }
520
521    #[test]
522    fn empty_topic_list_yields_no_slices() {
523        let version = 9;
524        let req = ProduceRequest {
525            acks: 1,
526            timeout_ms: 0,
527            topic_data: vec![],
528            ..Default::default()
529        };
530        let mut buf = BytesMut::new();
531        req.encode(&mut buf, version).unwrap();
532        let slices = produce_record_slices(buf.freeze(), version).unwrap();
533        assert2::assert!(slices.is_empty());
534    }
535}