crabka_replicator/
admin_util.rs1use std::collections::BTreeMap;
7use std::time::Duration;
8
9use bytes::Bytes;
10use crabka_client_admin::{AdminClient, CreateTopicSpec};
11use crabka_client_consumer::{AutoOffsetReset, Consumer};
12use crabka_client_core::security::ClientSecurity;
13
14const TOPIC_ALREADY_EXISTS: i16 = 36;
16
17pub async fn ensure_topic(
20 bootstrap: &str,
21 topic: &str,
22 partitions: i32,
23 security: Option<ClientSecurity>,
24) -> Result<(), String> {
25 let mut admin = AdminClient::connect_secured(&[bootstrap.to_string()], security)
26 .await
27 .map_err(|e| e.to_string())?;
28
29 let outcomes = admin
30 .create_topics(
31 &[CreateTopicSpec {
32 name: topic.to_string(),
33 partitions,
34 replicas: 1,
35 configs: BTreeMap::new(),
36 }],
37 10_000,
38 )
39 .await
40 .map_err(|e| e.to_string())?;
41
42 for outcome in &outcomes {
43 if let Some(ref err) = outcome.error
44 && err.code != TOPIC_ALREADY_EXISTS
45 {
46 return Err(format!(
47 "create_topic {topic}: error code {} ({}): {:?}",
48 err.code, err.name, err.message
49 ));
50 }
51 }
52
53 Ok(())
54}
55
56pub async fn ensure_compacted_topic(
58 bootstrap: &str,
59 topic: &str,
60 security: Option<ClientSecurity>,
61) -> Result<(), String> {
62 let mut admin = AdminClient::connect_secured(&[bootstrap.to_string()], security)
63 .await
64 .map_err(|e| e.to_string())?;
65
66 let mut configs = BTreeMap::new();
67 configs.insert("cleanup.policy".to_string(), "compact".to_string());
68
69 let outcomes = admin
70 .create_topics(
71 &[CreateTopicSpec {
72 name: topic.to_string(),
73 partitions: 1,
74 replicas: 1,
75 configs,
76 }],
77 10_000,
78 )
79 .await
80 .map_err(|e| e.to_string())?;
81
82 for outcome in &outcomes {
83 if let Some(ref err) = outcome.error
84 && err.code != TOPIC_ALREADY_EXISTS
85 {
86 return Err(format!(
87 "ensure_compacted_topic {topic}: error code {} ({}): {:?}",
88 err.code, err.name, err.message
89 ));
90 }
91 }
92
93 Ok(())
94}
95
96async fn build_drain_consumer(
100 bootstrap: &str,
101 group_id: String,
102 topic: &str,
103 security: Option<ClientSecurity>,
104) -> Result<Consumer, crabka_client_consumer::ConsumerError> {
105 if let Some(sec) = security {
106 Consumer::builder()
107 .bootstrap(bootstrap)
108 .group_id(group_id)
109 .client_id("crabka-replicator-util")
110 .subscribe(vec![topic.to_string()])
111 .auto_offset_reset(AutoOffsetReset::Earliest)
112 .security(sec)
113 .build()
114 .await
115 } else {
116 Consumer::builder()
117 .bootstrap(bootstrap)
118 .group_id(group_id)
119 .client_id("crabka-replicator-util")
120 .subscribe(vec![topic.to_string()])
121 .auto_offset_reset(AutoOffsetReset::Earliest)
122 .build()
123 .await
124 }
125}
126
127pub async fn read_all(
133 bootstrap: &str,
134 topic: &str,
135 security: Option<ClientSecurity>,
136) -> Result<Vec<(Option<Bytes>, Option<Bytes>)>, String> {
137 const MAX_EMPTY: usize = 3;
138
139 let group_id = format!("crabka-replicator-reader-{topic}");
140
141 let mut consumer = match build_drain_consumer(bootstrap, group_id, topic, security).await {
142 Ok(c) => c,
143 Err(e) => {
144 let msg = e.to_string();
145 if is_unknown_topic_error(&msg) {
146 return Ok(Vec::new());
147 }
148 return Err(msg);
149 }
150 };
151
152 let mut records = Vec::new();
153 let mut consecutive_empty = 0usize;
154
155 loop {
156 match consumer.poll(Duration::from_millis(500)).await {
157 Ok(batch) => {
158 if batch.is_empty() {
159 consecutive_empty += 1;
160 if consecutive_empty >= MAX_EMPTY {
161 break;
162 }
163 } else {
164 consecutive_empty = 0;
165 for r in batch {
166 records.push((r.key, r.value));
167 }
168 }
169 }
170 Err(e) => {
171 let msg = e.to_string();
172 if is_unknown_topic_error(&msg) {
173 break;
174 }
175 let _ = consumer.close().await;
176 return Err(msg);
177 }
178 }
179 }
180
181 let _ = consumer.close().await;
182 Ok(records)
183}
184
185pub async fn read_last_value_for_key(
189 bootstrap: &str,
190 topic: &str,
191 key: &[u8],
192 security: Option<ClientSecurity>,
193) -> Result<Option<Vec<u8>>, String> {
194 let all = read_all(bootstrap, topic, security).await?;
195
196 let matched = if key.is_empty() {
197 all.into_iter().last()
198 } else {
199 all.into_iter()
200 .filter(|(k, _)| k.as_deref() == Some(key))
201 .last()
202 };
203
204 Ok(matched.and_then(|(_, v)| v.map(|b| b.to_vec())))
205}
206
207fn is_unknown_topic_error(msg: &str) -> bool {
209 msg.contains("UNKNOWN_TOPIC_OR_PARTITION")
210 || msg.contains("unknown topic")
211 || msg.contains("UnknownTopicOrPartition")
212 || msg.contains("NotSubscribed")
213}
214
215#[cfg(test)]
216mod tests {
217 use assert2::assert;
218
219 #[test]
220 fn unknown_topic_error_matches_each_substring() {
221 assert!(super::is_unknown_topic_error(
225 "x UNKNOWN_TOPIC_OR_PARTITION y"
226 ));
227 assert!(super::is_unknown_topic_error("unknown topic foo"));
228 assert!(super::is_unknown_topic_error("UnknownTopicOrPartition"));
229 assert!(super::is_unknown_topic_error("err: NotSubscribed"));
230 assert!(!super::is_unknown_topic_error("connection refused"));
232 }
233
234 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
235 async fn ensure_produce_read_roundtrip() {
236 let dir = tempfile::TempDir::new().unwrap();
237 let broker = crabka_broker::Broker::start(crabka_broker::BrokerConfig::for_tests(
238 dir.path().to_path_buf(),
239 ))
240 .await
241 .unwrap();
242 let b = broker.listen_addr().to_string();
243
244 crate::test_util::create_topic(&b, "t", 1).await;
245 crate::test_util::produce(&b, "t", b"k1", b"v1").await;
246 crate::test_util::produce(&b, "t", b"k1", b"v2").await;
247
248 assert!(crate::test_util::topic_record_count(&b, "t").await == 2);
249
250 let last = super::read_last_value_for_key(&b, "t", b"k1", None)
251 .await
252 .unwrap();
253 assert!(last.as_deref() == Some(b"v2".as_slice()));
254
255 super::ensure_compacted_topic(&b, "state", None)
256 .await
257 .unwrap();
258
259 crate::test_util::await_topic_count(&b, "t", 2, std::time::Duration::from_secs(5)).await;
260 }
261}