ruststream_rdkafka/message.rs
1//! The delivery type yielded by [`KafkaSubscriber`](crate::KafkaSubscriber).
2
3use std::convert::Infallible;
4use std::fmt;
5use std::future::{Future, ready};
6use std::io::Write as _;
7use std::sync::{Arc, OnceLock};
8
9#[cfg(feature = "schema-registry")]
10use bytes::Bytes;
11use ruststream::{AckError, HeaderMap, IncomingMessage, Partitioned, Positioned, Str};
12
13use crate::convert;
14use crate::record::{HeldRecord, Shared};
15use crate::seek::{KafkaPosition, KafkaSeeker};
16use crate::tracker::PartitionSlot;
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/// How this delivery settles when acked.
29///
30/// The tracked forms carry the partition slot the delivery was pulled in, so a settle that
31/// arrives after the subscription was repositioned (a seek, a rebalance) is dropped instead of
32/// moving a position that no longer describes what this consumer reads.
33pub(crate) enum Settlement {
34 /// `Commit::Auto`: librdkafka owns the committed position; `ack`/`nack` are advisory.
35 Advisory,
36 /// `Commit::Tracked`: an ack advances the shared watermark and stores the new position,
37 /// through the tracker and the consumer the delivery's own record keeps alive.
38 Tracked { slot: PartitionSlot },
39 /// `Commit::Transactional`: an ack advances the shared watermark only - the EOS pipeline
40 /// commits positions through the producer transaction, so nothing is stored here.
41 Transactional { slot: PartitionSlot },
42}
43
44/// How a delivery answers [`IncomingMessage::partition_key`], which most deliveries are never
45/// asked.
46///
47/// Both forms answer out of what the delivery already holds, so a subscription with no keyed
48/// lanes - the default - pays nothing for a key nobody reads.
49pub(crate) enum Lane {
50 /// The record key, which the delivery carries as its [`PARTITION_KEY_HEADER`] header.
51 RecordKey,
52 /// The source partition, written into the delivery's own bytes on the first ask.
53 Partition(OnceLock<PartitionText>),
54}
55
56impl Lane {
57 /// This delivery's lane key.
58 fn of<'a>(&'a self, msg: &'a KafkaMessage) -> Option<&'a [u8]> {
59 match self {
60 Self::RecordKey => msg.record.key(),
61 Self::Partition(text) => Some(
62 text.get_or_init(|| PartitionText::of(msg.partition))
63 .as_bytes(),
64 ),
65 }
66 }
67}
68
69impl fmt::Debug for Lane {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 match self {
72 Self::RecordKey => f.write_str("RecordKey"),
73 Self::Partition(_) => f.write_str("Partition"),
74 }
75 }
76}
77
78/// A partition number as the decimal text a lane key is, held inline: the widest `i32` is eleven
79/// bytes, which is shorter than the pointer a heap copy of it would cost. The length is a byte
80/// because every delivery carries this cell whether or not anything reads it.
81#[derive(Debug)]
82pub(crate) struct PartitionText {
83 bytes: [u8; 11],
84 len: u8,
85}
86
87impl PartitionText {
88 fn of(partition: i32) -> Self {
89 let mut bytes = [0u8; 11];
90 let mut cursor = &mut bytes[..];
91 write!(cursor, "{partition}").expect("eleven bytes hold the widest i32");
92 let len = u8::try_from(11 - cursor.len()).expect("eleven bytes hold the widest i32");
93 Self { bytes, len }
94 }
95
96 fn as_bytes(&self) -> &[u8] {
97 &self.bytes[..self.len as usize]
98 }
99}
100
101/// One Kafka delivery: an owned snapshot of the record plus its settlement handle.
102///
103/// Settlement mapping depends on the [`Commit`](crate::Commit) mode of the subscription:
104///
105/// Under `Commit::Auto` (the default) librdkafka owns the committed position - it is stored
106/// the moment a message is handed to the application - so `ack` and `nack(false)` are advisory
107/// no-ops, and `nack(true)` reports [`AckError::Unsupported`]: nothing brings the record back.
108///
109/// Under `Commit::Tracked`:
110///
111/// - [`ack`](IncomingMessage::ack) settles the offset and advances the stored position across
112/// everything settled below it.
113/// - [`nack(false)`](IncomingMessage::nack) drops the message: the offset settles so the
114/// position can move past it.
115/// - [`nack(true)`](IncomingMessage::nack) hands the delivery back to its subscription, which
116/// delivers it again ahead of what it fetches next. Its offset stays unsettled meanwhile, so
117/// the committed position stays below it: were the process to stop first, Kafka redelivers
118/// the record from there to whoever reads the partition next. A rebalance or a seek that
119/// moves the partition before the delivery comes back drops it for the same reason. Until it
120/// settles, the unsettled offset also blocks the position, keeping every later ack
121/// uncommitted.
122///
123/// Under `Commit::Transactional` the settlements report to the exactly-once pipeline instead,
124/// and `nack(true)` keeps the open window from committing: it aborts, and its replay delivers
125/// the record again with the rest of the window.
126///
127/// Wire headers map name for name; a null-valued Kafka header arrives with an empty value
128/// (presence preserved).
129pub struct KafkaMessage {
130 /// The record librdkafka fetched, kept where it put it: the body, the key and the wire
131 /// headers are read out of the fetch buffer, and the consumer that owns that buffer stays
132 /// open for as long as this delivery does.
133 record: HeldRecord,
134 /// The payload the registry middleware transcoded, when there is one. Without it the body
135 /// is the record's own.
136 #[cfg(feature = "schema-registry")]
137 transcoded: Option<Bytes>,
138 /// The `RustStream` view of the record's headers, built on the first read: a delivery
139 /// nothing asks headers of never pays for them.
140 headers: OnceLock<HeaderMap>,
141 /// The record's topic, when the subscription reads several: a Kafka consumer reads a handful
142 /// of topics and delivers millions of records, so the name is minted once per topic and
143 /// every delivery of it takes a reference count. A subscription over one topic leaves it
144 /// empty, and the delivery reads the name its shared block holds, for no count of its own.
145 topic: Option<Str>,
146 partition: i32,
147 offset: i64,
148 settlement: Settlement,
149 /// How this delivery answers `partition_key()`.
150 lane: Lane,
151}
152
153impl fmt::Debug for KafkaMessage {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155 let mut out = f.debug_struct("KafkaMessage");
156 out.field("record", &self.record);
157 #[cfg(feature = "schema-registry")]
158 out.field("transcoded", &self.transcoded);
159 out.field("headers", &self.headers)
160 .field("topic", self.topic_name())
161 .field("partition", &self.partition)
162 .field("offset", &self.offset)
163 .field("settlement", &self.settlement)
164 .field("lane", &self.lane)
165 .finish()
166 }
167}
168
169impl fmt::Debug for Settlement {
170 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171 match self {
172 Self::Advisory => f.write_str("Advisory"),
173 Self::Tracked { .. } => f.debug_struct("Tracked").finish_non_exhaustive(),
174 Self::Transactional { .. } => f.debug_struct("Transactional").finish_non_exhaustive(),
175 }
176 }
177}
178
179impl KafkaMessage {
180 // An internal constructor mirroring the record's natural fields; grouping them into
181 // intermediate structs would only add indirection for the one caller.
182 #[allow(clippy::too_many_arguments)]
183 pub(crate) fn new(
184 record: HeldRecord,
185 headers: OnceLock<HeaderMap>,
186 topic: Option<Str>,
187 partition: i32,
188 offset: i64,
189 settlement: Settlement,
190 lane: Lane,
191 ) -> Self {
192 Self {
193 record,
194 #[cfg(feature = "schema-registry")]
195 transcoded: None,
196 headers,
197 topic,
198 partition,
199 offset,
200 settlement,
201 lane,
202 }
203 }
204
205 /// The topic as the shared string: the record's own, or the subscription's.
206 fn topic_name(&self) -> &Str {
207 self.record.shared().topic(self.topic.as_ref())
208 }
209
210 /// The subscription's reposition handle, for the context built off a batch.
211 pub(crate) fn seeker_handle(&self) -> Arc<KafkaSeeker> {
212 Arc::clone(&self.record.shared().seeker)
213 }
214
215 /// What the subscription shares, for the context built off this delivery: one count holds
216 /// the seeker and the topic name alike.
217 pub(crate) fn shared_handle(&self) -> Arc<Shared> {
218 Arc::clone(self.record.shared())
219 }
220
221 /// The topic name this delivery carries itself, when its subscription reads several.
222 pub(crate) fn own_topic(&self) -> Option<Str> {
223 self.topic.clone()
224 }
225
226 /// The topic this record was consumed from.
227 #[must_use]
228 pub fn topic(&self) -> &str {
229 self.topic_name()
230 }
231
232 /// The partition this record was consumed from.
233 #[must_use]
234 pub fn partition(&self) -> i32 {
235 self.partition
236 }
237
238 /// The record's offset within its partition.
239 #[must_use]
240 pub fn offset(&self) -> i64 {
241 self.offset
242 }
243
244 /// The record's timestamp in milliseconds since the epoch, when the broker provided one.
245 ///
246 /// Read from the record on the ask rather than carried by the delivery: librdkafka answers
247 /// it out of the fetch buffer this delivery holds open, and most deliveries are never asked.
248 #[must_use]
249 pub fn timestamp_millis(&self) -> Option<i64> {
250 self.record.timestamp_millis()
251 }
252
253 /// The record key, surfaced from Kafka's native key (see [`PARTITION_KEY_HEADER`]).
254 #[must_use]
255 pub fn key(&self) -> Option<&[u8]> {
256 self.record.key()
257 }
258
259 /// Replaces the payload with its registry-transcoded form (the subscriber's async
260 /// middleware), before the delivery is handed on.
261 #[cfg(feature = "schema-registry")]
262 pub(crate) fn replace_payload(&mut self, payload: Bytes) {
263 self.transcoded = Some(payload);
264 }
265
266 /// Whether this delivery still belongs to its partition's read position. One handed back
267 /// with `nack(true)` is delivered again only while it does: after a seek or a revoke the
268 /// replay or the partition's next owner delivers its record instead.
269 pub(crate) fn is_current(&self) -> bool {
270 match &self.settlement {
271 Settlement::Advisory => true,
272 Settlement::Tracked { slot } | Settlement::Transactional { slot } => {
273 self.record.shared().tracker.is_current(*slot)
274 }
275 }
276 }
277
278 fn settle(self) -> Result<(), AckError> {
279 match &self.settlement {
280 Settlement::Advisory => Ok(()),
281 Settlement::Tracked { slot } => self
282 .record
283 .shared()
284 .tracker
285 .settle_with(*slot, self.offset, |position, target| {
286 self.record.store(self.offset, position, target)
287 })
288 .map_err(|err| AckError::Broker(Box::new(err))),
289 Settlement::Transactional { slot } => {
290 let infallible: Result<(), Infallible> = self.record.shared().tracker.settle_with(
291 *slot,
292 self.offset,
293 |_position, _target| Ok(()),
294 );
295 infallible.expect("no-op store cannot fail");
296 Ok(())
297 }
298 }
299 }
300}
301
302impl IncomingMessage for KafkaMessage {
303 fn payload(&self) -> &[u8] {
304 #[cfg(feature = "schema-registry")]
305 if let Some(transcoded) = &self.transcoded {
306 return transcoded;
307 }
308 self.record.payload()
309 }
310
311 fn headers(&self) -> &HeaderMap {
312 self.headers
313 .get_or_init(|| convert::headers_from_message(&self.record))
314 }
315
316 /// Marks the offset processed (see the type-level settlement mapping).
317 ///
318 /// # Errors
319 ///
320 /// Returns [`AckError::Broker`] when librdkafka refuses the stored position, which is a
321 /// client-state failure rather than a network one - the partition is no longer this
322 /// consumer's, say. A descriptor passthrough that would take the offset store back is not
323 /// one of those cases and never reaches here: librdkafka would accept the store and keep
324 /// owning the position, so [`Commit::Tracked`](crate::Commit::Tracked) and
325 /// [`Commit::Transactional`](crate::Commit::Transactional) refuse such a subscription at
326 /// startup instead.
327 ///
328 /// # Cancel safety
329 ///
330 /// Cancel safe: the watermark update is synchronous, so the future either completed or did
331 /// nothing.
332 fn ack(self) -> impl Future<Output = Result<(), AckError>> {
333 ready(self.settle())
334 }
335
336 /// Settles negatively. `requeue = false` drops the delivery: the offset settles so the
337 /// committed position can move past it. `requeue = true` hands the delivery back to its
338 /// subscription, which delivers it again; its offset stays unsettled until then. Under
339 /// `Commit::Transactional` it aborts the open window, whose replay delivers it again. Under
340 /// `Commit::Auto` `requeue = false` is an advisory no-op and `requeue = true` is unsupported
341 /// (see the type-level settlement mapping).
342 ///
343 /// Kafka counts no deliveries of its own, so a registration that caps its attempts with
344 /// `max_attempts(..)` gets the framework's count instead: the runtime republishes the
345 /// delivery through the registration's retry publisher so the count travels with it.
346 ///
347 /// # Errors
348 ///
349 /// Returns [`AckError::Unsupported`] for `requeue = true` under `Commit::Auto`, where the
350 /// position was stored when the record was handed over, and [`AckError::Broker`] under the
351 /// same conditions as [`ack`](Self::ack).
352 ///
353 /// # Cancel safety
354 ///
355 /// Cancel safe: the watermark update is synchronous, so the future either completed or did
356 /// nothing.
357 fn nack(self, requeue: bool) -> impl Future<Output = Result<(), AckError>> {
358 ready(match (requeue, &self.settlement) {
359 // Auto-commit stored the position as the record was handed over, so no requeue is
360 // left to perform: a success here would promise a redelivery that never comes.
361 (true, Settlement::Advisory) => Err(AckError::Unsupported),
362 // The subscription delivers it again. Its offset stays unsettled meanwhile, which is
363 // what brings the record back from Kafka should the process stop first.
364 (true, Settlement::Tracked { .. }) => {
365 let tracker = Arc::clone(&self.record.shared().tracker);
366 tracker.attention.requeue(self);
367 Ok(())
368 }
369 // Under an exactly-once pipeline the unsettled offset keeps the open window from
370 // committing: it aborts, and its seek back delivers the whole window again, this
371 // record included. Delivering it again inside the window would publish its output
372 // twice in one transaction.
373 (true, Settlement::Transactional { .. }) => Ok(()),
374 (false, _) => self.settle(),
375 })
376 }
377
378 /// The keyed-lane key, so keyed worker lanes see it without a `Partitioned` bound: the
379 /// source partition (the default), or the record key under
380 /// [`LaneKey::RecordKey`](crate::LaneKey::RecordKey).
381 fn partition_key(&self) -> Option<&[u8]> {
382 self.lane.of(self)
383 }
384}
385
386impl Positioned for KafkaMessage {
387 type Position = KafkaPosition;
388
389 /// This delivery's own coordinates: seeking to them redelivers exactly this record (and the
390 /// ordered suffix behind it on the partition).
391 fn position(&self) -> Self::Position {
392 KafkaPosition::topic_offset(&**self.topic_name(), self.partition, self.offset)
393 }
394}
395
396impl Partitioned for KafkaMessage {
397 /// The keyed-lane key (see [`IncomingMessage::partition_key`] on this type): the source
398 /// partition (the default), or the record key under
399 /// [`LaneKey::RecordKey`](crate::LaneKey::RecordKey).
400 fn partition_key(&self) -> Option<&[u8]> {
401 self.lane.of(self)
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn a_partition_lane_key_is_the_partition_in_decimal() {
411 // The widest `i32` is eleven bytes, which is what the inline buffer and its length are
412 // sized for; a lane key that came out short would quietly merge two partitions' lanes.
413 for partition in [0, 7, 42, i32::MAX, i32::MIN] {
414 assert_eq!(
415 PartitionText::of(partition).as_bytes(),
416 partition.to_string().as_bytes(),
417 "the lane key of partition {partition}",
418 );
419 }
420 }
421}