use bytes::{Buf, Bytes};
use crate::{
ProtocolError,
primitives::{
array::get_array_len,
fixed::{get_i16, get_i32},
string_bytes::{get_compact_nullable_string_owned, get_nullable_string_owned},
uuid::{Uuid, get_uuid},
varint::get_uvarint,
},
tagged_fields::read_tagged_fields,
};
const FLEXIBLE_MIN: i16 = 9;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PartitionRecordSlice {
pub topic_index: usize,
pub partition_index: usize,
pub partition: i32,
pub records: Option<Bytes>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProduceFraming {
pub transactional_id: Option<String>,
pub acks: i16,
pub timeout_ms: i32,
pub topics: Vec<ProduceFramingTopic>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProduceFramingTopic {
pub name: String,
pub topic_id: Uuid,
pub partitions: Vec<PartitionRecordSlice>,
}
pub fn produce_record_slices(
mut body: Bytes,
version: i16,
) -> Result<Vec<PartitionRecordSlice>, ProtocolError> {
let flex = version >= FLEXIBLE_MIN;
let buf = &mut body;
if version >= 3 {
if flex {
get_compact_nullable_string_owned(buf)?;
} else {
get_nullable_string_owned(buf)?;
}
}
let _acks = get_i16(buf)?;
let _timeout_ms = get_i32(buf)?;
let mut out = Vec::new();
let topic_count = get_array_len(buf, flex)?;
for topic_index in 0..topic_count {
if version <= 12 {
if flex {
get_compact_nullable_string_owned(buf)?;
} else {
get_nullable_string_owned(buf)?;
}
}
if version >= 13 {
skip(buf, 16)?; }
let partition_count = get_array_len(buf, flex)?;
for partition_index in 0..partition_count {
let partition = get_i32(buf)?;
let records = read_nullable_bytes_slice(buf, flex)?;
out.push(PartitionRecordSlice {
topic_index,
partition_index,
partition,
records,
});
if flex {
read_tagged_fields(buf, |_tag, _payload| Ok(false))?;
}
}
if flex {
read_tagged_fields(buf, |_tag, _payload| Ok(false))?;
}
}
if flex {
read_tagged_fields(buf, |_tag, _payload| Ok(false))?;
}
Ok(out)
}
pub fn produce_framing(mut body: Bytes, version: i16) -> Result<ProduceFraming, ProtocolError> {
let flex = version >= FLEXIBLE_MIN;
let buf = &mut body;
let transactional_id = if version >= 3 {
if flex {
get_compact_nullable_string_owned(buf)?
} else {
get_nullable_string_owned(buf)?
}
} else {
None
};
let acks = get_i16(buf)?;
let timeout_ms = get_i32(buf)?;
let topic_count = get_array_len(buf, flex)?;
let mut topics = Vec::with_capacity(topic_count);
for topic_index in 0..topic_count {
let mut name = String::new();
if version <= 12 {
name = if flex {
get_compact_nullable_string_owned(buf)?.unwrap_or_default()
} else {
get_nullable_string_owned(buf)?.unwrap_or_default()
};
}
let mut topic_id = Uuid::ZERO;
if version >= 13 {
topic_id = get_uuid(buf)?;
}
let partition_count = get_array_len(buf, flex)?;
let mut partitions = Vec::with_capacity(partition_count);
for partition_index in 0..partition_count {
let partition = get_i32(buf)?;
let records = read_nullable_bytes_slice(buf, flex)?;
partitions.push(PartitionRecordSlice {
topic_index,
partition_index,
partition,
records,
});
if flex {
read_tagged_fields(buf, |_tag, _payload| Ok(false))?;
}
}
if flex {
read_tagged_fields(buf, |_tag, _payload| Ok(false))?;
}
topics.push(ProduceFramingTopic {
name,
topic_id,
partitions,
});
}
if flex {
read_tagged_fields(buf, |_tag, _payload| Ok(false))?;
}
Ok(ProduceFraming {
transactional_id,
acks,
timeout_ms,
topics,
})
}
fn read_nullable_bytes_slice(buf: &mut Bytes, flex: bool) -> Result<Option<Bytes>, ProtocolError> {
let len = if flex {
let raw = get_uvarint(buf)?;
if raw == 0 {
return Ok(None);
}
(raw - 1) as usize
} else {
let n = get_i32(buf)?;
if n < 0 {
return Ok(None);
}
usize::try_from(n).expect("non-negative i32 fits usize")
};
if buf.remaining() < len {
return Err(ProtocolError::UnexpectedEof {
needed: len - buf.remaining(),
});
}
Ok(Some(buf.copy_to_bytes(len)))
}
fn skip(buf: &mut Bytes, n: usize) -> Result<(), ProtocolError> {
if buf.remaining() < n {
return Err(ProtocolError::UnexpectedEof {
needed: n - buf.remaining(),
});
}
buf.advance(n);
Ok(())
}
#[cfg(test)]
mod tests {
use assert2::{assert, check};
use bytes::BytesMut;
use super::*;
use crate::{
Encode,
owned::produce_request::{PartitionProduceData, ProduceRequest, TopicProduceData},
records::{Record, RecordBatch, RecordsPayload},
};
fn batch_with_value(v: &[u8], base_offset: i64) -> RecordBatch {
RecordBatch {
base_offset,
records: vec![Record {
value: Some(Bytes::copy_from_slice(v)),
..Default::default()
}],
..Default::default()
}
}
fn check_roundtrip(req: &ProduceRequest, version: i16) {
let mut buf = BytesMut::new();
req.encode(&mut buf, version).unwrap();
let body = buf.freeze();
let slices = produce_record_slices(body.clone(), version).unwrap();
let mut expected: Vec<Option<Bytes>> = Vec::new();
for t in &req.topic_data {
for p in &t.partition_data {
let enc = p.records.as_ref().map(|rp| {
let mut b = BytesMut::new();
<RecordsPayload as Encode>::encode(rp, &mut b, version).unwrap();
b.freeze()
});
expected.push(enc);
}
}
assert!(
slices.len() == expected.len(),
"slice count mismatch: got {}, want {}",
slices.len(),
expected.len()
);
for (got, want) in slices.iter().zip(expected.iter()) {
match (&got.records, want) {
(Some(g), Some(w)) => assert!(&g[..] == &w[..], "records bytes differ"),
(None, None) => {}
_ => panic!("nullability mismatch"),
}
}
}
fn multi_partition_request(version: i16) -> ProduceRequest {
let topic = |name: &str, parts: usize| TopicProduceData {
name: if version <= 12 {
name.to_string()
} else {
String::new()
},
topic_id: if version >= 13 {
crate::primitives::uuid::Uuid([7u8; 16])
} else {
crate::primitives::uuid::Uuid::ZERO
},
partition_data: (0..parts)
.map(|i| PartitionProduceData {
index: i32::try_from(i).unwrap(),
records: Some(RecordsPayload::V2(vec![batch_with_value(
format!("topic-{name}-part-{i}").as_bytes(),
i64::try_from(i).unwrap(),
)])),
..Default::default()
})
.collect(),
..Default::default()
};
ProduceRequest {
transactional_id: None,
acks: -1,
timeout_ms: 1000,
topic_data: vec![topic("alpha", 2), topic("beta", 1)],
..Default::default()
}
}
#[test]
fn captures_match_decoder_non_flexible() {
for version in 3..=8 {
check_roundtrip(&multi_partition_request(version), version);
}
}
#[test]
fn captures_match_decoder_flexible() {
for version in 9..=13 {
check_roundtrip(&multi_partition_request(version), version);
}
}
#[test]
fn handles_null_records_field() {
let version = 9;
let req = ProduceRequest {
transactional_id: Some("txn".to_string()),
acks: 1,
timeout_ms: 0,
topic_data: vec![TopicProduceData {
name: "t".to_string(),
partition_data: vec![PartitionProduceData {
index: 0,
records: None,
..Default::default()
}],
..Default::default()
}],
..Default::default()
};
let mut buf = BytesMut::new();
req.encode(&mut buf, version).unwrap();
let slices = produce_record_slices(buf.freeze(), version).unwrap();
assert!(slices.len() == 1);
assert!(slices[0].records.is_none());
}
#[test]
fn captured_slice_is_zero_copy_view() {
let version = 9;
let req = multi_partition_request(version);
let mut buf = BytesMut::new();
req.encode(&mut buf, version).unwrap();
let body = buf.freeze();
let body_start = body.as_ptr() as usize;
let body_end = body_start + body.len();
let slices = produce_record_slices(body.clone(), version).unwrap();
let first = slices[0].records.as_ref().unwrap();
let ptr = first.as_ptr() as usize;
assert!(
ptr >= body_start && ptr < body_end,
"captured slice must point into the request frame (zero-copy)"
);
}
fn check_framing_roundtrip(req: &ProduceRequest, version: i16) {
let mut buf = BytesMut::new();
req.encode(&mut buf, version).unwrap();
let body = buf.freeze();
let framing = produce_framing(body.clone(), version).unwrap();
check!(framing.transactional_id == req.transactional_id);
check!(framing.acks == req.acks);
check!(framing.timeout_ms == req.timeout_ms);
assert!(framing.topics.len() == req.topic_data.len());
for (ft, rt) in framing.topics.iter().zip(req.topic_data.iter()) {
check!(ft.name == rt.name);
check!(ft.topic_id.0 == rt.topic_id.0);
assert!(ft.partitions.len() == rt.partition_data.len());
for (fp, rp) in ft.partitions.iter().zip(rt.partition_data.iter()) {
check!(fp.partition == rp.index);
let want = rp.records.as_ref().map(|rpld| {
let mut b = BytesMut::new();
<RecordsPayload as Encode>::encode(rpld, &mut b, version).unwrap();
b.freeze()
});
match (&fp.records, &want) {
(Some(g), Some(w)) => assert!(&g[..] == &w[..], "records bytes differ"),
(None, None) => {}
_ => panic!("nullability mismatch"),
}
}
}
}
#[test]
fn framing_matches_decoder_all_versions() {
for version in 3..=13 {
check_framing_roundtrip(&multi_partition_request(version), version);
}
}
#[test]
fn framing_captures_transactional_id_and_acks() {
let version = 9;
let req = ProduceRequest {
transactional_id: Some("my-txn".to_string()),
acks: -1,
timeout_ms: 7777,
topic_data: vec![TopicProduceData {
name: "t".to_string(),
partition_data: vec![PartitionProduceData {
index: 3,
records: None,
..Default::default()
}],
..Default::default()
}],
..Default::default()
};
let mut buf = BytesMut::new();
req.encode(&mut buf, version).unwrap();
let framing = produce_framing(buf.freeze(), version).unwrap();
let expected = ProduceFraming {
transactional_id: Some("my-txn".to_string()),
acks: -1,
timeout_ms: 7777,
topics: vec![ProduceFramingTopic {
name: "t".to_string(),
topic_id: Uuid::ZERO,
partitions: vec![PartitionRecordSlice {
topic_index: 0,
partition_index: 0,
partition: 3,
records: None,
}],
}],
};
assert!(framing == expected);
}
#[test]
fn empty_topic_list_yields_no_slices() {
let version = 9;
let req = ProduceRequest {
acks: 1,
timeout_ms: 0,
topic_data: vec![],
..Default::default()
};
let mut buf = BytesMut::new();
req.encode(&mut buf, version).unwrap();
let slices = produce_record_slices(buf.freeze(), version).unwrap();
assert!(slices.is_empty());
}
}