use std::collections::HashMap;
use std::sync::{Arc, Mutex, Weak};
use std::time::Duration;
use futures::future::select_all;
use rdkafka::consumer::{Consumer as _, ConsumerGroupMetadata, StreamConsumer};
use rdkafka::{Offset, TopicPartitionList};
#[cfg(feature = "asyncapi")]
use ruststream::asyncapi::Bindings;
use ruststream::runtime::{ForReply, Outgoing, PublishContext, PublishTransform, Reads};
use ruststream::{
Lend, OutgoingMessage, PairError, PublishPolicy, Publisher, Str, TransactionalPublisher as _,
};
use tracing::{debug, error};
use crate::broker::ConnectedKafkaBroker;
use crate::error::KafkaError;
#[cfg(feature = "testing")]
use crate::in_process::Member;
use crate::publisher::{
KafkaOptions, KafkaPublish, KafkaTransactionalPublish, KafkaTransactionalPublisher,
};
use crate::tracker::{CommitTracker, TrackedConsumer, TrackingContext};
const DEFAULT_COMMIT_INTERVAL: Duration = Duration::from_millis(100);
#[derive(Clone)]
pub(crate) struct EosSource {
tracker: Weak<CommitTracker>,
consumer: WeakConsumer,
}
#[derive(Clone)]
enum WeakConsumer {
Kafka(Weak<TrackedConsumer>),
#[cfg(feature = "testing")]
InProcess(Weak<Member>),
}
enum LiveConsumer {
Kafka(Arc<TrackedConsumer>),
#[cfg(feature = "testing")]
InProcess(Arc<Member>),
}
impl EosSource {
pub(crate) fn new(tracker: &Arc<CommitTracker>, consumer: &Arc<TrackedConsumer>) -> Self {
Self {
tracker: Arc::downgrade(tracker),
consumer: WeakConsumer::Kafka(Arc::downgrade(consumer)),
}
}
#[cfg(feature = "testing")]
pub(crate) fn in_process(tracker: &Arc<CommitTracker>, member: &Arc<Member>) -> Self {
Self {
tracker: Arc::downgrade(tracker),
consumer: WeakConsumer::InProcess(Arc::downgrade(member)),
}
}
pub(crate) fn alive(&self) -> bool {
self.tracker.strong_count() > 0
&& match &self.consumer {
WeakConsumer::Kafka(consumer) => consumer.strong_count() > 0,
#[cfg(feature = "testing")]
WeakConsumer::InProcess(member) => member.strong_count() > 0,
}
}
fn upgrade(&self) -> Option<LiveSource> {
Some(LiveSource {
tracker: self.tracker.upgrade()?,
consumer: match &self.consumer {
WeakConsumer::Kafka(consumer) => LiveConsumer::Kafka(consumer.upgrade()?),
#[cfg(feature = "testing")]
WeakConsumer::InProcess(member) => LiveConsumer::InProcess(member.upgrade()?),
},
})
}
}
struct LiveSource {
tracker: Arc<CommitTracker>,
consumer: LiveConsumer,
}
impl LiveSource {
async fn send_offsets(
&self,
publisher: &KafkaTransactionalPublisher,
positions: &[((Str, i32), i64)],
) -> Result<(), KafkaError> {
match &self.consumer {
LiveConsumer::Kafka(consumer) => {
let mut offsets = TopicPartitionList::new();
for ((topic, partition), next) in positions {
offsets
.add_partition_offset(topic, *partition, Offset::Offset(*next))
.map_err(KafkaError::publish)?;
}
publisher
.send_offsets(offsets, group_metadata(consumer)?)
.await
}
#[cfg(feature = "testing")]
LiveConsumer::InProcess(member) => {
let group = member.group().ok_or_else(no_group_metadata)?;
publisher.send_offsets_in_process(&group, positions)
}
}
}
fn seek_back(&self, topic: &str, partition: i32, offset: i64) -> Result<(), KafkaError> {
match &self.consumer {
LiveConsumer::Kafka(consumer) => consumer
.seek(
topic,
partition,
Offset::Offset(offset),
Duration::from_secs(5),
)
.map_err(KafkaError::consume),
#[cfg(feature = "testing")]
LiveConsumer::InProcess(member) => member.seek_partition(topic, partition, offset),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceOffset {
topic: Str,
partition: i32,
offset: i64,
}
impl SourceOffset {
#[must_use]
pub fn new(topic: impl Into<Str>, partition: i32, offset: i64) -> Self {
Self {
topic: topic.into(),
partition,
offset,
}
}
fn key(&self) -> (Str, i32) {
(self.topic.clone(), self.partition)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Phase {
Idle,
Opening,
Open,
Committing,
}
#[derive(Debug)]
struct Window {
phase: Phase,
enrolled: HashMap<(Str, i32), i64>,
failed: bool,
epoch: u64,
}
struct PipelineInner {
publisher: KafkaTransactionalPublisher,
id: String,
interval: Duration,
window: Mutex<Window>,
phase_changed: tokio::sync::Notify,
sources: Mutex<Vec<EosSource>>,
committed: Mutex<HashMap<(Str, i32), i64>>,
session_low: Mutex<HashMap<(Str, i32), i64>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[must_use]
pub struct KafkaEosPublish {
transactional: KafkaTransactionalPublish,
interval: Duration,
}
impl KafkaEosPublish {
pub fn new(id: impl Into<String>) -> Self {
Self {
transactional: KafkaPublish::default().transactional_id(id),
interval: DEFAULT_COMMIT_INTERVAL,
}
}
pub const fn commit_interval(mut self, interval: Duration) -> Self {
self.interval = interval;
self
}
pub fn transaction_timeout(mut self, timeout: Duration) -> Self {
self.transactional = self.transactional.transaction_timeout(timeout);
self
}
pub fn queue_timeout(mut self, timeout: Duration) -> Self {
self.transactional = self.transactional.queue_timeout(timeout);
self
}
#[must_use]
pub fn id(&self) -> &str {
self.transactional.id()
}
}
impl PublishPolicy<ConnectedKafkaBroker> for KafkaEosPublish {
type Live = EosPipeline;
async fn pair(self, connected: &ConnectedKafkaBroker) -> Result<Self::Live, PairError> {
let interval = self.interval;
let publisher = self.transactional.pair(connected).await?;
Ok(EosPipeline::new(publisher, interval))
}
#[cfg(feature = "asyncapi")]
fn channel_bindings(&self, channel: &str) -> Bindings {
crate::bindings::channel(channel)
}
}
#[derive(Clone)]
pub struct EosPipeline {
inner: Arc<PipelineInner>,
}
impl std::fmt::Debug for EosPipeline {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EosPipeline")
.field("id", &self.inner.id)
.field("interval", &self.inner.interval)
.finish_non_exhaustive()
}
}
impl EosPipeline {
fn new(publisher: KafkaTransactionalPublisher, interval: Duration) -> Self {
let id = publisher.id().to_owned();
Self {
inner: Arc::new(PipelineInner {
publisher,
id,
interval,
window: Mutex::new(Window {
phase: Phase::Idle,
enrolled: HashMap::new(),
failed: false,
epoch: 0,
}),
phase_changed: tokio::sync::Notify::new(),
sources: Mutex::new(Vec::new()),
committed: Mutex::new(HashMap::new()),
session_low: Mutex::new(HashMap::new()),
}),
}
}
#[must_use]
pub fn id(&self) -> &str {
&self.inner.id
}
pub async fn publish(
&self,
source: &SourceOffset,
msg: OutgoingMessage<'_>,
options: Option<&KafkaOptions>,
) -> Result<(), KafkaError> {
let epoch = self.admit(source).await?;
let sent = self.inner.publisher.publish(msg, options).await;
if sent.is_err() {
let mut window = self.inner.window.lock().expect("window mutex poisoned");
if window.epoch == epoch {
window.failed = true;
}
}
sent
}
async fn admit(&self, source: &SourceOffset) -> Result<u64, KafkaError> {
loop {
let phase_changed = self.inner.phase_changed.notified();
let voided = self.repositioned();
let action = {
let mut window = self.inner.window.lock().expect("window mutex poisoned");
match window.phase {
_ if voided && window.phase != Phase::Idle => Admission::Wait,
Phase::Open => {
Self::enroll(&mut window, &self.inner.session_low, source);
Admission::Admitted(window.epoch)
}
Phase::Idle => {
window.phase = Phase::Opening;
Admission::Opener
}
Phase::Opening => Admission::Wait,
Phase::Committing => {
let participant = window
.enrolled
.get(&source.key())
.is_some_and(|max| source.offset <= *max);
if participant {
Admission::Admitted(window.epoch)
} else {
Admission::Wait
}
}
}
};
match action {
Admission::Admitted(epoch) => return Ok(epoch),
Admission::Wait => {
phase_changed.await;
}
Admission::Opener => return self.open_window(source).await,
}
}
}
async fn open_window(&self, source: &SourceOffset) -> Result<u64, KafkaError> {
let registered = self.inner.publisher.state().eos_sources(&self.inner.id);
for source in registered.iter().filter_map(EosSource::upgrade) {
source.tracker.take_repositioned();
}
*self.inner.sources.lock().expect("sources mutex poisoned") = registered;
let begun = self.inner.publisher.begin_transaction().await;
let mut window = self.inner.window.lock().expect("window mutex poisoned");
match begun {
Ok(()) => {
window.phase = Phase::Open;
window.failed = false;
Self::enroll(&mut window, &self.inner.session_low, source);
let epoch = window.epoch;
drop(window);
self.inner
.publisher
.state()
.runtime()
.spawn(run_window(Arc::clone(&self.inner), epoch));
self.inner.phase_changed.notify_waiters();
Ok(epoch)
}
Err(err) => {
window.phase = Phase::Idle;
drop(window);
self.inner.phase_changed.notify_waiters();
Err(err)
}
}
}
fn repositioned(&self) -> bool {
self.inner
.sources
.lock()
.expect("sources mutex poisoned")
.iter()
.filter_map(EosSource::upgrade)
.any(|source| source.tracker.is_repositioned())
}
fn enroll(
window: &mut Window,
session_low: &Mutex<HashMap<(Str, i32), i64>>,
source: &SourceOffset,
) {
let key = source.key();
session_low
.lock()
.expect("session low mutex poisoned")
.entry(key.clone())
.or_insert(source.offset);
let max = window.enrolled.entry(key).or_insert(source.offset);
if source.offset > *max {
*max = source.offset;
}
}
}
enum Admission {
Admitted(u64),
Wait,
Opener,
}
async fn run_window(inner: Arc<PipelineInner>, epoch: u64) {
stay_open(&inner).await;
let enrolled = {
let mut window = inner.window.lock().expect("window mutex poisoned");
if window.epoch != epoch || window.phase != Phase::Open {
return;
}
window.phase = Phase::Committing;
window.enrolled.clone()
};
let outcome = commit_window(&inner, &enrolled).await;
{
let mut window = inner.window.lock().expect("window mutex poisoned");
window.phase = Phase::Idle;
window.enrolled.clear();
window.failed = false;
window.epoch += 1;
}
inner.phase_changed.notify_waiters();
if let Err(err) = outcome {
error!(
target: "ruststream_rdkafka",
pipeline = %inner.id,
error = %err,
"EOS window aborted; its sources seek back and the window redelivers",
);
}
}
async fn stay_open(inner: &Arc<PipelineInner>) {
let sources: Vec<LiveSource> = inner
.sources
.lock()
.expect("sources mutex poisoned")
.iter()
.filter_map(EosSource::upgrade)
.collect();
let waiters: Vec<_> = sources
.iter()
.map(|source| Box::pin(source.tracker.reposition_waiter()))
.collect();
if waiters.is_empty()
|| sources
.iter()
.any(|source| source.tracker.is_repositioned())
{
if waiters.is_empty() {
tokio::time::sleep(inner.interval).await;
}
return;
}
tokio::select! {
() = tokio::time::sleep(inner.interval) => {}
(..) = select_all(waiters) => {}
}
}
async fn commit_window(
inner: &Arc<PipelineInner>,
enrolled: &HashMap<(Str, i32), i64>,
) -> Result<(), KafkaError> {
let sources = live_sources(&inner.id, &inner.publisher);
let repositioned = sources
.iter()
.filter(|source| source.tracker.take_repositioned())
.count()
> 0;
if repositioned {
abort_window(inner, &sources, enrolled, Repositioned::Yes).await;
return Err(KafkaError::InvalidOptions(
"the subscription was repositioned while this window was open; its records are \
discarded and the replayed deliveries are processed into a fresh window"
.to_owned(),
));
}
let failed = {
let window = inner.window.lock().expect("window mutex poisoned");
window.failed
};
let ready = if failed {
Err(KafkaError::Publish(
"a publish into this window failed; the transaction is poisoned"
.to_owned()
.into(),
))
} else {
wait_settled(inner, &sources, enrolled).await
};
let result = match ready {
Ok(()) => try_commit(inner, &sources).await,
Err(err) => Err(err),
};
if let Err(err) = result {
abort_window(inner, &sources, enrolled, Repositioned::No).await;
return Err(err);
}
Ok(())
}
fn live_sources(id: &str, publisher: &KafkaTransactionalPublisher) -> Vec<LiveSource> {
publisher
.state()
.eos_sources(id)
.iter()
.filter_map(EosSource::upgrade)
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Repositioned {
Yes,
No,
}
async fn wait_settled(
inner: &Arc<PipelineInner>,
sources: &[LiveSource],
enrolled: &HashMap<(Str, i32), i64>,
) -> Result<(), KafkaError> {
let deadline = tokio::time::Instant::now() + inner.publisher.deadline();
loop {
let waiters: Vec<_> = sources
.iter()
.map(|source| Box::pin(source.tracker.advance_waiter()))
.collect();
let pending = enrolled.iter().find(|((topic, partition), max)| {
!sources.iter().any(|source| {
source
.tracker
.stored_position(topic, *partition)
.is_some_and(|stored| stored >= **max)
})
});
let Some(((topic, partition), max)) = pending else {
return Ok(());
};
if waiters.is_empty() {
return Err(KafkaError::InvalidOptions(format!(
"EOS pipeline has no registered sources for its id; is the subscription in \
`Commit::Transactional` mode with the matching pipeline id? (waiting on \
{topic}[{partition}] up to offset {max})",
)));
}
debug!(
target: "ruststream_rdkafka",
topic = %topic,
partition = partition,
up_to = max,
"EOS window waiting for participants to settle",
);
if tokio::time::timeout_at(deadline, select_all(waiters))
.await
.is_err()
{
return Err(KafkaError::Publish(
format!(
"EOS window stalled: {topic}[{partition}] did not settle up to offset \
{max} within the transaction deadline (a hung or retrying handler, or a \
revoked partition)",
)
.into(),
));
}
}
}
async fn try_commit(inner: &Arc<PipelineInner>, sources: &[LiveSource]) -> Result<(), KafkaError> {
let mut sent: Vec<((Str, i32), i64)> = Vec::new();
for source in sources {
let positions: Vec<((Str, i32), i64)> = source
.tracker
.stored_positions()
.into_iter()
.map(|(key, stored)| (key, stored + 1))
.collect();
if positions.is_empty() {
continue;
}
source.send_offsets(&inner.publisher, &positions).await?;
sent.extend(positions);
}
inner.publisher.commit().await?;
{
let mut committed = inner.committed.lock().expect("committed mutex poisoned");
for (key, next) in sent {
committed.insert(key, next);
}
}
Ok(())
}
fn group_metadata(
consumer: &StreamConsumer<TrackingContext>,
) -> Result<ConsumerGroupMetadata, KafkaError> {
consumer.group_metadata().ok_or_else(no_group_metadata)
}
fn no_group_metadata() -> KafkaError {
KafkaError::Publish(
"the source consumer has no group metadata (not a group member yet or already closed); \
cannot commit its offsets transactionally"
.to_owned()
.into(),
)
}
async fn abort_window(
inner: &Arc<PipelineInner>,
sources: &[LiveSource],
enrolled: &HashMap<(Str, i32), i64>,
repositioned: Repositioned,
) {
if let Err(err) = inner.publisher.abort().await {
error!(
target: "ruststream_rdkafka",
error = %err,
"EOS window abort failed; the transaction resolves by its broker-side timeout",
);
}
if repositioned == Repositioned::Yes {
{
let mut committed = inner.committed.lock().expect("committed mutex poisoned");
for key in enrolled.keys() {
committed.remove(key);
}
}
{
let mut session_low = inner
.session_low
.lock()
.expect("session low mutex poisoned");
for key in enrolled.keys() {
session_low.remove(key);
}
}
return;
}
let committed = inner
.committed
.lock()
.expect("committed mutex poisoned")
.clone();
let session_low = inner
.session_low
.lock()
.expect("session low mutex poisoned")
.clone();
for key @ (topic, partition) in enrolled.keys() {
let Some(target) = committed
.get(key)
.copied()
.or_else(|| session_low.get(key).copied())
else {
continue;
};
let Some(source) = sources
.iter()
.find(|source| source.tracker.covers(topic, *partition))
else {
continue;
};
if let Err(err) = source.seek_back(topic, *partition, target) {
debug!(
target: "ruststream_rdkafka",
topic = %topic,
partition = partition,
error = %err,
"seek-back after an aborted EOS window failed",
);
}
}
}
pub const EOS_SOURCE_HEADER: &str = "kafka-eos-source";
pub(crate) fn encode_source(topic: &str, partition: i32, offset: i64) -> String {
format!("{partition}:{offset}:{topic}")
}
fn decode_source(value: &str) -> Option<SourceOffset> {
let mut parts = value.splitn(3, ':');
let partition = parts.next()?.parse().ok()?;
let offset = parts.next()?.parse().ok()?;
let topic = parts.next()?;
Some(SourceOffset::new(topic, partition, offset))
}
#[derive(Debug, Clone, Copy, Default)]
pub struct EosReplies;
impl<C, Options> PublishTransform<ForReply<C>, Options> for EosReplies {
type Destination = Reads;
fn apply(
&self,
out: &mut Outgoing<'_>,
_options: &mut Option<Options>,
cx: &PublishContext<'_, C>,
) {
if let Some(source) = cx.headers().get_shared(EOS_SOURCE_HEADER) {
out.headers_mut()
.insert(Str::from_static(EOS_SOURCE_HEADER), source);
}
}
}
impl Publisher for EosPipeline {
type Payload = Lend;
type Error = KafkaError;
type Options = KafkaOptions;
async fn publish(
&self,
msg: OutgoingMessage<'_>,
options: Option<&Self::Options>,
) -> Result<(), Self::Error> {
let Some(source) = msg
.headers()
.get_str(EOS_SOURCE_HEADER)
.and_then(decode_source)
else {
return Err(KafkaError::InvalidOptions(
"an EOS reply carries no source coordinates: the subscription must be in \
`Commit::Transactional` mode for this pipeline, and the reply publisher must \
relay them (add the `EosReplies` transform to the mount site's chain, \
`.out_reply(KafkaEosPublish::new(id)).transform(EosReplies)`)"
.to_owned(),
));
};
let (topic, payload, mut headers) = msg.into_parts();
headers.remove(EOS_SOURCE_HEADER);
let stripped = OutgoingMessage::with_payload(topic, payload).with_headers(headers);
self.publish(&source, stripped, options).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_header_roundtrips_topics_with_colons() {
let encoded = encode_source("orders:eu:v1", 3, 42);
let decoded = decode_source(&encoded).expect("decodes");
assert_eq!(decoded, SourceOffset::new("orders:eu:v1", 3, 42));
}
#[test]
fn malformed_source_headers_are_rejected() {
for bad in ["", "3", "3:x:orders", "x:42:orders"] {
assert!(decode_source(bad).is_none(), "{bad:?} must not decode");
}
}
#[test]
fn the_policy_carries_the_pipeline_id_and_interval() {
let policy = KafkaEosPublish::new("enrich-1").commit_interval(Duration::from_millis(250));
assert_eq!(policy.id(), "enrich-1");
assert_eq!(policy.interval, Duration::from_millis(250));
}
}