use ahash::AHashSet as HashSet;
use tracing::{trace, warn};
use crate::Offset;
use crate::PartitionId;
use crate::consumer::ConsumerRecord;
use crate::error::{KrafkaError, ProtocolErrorKind};
use crate::protocol::{RecordBatch, RecordBatchHeader};
pub(super) fn control_batch_is_abort(batch: &RecordBatch) -> bool {
const CONTROL_TYPE_ABORT: i16 = 0;
batch.records.first().is_some_and(|record| {
record.key.as_ref().is_some_and(|key| {
key.len() >= 4 && i16::from_be_bytes([key[2], key[3]]) == CONTROL_TYPE_ABORT
})
})
}
pub(super) struct PartitionDecodeOutcome {
pub(super) last_offset: Option<Offset>,
pub(super) last_epoch: i32,
pub(super) error: Option<KrafkaError>,
pub(super) corrupt: bool,
}
pub(super) fn advance_through_batch(
last_offset: &mut Option<Offset>,
last_epoch: &mut i32,
fetch_offset: Offset,
batch_end: Offset,
batch_epoch: i32,
) {
if batch_end < fetch_offset {
return;
}
if last_offset.is_none_or(|advanced| batch_end > advanced) {
*last_offset = Some(batch_end);
*last_epoch = batch_epoch;
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn decode_partition_batches(
topic_name: &str,
partition: PartitionId,
mut batch_buf: bytes::Bytes,
partition_fetch_offset: Offset,
mut aborted_txns: Vec<crate::protocol::AbortedTransaction>,
mut record_budget: Option<&mut usize>,
max_decompressed_size: usize,
records: &mut Vec<ConsumerRecord>,
) -> PartitionDecodeOutcome {
let mut outcome = PartitionDecodeOutcome {
last_offset: None,
last_epoch: -1,
error: None,
corrupt: false,
};
aborted_txns.sort_unstable_by_key(|at| at.first_offset);
let topic: std::sync::Arc<str> = std::sync::Arc::from(topic_name);
let mut aborted_txns_iter = aborted_txns.iter().peekable();
let mut aborted_producers: HashSet<i64> = HashSet::new();
let mut budget_exhausted = false;
while batch_buf.len() >= 12 {
if record_budget.as_deref().is_some_and(|b| *b == 0) {
break;
}
if let Ok(header) = RecordBatchHeader::peek(&batch_buf)
&& header.attributes.is_transactional
&& !header.attributes.is_control_batch
&& batch_buf.len() >= header.total_size()
{
while aborted_txns_iter
.peek()
.is_some_and(|at| at.first_offset <= header.base_offset)
{
if let Some(at) = aborted_txns_iter.next() {
aborted_producers.insert(at.producer_id);
}
}
if aborted_producers.contains(&header.producer_id) {
advance_through_batch(
&mut outcome.last_offset,
&mut outcome.last_epoch,
partition_fetch_offset,
header.last_offset(),
header.partition_leader_epoch,
);
let _ = batch_buf.split_to(header.total_size());
continue;
}
}
match RecordBatch::decode_with_limit(&mut batch_buf, max_decompressed_size) {
Ok(batch) => {
let batch_epoch = batch.partition_leader_epoch;
let batch_end = batch
.base_offset
.saturating_add(batch.last_offset_delta as i64);
while aborted_txns_iter
.peek()
.is_some_and(|at| at.first_offset <= batch.base_offset)
{
if let Some(at) = aborted_txns_iter.next() {
aborted_producers.insert(at.producer_id);
}
}
if batch.attributes.is_control_batch {
if control_batch_is_abort(&batch) {
aborted_producers.remove(&batch.producer_id);
}
advance_through_batch(
&mut outcome.last_offset,
&mut outcome.last_epoch,
partition_fetch_offset,
batch_end,
batch_epoch,
);
continue;
}
if batch.attributes.is_transactional
&& aborted_producers.contains(&batch.producer_id)
{
advance_through_batch(
&mut outcome.last_offset,
&mut outcome.last_epoch,
partition_fetch_offset,
batch_end,
batch_epoch,
);
continue;
}
if batch.records.is_empty() {
advance_through_batch(
&mut outcome.last_offset,
&mut outcome.last_epoch,
partition_fetch_offset,
batch_end,
batch_epoch,
);
continue;
}
let allowed = record_budget.as_deref().copied().unwrap_or(usize::MAX);
let mut used = 0usize;
for record in batch.records.into_iter() {
let record_offset =
batch.base_offset.saturating_add(record.offset_delta as i64);
if record_offset < partition_fetch_offset {
continue;
}
if used == allowed {
budget_exhausted = true;
break;
}
used += 1;
records.push(ConsumerRecord {
topic: std::sync::Arc::clone(&topic),
partition,
offset: record_offset,
timestamp: batch.base_timestamp.saturating_add(record.timestamp_delta),
timestamp_type: batch.attributes.timestamp_type,
key: record.key,
value: record.value,
headers: crate::consumer::headers_from_wire(record.headers),
leader_epoch: Some(batch_epoch),
delivery_count: None,
});
outcome.last_offset = Some(record_offset);
outcome.last_epoch = batch_epoch;
}
if let Some(budget) = record_budget.as_deref_mut() {
*budget -= used;
}
if budget_exhausted {
break;
}
advance_through_batch(
&mut outcome.last_offset,
&mut outcome.last_epoch,
partition_fetch_offset,
batch_end,
batch_epoch,
);
}
Err(e) => {
let made_progress = outcome.last_offset.is_some();
let truncated_tail =
e.protocol_error_kind() == Some(ProtocolErrorKind::TruncatedFrame);
if made_progress && truncated_tail {
trace!(
topic = %topic_name,
partition,
"Trailing record batch truncated by the fetch size limit"
);
break;
}
outcome.corrupt = true;
if made_progress {
warn!(
topic = %topic_name,
partition,
fetch_offset = partition_fetch_offset,
error = %e,
"Corrupt record batch after a decodable prefix; \
delivering the prefix and stopping here"
);
break;
}
warn!(
topic = %topic_name,
partition,
fetch_offset = partition_fetch_offset,
error = %e,
"Record batch at the fetch position could not be decoded; \
the partition cannot advance"
);
outcome.error = Some(e);
break;
}
}
}
outcome
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn control_batch_type_is_read_from_the_marker_key() {
use crate::protocol::{Record, RecordBatch};
let marker = |control_type: i16| {
let mut key = bytes::BytesMut::new();
bytes::BufMut::put_i16(&mut key, 0); bytes::BufMut::put_i16(&mut key, control_type);
let mut batch = RecordBatch::new();
batch.attributes.is_control_batch = true;
batch.add_record(Record {
attributes: 0,
timestamp_delta: 0,
offset_delta: 0,
key: Some(key.freeze()),
value: Some(bytes::Bytes::new()),
headers: Vec::new(),
});
batch
};
assert!(control_batch_is_abort(&marker(0)), "type 0 is ABORT");
assert!(!control_batch_is_abort(&marker(1)), "type 1 is COMMIT");
let mut short = RecordBatch::new();
short.attributes.is_control_batch = true;
short.add_record(Record {
attributes: 0,
timestamp_delta: 0,
offset_delta: 0,
key: Some(bytes::Bytes::from_static(&[0, 0])),
value: None,
headers: Vec::new(),
});
assert!(!control_batch_is_abort(&short));
let mut keyless = RecordBatch::new();
keyless.attributes.is_control_batch = true;
assert!(!control_batch_is_abort(&keyless));
}
const WALK_EPOCH: i32 = 7;
fn walk_record(offset_delta: i32) -> crate::protocol::Record {
crate::protocol::Record {
attributes: 0,
timestamp_delta: 0,
offset_delta,
key: None,
value: Some(bytes::Bytes::from_static(b"v")),
headers: Vec::new(),
}
}
fn walk_batch(base_offset: i64, last_offset_delta: i32, deltas: &[i32]) -> RecordBatch {
let mut batch = RecordBatch::new();
batch.base_offset = base_offset;
batch.last_offset_delta = last_offset_delta;
batch.partition_leader_epoch = WALK_EPOCH;
for &delta in deltas {
batch.add_record(walk_record(delta));
}
batch
}
fn walk(
batches: &[RecordBatch],
fetch_offset: Offset,
budget: Option<&mut usize>,
) -> (Vec<ConsumerRecord>, PartitionDecodeOutcome) {
let mut buf = bytes::BytesMut::new();
for batch in batches {
buf.extend_from_slice(&batch.encode().expect("encode batch"));
}
let mut records = Vec::new();
let outcome = decode_partition_batches(
"walk-topic",
0,
buf.freeze(),
fetch_offset,
Vec::new(),
budget,
RecordBatch::MAX_DECOMPRESSED_SIZE,
&mut records,
);
(records, outcome)
}
#[test]
fn compaction_emptied_batch_is_skipped_not_a_stall() {
let (records, outcome) = walk(&[walk_batch(1000, 4, &[])], 1000, None);
assert!(records.is_empty(), "an emptied batch delivers nothing");
assert_eq!(
outcome.last_offset,
Some(1004),
"the position must advance through the emptied batch's span, \
or the next fetch re-reads it forever"
);
assert_eq!(outcome.last_epoch, WALK_EPOCH);
assert!(outcome.error.is_none());
assert!(!outcome.corrupt);
}
#[test]
fn compaction_emptied_batch_does_not_discard_the_rest_of_the_response() {
let batches = [walk_batch(1000, 4, &[]), walk_batch(1005, 1, &[0, 1])];
for mut budget in [None, Some(512usize)] {
let (records, outcome) = walk(&batches, 1000, budget.as_mut());
let offsets: Vec<Offset> = records.iter().map(|r| r.offset).collect();
assert_eq!(offsets, vec![1005, 1006]);
assert_eq!(outcome.last_offset, Some(1006));
if let Some(budget) = budget {
assert_eq!(
budget, 510,
"the emptied batch must not consume budget slots"
);
}
}
}
#[test]
fn drained_compacted_batch_advances_past_its_removed_tail() {
let (records, outcome) = walk(&[walk_batch(100, 9, &[0, 3, 5])], 100, None);
let offsets: Vec<Offset> = records.iter().map(|r| r.offset).collect();
assert_eq!(offsets, vec![100, 103, 105]);
assert!(records.iter().all(|r| r.leader_epoch == Some(WALK_EPOCH)));
assert_eq!(
outcome.last_offset,
Some(109),
"a drained batch advances to its span end (Java client's nextFetchOffset)"
);
}
#[test]
fn position_inside_a_compacted_batch_escapes_it() {
let (records, outcome) = walk(&[walk_batch(100, 9, &[0, 3, 5])], 106, None);
assert!(
records.is_empty(),
"everything in the batch was already delivered"
);
assert_eq!(
outcome.last_offset,
Some(109),
"the position must escape the straddling batch"
);
assert_eq!(outcome.last_epoch, WALK_EPOCH);
}
#[test]
fn budget_cut_batch_re_fetches_its_remainder() {
let mut budget = 2usize;
let (records, outcome) = walk(&[walk_batch(100, 2, &[0, 1, 2])], 100, Some(&mut budget));
let offsets: Vec<Offset> = records.iter().map(|r| r.offset).collect();
assert_eq!(offsets, vec![100, 101]);
assert_eq!(
outcome.last_offset,
Some(101),
"a budget-cut batch must not be skipped over"
);
assert_eq!(budget, 0);
}
#[test]
fn batch_below_the_fetch_position_cannot_rewind_it() {
let (records, outcome) = walk(&[walk_batch(100, 4, &[])], 200, None);
assert!(records.is_empty());
assert_eq!(
outcome.last_offset, None,
"no position update at all beats a backwards one"
);
}
#[test]
fn truncated_tail_after_a_skipped_batch_is_benign() {
let mut buf = bytes::BytesMut::new();
buf.extend_from_slice(&walk_batch(1000, 4, &[]).encode().expect("encode"));
let tail = walk_batch(1005, 1, &[0, 1]).encode().expect("encode");
buf.extend_from_slice(&tail[..tail.len() - 5]);
let mut records = Vec::new();
let outcome = decode_partition_batches(
"walk-topic",
0,
buf.freeze(),
1000,
Vec::new(),
None,
RecordBatch::MAX_DECOMPRESSED_SIZE,
&mut records,
);
assert!(records.is_empty());
assert_eq!(outcome.last_offset, Some(1004));
assert!(outcome.error.is_none(), "a truncated tail is not a fault");
assert!(!outcome.corrupt);
}
#[test]
fn corrupt_first_batch_reports_a_fault() {
let mut bad = bytes::BytesMut::from(
walk_batch(1000, 1, &[0, 1])
.encode()
.expect("encode")
.as_ref(),
);
bad[30] ^= 0xFF;
let mut records = Vec::new();
let outcome = decode_partition_batches(
"walk-topic",
0,
bad.freeze(),
1000,
Vec::new(),
None,
RecordBatch::MAX_DECOMPRESSED_SIZE,
&mut records,
);
assert!(records.is_empty());
assert_eq!(outcome.last_offset, None);
assert!(
outcome.error.is_some(),
"an undecodable position is a reported fault"
);
assert!(outcome.corrupt);
}
#[test]
fn aborted_transaction_batches_are_filtered_but_advance_the_position() {
let mut aborted_data = walk_batch(100, 2, &[0, 1, 2]);
aborted_data.attributes.is_transactional = true;
aborted_data.producer_id = 9;
let mut marker_key = bytes::BytesMut::new();
bytes::BufMut::put_i16(&mut marker_key, 0); bytes::BufMut::put_i16(&mut marker_key, 0); let mut abort_marker = walk_batch(103, 0, &[]);
abort_marker.attributes.is_control_batch = true;
abort_marker.producer_id = 9;
abort_marker.add_record(crate::protocol::Record {
attributes: 0,
timestamp_delta: 0,
offset_delta: 0,
key: Some(marker_key.freeze()),
value: Some(bytes::Bytes::new()),
headers: Vec::new(),
});
let mut committed_data = walk_batch(104, 1, &[0, 1]);
committed_data.attributes.is_transactional = true;
committed_data.producer_id = 9;
let mut buf = bytes::BytesMut::new();
for batch in [&aborted_data, &abort_marker, &committed_data] {
buf.extend_from_slice(&batch.encode().expect("encode"));
}
let mut records = Vec::new();
let outcome = decode_partition_batches(
"walk-topic",
0,
buf.freeze(),
100,
vec![crate::protocol::AbortedTransaction {
producer_id: 9,
first_offset: 100,
}],
None,
RecordBatch::MAX_DECOMPRESSED_SIZE,
&mut records,
);
let offsets: Vec<Offset> = records.iter().map(|r| r.offset).collect();
assert_eq!(
offsets,
vec![104, 105],
"only the committed transaction is delivered"
);
assert_eq!(outcome.last_offset, Some(105));
}
#[test]
fn advance_through_batch_never_moves_backwards() {
let mut last_offset = Some(500);
let mut last_epoch = 3;
advance_through_batch(&mut last_offset, &mut last_epoch, 100, 400, 9);
assert_eq!(last_offset, Some(500));
assert_eq!(last_epoch, 3);
advance_through_batch(&mut last_offset, &mut last_epoch, 700, 600, 9);
assert_eq!(last_offset, Some(500));
advance_through_batch(&mut last_offset, &mut last_epoch, 100, 600, 9);
assert_eq!(last_offset, Some(600));
assert_eq!(last_epoch, 9);
}
#[test]
fn aborted_compressed_batch_is_skipped_without_decompressing() {
let mut aborted = walk_batch(100, 2, &[0, 1, 2]);
aborted.attributes.is_transactional = true;
aborted.attributes.compression = crate::protocol::Compression::Gzip;
aborted.producer_id = 9;
let wire = aborted.encode().expect("encode");
let decompressions = || crate::protocol::DECOMPRESSIONS.with(std::cell::Cell::get);
let before = decompressions();
let mut records = Vec::new();
let outcome = decode_partition_batches(
"walk-topic",
0,
wire.clone(),
100,
vec![crate::protocol::AbortedTransaction {
producer_id: 9,
first_offset: 100,
}],
None,
RecordBatch::MAX_DECOMPRESSED_SIZE,
&mut records,
);
assert!(records.is_empty());
assert_eq!(outcome.last_offset, Some(102), "the span is still skipped");
assert_eq!(decompressions() - before, 0, "never decompressed");
let before = decompressions();
let mut records = Vec::new();
decode_partition_batches(
"walk-topic",
0,
wire,
100,
Vec::new(),
None,
RecordBatch::MAX_DECOMPRESSED_SIZE,
&mut records,
);
assert_eq!(records.len(), 3, "read_uncommitted delivers the records");
assert_eq!(decompressions() - before, 1);
}
}