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}