use std::collections::HashMap;
use std::future::{Future as _, poll_fn};
use std::pin::pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, MutexGuard};
use std::task::{Poll, Waker};
use std::time::Duration;
use rdkafka::consumer::{Consumer, StreamConsumer};
use rdkafka::error::{KafkaError as RdKafkaError, RDKafkaErrorCode};
use rdkafka::{ClientConfig, Offset, TopicPartitionList};
use tokio::sync::oneshot;
use tracing::warn;
use crate::error::KafkaError;
use crate::record::{Cart, HeldRecord};
use crate::tracker::{CommitTracker, TrackedConsumer, TrackingContext};
const RESOLVE_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Reset {
Earliest,
Latest,
Error,
}
impl Reset {
pub(crate) fn of(config: &ClientConfig) -> Self {
match config.get("auto.offset.reset") {
Some("smallest" | "earliest" | "beginning") => Self::Earliest,
Some("error") => Self::Error,
_ => Self::Latest,
}
}
}
type PartitionKey = (String, i32);
pub(crate) fn resolve_starts<C>(
consumer: &C,
list: &mut TopicPartitionList,
remembered: &HashMap<PartitionKey, Offset>,
reset: Reset,
) -> Result<(), RdKafkaError>
where
C: Consumer<TrackingContext>,
{
let mut unresolved = TopicPartitionList::new();
for element in list.elements() {
if matches!(element.offset(), Offset::Invalid | Offset::Stored) {
unresolved.add_partition(element.topic(), element.partition());
}
}
if unresolved.count() > 0 {
let committed = consumer.committed_offsets(unresolved, RESOLVE_TIMEOUT)?;
for element in committed.elements() {
let key = (element.topic().to_owned(), element.partition());
let start = match element.offset() {
Offset::Offset(offset) if offset >= 0 => Offset::Offset(offset),
_ => match (remembered.get(&key), reset) {
(Some(start), _) => *start,
(None, Reset::Earliest) => Offset::Beginning,
(None, Reset::Latest) => Offset::End,
(None, Reset::Error) => continue,
},
};
list.set_partition_offset(element.topic(), element.partition(), start)?;
}
}
let mut ends = TopicPartitionList::new();
for element in list.elements() {
if matches!(element.offset(), Offset::Beginning | Offset::End) {
ends.add_partition_offset(element.topic(), element.partition(), element.offset())?;
}
}
if ends.count() > 0 {
let resolved = consumer.offsets_for_times(ends, RESOLVE_TIMEOUT)?;
for element in resolved.elements() {
element.error()?;
if let Offset::Offset(offset) = element.offset() {
list.set_partition_offset(
element.topic(),
element.partition(),
Offset::Offset(offset),
)?;
}
}
}
Ok(())
}
pub(crate) type Opened = Result<(), String>;
struct Kept {
released: bool,
opened: Option<oneshot::Sender<Opened>>,
starts: HashMap<PartitionKey, Offset>,
waker: Option<Waker>,
last_error: Option<String>,
}
pub(crate) struct Startup {
released: AtomicBool,
polling: Mutex<()>,
kept: Mutex<Kept>,
reset: Reset,
literals: Vec<String>,
patterns: bool,
}
impl std::fmt::Debug for Startup {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Startup")
.field("released", &self.released.load(Ordering::Relaxed))
.field("reset", &self.reset)
.finish_non_exhaustive()
}
}
impl Startup {
pub(crate) fn kept(names: &[String], reset: Reset) -> (Self, oneshot::Receiver<Opened>) {
let (opened, wait) = oneshot::channel();
let startup = Self {
released: AtomicBool::new(false),
polling: Mutex::new(()),
kept: Mutex::new(Kept {
released: false,
opened: Some(opened),
starts: HashMap::new(),
waker: None,
last_error: None,
}),
reset,
literals: names
.iter()
.filter(|name| !name.starts_with('^'))
.cloned()
.collect(),
patterns: names.iter().any(|name| name.starts_with('^')),
};
(startup, wait)
}
pub(crate) fn released() -> Self {
Self {
released: AtomicBool::new(true),
polling: Mutex::new(()),
kept: Mutex::new(Kept {
released: true,
opened: None,
starts: HashMap::new(),
waker: None,
last_error: None,
}),
reset: Reset::Latest,
literals: Vec::new(),
patterns: false,
}
}
fn lock(&self) -> MutexGuard<'_, Kept> {
self.kept.lock().expect("startup mutex poisoned")
}
fn lock_polling(&self) -> MutexGuard<'_, ()> {
self.polling.lock().expect("startup polling mutex poisoned")
}
pub(crate) fn last_error(&self) -> Option<String> {
self.lock().last_error.clone()
}
pub(crate) fn assigning<C>(&self, consumer: &C, partitions: &mut TopicPartitionList) -> bool
where
C: Consumer<TrackingContext>,
{
let (released, remembered) = {
let kept = self.lock();
(kept.released, kept.starts.clone())
};
if let Err(err) = resolve_starts(consumer, partitions, &remembered, self.reset) {
let reason = format!("the start positions of its partitions could not be read: {err}");
let opened = self.lock().opened.take();
if let Some(opened) = opened {
let _ = opened.send(Err(reason));
} else {
warn!(error = %err, "{reason}");
}
}
!released
}
pub(crate) fn assigned<C>(
&self,
consumer: &C,
partitions: &TopicPartitionList,
applied: Result<(), &RdKafkaError>,
kept: bool,
) where
C: Consumer<TrackingContext>,
{
if kept
&& partitions.count() > 0
&& let Err(err) = consumer.pause(partitions)
{
warn!(error = %err, "a kept subscription could not pause its partitions");
}
let mut state = self.lock();
for element in partitions.elements() {
if matches!(element.offset(), Offset::Offset(_)) {
state.starts.insert(
(element.topic().to_owned(), element.partition()),
element.offset(),
);
}
}
if let Some(opened) = state.opened.take() {
let _ = opened.send(
applied.map_err(|err| format!("librdkafka did not take its assignment: {err}")),
);
}
}
pub(crate) fn revoking<C>(&self, consumer: &C, partitions: &TopicPartitionList)
where
C: Consumer<TrackingContext>,
{
if self.lock().released || partitions.count() == 0 {
return;
}
if let Err(err) = consumer.resume(partitions) {
warn!(error = %err, "a kept subscription could not release its revoked partitions");
}
}
pub(crate) fn moved(&self, targets: &TopicPartitionList) {
let mut kept = self.lock();
if kept.released {
return;
}
for element in targets.elements() {
kept.starts.insert(
(element.topic().to_owned(), element.partition()),
element.offset(),
);
}
}
pub(crate) fn hand_over(&self, consumer: &TrackedConsumer, tracker: &CommitTracker) {
if self.released.load(Ordering::Acquire) {
return;
}
let _polling = self.lock_polling();
let mut kept = self.lock();
if kept.released {
return;
}
self.release_locked(&mut kept);
if let Err(err) = resume_from_starts(consumer, &kept.starts) {
tracker
.attention
.record_start_failure(KafkaError::InvalidOptions(format!(
"subscription {:?} could not resume its partitions from where it was opened: \
{err}",
consumer.context().subscription(),
)));
}
}
pub(crate) fn release(&self) {
if self.released.load(Ordering::Acquire) {
return;
}
let mut kept = self.lock();
self.release_locked(&mut kept);
}
fn release_locked(&self, kept: &mut Kept) {
kept.released = true;
self.released.store(true, Ordering::Release);
if let Some(waker) = kept.waker.take() {
waker.wake();
}
}
fn took<C>(&self, consumer: &C, topic: &str, partition: i32, offset: i64)
where
C: Consumer<TrackingContext>,
{
self.lock()
.starts
.entry((topic.to_owned(), partition))
.and_modify(|start| {
if matches!(start, Offset::Offset(known) if *known > offset) {
*start = Offset::Offset(offset);
}
})
.or_insert(Offset::Offset(offset));
let mut list = TopicPartitionList::new();
list.add_partition(topic, partition);
if let Err(err) = consumer.pause(&list) {
warn!(error = %err, topic, partition, "a kept subscription could not pause a partition");
}
}
fn reported<C>(&self, consumer: &C, err: &RdKafkaError)
where
C: Consumer<TrackingContext>,
{
let code = err.rdkafka_error_code();
let mut kept = self.lock();
kept.last_error = Some(err.to_string());
if kept.opened.is_none() {
return;
}
if code.is_some_and(refuses_the_join) {
if let Some(opened) = kept.opened.take() {
let _ = opened.send(Err(format!("the cluster refused its join: {err}")));
}
return;
}
if code != Some(RDKafkaErrorCode::UnknownTopicOrPartition) {
return;
}
drop(kept);
if self.patterns || !self.any_literal_exists(consumer) {
let opened = self.lock().opened.take();
if let Some(opened) = opened {
let _ = opened.send(Ok(()));
}
}
}
fn any_literal_exists<C>(&self, consumer: &C) -> bool
where
C: Consumer<TrackingContext>,
{
let Ok(metadata) = consumer.fetch_metadata(None, RESOLVE_TIMEOUT) else {
return true;
};
metadata.topics().iter().any(|topic| {
topic.error().is_none() && self.literals.iter().any(|name| name == topic.name())
})
}
}
fn refuses_the_join(code: RDKafkaErrorCode) -> bool {
matches!(
code,
RDKafkaErrorCode::InconsistentGroupProtocol
| RDKafkaErrorCode::InvalidGroupId
| RDKafkaErrorCode::GroupAuthorizationFailed
| RDKafkaErrorCode::TopicAuthorizationFailed
)
}
fn resume_from_starts(
consumer: &TrackedConsumer,
starts: &HashMap<PartitionKey, Offset>,
) -> Result<(), RdKafkaError> {
let assignment = consumer.assignment()?;
let mut seek = TopicPartitionList::new();
for element in assignment.elements() {
if let Some(start) = starts.get(&(element.topic().to_owned(), element.partition())) {
seek.add_partition_offset(element.topic(), element.partition(), *start)?;
}
}
if seek.count() > 0 {
let outcome = consumer.seek_partitions(seek, RESOLVE_TIMEOUT)?;
for element in outcome.elements() {
element.error()?;
}
}
consumer.resume(&assignment)
}
pub(crate) fn keep(cart: &Cart) {
let client: &StreamConsumer<TrackingContext> = &cart.consumer;
let startup = &client.context().startup;
let mut next = pin!(HeldRecord::next(cart));
futures::executor::block_on(poll_fn(|cx| {
let _polling = startup.lock_polling();
{
let mut kept = startup.lock();
if kept.released {
return Poll::Ready(());
}
kept.waker = Some(cx.waker().clone());
}
loop {
match next.as_mut().poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Ok(record)) => {
startup.took(client, record.topic(), record.partition(), record.offset());
}
Poll::Ready(Err(err)) => startup.reported(client, &err),
}
next.set(HeldRecord::next(cart));
}
}));
}