ruststream_rdkafka/subscription/mod.rs
1//! The subscription descriptors and the group settings they share.
2//!
3//! Kafka reads a topic in three shapes, and each is a type of its own because they differ in
4//! what a deferred retry copy can be published to: [`KafkaTopic`] reads one topic and a publish
5//! to that topic reaches it again, while [`KafkaTopics`] (several topics, or a regex) and
6//! [`KafkaPartitions`] (named partitions of one topic) read a set no single publish addresses.
7
8mod partitions;
9mod topic;
10mod topics;
11
12pub use partitions::KafkaPartitions;
13pub use topic::KafkaTopic;
14pub use topics::KafkaTopics;
15
16use rdkafka::error::KafkaError as RdKafkaError;
17
18use crate::error::KafkaError;
19use crate::properties::Properties;
20
21/// Where a consumer group starts reading when it has no valid committed offset.
22///
23/// Kafka resumes from the group's committed position when a valid one exists; this choice (it
24/// maps to librdkafka's `auto.offset.reset`) applies when there is none - the group has never
25/// committed the partition, or the committed offset was deleted by retention / is out of
26/// range. The second case is why it matters for long-idle groups: with the librdkafka default
27/// (latest) an expired group skips to the end instead of reprocessing.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29#[non_exhaustive]
30pub enum StartOffset {
31 /// Leave the choice to librdkafka (its default resets to the latest offset).
32 #[default]
33 Committed,
34 /// Start from the earliest retained offset.
35 Earliest,
36 /// Start from the latest offset: only messages published after the subscription opened.
37 Latest,
38}
39
40/// The partition assignment strategy for the consumer group (librdkafka's
41/// `partition.assignment.strategy`).
42///
43/// These are librdkafka's built-in strategies; the client offers no API for a custom group
44/// assignor (the rebalance callback only observes assignments). Cooperative and eager
45/// strategies cannot mix within one group - librdkafka rejects the join, and the error
46/// surfaces on the subscriber stream.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum Assignment {
50 /// Co-partitioned ranges per topic (the Kafka default family).
51 Range,
52 /// Round-robin across all subscribed topics.
53 RoundRobin,
54 /// Incremental cooperative rebalancing: unaffected partitions keep flowing during a
55 /// rebalance instead of stopping the world.
56 CooperativeSticky,
57}
58
59impl Assignment {
60 pub(crate) fn as_config_value(self) -> &'static str {
61 match self {
62 Self::Range => "range",
63 Self::RoundRobin => "roundrobin",
64 Self::CooperativeSticky => "cooperative-sticky",
65 }
66 }
67}
68
69/// What drives keyed worker lanes (`workers(n, by_key)`) for this subscription.
70///
71/// The runtime lanes deliveries by [`IncomingMessage::partition_key`]
72/// (deliveries sharing a lane key process in order on one lane); this choice picks what that
73/// key is for Kafka.
74///
75/// [`IncomingMessage::partition_key`]: ruststream::IncomingMessage::partition_key
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
77#[non_exhaustive]
78pub enum LaneKey {
79 /// The source partition (the default): lanes mirror Kafka's own ordering unit, so
80 /// everything a partition delivers (keyless included) processes in order on one lane.
81 #[default]
82 Partition,
83 /// The native record key: per-key ordering, finer than a partition, so messages of one
84 /// partition may process concurrently when their keys differ. Keyless deliveries carry no
85 /// lane key and rotate across lanes, losing their partition order.
86 RecordKey,
87}
88
89/// How processed deliveries are committed back to the consumer group.
90///
91/// # Examples
92///
93/// A tracked subscription with one lane per record key, a cap on deliveries and a dead-letter
94/// topic:
95///
96/// ```
97/// # #[cfg(feature = "json")]
98/// # mod demo {
99/// use ruststream_rdkafka::prelude::*;
100/// use serde::Deserialize;
101///
102/// #[derive(Deserialize)]
103/// struct Payment {
104/// id: u64,
105/// }
106///
107/// /// One lane per record key, so payments of one account stay ordered while others run
108/// /// concurrently.
109/// #[subscriber(
110/// KafkaTopic::new("payments")
111/// .group("payments-svc")
112/// .commit(Commit::Tracked)
113/// .start(StartOffset::Earliest)
114/// .lane_key(LaneKey::RecordKey),
115/// workers(8, by_key)
116/// )]
117/// async fn charge(payment: &Payment) -> HandlerOutcome {
118/// if payment.id == 0 {
119/// return HandlerOutcome::drop();
120/// }
121/// HandlerOutcome::ack()
122/// }
123///
124/// fn app() -> RustStream {
125/// RustStream::new(AppInfo::new("payments", "0.1.0")).with_broker(
126/// KafkaBroker::new(["localhost:9092"]),
127/// |b| {
128/// // Five deliveries of one payment, the first included; the sixth goes to the
129/// // dead-letter topic instead, payload and headers as they arrived.
130/// b.include(charge)
131/// .max_attempts(nonzero!(5u32))
132/// .dead_letter("payments.dlq");
133/// },
134/// )
135/// }
136/// # }
137/// # fn main() {}
138/// ```
139#[derive(Debug, Clone, PartialEq, Eq, Default)]
140#[non_exhaustive]
141pub enum Commit {
142 /// librdkafka auto-commit, the librdkafka default: positions are stored as messages are
143 /// handed to the application and committed every `auto.commit.interval.ms`. `ack` and
144 /// `nack(false)` are advisory no-ops and `nack(true)` reports
145 /// [`AckError::Unsupported`](ruststream::AckError::Unsupported); a crash can lose the tail of
146 /// processed-but-uncommitted work or skip unprocessed deliveries that were already stored.
147 #[default]
148 Auto,
149 /// Per-message acknowledgement: `enable.auto.offset.store` is switched off and an `ack`
150 /// advances the stored position to just below the lowest still-unsettled delivery (or to
151 /// the highest delivered offset once none are outstanding). At-least-once stays precise
152 /// with concurrent handler lanes, and offset gaps the consumer never receives (transaction
153 /// markers, compacted-away records) cannot block the position. Auto-commit still flushes
154 /// the stored position in the background and once more when the consumer closes.
155 Tracked,
156 /// Exactly-once: the consumer never commits its own offsets - the
157 /// [`EosPipeline`](crate::EosPipeline) whose transactional id matches this name commits
158 /// them through the producer transaction (`send_offsets_to_transaction`), so source
159 /// positions move atomically with the records the handlers publish. `enable.auto.commit`
160 /// and `enable.auto.offset.store` are switched off; `ack` advances the shared watermark
161 /// exactly like [`Tracked`](Self::Tracked), and the pipeline picks the watermark up at its
162 /// next window commit.
163 Transactional(String),
164}
165
166/// The consumer-group settings every subscription form carries.
167///
168/// One struct rather than six repeated fields: the three descriptors differ in what they read,
169/// not in how the group behaves around it.
170#[derive(Debug, Clone, Default)]
171pub(crate) struct GroupSettings {
172 pub(crate) group: Option<String>,
173 pub(crate) start: StartOffset,
174 pub(crate) commit: Commit,
175 pub(crate) assignment: Option<Assignment>,
176 pub(crate) lane_key: LaneKey,
177 pub(crate) config: Properties,
178}
179
180/// How the consumer takes its partitions: through the group protocol, or by naming them.
181#[derive(Debug)]
182pub(crate) enum Reader {
183 /// librdkafka `subscribe()` to these names; an entry starting with `^` is a topic regex.
184 Subscribed(Vec<String>),
185 /// librdkafka `assign()` of exactly these partitions of one topic: no group membership,
186 /// no rebalancing.
187 Assigned { topic: String, partitions: Vec<i32> },
188}
189
190/// Whether Kafka accepts `name` as a topic: 1 to 249 characters from `[a-zA-Z0-9._-]`, and not
191/// `.` or `..`.
192pub(crate) fn legal_topic(name: &str) -> bool {
193 !name.is_empty()
194 && name.len() <= 249
195 && name != "."
196 && name != ".."
197 && name
198 .bytes()
199 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
200}
201
202/// Refuses a subscription to a topic name the cluster would refuse. librdkafka accepts such a
203/// subscription and reports the name only later, on the consumer's queue, so the subscription
204/// would open and deliver nothing; refusing it here fails the service at startup instead.
205/// Patterns are regexes and are left to librdkafka.
206pub(crate) fn refuse_illegal_topics(reader: &Reader) -> Result<(), KafkaError> {
207 let illegal = match reader {
208 Reader::Subscribed(names) => names
209 .iter()
210 .find(|name| !name.starts_with('^') && !legal_topic(name)),
211 Reader::Assigned { topic, .. } => Some(topic).filter(|topic| !legal_topic(topic)),
212 };
213 illegal.map_or(Ok(()), |topic| {
214 Err(KafkaError::subscribe(RdKafkaError::Subscription(format!(
215 "{topic:?} is not a legal Kafka topic name"
216 ))))
217 })
218}
219
220/// A descriptor resolved into what opening a consumer needs, with nothing left to validate.
221#[derive(Debug)]
222pub(crate) struct SubscriptionPlan {
223 /// The handler-metadata name, and the channel the generated document reports.
224 pub(crate) name: String,
225 pub(crate) reader: Reader,
226 pub(crate) settings: GroupSettings,
227}
228
229/// Rejects a name that librdkafka would read as a topic regex.
230///
231/// The check is at startup because the name is a runtime string: a mount site may take it from
232/// configuration, so no type can carry the anchor.
233pub(crate) fn reject_pattern(name: &str, form: &str) -> Result<(), KafkaError> {
234 if name.starts_with('^') {
235 return Err(KafkaError::InvalidOptions(format!(
236 "{form} reads one topic, and {name:?} is a librdkafka topic regex (the leading '^' \
237 is its anchor); subscribe to a pattern with `KafkaTopics::pattern`"
238 )));
239 }
240 Ok(())
241}
242
243/// The librdkafka properties a commit mode decides for itself.
244///
245/// [`Commit::Tracked`] and [`Commit::Transactional`] settle by a position this crate computes,
246/// and both of these say who owns that position. A passthrough setting one does not tune the
247/// mode, it undoes it: the acks keep running and decide nothing, which is a silent loss of the
248/// guarantee the mode was chosen for.
249const MODE_OWNED_PROPERTIES: [&str; 2] = ["enable.auto.offset.store", "enable.auto.commit"];
250
251/// Rejects a passthrough that would take a commit mode's own property away from it.
252///
253/// The passthrough is applied last and wins over every typed option, which is what makes it an
254/// escape hatch - and what would make this particular clash invisible. [`Commit::Auto`] leaves
255/// the position to librdkafka and owns neither property, so there the passthrough keeps winning.
256pub(crate) fn reject_commit_mode_clash(
257 name: &str,
258 commit: &Commit,
259 config: &[(String, String)],
260) -> Result<(), KafkaError> {
261 let mode = match commit {
262 Commit::Auto => return Ok(()),
263 Commit::Tracked => "`Commit::Tracked`",
264 Commit::Transactional(_) => "`Commit::Transactional`",
265 };
266 for (key, value) in config {
267 let Some(owned) = MODE_OWNED_PROPERTIES
268 .iter()
269 .find(|owned| key.eq_ignore_ascii_case(owned))
270 else {
271 continue;
272 };
273 return Err(KafkaError::InvalidOptions(format!(
274 "subscription to {name:?} sets {owned}={value:?} through its config passthrough, \
275 and {mode} owns that property: the passthrough would leave the committed position \
276 to librdkafka while every ack decided nothing. Drop it, or take `Commit::Auto`, \
277 which owns neither"
278 )));
279 }
280 Ok(())
281}
282
283/// Rejects an empty topic name, whichever form named it.
284pub(crate) fn reject_empty(name: &str) -> Result<(), KafkaError> {
285 if name.is_empty() {
286 return Err(KafkaError::InvalidOptions(
287 "topic name must not be empty; name the topic the handler consumes".to_owned(),
288 ));
289 }
290 Ok(())
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn topic_names_follow_kafkas_rule() {
299 assert!(legal_topic("orders.eu-1_v2"));
300 for bad in ["", ".", "..", "orders eu", "orders/eu", &"x".repeat(250)] {
301 assert!(!legal_topic(bad), "{bad:?} must be refused");
302 }
303 }
304}