Skip to main content

ruststream_rdkafka/
message.rs

1//! The delivery type yielded by [`KafkaSubscriber`](crate::KafkaSubscriber).
2
3use std::convert::Infallible;
4use std::fmt;
5use std::sync::Arc;
6
7use bytes::Bytes;
8use rdkafka::consumer::{Consumer as _, StreamConsumer};
9use ruststream::{AckError, Headers, IncomingMessage, Partitioned};
10
11use crate::retry::{
12    DLQ_SOURCE_OFFSET_HEADER, DLQ_SOURCE_PARTITION_HEADER, DLQ_SOURCE_TOPIC_HEADER,
13    RETRY_COUNT_HEADER, Retry, RetryContext,
14};
15use crate::tracker::{CommitTracker, TrackingContext};
16
17/// Header carrying a message's partition key, mapped onto Kafka's native record key.
18///
19/// On publish, this header becomes the record key (so Kafka itself routes deliveries that share
20/// a key to the same partition) and is not duplicated as a wire header. On consume, the header
21/// always mirrors the native record key - a same-named wire header from a foreign producer is
22/// not preserved, because the record key is Kafka's source of truth for partitioning. Keyed
23/// worker lanes (`workers(n, by_key)`) read it through
24/// [`IncomingMessage::partition_key`]; [`Partitioned`] mirrors it as the capability surface.
25pub const PARTITION_KEY_HEADER: &str = "kafka-partition-key";
26
27/// Header naming the explicit destination partition for a publish (an ASCII decimal).
28///
29/// When present, the publisher targets that exact partition (winning over the partitioner and
30/// the record key) and strips the header from the wire. An unparsable value fails the publish
31/// with a clear error instead of silently falling back to the partitioner.
32pub const PARTITION_HEADER: &str = "kafka-partition";
33
34/// How this delivery settles when acked.
35pub(crate) enum Settlement {
36    /// `Commit::Auto`: librdkafka owns the committed position; `ack`/`nack` are advisory.
37    Advisory,
38    /// `Commit::Tracked`: an ack advances the shared watermark and stores the new position.
39    Tracked {
40        consumer: Arc<StreamConsumer<TrackingContext>>,
41        tracker: Arc<CommitTracker>,
42    },
43    /// `Commit::Transactional`: an ack advances the shared watermark only - the EOS pipeline
44    /// commits positions through the producer transaction, so nothing is stored here.
45    Transactional { tracker: Arc<CommitTracker> },
46}
47
48/// One Kafka delivery: an owned snapshot of the record plus its settlement handle.
49///
50/// Settlement mapping depends on the [`Commit`](crate::Commit) mode of the subscription:
51///
52/// Under `Commit::Auto` (the default) librdkafka owns the committed position - it is stored
53/// the moment a message is handed to the application - so `ack` and both `nack` forms are
54/// advisory no-ops; in particular `nack(true)` does NOT cause a redelivery.
55///
56/// Under `Commit::Tracked`:
57///
58/// - [`ack`](IncomingMessage::ack) settles the offset and advances the stored position across
59///   everything settled below it.
60/// - [`nack(false)`](IncomingMessage::nack) drops the message: the offset settles so the
61///   position can move past it (Kafka has no per-message dead-letter path; a dead-letter topic
62///   is a planned descriptor option).
63/// - [`nack(true)`](IncomingMessage::nack) leaves the offset unsettled: the committed position
64///   stays below it, so Kafka redelivers from there when the partition is next re-fetched (a
65///   rebalance or a restart). Until then the unsettled offset also blocks the position,
66///   keeping every later ack uncommitted - precise, but worth knowing when a handler nacks in
67///   a loop.
68///
69/// Wire headers map name for name; a null-valued Kafka header arrives with an empty value
70/// (presence preserved).
71#[derive(Debug)]
72pub struct KafkaMessage {
73    payload: Bytes,
74    headers: Headers,
75    topic: String,
76    partition: i32,
77    offset: i64,
78    timestamp_millis: Option<i64>,
79    settlement: Settlement,
80    /// The keyed-lane key: the source partition (the default), or the record key under
81    /// `LaneKey::RecordKey`.
82    lane: Option<Bytes>,
83    retry: Option<Arc<RetryContext>>,
84}
85
86impl fmt::Debug for Settlement {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        match self {
89            Self::Advisory => f.write_str("Advisory"),
90            Self::Tracked { .. } => f.debug_struct("Tracked").finish_non_exhaustive(),
91            Self::Transactional { .. } => f.debug_struct("Transactional").finish_non_exhaustive(),
92        }
93    }
94}
95
96impl KafkaMessage {
97    // An internal constructor mirroring the record's natural fields; grouping them into
98    // intermediate structs would only add indirection for the one caller.
99    #[allow(clippy::too_many_arguments)]
100    pub(crate) fn new(
101        payload: Bytes,
102        headers: Headers,
103        topic: String,
104        partition: i32,
105        offset: i64,
106        timestamp_millis: Option<i64>,
107        settlement: Settlement,
108        lane: Option<Bytes>,
109        retry: Option<Arc<RetryContext>>,
110    ) -> Self {
111        Self {
112            payload,
113            headers,
114            topic,
115            partition,
116            offset,
117            timestamp_millis,
118            settlement,
119            lane,
120            retry,
121        }
122    }
123
124    /// The topic this record was consumed from.
125    #[must_use]
126    pub fn topic(&self) -> &str {
127        &self.topic
128    }
129
130    /// The partition this record was consumed from.
131    #[must_use]
132    pub fn partition(&self) -> i32 {
133        self.partition
134    }
135
136    /// The record's offset within its partition.
137    #[must_use]
138    pub fn offset(&self) -> i64 {
139        self.offset
140    }
141
142    /// The record's timestamp in milliseconds since the epoch, when the broker provided one.
143    #[must_use]
144    pub fn timestamp_millis(&self) -> Option<i64> {
145        self.timestamp_millis
146    }
147
148    /// The record key, surfaced from Kafka's native key (see [`PARTITION_KEY_HEADER`]).
149    #[must_use]
150    pub fn key(&self) -> Option<&[u8]> {
151        self.headers.get(PARTITION_KEY_HEADER)
152    }
153
154    /// Replaces the payload with its registry-transcoded form (the subscriber's async
155    /// middleware), before the delivery is handed on.
156    #[cfg(feature = "schema-registry")]
157    pub(crate) fn replace_payload(&mut self, payload: Bytes) {
158        self.payload = payload;
159    }
160
161    fn settle(self) -> Result<(), AckError> {
162        match self.settlement {
163            Settlement::Advisory => Ok(()),
164            Settlement::Tracked { consumer, tracker } => tracker
165                .settle_with(&self.topic, self.partition, self.offset, |position| {
166                    consumer.store_offset(&self.topic, self.partition, position)
167                })
168                .map_err(|err| AckError::Broker(Box::new(err))),
169            Settlement::Transactional { tracker } => {
170                let infallible: Result<(), Infallible> =
171                    tracker
172                        .settle_with(&self.topic, self.partition, self.offset, |_position| Ok(()));
173                infallible.expect("no-op store cannot fail");
174                Ok(())
175            }
176        }
177    }
178
179    /// The number of retry republishes already behind this delivery, from
180    /// [`RETRY_COUNT_HEADER`]; the original publish carries none.
181    fn retry_attempts(&self) -> u32 {
182        self.headers
183            .get_str(RETRY_COUNT_HEADER)
184            .and_then(|value| value.parse().ok())
185            .unwrap_or(0)
186    }
187
188    /// The retry path for `nack(true)` when a policy is configured.
189    async fn retry_requeue(self, retry: Arc<RetryContext>) -> Result<(), AckError> {
190        match retry.policy() {
191            Some(Retry::Topic(topic)) => {
192                let next_delivery = self.retry_attempts() + 2;
193                if retry.over_cap(next_delivery) {
194                    return self.drop_path(&retry).await;
195                }
196                let mut headers = self.headers.clone();
197                headers.insert(RETRY_COUNT_HEADER, (self.retry_attempts() + 1).to_string());
198                retry
199                    .republish(topic, &self.payload, &headers)
200                    .await
201                    .map_err(|err| AckError::Broker(Box::new(err)))?;
202                self.settle()
203            }
204            Some(Retry::SeekBack) => {
205                let next_delivery =
206                    retry.next_seek_delivery(&self.topic, self.partition, self.offset);
207                if retry.over_cap(next_delivery) {
208                    retry.forget_seeks(&self.topic, self.partition, self.offset);
209                    return self.drop_path(&retry).await;
210                }
211                retry.record_seek(&self.topic, self.partition, self.offset);
212                retry
213                    .seek_back(&self.topic, self.partition, self.offset)
214                    .map_err(|err| AckError::Broker(Box::new(err)))
215                // Deliberately NOT settled: the seeked redelivery replays this offset, and
216                // under Tracked the replay resets the partition's watermark state.
217            }
218            Some(Retry::Drop) | None => self.drop_path(&retry).await,
219        }
220    }
221
222    /// The drop path: dead-letter when configured, then settle.
223    async fn drop_path(self, retry: &RetryContext) -> Result<(), AckError> {
224        if let Some(dlq) = retry.dead_letter() {
225            let mut headers = self.headers.clone();
226            headers.insert(DLQ_SOURCE_TOPIC_HEADER, self.topic.clone());
227            headers.insert(DLQ_SOURCE_PARTITION_HEADER, self.partition.to_string());
228            headers.insert(DLQ_SOURCE_OFFSET_HEADER, self.offset.to_string());
229            retry
230                .republish(dlq, &self.payload, &headers)
231                .await
232                .map_err(|err| AckError::Broker(Box::new(err)))?;
233        }
234        self.settle()
235    }
236}
237
238impl IncomingMessage for KafkaMessage {
239    fn payload(&self) -> &[u8] {
240        &self.payload
241    }
242
243    fn headers(&self) -> &Headers {
244        &self.headers
245    }
246
247    /// Marks the offset processed (see the type-level settlement mapping).
248    ///
249    /// # Errors
250    ///
251    /// Returns [`AckError::Broker`] when the offset store rejects the new position, for example
252    /// because `enable.auto.offset.store` was overridden back to `true` on a `Commit::Tracked`
253    /// subscription.
254    ///
255    /// # Cancel safety
256    ///
257    /// Cancel safe: the watermark update is synchronous, so the future either completed or did
258    /// nothing.
259    async fn ack(self) -> Result<(), AckError> {
260        self.settle()
261    }
262
263    /// Settles negatively. With a [`Retry`] policy configured on the subscription,
264    /// `requeue = true` runs it (republish to the retry topic, seek back, or drop) and
265    /// `requeue = false` runs the drop path (dead-letter when configured, then settle).
266    /// Without a policy, `requeue = false` settles the offset and `requeue = true` leaves it
267    /// unsettled for Kafka's native re-consumption - which under `Commit::Auto` makes both
268    /// forms advisory no-ops (see the type-level settlement mapping).
269    ///
270    /// # Errors
271    ///
272    /// Returns [`AckError::Broker`] when a retry/dead-letter republish or seek fails, and
273    /// under the same conditions as [`ack`](Self::ack).
274    ///
275    /// # Cancel safety
276    ///
277    /// Without a policy: cancel safe (the watermark update is synchronous). With a policy: not
278    /// cancel safe - dropping the future may leave the retry or dead-letter copy published
279    /// with the original unsettled (a duplicate, never a loss).
280    async fn nack(self, requeue: bool) -> Result<(), AckError> {
281        match (self.retry.clone(), requeue) {
282            (Some(retry), true) => self.retry_requeue(retry).await,
283            (Some(retry), false) => self.drop_path(&retry).await,
284            // Leaving the offset unsettled is the whole mechanism: under Tracked the committed
285            // position stays below it, so Kafka redelivers from there on the next fetch of
286            // this partition.
287            (None, true) => Ok(()),
288            (None, false) => self.settle(),
289        }
290    }
291
292    /// The keyed-lane key, so keyed worker lanes see it without a `Partitioned` bound: the
293    /// source partition (the default), or the record key under
294    /// [`LaneKey::RecordKey`](crate::LaneKey::RecordKey).
295    fn partition_key(&self) -> Option<&[u8]> {
296        self.lane.as_deref()
297    }
298}
299
300impl Partitioned for KafkaMessage {
301    /// The keyed-lane key (see [`IncomingMessage::partition_key`] on this type): the source
302    /// partition (the default), or the record key under
303    /// [`LaneKey::RecordKey`](crate::LaneKey::RecordKey).
304    fn partition_key(&self) -> Option<&[u8]> {
305        self.lane.as_deref()
306    }
307}