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}