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