1use std::collections::BTreeMap;
7
8use bytes::Bytes;
9use krabka_client_admin::{AdminClient, CreateTopicSpec, TopicMutationOptions};
10use krabka_client_consumer::{AutoOffsetReset, Consumer, IsolationLevel};
11use krabka_client_core::security::ClientSecurity;
12
13use crate::config::{ClientResourcePolicy, ReplicationFactor, ReplicatorRuntimePolicy};
14
15const TOPIC_ALREADY_EXISTS: i16 = 36;
17
18#[tracing::instrument(
21 level = "info",
22 skip_all,
23 fields(topic = %topic, partitions, bootstrap = %bootstrap),
24 err,
25)]
26pub async fn ensure_topic(
29 bootstrap: &str,
30 topic: &str,
31 partitions: i32,
32 security: Option<ClientSecurity>,
33) -> Result<(), String> {
34 ensure_topic_with_policy(
35 bootstrap,
36 topic,
37 partitions,
38 security,
39 ClientResourcePolicy::default(),
40 )
41 .await
42}
43
44pub async fn ensure_topic_with_policy(
51 bootstrap: &str,
52 topic: &str,
53 partitions: i32,
54 security: Option<ClientSecurity>,
55 client_resource_policy: ClientResourcePolicy,
56) -> Result<(), String> {
57 let runtime_policy = ReplicatorRuntimePolicy::default();
58 ensure_topic_with_runtime_policy(
59 bootstrap,
60 topic,
61 partitions,
62 security,
63 client_resource_policy,
64 &runtime_policy,
65 runtime_policy.data_topic_replication_factor,
66 )
67 .await
68}
69
70pub async fn ensure_topic_with_replication_factor(
76 bootstrap: &str,
77 topic: &str,
78 partitions: i32,
79 security: Option<ClientSecurity>,
80 replication_factor: ReplicationFactor,
81) -> Result<(), String> {
82 let runtime_policy = ReplicatorRuntimePolicy {
83 data_topic_replication_factor: replication_factor,
84 ..ReplicatorRuntimePolicy::default()
85 };
86 ensure_topic_with_runtime_policy(
87 bootstrap,
88 topic,
89 partitions,
90 security,
91 ClientResourcePolicy::default(),
92 &runtime_policy,
93 replication_factor,
94 )
95 .await
96}
97
98pub(crate) async fn ensure_topic_with_runtime_policy(
100 bootstrap: &str,
101 topic: &str,
102 partitions: i32,
103 security: Option<ClientSecurity>,
104 client_resource_policy: ClientResourcePolicy,
105 runtime_policy: &ReplicatorRuntimePolicy,
106 replication_factor: ReplicationFactor,
107) -> Result<(), String> {
108 let mut admin = AdminClient::connect_with_options(
109 &[bootstrap.to_string()],
110 admin_options(security, client_resource_policy, runtime_policy)?,
111 )
112 .await
113 .map_err(|e| e.to_string())?;
114
115 let outcomes = admin
116 .create_topics(
117 &[CreateTopicSpec {
118 name: topic.to_string(),
119 partitions,
120 replicas: i32::from(replication_factor.get()),
121 configs: BTreeMap::new(),
122 ..Default::default()
123 }],
124 TopicMutationOptions::with_timeout(runtime_policy.topic_create_timeout),
125 )
126 .await
127 .map_err(|e| e.to_string())?;
128
129 for outcome in &outcomes {
130 if let Some(ref err) = outcome.error
131 && err.code != TOPIC_ALREADY_EXISTS
132 {
133 return Err(format!(
134 "create_topic {topic}: error code {} ({}): {:?}",
135 err.code, err.name, err.message
136 ));
137 }
138 }
139
140 Ok(())
141}
142
143#[tracing::instrument(
145 level = "info",
146 skip_all,
147 fields(topic = %topic, bootstrap = %bootstrap),
148 err,
149)]
150pub async fn ensure_compacted_topic(
153 bootstrap: &str,
154 topic: &str,
155 security: Option<ClientSecurity>,
156) -> Result<(), String> {
157 ensure_compacted_topic_with_policy(bootstrap, topic, security, ClientResourcePolicy::default())
158 .await
159}
160
161pub async fn ensure_compacted_topic_with_policy(
168 bootstrap: &str,
169 topic: &str,
170 security: Option<ClientSecurity>,
171 client_resource_policy: ClientResourcePolicy,
172) -> Result<(), String> {
173 let runtime_policy = ReplicatorRuntimePolicy::default();
174 ensure_compacted_topic_with_runtime_policy(
175 bootstrap,
176 topic,
177 security,
178 client_resource_policy,
179 &runtime_policy,
180 )
181 .await
182}
183
184pub async fn ensure_compacted_topic_with_replication_factor(
190 bootstrap: &str,
191 topic: &str,
192 security: Option<ClientSecurity>,
193 replication_factor: ReplicationFactor,
194) -> Result<(), String> {
195 let runtime_policy = ReplicatorRuntimePolicy {
196 internal_topic_replication_factor: replication_factor,
197 ..ReplicatorRuntimePolicy::default()
198 };
199 ensure_compacted_topic_with_runtime_policy(
200 bootstrap,
201 topic,
202 security,
203 ClientResourcePolicy::default(),
204 &runtime_policy,
205 )
206 .await
207}
208
209pub(crate) async fn ensure_compacted_topic_with_runtime_policy(
210 bootstrap: &str,
211 topic: &str,
212 security: Option<ClientSecurity>,
213 client_resource_policy: ClientResourcePolicy,
214 runtime_policy: &ReplicatorRuntimePolicy,
215) -> Result<(), String> {
216 let mut admin = AdminClient::connect_with_options(
217 &[bootstrap.to_string()],
218 admin_options(security, client_resource_policy, runtime_policy)?,
219 )
220 .await
221 .map_err(|e| e.to_string())?;
222
223 let mut configs = BTreeMap::new();
224 configs.insert("cleanup.policy".to_string(), "compact".to_string());
225
226 let outcomes = admin
227 .create_topics(
228 &[CreateTopicSpec {
229 name: topic.to_string(),
230 partitions: 1,
231 replicas: i32::from(runtime_policy.internal_topic_replication_factor.get()),
232 configs,
233 ..Default::default()
234 }],
235 TopicMutationOptions::with_timeout(runtime_policy.topic_create_timeout),
236 )
237 .await
238 .map_err(|e| e.to_string())?;
239
240 for outcome in &outcomes {
241 if let Some(ref err) = outcome.error
242 && err.code != TOPIC_ALREADY_EXISTS
243 {
244 return Err(format!(
245 "ensure_compacted_topic {topic}: error code {} ({}): {:?}",
246 err.code, err.name, err.message
247 ));
248 }
249 }
250
251 Ok(())
252}
253
254fn admin_options(
255 security: Option<ClientSecurity>,
256 policy: ClientResourcePolicy,
257 runtime_policy: &ReplicatorRuntimePolicy,
258) -> Result<krabka_client_core::ConnectionOptions, String> {
259 Ok(krabka_client_core::ConnectionOptions {
260 dns_timeout: krabka_client_core::ClientDnsTimeout::new(runtime_policy.client_dns_timeout)?,
261 socket_connection_setup_timeout: runtime_policy.client_connect_timeout,
262 socket_connection_setup_timeout_max: runtime_policy.client_connect_timeout,
263 request_timeout: runtime_policy.client_request_timeout,
264 client_id: "krabka-operator".to_owned(),
265 dispatch_queue_capacity: policy.dispatch_queue_capacity,
266 frame_max: policy.frame_max,
267 security: security.map(Box::new),
268 ..Default::default()
269 })
270}
271
272async fn build_drain_consumer(
276 bootstrap: &str,
277 group_id: String,
278 topic: &str,
279 security: Option<ClientSecurity>,
280 client_resource_policy: ClientResourcePolicy,
281) -> Result<Consumer, krabka_client_consumer::ConsumerError> {
282 if let Some(sec) = security {
283 Consumer::builder()
284 .bootstrap(bootstrap)
285 .group_id(group_id)
286 .client_id("krabka-replicator-util")
287 .dispatch_queue_capacity(client_resource_policy.dispatch_queue_capacity.get())
288 .frame_max(client_resource_policy.frame_max.size())
289 .subscribe(vec![topic.to_string()])
290 .auto_offset_reset(AutoOffsetReset::Earliest)
291 .enable_auto_commit(false)
292 .allow_auto_create_topics(false)
293 .isolation_level(IsolationLevel::ReadCommitted)
294 .security(sec)
295 .build()
296 .await
297 } else {
298 Consumer::builder()
299 .bootstrap(bootstrap)
300 .group_id(group_id)
301 .client_id("krabka-replicator-util")
302 .dispatch_queue_capacity(client_resource_policy.dispatch_queue_capacity.get())
303 .frame_max(client_resource_policy.frame_max.size())
304 .subscribe(vec![topic.to_string()])
305 .auto_offset_reset(AutoOffsetReset::Earliest)
306 .enable_auto_commit(false)
307 .allow_auto_create_topics(false)
308 .isolation_level(IsolationLevel::ReadCommitted)
309 .build()
310 .await
311 }
312}
313
314pub type RawRecord = (Option<Bytes>, Option<Bytes>);
321
322#[tracing::instrument(
323 level = "debug",
324 skip_all,
325 fields(topic = %topic, drained = tracing::field::Empty),
326 err,
327)]
328pub async fn read_all(
331 bootstrap: &str,
332 topic: &str,
333 security: Option<ClientSecurity>,
334) -> Result<Vec<RawRecord>, String> {
335 read_all_with_policy(bootstrap, topic, security, ClientResourcePolicy::default()).await
336}
337
338pub async fn read_all_with_policy(
345 bootstrap: &str,
346 topic: &str,
347 security: Option<ClientSecurity>,
348 client_resource_policy: ClientResourcePolicy,
349) -> Result<Vec<RawRecord>, String> {
350 let runtime_policy = ReplicatorRuntimePolicy::default();
351 read_all_with_runtime_policy(
352 bootstrap,
353 topic,
354 security,
355 client_resource_policy,
356 &runtime_policy,
357 )
358 .await
359}
360
361pub(crate) async fn read_all_with_runtime_policy(
362 bootstrap: &str,
363 topic: &str,
364 security: Option<ClientSecurity>,
365 client_resource_policy: ClientResourcePolicy,
366 runtime_policy: &ReplicatorRuntimePolicy,
367) -> Result<Vec<RawRecord>, String> {
368 let group_id = format!("krabka-replicator-reader-{topic}");
369
370 let mut consumer =
371 match build_drain_consumer(bootstrap, group_id, topic, security, client_resource_policy)
372 .await
373 {
374 Ok(c) => c,
375 Err(e) => {
376 let msg = e.to_string();
377 if is_unknown_topic_error(&msg) {
378 return Ok(Vec::new());
379 }
380 return Err(msg);
381 }
382 };
383
384 let mut records = Vec::new();
385 let mut consecutive_empty = 0usize;
386
387 loop {
388 match consumer
389 .poll(runtime_policy.internal_drain_poll_timeout)
390 .await
391 {
392 Ok(batch) => {
393 if batch.is_empty() {
394 consecutive_empty += 1;
395 if consecutive_empty >= runtime_policy.internal_drain_empty_polls.get() {
396 break;
397 }
398 } else {
399 consecutive_empty = 0;
400 for r in batch {
401 records.push((r.key, r.value));
402 }
403 }
404 }
405 Err(e) => {
406 let msg = e.to_string();
407 if is_unknown_topic_error(&msg) {
408 break;
409 }
410 let _ = consumer.close().await;
411 return Err(msg);
412 }
413 }
414 }
415
416 let _ = consumer.close().await;
417 tracing::Span::current().record("drained", records.len());
418 Ok(records)
419}
420
421pub async fn read_last_value_for_key(
428 bootstrap: &str,
429 topic: &str,
430 key: &[u8],
431 security: Option<ClientSecurity>,
432) -> Result<Option<Vec<u8>>, String> {
433 read_last_value_for_key_with_policy(
434 bootstrap,
435 topic,
436 key,
437 security,
438 ClientResourcePolicy::default(),
439 )
440 .await
441}
442
443pub async fn read_last_value_for_key_with_policy(
450 bootstrap: &str,
451 topic: &str,
452 key: &[u8],
453 security: Option<ClientSecurity>,
454 client_resource_policy: ClientResourcePolicy,
455) -> Result<Option<Vec<u8>>, String> {
456 let runtime_policy = ReplicatorRuntimePolicy::default();
457 read_last_value_for_key_with_runtime_policy(
458 bootstrap,
459 topic,
460 key,
461 security,
462 client_resource_policy,
463 &runtime_policy,
464 )
465 .await
466}
467
468pub(crate) async fn read_last_value_for_key_with_runtime_policy(
469 bootstrap: &str,
470 topic: &str,
471 key: &[u8],
472 security: Option<ClientSecurity>,
473 client_resource_policy: ClientResourcePolicy,
474 runtime_policy: &ReplicatorRuntimePolicy,
475) -> Result<Option<Vec<u8>>, String> {
476 let all = read_all_with_runtime_policy(
477 bootstrap,
478 topic,
479 security,
480 client_resource_policy,
481 runtime_policy,
482 )
483 .await?;
484
485 let matched = if key.is_empty() {
486 all.into_iter().last()
487 } else {
488 all.into_iter()
489 .filter(|(k, _)| k.as_deref() == Some(key))
490 .last()
491 };
492
493 Ok(matched.and_then(|(_, v)| v.map(|b| b.to_vec())))
494}
495
496pub(crate) fn is_unknown_topic_error(msg: &str) -> bool {
498 msg.contains("UNKNOWN_TOPIC_OR_PARTITION")
499 || msg.contains("unknown topic")
500 || msg.contains("UnknownTopicOrPartition")
501 || msg.contains("NotSubscribed")
502}
503
504#[cfg(test)]
505mod tests {
506 use krabka_units::prelude::{TimeExt as _, secs};
507
508 #[test]
509 fn create_topics_timeout_reaches_the_wire_as_int32_millis() {
510 let policy = crate::config::ReplicatorRuntimePolicy::default();
514 assert2::assert!(policy.topic_create_timeout.millis_i32() == 10_000);
515 }
516
517 #[test]
518 fn drain_poll_timeout_is_half_a_second() {
519 let policy = crate::config::ReplicatorRuntimePolicy::default();
520 assert2::assert!(policy.internal_drain_poll_timeout == krabka_units::millis(500));
521 }
522
523 #[test]
524 fn unknown_topic_error_matches_each_substring() {
525 for (msg, want) in [
529 ("x UNKNOWN_TOPIC_OR_PARTITION y", true),
530 ("unknown topic foo", true),
531 ("UnknownTopicOrPartition", true),
532 ("err: NotSubscribed", true),
533 ("connection refused", false),
535 ] {
536 assert2::assert!(super::is_unknown_topic_error(msg) == want);
537 }
538 }
539
540 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
541 async fn reading_a_missing_topic_preserves_explicit_partition_creation() {
542 let dir = tempfile::TempDir::new().unwrap();
543 let broker = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
544 dir.path().to_path_buf(),
545 ))
546 .await
547 .unwrap();
548 let bootstrap = broker.listen_addr().to_string();
549 let topic = "explicit-three-partitions";
550
551 let records = super::read_all(&bootstrap, topic, None).await.unwrap();
552 assert2::assert!(records.is_empty());
553
554 super::ensure_topic(&bootstrap, topic, 3, None)
555 .await
556 .unwrap();
557 let mut admin = krabka_client_admin::AdminClient::connect(&[bootstrap])
558 .await
559 .unwrap();
560 let metadata = admin.metadata(&[topic]).await.unwrap();
561 assert2::assert!(metadata.topics[0].partition_count == 3);
562 }
563
564 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
565 async fn ensure_produce_read_roundtrip() {
566 let dir = tempfile::TempDir::new().unwrap();
567 let broker = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
568 dir.path().to_path_buf(),
569 ))
570 .await
571 .unwrap();
572 let b = broker.listen_addr().to_string();
573
574 crate::test_util::create_topic(&b, "t", 1).await;
575 crate::test_util::produce(&b, "t", b"k1", b"v1").await;
576 crate::test_util::produce(&b, "t", b"k1", b"v2").await;
577
578 assert2::assert!(crate::test_util::topic_record_count(&b, "t").await == 2);
579
580 let last = super::read_last_value_for_key(&b, "t", b"k1", None)
581 .await
582 .unwrap();
583 assert2::assert!(last.as_deref() == Some(b"v2".as_slice()));
584
585 super::ensure_compacted_topic(&b, "state", None)
586 .await
587 .unwrap();
588
589 crate::test_util::await_topic_count(&b, "t", 2, secs(5)).await;
590 }
591}