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}