use ahash::{AHashMap, AHashSet};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use tokio::time::Instant;
use arc_swap::ArcSwap;
use parking_lot::Mutex as SyncMutex;
use tokio::sync::{Notify, oneshot};
use tracing::{debug, info, warn};
use crate::error::{ErrorCode, KrafkaError, Result};
use crate::network::{BrokerConnection, ConnectionPool};
use crate::protocol::{
ApiKey, MetadataRequest, MetadataResponse, VersionedDecode, VersionedEncode,
};
use crate::util::BackoffPolicy;
use crate::{BrokerId, PartitionId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum MetadataRecoveryStrategy {
None,
#[default]
Rebootstrap,
}
#[non_exhaustive]
#[must_use]
#[derive(Debug, Clone)]
pub struct BrokerInfo {
id: BrokerId,
host: String,
port: i32,
rack: Option<String>,
address: String,
}
impl BrokerInfo {
pub fn new(id: BrokerId, host: String, port: i32, rack: Option<String>) -> Self {
let address = format!("{host}:{port}");
Self {
id,
host,
port,
rack,
address,
}
}
#[inline]
pub fn host(&self) -> &str {
&self.host
}
#[inline]
pub fn id(&self) -> BrokerId {
self.id
}
#[inline]
pub fn port(&self) -> i32 {
self.port
}
#[inline]
pub fn rack(&self) -> Option<&str> {
self.rack.as_deref()
}
#[inline]
pub fn address(&self) -> &str {
&self.address
}
}
pub(crate) fn broker_info_for_node(
endpoints: &[crate::protocol::NodeEndpoint],
node_id: BrokerId,
) -> Option<BrokerInfo> {
endpoints
.iter()
.find(|endpoint| endpoint.node_id == node_id)
.map(|endpoint| {
BrokerInfo::new(
endpoint.node_id,
endpoint.host.clone(),
endpoint.port,
endpoint.rack.clone(),
)
})
}
#[non_exhaustive]
#[must_use]
#[derive(Debug, Clone)]
pub struct PartitionInfo {
pub topic: String,
pub partition: PartitionId,
pub leader: BrokerId,
pub leader_epoch: i32,
pub replicas: Vec<BrokerId>,
pub isr: Vec<BrokerId>,
pub offline_replicas: Vec<BrokerId>,
pub error_code: ErrorCode,
}
impl PartitionInfo {
#[inline]
#[must_use]
pub fn is_routable(&self) -> bool {
self.error_code.is_ok() && self.leader >= 0
}
}
#[non_exhaustive]
#[must_use]
#[derive(Debug, Clone)]
pub struct TopicInfo {
pub name: String,
pub topic_id: [u8; 16],
pub is_internal: bool,
pub partitions: std::collections::HashMap<PartitionId, PartitionInfo>,
}
impl TopicInfo {
#[inline]
pub fn partition_count(&self) -> usize {
self.partitions.len()
}
#[inline]
pub fn partition(&self, partition_id: PartitionId) -> Option<&PartitionInfo> {
self.partitions.get(&partition_id)
}
#[inline]
pub fn partitions_iter(&self) -> impl Iterator<Item = &PartitionInfo> + '_ {
self.partitions.values()
}
#[inline]
pub fn leader(&self, partition_id: PartitionId) -> Option<BrokerId> {
self.partition(partition_id)
.filter(|p| p.is_routable())
.map(|p| p.leader)
}
#[inline]
pub fn leader_epoch(&self, partition_id: PartitionId) -> Option<i32> {
self.partition(partition_id)
.map(|p| p.leader_epoch)
.filter(|e| *e >= 0)
}
}
const DEFAULT_RETRY_BACKOFF_MAX: Duration = Duration::from_millis(1000);
const DEFAULT_RETRY_BACKOFF: Duration = Duration::from_millis(100);
const RETRY_BACKOFF_JITTER: f64 = 0.2;
const REBOOTSTRAP_TRIGGER_JITTER: f64 = 0.2;
const CONNECT_FANOUT: usize = 3;
#[derive(Debug)]
struct RefreshBackoffState {
last_attempt_completed: Option<Instant>,
consecutive_failures: u32,
current_delay: Duration,
}
impl RefreshBackoffState {
fn new() -> Self {
Self {
last_attempt_completed: None,
consecutive_failures: 0,
current_delay: Duration::ZERO,
}
}
fn remaining(&self) -> Option<Duration> {
let last = self.last_attempt_completed?;
let elapsed = last.elapsed();
if elapsed >= self.current_delay {
None
} else {
Some(self.current_delay - elapsed)
}
}
fn record_success(&mut self, policy: &BackoffPolicy) {
self.consecutive_failures = 0;
self.current_delay = policy.calculate_backoff(1);
self.last_attempt_completed = Some(Instant::now());
}
fn record_failure(&mut self, policy: &BackoffPolicy) {
self.consecutive_failures = self.consecutive_failures.saturating_add(1);
self.current_delay = policy.calculate_backoff(self.consecutive_failures);
self.last_attempt_completed = Some(Instant::now());
}
}
fn now_millis() -> u64 {
static EPOCH: OnceLock<Instant> = OnceLock::new();
let epoch = EPOCH.get_or_init(Instant::now);
u64::try_from(epoch.elapsed().as_millis())
.unwrap_or(u64::MAX)
.saturating_add(1)
}
fn millis(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
#[derive(Debug)]
struct TopicStamp {
refreshed: Instant,
last_used_ms: AtomicU64,
}
impl TopicStamp {
fn fetched_now(last_used_ms: u64) -> Arc<Self> {
Arc::new(Self {
refreshed: Instant::now(),
last_used_ms: AtomicU64::new(last_used_ms),
})
}
fn is_live(&self, now: Instant, now_ms: u64, ttl: Duration) -> bool {
let used = self.last_used_ms.load(Ordering::Relaxed);
now.duration_since(self.refreshed) <= ttl
|| (used != 0 && now_ms.saturating_sub(used) <= millis(ttl))
}
}
#[derive(Debug, Clone)]
struct MetadataCache {
cluster_id: Option<String>,
controller_id: BrokerId,
brokers: AHashMap<BrokerId, BrokerInfo>,
topics: AHashMap<String, Arc<TopicInfo>>,
topic_ids: AHashMap<[u8; 16], Arc<String>>,
name_to_topic_id: AHashMap<String, [u8; 16]>,
topic_stamps: AHashMap<String, Arc<TopicStamp>>,
topic_errors: AHashMap<String, ErrorCode>,
last_updated: Instant,
generation: u64,
reset_epoch: u64,
}
impl MetadataCache {
fn new() -> Self {
Self {
cluster_id: None,
controller_id: -1,
brokers: AHashMap::new(),
topics: AHashMap::new(),
topic_ids: AHashMap::new(),
name_to_topic_id: AHashMap::new(),
topic_stamps: AHashMap::new(),
topic_errors: AHashMap::new(),
last_updated: Instant::now(),
generation: 0,
reset_epoch: 0,
}
}
fn is_stale(&self, max_age: Duration) -> bool {
self.last_updated.elapsed() > max_age
}
fn topic_is_fresh(&self, topic: &str, max_age: Duration) -> bool {
self.topics.contains_key(topic)
&& self
.topic_stamps
.get(topic)
.is_some_and(|stamp| stamp.refreshed.elapsed() <= max_age)
}
fn merge(
&self,
response: MetadataResponse,
full_refresh: bool,
topic_ttl: Option<Duration>,
) -> Self {
let now = Instant::now();
let now_ms = now_millis();
let brokers: AHashMap<BrokerId, BrokerInfo> = response
.brokers
.into_iter()
.map(|b| {
(
b.node_id,
BrokerInfo::new(b.node_id, b.host, b.port, b.rack),
)
})
.collect();
let retained = |name: &String| -> bool {
match topic_ttl {
None => true,
Some(ttl) => self
.topic_stamps
.get(name)
.is_some_and(|stamp| stamp.is_live(now, now_ms, ttl)),
}
};
let mut topics: AHashMap<String, Arc<TopicInfo>> = if full_refresh {
AHashMap::new()
} else {
let kept: AHashMap<_, _> = self
.topics
.iter()
.filter(|(name, _)| retained(name))
.map(|(k, v)| (k.clone(), Arc::clone(v)))
.collect();
let evicted = self.topics.len() - kept.len();
if evicted > 0 {
debug!(evicted, "evicted idle topics from metadata cache");
}
kept
};
let mut topic_errors: AHashMap<String, ErrorCode> = if full_refresh {
AHashMap::new()
} else {
self.topic_errors
.iter()
.filter(|(name, _)| match topic_ttl {
None => true,
Some(_) => topics.contains_key(name.as_str()),
})
.map(|(k, v)| (k.clone(), *v))
.collect()
};
let mut fetched: Vec<String> = Vec::new();
for topic in response.topics {
let Some(name) = topic.name else {
continue;
};
if !topic.error_code.is_ok() {
topic_errors.insert(name.clone(), topic.error_code);
let gone = matches!(
topic.error_code,
ErrorCode::UnknownTopicOrPartition | ErrorCode::UnknownTopicId
);
if topic.error_code.is_retriable() && !gone {
debug!(topic = %name, error = ?topic.error_code, "transient topic error; keeping cached entry");
if !topics.contains_key(&name)
&& let Some(previous) = self.topics.get(&name)
{
topics.insert(name.clone(), Arc::clone(previous));
}
if topics.contains_key(&name) {
fetched.push(name);
}
} else {
warn!(topic = %name, error = ?topic.error_code, "topic metadata error");
topics.remove(&name);
}
continue;
}
let topic_id = topic.topic_id.unwrap_or([0; 16]);
let cached = self.topics.get(&name).filter(|cached| {
let recreated = topic_id != [0; 16]
&& cached.topic_id != [0; 16]
&& cached.topic_id != topic_id;
if recreated {
info!(topic = %name, "topic ID changed; the topic was re-created");
}
!recreated
});
let partitions: std::collections::HashMap<PartitionId, PartitionInfo> = topic
.partitions
.into_iter()
.map(|p| {
let healthy = p.error_code.is_ok();
if !healthy {
debug!(
topic = %name,
partition = p.partition_index,
error = ?p.error_code,
"partition reported an error; retaining entry with no leader"
);
}
let incoming = PartitionInfo {
topic: name.clone(),
partition: p.partition_index,
leader: if healthy { p.leader_id } else { -1 },
leader_epoch: if healthy { p.leader_epoch } else { -1 },
replicas: p.replica_nodes,
isr: p.isr_nodes,
offline_replicas: p.offline_replicas,
error_code: p.error_code,
};
let merged = match cached.and_then(|t| t.partitions.get(&p.partition_index)) {
Some(previous)
if previous.leader_epoch >= 0
&& incoming.leader_epoch >= 0
&& incoming.leader_epoch < previous.leader_epoch =>
{
debug!(
topic = %name,
partition = p.partition_index,
cached_epoch = previous.leader_epoch,
response_epoch = incoming.leader_epoch,
"ignoring stale leader epoch from metadata response (KIP-320)"
);
previous.clone()
}
_ => incoming,
};
(p.partition_index, merged)
})
.collect();
topic_errors.remove(&name);
fetched.push(name.clone());
topics.insert(
name.clone(),
Arc::new(TopicInfo {
name,
topic_id,
is_internal: topic.is_internal,
partitions,
}),
);
}
let mut topic_stamps: AHashMap<String, Arc<TopicStamp>> = self
.topic_stamps
.iter()
.filter(|(name, _)| topics.contains_key(name.as_str()))
.map(|(k, v)| (k.clone(), Arc::clone(v)))
.collect();
for name in fetched {
let last_used = topic_stamps
.get(&name)
.map_or(0, |stamp| stamp.last_used_ms.load(Ordering::Relaxed));
topic_stamps.insert(name, TopicStamp::fetched_now(last_used));
}
let mut topic_ids: AHashMap<[u8; 16], Arc<String>> = AHashMap::new();
let mut name_to_topic_id: AHashMap<String, [u8; 16]> = AHashMap::new();
for (name, info) in &topics {
if info.topic_id != [0; 16] {
topic_ids.insert(info.topic_id, Arc::new(name.clone()));
name_to_topic_id.insert(name.clone(), info.topic_id);
}
}
Self {
cluster_id: response.cluster_id,
controller_id: response.controller_id,
brokers,
topics,
topic_ids,
name_to_topic_id,
topic_stamps,
topic_errors,
last_updated: now,
generation: self.generation + 1,
reset_epoch: self.reset_epoch,
}
}
}
#[derive(Default)]
struct PendingFetch {
full: bool,
topics: AHashSet<String>,
waiters: Vec<oneshot::Sender<Result<()>>>,
rebootstrap: bool,
}
impl PendingFetch {
fn is_empty(&self) -> bool {
!self.full && !self.rebootstrap && self.waiters.is_empty()
}
}
struct Inner {
bootstrap_servers: ArcSwap<Vec<String>>,
pool: Arc<ConnectionPool>,
cache: ArcSwap<MetadataCache>,
write_lock: SyncMutex<()>,
max_age: Duration,
retry_backoff: Option<BackoffPolicy>,
refresh_backoff: SyncMutex<RefreshBackoffState>,
recovery_strategy: MetadataRecoveryStrategy,
rebootstrap_trigger: Duration,
rebootstrap_jitter: Duration,
metadata_attempt_start: SyncMutex<Option<Instant>>,
topic_cache_ttl: Option<Duration>,
auto_create_topics: bool,
pending: SyncMutex<PendingFetch>,
wake: Notify,
writer_started: AtomicBool,
closed: AtomicBool,
}
pub struct ClusterMetadata {
inner: Arc<Inner>,
}
impl Drop for ClusterMetadata {
fn drop(&mut self) {
self.inner.closed.store(true, Ordering::Release);
self.inner.wake.notify_one();
}
}
impl ClusterMetadata {
pub fn new(
bootstrap_servers: Vec<String>,
pool: Arc<ConnectionPool>,
max_age: Duration,
) -> Self {
Self {
inner: Arc::new(Inner {
bootstrap_servers: ArcSwap::from_pointee(bootstrap_servers),
pool,
cache: ArcSwap::from_pointee(MetadataCache::new()),
write_lock: SyncMutex::new(()),
max_age,
retry_backoff: Some(Inner::default_retry_backoff_policy()),
refresh_backoff: SyncMutex::new(RefreshBackoffState::new()),
recovery_strategy: MetadataRecoveryStrategy::default(),
rebootstrap_trigger: Duration::from_secs(300),
rebootstrap_jitter: Duration::from_millis(500),
metadata_attempt_start: SyncMutex::new(None),
topic_cache_ttl: Some(Duration::from_secs(300)),
auto_create_topics: false,
pending: SyncMutex::new(PendingFetch::default()),
wake: Notify::new(),
writer_started: AtomicBool::new(false),
closed: AtomicBool::new(false),
}),
}
}
fn configure(mut self, apply: impl FnOnce(&mut Inner)) -> Self {
match Arc::get_mut(&mut self.inner) {
Some(inner) => apply(inner),
None => warn!("ClusterMetadata is already in use; configuration change ignored"),
}
self
}
#[must_use]
pub fn with_recovery_strategy(self, strategy: MetadataRecoveryStrategy) -> Self {
self.configure(|inner| inner.recovery_strategy = strategy)
}
#[must_use]
pub fn with_rebootstrap_trigger(self, duration: Duration) -> Self {
self.configure(|inner| inner.rebootstrap_trigger = duration)
}
#[must_use]
pub fn with_topic_cache_ttl(self, ttl: Duration) -> Self {
self.configure(|inner| inner.topic_cache_ttl = Some(ttl))
}
#[must_use]
pub fn with_topic_cache_ttl_disabled(self) -> Self {
self.configure(|inner| inner.topic_cache_ttl = None)
}
#[must_use]
pub fn with_auto_create_topics(self, allow: bool) -> Self {
self.configure(|inner| inner.auto_create_topics = allow)
}
#[must_use]
pub fn with_retry_backoff(self, backoff: impl Into<Option<Duration>>) -> Self {
let backoff = backoff.into();
self.configure(|inner| {
inner.retry_backoff = backoff.map(|base| {
let mut policy = inner
.retry_backoff
.take()
.unwrap_or_else(Inner::default_retry_backoff_policy);
policy.initial_backoff = base;
policy.max_backoff = policy.max_backoff.max(base);
policy
});
})
}
#[must_use]
pub fn with_retry_backoff_max(self, max_backoff: Duration) -> Self {
self.configure(|inner| {
if let Some(policy) = inner.retry_backoff.as_mut() {
policy.max_backoff = max_backoff.max(policy.initial_backoff);
}
})
}
#[must_use]
pub fn with_retry_backoff_policy(self, policy: BackoffPolicy) -> Self {
self.configure(|inner| inner.retry_backoff = Some(policy))
}
#[must_use]
pub fn with_rebootstrap_jitter(self, jitter: Duration) -> Self {
self.configure(|inner| inner.rebootstrap_jitter = jitter)
}
pub fn bootstrap_servers(&self) -> Vec<String> {
(**self.inner.bootstrap_servers.load()).clone()
}
pub(crate) fn max_age(&self) -> Duration {
self.inner.max_age
}
pub async fn refresh(&self) -> Result<()> {
self.request(None, true).await
}
pub async fn refresh_for_topics(&self, topics: Option<&[&str]>) -> Result<()> {
self.request(topics, false).await
}
pub async fn force_refresh(&self, topics: Option<&[&str]>) -> Result<()> {
self.request(topics, true).await
}
async fn request(&self, topics: Option<&[&str]>, force: bool) -> Result<()> {
if let Some(names) = topics {
self.touch_topics(names);
if !force && self.inner.all_fresh(names) {
return Ok(());
}
}
let receiver = {
let mut pending = self.inner.pending.lock();
match topics {
None => pending.full = true,
Some(names) => pending
.topics
.extend(names.iter().map(|name| (*name).to_string())),
}
let (sender, receiver) = oneshot::channel();
pending.waiters.push(sender);
receiver
};
self.wake_writer();
receiver
.await
.unwrap_or_else(|_| Err(KrafkaError::closed("the metadata writer has stopped")))
}
fn wake_writer(&self) {
if !self.inner.writer_started.swap(true, Ordering::AcqRel) {
tokio::spawn(run_writer(Arc::clone(&self.inner)));
}
self.inner.wake.notify_one();
}
pub fn update_seed_brokers(&self, servers: Vec<String>) -> Result<()> {
if servers.is_empty() {
return Err(KrafkaError::config(
"update_seed_brokers: at least one server required",
));
}
info!(count = servers.len(), "Updating seed brokers (KIP-899)");
self.inner.bootstrap_servers.store(Arc::new(servers));
Ok(())
}
pub async fn rebootstrap(&self) {
self.inner.rebootstrap("requested").await;
}
#[allow(dead_code)]
pub(crate) fn request_rebootstrap(&self) {
self.inner.pending.lock().rebootstrap = true;
self.wake_writer();
}
pub fn broker(&self, broker_id: BrokerId) -> Option<BrokerInfo> {
self.inner.cache.load().brokers.get(&broker_id).cloned()
}
pub fn brokers(&self) -> Vec<BrokerInfo> {
let mut brokers: Vec<BrokerInfo> =
self.inner.cache.load().brokers.values().cloned().collect();
brokers.sort_by_key(BrokerInfo::id);
brokers
}
pub fn topic(&self, name: &str) -> Option<TopicInfo> {
self.topic_arc(name).map(|t| t.as_ref().clone())
}
pub fn topic_arc(&self, name: &str) -> Option<Arc<TopicInfo>> {
self.touch_topic(name);
self.inner.cache.load().topics.get(name).map(Arc::clone)
}
pub fn topic_name_for_id(&self, topic_id: &[u8; 16]) -> Option<String> {
let name = self
.inner
.cache
.load()
.topic_ids
.get(topic_id)
.map(|name| (**name).clone());
if let Some(name) = name.as_deref() {
self.touch_topic(name);
}
name
}
pub fn topic_id_for_name(&self, name: &str) -> Option<[u8; 16]> {
self.touch_topic(name);
self.inner.cache.load().name_to_topic_id.get(name).copied()
}
pub fn topics(&self) -> Vec<TopicInfo> {
self.inner
.cache
.load()
.topics
.values()
.map(|t| t.as_ref().clone())
.collect()
}
pub fn topics_arc(&self) -> Vec<Arc<TopicInfo>> {
self.inner
.cache
.load()
.topics
.values()
.map(Arc::clone)
.collect()
}
pub fn leader(&self, topic: &str, partition: PartitionId) -> Option<BrokerId> {
self.touch_topic(topic);
self.inner
.cache
.load()
.topics
.get(topic)
.and_then(|t| t.leader(partition))
}
pub fn leader_epoch(&self, topic: &str, partition: PartitionId) -> Option<i32> {
self.touch_topic(topic);
self.inner
.cache
.load()
.topics
.get(topic)
.and_then(|t| t.leader_epoch(partition))
}
pub fn apply_leader_hint(
&self,
topic: &str,
partition: PartitionId,
leader_id: BrokerId,
leader_epoch: i32,
endpoint: Option<BrokerInfo>,
) -> bool {
if leader_id < 0 {
return false;
}
self.touch_topic(topic);
self.inner.write(|current| {
let endpoint_is_new = endpoint.as_ref().is_some_and(|info| {
current
.brokers
.get(&info.id())
.is_none_or(|known| known.address() != info.address())
});
let reachable = endpoint.is_some() || current.brokers.contains_key(&leader_id);
let partition_is_new = reachable
&& leader_epoch >= 0
&& current
.topics
.get(topic)
.and_then(|t| t.partition(partition))
.is_some_and(|p| p.leader_epoch < 0 || leader_epoch > p.leader_epoch);
if !endpoint_is_new && !partition_is_new {
return None;
}
let mut next = current.clone();
next.generation += 1;
if endpoint_is_new && let Some(info) = endpoint.clone() {
debug!(
node_id = info.id(),
address = info.address(),
"registering broker endpoint advertised with a leader hint (KIP-951)"
);
next.brokers.insert(info.id(), info);
}
if partition_is_new && let Some(cached_topic) = next.topics.get(topic) {
let mut updated = TopicInfo::clone(cached_topic);
if let Some(p) = updated.partitions.get_mut(&partition) {
debug!(
topic,
partition,
leader_id,
leader_epoch,
previous_leader = p.leader,
previous_epoch = p.leader_epoch,
"applying broker-reported leader (KIP-951)"
);
p.leader = leader_id;
p.leader_epoch = leader_epoch;
p.error_code = ErrorCode::None;
}
next.topics.insert(topic.to_string(), Arc::new(updated));
}
Some(next)
})
}
pub async fn get_leader_connection(
&self,
topic: &str,
partition: PartitionId,
) -> Result<Arc<BrokerConnection>> {
self.touch_topic(topic);
let resolve = |cache: &MetadataCache| -> Option<(BrokerId, String)> {
let leader_id = cache.topics.get(topic).and_then(|t| t.leader(partition))?;
let address = cache.brokers.get(&leader_id)?.address().to_string();
Some((leader_id, address))
};
let (resolved, fresh) = {
let cache = self.inner.cache.load();
(
resolve(&cache),
cache.topic_is_fresh(topic, self.inner.max_age),
)
};
let (leader_id, address) = match resolved {
Some(found) if fresh => found,
stale => {
self.request(Some(&[topic]), stale.is_none()).await?;
let cache = self.inner.cache.load();
match resolve(&cache) {
Some(found) => found,
None => {
let code = cache
.topic_errors
.get(topic)
.copied()
.unwrap_or(ErrorCode::LeaderNotAvailable);
return Err(KrafkaError::broker(
code,
format!("no routable leader for {topic}-{partition}"),
));
}
}
}
};
self.inner
.pool
.get_connection_by_id(leader_id, &address)
.await
}
pub async fn get_broker_connection(
&self,
broker_id: BrokerId,
) -> Result<Arc<BrokerConnection>> {
let address = match self.broker(broker_id) {
Some(broker) => broker.address().to_string(),
None => {
self.force_refresh(Some(&[])).await?;
self.broker(broker_id)
.ok_or_else(|| {
KrafkaError::broker(
ErrorCode::BrokerNotAvailable,
format!("broker {broker_id} is not in the cluster metadata"),
)
})?
.address()
.to_string()
}
};
self.inner
.pool
.get_connection_by_id(broker_id, &address)
.await
}
pub fn controller(&self) -> Option<BrokerInfo> {
let cache = self.inner.cache.load();
if cache.controller_id < 0 {
return None;
}
cache.brokers.get(&cache.controller_id).cloned()
}
pub fn cluster_id(&self) -> Option<String> {
self.inner.cache.load().cluster_id.clone()
}
pub fn needs_refresh(&self) -> bool {
self.inner.cache.load().is_stale(self.inner.max_age)
}
pub fn partition_count(&self, topic: &str) -> Option<usize> {
self.touch_topic(topic);
self.inner
.cache
.load()
.topics
.get(topic)
.map(|t| t.partition_count())
}
pub async fn ensure_partition_count(&self, topic: &str, max_wait: Duration) -> Result<usize> {
if let Some(count) = self.partition_count(topic).filter(|count| *count > 0) {
return Ok(count);
}
let deadline = Instant::now() + max_wait;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
match tokio::time::timeout(remaining, self.force_refresh(Some(&[topic]))).await {
Err(_) => break,
Ok(Ok(())) => {
if let Some(count) = self.partition_count(topic).filter(|count| *count > 0) {
return Ok(count);
}
}
Ok(Err(e)) if !e.is_retriable() => return Err(e),
Ok(Err(e)) => {
debug!(topic, error = %e, "metadata fetch for an unknown topic failed; retrying within max_wait");
}
}
if let Some(code) = self.topic_error(topic)
&& !code.is_retriable()
{
return Err(KrafkaError::broker(
code,
format!("metadata for topic {topic} was rejected by the broker"),
));
}
if self.inner.retry_backoff.is_none() {
let remaining = deadline.saturating_duration_since(Instant::now());
tokio::time::sleep(DEFAULT_RETRY_BACKOFF.min(remaining)).await;
}
}
Err(match self.topic_error(topic) {
Some(code) => KrafkaError::broker(
code,
format!(
"topic {topic} is still not present in cluster metadata after {} ms",
max_wait.as_millis()
),
),
None => KrafkaError::timeout(format!(
"topic {topic} is not present in cluster metadata after {} ms",
max_wait.as_millis()
)),
})
}
pub fn topic_error(&self, topic: &str) -> Option<ErrorCode> {
self.inner.cache.load().topic_errors.get(topic).copied()
}
pub fn touch_topic(&self, topic: &str) {
if self.inner.topic_cache_ttl.is_none() {
return;
}
if let Some(stamp) = self.inner.cache.load().topic_stamps.get(topic) {
stamp.last_used_ms.store(now_millis(), Ordering::Relaxed);
}
}
pub fn touch_topics(&self, topics: &[&str]) {
if self.inner.topic_cache_ttl.is_none() {
return;
}
let cache = self.inner.cache.load();
let now = now_millis();
for topic in topics {
if let Some(stamp) = cache.topic_stamps.get(*topic) {
stamp.last_used_ms.store(now, Ordering::Relaxed);
}
}
}
}
impl Inner {
fn default_retry_backoff_policy() -> BackoffPolicy {
BackoffPolicy {
initial_backoff: DEFAULT_RETRY_BACKOFF,
max_backoff: DEFAULT_RETRY_BACKOFF_MAX,
backoff_multiplier: 2.0,
jitter_factor: RETRY_BACKOFF_JITTER,
}
}
fn all_fresh(&self, names: &[&str]) -> bool {
let cache = self.cache.load();
!cache.brokers.is_empty()
&& names
.iter()
.all(|name| cache.topic_is_fresh(name, self.max_age))
}
fn write(&self, update: impl FnOnce(&MetadataCache) -> Option<MetadataCache>) -> bool {
let _serialized = self.write_lock.lock();
let current = self.cache.load_full();
match update(¤t) {
Some(next) => {
self.cache.store(Arc::new(next));
true
}
None => false,
}
}
fn apply(&self, response: MetadataResponse, full_refresh: bool, reset_epoch: u64) -> bool {
let ttl = self.topic_cache_ttl;
self.write(|current| {
if current.reset_epoch != reset_epoch {
debug!("discarding a metadata response fetched before a rebootstrap");
return None;
}
let next = current.merge(response, full_refresh, ttl);
debug!(
brokers = next.brokers.len(),
topics = next.topics.len(),
"updated metadata"
);
Some(next)
})
}
fn backoff_remaining(&self) -> Option<Duration> {
self.retry_backoff.as_ref()?;
self.refresh_backoff.lock().remaining()
}
async fn refresh(&self, topics: Option<&[String]>) -> Result<()> {
self.metadata_attempt_start
.lock()
.get_or_insert_with(Instant::now);
let result = self.refresh_attempt(topics).await;
if let Some(policy) = self.retry_backoff.as_ref() {
let mut backoff = self.refresh_backoff.lock();
match &result {
Ok(()) => backoff.record_success(policy),
Err(_) => backoff.record_failure(policy),
}
}
result
}
async fn refresh_attempt(&self, topics: Option<&[String]>) -> Result<()> {
let rebootstrap_enabled = self.recovery_strategy == MetadataRecoveryStrategy::Rebootstrap;
let mut rebootstrapped = false;
if self.needs_rebootstrap() {
self.rebootstrap("no successful metadata response within the rebootstrap trigger")
.await;
rebootstrapped = true;
}
for _ in 0..3 {
let reset_epoch = self.cache.load().reset_epoch;
let conn = match self.get_any_connection().await {
Ok(conn) => conn,
Err(e) => {
if rebootstrap_enabled
&& !rebootstrapped
&& !self.cache.load().brokers.is_empty()
{
self.rebootstrap("no known broker is reachable").await;
rebootstrapped = true;
continue;
}
return Err(e);
}
};
let version = conn
.negotiate_api_version(
ApiKey::Metadata,
crate::protocol::versions::METADATA_MAX,
crate::protocol::versions::METADATA_MIN,
)
.unwrap_or(crate::protocol::versions::METADATA_MIN);
let request = match topics {
Some(names) => {
let mut request =
MetadataRequest::for_topics(names.iter().map(String::as_str).collect());
request.allow_auto_topic_creation = self.auto_create_topics;
request
}
None => MetadataRequest::all_topics(),
};
let mut response = conn
.send_request(ApiKey::Metadata, version, |buf| {
request.encode_versioned(version, buf)
})
.await?;
let metadata = MetadataResponse::decode_versioned(version, &mut response)?;
if metadata.error_code == ErrorCode::RebootstrapRequired
&& rebootstrap_enabled
&& !rebootstrapped
{
info!("broker requested a rebootstrap (REBOOTSTRAP_REQUIRED)");
self.rebootstrap("REBOOTSTRAP_REQUIRED").await;
rebootstrapped = true;
continue;
}
if !metadata.error_code.is_ok() {
return Err(KrafkaError::broker(
metadata.error_code,
"metadata request failed",
));
}
*self.metadata_attempt_start.lock() = None;
if self.apply(metadata, topics.is_none(), reset_epoch) {
return Ok(());
}
}
Err(KrafkaError::unavailable(
"metadata was reset while the fetch was in flight",
))
}
async fn rebootstrap(&self, reason: &str) {
let delay = if self.rebootstrap_jitter.is_zero() {
Duration::ZERO
} else {
use rand::Rng as _;
let nanos = crate::util::with_rng(|rng| {
rng.random_range(0..self.rebootstrap_jitter.as_nanos().max(1))
});
Duration::from_nanos(u64::try_from(nanos).unwrap_or(u64::MAX))
};
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
warn!(
reason,
"rebootstrapping: rediscovering the cluster from the bootstrap servers (KIP-899)"
);
self.reset_to_seeds();
}
fn reset_to_seeds(&self) {
self.write(|current| {
let mut next = MetadataCache::new();
next.generation = current.generation + 1;
next.reset_epoch = current.reset_epoch + 1;
Some(next)
});
*self.metadata_attempt_start.lock() = Some(Instant::now());
}
fn needs_rebootstrap(&self) -> bool {
if self.recovery_strategy != MetadataRecoveryStrategy::Rebootstrap {
return false;
}
let Some(attempt_start) = *self.metadata_attempt_start.lock() else {
return false;
};
let elapsed = attempt_start.elapsed();
let effective_trigger = {
use rand::Rng as _;
let spread = self.rebootstrap_trigger.mul_f64(REBOOTSTRAP_TRIGGER_JITTER);
if spread.is_zero() {
self.rebootstrap_trigger
} else {
let nanos =
crate::util::with_rng(|rng| rng.random_range(0..spread.as_nanos().max(1)));
self.rebootstrap_trigger
+ Duration::from_nanos(u64::try_from(nanos).unwrap_or(u64::MAX))
}
};
elapsed >= effective_trigger
}
async fn get_any_connection(&self) -> Result<Arc<BrokerConnection>> {
let mut addrs = self.connection_candidates();
if addrs.is_empty() {
return Err(KrafkaError::unavailable(
"no bootstrap servers or brokers to connect to",
));
}
{
use rand::seq::SliceRandom as _;
crate::util::with_rng(|rng| addrs.shuffle(rng));
}
use futures::StreamExt as _;
let mut cause: Option<KrafkaError> = None;
for chunk in addrs.chunks(CONNECT_FANOUT) {
let mut attempts: futures::stream::FuturesUnordered<_> = chunk
.iter()
.map(|addr| {
let pool = Arc::clone(&self.pool);
let addr = addr.clone();
async move { pool.get_connection(&addr).await }
})
.collect();
while let Some(attempt) = attempts.next().await {
match attempt {
Ok(conn) => return Ok(conn),
Err(e) => {
if !matches!(cause, Some(KrafkaError::Auth { .. })) {
cause = Some(e);
}
}
}
}
}
Err(no_broker_reachable(&addrs, cause))
}
fn connection_candidates(&self) -> Vec<String> {
let cache = self.cache.load();
let servers = self.bootstrap_servers.load();
let mut addrs: Vec<String> = Vec::with_capacity(cache.brokers.len() + servers.len());
let mut seen: AHashSet<&str> = AHashSet::with_capacity(cache.brokers.len());
let mut brokers: Vec<&BrokerInfo> = cache.brokers.values().collect();
brokers.sort_by_key(|b| b.id());
for broker in brokers {
if seen.insert(broker.address()) {
addrs.push(broker.address().to_string());
}
}
for s in servers.iter() {
if seen.insert(s.as_str()) {
addrs.push(s.clone());
}
}
addrs
}
fn fail_pending(&self) {
let pending = std::mem::take(&mut *self.pending.lock());
for waiter in pending.waiters {
let _ = waiter.send(Err(KrafkaError::closed("cluster metadata was dropped")));
}
}
}
fn no_broker_reachable(addrs: &[String], last: Option<KrafkaError>) -> KrafkaError {
let tried = addrs.join(", ");
match last {
Some(KrafkaError::Auth { message, source }) => KrafkaError::Auth {
message: format!("could not connect to any of [{tried}]: {message}"),
source,
},
Some(last) => KrafkaError::network(std::io::Error::new(
std::io::ErrorKind::NotConnected,
NoBrokerReachable { tried, last },
)),
None => KrafkaError::unavailable(format!("could not connect to any of [{tried}]")),
}
}
#[derive(Debug)]
struct NoBrokerReachable {
tried: String,
last: KrafkaError,
}
impl std::fmt::Display for NoBrokerReachable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"could not connect to any of [{}]; last error: {}",
self.tried, self.last
)
}
}
impl std::error::Error for NoBrokerReachable {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.last)
}
}
async fn run_writer(inner: Arc<Inner>) {
loop {
if inner.closed.load(Ordering::Acquire) {
inner.fail_pending();
return;
}
if inner.pending.lock().is_empty() {
inner.wake.notified().await;
continue;
}
if let Some(wait) = inner.backoff_remaining() {
tokio::time::sleep(wait).await;
}
let batch = std::mem::take(&mut *inner.pending.lock());
if batch.rebootstrap {
inner.rebootstrap("requested by the protocol").await;
}
if batch.waiters.iter().all(oneshot::Sender::is_closed) {
continue;
}
let topics: Option<Vec<String>> = if batch.full {
None
} else {
Some(batch.topics.into_iter().collect())
};
let result = inner.refresh(topics.as_deref()).await;
if let Err(e) = &result {
debug!(error = %e, "metadata fetch failed");
}
for waiter in batch.waiters {
let _ = waiter.send(result.clone());
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
impl ClusterMetadata {
fn update_cache(&self, response: MetadataResponse, full_refresh: bool) {
let epoch = self.inner.cache.load().reset_epoch;
assert!(self.inner.apply(response, full_refresh, epoch));
}
}
#[test]
fn test_broker_info_address() {
let broker = BrokerInfo::new(1, "localhost".to_string(), 9092, None);
assert_eq!(broker.address(), "localhost:9092");
}
#[test]
fn test_topic_info() {
let topic = TopicInfo {
name: "test".to_string(),
topic_id: [0; 16],
is_internal: false,
partitions: [
(
0,
PartitionInfo {
topic: "test".to_string(),
partition: 0,
leader: 1,
leader_epoch: 0,
replicas: vec![1, 2, 3],
isr: vec![1, 2, 3],
offline_replicas: vec![],
error_code: ErrorCode::None,
},
),
(
1,
PartitionInfo {
topic: "test".to_string(),
partition: 1,
leader: 2,
leader_epoch: 0,
replicas: vec![2, 3, 1],
isr: vec![2, 3, 1],
offline_replicas: vec![],
error_code: ErrorCode::None,
},
),
]
.into_iter()
.collect(),
};
assert_eq!(topic.partition_count(), 2);
assert_eq!(topic.leader(0), Some(1));
assert_eq!(topic.leader(1), Some(2));
assert_eq!(topic.leader(2), None);
}
#[test]
fn test_metadata_cache_stale() {
let cache = MetadataCache::new();
assert!(!cache.is_stale(Duration::from_secs(60)));
}
#[test]
fn test_metadata_cache_new_is_empty() {
let cache = MetadataCache::new();
assert!(cache.brokers.is_empty());
assert!(cache.topics.is_empty());
assert!(cache.cluster_id.is_none());
assert_eq!(cache.controller_id, -1);
}
#[test]
fn test_broker_info_with_rack() {
let broker = BrokerInfo::new(
1,
"broker1.kafka.local".to_string(),
9093,
Some("us-east-1a".to_string()),
);
assert_eq!(broker.address(), "broker1.kafka.local:9093");
assert_eq!(broker.rack(), Some("us-east-1a"));
}
#[test]
fn test_metadata_cache_topic_ids() {
let mut cache = MetadataCache::new();
assert!(cache.topic_ids.is_empty());
let uuid: [u8; 16] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
cache
.topic_ids
.insert(uuid, Arc::new("my-topic".to_string()));
assert_eq!(
cache.topic_ids.get(&uuid),
Some(&Arc::new("my-topic".to_string()))
);
}
#[test]
fn test_metadata_cache_new_has_empty_topic_ids() {
let cache = MetadataCache::new();
assert!(cache.topic_ids.is_empty());
}
#[test]
fn test_metadata_recovery_strategy_default() {
assert_eq!(
MetadataRecoveryStrategy::default(),
MetadataRecoveryStrategy::Rebootstrap,
"KIP-1102 made rebootstrap the default"
);
}
#[test]
fn test_cluster_metadata_with_recovery_strategy() {
let pool = Arc::new(ConnectionPool::new(
crate::network::ConnectionConfig::default(),
));
let meta = ClusterMetadata::new(
vec!["localhost:9092".to_string()],
pool,
Duration::from_secs(300),
)
.with_recovery_strategy(MetadataRecoveryStrategy::Rebootstrap)
.with_rebootstrap_trigger(Duration::from_secs(60));
assert_eq!(
meta.inner.recovery_strategy,
MetadataRecoveryStrategy::Rebootstrap
);
assert_eq!(meta.inner.rebootstrap_trigger, Duration::from_secs(60));
}
#[test]
fn test_update_seed_brokers() {
let pool = Arc::new(ConnectionPool::new(
crate::network::ConnectionConfig::default(),
));
let meta = ClusterMetadata::new(
vec!["broker1:9092".to_string()],
pool,
Duration::from_secs(300),
);
assert_eq!(meta.bootstrap_servers(), vec!["broker1:9092"]);
meta.update_seed_brokers(vec!["broker2:9092".to_string(), "broker3:9092".to_string()])
.unwrap();
assert_eq!(
meta.bootstrap_servers(),
vec!["broker2:9092", "broker3:9092"]
);
}
#[test]
fn test_update_seed_brokers_rejects_empty() {
let pool = Arc::new(ConnectionPool::new(
crate::network::ConnectionConfig::default(),
));
let meta = ClusterMetadata::new(
vec!["broker1:9092".to_string()],
pool,
Duration::from_secs(300),
);
let result = meta.update_seed_brokers(vec![]);
assert!(result.is_err());
assert_eq!(meta.bootstrap_servers(), vec!["broker1:9092"]);
}
#[test]
fn test_needs_rebootstrap_never_fires_with_strategy_none() {
let pool = Arc::new(ConnectionPool::new(
crate::network::ConnectionConfig::default(),
));
let meta = ClusterMetadata::new(
vec!["localhost:9092".to_string()],
pool,
Duration::from_secs(300),
)
.with_recovery_strategy(MetadataRecoveryStrategy::None)
.with_rebootstrap_trigger(Duration::ZERO);
*meta.inner.metadata_attempt_start.lock() = Some(Instant::now() - Duration::from_secs(1));
assert!(!meta.inner.needs_rebootstrap());
}
#[test]
fn test_needs_rebootstrap_not_yet_triggered() {
let pool = Arc::new(ConnectionPool::new(
crate::network::ConnectionConfig::default(),
));
let meta = ClusterMetadata::new(
vec!["localhost:9092".to_string()],
pool,
Duration::from_secs(300),
)
.with_recovery_strategy(MetadataRecoveryStrategy::Rebootstrap)
.with_rebootstrap_trigger(Duration::from_secs(300));
assert!(!meta.inner.needs_rebootstrap());
{
let mut start = meta.inner.metadata_attempt_start.lock();
*start = Some(Instant::now());
}
assert!(!meta.inner.needs_rebootstrap());
assert!(meta.inner.metadata_attempt_start.lock().is_some());
}
#[tokio::test]
async fn test_needs_rebootstrap_triggers_after_timeout() {
let pool = Arc::new(ConnectionPool::new(
crate::network::ConnectionConfig::default(),
));
let meta = ClusterMetadata::new(
vec!["localhost:9092".to_string()],
pool,
Duration::from_secs(300),
)
.with_recovery_strategy(MetadataRecoveryStrategy::Rebootstrap)
.with_rebootstrap_trigger(Duration::ZERO) .with_rebootstrap_jitter(Duration::ZERO);
{
let mut start = meta.inner.metadata_attempt_start.lock();
*start = Some(Instant::now());
}
assert!(meta.inner.needs_rebootstrap());
meta.rebootstrap().await;
assert!(meta.inner.metadata_attempt_start.lock().is_some());
assert!(meta.inner.cache.load().brokers.is_empty());
}
#[tokio::test]
async fn test_rebootstrap_clears_cache() {
let pool = Arc::new(ConnectionPool::new(
crate::network::ConnectionConfig::default(),
));
let meta = ClusterMetadata::new(
vec!["localhost:9092".to_string()],
pool,
Duration::from_secs(300),
)
.with_rebootstrap_jitter(Duration::ZERO);
let mut cache = MetadataCache::new();
cache
.brokers
.insert(1, BrokerInfo::new(1, "host".to_string(), 9092, None));
meta.inner.cache.store(Arc::new(cache));
assert!(!meta.inner.cache.load().brokers.is_empty());
meta.rebootstrap().await;
assert!(meta.inner.cache.load().brokers.is_empty());
assert!(meta.inner.metadata_attempt_start.lock().is_some());
}
#[test]
fn test_topic_cache_ttl_default_is_five_minutes() {
let pool = Arc::new(ConnectionPool::new(
crate::network::ConnectionConfig::default(),
));
let meta = ClusterMetadata::new(
vec!["localhost:9092".to_string()],
pool,
Duration::from_secs(300),
);
assert_eq!(meta.inner.topic_cache_ttl, Some(Duration::from_secs(300)));
}
#[test]
fn test_topic_cache_ttl_disabled_opt_out() {
let pool = Arc::new(ConnectionPool::new(
crate::network::ConnectionConfig::default(),
));
let meta = ClusterMetadata::new(
vec!["localhost:9092".to_string()],
pool,
Duration::from_secs(300),
)
.with_topic_cache_ttl_disabled();
assert_eq!(meta.inner.topic_cache_ttl, None);
}
fn ok_topics_response(topic_names: &[&str]) -> MetadataResponse {
use crate::protocol::{MetadataBroker, MetadataPartitionResponse, MetadataTopicResponse};
MetadataResponse {
throttle_time_ms: 0,
brokers: vec![MetadataBroker {
node_id: 1,
host: "localhost".to_string(),
port: 9092,
rack: None,
}],
cluster_id: None,
controller_id: 1,
error_code: ErrorCode::None,
topics: topic_names
.iter()
.map(|name| MetadataTopicResponse {
error_code: ErrorCode::None,
name: Some((*name).to_string()),
topic_id: None,
is_internal: false,
partitions: vec![MetadataPartitionResponse {
error_code: ErrorCode::None,
partition_index: 0,
leader_id: 1,
leader_epoch: 0,
replica_nodes: vec![1],
isr_nodes: vec![1],
offline_replicas: vec![],
}],
})
.collect(),
}
}
fn ttl_metadata(ttl: Duration) -> ClusterMetadata {
let pool = Arc::new(ConnectionPool::new(
crate::network::ConnectionConfig::default(),
));
ClusterMetadata::new(
vec!["localhost:9092".to_string()],
pool,
Duration::from_secs(300),
)
.with_topic_cache_ttl(ttl)
}
#[test]
fn test_a_topic_in_use_survives_a_partial_refresh_that_does_not_name_it() {
let meta = ttl_metadata(Duration::from_millis(50));
meta.update_cache(ok_topics_response(&["topic-a", "topic-b"]), true);
std::thread::sleep(Duration::from_millis(80));
meta.touch_topic("topic-b");
meta.update_cache(ok_topics_response(&["topic-a"]), false);
let cache = meta.inner.cache.load();
assert!(
cache.topics.contains_key("topic-a"),
"topic-a was in the response and must be cached"
);
assert!(
cache.topics.contains_key("topic-b"),
"topic-b is in active use and must not be evicted by a refresh for topic-a"
);
}
#[test]
fn test_an_idle_topic_is_evicted_by_a_partial_refresh() {
let meta = ttl_metadata(Duration::from_millis(50));
meta.update_cache(ok_topics_response(&["topic-a", "topic-b"]), true);
meta.touch_topic("topic-b");
std::thread::sleep(Duration::from_millis(80));
meta.update_cache(ok_topics_response(&["topic-a"]), false);
let cache = meta.inner.cache.load();
assert!(cache.topics.contains_key("topic-a"));
assert!(
!cache.topics.contains_key("topic-b"),
"a topic idle for longer than the TTL must be evicted"
);
}
#[test]
fn test_a_freshly_refreshed_topic_is_not_evicted_before_it_is_used() {
let meta = ttl_metadata(Duration::from_secs(60));
meta.update_cache(ok_topics_response(&["topic-a", "topic-b"]), true);
meta.update_cache(ok_topics_response(&["topic-a"]), false);
assert!(
meta.inner.cache.load().topics.contains_key("topic-b"),
"a topic refreshed within the TTL must survive even with no recorded use"
);
}
fn last_used(meta: &ClusterMetadata, topic: &str) -> Option<u64> {
meta.inner
.cache
.load()
.topic_stamps
.get(topic)
.map(|stamp| stamp.last_used_ms.load(Ordering::Relaxed))
.filter(|ms| *ms != 0)
}
#[test]
fn test_read_accessors_record_topic_usage() {
let meta = ttl_metadata(Duration::from_secs(60));
meta.update_cache(ok_topics_response(&["topic-a", "topic-b"]), true);
assert!(
last_used(&meta, "topic-a").is_none(),
"a refresh alone is not a use"
);
assert_eq!(meta.partition_count("topic-a"), Some(1));
assert!(last_used(&meta, "topic-a").is_some());
assert!(meta.leader("topic-b", 0).is_some());
assert!(last_used(&meta, "topic-b").is_some());
}
#[test]
fn test_a_refetch_keeps_the_last_use() {
let meta = ttl_metadata(Duration::from_secs(60));
meta.update_cache(ok_topics_response(&["topic-a"]), true);
meta.touch_topic("topic-a");
let used = last_used(&meta, "topic-a");
assert!(used.is_some());
meta.update_cache(ok_topics_response(&["topic-a"]), false);
assert_eq!(last_used(&meta, "topic-a"), used);
}
#[test]
fn test_usage_is_not_tracked_when_ttl_eviction_is_disabled() {
let pool = Arc::new(ConnectionPool::new(
crate::network::ConnectionConfig::default(),
));
let meta = ClusterMetadata::new(
vec!["localhost:9092".to_string()],
pool,
Duration::from_secs(300),
)
.with_topic_cache_ttl_disabled();
meta.update_cache(ok_topics_response(&["topic-a"]), true);
meta.touch_topic("topic-a");
assert!(last_used(&meta, "topic-a").is_none());
}
#[test]
fn test_topic_stamps_track_exactly_the_cached_topics() {
let meta = ttl_metadata(Duration::from_millis(10));
for i in 0..50 {
meta.touch_topic(&format!("never-fetched-{i}"));
}
assert!(meta.inner.cache.load().topic_stamps.is_empty());
meta.update_cache(ok_topics_response(&["topic-a", "topic-b"]), true);
std::thread::sleep(Duration::from_millis(30));
meta.update_cache(ok_topics_response(&["topic-a"]), false);
let cache = meta.inner.cache.load();
let mut stamped: Vec<&String> = cache.topic_stamps.keys().collect();
stamped.sort();
assert_eq!(stamped, vec!["topic-a"]);
}
#[test]
fn test_topic_errors_are_recorded_and_cleared() {
use crate::protocol::{MetadataBroker, MetadataTopicResponse};
fn errored(topic: &str, code: ErrorCode) -> MetadataResponse {
MetadataResponse {
throttle_time_ms: 0,
brokers: vec![MetadataBroker {
node_id: 1,
host: "localhost".to_string(),
port: 9092,
rack: None,
}],
cluster_id: None,
controller_id: 1,
error_code: ErrorCode::None,
topics: vec![MetadataTopicResponse {
error_code: code,
name: Some(topic.to_string()),
topic_id: None,
is_internal: false,
partitions: vec![],
}],
}
}
let meta = ttl_metadata(Duration::from_secs(60));
meta.update_cache(
errored("secret", ErrorCode::TopicAuthorizationFailed),
false,
);
assert_eq!(
meta.topic_error("secret"),
Some(ErrorCode::TopicAuthorizationFailed)
);
meta.update_cache(
errored("missing", ErrorCode::UnknownTopicOrPartition),
false,
);
assert_eq!(
meta.topic_error("missing"),
Some(ErrorCode::UnknownTopicOrPartition),
"a retriable topic error is recorded too: it is the reason a caller is waiting"
);
meta.update_cache(ok_topics_response(&["missing"]), false);
assert_eq!(
meta.topic_error("missing"),
None,
"a topic that comes back healthy has no outstanding error"
);
}
#[test]
fn test_partial_refresh_preserves_retained_topic_timestamps() {
use crate::protocol::{MetadataBroker, MetadataPartitionResponse, MetadataTopicResponse};
fn make_response(topic_names: &[&str]) -> MetadataResponse {
MetadataResponse {
throttle_time_ms: 0,
brokers: vec![MetadataBroker {
node_id: 1,
host: "localhost".to_string(),
port: 9092,
rack: None,
}],
cluster_id: None,
controller_id: 1,
error_code: ErrorCode::None,
topics: topic_names
.iter()
.map(|name| MetadataTopicResponse {
error_code: ErrorCode::None,
name: Some(name.to_string()),
topic_id: None,
is_internal: false,
partitions: vec![MetadataPartitionResponse {
error_code: ErrorCode::None,
partition_index: 0,
leader_id: 1,
leader_epoch: 0,
replica_nodes: vec![1],
isr_nodes: vec![1],
offline_replicas: vec![],
}],
})
.collect(),
}
}
let pool = Arc::new(ConnectionPool::new(
crate::network::ConnectionConfig::default(),
));
let meta = ClusterMetadata::new(
vec!["localhost:9092".to_string()],
pool,
Duration::from_secs(300),
);
meta.update_cache(make_response(&["topic-a"]), false);
let ts_a = meta
.inner
.cache
.load()
.topic_stamps
.get("topic-a")
.map(|s| s.refreshed)
.unwrap();
meta.update_cache(make_response(&["topic-b"]), false);
let cache = meta.inner.cache.load();
assert!(
cache.topics.contains_key("topic-a"),
"topic-a should still be in the cache (TTL not yet expired)"
);
assert!(
cache.topics.contains_key("topic-b"),
"topic-b should appear after the second update"
);
let ts_a_after = cache
.topic_stamps
.get("topic-a")
.map(|s| s.refreshed)
.unwrap();
assert_eq!(
ts_a, ts_a_after,
"retained topic-a's timestamp must not be advanced by a partial refresh"
);
assert!(
cache.topic_stamps.contains_key("topic-b"),
"freshly refreshed topic-b must have a timestamp"
);
}
#[test]
fn test_transient_error_topic_refreshes_ttl_timestamp() {
use crate::protocol::{MetadataBroker, MetadataPartitionResponse, MetadataTopicResponse};
fn make_ok_response(topic_names: &[&str]) -> MetadataResponse {
MetadataResponse {
throttle_time_ms: 0,
brokers: vec![MetadataBroker {
node_id: 1,
host: "localhost".to_string(),
port: 9092,
rack: None,
}],
cluster_id: None,
controller_id: 1,
error_code: ErrorCode::None,
topics: topic_names
.iter()
.map(|name| MetadataTopicResponse {
error_code: ErrorCode::None,
name: Some(name.to_string()),
topic_id: None,
is_internal: false,
partitions: vec![MetadataPartitionResponse {
error_code: ErrorCode::None,
partition_index: 0,
leader_id: 1,
leader_epoch: 0,
replica_nodes: vec![1],
isr_nodes: vec![1],
offline_replicas: vec![],
}],
})
.collect(),
}
}
fn make_transient_error_response(topic_name: &str) -> MetadataResponse {
MetadataResponse {
throttle_time_ms: 0,
brokers: vec![MetadataBroker {
node_id: 1,
host: "localhost".to_string(),
port: 9092,
rack: None,
}],
cluster_id: None,
controller_id: 1,
error_code: ErrorCode::None,
topics: vec![MetadataTopicResponse {
error_code: ErrorCode::LeaderNotAvailable,
name: Some(topic_name.to_string()),
topic_id: None,
is_internal: false,
partitions: vec![],
}],
}
}
let pool = Arc::new(ConnectionPool::new(
crate::network::ConnectionConfig::default(),
));
let meta = ClusterMetadata::new(
vec!["localhost:9092".to_string()],
pool,
Duration::from_secs(300),
);
meta.update_cache(make_ok_response(&["topic-a"]), false);
let ts_before = meta
.inner
.cache
.load()
.topic_stamps
.get("topic-a")
.map(|s| s.refreshed)
.unwrap();
meta.update_cache(make_transient_error_response("topic-a"), false);
let cache = meta.inner.cache.load();
assert!(
cache.topics.contains_key("topic-a"),
"topic-a must be retained when the response has a transient error"
);
let ts_after = cache
.topic_stamps
.get("topic-a")
.map(|s| s.refreshed)
.unwrap();
assert!(
ts_after >= ts_before,
"transient-error response must advance the TTL timestamp so the topic \
is not evicted on the next refresh"
);
}
#[test]
fn test_transient_error_restores_ttl_evicted_topic() {
use crate::protocol::{MetadataBroker, MetadataPartitionResponse, MetadataTopicResponse};
let pool = Arc::new(ConnectionPool::new(
crate::network::ConnectionConfig::default(),
));
let meta = ClusterMetadata::new(
vec!["localhost:9092".to_string()],
pool,
Duration::from_secs(300),
)
.with_topic_cache_ttl(Duration::from_nanos(1));
meta.update_cache(
MetadataResponse {
throttle_time_ms: 0,
brokers: vec![MetadataBroker {
node_id: 1,
host: "localhost".to_string(),
port: 9092,
rack: None,
}],
cluster_id: None,
controller_id: 1,
error_code: ErrorCode::None,
topics: vec![MetadataTopicResponse {
error_code: ErrorCode::None,
name: Some("topic-a".to_string()),
topic_id: None,
is_internal: false,
partitions: vec![MetadataPartitionResponse {
error_code: ErrorCode::None,
partition_index: 0,
leader_id: 1,
leader_epoch: 0,
replica_nodes: vec![1],
isr_nodes: vec![1],
offline_replicas: vec![],
}],
}],
},
false,
);
assert!(
meta.inner.cache.load().topics.contains_key("topic-a"),
"pre-condition: topic-a seeded"
);
std::thread::sleep(Duration::from_millis(20));
meta.update_cache(
MetadataResponse {
throttle_time_ms: 0,
brokers: vec![MetadataBroker {
node_id: 1,
host: "localhost".to_string(),
port: 9092,
rack: None,
}],
cluster_id: None,
controller_id: 1,
error_code: ErrorCode::None,
topics: vec![MetadataTopicResponse {
error_code: ErrorCode::LeaderNotAvailable,
name: Some("topic-a".to_string()),
topic_id: None,
is_internal: false,
partitions: vec![],
}],
},
false,
);
assert!(
meta.inner.cache.load().topics.contains_key("topic-a"),
"topic-a must be restored from old cache after TTL eviction + transient error"
);
}
#[test]
fn test_transient_error_never_cached_topic_not_stamped() {
use crate::protocol::{MetadataBroker, MetadataTopicResponse};
let pool = Arc::new(ConnectionPool::new(
crate::network::ConnectionConfig::default(),
));
let meta = ClusterMetadata::new(
vec!["localhost:9092".to_string()],
pool,
Duration::from_secs(300),
);
meta.update_cache(
MetadataResponse {
throttle_time_ms: 0,
brokers: vec![MetadataBroker {
node_id: 1,
host: "localhost".to_string(),
port: 9092,
rack: None,
}],
cluster_id: None,
controller_id: 1,
error_code: ErrorCode::None,
topics: vec![MetadataTopicResponse {
error_code: ErrorCode::LeaderNotAvailable,
name: Some("unknown-topic".to_string()),
topic_id: None,
is_internal: false,
partitions: vec![],
}],
},
false,
);
let cache = meta.inner.cache.load();
assert!(
!cache.topics.contains_key("unknown-topic"),
"unknown-topic must not appear in topics when only a transient error was received \
and there is no prior cache entry"
);
assert!(
!cache.topic_stamps.contains_key("unknown-topic"),
"unknown-topic must not be stamped when it is not in topics"
);
}
#[test]
fn test_transient_error_restores_uuid_mapping_for_evicted_topic() {
use crate::protocol::{MetadataBroker, MetadataPartitionResponse, MetadataTopicResponse};
let pool = Arc::new(ConnectionPool::new(
crate::network::ConnectionConfig::default(),
));
let meta = ClusterMetadata::new(
vec!["localhost:9092".to_string()],
pool,
Duration::from_secs(300),
)
.with_topic_cache_ttl(Duration::from_nanos(1));
let uuid: [u8; 16] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f, 0x10,
];
meta.update_cache(
MetadataResponse {
throttle_time_ms: 0,
brokers: vec![MetadataBroker {
node_id: 1,
host: "localhost".to_string(),
port: 9092,
rack: None,
}],
cluster_id: None,
controller_id: 1,
error_code: ErrorCode::None,
topics: vec![MetadataTopicResponse {
error_code: ErrorCode::None,
name: Some("topic-b".to_string()),
topic_id: Some(uuid),
is_internal: false,
partitions: vec![MetadataPartitionResponse {
error_code: ErrorCode::None,
partition_index: 0,
leader_id: 1,
leader_epoch: 0,
replica_nodes: vec![1],
isr_nodes: vec![1],
offline_replicas: vec![],
}],
}],
},
false,
);
assert!(
meta.inner
.cache
.load()
.name_to_topic_id
.contains_key("topic-b"),
"pre-condition: UUID mapping seeded"
);
std::thread::sleep(Duration::from_millis(20));
meta.update_cache(
MetadataResponse {
throttle_time_ms: 0,
brokers: vec![MetadataBroker {
node_id: 1,
host: "localhost".to_string(),
port: 9092,
rack: None,
}],
cluster_id: None,
controller_id: 1,
error_code: ErrorCode::None,
topics: vec![MetadataTopicResponse {
error_code: ErrorCode::LeaderNotAvailable,
name: Some("topic-b".to_string()),
topic_id: Some(uuid),
is_internal: false,
partitions: vec![],
}],
},
false,
);
let cache = meta.inner.cache.load();
assert!(
cache.topics.contains_key("topic-b"),
"topic-b must be restored in topics"
);
assert_eq!(
cache.name_to_topic_id.get("topic-b"),
Some(&uuid),
"UUID mapping for topic-b must be restored in name_to_topic_id"
);
assert!(
cache.topic_ids.contains_key(&uuid),
"UUID must be present in topic_ids"
);
}
fn test_metadata() -> ClusterMetadata {
let pool = Arc::new(ConnectionPool::new(
crate::network::ConnectionConfig::default(),
));
ClusterMetadata::new(
vec!["localhost:9092".to_string()],
pool,
Duration::from_secs(300),
)
}
fn partition_response(
index: PartitionId,
leader: BrokerId,
epoch: i32,
error: ErrorCode,
) -> crate::protocol::MetadataPartitionResponse {
crate::protocol::MetadataPartitionResponse {
error_code: error,
partition_index: index,
leader_id: leader,
leader_epoch: epoch,
replica_nodes: vec![leader],
isr_nodes: vec![leader],
offline_replicas: vec![],
}
}
fn metadata_response(
partitions: Vec<crate::protocol::MetadataPartitionResponse>,
) -> MetadataResponse {
MetadataResponse {
error_code: ErrorCode::None,
throttle_time_ms: 0,
brokers: vec![crate::protocol::MetadataBroker {
node_id: 1,
host: "h".into(),
port: 9092,
rack: None,
}],
cluster_id: Some("c".into()),
controller_id: 1,
topics: vec![crate::protocol::MetadataTopicResponse {
error_code: ErrorCode::None,
name: Some("t".into()),
topic_id: None,
is_internal: false,
partitions,
}],
}
}
#[test]
fn test_errored_partitions_are_retained_so_partition_count_is_stable() {
let meta = test_metadata();
let partitions = (0..12)
.map(|i| {
if (9..12).contains(&i) {
partition_response(i, -1, -1, ErrorCode::LeaderNotAvailable)
} else {
partition_response(i, 1, 5, ErrorCode::None)
}
})
.collect();
meta.update_cache(metadata_response(partitions), true);
assert_eq!(
meta.partition_count("t"),
Some(12),
"partition_count must reflect the full topic, not just healthy partitions"
);
let topic = meta.topic_arc("t").unwrap();
for i in 9..12 {
let p = topic.partition(i).expect("errored partition must be kept");
assert_eq!(p.error_code, ErrorCode::LeaderNotAvailable);
assert_eq!(
p.leader, -1,
"an errored partition has no trustworthy leader"
);
assert!(!p.is_routable());
assert_eq!(
topic.leader(i),
None,
"routing must fail for this partition rather than dial broker -1"
);
}
assert_eq!(topic.leader(0), Some(1));
assert!(topic.partition(0).unwrap().is_routable());
}
#[test]
fn test_stale_leader_epoch_is_ignored() {
let meta = test_metadata();
meta.update_cache(
metadata_response(vec![partition_response(0, 2, 42, ErrorCode::None)]),
true,
);
assert_eq!(meta.leader("t", 0), Some(2));
assert_eq!(meta.leader_epoch("t", 0), Some(42));
meta.update_cache(
metadata_response(vec![partition_response(0, 1, 41, ErrorCode::None)]),
false,
);
assert_eq!(
meta.leader("t", 0),
Some(2),
"a lower leader epoch must not revert the cached leader"
);
assert_eq!(meta.leader_epoch("t", 0), Some(42));
}
#[test]
fn test_newer_leader_epoch_is_applied() {
let meta = test_metadata();
meta.update_cache(
metadata_response(vec![partition_response(0, 1, 41, ErrorCode::None)]),
true,
);
meta.update_cache(
metadata_response(vec![partition_response(0, 2, 42, ErrorCode::None)]),
false,
);
assert_eq!(meta.leader("t", 0), Some(2));
assert_eq!(meta.leader_epoch("t", 0), Some(42));
}
#[test]
fn test_equal_leader_epoch_is_applied() {
let meta = test_metadata();
meta.update_cache(
metadata_response(vec![partition_response(0, 1, 7, ErrorCode::None)]),
true,
);
meta.update_cache(
metadata_response(vec![partition_response(0, 3, 7, ErrorCode::None)]),
false,
);
assert_eq!(meta.leader("t", 0), Some(3));
}
#[test]
fn test_unknown_epoch_does_not_block_updates() {
let meta = test_metadata();
meta.update_cache(
metadata_response(vec![partition_response(0, 1, 5, ErrorCode::None)]),
true,
);
meta.update_cache(
metadata_response(vec![partition_response(0, 4, -1, ErrorCode::None)]),
false,
);
assert_eq!(meta.leader("t", 0), Some(4));
assert_eq!(
meta.leader_epoch("t", 0),
None,
"an unknown epoch reads as None, not -1"
);
}
#[test]
fn test_controller_is_none_when_unelected() {
let meta = test_metadata();
assert!(
meta.controller().is_none(),
"controller_id -1 means no controller is elected"
);
}
#[test]
fn test_controller_is_none_when_id_not_in_broker_set() {
let meta = test_metadata();
let mut cache = MetadataCache::new();
cache.controller_id = 7;
cache
.brokers
.insert(1, BrokerInfo::new(1, "h".into(), 9092, None));
meta.inner.cache.store(Arc::new(cache));
assert!(meta.controller().is_none());
}
#[test]
fn test_controller_resolves_from_metadata() {
let meta = test_metadata();
meta.update_cache(
metadata_response(vec![partition_response(0, 1, 0, ErrorCode::None)]),
true,
);
let controller = meta.controller().expect("controller should resolve");
assert_eq!(controller.id(), 1);
assert_eq!(controller.address(), "h:9092");
}
#[test]
fn test_topic_arc_shares_the_cached_allocation() {
let meta = test_metadata();
meta.update_cache(
metadata_response(vec![partition_response(0, 1, 0, ErrorCode::None)]),
true,
);
let a = meta.topic_arc("t").unwrap();
let b = meta.topic_arc("t").unwrap();
assert!(Arc::ptr_eq(&a, &b), "topic_arc must not deep-copy");
assert_eq!(meta.topics_arc().len(), 1);
assert!(meta.topic_arc("missing").is_none());
assert_eq!(meta.topic("t").unwrap().name, a.name);
}
#[test]
fn test_default_retry_backoff_is_exponential_and_capped() {
let meta = test_metadata();
let policy = meta
.inner
.retry_backoff
.as_ref()
.expect("enabled by default");
assert_eq!(policy.initial_backoff, DEFAULT_RETRY_BACKOFF);
assert_eq!(policy.max_backoff, DEFAULT_RETRY_BACKOFF_MAX);
assert!(
policy.backoff_multiplier > 1.0,
"a flat curve would keep the retry rate constant while the cluster is down"
);
assert!(policy.jitter_factor() > 0.0, "retries must be scattered");
}
#[test]
fn test_refresh_backoff_grows_with_consecutive_failures() {
let meta = test_metadata();
let policy = meta.inner.retry_backoff.clone().unwrap();
let mut state = RefreshBackoffState::new();
let expected_bases_ms = [100u64, 200, 400, 800, 1000, 1000];
let mut previous_base = 0u64;
for (failure, base_ms) in expected_bases_ms.iter().copied().enumerate() {
state.record_failure(&policy);
assert_eq!(state.consecutive_failures as usize, failure + 1);
let low = Duration::from_millis((base_ms as f64 * 0.8) as u64);
let high = Duration::from_millis((base_ms as f64 * 1.2).ceil() as u64);
assert!(
state.current_delay >= low && state.current_delay <= high,
"failure {}: delay {:?} outside jitter envelope [{low:?}, {high:?}]",
failure + 1,
state.current_delay,
);
assert!(base_ms >= previous_base);
previous_base = base_ms;
}
assert!(
state.current_delay <= Duration::from_millis(1200),
"the delay must stop growing at retry.backoff.max.ms (plus jitter)"
);
}
#[test]
fn test_refresh_backoff_is_jittered_across_clients() {
let meta = test_metadata();
let policy = meta.inner.retry_backoff.clone().unwrap();
let mut delays = std::collections::HashSet::new();
for _ in 0..64 {
let mut state = RefreshBackoffState::new();
for _ in 0..4 {
state.record_failure(&policy);
}
delays.insert(state.current_delay.as_nanos());
}
assert!(
delays.len() > 1,
"all clients computed the same backoff; jitter is not being applied"
);
}
#[test]
fn test_refresh_backoff_resets_on_success() {
let meta = test_metadata();
let policy = meta.inner.retry_backoff.clone().unwrap();
let mut state = RefreshBackoffState::new();
for _ in 0..8 {
state.record_failure(&policy);
}
assert_eq!(state.consecutive_failures, 8);
assert!(state.current_delay >= Duration::from_millis(800));
state.record_success(&policy);
assert_eq!(state.consecutive_failures, 0);
assert!(
state.current_delay <= Duration::from_millis(120),
"after a success the delay must be back at the base, got {:?}",
state.current_delay
);
}
#[test]
fn test_refresh_backoff_remaining_is_none_before_first_attempt() {
let state = RefreshBackoffState::new();
assert_eq!(
state.remaining(),
None,
"the very first refresh must never be rate-limited"
);
}
#[test]
fn test_refresh_backoff_remaining_expires() {
let policy = BackoffPolicy {
initial_backoff: Duration::from_millis(20),
max_backoff: Duration::from_millis(20),
backoff_multiplier: 2.0,
jitter_factor: 0.0,
};
let mut state = RefreshBackoffState::new();
state.record_failure(&policy);
assert!(state.remaining().is_some());
std::thread::sleep(Duration::from_millis(40));
assert_eq!(
state.remaining(),
None,
"once the delay has elapsed another attempt must be permitted"
);
}
#[test]
fn test_with_retry_backoff_sets_base_and_raises_max() {
let meta = test_metadata().with_retry_backoff(Duration::from_millis(250));
let policy = meta.inner.retry_backoff.as_ref().unwrap();
assert_eq!(policy.initial_backoff, Duration::from_millis(250));
assert_eq!(
policy.max_backoff, DEFAULT_RETRY_BACKOFF_MAX,
"a base below the default ceiling leaves the ceiling alone"
);
let meta = test_metadata().with_retry_backoff(Duration::from_secs(5));
let policy = meta.inner.retry_backoff.as_ref().unwrap();
assert_eq!(policy.initial_backoff, Duration::from_secs(5));
assert_eq!(policy.max_backoff, Duration::from_secs(5));
}
#[test]
fn test_with_retry_backoff_max_never_inverts_the_curve() {
let meta = test_metadata()
.with_retry_backoff(Duration::from_millis(500))
.with_retry_backoff_max(Duration::from_millis(10));
let policy = meta.inner.retry_backoff.as_ref().unwrap();
assert_eq!(policy.max_backoff, Duration::from_millis(500));
}
#[test]
fn test_with_retry_backoff_none_disables_rate_limiting() {
let meta = test_metadata().with_retry_backoff(None);
assert!(meta.inner.retry_backoff.is_none());
let meta = meta.with_retry_backoff_max(Duration::from_secs(1));
assert!(meta.inner.retry_backoff.is_none());
}
#[tokio::test]
async fn test_failed_refresh_arms_the_backoff() {
let meta = ClusterMetadata::new(
vec!["127.0.0.1:1".to_string()],
Arc::new(ConnectionPool::new(
crate::network::ConnectionConfig::default(),
)),
Duration::from_secs(300),
);
assert!(meta.refresh_for_topics(Some(&["t"])).await.is_err());
{
let state = meta.inner.refresh_backoff.lock();
assert_eq!(
state.consecutive_failures, 1,
"a connection failure is a refresh failure and must count"
);
assert!(state.remaining().is_some(), "the limiter must now be armed");
}
let started = Instant::now();
assert!(meta.refresh_for_topics(Some(&["t"])).await.is_err());
assert!(
started.elapsed() >= Duration::from_millis(50),
"the second fetch went out after {:?}, inside the backoff",
started.elapsed()
);
assert_eq!(meta.inner.refresh_backoff.lock().consecutive_failures, 2);
}
#[tokio::test]
async fn test_consecutive_refresh_failures_escalate_the_delay() {
let meta = ClusterMetadata::new(
vec!["127.0.0.1:1".to_string()],
Arc::new(ConnectionPool::new(
crate::network::ConnectionConfig::default(),
)),
Duration::from_secs(300),
)
.with_retry_backoff(Duration::from_micros(200))
.with_retry_backoff_max(Duration::from_millis(50));
let mut delays = Vec::new();
for _ in 0..4 {
assert!(meta.refresh().await.is_err());
delays.push(meta.inner.refresh_backoff.lock().current_delay);
}
assert_eq!(meta.inner.refresh_backoff.lock().consecutive_failures, 4);
assert!(
delays[3] > delays[0],
"backoff must grow across consecutive failures: {delays:?}"
);
}
#[test]
fn test_rebootstrap_jitter_default_and_override() {
let meta = test_metadata();
assert_eq!(
meta.inner.rebootstrap_jitter,
Duration::from_millis(500),
"a restarted fleet must not converge on one seed broker"
);
let meta = test_metadata().with_rebootstrap_jitter(Duration::ZERO);
assert_eq!(meta.inner.rebootstrap_jitter, Duration::ZERO);
}
#[tokio::test]
async fn test_rebootstrap_cannot_fire_in_a_tight_loop() {
let meta = test_metadata()
.with_recovery_strategy(MetadataRecoveryStrategy::Rebootstrap)
.with_rebootstrap_trigger(Duration::from_secs(300))
.with_rebootstrap_jitter(Duration::ZERO);
*meta.inner.metadata_attempt_start.lock() = Some(Instant::now() - Duration::from_secs(600));
assert!(meta.inner.needs_rebootstrap());
meta.rebootstrap().await;
assert!(
!meta.inner.needs_rebootstrap(),
"back-to-back rebootstraps would turn a cluster outage into a \
connection-churn storm against the seed brokers"
);
assert!(meta.inner.metadata_attempt_start.lock().is_some());
}
#[test]
fn test_rebootstrap_trigger_jitter_only_delays() {
let meta = test_metadata()
.with_recovery_strategy(MetadataRecoveryStrategy::Rebootstrap)
.with_rebootstrap_trigger(Duration::from_secs(10));
*meta.inner.metadata_attempt_start.lock() = Some(Instant::now() - Duration::from_secs(9));
for _ in 0..64 {
assert!(!meta.inner.needs_rebootstrap());
}
*meta.inner.metadata_attempt_start.lock() = Some(Instant::now() - Duration::from_secs(30));
for _ in 0..64 {
assert!(meta.inner.needs_rebootstrap());
}
}
#[tokio::test]
async fn test_rebootstrap_reresolves_seed_brokers() {
let meta = ClusterMetadata::new(
vec!["seed.example.com:9092".to_string()],
Arc::new(ConnectionPool::new(
crate::network::ConnectionConfig::default(),
)),
Duration::from_secs(300),
)
.with_rebootstrap_jitter(Duration::ZERO);
let mut cache = MetadataCache::new();
cache
.brokers
.insert(1, BrokerInfo::new(1, "old-broker-1".into(), 9092, None));
cache
.brokers
.insert(2, BrokerInfo::new(2, "old-broker-2".into(), 9092, None));
meta.inner.cache.store(Arc::new(cache));
let before = meta.inner.connection_candidates();
assert!(before.iter().any(|a| a == "old-broker-1:9092"));
assert!(before.iter().any(|a| a == "seed.example.com:9092"));
meta.rebootstrap().await;
let after = meta.inner.connection_candidates();
assert_eq!(
after,
vec!["seed.example.com:9092".to_string()],
"after a rebootstrap only the seed hostnames remain, so the next \
dial resolves them afresh"
);
}
#[test]
fn test_updated_seed_brokers_appear_in_connection_candidates() {
let meta = test_metadata();
assert!(
meta.inner
.connection_candidates()
.contains(&"localhost:9092".to_string())
);
meta.update_seed_brokers(vec!["new-seed:9092".to_string()])
.unwrap();
assert_eq!(meta.inner.connection_candidates(), vec!["new-seed:9092"]);
}
#[test]
fn test_connection_candidates_deduplicates_seeds_already_known_as_brokers() {
let meta = test_metadata();
let mut cache = MetadataCache::new();
cache
.brokers
.insert(1, BrokerInfo::new(1, "localhost".into(), 9092, None));
meta.inner.cache.store(Arc::new(cache));
assert_eq!(
meta.inner.connection_candidates(),
vec!["localhost:9092".to_string()],
"a seed that is also a known broker must not be dialled twice"
);
}
#[test]
fn test_topic_freshness_is_per_topic_not_cache_wide() {
let meta = test_metadata();
let max_age = Duration::from_secs(60);
let mut cache = MetadataCache::new();
cache.topics.insert(
"stale".into(),
Arc::new(TopicInfo {
name: "stale".into(),
topic_id: [0; 16],
is_internal: false,
partitions: std::collections::HashMap::new(),
}),
);
cache.topics.insert(
"fresh".into(),
Arc::new(TopicInfo {
name: "fresh".into(),
topic_id: [0; 16],
is_internal: false,
partitions: std::collections::HashMap::new(),
}),
);
cache.topic_stamps.insert(
"stale".into(),
Arc::new(TopicStamp {
refreshed: Instant::now() - Duration::from_secs(600),
last_used_ms: AtomicU64::new(0),
}),
);
cache
.topic_stamps
.insert("fresh".into(), TopicStamp::fetched_now(0));
cache.last_updated = Instant::now();
meta.inner.cache.store(Arc::new(cache));
let cache = meta.inner.cache.load();
assert!(
!cache.is_stale(max_age),
"pre-condition: the cache as a whole looks current"
);
assert!(cache.topic_is_fresh("fresh", max_age));
assert!(
!cache.topic_is_fresh("stale", max_age),
"a topic not refreshed within max_age is stale even though the \
cache-wide timestamp is recent"
);
assert!(
!cache.topic_is_fresh("never-seen", max_age),
"an unknown topic is never fresh"
);
}
#[test]
fn test_topic_without_timestamp_is_not_fresh() {
let mut cache = MetadataCache::new();
cache.topics.insert(
"t".into(),
Arc::new(TopicInfo {
name: "t".into(),
topic_id: [0; 16],
is_internal: false,
partitions: std::collections::HashMap::new(),
}),
);
assert!(!cache.topic_is_fresh("t", Duration::from_secs(60)));
}
fn metadata_with_leader(epoch: i32) -> ClusterMetadata {
let meta = test_metadata();
meta.update_cache(
metadata_response(vec![partition_response(0, 1, epoch, ErrorCode::None)]),
true,
);
let mut cache = MetadataCache::clone(&meta.inner.cache.load());
cache
.brokers
.insert(2, BrokerInfo::new(2, "h2".into(), 9092, None));
meta.inner.cache.store(Arc::new(cache));
meta
}
fn endpoint(id: BrokerId) -> Option<BrokerInfo> {
Some(BrokerInfo::new(id, format!("h{id}"), 9092, None))
}
#[test]
fn test_leader_hint_with_a_newer_epoch_is_applied() {
let meta = metadata_with_leader(5);
assert!(meta.apply_leader_hint("t", 0, 2, 6, endpoint(2)));
assert_eq!(meta.leader("t", 0), Some(2));
assert_eq!(meta.leader_epoch("t", 0), Some(6));
}
#[test]
fn test_leader_hint_with_an_older_epoch_is_ignored() {
let meta = metadata_with_leader(5);
assert!(!meta.apply_leader_hint("t", 0, 2, 4, None));
assert_eq!(meta.leader("t", 0), Some(1));
assert_eq!(meta.leader_epoch("t", 0), Some(5));
}
#[test]
fn test_leader_hint_with_an_equal_epoch_is_ignored() {
let meta = metadata_with_leader(5);
assert!(!meta.apply_leader_hint("t", 0, 2, 5, None));
assert_eq!(meta.leader("t", 0), Some(1));
}
#[test]
fn test_leader_hint_supersedes_an_unknown_cached_epoch() {
let meta = test_metadata();
meta.update_cache(
metadata_response(vec![partition_response(
0,
-1,
-1,
ErrorCode::LeaderNotAvailable,
)]),
true,
);
assert!(meta.apply_leader_hint("t", 0, 2, 0, endpoint(2)));
assert_eq!(meta.leader("t", 0), Some(2));
}
#[test]
fn test_leader_hint_clears_a_stale_partition_error() {
let meta = test_metadata();
meta.update_cache(
metadata_response(vec![partition_response(
0,
-1,
-1,
ErrorCode::LeaderNotAvailable,
)]),
true,
);
assert!(!meta.topic("t").unwrap().partition(0).unwrap().is_routable());
assert!(meta.apply_leader_hint("t", 0, 2, 3, endpoint(2)));
let topic = meta.topic("t").unwrap();
let p = topic.partition(0).unwrap();
assert!(p.is_routable());
assert_eq!(p.error_code, ErrorCode::None);
}
#[test]
fn test_leader_hint_registers_an_unknown_broker_endpoint() {
let meta = metadata_with_leader(5);
assert!(meta.broker(7).is_none());
assert!(meta.apply_leader_hint("t", 0, 7, 6, endpoint(7)));
assert_eq!(meta.broker(7).unwrap().address(), "h7:9092");
assert_eq!(meta.leader("t", 0), Some(7));
}
#[test]
fn test_leader_hint_for_an_unreachable_broker_is_dropped() {
let meta = metadata_with_leader(5);
assert!(!meta.apply_leader_hint("t", 0, 99, 6, None));
assert_eq!(meta.leader("t", 0), Some(1));
assert!(meta.broker(99).is_none());
}
#[test]
fn test_leader_hint_registers_an_endpoint_even_when_the_epoch_is_stale() {
let meta = metadata_with_leader(5);
assert!(meta.apply_leader_hint("t", 0, 8, 1, endpoint(8)));
assert_eq!(meta.broker(8).unwrap().address(), "h8:9092");
assert_eq!(
meta.leader("t", 0),
Some(1),
"the stale epoch was not applied"
);
}
#[test]
fn test_leader_hint_ignores_a_negative_leader_id() {
let meta = metadata_with_leader(5);
assert!(!meta.apply_leader_hint("t", 0, -1, 99, None));
assert_eq!(meta.leader("t", 0), Some(1));
}
#[test]
fn test_leader_hint_does_not_invent_unknown_topics_or_partitions() {
let meta = metadata_with_leader(5);
assert!(!meta.apply_leader_hint("other", 0, 2, 9, None));
assert!(!meta.apply_leader_hint("t", 7, 2, 9, None));
assert!(meta.topic("other").is_none());
assert_eq!(meta.topic("t").unwrap().partition_count(), 1);
}
#[test]
fn test_leader_hint_does_not_mark_the_topic_as_freshly_refreshed() {
let meta = metadata_with_leader(5);
let before = meta.inner.cache.load().topic_stamps["t"].refreshed;
assert!(meta.apply_leader_hint("t", 0, 2, 6, endpoint(2)));
assert_eq!(meta.inner.cache.load().topic_stamps["t"].refreshed, before);
}
#[test]
fn test_leader_hint_updates_an_existing_broker_address() {
let meta = metadata_with_leader(5);
assert_eq!(meta.broker(2).unwrap().address(), "h2:9092");
assert!(meta.apply_leader_hint(
"t",
0,
2,
6,
Some(BrokerInfo::new(2, "moved".into(), 9093, None))
));
assert_eq!(meta.broker(2).unwrap().address(), "moved:9093");
}
#[test]
fn test_broker_info_for_node_matches_by_node_id() {
let endpoints = vec![
crate::protocol::NodeEndpoint {
node_id: 4,
host: "a".into(),
port: 1,
rack: None,
},
crate::protocol::NodeEndpoint {
node_id: 5,
host: "b".into(),
port: 2,
rack: Some("r".into()),
},
];
let found = broker_info_for_node(&endpoints, 5).unwrap();
assert_eq!(found.address(), "b:2");
assert_eq!(found.rack(), Some("r"));
assert!(broker_info_for_node(&endpoints, 6).is_none());
}
fn response_with_id(
id: [u8; 16],
partitions: Vec<crate::protocol::MetadataPartitionResponse>,
) -> MetadataResponse {
let mut response = metadata_response(partitions);
response.topics[0].topic_id = Some(id);
response
}
#[test]
fn test_a_changed_topic_id_resets_the_leader_epochs() {
let meta = test_metadata();
meta.update_cache(
response_with_id([1; 16], vec![partition_response(0, 1, 5, ErrorCode::None)]),
true,
);
meta.update_cache(
response_with_id([2; 16], vec![partition_response(0, 2, 0, ErrorCode::None)]),
true,
);
assert_eq!(meta.leader("t", 0), Some(2));
assert_eq!(meta.leader_epoch("t", 0), Some(0));
assert_eq!(meta.topic_id_for_name("t"), Some([2; 16]));
}
#[test]
fn test_the_same_topic_id_keeps_the_newer_epoch() {
let meta = test_metadata();
meta.update_cache(
response_with_id([1; 16], vec![partition_response(0, 1, 5, ErrorCode::None)]),
true,
);
meta.update_cache(
response_with_id([1; 16], vec![partition_response(0, 2, 4, ErrorCode::None)]),
true,
);
assert_eq!(meta.leader("t", 0), Some(1));
assert_eq!(meta.leader_epoch("t", 0), Some(5));
}
#[test]
fn test_a_leader_hint_survives_a_later_response_with_an_older_epoch() {
let meta = metadata_with_leader(5);
assert!(meta.apply_leader_hint("t", 0, 2, 6, endpoint(2)));
meta.update_cache(
metadata_response(vec![partition_response(0, 1, 5, ErrorCode::None)]),
false,
);
assert_eq!(meta.leader("t", 0), Some(2));
assert_eq!(meta.leader_epoch("t", 0), Some(6));
}
#[test]
fn test_a_response_fetched_before_a_rebootstrap_is_discarded() {
let meta = test_metadata();
let epoch_at_fetch = meta.inner.cache.load().reset_epoch;
meta.inner.reset_to_seeds();
let applied = meta.inner.apply(
metadata_response(vec![partition_response(0, 1, 0, ErrorCode::None)]),
true,
epoch_at_fetch,
);
assert!(!applied);
assert!(meta.brokers().is_empty());
assert!(meta.topic("t").is_none());
}
#[test]
fn test_a_partial_response_replaces_the_broker_map() {
let meta = metadata_with_leader(1);
assert!(meta.broker(2).is_some());
meta.update_cache(ok_topics_response(&["other"]), false);
assert!(
meta.broker(2).is_none(),
"a broker absent from the response leaves the map"
);
assert!(meta.broker(1).is_some());
}
#[test]
fn test_an_unknown_topic_leaves_the_cache() {
let meta = test_metadata();
meta.update_cache(
metadata_response(vec![partition_response(0, 1, 0, ErrorCode::None)]),
true,
);
let mut gone = metadata_response(vec![]);
gone.topics[0].error_code = ErrorCode::UnknownTopicOrPartition;
meta.update_cache(gone, false);
assert!(meta.topic("t").is_none());
assert_eq!(
meta.topic_error("t"),
Some(ErrorCode::UnknownTopicOrPartition)
);
}
}