use std::collections::HashMap;
use kacrab_protocol::{
generated::{
ApiKey, ErrorCode, ListOffsetsPartition, ListOffsetsRequestData, ListOffsetsResponseData,
ListOffsetsTopic, OffsetForLeaderEpochRequestData, OffsetForLeaderEpochResponseData,
offset_for_leader_epoch_request::{OffsetForLeaderPartition, OffsetForLeaderTopic},
},
version::client_api_info,
};
use super::{
config::ConsumerRuntimeConfig,
error::{ConsumerError, Result},
subscription::FetchPosition,
};
use crate::{
common::TopicPartition,
wire::{ClusterMetadata, WireClient},
};
pub(super) const EARLIEST_TIMESTAMP: i64 = -2;
pub(super) const LATEST_TIMESTAMP: i64 = -1;
#[derive(Debug, Clone, Copy)]
pub(super) struct ResolvedOffset {
pub offset: i64,
pub leader_epoch: Option<i32>,
pub timestamp: i64,
}
impl ResolvedOffset {
pub(super) const fn into_position(self) -> FetchPosition {
FetchPosition::new(self.offset, self.leader_epoch)
}
}
pub(super) async fn list_offsets(
wire: &WireClient,
config: &ConsumerRuntimeConfig,
metadata: &ClusterMetadata,
entries: &[(TopicPartition, i64)],
) -> Result<HashMap<TopicPartition, ResolvedOffset>> {
if entries.is_empty() {
return Ok(HashMap::new());
}
let mut by_leader: HashMap<i32, Vec<(TopicPartition, i64)>> = HashMap::new();
for (partition, timestamp) in entries {
let leader = partition_leader(metadata, &partition.topic, partition.partition)
.ok_or_else(|| leader_unavailable(partition))?;
by_leader
.entry(leader)
.or_default()
.push((partition.clone(), *timestamp));
}
let version = client_api_info(ApiKey::ListOffsets).max_version;
let request_timeout_ms = i32::try_from(config.request_timeout.as_millis()).unwrap_or(i32::MAX);
let mut resolved = HashMap::with_capacity(entries.len());
for (leader, leader_entries) in by_leader {
let request = ListOffsetsRequestData {
replica_id: -1,
isolation_level: config.isolation_level.wire(),
topics: list_offsets_topics(&leader_entries),
timeout_ms: request_timeout_ms,
_unknown_tagged_fields: Vec::new(),
};
let response: ListOffsetsResponseData = wire
.send_to_broker(leader, ApiKey::ListOffsets, version, &request)
.await?;
for topic in response.topics {
for partition in topic.partitions {
let error = ErrorCode::from(partition.error_code);
if error.is_error() {
return Err(ConsumerError::broker(
"list_offsets",
error,
format!(
"{}-{} list_offsets failed",
topic.name.as_str(),
partition.partition_index
),
));
}
let _previous = resolved.insert(
TopicPartition::new(topic.name.as_str().to_owned(), partition.partition_index),
ResolvedOffset {
offset: partition.offset,
leader_epoch: (partition.leader_epoch >= 0)
.then_some(partition.leader_epoch),
timestamp: partition.timestamp,
},
);
}
}
}
Ok(resolved)
}
fn list_offsets_topics(entries: &[(TopicPartition, i64)]) -> Vec<ListOffsetsTopic> {
let mut topics: Vec<ListOffsetsTopic> = Vec::new();
for (partition, timestamp) in entries {
let wire_partition = ListOffsetsPartition {
partition_index: partition.partition,
current_leader_epoch: -1,
timestamp: *timestamp,
_unknown_tagged_fields: Vec::new(),
};
if let Some(topic) = topics
.iter_mut()
.find(|topic| topic.name.as_str() == partition.topic)
{
topic.partitions.push(wire_partition);
} else {
topics.push(ListOffsetsTopic {
name: partition.topic.clone().into(),
partitions: vec![wire_partition],
_unknown_tagged_fields: Vec::new(),
});
}
}
topics
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum PositionValidation {
Valid { leader_epoch: i32 },
Truncated {
offset: i64,
leader_epoch: Option<i32>,
},
}
pub(super) fn partition_leader_epoch(
metadata: &ClusterMetadata,
topic: &str,
partition: i32,
) -> Option<i32> {
metadata
.topic(topic)?
.partitions
.iter()
.find(|entry| entry.partition_index == partition)
.map(|entry| entry.leader_epoch)
.filter(|epoch| *epoch >= 0)
}
pub(super) async fn validate_offsets(
wire: &WireClient,
metadata: &ClusterMetadata,
entries: &[(TopicPartition, FetchPosition, i32)],
) -> Result<HashMap<TopicPartition, PositionValidation>> {
if entries.is_empty() {
return Ok(HashMap::new());
}
let mut by_leader: HashMap<i32, Vec<(TopicPartition, FetchPosition, i32)>> = HashMap::new();
for entry in entries {
let Some(leader) = partition_leader(metadata, &entry.0.topic, entry.0.partition) else {
continue;
};
by_leader.entry(leader).or_default().push(entry.clone());
}
let version = client_api_info(ApiKey::OffsetForLeaderEpoch).max_version;
let mut out = HashMap::new();
for (leader, leader_entries) in by_leader {
let request = OffsetForLeaderEpochRequestData {
replica_id: -1,
topics: leader_epoch_topics(&leader_entries),
_unknown_tagged_fields: Vec::new(),
};
let response: OffsetForLeaderEpochResponseData = wire
.send_to_broker(leader, ApiKey::OffsetForLeaderEpoch, version, &request)
.await?;
for topic in response.topics {
for partition in topic.partitions {
if ErrorCode::from(partition.error_code).is_error() {
continue;
}
let tp = TopicPartition::new(topic.topic.as_str().to_owned(), partition.partition);
let Some(position) = leader_entries
.iter()
.find(|entry| entry.0 == tp)
.map(|entry| entry.1)
else {
continue;
};
let outcome = classify_epoch_end(
partition.end_offset,
partition.leader_epoch,
position,
entry_current_epoch(&leader_entries, &tp),
);
let _previous = out.insert(tp, outcome);
}
}
}
Ok(out)
}
const fn classify_epoch_end(
end_offset: i64,
response_leader_epoch: i32,
position: FetchPosition,
current_epoch: i32,
) -> PositionValidation {
if end_offset >= 0 && end_offset < position.offset {
PositionValidation::Truncated {
offset: end_offset,
leader_epoch: if response_leader_epoch >= 0 {
Some(response_leader_epoch)
} else {
None
},
}
} else {
PositionValidation::Valid {
leader_epoch: current_epoch,
}
}
}
fn entry_current_epoch(
entries: &[(TopicPartition, FetchPosition, i32)],
partition: &TopicPartition,
) -> i32 {
entries
.iter()
.find(|entry| &entry.0 == partition)
.map_or(-1, |entry| entry.2)
}
fn leader_epoch_topics(
entries: &[(TopicPartition, FetchPosition, i32)],
) -> Vec<OffsetForLeaderTopic> {
let mut topics: Vec<OffsetForLeaderTopic> = Vec::new();
for (partition, position, current_epoch) in entries {
let wire_partition = OffsetForLeaderPartition {
partition: partition.partition,
current_leader_epoch: *current_epoch,
leader_epoch: position.leader_epoch.unwrap_or(-1),
_unknown_tagged_fields: Vec::new(),
};
if let Some(topic) = topics
.iter_mut()
.find(|topic| topic.topic.as_str() == partition.topic)
{
topic.partitions.push(wire_partition);
} else {
topics.push(OffsetForLeaderTopic {
topic: partition.topic.clone().into(),
partitions: vec![wire_partition],
_unknown_tagged_fields: Vec::new(),
});
}
}
topics
}
pub(super) fn partition_leader(
metadata: &ClusterMetadata,
topic: &str,
partition: i32,
) -> Option<i32> {
metadata
.topic(topic)?
.partitions
.iter()
.find(|entry| entry.partition_index == partition)
.map(|entry| entry.leader_id)
.filter(|leader| *leader >= 0)
}
fn leader_unavailable(partition: &TopicPartition) -> ConsumerError {
ConsumerError::broker(
"list_offsets",
ErrorCode::LeaderNotAvailable,
format!(
"no known leader for {}-{}",
partition.topic, partition.partition
),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epoch_end_below_position_is_truncation() {
let outcome = classify_epoch_end(40, 3, FetchPosition::new(50, Some(5)), 7);
assert_eq!(
outcome,
PositionValidation::Truncated {
offset: 40,
leader_epoch: Some(3),
}
);
}
#[test]
fn epoch_end_at_or_above_position_is_valid() {
let outcome = classify_epoch_end(50, 5, FetchPosition::new(50, Some(5)), 7);
assert_eq!(outcome, PositionValidation::Valid { leader_epoch: 7 });
let unknown = classify_epoch_end(-1, -1, FetchPosition::new(50, Some(5)), 7);
assert_eq!(unknown, PositionValidation::Valid { leader_epoch: 7 });
}
#[test]
fn leader_epoch_topics_group_partitions_and_carry_epochs() {
let entries = vec![
(
TopicPartition::new("t", 0),
FetchPosition::new(10, Some(4)),
6,
),
(TopicPartition::new("t", 1), FetchPosition::new(20, None), 6),
];
let topics = leader_epoch_topics(&entries);
assert_eq!(topics.len(), 1);
let partitions = &topics[0].partitions;
assert_eq!(partitions.len(), 2);
assert_eq!(partitions[0].current_leader_epoch, 6);
assert_eq!(partitions[0].leader_epoch, 4);
assert_eq!(partitions[1].leader_epoch, -1);
}
}