Skip to main content

crabka_replicator/
admin_util.rs

1//! Shared admin/IO helpers used by replicator engine tasks.
2//!
3//! All functions return `Result<_, String>` and map client errors via
4//! `.map_err(|e| e.to_string())` so callers stay wire-error-agnostic.
5
6use 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
14/// Kafka error code: the topic already exists.
15const TOPIC_ALREADY_EXISTS: i16 = 36;
16
17/// Ensure `topic` exists with the given parameters, treating an
18/// already-exists response as success.
19pub 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
56/// Ensure a compacted topic exists with 1 partition and 1 replica.
57pub 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
96/// Build a drain consumer for the given topic.  Security is threaded through
97/// conditionally — `bon` wraps bare `T` setters in `Some`, so passing `None`
98/// means simply omitting the `.security()` call.
99async 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
127/// Drain all records from `topic` from the earliest offset, returning
128/// `(key, value)` pairs in order.
129///
130/// Uses N=3 consecutive empty polls (500 ms each) as the drain sentinel.
131/// Poll errors for a not-yet-existing topic are silently treated as empty.
132pub 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
185/// Return the value bytes of the last record whose key equals `key`.
186///
187/// If `key` is empty, returns the last record overall regardless of key.
188pub 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
207/// Returns `true` if the error message indicates the topic doesn't exist.
208fn 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        // Each positive exercises exactly one of the OR'd substrings, so the
222        // single-substring match must hold on its own (kills `||`→`&&`, which
223        // would require all four, and the `-> true`/`-> false` constants).
224        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        // A message with none of the substrings must not match (kills `-> true`).
231        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}