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}