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