Skip to main content

ruststream_rdkafka/
topic.rs

1//! The subscription descriptor: one topic consumed through one consumer group.
2
3use ruststream::SubscriptionSource;
4
5use crate::broker::KafkaBroker;
6use crate::error::KafkaError;
7use crate::retry::Retry;
8use crate::subscriber::KafkaSubscriber;
9
10/// Where a consumer group starts reading when it has no valid committed offset.
11///
12/// Kafka resumes from the group's committed position when a valid one exists; this choice (it
13/// maps to librdkafka's `auto.offset.reset`) applies when there is none - the group has never
14/// committed the partition, or the committed offset was deleted by retention / is out of
15/// range. The second case is why it matters for long-idle groups: with the librdkafka default
16/// (latest) an expired group skips to the end instead of reprocessing.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18#[non_exhaustive]
19pub enum StartOffset {
20    /// Leave the choice to librdkafka (its default resets to the latest offset).
21    #[default]
22    Committed,
23    /// Start from the earliest retained offset.
24    Earliest,
25    /// Start from the latest offset (only messages published after the group formed).
26    Latest,
27}
28
29/// The partition assignment strategy for the consumer group (librdkafka's
30/// `partition.assignment.strategy`).
31///
32/// These are librdkafka's built-in strategies; the client offers no API for a custom group
33/// assignor (the rebalance callback only observes assignments). Cooperative and eager
34/// strategies cannot mix within one group - librdkafka rejects the join, and the error
35/// surfaces on the subscriber stream.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37#[non_exhaustive]
38pub enum Assignment {
39    /// Co-partitioned ranges per topic (the Kafka default family).
40    Range,
41    /// Round-robin across all subscribed topics.
42    RoundRobin,
43    /// Incremental cooperative rebalancing: unaffected partitions keep flowing during a
44    /// rebalance instead of stopping the world.
45    CooperativeSticky,
46}
47
48impl Assignment {
49    pub(crate) fn as_config_value(self) -> &'static str {
50        match self {
51            Self::Range => "range",
52            Self::RoundRobin => "roundrobin",
53            Self::CooperativeSticky => "cooperative-sticky",
54        }
55    }
56}
57
58/// What drives keyed worker lanes (`workers(n, by_key)`) for this subscription.
59///
60/// The runtime lanes deliveries by [`IncomingMessage::partition_key`]
61/// (deliveries sharing a lane key process in order on one lane); this choice picks what that
62/// key is for Kafka.
63///
64/// [`IncomingMessage::partition_key`]: ruststream::IncomingMessage::partition_key
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66#[non_exhaustive]
67pub enum LaneKey {
68    /// The source partition (the default): lanes mirror Kafka's own ordering unit, so
69    /// everything a partition delivers (keyless included) processes in order on one lane.
70    #[default]
71    Partition,
72    /// The native record key: per-key ordering, finer than a partition, so messages of one
73    /// partition may process concurrently when their keys differ. Keyless deliveries carry no
74    /// lane key and rotate across lanes, losing their partition order.
75    RecordKey,
76}
77
78/// How processed deliveries are committed back to the consumer group.
79#[derive(Debug, Clone, PartialEq, Eq, Default)]
80#[non_exhaustive]
81pub enum Commit {
82    /// librdkafka auto-commit, the librdkafka default: positions are stored as messages are
83    /// handed to the application and committed every `auto.commit.interval.ms`. `ack` and
84    /// `nack` are advisory no-ops; a crash can lose the tail of processed-but-uncommitted work
85    /// or skip unprocessed deliveries that were already stored.
86    #[default]
87    Auto,
88    /// Per-message acknowledgement: `enable.auto.offset.store` is switched off and an `ack`
89    /// advances the stored position to just below the lowest still-unsettled delivery (or to
90    /// the highest delivered offset once none are outstanding). At-least-once stays precise
91    /// with concurrent handler lanes, and offset gaps the consumer never receives (transaction
92    /// markers, compacted-away records) cannot block the position. Auto-commit still flushes
93    /// the stored position in the background and once more when the consumer closes.
94    Tracked,
95    /// Exactly-once: the consumer never commits its own offsets - the
96    /// [`EosPipeline`](crate::EosPipeline) whose transactional id matches this name commits
97    /// them through the producer transaction (`send_offsets_to_transaction`), so source
98    /// positions move atomically with the records the handlers publish. `enable.auto.commit`
99    /// and `enable.auto.offset.store` are switched off; `ack` advances the shared watermark
100    /// exactly like [`Tracked`](Self::Tracked), and the pipeline picks the watermark up at its
101    /// next window commit.
102    Transactional(String),
103}
104
105/// A subscription to one Kafka topic through one consumer group.
106///
107/// Everything except the topic name is optional; unset options fall back to the librdkafka
108/// defaults (this crate does not impose its own). The group can also come from
109/// [`KafkaBroker::default_group`]; a subscription that ends up with no group at all is a
110/// startup error, because Kafka cannot subscribe without one.
111///
112/// # Examples
113///
114/// ```
115/// use ruststream_rdkafka::{Assignment, Commit, KafkaTopic, StartOffset};
116///
117/// let topic = KafkaTopic::new("orders")
118///     .group("orders-svc")
119///     .start(StartOffset::Earliest)
120///     .commit(Commit::Tracked)
121///     .assignment(Assignment::CooperativeSticky)
122///     .config("fetch.min.bytes", "1024");
123/// assert_eq!(topic.topic(), "orders");
124/// ```
125#[derive(Debug, Clone)]
126pub struct KafkaTopic {
127    /// The subscribed names; librdkafka treats entries starting with `^` as regex patterns.
128    topics: Vec<String>,
129    /// The handler-metadata name: the subscribed names joined with `,`.
130    name: String,
131    /// Set by [`pattern`](Self::pattern), which promises a `^`-anchored regex.
132    requires_pattern: bool,
133    group: Option<String>,
134    start: StartOffset,
135    commit: Commit,
136    assignment: Option<Assignment>,
137    lane_key: LaneKey,
138    partitions: Vec<i32>,
139    retry: Option<Retry>,
140    max_deliveries: Option<u32>,
141    dead_letter: Option<String>,
142    config: Vec<(String, String)>,
143}
144
145impl KafkaTopic {
146    fn with_first(first: String, requires_pattern: bool) -> Self {
147        Self {
148            name: first.clone(),
149            topics: vec![first],
150            requires_pattern,
151            group: None,
152            start: StartOffset::default(),
153            commit: Commit::default(),
154            assignment: None,
155            lane_key: LaneKey::default(),
156            partitions: Vec::new(),
157            retry: None,
158            max_deliveries: None,
159            dead_letter: None,
160            config: Vec::new(),
161        }
162    }
163
164    /// Describes a subscription to `topic` with librdkafka defaults for everything else.
165    #[must_use]
166    pub fn new(topic: impl Into<String>) -> Self {
167        Self::with_first(topic.into(), false)
168    }
169
170    /// Describes a subscription to every existing topic matching `pattern`.
171    ///
172    /// The pattern is a librdkafka topic regex and must start with `^` (that anchor is how
173    /// librdkafka distinguishes a pattern from a literal name); subscribing fails with a clear
174    /// error otherwise. Topics created after the group formed are picked up on the next
175    /// metadata refresh.
176    ///
177    /// # Examples
178    ///
179    /// ```
180    /// use ruststream_rdkafka::KafkaTopic;
181    ///
182    /// let orders = KafkaTopic::pattern("^orders\\..*").group("orders-svc");
183    /// assert_eq!(orders.topic(), "^orders\\..*");
184    /// ```
185    #[must_use]
186    pub fn pattern(pattern: impl Into<String>) -> Self {
187        Self::with_first(pattern.into(), true)
188    }
189
190    /// Adds another topic to the same subscription: one consumer, one group, several topics.
191    ///
192    /// All matched topics share the handler (and therefore its payload type). Entries starting
193    /// with `^` are librdkafka regex patterns, exactly as in [`pattern`](Self::pattern).
194    ///
195    /// # Examples
196    ///
197    /// ```
198    /// use ruststream_rdkafka::KafkaTopic;
199    ///
200    /// let both = KafkaTopic::new("orders").and_topic("cancellations");
201    /// assert_eq!(both.topic(), "orders,cancellations");
202    /// ```
203    #[must_use]
204    pub fn and_topic(mut self, topic: impl Into<String>) -> Self {
205        let topic = topic.into();
206        self.name.push(',');
207        self.name.push_str(&topic);
208        self.topics.push(topic);
209        self
210    }
211
212    /// The consumer group for this subscription, overriding
213    /// [`KafkaBroker::default_group`].
214    #[must_use]
215    pub fn group(mut self, group: impl Into<String>) -> Self {
216        self.group = Some(group.into());
217        self
218    }
219
220    /// Where the group starts when it has no committed offset (see [`StartOffset`]).
221    #[must_use]
222    pub fn start(mut self, start: StartOffset) -> Self {
223        self.start = start;
224        self
225    }
226
227    /// How processed deliveries are committed (see [`Commit`]).
228    #[must_use]
229    pub fn commit(mut self, commit: Commit) -> Self {
230        self.commit = commit;
231        self
232    }
233
234    /// The partition assignment strategy (see [`Assignment`]); unset means the librdkafka
235    /// default (`range,roundrobin`).
236    #[must_use]
237    pub fn assignment(mut self, assignment: Assignment) -> Self {
238        self.assignment = Some(assignment);
239        self
240    }
241
242    /// What drives keyed worker lanes for this subscription (see [`LaneKey`]); the default
243    /// lanes by the source partition, Kafka's native ordering unit.
244    ///
245    /// # Examples
246    ///
247    /// ```
248    /// use ruststream_rdkafka::{KafkaTopic, LaneKey};
249    ///
250    /// // Opt into finer, per-record-key lanes: one tenant never processes concurrently,
251    /// // different tenants in one partition do.
252    /// let topic = KafkaTopic::new("orders")
253    ///     .group("orders-svc")
254    ///     .lane_key(LaneKey::RecordKey);
255    /// # let _ = topic;
256    /// ```
257    #[must_use]
258    pub fn lane_key(mut self, lane_key: LaneKey) -> Self {
259        self.lane_key = lane_key;
260        self
261    }
262
263    /// Switches the subscription to manual partition assignment: the consumer `assign()`s
264    /// exactly these partitions of the topic - no group membership, no rebalancing.
265    ///
266    /// Deliveries start per [`start`](Self::start); with a group also named the consumer
267    /// commits into it without joining it (so `StartOffset::Committed` resumes from the
268    /// group's positions), and without one commits are off and the start offset must be
269    /// explicit. Does not combine with [`and_topic`](Self::and_topic) /
270    /// [`pattern`](Self::pattern) (manual assignment names exact partitions of one topic) or
271    /// with `Commit::Transactional`.
272    ///
273    /// # Examples
274    ///
275    /// ```
276    /// use ruststream_rdkafka::{KafkaTopic, StartOffset};
277    ///
278    /// // An inspection reader pinned to partition 0, no group side effects.
279    /// let topic = KafkaTopic::new("orders")
280    ///     .partitions([0])
281    ///     .start(StartOffset::Earliest);
282    /// # let _ = topic;
283    /// ```
284    #[must_use]
285    pub fn partitions(mut self, partitions: impl IntoIterator<Item = i32>) -> Self {
286        self.partitions = partitions.into_iter().collect();
287        self
288    }
289
290    /// What `nack(true)` does on this subscription (see [`Retry`]); unset keeps Kafka's native
291    /// behavior - the offset stays unsettled and redelivers on the next fetch of the partition.
292    ///
293    /// # Examples
294    ///
295    /// ```
296    /// use ruststream_rdkafka::{KafkaTopic, Retry};
297    ///
298    /// let topic = KafkaTopic::new("orders")
299    ///     .group("orders-svc")
300    ///     .retry(Retry::Topic("orders.retry".into()))
301    ///     .max_deliveries(5)
302    ///     .dead_letter("orders.dlq");
303    /// # let _ = topic;
304    /// ```
305    #[must_use]
306    pub fn retry(mut self, retry: Retry) -> Self {
307        self.retry = Some(retry);
308        self
309    }
310
311    /// The poison cap: how many times a message may be delivered before `nack(true)` takes the
312    /// drop path instead of retrying (the original delivery counts as one). Enforced by the
313    /// [`retry`](Self::retry) policy - through [`RETRY_COUNT_HEADER`](crate::RETRY_COUNT_HEADER)
314    /// for [`Retry::Topic`], and through an in-session counter for [`Retry::SeekBack`].
315    #[must_use]
316    pub fn max_deliveries(mut self, max_deliveries: u32) -> Self {
317        self.max_deliveries = Some(max_deliveries);
318        self
319    }
320
321    /// The dead-letter topic for the drop path: `nack(false)` and an exhausted retry republish
322    /// the message there (stamped with the `kafka-dlq-source-*` headers), then settle. Without
323    /// it the drop path just settles.
324    #[must_use]
325    pub fn dead_letter(mut self, topic: impl Into<String>) -> Self {
326        self.dead_letter = Some(topic.into());
327        self
328    }
329
330    /// Raw librdkafka consumer property passthrough for anything not surfaced as a typed
331    /// option, applied last (it wins over the typed options and the broker-wide config).
332    #[must_use]
333    pub fn config(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
334        self.config.push((key.into(), value.into()));
335        self
336    }
337
338    /// The subscribed name(s), joined with `,` when there are several (also the handler
339    /// metadata name); a pattern subscription returns the pattern.
340    #[must_use]
341    pub fn topic(&self) -> &str {
342        &self.name
343    }
344
345    pub(crate) fn subscribed_topics(&self) -> &[String] {
346        &self.topics
347    }
348
349    pub(crate) fn validate(&self) -> Result<(), KafkaError> {
350        if self.requires_pattern && !self.topics[0].starts_with('^') {
351            return Err(KafkaError::InvalidOptions(format!(
352                "pattern {:?} must start with '^' (librdkafka's anchor for topic regexes); \
353                 without it the name would be subscribed literally",
354                self.topics[0],
355            )));
356        }
357        if !self.partitions.is_empty() && (self.topics.len() > 1 || self.requires_pattern) {
358            return Err(KafkaError::InvalidOptions(
359                "manual partition assignment names exact partitions of one topic; it does \
360                 not combine with `and_topic` or `pattern`"
361                    .to_owned(),
362            ));
363        }
364        Ok(())
365    }
366
367    pub(crate) fn group_or<'a>(&'a self, fallback: Option<&'a str>) -> Option<&'a str> {
368        self.group.as_deref().or(fallback)
369    }
370
371    pub(crate) fn start_offset(&self) -> StartOffset {
372        self.start
373    }
374
375    pub(crate) fn commit_mode(&self) -> &Commit {
376        &self.commit
377    }
378
379    pub(crate) fn assignment_strategy(&self) -> Option<Assignment> {
380        self.assignment
381    }
382
383    pub(crate) fn lane_key_choice(&self) -> LaneKey {
384        self.lane_key
385    }
386
387    pub(crate) fn retry_policy(&self) -> Option<&Retry> {
388        self.retry.as_ref()
389    }
390
391    pub(crate) fn max_deliveries_cap(&self) -> Option<u32> {
392        self.max_deliveries
393    }
394
395    pub(crate) fn dead_letter_topic(&self) -> Option<&str> {
396        self.dead_letter.as_deref()
397    }
398
399    pub(crate) fn assigned_partitions(&self) -> &[i32] {
400        &self.partitions
401    }
402
403    pub(crate) fn config_entries(&self) -> &[(String, String)] {
404        &self.config
405    }
406}
407
408impl SubscriptionSource<KafkaBroker> for KafkaTopic {
409    type Subscriber = KafkaSubscriber;
410
411    fn name(&self) -> &str {
412        &self.name
413    }
414
415    async fn subscribe(self, broker: &KafkaBroker) -> Result<Self::Subscriber, KafkaError> {
416        broker.subscribe(self).await
417    }
418}
419
420#[cfg(feature = "testing")]
421impl SubscriptionSource<crate::testing::KafkaTestBroker> for KafkaTopic {
422    type Subscriber = crate::testing::KafkaTestSubscriber;
423
424    fn name(&self) -> &str {
425        &self.name
426    }
427
428    async fn subscribe(
429        self,
430        broker: &crate::testing::KafkaTestBroker,
431    ) -> Result<Self::Subscriber, KafkaError> {
432        if !self.partitions.is_empty() {
433            return Err(KafkaError::InvalidOptions(
434                "the in-process test broker does not simulate partitions; manual partition \
435                 assignment needs a real cluster"
436                    .to_owned(),
437            ));
438        }
439        broker.subscribe_topics(&self.topics).await
440    }
441}