#![cfg_attr(
not(feature = "testing"),
allow(clippy::infallible_destructuring_match)
)]
use std::collections::HashMap;
use std::fmt;
use std::future::Future;
#[cfg(all(feature = "testing", not(feature = "schema-registry")))]
use std::future::ready;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use rdkafka::consumer::{Consumer as _, StreamConsumer};
use rdkafka::producer::{BaseProducer, FutureProducer, Producer as _};
use rdkafka::{ClientConfig, Offset, TopicPartitionList};
#[cfg(feature = "testing")]
use ruststream::testing::InProcess;
use ruststream::{
AddressedCopies, Broker, ConnectedBroker, DescribeServer, ServerSpec, Str, Subscribe,
SubscriptionSource,
};
use tokio::runtime::Handle;
use tokio::sync::oneshot;
use tokio::task;
use crate::eos::EosSource;
use crate::error::KafkaError;
#[cfg(feature = "testing")]
use crate::in_process::{Cluster, MemberSpec, ProducerSettings};
use crate::properties::Properties;
use crate::publisher::{KafkaPublish, KafkaPublisher};
#[cfg(feature = "schema-registry")]
use crate::schema_registry::{SchemaPrefetch, SchemaRegistry};
use crate::startup::{self, Opened, Reset, Startup};
use crate::subscriber::{DeliveredTopic, KafkaSubscriber};
use crate::subscription::{
Commit, GroupSettings, KafkaTopic, Reader, StartOffset, SubscriptionPlan, refuse_illegal_topics,
};
#[cfg(feature = "testing")]
use crate::testable::Routes;
use crate::tracker::{CommitTracker, TrackedConsumer, TrackingContext};
pub(crate) struct ConnState {
transport: Transport,
producer_config: ClientConfig,
base_config: ClientConfig,
default_group: Option<String>,
flush_timeout: Duration,
assignment_timeout: Duration,
eos_sources: Mutex<HashMap<String, Vec<EosSource>>>,
closed: AtomicBool,
runtime: Handle,
#[cfg(feature = "schema-registry")]
schema_registry: Option<SchemaRegistry>,
#[cfg(feature = "schema-registry")]
schema_prefetch: Option<SchemaPrefetch>,
#[cfg(feature = "testing")]
pub(crate) routes: Routes,
}
#[cfg(feature = "testing")]
fn in_process_has_no_client() -> KafkaError {
KafkaError::InvalidOptions(
"the in-process connection has no librdkafka client; this operation belongs to a \
connection made with `connect`"
.to_owned(),
)
}
pub(crate) enum Transport {
Kafka(OnceLock<FutureProducer>),
#[cfg(feature = "testing")]
InProcess(Arc<Cluster>),
}
#[cfg(not(feature = "testing"))]
const _: () = assert!(size_of::<Transport>() == size_of::<OnceLock<FutureProducer>>());
impl ConnState {
pub(crate) const fn runtime(&self) -> &Handle {
&self.runtime
}
pub(crate) fn producer(&self) -> Result<&FutureProducer, KafkaError> {
let cell = match &self.transport {
Transport::Kafka(cell) => cell,
#[cfg(feature = "testing")]
Transport::InProcess(_) => return Err(in_process_has_no_client()),
};
if let Some(producer) = cell.get() {
return Ok(producer);
}
let opened: FutureProducer = self
.producer_config
.create()
.map_err(|err| KafkaError::Publish(Box::new(err)))?;
let _ = cell.set(opened);
Ok(cell.get().expect("the cell holds a producer"))
}
#[cfg(feature = "testing")]
pub(crate) fn in_process(&self) -> Option<&Arc<Cluster>> {
match &self.transport {
Transport::Kafka(_) => None,
Transport::InProcess(cluster) => Some(cluster),
}
}
pub(crate) fn producer_config(&self) -> &ClientConfig {
&self.producer_config
}
pub(crate) fn ensure_open(&self, topic: &str) -> Result<(), KafkaError> {
if self.closed.load(Ordering::Acquire) {
return Err(KafkaError::Closed {
topic: topic.to_owned(),
});
}
Ok(())
}
fn register_eos(&self, pipeline: &str, source: EosSource) {
let mut sources = self
.eos_sources
.lock()
.expect("eos source registry mutex poisoned");
sources.entry(pipeline.to_owned()).or_default().push(source);
}
pub(crate) fn eos_sources(&self, pipeline: &str) -> Vec<EosSource> {
let mut sources = self
.eos_sources
.lock()
.expect("eos source registry mutex poisoned");
sources
.get_mut(pipeline)
.map_or_else(Vec::new, |registered| {
registered.retain(EosSource::alive);
registered.clone()
})
}
}
impl fmt::Debug for ConnState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ConnState")
.field("closed", &self.closed.load(Ordering::Relaxed))
.finish_non_exhaustive()
}
}
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_FLUSH_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_ASSIGNMENT_TIMEOUT: Duration = Duration::from_mins(1);
#[derive(Debug, Clone)]
pub struct KafkaBroker {
servers: Vec<String>,
default_group: Option<String>,
client_config: Properties,
producer_config: Properties,
connect_timeout: Duration,
flush_timeout: Duration,
assignment_timeout: Duration,
#[cfg(feature = "schema-registry")]
schema_registry: Option<SchemaRegistry>,
#[cfg(feature = "schema-registry")]
schema_prefetch: Option<SchemaPrefetch>,
}
impl KafkaBroker {
#[must_use]
pub fn new<I, S>(servers: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
servers: servers.into_iter().map(Into::into).collect(),
default_group: None,
client_config: Properties::default(),
producer_config: Properties::default(),
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
flush_timeout: DEFAULT_FLUSH_TIMEOUT,
assignment_timeout: DEFAULT_ASSIGNMENT_TIMEOUT,
#[cfg(feature = "schema-registry")]
schema_registry: None,
#[cfg(feature = "schema-registry")]
schema_prefetch: None,
}
}
#[must_use]
pub fn default_group(mut self, group: impl Into<String>) -> Self {
self.default_group = Some(group.into());
self
}
#[must_use]
pub fn config(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.client_config.set(key.into(), value.into());
self
}
#[must_use]
pub fn producer_config(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.producer_config.set(key.into(), value.into());
self
}
#[must_use]
pub fn connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = timeout;
self
}
#[must_use]
pub fn flush_timeout(mut self, timeout: Duration) -> Self {
self.flush_timeout = timeout;
self
}
#[must_use]
pub fn assignment_timeout(mut self, timeout: Duration) -> Self {
self.assignment_timeout = timeout;
self
}
#[cfg(feature = "schema-registry")]
#[must_use]
pub fn schema_registry(mut self, registry: SchemaRegistry) -> Self {
self.schema_registry = Some(registry);
self
}
#[cfg(feature = "schema-registry")]
#[must_use]
pub fn schema_prefetch(mut self, prefetch: SchemaPrefetch) -> Self {
self.schema_prefetch = Some(prefetch);
self
}
fn base_config(&self) -> ClientConfig {
let mut config = ClientConfig::new();
config.set("bootstrap.servers", self.servers.join(","));
for (key, value) in &self.client_config {
config.set(key, value);
}
config
}
}
impl Broker for KafkaBroker {
type Error = KafkaError;
type Connected = ConnectedKafkaBroker;
async fn connect(self) -> Result<Self::Connected, Self::Error> {
if self.servers.is_empty() {
return Err(KafkaError::InvalidOptions(
"at least one bootstrap server is required".to_owned(),
));
}
let base_config = self.base_config();
let mut producer_config = base_config.clone();
for (key, value) in &self.producer_config {
producer_config.set(key, value);
}
let probe: BaseProducer = producer_config.create().map_err(KafkaError::connect)?;
let timeout = self.connect_timeout;
task::spawn_blocking(move || {
let reached = probe.client().fetch_metadata(None, timeout);
drop(probe);
reached
})
.await
.map_err(|err| KafkaError::Connect(Box::new(err)))?
.map_err(KafkaError::connect)?;
#[cfg(feature = "schema-registry")]
if let Some(prefetch) = &self.schema_prefetch {
prefetch.warm_subjects().await?;
}
let state = Arc::new(ConnState {
transport: Transport::Kafka(OnceLock::new()),
producer_config,
base_config,
default_group: self.default_group,
flush_timeout: self.flush_timeout,
assignment_timeout: self.assignment_timeout,
eos_sources: Mutex::new(HashMap::new()),
closed: AtomicBool::new(false),
runtime: Handle::current(),
#[cfg(feature = "schema-registry")]
schema_registry: self.schema_registry,
#[cfg(feature = "schema-registry")]
schema_prefetch: self.schema_prefetch,
#[cfg(feature = "testing")]
routes: Routes::default(),
});
Ok(ConnectedKafkaBroker { state })
}
}
#[cfg(feature = "testing")]
impl InProcess for KafkaBroker {
fn connect_in_process(
self,
) -> impl Future<Output = Result<Self::Connected, Self::Error>> + Send {
#[cfg(feature = "schema-registry")]
let prefetch = self.schema_prefetch.clone();
let connected = self.into_in_process();
#[cfg(feature = "schema-registry")]
return async move {
if let Some(prefetch) = &prefetch {
prefetch.warm_subjects().await?;
}
connected
};
#[cfg(not(feature = "schema-registry"))]
ready(connected)
}
}
#[cfg(feature = "testing")]
impl KafkaBroker {
fn into_in_process(self) -> Result<ConnectedKafkaBroker, KafkaError> {
if self.servers.is_empty() {
return Err(KafkaError::InvalidOptions(
"at least one bootstrap server is required".to_owned(),
));
}
let base_config = self.base_config();
let mut producer_config = base_config.clone();
for (key, value) in &self.producer_config {
producer_config.set(key, value);
}
let settings = ProducerSettings::read(&producer_config).map_err(KafkaError::connect)?;
let state = Arc::new(ConnState {
transport: Transport::InProcess(Cluster::new(settings)),
producer_config,
base_config,
default_group: self.default_group,
flush_timeout: self.flush_timeout,
assignment_timeout: self.assignment_timeout,
eos_sources: Mutex::new(HashMap::new()),
closed: AtomicBool::new(false),
runtime: Handle::current(),
#[cfg(feature = "schema-registry")]
schema_registry: self.schema_registry,
#[cfg(feature = "schema-registry")]
schema_prefetch: self.schema_prefetch,
routes: Routes::default(),
});
Ok(ConnectedKafkaBroker { state })
}
}
#[cfg(feature = "testing")]
ruststream::register_testable_broker!(KafkaBroker);
impl DescribeServer for KafkaBroker {
fn describe_server(&self) -> ServerSpec {
let hosts: Vec<String> = self
.servers
.iter()
.map(|server| ServerSpec::host_from_url(server))
.collect();
let spec = ServerSpec::new(hosts.join(","), "kafka");
#[cfg(feature = "asyncapi")]
let spec = spec.bindings(crate::bindings::server(self.registry_url()));
spec
}
}
#[cfg(feature = "asyncapi")]
impl KafkaBroker {
fn registry_url(&self) -> Option<&str> {
#[cfg(feature = "schema-registry")]
{
self.schema_registry
.as_ref()
.and_then(SchemaRegistry::base_url)
}
#[cfg(not(feature = "schema-registry"))]
{
None
}
}
}
#[derive(Debug)]
pub struct ConnectedKafkaBroker {
state: Arc<ConnState>,
}
impl ConnectedKafkaBroker {
#[must_use]
pub fn publisher(&self, policy: KafkaPublish) -> KafkaPublisher {
KafkaPublisher::new(Arc::clone(&self.state), policy.queue_timeout_setting())
}
pub(crate) fn state(&self) -> &Arc<ConnState> {
&self.state
}
pub fn subscribe_with<Source>(
&self,
def: Source,
) -> impl Future<Output = Result<KafkaSubscriber, KafkaError>>
where
Source: SubscriptionSource<Self, Subscriber = KafkaSubscriber>,
{
def.subscribe(self)
}
pub(crate) async fn open(&self, plan: SubscriptionPlan) -> Result<KafkaSubscriber, KafkaError> {
self.state.ensure_open(&plan.name)?;
refuse_illegal_topics(&plan.reader)?;
let manual = matches!(plan.reader, Reader::Assigned { .. });
let group = plan
.settings
.group
.clone()
.or_else(|| self.state.default_group.clone());
let group = match group {
Some(group) => Some(group),
None if manual => None,
None => {
return Err(KafkaError::InvalidOptions(format!(
"subscription to {:?} has no consumer group: name one on the descriptor or \
set `KafkaBroker::default_group`",
plan.name,
)));
}
};
if manual {
validate_manual_assignment(&plan, group.as_deref())?;
}
let config = self.consumer_config(&plan.settings, group.as_deref());
#[cfg(feature = "testing")]
self.state.routes.record(&plan, group.as_deref());
#[cfg(feature = "testing")]
if let Some(cluster) = self.state.in_process() {
return self.open_in_process(cluster, plan, group, &config);
}
let reset = Reset::of(&config);
let tracker = Arc::new(CommitTracker::default());
let (startup, opened) = match &plan.reader {
Reader::Subscribed(names) => {
let (startup, opened) = Startup::kept(names, reset);
(startup, Some(opened))
}
Reader::Assigned { .. } => (Startup::released(), None),
};
let context = TrackingContext::new(Arc::clone(&tracker), &plan.name, startup);
let consumer: StreamConsumer<TrackingContext> = config
.create_with_context(context)
.map_err(KafkaError::subscribe)?;
let consumer = Arc::new(TrackedConsumer::new(consumer));
let delivered_topic = match &plan.reader {
Reader::Assigned { topic, partitions } => {
let assigning = Arc::clone(&consumer);
let (topic_name, partitions) = (topic.clone(), partitions.clone());
let start = plan.settings.start;
task::spawn_blocking(move || {
assign_partitions(&assigning, &topic_name, &partitions, start, reset)
})
.await
.map_err(|err| KafkaError::Subscribe(Box::new(err)))??;
DeliveredTopic::One(Str::from(topic.as_str()))
}
Reader::Subscribed(names) => {
let literal = match names.as_slice() {
[only] if !only.starts_with('^') => Some(Str::from(only.as_str())),
_ => None,
};
let borrowed: Vec<&str> = names.iter().map(String::as_str).collect();
consumer
.subscribe(&borrowed)
.map_err(KafkaError::subscribe)?;
literal.map_or(DeliveredTopic::PerRecord(None), DeliveredTopic::One)
}
};
if let Commit::Transactional(pipeline) = &plan.settings.commit {
self.state
.register_eos(pipeline, EosSource::new(&tracker, &consumer));
}
let settings = plan.settings;
let subscriber = KafkaSubscriber::new(
consumer,
plan.name,
delivered_topic,
settings.commit,
tracker,
settings.lane_key,
Arc::clone(&self.state),
);
#[cfg(feature = "schema-registry")]
let subscriber = subscriber
.with_schema_registry(self.state.schema_registry.clone())
.with_schema_prefetch(self.state.schema_prefetch.clone());
if let Some(opened) = opened {
self.await_assignment(&subscriber, opened, group.as_deref())
.await?;
}
Ok(subscriber)
}
async fn await_assignment(
&self,
subscriber: &KafkaSubscriber,
opened: oneshot::Receiver<Opened>,
group: Option<&str>,
) -> Result<(), KafkaError> {
let cart = subscriber.cart();
let consumer = &cart.consumer;
let subscription = subscriber.topic();
let keeper = Arc::clone(&cart);
self.state
.runtime()
.spawn_blocking(move || startup::keep(&keeper));
let bound = self.state.assignment_timeout;
let group = group.unwrap_or_default();
let outcome = match tokio::time::timeout(bound, opened).await {
Ok(Ok(Ok(()))) => Ok(()),
Ok(Ok(Err(reason))) => Err(format!(
"subscription {subscription:?} in consumer group {group:?} could not be opened: \
{reason}"
)),
Ok(Err(_)) | Err(_) => {
let cause = consumer
.context()
.startup
.last_error()
.map(|err| format!("; the consumer last reported: {err}"))
.unwrap_or_default();
Err(format!(
"subscription {subscription:?} in consumer group {group:?} was not assigned \
its partitions within {bound:?}{cause}"
))
}
};
outcome.map_err(|message| KafkaError::Subscribe(message.into()))
}
fn consumer_config(&self, def: &GroupSettings, group: Option<&str>) -> ClientConfig {
let mut config = self.state.base_config.clone();
if let Some(group) = group {
config.set("group.id", group);
} else {
config.set("group.id", "ruststream.standalone");
config.set("enable.auto.commit", "false");
}
match def.start {
StartOffset::Committed => {}
StartOffset::Earliest => {
config.set("auto.offset.reset", "earliest");
}
StartOffset::Latest => {
config.set("auto.offset.reset", "latest");
}
}
if let Some(assignment) = def.assignment {
config.set(
"partition.assignment.strategy",
assignment.as_config_value(),
);
}
match &def.commit {
Commit::Auto => {}
Commit::Tracked => {
config.set("enable.auto.offset.store", "false");
}
Commit::Transactional(_) => {
config.set("enable.auto.offset.store", "false");
config.set("enable.auto.commit", "false");
}
}
for (key, value) in &def.config {
config.set(key, value);
}
config
}
}
#[cfg(feature = "testing")]
impl ConnectedKafkaBroker {
fn open_in_process(
&self,
cluster: &Arc<Cluster>,
plan: SubscriptionPlan,
group: Option<String>,
config: &ClientConfig,
) -> Result<KafkaSubscriber, KafkaError> {
let tracker = Arc::new(CommitTracker::default());
let delivered_topic = match &plan.reader {
Reader::Assigned { topic, .. } => DeliveredTopic::One(Str::from(topic.as_str())),
Reader::Subscribed(names) => match names.as_slice() {
[only] if !only.starts_with('^') => DeliveredTopic::One(Str::from(only.as_str())),
_ => DeliveredTopic::PerRecord(None),
},
};
let (names, assigned) = match &plan.reader {
Reader::Subscribed(names) => (Some(names.as_slice()), None),
Reader::Assigned { topic, partitions } => {
(None, Some((topic.clone(), partitions.clone())))
}
};
let spec = MemberSpec::read(
plan.name.clone(),
group,
names,
assigned,
plan.settings.start,
config,
Arc::clone(&tracker),
)?;
let member = cluster.join(spec);
if let Commit::Transactional(pipeline) = &plan.settings.commit {
self.state
.register_eos(pipeline, EosSource::in_process(&tracker, &member));
}
let settings = plan.settings;
let subscriber = KafkaSubscriber::in_process(
member,
plan.name,
delivered_topic,
settings.commit,
tracker,
settings.lane_key,
Arc::clone(&self.state),
);
#[cfg(feature = "schema-registry")]
let subscriber = subscriber
.with_schema_registry(self.state.schema_registry.clone())
.with_schema_prefetch(self.state.schema_prefetch.clone());
Ok(subscriber)
}
}
impl ConnectedBroker for ConnectedKafkaBroker {
type Error = KafkaError;
type Closed = ClosedKafkaBroker;
async fn shutdown(self) -> Result<Self::Closed, Self::Error> {
self.state.closed.store(true, Ordering::Release);
let cell = match &self.state.transport {
Transport::Kafka(cell) => cell,
#[cfg(feature = "testing")]
Transport::InProcess(_) => return Ok(ClosedKafkaBroker { unflushed: 0 }),
};
let Some(producer) = cell.get().cloned() else {
return Ok(ClosedKafkaBroker { unflushed: 0 });
};
let timeout = self.state.flush_timeout;
let unflushed = task::spawn_blocking(move || {
producer.flush(timeout)?;
Ok::<_, rdkafka::error::KafkaError>(producer.in_flight_count())
})
.await
.map_err(|err| KafkaError::Publish(Box::new(err)))?
.map_err(KafkaError::publish)?;
Ok(ClosedKafkaBroker { unflushed })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClosedKafkaBroker {
unflushed: i32,
}
impl ClosedKafkaBroker {
#[must_use]
pub fn unflushed_records(&self) -> i32 {
self.unflushed
}
}
impl Subscribe for ConnectedKafkaBroker {
type Subscriber = KafkaSubscriber;
type Copies = AddressedCopies;
async fn subscribe(&self, name: &str) -> Result<Self::Subscriber, Self::Error> {
self.subscribe_with(KafkaTopic::new(name)).await
}
}
fn validate_manual_assignment(
plan: &SubscriptionPlan,
group: Option<&str>,
) -> Result<(), KafkaError> {
let def = &plan.settings;
if matches!(def.commit, Commit::Transactional(_)) {
return Err(KafkaError::InvalidOptions(
"manual partition assignment does not compose with `Commit::Transactional`: an \
EOS pipeline commits through the consumer group protocol"
.to_owned(),
));
}
if group.is_none() {
if def.commit == Commit::Tracked {
return Err(KafkaError::InvalidOptions(
"`Commit::Tracked` needs a group to commit into; name one with \
`KafkaPartitions::group` or drop the commit mode for a group-less reader"
.to_owned(),
));
}
if def.start == StartOffset::Committed {
return Err(KafkaError::InvalidOptions(
"a group-less manual assignment has no committed offsets to start from; set \
`start(StartOffset::Earliest)` or `Latest`, or name a group"
.to_owned(),
));
}
}
Ok(())
}
fn assign_partitions(
consumer: &TrackedConsumer,
topic: &str,
partitions: &[i32],
start: StartOffset,
reset: Reset,
) -> Result<(), KafkaError> {
let offset = match start {
StartOffset::Committed => Offset::Stored,
StartOffset::Earliest => Offset::Beginning,
StartOffset::Latest => Offset::End,
};
let mut assignment = TopicPartitionList::new();
for partition in partitions {
assignment
.add_partition_offset(topic, *partition, offset)
.map_err(KafkaError::subscribe)?;
}
startup::resolve_starts(&**consumer, &mut assignment, &HashMap::new(), reset)
.map_err(KafkaError::subscribe)?;
consumer.assign(&assignment).map_err(KafkaError::subscribe)
}
#[cfg(test)]
mod tests {
use ruststream::{OutgoingMessage, Publisher as _};
use super::*;
#[test]
fn construction_is_synchronous_and_io_free() {
let broker = KafkaBroker::new(["a:9092", "b:9092"]).default_group("g");
assert_eq!(
broker.describe_server().host.as_deref(),
Some("a:9092,b:9092")
);
assert_eq!(broker.describe_server().protocol, "kafka");
}
fn live_url() -> Option<String> {
match std::env::var("KAFKA_TEST_URL") {
Ok(url) if !url.is_empty() => Some(url),
_ => {
assert!(
!std::env::var("RUSTSTREAM_REQUIRE_LIVE").is_ok_and(|flag| !flag.is_empty()),
"RUSTSTREAM_REQUIRE_LIVE is set, so this test must run, but KAFKA_TEST_URL \
is unset or empty",
);
None
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_connection_opens_no_producer_until_something_publishes() {
let Some(url) = live_url() else { return };
let broker = KafkaBroker::new([url]).connect().await.expect("connect");
assert!(
matches!(&broker.state.transport, Transport::Kafka(cell) if cell.get().is_none()),
"connecting must open no producer: a service that only consumes never publishes, and \
a producer is a client, its threads and a connection to the cluster",
);
let publisher = broker.publisher(KafkaPublish::default());
publisher
.publish(
OutgoingMessage::new("ruststream-lazy-producer", b"one".as_slice()),
None,
)
.await
.expect("publish");
assert!(
matches!(&broker.state.transport, Transport::Kafka(cell) if cell.get().is_some()),
"the first publish opens the producer",
);
broker.shutdown().await.expect("shutdown");
}
#[tokio::test]
async fn connect_with_no_servers_fails_fast() {
let err = KafkaBroker::new(Vec::<String>::new())
.connect()
.await
.unwrap_err();
assert!(matches!(err, KafkaError::InvalidOptions(_)));
}
}