use std::collections::{HashMap, VecDeque};
use std::ops::Deref;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use rdkafka::consumer::{
BaseConsumer, Consumer as _, ConsumerContext, RebalanceProtocol, StreamConsumer,
};
use rdkafka::error::{KafkaError as RdKafkaError, RDKafkaErrorCode};
use rdkafka::types::RDKafkaRespErr;
use rdkafka::{ClientContext, Offset, TopicPartitionList};
use ruststream::Str;
use tokio::sync::Notify;
use tokio::sync::futures::Notified;
use tracing::warn;
use crate::attention::Attention;
use crate::error::KafkaError;
use crate::seek::{self, KafkaPosition};
use crate::startup::Startup;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct PartitionSlot {
index: usize,
generation: u64,
}
#[derive(Debug, Default)]
struct Assigned {
states: Vec<PartitionState>,
slots: HashMap<(Str, i32), usize>,
last: Option<(Str, i32, usize)>,
}
impl Assigned {
fn slot(&mut self, topic: &Str, partition: i32) -> usize {
if let Some((name, known, index)) = &self.last
&& *known == partition
&& name == topic
{
return *index;
}
let next = self.states.len();
let index = *self.slots.entry((topic.clone(), partition)).or_insert(next);
if index == next {
self.states
.push(PartitionState::new(topic.clone(), partition));
}
self.last = Some((topic.clone(), partition, index));
index
}
}
#[derive(Debug, Default)]
struct Outstanding(VecDeque<i64>);
impl Outstanding {
fn insert(&mut self, offset: i64) {
debug_assert!(self.0.back().is_none_or(|last| *last < offset));
self.0.push_back(offset);
}
fn remove(&mut self, offset: i64) -> bool {
if self.0.front() == Some(&offset) {
self.0.pop_front();
return true;
}
match self.0.binary_search(&offset) {
Ok(index) => {
self.0.remove(index);
true
}
Err(_) => false,
}
}
fn lowest(&self) -> Option<i64> {
self.0.front().copied()
}
fn clear(&mut self) {
self.0.clear();
}
}
#[derive(Debug)]
pub(crate) struct StoreTarget<'a> {
topic: &'a Str,
partition: i32,
list: &'a mut Option<TopicPartitionList>,
}
impl StoreTarget<'_> {
#[cfg(feature = "testing")]
pub(crate) fn topic(&self) -> &str {
self.topic
}
#[cfg(feature = "testing")]
pub(crate) const fn partition(&self) -> i32 {
self.partition
}
pub(crate) fn list(&mut self, position: i64) -> Result<&TopicPartitionList, RdKafkaError> {
let next = Offset::Offset(position + 1);
if let Some(list) = self.list {
list.set_all_offsets(next)?;
return Ok(list);
}
let mut list = TopicPartitionList::with_capacity(1);
list.add_partition_offset(self.topic, self.partition, next)?;
Ok(self.list.insert(list))
}
}
#[derive(Debug)]
struct PartitionState {
topic: Str,
partition: i32,
outstanding: Outstanding,
store_list: Option<TopicPartitionList>,
highest: Option<i64>,
stored: Option<i64>,
generation: u64,
}
impl PartitionState {
fn new(topic: Str, partition: i32) -> Self {
Self {
topic,
partition,
outstanding: Outstanding::default(),
store_list: None,
highest: None,
stored: None,
generation: 0,
}
}
fn replay_from(&mut self, offset: i64) {
self.outstanding.clear();
self.outstanding.insert(offset);
self.highest = Some(offset);
self.stored = None;
}
}
#[derive(Debug, Default)]
pub(crate) struct CommitTracker {
partitions: Mutex<Assigned>,
advanced: Notify,
watched: AtomicBool,
repositioned: AtomicBool,
repositions: Notify,
pub(crate) attention: Attention,
}
impl CommitTracker {
#[allow(clippy::significant_drop_tightening)]
pub(crate) fn delivered(&self, topic: &Str, partition: i32, offset: i64) -> PartitionSlot {
let mut partitions = self
.partitions
.lock()
.expect("commit tracker mutex poisoned");
let index = partitions.slot(topic, partition);
let state = &mut partitions.states[index];
match state.highest {
Some(highest) if offset <= highest => state.replay_from(offset),
_ => {
state.highest = Some(offset);
state.outstanding.insert(offset);
}
}
PartitionSlot {
index,
generation: state.generation,
}
}
pub(crate) fn reposition(&self, topic: &Str, partition: i32) {
let mut partitions = self
.partitions
.lock()
.expect("commit tracker mutex poisoned");
let index = partitions.slot(topic, partition);
let state = &mut partitions.states[index];
state.generation += 1;
state.outstanding.clear();
state.store_list = None;
state.highest = None;
state.stored = None;
drop(partitions);
self.repositioned.store(true, Ordering::Release);
self.repositions.notify_waiters();
}
pub(crate) fn is_current(&self, slot: PartitionSlot) -> bool {
let partitions = self
.partitions
.lock()
.expect("commit tracker mutex poisoned");
partitions.states[slot.index].generation == slot.generation
}
pub(crate) fn take_repositioned(&self) -> bool {
self.repositioned.swap(false, Ordering::AcqRel)
}
pub(crate) fn is_repositioned(&self) -> bool {
self.repositioned.load(Ordering::Acquire)
}
pub(crate) fn reposition_waiter(&self) -> Notified<'_> {
self.repositions.notified()
}
#[allow(clippy::significant_drop_tightening)]
pub(crate) fn settle_with<E>(
&self,
slot: PartitionSlot,
offset: i64,
store: impl FnOnce(i64, StoreTarget<'_>) -> Result<(), E>,
) -> Result<(), E> {
let mut partitions = self
.partitions
.lock()
.expect("commit tracker mutex poisoned");
let state = &mut partitions.states[slot.index];
if state.generation != slot.generation {
return Ok(());
}
if !state.outstanding.remove(offset) {
return Ok(());
}
let Some(highest) = state.highest else {
return Ok(());
};
let position = state
.outstanding
.lowest()
.map_or(highest, |lowest| lowest - 1);
if position < 0 || state.stored.is_some_and(|stored| position <= stored) {
return Ok(());
}
store(
position,
StoreTarget {
topic: &state.topic,
partition: state.partition,
list: &mut state.store_list,
},
)?;
state.stored = Some(position);
if self.watched.load(Ordering::Acquire) {
self.advanced.notify_waiters();
}
Ok(())
}
pub(crate) fn stored_position(&self, topic: &Str, partition: i32) -> Option<i64> {
let partitions = self
.partitions
.lock()
.expect("commit tracker mutex poisoned");
partitions
.slots
.get(&(topic.clone(), partition))
.and_then(|index| partitions.states[*index].stored)
}
pub(crate) fn covers(&self, topic: &Str, partition: i32) -> bool {
let partitions = self
.partitions
.lock()
.expect("commit tracker mutex poisoned");
partitions
.slots
.get(&(topic.clone(), partition))
.is_some_and(|index| partitions.states[*index].highest.is_some())
}
pub(crate) fn stored_positions(&self) -> Vec<((Str, i32), i64)> {
let partitions = self
.partitions
.lock()
.expect("commit tracker mutex poisoned");
partitions
.states
.iter()
.filter_map(|state| {
state
.stored
.map(|stored| ((state.topic.clone(), state.partition), stored))
})
.collect()
}
pub(crate) fn advance_waiter(&self) -> Notified<'_> {
self.watched.store(true, Ordering::Release);
self.advanced.notified()
}
fn release_store_lists(&self) {
let mut partitions = self
.partitions
.lock()
.expect("commit tracker mutex poisoned");
for state in &mut partitions.states {
state.store_list = None;
}
}
fn revoke(&self, revoked: &TopicPartitionList) {
for element in revoked.elements() {
self.reposition(&Str::from(element.topic()), element.partition());
}
}
}
pub(crate) struct TrackingContext {
tracker: Arc<CommitTracker>,
subscription: String,
start: Mutex<Option<KafkaPosition>>,
pub(crate) startup: Startup,
}
impl TrackingContext {
pub(crate) fn new(
tracker: Arc<CommitTracker>,
subscription: impl Into<String>,
startup: Startup,
) -> Self {
Self {
tracker,
subscription: subscription.into(),
start: Mutex::new(None),
startup,
}
}
pub(crate) fn subscription(&self) -> &str {
&self.subscription
}
pub(crate) fn hold_start(&self, position: KafkaPosition) {
*self.start.lock().expect("start position mutex poisoned") = Some(position);
}
fn take_start(&self) -> Option<KafkaPosition> {
self.start
.lock()
.expect("start position mutex poisoned")
.take()
}
fn open_at_start(&self, consumer: &BaseConsumer<Self>, partitions: &mut TopicPartitionList) {
let Some(position) = self.take_start() else {
return;
};
if let Err(err) = seek::position_assignment(consumer, &self.tracker, partitions, &position)
{
self.tracker
.attention
.record_start_failure(KafkaError::InvalidOptions(format!(
"subscription {:?} could not be opened at its start position: {err}",
self.subscription,
)));
}
}
}
impl ClientContext for TrackingContext {}
impl ConsumerContext for TrackingContext {
fn rebalance(
&self,
consumer: &BaseConsumer<Self>,
err: RDKafkaRespErr,
partitions: &mut TopicPartitionList,
) {
let mut kept = false;
let assigning = match err {
RDKafkaRespErr::RD_KAFKA_RESP_ERR__ASSIGN_PARTITIONS => {
kept = self.startup.assigning(consumer, partitions);
self.open_at_start(consumer, partitions);
true
}
RDKafkaRespErr::RD_KAFKA_RESP_ERR__REVOKE_PARTITIONS => {
self.startup.revoking(consumer, partitions);
self.tracker.revoke(partitions);
false
}
other => {
warn!(
subscription = %self.subscription,
error = %RDKafkaErrorCode::from(other),
"the group reported a rebalance error; this member releases its assignment",
);
false
}
};
let cooperative = matches!(
consumer.rebalance_protocol(),
RebalanceProtocol::Cooperative
);
let applied = match (assigning, cooperative) {
(true, true) => consumer.incremental_assign(partitions),
(true, false) => consumer.assign(partitions),
(false, true) => consumer.incremental_unassign(partitions),
(false, false) => consumer.unassign(),
};
if let Err(err) = &applied {
warn!(
subscription = %self.subscription,
error = %err,
"librdkafka did not apply the rebalance",
);
}
if assigning {
self.startup
.assigned(consumer, partitions, applied.as_ref().copied(), kept);
}
}
}
pub(crate) struct TrackedConsumer(StreamConsumer<TrackingContext>);
impl TrackedConsumer {
pub(crate) const fn new(consumer: StreamConsumer<TrackingContext>) -> Self {
Self(consumer)
}
}
impl Deref for TrackedConsumer {
type Target = StreamConsumer<TrackingContext>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Drop for TrackedConsumer {
fn drop(&mut self) {
self.0.context().tracker.release_store_lists();
}
}
#[cfg(test)]
mod tests {
use std::convert::Infallible;
use std::future::Future as _;
use std::pin::pin;
use std::task::{Context, Waker};
use super::*;
fn topic() -> Str {
Str::from_static("t")
}
fn settle(tracker: &CommitTracker, offset: i64) -> Option<i64> {
settle_in(tracker, offset, slot(tracker, "t", 0))
}
fn settle_in(tracker: &CommitTracker, offset: i64, slot: PartitionSlot) -> Option<i64> {
let mut stored = None;
tracker
.settle_with(slot, offset, |position, _target| {
stored = Some(position);
Ok::<(), Infallible>(())
})
.expect("infallible");
stored
}
fn slot(tracker: &CommitTracker, topic: &str, partition: i32) -> PartitionSlot {
let mut partitions = tracker
.partitions
.lock()
.expect("commit tracker mutex poisoned");
let index = partitions.slot(&Str::from(topic), partition);
PartitionSlot {
index,
generation: partitions.states[index].generation,
}
}
#[test]
fn contiguous_acks_advance_the_position() {
let tracker = CommitTracker::default();
tracker.delivered(&topic(), 0, 5);
tracker.delivered(&topic(), 0, 6);
assert_eq!(settle(&tracker, 5), Some(5));
assert_eq!(settle(&tracker, 6), Some(6));
}
#[test]
fn offset_gaps_never_block_the_position() {
let tracker = CommitTracker::default();
tracker.delivered(&topic(), 0, 0);
tracker.delivered(&topic(), 0, 1);
tracker.delivered(&topic(), 0, 3);
assert_eq!(settle(&tracker, 0), Some(0));
assert_eq!(settle(&tracker, 1), Some(2));
assert_eq!(settle(&tracker, 3), Some(3));
}
#[test]
fn out_of_order_acks_stay_bounded_by_the_lowest_outstanding() {
let tracker = CommitTracker::default();
for offset in 3..=5 {
tracker.delivered(&topic(), 0, offset);
}
assert_eq!(settle(&tracker, 4), Some(2));
assert_eq!(settle(&tracker, 5), None);
assert_eq!(settle(&tracker, 3), Some(5));
}
#[test]
fn unsettled_delivery_blocks_the_position() {
let tracker = CommitTracker::default();
for offset in 0..3 {
tracker.delivered(&topic(), 0, offset);
}
assert_eq!(settle(&tracker, 1), None);
assert_eq!(settle(&tracker, 2), None);
}
#[test]
fn partitions_are_tracked_independently() {
let tracker = CommitTracker::default();
tracker.delivered(&topic(), 0, 10);
let one = tracker.delivered(&topic(), 1, 20);
assert_eq!(settle_in(&tracker, 20, one), Some(20));
assert_eq!(settle(&tracker, 10), Some(10));
}
#[test]
fn a_slot_settles_the_partition_it_was_resolved_for() {
let other = Str::from_static("u");
let tracker = CommitTracker::default();
let a = tracker.delivered(&topic(), 0, 100);
let b = tracker.delivered(&topic(), 1, 200);
let c = tracker.delivered(&other, 0, 300);
assert_eq!(settle_in(&tracker, 200, b), Some(200));
assert_eq!(tracker.stored_position(&topic(), 1), Some(200));
assert_eq!(tracker.stored_position(&topic(), 0), None);
assert_eq!(tracker.stored_position(&other, 0), None);
assert_eq!(settle_in(&tracker, 300, c), Some(300));
assert_eq!(settle_in(&tracker, 100, a), Some(100));
let mut stored = tracker.stored_positions();
stored.sort();
assert_eq!(
stored,
vec![((topic(), 0), 100), ((topic(), 1), 200), ((other, 0), 300),],
);
}
#[test]
fn a_partition_keeps_its_slot_across_repositions() {
let tracker = CommitTracker::default();
let before = tracker.delivered(&topic(), 0, 5);
tracker.reposition(&topic(), 0);
let after = tracker.delivered(&topic(), 0, 9);
assert_eq!(
before.index, after.index,
"the same partition keeps one slot across a reposition",
);
assert_eq!(settle_in(&tracker, 9, after), Some(9));
assert_eq!(tracker.stored_positions(), vec![((topic(), 0), 9)]);
}
#[test]
fn a_reposition_drops_the_settles_of_deliveries_pulled_before_it() {
let tracker = CommitTracker::default();
let before = tracker.delivered(&topic(), 0, 10);
tracker.reposition(&topic(), 0);
assert_eq!(
settle_in(&tracker, 10, before),
None,
"a delivery pulled before the seek must not advance the position",
);
let after = tracker.delivered(&topic(), 0, 4);
assert_ne!(after, before, "a reposition must start a new generation");
assert_eq!(settle_in(&tracker, 4, after), Some(4));
}
#[test]
fn a_reposition_clears_the_stored_watermark() {
let tracker = CommitTracker::default();
tracker.delivered(&topic(), 0, 7);
assert_eq!(settle(&tracker, 7), Some(7));
tracker.reposition(&topic(), 0);
assert_eq!(
tracker.stored_position(&topic(), 0),
None,
"the watermark of the replaced read position must not survive the seek",
);
assert!(!tracker.covers(&topic(), 0), "nothing is delivered yet");
let generation = tracker.delivered(&topic(), 0, 2);
assert_eq!(settle_in(&tracker, 2, generation), Some(2));
}
#[test]
fn the_reposition_flag_is_taken_once() {
let tracker = CommitTracker::default();
assert!(!tracker.take_repositioned());
tracker.reposition(&topic(), 0);
assert!(tracker.take_repositioned());
assert!(
!tracker.take_repositioned(),
"the flag reports each reposition to the pipeline exactly once",
);
}
#[test]
fn replay_resets_the_partition_state() {
let tracker = CommitTracker::default();
tracker.delivered(&topic(), 0, 10);
assert_eq!(settle(&tracker, 10), Some(10));
tracker.delivered(&topic(), 0, 4);
assert_eq!(settle(&tracker, 4), Some(4));
}
#[test]
fn a_waiter_registered_before_a_settle_is_woken_by_it() {
let tracker = CommitTracker::default();
let slot = tracker.delivered(&topic(), 0, 0);
let mut waiter = pin!(tracker.advance_waiter());
let mut cx = Context::from_waker(Waker::noop());
assert!(
waiter.as_mut().poll(&mut cx).is_pending(),
"nothing has advanced yet",
);
assert_eq!(settle_in(&tracker, 0, slot), Some(0));
assert!(
waiter.as_mut().poll(&mut cx).is_ready(),
"a settle that advanced the stored position has to wake a registered waiter",
);
}
#[test]
fn a_burst_of_open_offsets_collapses_and_reopens() {
let tracker = CommitTracker::default();
for offset in 0..=2 {
tracker.delivered(&topic(), 0, offset);
}
assert_eq!(settle(&tracker, 1), None, "offset 0 is still open");
assert_eq!(settle(&tracker, 1), None, "and 1 settles only once");
assert_eq!(settle(&tracker, 2), None);
assert_eq!(settle(&tracker, 0), Some(2), "the whole prefix is settled");
tracker.delivered(&topic(), 0, 3);
tracker.delivered(&topic(), 0, 4);
assert_eq!(settle(&tracker, 4), None, "offset 3 is open again");
assert_eq!(settle(&tracker, 3), Some(4));
}
#[test]
fn holes_settled_in_any_order_keep_the_lowest_open_offset_as_the_bound() {
let tracker = CommitTracker::default();
for offset in [0, 1, 2, 3, 5, 6, 7, 8] {
tracker.delivered(&topic(), 0, offset);
}
assert_eq!(settle(&tracker, 5), None, "0 is still open");
assert_eq!(settle(&tracker, 2), None);
assert_eq!(settle(&tracker, 8), None);
assert_eq!(settle(&tracker, 0), Some(0), "1 bounds the position now");
assert_eq!(settle(&tracker, 3), None, "1 is still open");
assert_eq!(settle(&tracker, 1), Some(5), "6 is the lowest open offset");
assert_eq!(settle(&tracker, 7), None, "6 is still open");
assert_eq!(
settle(&tracker, 7),
None,
"a duplicate in the middle moves nothing"
);
assert_eq!(
settle(&tracker, 6),
Some(8),
"the whole partition is settled"
);
}
#[test]
fn the_open_offsets_keep_their_storage_across_settles_and_repositions() {
let tracker = CommitTracker::default();
for offset in 0..64 {
tracker.delivered(&topic(), 0, offset);
}
let capacity = |tracker: &CommitTracker| {
let partitions = tracker.partitions.lock().expect("not poisoned");
partitions.states[0].outstanding.0.capacity()
};
let grown = capacity(&tracker);
for offset in (0..64).rev() {
settle(&tracker, offset);
}
assert_eq!(
capacity(&tracker),
grown,
"the drained burst keeps its storage"
);
tracker.reposition(&topic(), 0);
assert_eq!(capacity(&tracker), grown, "a reposition keeps it too");
for offset in 100..164 {
tracker.delivered(&topic(), 0, offset);
}
assert_eq!(
capacity(&tracker),
grown,
"the next burst fits without growing"
);
tracker.delivered(&topic(), 0, 50);
assert_eq!(
capacity(&tracker),
grown,
"and so does a replay from an earlier offset"
);
}
fn store_through_list(tracker: &CommitTracker, offset: i64) -> Option<(String, i32, Offset)> {
let mut named = None;
tracker
.settle_with(slot(tracker, "t", 0), offset, |position, mut target| {
let list = target.list(position)?;
let element = &list.elements()[0];
named = Some((
element.topic().to_owned(),
element.partition(),
element.offset(),
));
Ok::<(), RdKafkaError>(())
})
.expect("a valid offset");
named
}
fn has_store_list(tracker: &CommitTracker) -> bool {
let partitions = tracker.partitions.lock().expect("not poisoned");
partitions.states[0].store_list.is_some()
}
#[test]
fn the_store_list_names_the_next_offset_and_is_rebuilt_after_a_reposition() {
let tracker = CommitTracker::default();
for offset in 0..3 {
tracker.delivered(&topic(), 0, offset);
}
assert_eq!(store_through_list(&tracker, 1), None);
assert_eq!(
store_through_list(&tracker, 0),
Some(("t".to_owned(), 0, Offset::Offset(2))),
"position 1 is stored as the offset to resume at",
);
assert_eq!(
store_through_list(&tracker, 2),
Some(("t".to_owned(), 0, Offset::Offset(3))),
"the reused list carries the new position",
);
tracker.reposition(&topic(), 0);
assert!(!has_store_list(&tracker));
tracker.delivered(&topic(), 0, 10);
assert_eq!(
store_through_list(&tracker, 10),
Some(("t".to_owned(), 0, Offset::Offset(11))),
);
assert!(has_store_list(&tracker));
tracker.release_store_lists();
assert!(!has_store_list(&tracker));
}
#[test]
fn duplicate_settles_are_ignored() {
let tracker = CommitTracker::default();
tracker.delivered(&topic(), 0, 0);
tracker.delivered(&topic(), 0, 1);
assert_eq!(settle(&tracker, 0), Some(0));
assert_eq!(settle(&tracker, 0), None);
assert_eq!(settle(&tracker, 1), Some(1));
}
#[test]
fn store_failure_is_retried_by_the_next_settle() {
let tracker = CommitTracker::default();
tracker.delivered(&topic(), 0, 0);
tracker.delivered(&topic(), 0, 1);
let failed: Result<(), &str> =
tracker.settle_with(slot(&tracker, "t", 0), 0, |_, _| Err("store failed"));
assert!(failed.is_err());
assert_eq!(settle(&tracker, 1), Some(1));
}
}