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::subscriber::KafkaSubscriber;
8
9/// Where a consumer group starts reading when it has no valid committed offset.
10///
11/// Kafka resumes from the group's committed position when a valid one exists; this choice (it
12/// maps to librdkafka's `auto.offset.reset`) applies when there is none - the group has never
13/// committed the partition, or the committed offset was deleted by retention / is out of
14/// range. The second case is why it matters for long-idle groups: with the librdkafka default
15/// (latest) an expired group skips to the end instead of reprocessing.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17#[non_exhaustive]
18pub enum StartOffset {
19    /// Leave the choice to librdkafka (its default resets to the latest offset).
20    #[default]
21    Committed,
22    /// Start from the earliest retained offset.
23    Earliest,
24    /// Start from the latest offset (only messages published after the group formed).
25    Latest,
26}
27
28/// How processed deliveries are committed back to the consumer group.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30#[non_exhaustive]
31pub enum Commit {
32    /// librdkafka auto-commit, the librdkafka default: positions are stored as messages are
33    /// handed to the application and committed every `auto.commit.interval.ms`. `ack` and
34    /// `nack` are advisory no-ops; a crash can lose the tail of processed-but-uncommitted work
35    /// or skip unprocessed deliveries that were already stored.
36    #[default]
37    Auto,
38    /// Per-message acknowledgement: `enable.auto.offset.store` is switched off and an `ack`
39    /// advances the stored position to just below the lowest still-unsettled delivery (or to
40    /// the highest delivered offset once none are outstanding). At-least-once stays precise
41    /// with concurrent handler lanes, and offset gaps the consumer never receives (transaction
42    /// markers, compacted-away records) cannot block the position. Auto-commit still flushes
43    /// the stored position in the background and once more when the consumer closes.
44    Tracked,
45}
46
47/// A subscription to one Kafka topic through one consumer group.
48///
49/// Everything except the topic name is optional; unset options fall back to the librdkafka
50/// defaults (this crate does not impose its own). The group can also come from
51/// [`KafkaBroker::default_group`]; a subscription that ends up with no group at all is a
52/// startup error, because Kafka cannot subscribe without one.
53///
54/// # Examples
55///
56/// ```
57/// use ruststream_rdkafka::{Commit, KafkaTopic, StartOffset};
58///
59/// let topic = KafkaTopic::new("orders")
60///     .group("orders-svc")
61///     .start(StartOffset::Earliest)
62///     .commit(Commit::Tracked)
63///     .config("fetch.min.bytes", "1024");
64/// assert_eq!(topic.topic(), "orders");
65/// ```
66#[derive(Debug, Clone)]
67pub struct KafkaTopic {
68    topic: String,
69    group: Option<String>,
70    start: StartOffset,
71    commit: Commit,
72    config: Vec<(String, String)>,
73}
74
75impl KafkaTopic {
76    /// Describes a subscription to `topic` with librdkafka defaults for everything else.
77    #[must_use]
78    pub fn new(topic: impl Into<String>) -> Self {
79        Self {
80            topic: topic.into(),
81            group: None,
82            start: StartOffset::default(),
83            commit: Commit::default(),
84            config: Vec::new(),
85        }
86    }
87
88    /// The consumer group for this subscription, overriding
89    /// [`KafkaBroker::default_group`].
90    #[must_use]
91    pub fn group(mut self, group: impl Into<String>) -> Self {
92        self.group = Some(group.into());
93        self
94    }
95
96    /// Where the group starts when it has no committed offset (see [`StartOffset`]).
97    #[must_use]
98    pub fn start(mut self, start: StartOffset) -> Self {
99        self.start = start;
100        self
101    }
102
103    /// How processed deliveries are committed (see [`Commit`]).
104    #[must_use]
105    pub fn commit(mut self, commit: Commit) -> Self {
106        self.commit = commit;
107        self
108    }
109
110    /// Raw librdkafka consumer property passthrough for anything not surfaced as a typed
111    /// option, applied last (it wins over the typed options and the broker-wide config).
112    #[must_use]
113    pub fn config(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
114        self.config.push((key.into(), value.into()));
115        self
116    }
117
118    /// The topic this subscription consumes.
119    #[must_use]
120    pub fn topic(&self) -> &str {
121        &self.topic
122    }
123
124    pub(crate) fn group_or<'a>(&'a self, fallback: Option<&'a str>) -> Option<&'a str> {
125        self.group.as_deref().or(fallback)
126    }
127
128    pub(crate) fn start_offset(&self) -> StartOffset {
129        self.start
130    }
131
132    pub(crate) fn commit_mode(&self) -> Commit {
133        self.commit
134    }
135
136    pub(crate) fn config_entries(&self) -> &[(String, String)] {
137        &self.config
138    }
139}
140
141impl SubscriptionSource<KafkaBroker> for KafkaTopic {
142    type Subscriber = KafkaSubscriber;
143
144    fn name(&self) -> &str {
145        &self.topic
146    }
147
148    async fn subscribe(self, broker: &KafkaBroker) -> Result<Self::Subscriber, KafkaError> {
149        broker.subscribe(self).await
150    }
151}
152
153#[cfg(feature = "testing")]
154impl SubscriptionSource<crate::testing::KafkaTestBroker> for KafkaTopic {
155    type Subscriber = crate::testing::KafkaTestSubscriber;
156
157    fn name(&self) -> &str {
158        &self.topic
159    }
160
161    async fn subscribe(
162        self,
163        broker: &crate::testing::KafkaTestBroker,
164    ) -> Result<Self::Subscriber, KafkaError> {
165        broker.subscribe(&self.topic).await
166    }
167}