use std::sync::Arc;
use std::time::Duration;
use rdkafka::consumer::{Consumer, StreamConsumer};
use rdkafka::error::RDKafkaErrorCode;
use rdkafka::{Offset, TopicPartitionList};
use ruststream::{Seeker, Str};
use tokio::task;
use crate::broker::ConnState;
use crate::error::KafkaError;
#[cfg(feature = "testing")]
use crate::in_process::Member;
use crate::tracker::{CommitTracker, TrackedConsumer, TrackingContext};
const SEEK_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum KafkaPosition {
Earliest,
Latest,
Timestamp(i64),
Offset {
topic: Option<String>,
partition: i32,
offset: i64,
},
}
impl KafkaPosition {
#[must_use]
pub const fn earliest() -> Self {
Self::Earliest
}
#[must_use]
pub const fn latest() -> Self {
Self::Latest
}
#[must_use]
pub const fn offset(partition: i32, offset: i64) -> Self {
Self::Offset {
topic: None,
partition,
offset,
}
}
#[must_use]
pub fn topic_offset(topic: impl Into<String>, partition: i32, offset: i64) -> Self {
Self::Offset {
topic: Some(topic.into()),
partition,
offset,
}
}
#[must_use]
pub const fn timestamp(when_millis: i64) -> Self {
Self::Timestamp(when_millis)
}
}
#[derive(Clone)]
enum Repositioner {
Live {
consumer: Arc<TrackedConsumer>,
tracker: Arc<CommitTracker>,
},
#[cfg(feature = "testing")]
InProcess(Arc<Member>),
}
#[cfg(not(feature = "testing"))]
const _: () = assert!(size_of::<Repositioner>() == 2 * size_of::<usize>());
#[derive(Clone)]
pub struct KafkaSeeker {
inner: Repositioner,
connection: Arc<ConnState>,
subscription: Str,
}
impl std::fmt::Debug for KafkaSeeker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let transport = match &self.inner {
Repositioner::Live { .. } => "live",
#[cfg(feature = "testing")]
Repositioner::InProcess(_) => "in-process",
};
f.debug_struct("KafkaSeeker")
.field("transport", &transport)
.finish_non_exhaustive()
}
}
impl KafkaSeeker {
pub(crate) const fn new(
consumer: Arc<TrackedConsumer>,
tracker: Arc<CommitTracker>,
connection: Arc<ConnState>,
subscription: Str,
) -> Self {
Self {
inner: Repositioner::Live { consumer, tracker },
connection,
subscription,
}
}
#[cfg(feature = "testing")]
pub(crate) const fn in_process(
member: Arc<Member>,
connection: Arc<ConnState>,
subscription: Str,
) -> Self {
Self {
inner: Repositioner::InProcess(member),
connection,
subscription,
}
}
}
impl Seeker for KafkaSeeker {
type Position = KafkaPosition;
type Error = KafkaError;
async fn seek(&self, to: Self::Position) -> Result<(), Self::Error> {
self.connection.ensure_open(&self.subscription)?;
match &self.inner {
Repositioner::Live { consumer, tracker } => {
let consumer = Arc::clone(consumer);
let tracker = Arc::clone(tracker);
task::spawn_blocking(move || reposition(&consumer, &tracker, &to))
.await
.map_err(|err| KafkaError::Consume(Box::new(err)))?
}
#[cfg(feature = "testing")]
Repositioner::InProcess(member) => member.seek(&to),
}
}
}
fn reposition(
consumer: &StreamConsumer<TrackingContext>,
tracker: &CommitTracker,
to: &KafkaPosition,
) -> Result<(), KafkaError> {
let assignment = consumer.assignment().map_err(KafkaError::consume)?;
if assignment.count() == 0 {
consumer.context().hold_start(to.clone());
return Ok(());
}
let targets = apply_position(consumer, tracker, &assignment, to)?;
consumer.context().startup.moved(&targets);
Ok(())
}
fn apply_position<C>(
consumer: &C,
tracker: &CommitTracker,
assignment: &TopicPartitionList,
to: &KafkaPosition,
) -> Result<TopicPartitionList, KafkaError>
where
C: Consumer<TrackingContext>,
{
let targets = targets_in(consumer, assignment, to)?;
for element in targets.elements() {
tracker.reposition(&Str::from(element.topic()), element.partition());
}
clear_stored_offsets(consumer, &targets)?;
let outcome = consumer
.seek_partitions(targets.clone(), SEEK_TIMEOUT)
.map_err(KafkaError::consume)?;
for element in outcome.elements() {
element.error().map_err(KafkaError::consume)?;
}
Ok(targets)
}
pub(crate) fn position_assignment<C>(
consumer: &C,
tracker: &CommitTracker,
assignment: &mut TopicPartitionList,
to: &KafkaPosition,
) -> Result<(), KafkaError>
where
C: Consumer<TrackingContext>,
{
let targets = targets_in(consumer, assignment, to)?;
for element in targets.elements() {
tracker.reposition(&Str::from(element.topic()), element.partition());
assignment
.set_partition_offset(element.topic(), element.partition(), element.offset())
.map_err(KafkaError::consume)?;
}
Ok(())
}
fn targets_in<C>(
consumer: &C,
assignment: &TopicPartitionList,
to: &KafkaPosition,
) -> Result<TopicPartitionList, KafkaError>
where
C: Consumer<TrackingContext>,
{
let targets = resolve(consumer, assignment, to)?;
if targets.count() == 0 {
return Err(KafkaError::InvalidOptions(format!(
"{to:?} names no partition assigned to this consumer; a seek moves the partitions \
this instance holds, and its assignment is {}",
describe(assignment),
)));
}
Ok(targets)
}
pub(crate) fn clear_stored_offsets<C>(
consumer: &C,
targets: &TopicPartitionList,
) -> Result<(), KafkaError>
where
C: Consumer<TrackingContext>,
{
let mut cleared = TopicPartitionList::new();
for element in targets.elements() {
cleared
.add_partition_offset(element.topic(), element.partition(), Offset::Invalid)
.map_err(KafkaError::consume)?;
}
match consumer.store_offsets(&cleared) {
Ok(()) => Ok(()),
Err(err) if err.rdkafka_error_code() == Some(RDKafkaErrorCode::InvalidArgument) => Ok(()),
Err(err) => Err(KafkaError::consume(err)),
}
}
fn resolve<C>(
consumer: &C,
assignment: &TopicPartitionList,
to: &KafkaPosition,
) -> Result<TopicPartitionList, KafkaError>
where
C: Consumer<TrackingContext>,
{
let mut targets = TopicPartitionList::new();
match to {
KafkaPosition::Earliest | KafkaPosition::Latest => {
for element in assignment.elements() {
let (low, high) = consumer
.fetch_watermarks(element.topic(), element.partition(), SEEK_TIMEOUT)
.map_err(KafkaError::consume)?;
let offset = if matches!(to, KafkaPosition::Earliest) {
low
} else {
high
};
targets
.add_partition_offset(
element.topic(),
element.partition(),
Offset::Offset(offset),
)
.map_err(KafkaError::consume)?;
}
}
KafkaPosition::Timestamp(when_millis) => {
let mut query = TopicPartitionList::new();
for element in assignment.elements() {
query
.add_partition_offset(
element.topic(),
element.partition(),
Offset::Offset(*when_millis),
)
.map_err(KafkaError::consume)?;
}
let resolved = consumer
.offsets_for_times(query, SEEK_TIMEOUT)
.map_err(KafkaError::consume)?;
for element in resolved.elements() {
let offset = match element.offset() {
Offset::Offset(offset) if offset >= 0 => offset,
_ => {
consumer
.fetch_watermarks(element.topic(), element.partition(), SEEK_TIMEOUT)
.map_err(KafkaError::consume)?
.1
}
};
let offset = Offset::Offset(offset);
targets
.add_partition_offset(element.topic(), element.partition(), offset)
.map_err(KafkaError::consume)?;
}
}
KafkaPosition::Offset {
topic,
partition,
offset,
} => {
for element in assignment.elements() {
let named = topic.as_ref().is_none_or(|name| name == element.topic());
if named && element.partition() == *partition {
targets
.add_partition_offset(
element.topic(),
element.partition(),
Offset::Offset(*offset),
)
.map_err(KafkaError::consume)?;
}
}
}
}
Ok(targets)
}
pub(crate) fn describe(assignment: &TopicPartitionList) -> String {
if assignment.count() == 0 {
return "empty".to_owned();
}
assignment
.elements()
.iter()
.map(|element| format!("{}[{}]", element.topic(), element.partition()))
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constructors_build_the_variants_the_seeker_matches_on() {
assert_eq!(KafkaPosition::earliest(), KafkaPosition::Earliest);
assert_eq!(KafkaPosition::latest(), KafkaPosition::Latest);
assert_eq!(KafkaPosition::timestamp(42), KafkaPosition::Timestamp(42));
assert_eq!(
KafkaPosition::offset(3, 1_024),
KafkaPosition::Offset {
topic: None,
partition: 3,
offset: 1_024,
},
);
assert_eq!(
KafkaPosition::topic_offset("orders", 3, 1_024),
KafkaPosition::Offset {
topic: Some("orders".to_owned()),
partition: 3,
offset: 1_024,
},
);
}
}