1use std::{
10 sync::{
11 Arc, Weak,
12 atomic::{AtomicUsize, Ordering},
13 },
14 time::Duration,
15};
16
17use dashmap::DashMap;
18use krabka_client_core::Client;
19use tokio::time::{self, Instant};
20use tokio_util::sync::CancellationToken;
21
22use crate::{
23 accumulator::AccumulatorMap, builder::ProducerRetryPolicy, error::ProducerError,
24 metadata_wait::MetadataRefresh, producer::MetadataCache, sender::adopt_metadata,
25};
26
27pub const DEFAULT_PRODUCER_METADATA_MAX_AGE: Duration = Duration::from_mins(5);
29
30pub const DEFAULT_PRODUCER_METADATA_MAX_IDLE: Duration = Duration::from_mins(5);
32
33pub const MIN_PRODUCER_METADATA_MAX_IDLE: Duration = Duration::from_secs(5);
36
37pub struct MetadataAge {
39 client: Client,
40 max_age: Duration,
41 max_idle: Duration,
42 retry_backoff: Duration,
43 retry_backoff_max: Duration,
44 metadata_cache: Arc<MetadataCache>,
45 partition_leaders: Arc<DashMap<(String, i32), i32>>,
46 metadata_refresh: Weak<MetadataRefresh>,
47 producer_dropped: CancellationToken,
49 accumulators: AccumulatorMap,
50 in_flight: Arc<AtomicUsize>,
51}
52
53pub fn validate_metadata_max_idle(max_idle: Duration) -> Result<(), ProducerError> {
59 if max_idle < MIN_PRODUCER_METADATA_MAX_IDLE {
60 return Err(ProducerError::InvalidConfig(format!(
61 "metadata_max_idle must be at least 5 s, got {max_idle:?}"
62 )));
63 }
64 Ok(())
65}
66
67impl MetadataAge {
68 pub(crate) fn new(client: &Client, retry_policy: &ProducerRetryPolicy) -> Self {
72 Self {
73 client: client.clone(),
74 max_age: DEFAULT_PRODUCER_METADATA_MAX_AGE,
75 max_idle: DEFAULT_PRODUCER_METADATA_MAX_IDLE,
76 retry_backoff: retry_policy.retry_backoff(),
77 retry_backoff_max: retry_policy.retry_backoff_max(),
78 metadata_cache: Arc::default(),
79 partition_leaders: Arc::default(),
80 metadata_refresh: Weak::new(),
81 producer_dropped: CancellationToken::new(),
82 accumulators: AccumulatorMap::default(),
83 in_flight: Arc::default(),
84 }
85 }
86
87 pub(crate) const fn with_ages(mut self, max_age: Duration, max_idle: Duration) -> Self {
89 self.max_age = max_age;
90 self.max_idle = max_idle;
91 self
92 }
93
94 pub(crate) fn with_caches(
96 mut self,
97 metadata_cache: &Arc<MetadataCache>,
98 partition_leaders: &Arc<DashMap<(String, i32), i32>>,
99 metadata_refresh: &Arc<MetadataRefresh>,
100 ) -> Self {
101 self.metadata_cache = Arc::clone(metadata_cache);
102 self.partition_leaders = Arc::clone(partition_leaders);
103 self.metadata_refresh = Arc::downgrade(metadata_refresh);
104 self.producer_dropped = metadata_refresh.dropped();
105 self
106 }
107
108 pub(crate) fn with_queues(
111 mut self,
112 (accumulators, in_flight): (&AccumulatorMap, &Arc<AtomicUsize>),
113 ) -> Self {
114 self.accumulators = Arc::clone(accumulators);
115 self.in_flight = Arc::clone(in_flight);
116 self
117 }
118
119 pub(crate) fn spawn(self, shutdown: CancellationToken) {
121 tokio::spawn(self.run(shutdown));
122 }
123
124 async fn run(self, shutdown: CancellationToken) {
127 let started = Instant::now();
128 let mut backoff = self.retry_backoff;
129 loop {
130 let topics = self.client.metadata_topics();
131 let last = topics.last_refreshed().unwrap_or(started);
132 tokio::select! {
133 () = shutdown.cancelled() => return,
134 () = self.producer_dropped.cancelled() => return,
135 () = time::sleep_until(last + self.max_age) => {}
136 }
137 if topics
138 .last_refreshed()
139 .is_some_and(|refreshed| refreshed > last)
140 {
141 continue;
143 }
144 self.expire_idle_topics().await;
145 match self.client.refresh_metadata().await {
146 Ok(response) => {
147 adopt_metadata(&response, &self.metadata_cache, &self.partition_leaders);
148 backoff = self.retry_backoff;
149 }
150 Err(error) => {
151 tracing::debug!(error = %error, "periodic producer metadata refresh failed");
152 tokio::select! {
153 () = shutdown.cancelled() => return,
154 () = time::sleep(backoff) => {}
155 }
156 backoff = backoff.saturating_mul(2).min(self.retry_backoff_max);
157 }
158 }
159 }
160 }
161
162 async fn has_queued_records(&self, topic: &str) -> bool {
164 let queues = self
165 .accumulators
166 .iter()
167 .filter(|entry| entry.key().0 == topic)
168 .map(|entry| Arc::clone(entry.value()))
169 .collect::<Vec<_>>();
170 for queue in queues {
171 if queue.lock().await.queue_size() > 0 {
172 return true;
173 }
174 }
175 false
176 }
177
178 async fn expire_idle_topics(&self) {
181 let topics = self.client.metadata_topics();
185 if self.in_flight.load(Ordering::Acquire) > 0 {
186 return;
187 }
188 for topic in topics.names() {
189 if self.has_queued_records(&topic).await {
190 topics.add(&topic);
191 }
192 }
193 let expired = topics.remove_idle(self.max_idle);
194 if expired.is_empty() {
195 return;
196 }
197 tracing::debug!(topics = ?expired, "producer metadata topics expired");
198 if let Some(refresh) = self.metadata_refresh.upgrade() {
199 refresh.forget(&expired);
200 }
201 let mut cache = self.metadata_cache.lock();
202 for topic in &expired {
203 cache.remove(topic);
204 }
205 drop(cache);
206 self.partition_leaders
207 .retain(|(topic, _), _| !expired.contains(topic));
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use std::sync::{
214 Mutex as StdMutex,
215 atomic::{AtomicI32, AtomicU16, Ordering},
216 };
217
218 use assert2::check;
219 use bytes::BytesMut;
220 use krabka_client_core::MockBroker;
221 use krabka_protocol::{
222 Decode, Encode,
223 owned::{
224 api_versions_request,
225 api_versions_response::{ApiVersion, ApiVersionsResponse},
226 metadata_request::{self, MetadataRequest},
227 metadata_response::{
228 self, MetadataResponse, MetadataResponseBroker, MetadataResponsePartition,
229 MetadataResponseTopic,
230 },
231 },
232 primitives::uuid::Uuid,
233 };
234 use tokio::sync::Mutex;
235
236 use super::*;
237 use crate::{Producer, accumulator::Accumulator, producer::TopicMetadata};
238
239 const TOPIC: &str = "orders";
240 const CLIENT_ID: &str = "metadata-age-test";
241
242 struct ChangingBroker {
245 mock: MockBroker,
246 partitions: Arc<AtomicI32>,
247 leader: Arc<AtomicI32>,
248 requests: Arc<StdMutex<Vec<MetadataRequest>>>,
249 }
250
251 async fn changing_broker() -> ChangingBroker {
252 let port = Arc::new(AtomicU16::new(0));
253 let partitions = Arc::new(AtomicI32::new(4));
254 let leader = Arc::new(AtomicI32::new(1));
255 let requests = Arc::new(StdMutex::new(Vec::new()));
256 let (h_port, h_partitions, h_leader, h_requests) = (
257 Arc::clone(&port),
258 Arc::clone(&partitions),
259 Arc::clone(&leader),
260 Arc::clone(&requests),
261 );
262 let mock = MockBroker::start(move |api_key, version, _corr_id, body| {
263 if api_key == api_versions_request::API_KEY {
264 let mut buf = BytesMut::new();
265 ApiVersionsResponse {
266 api_keys: vec![ApiVersion {
267 api_key: metadata_request::API_KEY,
268 min_version: 0,
269 max_version: 12,
270 ..Default::default()
271 }],
272 ..Default::default()
273 }
274 .encode(&mut buf, 0)
275 .unwrap();
276 return Some(buf.to_vec());
277 }
278 if api_key != metadata_request::API_KEY {
279 return None;
280 }
281 let header_len =
282 2 + CLIENT_ID.len() + usize::from(version >= metadata_request::FLEXIBLE_MIN);
283 let mut request_body = &body[header_len..];
284 let request = MetadataRequest::decode(&mut request_body, version).unwrap();
285 let named = request.topics.as_ref().is_some_and(|topics| {
286 topics
287 .iter()
288 .any(|topic| topic.name.as_deref() == Some(TOPIC))
289 });
290 h_requests.lock().unwrap().push(request);
291 let port = i32::from(h_port.load(Ordering::SeqCst));
292 let response = MetadataResponse {
293 brokers: [1, 2]
294 .into_iter()
295 .map(|node_id| MetadataResponseBroker {
296 node_id,
297 host: "127.0.0.1".into(),
298 port,
299 ..Default::default()
300 })
301 .collect(),
302 topics: named
303 .then(|| MetadataResponseTopic {
304 name: Some(TOPIC.into()),
305 partitions: (0..h_partitions.load(Ordering::SeqCst))
306 .map(|partition_index| MetadataResponsePartition {
307 partition_index,
308 leader_id: if partition_index == 0 {
309 h_leader.load(Ordering::SeqCst)
310 } else {
311 1
312 },
313 ..Default::default()
314 })
315 .collect(),
316 ..Default::default()
317 })
318 .into_iter()
319 .collect(),
320 ..Default::default()
321 };
322 let mut buf = BytesMut::new();
323 if version >= metadata_response::FLEXIBLE_MIN {
324 buf.extend_from_slice(&[0]);
325 }
326 response.encode(&mut buf, version).unwrap();
327 Some(buf.to_vec())
328 })
329 .await
330 .unwrap();
331 port.store(mock.addr.port(), Ordering::SeqCst);
332 ChangingBroker {
333 mock,
334 partitions,
335 leader,
336 requests,
337 }
338 }
339
340 #[derive(Clone, Copy, Debug)]
341 enum Change {
342 Partitions(i32),
343 Leader(i32),
344 }
345
346 #[derive(Debug, PartialEq, Eq)]
348 struct Held {
349 partitions: Option<i32>,
350 leader_of_partition_0: Option<i32>,
351 }
352
353 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
357 async fn the_producer_adopts_broker_changes_after_metadata_max_age() {
358 let max_age = Duration::from_millis(400);
359 let before_age = Duration::from_millis(150);
360 let after_age = Duration::from_millis(700);
361 for (name, change, wait, expected) in [
362 (
363 "partitions 4 to 8, after the age",
364 Change::Partitions(8),
365 after_age,
366 Held {
367 partitions: Some(8),
368 leader_of_partition_0: Some(1),
369 },
370 ),
371 (
372 "partitions 4 to 8, before the age",
373 Change::Partitions(8),
374 before_age,
375 Held {
376 partitions: Some(4),
377 leader_of_partition_0: Some(1),
378 },
379 ),
380 (
381 "leader of partition 0 moves to broker 2, after the age",
382 Change::Leader(2),
383 after_age,
384 Held {
385 partitions: Some(4),
386 leader_of_partition_0: Some(2),
387 },
388 ),
389 ] {
390 let broker = changing_broker().await;
391 let producer = Producer::builder()
392 .bootstrap(broker.mock.addr.to_string())
393 .client_id(CLIENT_ID)
394 .enable_idempotence(false)
395 .metadata_max_age(max_age)
396 .build()
397 .await
398 .expect("producer connects");
399 producer
400 .partition_count(TOPIC, None)
401 .await
402 .expect("topic metadata");
403 producer.client.refresh_metadata().await.unwrap();
405 match change {
406 Change::Partitions(count) => broker.partitions.store(count, Ordering::SeqCst),
407 Change::Leader(leader) => broker.leader.store(leader, Ordering::SeqCst),
408 }
409 time::sleep(wait).await;
410 let held = Held {
411 partitions: producer
412 .metadata_cache
413 .lock()
414 .get(TOPIC)
415 .map(|topic| topic.num_partitions),
416 leader_of_partition_0: producer
417 .partition_leaders
418 .get(&(TOPIC.to_owned(), 0))
419 .map(|leader| *leader),
420 };
421 broker.mock.stop();
422 drop(producer);
423 check!(held == expected, "{name}");
424 }
425 }
426
427 #[derive(Debug, PartialEq, Eq)]
429 struct AfterIdle {
430 last_request: Option<MetadataRequest>,
431 cached: bool,
432 leaders: usize,
433 task_finished: bool,
434 }
435
436 #[derive(Clone, Copy, Debug)]
438 enum Idle {
439 NoRecords,
441 QueuedRecord,
443 InFlight,
445 }
446
447 fn accumulator_with_one_record() -> Accumulator {
449 let mut accumulator = Accumulator::new(1024);
450 let _ = accumulator.append(None, Some("v".into()), Vec::new(), 0, None, None);
451 accumulator
452 }
453
454 async fn idle_expiry(idle: Idle) -> AfterIdle {
457 let broker = changing_broker().await;
458 let client = Client::builder()
459 .bootstrap(broker.mock.addr.to_string())
460 .client_id(CLIENT_ID)
461 .metadata_scope(krabka_client_core::MetadataScope::Topics {
462 allow_auto_topic_creation: true,
463 })
464 .build()
465 .await
466 .unwrap();
467 client.metadata_topics().add(TOPIC);
468 let metadata_cache = Arc::new(MetadataCache::from(
469 maplit::hashmap! {TOPIC.to_owned() => TopicMetadata {
470 num_partitions: 4,
471 topic_id: Uuid::ZERO,
472 }},
473 ));
474 let partition_leaders = Arc::new(DashMap::from_iter([((TOPIC.to_owned(), 0), 1)]));
475 let accumulators = AccumulatorMap::default();
476 let in_flight = Arc::new(AtomicUsize::new(0));
477 match idle {
478 Idle::NoRecords => {}
479 Idle::QueuedRecord => {
480 accumulators.insert(
481 (TOPIC.to_owned(), 0),
482 Arc::new(Mutex::new(accumulator_with_one_record())),
483 );
484 }
485 Idle::InFlight => in_flight.store(1, Ordering::SeqCst),
486 }
487 let retry_policy = ProducerRetryPolicy::default();
488 let metadata_refresh = Arc::new(MetadataRefresh::default());
489 let task = tokio::spawn(
490 MetadataAge::new(&client, &retry_policy)
491 .with_ages(Duration::from_millis(300), Duration::from_millis(200))
492 .with_caches(&metadata_cache, &partition_leaders, &metadata_refresh)
493 .with_queues((&accumulators, &in_flight))
494 .run(CancellationToken::new()),
495 );
496 time::sleep(Duration::from_millis(500)).await;
497 drop(metadata_refresh);
499 let task_finished = time::timeout(Duration::from_secs(1), task).await.is_ok();
500 let last_request = broker.requests.lock().unwrap().last().cloned();
501 let observed = AfterIdle {
502 last_request,
503 cached: metadata_cache.lock().contains_key(TOPIC),
504 leaders: partition_leaders.len(),
505 task_finished,
506 };
507 broker.mock.stop();
508 observed
509 }
510
511 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
517 async fn an_idle_topic_leaves_the_requests_and_the_caches_unless_records_wait() {
518 let named = |topics: &[&str]| {
519 Some(krabka_client_core::topics_request(
520 topics.iter().map(|&topic| topic.to_owned()),
521 true,
522 ))
523 };
524 for (name, idle, expected) in [
525 (
526 "no records",
527 Idle::NoRecords,
528 AfterIdle {
529 last_request: named(&[]),
530 cached: false,
531 leaders: 0,
532 task_finished: true,
533 },
534 ),
535 (
536 "a queued record",
537 Idle::QueuedRecord,
538 AfterIdle {
539 last_request: named(&[TOPIC]),
540 cached: true,
541 leaders: 4,
542 task_finished: true,
543 },
544 ),
545 (
546 "a batch in flight",
547 Idle::InFlight,
548 AfterIdle {
549 last_request: named(&[TOPIC]),
550 cached: true,
551 leaders: 4,
552 task_finished: true,
553 },
554 ),
555 ] {
556 check!(idle_expiry(idle).await == expected, "{name}");
557 }
558 }
559
560 #[tokio::test]
561 async fn metadata_max_idle_below_five_seconds_is_rejected() {
562 let error = Producer::builder()
563 .bootstrap("unused.invalid:9092")
564 .metadata_max_idle(Duration::from_millis(4999))
565 .build()
566 .await
567 .err();
568 check!(matches!(
569 error,
570 Some(ProducerError::InvalidConfig(message))
571 if message == "metadata_max_idle must be at least 5 s, got 4.999s"
572 ));
573 }
574}