Skip to main content

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}