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}