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}