Skip to main content

crabka_protocol/owned/
fetch_response_plan.rs

1//! Write-plan serialization for `FetchResponse` (the broker's zero-copy fetch
2//! path, "Increment C").
3//!
4//! The generated [`FetchResponse::encode`] materializes the whole response —
5//! including every partition's records region — into one contiguous
6//! `BytesMut`, which forces the broker to copy the (potentially 100 KB+)
7//! records bytes through a throwaway buffer (`__rb_buf`), then again into the
8//! `Framed` codec's write buffer, then a third time to prepend the response
9//! header. This module produces the **same bytes** as an ordered list of
10//! [`FetchWriteOp`] segments so the broker can write the envelope inline and
11//! hand each partition's records region to the socket directly (vectored
12//! write, or — on Linux plaintext — `sendfile`), without copying the records
13//! payload.
14//!
15//! ## Byte-exactness invariant
16//!
17//! Concatenating the bytes of every [`FetchWriteOp`] produced by
18//! [`FetchResponse::write_plan`] yields **exactly** the bytes that
19//! [`FetchResponse::encode`] writes, for every Fetch version v4..=v18 (the
20//! generated `MIN_VERSION..=MAX_VERSION`), covering both the non-flexible
21//! (`INT32` length prefix, v4–v11) and flexible (`COMPACT_BYTES`
22//! unsigned-varint prefix + trailing tagged-field buffer, v12+) records-field
23//! encodings, multi-topic / multi-partition / multi-batch responses, and the
24//! truncated-trailing-batch case (the records `Bytes` is emitted verbatim, so
25//! a clipped final batch rides through untouched).
26//!
27//! This is enforced by `fetch_response_plan_matches_encode` in the test module
28//! below, which asserts `concat(write_plan) == encode` for `populated(version)`
29//! and hand-built multi-partition fixtures across all versions.
30
31use bytes::{Bytes, BytesMut};
32
33use crate::ProtocolError;
34use crate::primitives::array::{array_len_prefix_len, put_array_len, put_nullable_array_len};
35use crate::primitives::fixed::{put_i16, put_i32, put_i64};
36use crate::primitives::string_bytes::{put_compact_string, put_string};
37use crate::primitives::uuid::put_uuid;
38use crate::records::RecordsPayload;
39use crate::tagged_fields::{WriteTaggedFields, encode_to_bytes};
40
41use super::{FetchResponse, FetchableTopicResponse, PartitionData};
42
43/// One ordered segment of a `FetchResponse` write-plan.
44///
45/// The broker drains these in order: `Inline` bytes are written from
46/// userspace; a `Records` segment carries the partition's records payload,
47/// which the broker resolves to a vectored `write_all` (any payload kind) or
48/// — on Linux plaintext for `RecordsPayload::FileRegions` — a `sendfile`.
49///
50/// The `Records` op holds the [`RecordsPayload`] itself (a cheap refcounted
51/// clone for `Raw`/`FileRegions`) rather than pre-encoded bytes so the broker
52/// can choose its drain strategy. The records **length prefix** (`INT32` for
53/// non-flex, unsigned-varint for flex) is part of the **preceding** `Inline`
54/// segment, exactly as the wire format requires; `Records` is the raw payload
55/// region only.
56#[derive(Debug, Clone)]
57pub enum FetchWriteOp {
58    /// Userspace bytes — response header fragment, partition metadata,
59    /// records length prefixes, tagged-field trailers, array prefixes, etc.
60    Inline(Bytes),
61    /// A partition's records payload region (no length prefix). Drained by
62    /// the broker via vectored write or sendfile.
63    Records(RecordsPayload),
64}
65
66impl FetchWriteOp {
67    /// Byte length this op contributes to the frame body.
68    #[must_use]
69    pub fn len(&self) -> usize {
70        match self {
71            Self::Inline(b) => b.len(),
72            Self::Records(p) => p.payload_len(),
73        }
74    }
75
76    /// `true` when this op contributes zero bytes.
77    #[must_use]
78    pub fn is_empty(&self) -> bool {
79        self.len() == 0
80    }
81}
82
83impl FetchResponse {
84    /// Serialize this response as an ordered [`FetchWriteOp`] plan whose
85    /// concatenated bytes are **byte-identical** to [`FetchResponse::encode`]
86    /// at the same `version`.
87    ///
88    /// Only valid for the canonical (v4+) Fetch codec — the legacy
89    /// `kafka_3_6_2` path (v0–v3) does not get a plan (it down-converts and
90    /// stays on the copy path). The caller must gate on `version >= 4`.
91    ///
92    /// The records length prefix is written into the inline segment that
93    /// precedes each `Records` op, so resolving `Records` to its bytes and
94    /// concatenating reproduces the exact wire frame.
95    pub fn write_plan(&self, version: i16) -> Result<Vec<FetchWriteOp>, ProtocolError> {
96        debug_assert!(
97            version >= 4,
98            "write_plan is only valid for the canonical Fetch codec (v4+)"
99        );
100        let flex = version >= 12;
101        let mut ops: Vec<FetchWriteOp> = Vec::new();
102        // Running inline accumulator. Flushed (as an `Inline` op) whenever a
103        // `Records` op must be emitted, and once more at the end.
104        let mut buf = BytesMut::new();
105
106        // --- top-level header ---
107        if version >= 1 {
108            put_i32(&mut buf, self.throttle_time_ms);
109        }
110        if version >= 7 {
111            put_i16(&mut buf, self.error_code);
112        }
113        if version >= 7 {
114            put_i32(&mut buf, self.session_id);
115        }
116        // responses[] (always present, v0+)
117        put_array_len(&mut buf, self.responses.len(), flex);
118        for topic in &self.responses {
119            encode_topic(&mut buf, &mut ops, topic, version, flex)?;
120        }
121
122        // Top-level tagged fields (node_endpoints at tag 0, v16+). These come
123        // *after* all partition records, so they always land in the trailing
124        // inline segment. Mirror the generated encoder exactly.
125        if flex {
126            let mut tagged = WriteTaggedFields::new();
127            if !crate::codegen_helpers::is_default(&self.node_endpoints) {
128                let node_endpoints = &self.node_endpoints;
129                let payload = encode_to_bytes(
130                    {
131                        let prefix = array_len_prefix_len(node_endpoints.len(), flex);
132                        let body: usize = node_endpoints
133                            .iter()
134                            .map(|it| crate::Encode::encoded_len(it, version))
135                            .sum();
136                        prefix + body
137                    },
138                    |b| {
139                        put_array_len(b, node_endpoints.len(), flex);
140                        for it in node_endpoints {
141                            crate::Encode::encode(it, b, version)?;
142                        }
143                        Ok(())
144                    },
145                );
146                tagged.add(0, payload);
147            }
148            tagged.write(&mut buf, &self.unknown_tagged_fields);
149        }
150
151        if !buf.is_empty() {
152            ops.push(FetchWriteOp::Inline(buf.freeze()));
153        }
154        Ok(ops)
155    }
156}
157
158/// Encode one `FetchableTopicResponse` worth of bytes, splitting out each
159/// partition's records region as a `Records` op. Mirrors the generated
160/// `FetchableTopicResponse::encode` field-for-field.
161fn encode_topic(
162    buf: &mut BytesMut,
163    ops: &mut Vec<FetchWriteOp>,
164    topic: &FetchableTopicResponse,
165    version: i16,
166    flex: bool,
167) -> Result<(), ProtocolError> {
168    if (0..=12).contains(&version) {
169        if flex {
170            put_compact_string(buf, &topic.topic);
171        } else {
172            put_string(buf, &topic.topic);
173        }
174    }
175    if version >= 13 {
176        put_uuid(buf, topic.topic_id);
177    }
178    // partitions[]
179    put_array_len(buf, topic.partitions.len(), flex);
180    for partition in &topic.partitions {
181        encode_partition(buf, ops, partition, version, flex)?;
182    }
183    if flex {
184        // FetchableTopicResponse has no known tagged fields — only unknowns.
185        let tagged = WriteTaggedFields::new();
186        tagged.write(buf, &topic.unknown_tagged_fields);
187    }
188    Ok(())
189}
190
191/// Encode one `PartitionData`, emitting the records payload as a separate
192/// `Records` op (the records *length prefix* stays in the inline `buf`).
193/// Mirrors the generated `PartitionData::encode` field-for-field.
194fn encode_partition(
195    buf: &mut BytesMut,
196    ops: &mut Vec<FetchWriteOp>,
197    p: &PartitionData,
198    version: i16,
199    flex: bool,
200) -> Result<(), ProtocolError> {
201    put_i32(buf, p.partition_index);
202    put_i16(buf, p.error_code);
203    put_i64(buf, p.high_watermark);
204    if version >= 4 {
205        put_i64(buf, p.last_stable_offset);
206    }
207    if version >= 5 {
208        put_i64(buf, p.log_start_offset);
209    }
210    if version >= 4 {
211        let len = p.aborted_transactions.as_ref().map(Vec::len);
212        put_nullable_array_len(buf, len, flex);
213        if let Some(v) = &p.aborted_transactions {
214            for it in v {
215                crate::Encode::encode(it, buf, version)?;
216            }
217        }
218    }
219    if version >= 11 {
220        put_i32(buf, p.preferred_read_replica);
221    }
222    // records: nullable. Write the length prefix into `buf`, then emit the
223    // payload region as its own op (no copy of the records bytes).
224    match &p.records {
225        None => {
226            if flex {
227                // COMPACT_BYTES null == uvarint 0.
228                crate::primitives::varint::put_uvarint(buf, 0);
229            } else {
230                put_i32(buf, -1);
231            }
232        }
233        Some(payload) => {
234            let payload_len = payload.payload_len();
235            if flex {
236                // COMPACT_BYTES: uvarint(len + 1) prefix.
237                let prefixed = u32::try_from(payload_len + 1).map_err(|_| {
238                    ProtocolError::InvalidValue("records too large for compact len")
239                })?;
240                crate::primitives::varint::put_uvarint(buf, prefixed);
241            } else {
242                let len = i32::try_from(payload_len)
243                    .map_err(|_| ProtocolError::InvalidValue("records too large for i32 len"))?;
244                put_i32(buf, len);
245            }
246            // Flush the accumulated inline bytes (which now end exactly at the
247            // records length prefix), then emit the records region.
248            flush_inline(buf, ops);
249            ops.push(FetchWriteOp::Records(payload.clone()));
250        }
251    }
252    if flex {
253        // PartitionData tagged fields: diverging_epoch(0), current_leader(1),
254        // snapshot_id(2). These serialize AFTER the records bytes — they land
255        // in the next inline segment, which is correct.
256        let mut tagged = WriteTaggedFields::new();
257        if !crate::codegen_helpers::is_default(&p.diverging_epoch) {
258            let payload = encode_to_bytes(
259                crate::Encode::encoded_len(&p.diverging_epoch, version),
260                |b| {
261                    crate::Encode::encode(&p.diverging_epoch, b, version)?;
262                    Ok(())
263                },
264            );
265            tagged.add(0, payload);
266        }
267        if !crate::codegen_helpers::is_default(&p.current_leader) {
268            let payload = encode_to_bytes(
269                crate::Encode::encoded_len(&p.current_leader, version),
270                |b| {
271                    crate::Encode::encode(&p.current_leader, b, version)?;
272                    Ok(())
273                },
274            );
275            tagged.add(1, payload);
276        }
277        if !crate::codegen_helpers::is_default(&p.snapshot_id) {
278            let payload =
279                encode_to_bytes(crate::Encode::encoded_len(&p.snapshot_id, version), |b| {
280                    crate::Encode::encode(&p.snapshot_id, b, version)?;
281                    Ok(())
282                });
283            tagged.add(2, payload);
284        }
285        tagged.write(buf, &p.unknown_tagged_fields);
286    }
287    Ok(())
288}
289
290/// Flush the running inline accumulator as an `Inline` op (no-op if empty),
291/// leaving `buf` empty and ready to accumulate the next segment. Uses
292/// `split()` so the emitted `Bytes` shares the existing allocation and the
293/// continued accumulation reuses the buffer's spare capacity.
294#[inline]
295fn flush_inline(buf: &mut BytesMut, ops: &mut Vec<FetchWriteOp>) {
296    if !buf.is_empty() {
297        ops.push(FetchWriteOp::Inline(buf.split().freeze()));
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::Encode;
305    use crate::records::RecordsPayload;
306    use assert2::assert;
307    use bytes::BytesMut;
308
309    use super::super::{AbortedTransaction, FetchableTopicResponse, NodeEndpoint, PartitionData};
310
311    /// Concatenate a write-plan's ops into one byte buffer (resolving
312    /// `Records` ops via the payload's own `encode_to`, exactly as the broker
313    /// fallback path would).
314    fn concat_plan(ops: &[FetchWriteOp]) -> Vec<u8> {
315        let mut out = BytesMut::new();
316        for op in ops {
317            match op {
318                FetchWriteOp::Inline(b) => out.extend_from_slice(b),
319                FetchWriteOp::Records(p) => p.encode_to(&mut out).unwrap(),
320            }
321        }
322        out.to_vec()
323    }
324
325    fn raw_batch_bytes(base_offset: i64) -> Bytes {
326        // Build a real v2 batch and take its verbatim bytes (this is what the
327        // fetch pass-through stores in `RecordsPayload::Raw`).
328        use crate::records::{Record, RecordBatch};
329        let rb = RecordBatch {
330            base_offset,
331            records: vec![Record {
332                key: Some(Bytes::from_static(b"k")),
333                value: Some(Bytes::from_static(b"val")),
334                ..Default::default()
335            }],
336            ..RecordBatch::default()
337        };
338        let mut buf = BytesMut::new();
339        rb.encode(&mut buf).unwrap();
340        buf.freeze()
341    }
342
343    /// The load-bearing golden test: for a hand-built multi-topic,
344    /// multi-partition, multi-batch response, the write-plan's concatenated
345    /// bytes must equal the generated `encode` output, at every version.
346    fn assert_plan_matches_encode(resp: &FetchResponse, version: i16) {
347        let mut canonical = BytesMut::new();
348        resp.encode(&mut canonical, version).unwrap();
349        let plan = resp.write_plan(version).unwrap();
350        let assembled = concat_plan(&plan);
351        assert!(
352            &assembled[..] == &canonical[..],
353            "write_plan != encode at version {version}: plan_len={} encode_len={}",
354            assembled.len(),
355            canonical.len()
356        );
357        // The plan's total length must also equal encoded_len (the frame
358        // length prefix the broker writes is derived from encoded_len).
359        let plan_total: usize = plan.iter().map(FetchWriteOp::len).sum();
360        assert!(plan_total == resp.encoded_len(version));
361        assert!(plan_total == canonical.len());
362    }
363
364    fn multi_partition_response(version: i16) -> FetchResponse {
365        // Two batches concatenated in p0's records (multi-batch), one batch in
366        // p1, a null-records p2 (e.g. empty fetch), and a second topic with one
367        // partition — exercises every records-field shape.
368        let mut p0_records = BytesMut::new();
369        p0_records.extend_from_slice(&raw_batch_bytes(0));
370        p0_records.extend_from_slice(&raw_batch_bytes(1));
371        let p0 = PartitionData {
372            partition_index: 0,
373            high_watermark: 2,
374            last_stable_offset: 2,
375            log_start_offset: 0,
376            records: Some(RecordsPayload::Raw(p0_records.freeze())),
377            ..PartitionData::default()
378        };
379        let p1 = PartitionData {
380            partition_index: 1,
381            high_watermark: 1,
382            last_stable_offset: 1,
383            log_start_offset: 0,
384            // read_committed: Some(empty) aborted list to exercise the array
385            // path on v4+.
386            aborted_transactions: if version >= 4 {
387                Some(vec![AbortedTransaction {
388                    producer_id: 7,
389                    first_offset: 0,
390                    ..AbortedTransaction::default()
391                }])
392            } else {
393                None
394            },
395            records: Some(RecordsPayload::Raw(raw_batch_bytes(0))),
396            ..PartitionData::default()
397        };
398        let p2 = PartitionData {
399            partition_index: 2,
400            error_code: 0,
401            high_watermark: 0,
402            records: None,
403            ..PartitionData::default()
404        };
405        let topic0 = FetchableTopicResponse {
406            topic: if version <= 12 {
407                "topic-a".to_string()
408            } else {
409                String::new()
410            },
411            topic_id: if version >= 13 {
412                crate::primitives::uuid::Uuid([9u8; 16])
413            } else {
414                crate::primitives::uuid::Uuid([0u8; 16])
415            },
416            partitions: vec![p0, p1, p2],
417            ..FetchableTopicResponse::default()
418        };
419        let topic1 = FetchableTopicResponse {
420            topic: if version <= 12 {
421                "topic-b".to_string()
422            } else {
423                String::new()
424            },
425            topic_id: if version >= 13 {
426                crate::primitives::uuid::Uuid([3u8; 16])
427            } else {
428                crate::primitives::uuid::Uuid([0u8; 16])
429            },
430            partitions: vec![PartitionData {
431                partition_index: 0,
432                high_watermark: 1,
433                last_stable_offset: 1,
434                log_start_offset: 0,
435                records: Some(RecordsPayload::Raw(raw_batch_bytes(5))),
436                ..PartitionData::default()
437            }],
438            ..FetchableTopicResponse::default()
439        };
440        FetchResponse {
441            throttle_time_ms: 3,
442            error_code: 0,
443            session_id: 42,
444            responses: vec![topic0, topic1],
445            node_endpoints: if version >= 16 {
446                vec![NodeEndpoint {
447                    node_id: 1,
448                    host: "h".to_string(),
449                    port: 9092,
450                    rack: None,
451                    ..NodeEndpoint::default()
452                }]
453            } else {
454                Vec::new()
455            },
456            ..FetchResponse::default()
457        }
458    }
459
460    #[test]
461    fn fetch_response_plan_matches_encode_all_versions() {
462        // Cover non-flexible (4..=11, i32 prefix) and flexible (12..=18,
463        // uvarint prefix + tagged buffers) — the canonical codec range.
464        for version in 4..=18 {
465            assert_plan_matches_encode(&multi_partition_response(version), version);
466        }
467    }
468
469    #[test]
470    fn fetch_response_plan_matches_encode_populated() {
471        // The generated `populated(version)` fixture also exercises the
472        // diverging_epoch / current_leader / snapshot_id tagged fields (set on
473        // v12+) and node_endpoints (v16+).
474        for version in 4..=18 {
475            assert_plan_matches_encode(&FetchResponse::populated(version), version);
476        }
477    }
478
479    #[test]
480    fn fetch_response_plan_matches_encode_default() {
481        for version in 4..=18 {
482            assert_plan_matches_encode(&FetchResponse::default(), version);
483        }
484    }
485
486    #[test]
487    fn truncated_trailing_batch_rides_through_verbatim() {
488        // Kafka returns a partition's records ending in a partially-truncated
489        // final batch when the byte budget cuts mid-batch. `RecordsPayload::Raw`
490        // is emitted verbatim by the plan, so the clipped bytes must survive
491        // byte-for-byte and match `encode`.
492        for version in [4i16, 11, 12, 18] {
493            let mut records = BytesMut::new();
494            records.extend_from_slice(&raw_batch_bytes(0));
495            records.extend_from_slice(&raw_batch_bytes(1));
496            // 9 bytes of a partial trailing batch header.
497            records.extend_from_slice(&[0u8; 9]);
498            let resp = FetchResponse {
499                throttle_time_ms: 0,
500                session_id: 1,
501                responses: vec![FetchableTopicResponse {
502                    topic: if version <= 12 {
503                        "t".to_string()
504                    } else {
505                        String::new()
506                    },
507                    topic_id: if version >= 13 {
508                        crate::primitives::uuid::Uuid([1u8; 16])
509                    } else {
510                        crate::primitives::uuid::Uuid([0u8; 16])
511                    },
512                    partitions: vec![PartitionData {
513                        partition_index: 0,
514                        high_watermark: 2,
515                        last_stable_offset: 2,
516                        log_start_offset: 0,
517                        records: Some(RecordsPayload::Raw(records.freeze())),
518                        ..PartitionData::default()
519                    }],
520                    ..FetchableTopicResponse::default()
521                }],
522                ..FetchResponse::default()
523            };
524            assert_plan_matches_encode(&resp, version);
525        }
526    }
527
528    #[test]
529    fn empty_responses_plan_matches_encode() {
530        for version in 4..=18 {
531            let resp = FetchResponse {
532                throttle_time_ms: 0,
533                session_id: 0,
534                responses: Vec::new(),
535                ..FetchResponse::default()
536            };
537            assert_plan_matches_encode(&resp, version);
538        }
539    }
540}