Skip to main content

ruststream_rdkafka/
subscriber.rs

1//! The subscriber: a stream of Kafka deliveries from one topic subscription.
2
3use std::fmt;
4use std::sync::Arc;
5
6use bytes::Bytes;
7use futures::Stream;
8use futures::future::FutureExt as _;
9use rdkafka::Message as _;
10use rdkafka::consumer::StreamConsumer;
11use rdkafka::error::RDKafkaErrorCode;
12#[cfg(feature = "schema-registry")]
13use ruststream::IncomingMessage;
14use ruststream::{BatchSubscriber, Subscriber};
15use tracing::{debug, warn};
16
17use crate::convert;
18use crate::eos::EOS_SOURCE_HEADER;
19use crate::error::KafkaError;
20use crate::message::{KafkaMessage, PARTITION_KEY_HEADER, Settlement};
21use crate::retry::RetryContext;
22use crate::topic::{Commit, LaneKey};
23use crate::tracker::{CommitTracker, TrackingContext};
24
25/// Whether librdkafka is already retrying this error by itself, making a stream error item
26/// noise rather than signal. The set is deliberately small and explicit; when in doubt, the
27/// error is forwarded.
28fn is_transient(err: &rdkafka::error::KafkaError) -> bool {
29    // A subscribed topic that does not exist (yet): pending creation is routine when the
30    // broker auto-creates topics, and librdkafka keeps refreshing metadata until it appears.
31    err.rdkafka_error_code() == Some(RDKafkaErrorCode::UnknownTopicOrPartition)
32}
33
34/// A consumer-group member on one topic, yielding [`KafkaMessage`] deliveries.
35///
36/// Created by subscribing a [`KafkaTopic`](crate::KafkaTopic) descriptor (or a bare topic name)
37/// through [`KafkaBroker`](crate::KafkaBroker). The subscriber owns a dedicated librdkafka
38/// consumer; dropping it closes the consumer, which leaves the group and (under auto-commit)
39/// commits the final stored position. Under `Commit::Tracked` each in-flight delivery keeps
40/// the consumer alive, so the close happens once the last outstanding message settles or
41/// drops - do not rely on subscriber drop as an immediate group-departure barrier.
42///
43/// Back-pressure: polling the stream is what drives the consumer, so consuming slower simply
44/// fetches slower; librdkafka's own fetch queue bounds (`queued.max.messages.kbytes` and
45/// friends, settable through [`KafkaTopic::config`](crate::KafkaTopic::config)) cap local
46/// buffering.
47pub struct KafkaSubscriber {
48    consumer: Arc<StreamConsumer<TrackingContext>>,
49    topic: String,
50    commit: Commit,
51    tracker: Arc<CommitTracker>,
52    lane_key: LaneKey,
53    retry: Option<Arc<RetryContext>>,
54    #[cfg(feature = "schema-registry")]
55    schema_registry: Option<crate::schema_registry::SchemaRegistry>,
56    /// Whether the subscriber is inside an episode of transient consume errors; the first
57    /// error of an episode warns, repeats are debug, recovery closes the episode.
58    in_transient_episode: bool,
59}
60
61impl KafkaSubscriber {
62    pub(crate) fn new(
63        consumer: Arc<StreamConsumer<TrackingContext>>,
64        topic: String,
65        commit: Commit,
66        tracker: Arc<CommitTracker>,
67        lane_key: LaneKey,
68        retry: Option<Arc<RetryContext>>,
69    ) -> Self {
70        Self {
71            consumer,
72            topic,
73            commit,
74            tracker,
75            lane_key,
76            retry,
77            #[cfg(feature = "schema-registry")]
78            schema_registry: None,
79            in_transient_episode: false,
80        }
81    }
82
83    #[cfg(feature = "schema-registry")]
84    pub(crate) fn with_schema_registry(
85        mut self,
86        registry: Option<crate::schema_registry::SchemaRegistry>,
87    ) -> Self {
88        self.schema_registry = registry;
89        self
90    }
91
92    /// Logs a transient consume error: one warning when the episode starts (the signal a
93    /// human acts on in monitoring), debug for the repeats.
94    fn note_transient(&mut self, err: &rdkafka::error::KafkaError) {
95        if self.in_transient_episode {
96            debug!(
97                target: "ruststream_rdkafka",
98                topic = %self.topic,
99                error = %err,
100                "transient consume error (repeat)",
101            );
102        } else {
103            self.in_transient_episode = true;
104            warn!(
105                target: "ruststream_rdkafka",
106                topic = %self.topic,
107                error = %err,
108                "transient consume error; librdkafka keeps retrying",
109            );
110        }
111    }
112
113    /// Closes a transient-error episode once deliveries flow again.
114    fn note_recovered(&mut self) {
115        if self.in_transient_episode {
116            self.in_transient_episode = false;
117            debug!(
118                target: "ruststream_rdkafka",
119                topic = %self.topic,
120                "recovered from transient consume errors",
121            );
122        }
123    }
124
125    /// The topic this subscriber consumes.
126    #[must_use]
127    pub fn topic(&self) -> &str {
128        &self.topic
129    }
130
131    /// The registry middleware: transcodes a framed payload to plain JSON on the async
132    /// consume path, so handlers and the default codec see JSON regardless of the wire
133    /// format. A no-op without an attached registry or for non-framed payloads.
134    #[cfg(feature = "schema-registry")]
135    async fn transcode(&self, item: &mut KafkaMessage) {
136        if let Some(registry) = &self.schema_registry
137            && let Some(json) = registry
138                .incoming_to_json(IncomingMessage::payload(item))
139                .await
140        {
141            item.replace_payload(Bytes::from(json));
142        }
143    }
144
145    fn map_delivery(&self, delivery: &rdkafka::message::BorrowedMessage<'_>) -> KafkaMessage {
146        let mut headers = convert::headers_from_message(delivery);
147        if matches!(self.commit, Commit::Transactional(_)) {
148            // The source coordinates ride the delivery's headers so the reply path can pair a
149            // publishing handler's reply with its consumed offset (see EosPipeline::replies);
150            // stripped from every outgoing publish, so they never hit the wire.
151            headers.insert(
152                EOS_SOURCE_HEADER,
153                crate::eos::encode_source(
154                    delivery.topic(),
155                    delivery.partition(),
156                    delivery.offset(),
157                ),
158            );
159        }
160        let payload = delivery
161            .payload()
162            .map_or_else(Bytes::new, Bytes::copy_from_slice);
163        let settlement = match &self.commit {
164            Commit::Auto => Settlement::Advisory,
165            Commit::Tracked => {
166                self.tracker
167                    .delivered(delivery.topic(), delivery.partition(), delivery.offset());
168                Settlement::Tracked {
169                    consumer: Arc::clone(&self.consumer),
170                    tracker: Arc::clone(&self.tracker),
171                }
172            }
173            Commit::Transactional(_) => {
174                self.tracker
175                    .delivered(delivery.topic(), delivery.partition(), delivery.offset());
176                Settlement::Transactional {
177                    tracker: Arc::clone(&self.tracker),
178                }
179            }
180        };
181        let lane = match self.lane_key {
182            LaneKey::RecordKey => headers
183                .get(PARTITION_KEY_HEADER)
184                .map(Bytes::copy_from_slice),
185            LaneKey::Partition => Some(Bytes::from(delivery.partition().to_string())),
186        };
187        KafkaMessage::new(
188            payload,
189            headers,
190            delivery.topic().to_owned(),
191            delivery.partition(),
192            delivery.offset(),
193            delivery.timestamp().to_millis(),
194            settlement,
195            lane,
196            self.retry.clone(),
197        )
198    }
199}
200
201impl fmt::Debug for KafkaSubscriber {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        f.debug_struct("KafkaSubscriber")
204            .field("topic", &self.topic)
205            .field("commit", &self.commit)
206            .finish_non_exhaustive()
207    }
208}
209
210impl Subscriber for KafkaSubscriber {
211    type Message = KafkaMessage;
212    type Error = KafkaError;
213
214    /// Streams deliveries as they arrive; the stream yields an error item when the consumer
215    /// fails (it does not end on its own - drop the subscriber to leave the group).
216    ///
217    /// Errors librdkafka is already retrying by itself are not forwarded as stream items:
218    /// today that is exactly `UnknownTopicOrPartition` (a subscribed topic pending creation).
219    /// Such an episode surfaces as one warning when it starts - the monitoring signal to act
220    /// on - with debug lines for the repeats and for the recovery, so a topic that appears
221    /// late (broker auto-creation, provisioning races) recovers without flooding the dispatch
222    /// error log, while a topic that never appears leaves the warning standing. Everything
223    /// else is forwarded.
224    ///
225    /// # Cancel safety
226    ///
227    /// Polling is cancel safe (the underlying `recv` is documented cancellation safe, so no
228    /// delivery is lost by dropping the stream between polls), and the stream can be re-created
229    /// by calling `stream` again: deliveries buffer in the consumer, not in the returned stream.
230    fn stream(&mut self) -> impl Stream<Item = Result<Self::Message, Self::Error>> + Send + '_ {
231        futures::stream::unfold(self, |sub| async move {
232            loop {
233                match sub.consumer.recv().await {
234                    Ok(delivery) => {
235                        #[allow(unused_mut)] // mutated by the registry transcode only
236                        let mut item = sub.map_delivery(&delivery);
237                        drop(delivery);
238                        #[cfg(feature = "schema-registry")]
239                        sub.transcode(&mut item).await;
240                        sub.note_recovered();
241                        return Some((Ok(item), sub));
242                    }
243                    Err(err) if is_transient(&err) => sub.note_transient(&err),
244                    Err(err) => return Some((Err(KafkaError::consume(err)), sub)),
245                }
246            }
247        })
248    }
249}
250
251impl BatchSubscriber for KafkaSubscriber {
252    type Batch = Vec<KafkaMessage>;
253
254    /// Streams non-empty pages natively: each waits for one delivery, then drains everything
255    /// librdkafka has already fetched. There is no crate-imposed window - the page is bounded
256    /// by librdkafka's own fetch-queue limits (`queued.max.messages.kbytes` and friends,
257    /// settable through [`KafkaTopic::config`](crate::KafkaTopic::config)); wrap the source in
258    /// the core [`Buffered`](ruststream::Buffered) adapter for an explicit size/deadline
259    /// window. A consumer error inside an open page yields the page first; the error (if it
260    /// persists) surfaces on the next poll.
261    ///
262    /// # Cancel safety
263    ///
264    /// Same guarantees as [`Subscriber::stream`]: cancel safe between polls, no delivery is
265    /// lost by dropping the stream.
266    fn batches(
267        &mut self,
268    ) -> impl Stream<Item = Result<Self::Batch, <Self as Subscriber>::Error>> + Send + '_ {
269        futures::stream::unfold(self, |sub| async move {
270            // Wait for the page's first delivery.
271            let first = loop {
272                match sub.consumer.recv().await {
273                    Ok(delivery) => break sub.map_delivery(&delivery),
274                    Err(err) if is_transient(&err) => sub.note_transient(&err),
275                    Err(err) => return Some((Err(KafkaError::consume(err)), sub)),
276                }
277            };
278            sub.note_recovered();
279
280            let mut batch = vec![first];
281            // Drain what is already fetched; recv is cancel safe, so dropping the probe
282            // future loses nothing.
283            while let Some(result) = sub.consumer.recv().now_or_never() {
284                match result {
285                    Ok(delivery) => {
286                        let item = sub.map_delivery(&delivery);
287                        batch.push(item);
288                    }
289                    Err(err) if is_transient(&err) => sub.note_transient(&err),
290                    // Yield what was collected; a persistent error re-surfaces on the next
291                    // page's first recv.
292                    Err(_) => break,
293                }
294            }
295            #[cfg(feature = "schema-registry")]
296            for item in &mut batch {
297                sub.transcode(item).await;
298            }
299            Some((Ok(batch), sub))
300        })
301    }
302}