Skip to main content

ruststream_rdkafka/
message.rs

1//! The delivery type yielded by [`KafkaSubscriber`](crate::KafkaSubscriber).
2
3use std::fmt;
4use std::sync::Arc;
5
6use bytes::Bytes;
7use rdkafka::consumer::{Consumer as _, StreamConsumer};
8use ruststream::{AckError, Headers, IncomingMessage, Partitioned};
9
10use crate::tracker::{CommitTracker, TrackingContext};
11
12/// Header carrying a message's partition key, mapped onto Kafka's native record key.
13///
14/// On publish, this header becomes the record key (so Kafka itself routes deliveries that share
15/// a key to the same partition) and is not duplicated as a wire header. On consume, the header
16/// always mirrors the native record key - a same-named wire header from a foreign producer is
17/// not preserved, because the record key is Kafka's source of truth for partitioning. Keyed
18/// worker lanes (`workers(n, by_key)`) read it through
19/// [`IncomingMessage::partition_key`]; [`Partitioned`] mirrors it as the capability surface.
20pub const PARTITION_KEY_HEADER: &str = "kafka-partition-key";
21
22/// How this delivery settles when acked.
23pub(crate) enum Settlement {
24    /// `Commit::Auto`: librdkafka owns the committed position; `ack`/`nack` are advisory.
25    Advisory,
26    /// `Commit::Tracked`: an ack advances the shared watermark and stores the new position.
27    Tracked {
28        consumer: Arc<StreamConsumer<TrackingContext>>,
29        tracker: Arc<CommitTracker>,
30    },
31}
32
33/// One Kafka delivery: an owned snapshot of the record plus its settlement handle.
34///
35/// Settlement mapping depends on the [`Commit`](crate::Commit) mode of the subscription:
36///
37/// Under `Commit::Auto` (the default) librdkafka owns the committed position - it is stored
38/// the moment a message is handed to the application - so `ack` and both `nack` forms are
39/// advisory no-ops; in particular `nack(true)` does NOT cause a redelivery.
40///
41/// Under `Commit::Tracked`:
42///
43/// - [`ack`](IncomingMessage::ack) settles the offset and advances the stored position across
44///   everything settled below it.
45/// - [`nack(false)`](IncomingMessage::nack) drops the message: the offset settles so the
46///   position can move past it (Kafka has no per-message dead-letter path; a dead-letter topic
47///   is a planned descriptor option).
48/// - [`nack(true)`](IncomingMessage::nack) leaves the offset unsettled: the committed position
49///   stays below it, so Kafka redelivers from there when the partition is next re-fetched (a
50///   rebalance or a restart). Until then the unsettled offset also blocks the position,
51///   keeping every later ack uncommitted - precise, but worth knowing when a handler nacks in
52///   a loop.
53///
54/// Wire headers map name for name; a null-valued Kafka header arrives with an empty value
55/// (presence preserved).
56#[derive(Debug)]
57pub struct KafkaMessage {
58    payload: Bytes,
59    headers: Headers,
60    topic: String,
61    partition: i32,
62    offset: i64,
63    timestamp_millis: Option<i64>,
64    settlement: Settlement,
65}
66
67impl fmt::Debug for Settlement {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        match self {
70            Self::Advisory => f.write_str("Advisory"),
71            Self::Tracked { .. } => f.debug_struct("Tracked").finish_non_exhaustive(),
72        }
73    }
74}
75
76impl KafkaMessage {
77    pub(crate) fn new(
78        payload: Bytes,
79        headers: Headers,
80        topic: String,
81        partition: i32,
82        offset: i64,
83        timestamp_millis: Option<i64>,
84        settlement: Settlement,
85    ) -> Self {
86        Self {
87            payload,
88            headers,
89            topic,
90            partition,
91            offset,
92            timestamp_millis,
93            settlement,
94        }
95    }
96
97    /// The topic this record was consumed from.
98    #[must_use]
99    pub fn topic(&self) -> &str {
100        &self.topic
101    }
102
103    /// The partition this record was consumed from.
104    #[must_use]
105    pub fn partition(&self) -> i32 {
106        self.partition
107    }
108
109    /// The record's offset within its partition.
110    #[must_use]
111    pub fn offset(&self) -> i64 {
112        self.offset
113    }
114
115    /// The record's timestamp in milliseconds since the epoch, when the broker provided one.
116    #[must_use]
117    pub fn timestamp_millis(&self) -> Option<i64> {
118        self.timestamp_millis
119    }
120
121    /// The record key, surfaced from Kafka's native key (see [`PARTITION_KEY_HEADER`]).
122    #[must_use]
123    pub fn key(&self) -> Option<&[u8]> {
124        self.headers.get(PARTITION_KEY_HEADER)
125    }
126
127    fn settle(self) -> Result<(), AckError> {
128        match self.settlement {
129            Settlement::Advisory => Ok(()),
130            Settlement::Tracked { consumer, tracker } => tracker
131                .settle_with(&self.topic, self.partition, self.offset, |position| {
132                    consumer.store_offset(&self.topic, self.partition, position)
133                })
134                .map_err(|err| AckError::Broker(Box::new(err))),
135        }
136    }
137}
138
139impl IncomingMessage for KafkaMessage {
140    fn payload(&self) -> &[u8] {
141        &self.payload
142    }
143
144    fn headers(&self) -> &Headers {
145        &self.headers
146    }
147
148    /// Marks the offset processed (see the type-level settlement mapping).
149    ///
150    /// # Errors
151    ///
152    /// Returns [`AckError::Broker`] when the offset store rejects the new position, for example
153    /// because `enable.auto.offset.store` was overridden back to `true` on a `Commit::Tracked`
154    /// subscription.
155    ///
156    /// # Cancel safety
157    ///
158    /// Cancel safe: the watermark update is synchronous, so the future either completed or did
159    /// nothing.
160    async fn ack(self) -> Result<(), AckError> {
161        self.settle()
162    }
163
164    /// Settles negatively: drops the offset (`requeue = false`) or leaves it unsettled for
165    /// Kafka's native re-consumption (`requeue = true`). Only meaningful under
166    /// `Commit::Tracked`; under `Commit::Auto` both forms are advisory no-ops (see the
167    /// type-level settlement mapping).
168    ///
169    /// # Errors
170    ///
171    /// Returns [`AckError::Broker`] under the same conditions as [`ack`](Self::ack).
172    ///
173    /// # Cancel safety
174    ///
175    /// Cancel safe: the watermark update is synchronous, so the future either completed or did
176    /// nothing.
177    async fn nack(self, requeue: bool) -> Result<(), AckError> {
178        if requeue {
179            // Leaving the offset unsettled is the whole mechanism: under Tracked the committed
180            // position stays below it, so Kafka redelivers from there on the next fetch of
181            // this partition.
182            return Ok(());
183        }
184        self.settle()
185    }
186
187    /// The record key, so keyed worker lanes see it without a `Partitioned` bound.
188    fn partition_key(&self) -> Option<&[u8]> {
189        self.headers.get(PARTITION_KEY_HEADER)
190    }
191}
192
193impl Partitioned for KafkaMessage {
194    /// The record key Kafka partitioned this message by, or `None` for keyless records.
195    fn partition_key(&self) -> Option<&[u8]> {
196        self.headers.get(PARTITION_KEY_HEADER)
197    }
198}