Skip to main content

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}