Skip to main content

barnabas_client/
builder.rs

1//! Staged builders, so the type says what is still missing.
2//!
3//! # Why not one builder struct
4//!
5//! The usual `Builder::new().a().b().build()` shape answers "what can I set?"
6//! but not "what must I still set?" — `build()` is always offered, and a
7//! missing bootstrap list is a runtime error. Here each stage is **its own
8//! type**, exposing only what is valid at that point:
9//!
10//! ```text
11//! Consumer::builder(Glommio)   → needs a bootstrap list, and offers nothing else
12//!   .bootstrap([..])           → needs a client id, and offers nothing else
13//!   .client_id("my-app")       → now optional settings appear, and `build` exists
14//! ```
15//!
16//! An editor's completion list is therefore the set of legal next steps, and
17//! forgetting a required one is a compile error naming the stage rather than a
18//! failure at connect.
19//!
20//! # What this fixes about the constructors
21//!
22//! `Consumer::assign` takes seven positional arguments, two of which are an
23//! adjacent `partition: i32` and `offset: i64` — transposing them compiles.
24//! `EARLIEST` and `LATEST` are `i64` sentinels sharing that offset parameter.
25//! And every option is a setter that only exists *after* construction, so the
26//! thing that tells you `max_wait` is adjustable is documentation rather than
27//! the type.
28//!
29//! The constructors remain: they are the shortest path when nothing optional is
30//! wanted, and the builder is written in terms of them.
31
32use std::time::Duration;
33
34use barnabas_core::{IsolationLevel, Partitioner};
35use kafka_protocol::records::Compression;
36
37use crate::{Consumer, Credentials, Producer, Result, Transport, EARLIEST, LATEST};
38
39/// Where a partition starts reading.
40///
41/// An enum rather than the `i64` the protocol uses, because `EARLIEST` and
42/// `LATEST` are negative sentinels sharing a parameter with real offsets — a
43/// distinction worth having the compiler keep.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum StartOffset {
46    Earliest,
47    Latest,
48    /// A stored offset — the case a system with its own checkpoints uses.
49    At(i64),
50}
51
52impl StartOffset {
53    fn as_i64(self) -> i64 {
54        match self {
55            Self::Earliest => EARLIEST,
56            Self::Latest => LATEST,
57            Self::At(offset) => offset,
58        }
59    }
60}
61
62// ── consumer ─────────────────────────────────────────────────────────────────
63
64/// Stage 1: has a transport, needs a bootstrap list.
65pub struct ConsumerBuilder<T> {
66    transport: T,
67}
68
69impl<T: Transport> ConsumerBuilder<T> {
70    pub(crate) fn new(transport: T) -> Self {
71        Self { transport }
72    }
73
74    /// The brokers to contact first. Any one that answers is enough; the rest
75    /// of the cluster comes from its metadata.
76    #[must_use]
77    pub fn bootstrap<I, S>(self, addrs: I) -> ConsumerNeedsClientId<T>
78    where
79        I: IntoIterator<Item = S>,
80        S: Into<String>,
81    {
82        ConsumerNeedsClientId {
83            transport: self.transport,
84            bootstrap: addrs.into_iter().map(Into::into).collect(),
85        }
86    }
87}
88
89/// Stage 2: needs a client id.
90pub struct ConsumerNeedsClientId<T> {
91    transport: T,
92    bootstrap: Vec<String>,
93}
94
95impl<T: Transport> ConsumerNeedsClientId<T> {
96    /// How this client identifies itself to the broker. It appears in the
97    /// broker's logs and metrics, so it is worth making it say which service
98    /// this is.
99    #[must_use]
100    pub fn client_id(self, client_id: impl Into<String>) -> ConsumerReady<T> {
101        ConsumerReady {
102            transport: self.transport,
103            bootstrap: self.bootstrap,
104            client_id: client_id.into(),
105            isolation: IsolationLevel::ReadCommitted,
106            credentials: None,
107            max_wait: None,
108            prefetch: None,
109            incremental: None,
110            assignments: Vec::new(),
111            every_partition: Vec::new(),
112        }
113    }
114}
115
116/// Stage 3: everything required is present; the rest is optional.
117pub struct ConsumerReady<T> {
118    transport: T,
119    bootstrap: Vec<String>,
120    client_id: String,
121    isolation: IsolationLevel,
122    credentials: Option<Credentials>,
123    max_wait: Option<Duration>,
124    prefetch: Option<bool>,
125    incremental: Option<bool>,
126    assignments: Vec<(String, i32, StartOffset)>,
127    every_partition: Vec<(String, StartOffset)>,
128}
129
130impl<T: Transport> ConsumerReady<T> {
131    /// Defaults to [`IsolationLevel::ReadCommitted`] — the safe end, since
132    /// READ_UNCOMMITTED shows records from transactions that later aborted.
133    #[must_use]
134    pub fn isolation(mut self, isolation: IsolationLevel) -> Self {
135        self.isolation = isolation;
136        self
137    }
138
139    /// SASL credentials. Pair `PLAIN` with TLS; it sends the password in the
140    /// clear.
141    #[must_use]
142    pub fn credentials(mut self, credentials: Credentials) -> Self {
143        self.credentials = Some(credentials);
144        self
145    }
146
147    /// Assign a partition and where to start it. Call it once per partition.
148    ///
149    /// Assignment is the caller's: there is no consumer group and no rebalance,
150    /// so nothing assigns partitions behind your back.
151    #[must_use]
152    pub fn assign(mut self, topic: impl Into<String>, partition: i32, start: StartOffset) -> Self {
153        self.assignments.push((topic.into(), partition, start));
154        self
155    }
156
157    /// Assign a range of partitions, all starting at the same place.
158    #[must_use]
159    pub fn assign_range(
160        mut self,
161        topic: impl Into<String>,
162        partitions: impl IntoIterator<Item = i32>,
163        start: StartOffset,
164    ) -> Self {
165        let topic = topic.into();
166        for partition in partitions {
167            self.assignments.push((topic.clone(), partition, start));
168        }
169        self
170    }
171
172    /// Assign **every** partition of `topic`, asking the broker how many there
173    /// are.
174    ///
175    /// The count is the one thing about an assignment worth asking for: it
176    /// changes when a topic is expanded, and hardcoding it in an
177    /// [`assign_range`](Self::assign_range) silently stops consuming the new
178    /// partitions. *Which* partitions this client owns is still the caller's —
179    /// there is no consumer group here, so a process that wants a share of a
180    /// topic rather than all of it assigns that share itself.
181    ///
182    /// **Resolved once, at [`build`](Self::build), and that is a real hazard for
183    /// a topic you do not own.** Adding partitions is how a topic is scaled,
184    /// and it is usually done by whoever produces to it. A topic expanded from
185    /// 8 to 16 partitions after this call leaves partitions 8–15 unread
186    /// indefinitely: nothing errors, and the consumer looks healthy while
187    /// missing a share of its input.
188    ///
189    /// Until this client can watch for that — see `docs/completing-the-client.md`,
190    /// which is where the fix is scoped — `assign_all` means *all of them as of
191    /// now*, and a caller reading a topic owned by someone else should poll
192    /// [`Consumer::partition_count`](crate::Consumer::partition_count) and
193    /// rebuild when it grows.
194    #[must_use]
195    pub fn assign_all(mut self, topic: impl Into<String>, start: StartOffset) -> Self {
196        self.every_partition.push((topic.into(), start));
197        self
198    }
199
200    /// How long a fetch waits at the broker for data before coming back empty.
201    #[must_use]
202    pub fn max_wait(mut self, max_wait: Duration) -> Self {
203        self.max_wait = Some(max_wait);
204        self
205    }
206
207    /// Keep a fetch permanently in flight, so the broker is already working
208    /// while the caller processes the last batch. On by default.
209    #[must_use]
210    pub fn prefetch(mut self, prefetch: bool) -> Self {
211        self.prefetch = Some(prefetch);
212        self
213    }
214
215    /// Incremental fetch sessions (KIP-227). On by default; turn it off for a
216    /// broker or proxy that mishandles them.
217    #[must_use]
218    pub fn incremental_fetch(mut self, incremental: bool) -> Self {
219        self.incremental = Some(incremental);
220        self
221    }
222
223    /// Connect, authenticate, and resolve every assignment's starting offset.
224    ///
225    /// # Errors
226    /// If no bootstrap address answers, authentication fails, or a topic or
227    /// partition in an assignment does not exist.
228    pub async fn build(self) -> Result<Consumer<T>> {
229        let mut consumer = if self.credentials.is_some() {
230            let mut cluster =
231                crate::Cluster::connect(self.transport, &self.bootstrap, &self.client_id).await?;
232            if let Some(credentials) = self.credentials {
233                cluster.set_credentials(credentials);
234            }
235            Consumer::from_cluster(cluster, self.isolation)
236        } else {
237            Consumer::new(
238                self.transport,
239                &self.bootstrap,
240                &self.client_id,
241                self.isolation,
242            )
243            .await?
244        };
245
246        // Settings before assignments: `add` resolves offsets with a request,
247        // and it should use the settings the caller asked for.
248        if let Some(max_wait) = self.max_wait {
249            consumer.set_max_wait(max_wait);
250        }
251        if let Some(prefetch) = self.prefetch {
252            consumer.set_prefetch(prefetch);
253        }
254        if let Some(incremental) = self.incremental {
255            consumer.set_incremental_fetch(incremental);
256        }
257        for (topic, start) in self.every_partition {
258            let count = consumer.partition_count(&topic).await?;
259            for partition in 0..count {
260                consumer.assign(&topic, partition, start.as_i64()).await?;
261            }
262        }
263        for (topic, partition, start) in self.assignments {
264            consumer.assign(&topic, partition, start.as_i64()).await?;
265        }
266        Ok(consumer)
267    }
268}
269
270// ── producer ─────────────────────────────────────────────────────────────────
271
272/// Stage 1: has a transport, needs a bootstrap list.
273pub struct ProducerBuilder<T> {
274    transport: T,
275}
276
277impl<T: Transport> ProducerBuilder<T> {
278    pub(crate) fn new(transport: T) -> Self {
279        Self { transport }
280    }
281
282    /// See [`ConsumerBuilder::bootstrap`].
283    #[must_use]
284    pub fn bootstrap<I, S>(self, addrs: I) -> ProducerNeedsClientId<T>
285    where
286        I: IntoIterator<Item = S>,
287        S: Into<String>,
288    {
289        ProducerNeedsClientId {
290            transport: self.transport,
291            bootstrap: addrs.into_iter().map(Into::into).collect(),
292        }
293    }
294}
295
296/// Stage 2: needs a client id.
297pub struct ProducerNeedsClientId<T> {
298    transport: T,
299    bootstrap: Vec<String>,
300}
301
302impl<T: Transport> ProducerNeedsClientId<T> {
303    /// See [`ConsumerNeedsClientId::client_id`].
304    #[must_use]
305    pub fn client_id(self, client_id: impl Into<String>) -> ProducerReady<T> {
306        ProducerReady {
307            transport: self.transport,
308            bootstrap: self.bootstrap,
309            client_id: client_id.into(),
310            transactional_id: None,
311            compression: None,
312            partitioner: None,
313            max_in_flight: None,
314        }
315    }
316}
317
318/// Stage 3: everything required is present; the rest is optional.
319///
320/// Builds an **idempotent** producer unless
321/// [`transactional_id`](Self::transactional_id) is given — idempotence is not
322/// optional here, because a producer without it silently duplicates on retry.
323pub struct ProducerReady<T> {
324    transport: T,
325    bootstrap: Vec<String>,
326    client_id: String,
327    transactional_id: Option<String>,
328    compression: Option<Compression>,
329    partitioner: Option<Partitioner>,
330    max_in_flight: Option<usize>,
331}
332
333impl<T: Transport> ProducerReady<T> {
334    /// Make this a transactional producer under `id`.
335    ///
336    /// **Stable per instance, and held by one process at a time.** Building a
337    /// producer fences any earlier one holding the same id, which is how a
338    /// restarted job stops its own zombie — and, if two live instances share an
339    /// id, how they stop each other.
340    #[must_use]
341    pub fn transactional_id(mut self, id: impl Into<String>) -> Self {
342        self.transactional_id = Some(id.into());
343        self
344    }
345
346    /// Compress whole batches. `snappy` and `zstd` measure fastest here — see
347    /// `PERF.md`.
348    #[must_use]
349    pub fn compression(mut self, compression: Compression) -> Self {
350        self.compression = Some(compression);
351        self
352    }
353
354    /// Which hash places a keyed record. Defaults to librdkafka's CRC-32, so a
355    /// program migrating off `rdkafka` keeps its key placement;
356    /// [`Partitioner::Murmur2`] matches the Java client instead.
357    #[must_use]
358    pub fn partitioner(mut self, partitioner: Partitioner) -> Self {
359        self.partitioner = partitioner.into();
360        self
361    }
362
363    /// Requests in flight per connection. Five by default, as in the Java
364    /// client; one restores strict request-response.
365    #[must_use]
366    pub fn max_in_flight(mut self, max: usize) -> Self {
367        self.max_in_flight = Some(max);
368        self
369    }
370
371    /// Connect and acquire a producer id.
372    ///
373    /// # Errors
374    /// If no bootstrap address answers, or the transaction coordinator never
375    /// becomes available.
376    pub async fn build(self) -> Result<Producer<T>> {
377        let mut producer = match &self.transactional_id {
378            Some(id) => {
379                Producer::transactional(self.transport, &self.bootstrap, &self.client_id, id)
380                    .await?
381            }
382            None => Producer::idempotent(self.transport, &self.bootstrap, &self.client_id).await?,
383        };
384        if let Some(compression) = self.compression {
385            producer.set_compression(compression);
386        }
387        if let Some(partitioner) = self.partitioner {
388            producer.set_partitioner(partitioner);
389        }
390        if let Some(max) = self.max_in_flight {
391            producer.set_max_in_flight(max);
392        }
393        Ok(producer)
394    }
395}