Skip to main content

ruststream_rdkafka/
context.rs

1//! Per-delivery and per-batch context fields exposed to handlers.
2//!
3//! A handler types its context parameter as `Context<'_, KafkaContext>`, or binds one field as a
4//! parameter with the core's `Ctx<K>` extractor and a key from [`keys`].
5//!
6//! - [Kafka context][context]: [`KafkaContext`], the record's topic, partition, offset, timestamp
7//!   and key, under the keys [`keys::Topic`], [`keys::Partition`], [`keys::Offset`],
8//!   [`keys::TimestampMillis`] and [`keys::Key`].
9//! - [KafkaBatchContext][batch]: [`KafkaBatchContext`], a batch handler's context, which carries
10//!   only the seek handle; each element of a `&[Message<H, T>]` batch carries its own
11//!   coordinates.
12//! - [Seek][seek]: [`keys::SeekHandle`] and [`keys::Position`] move a running subscription.
13//! - [EosPipeline][eos]: [`keys::Source`] gives a hand-written exactly-once publish its
14//!   coordinates.
15//!
16//! [context]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/context/
17//! [batch]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/context/batch-context/
18//! [seek]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/positions/seek/
19//! [eos]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/transactions/exactly-once/eos-pipeline/
20
21use std::fmt;
22use std::sync::Arc;
23
24use bytes::Bytes;
25use ruststream::{BuildBatchContext, BuildContext, Field, Str};
26
27use crate::message::KafkaMessage;
28use crate::record::Shared;
29use crate::seek::{KafkaPosition, KafkaSeeker};
30
31/// Native Kafka delivery metadata plus this subscription's reposition handle, built once per
32/// delivery.
33#[derive(Clone)]
34pub struct KafkaContext {
35    /// What the subscription shares, the seeker and the topic name among it, behind one
36    /// reference count.
37    shared: Arc<Shared>,
38    /// The record's own topic, when the subscription reads several.
39    topic: Option<Str>,
40    partition: i32,
41    offset: i64,
42    timestamp_millis: Option<i64>,
43    key: Option<Bytes>,
44}
45
46impl KafkaContext {
47    /// The topic the record was consumed from.
48    #[must_use]
49    pub fn topic(&self) -> &str {
50        self.topic_name()
51    }
52
53    /// The topic as the shared string: the record's own, or the subscription's.
54    fn topic_name(&self) -> &Str {
55        self.shared.topic(self.topic.as_ref())
56    }
57
58    /// The partition the record was consumed from.
59    #[must_use]
60    pub fn partition(&self) -> i32 {
61        self.partition
62    }
63
64    /// The record's offset within its partition.
65    #[must_use]
66    pub fn offset(&self) -> i64 {
67        self.offset
68    }
69
70    /// The record's timestamp in milliseconds since the epoch, when the broker provided one.
71    #[must_use]
72    pub fn timestamp_millis(&self) -> Option<i64> {
73        self.timestamp_millis
74    }
75
76    /// The record key, or `None` for keyless records.
77    #[must_use]
78    pub fn key(&self) -> Option<&[u8]> {
79        self.key.as_deref()
80    }
81
82    /// The topic name in the form the subscription minted it: a field key reading it hands it
83    /// over for a reference count instead of copying it.
84    pub(crate) fn shared_topic(&self) -> Str {
85        self.topic_name().clone()
86    }
87
88    /// The record key in the form the delivery carries it, handed over the same way.
89    pub(crate) fn shared_key(&self) -> Option<Bytes> {
90        self.key.clone()
91    }
92
93    /// This delivery's own coordinates: seeking to them redelivers exactly this record, and the
94    /// ordered suffix behind it on the partition.
95    #[must_use]
96    pub fn position(&self) -> KafkaPosition {
97        KafkaPosition::topic_offset(self.topic(), self.partition, self.offset)
98    }
99
100    /// The handle repositioning the subscription this delivery came from.
101    #[must_use]
102    pub fn seeker(&self) -> &KafkaSeeker {
103        &self.shared.seeker
104    }
105}
106
107impl fmt::Debug for KafkaContext {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        f.debug_struct("KafkaContext")
110            .field("topic", self.topic_name())
111            .field("partition", &self.partition)
112            .field("offset", &self.offset)
113            .field("timestamp_millis", &self.timestamp_millis)
114            .field("key", &self.key)
115            .field("seeker", &self.shared.seeker)
116            .finish()
117    }
118}
119
120impl BuildContext<KafkaMessage> for KafkaContext {
121    fn build(msg: &KafkaMessage) -> Self {
122        Self {
123            shared: msg.shared_handle(),
124            topic: msg.own_topic(),
125            partition: msg.partition(),
126            offset: msg.offset(),
127            timestamp_millis: msg.timestamp_millis(),
128            key: msg.key().map(Bytes::copy_from_slice),
129        }
130    }
131}
132
133/// The subscription-scoped context of a batch handler: the reposition handle every delivery of
134/// the batch shares.
135///
136/// Built once per dispatched batch, off its first delivery. A batch has no single position - it
137/// spans many records - so the coordinates a body reacts to travel with the elements, and
138/// keeping this a type of its own is what rejects a batch body naming [`KafkaContext`] at compile
139/// time.
140///
141/// # Examples
142///
143/// ```
144/// # #[cfg(feature = "json")]
145/// # mod demo {
146/// use ruststream_rdkafka::context::KafkaBatchContext;
147/// use ruststream_rdkafka::prelude::*;
148/// # #[derive(serde::Deserialize)]
149/// # struct Order { id: u64, resume_at: Option<i64>, partition: i32 }
150///
151/// /// The batch is settled first; the reposition then opens the next batch at the target the
152/// /// producer marked on one of the elements.
153/// #[subscriber(KafkaTopic::new("orders").group("orders-svc"))]
154/// async fn reprocess(orders: &[Order], ctx: &mut Context<'_, KafkaBatchContext>) -> HandlerOutcome {
155///     let target = orders
156///         .iter()
157///         .find_map(|order| order.resume_at.map(|at| (order.partition, at)));
158///     if let Some((partition, at)) = target
159///         && ctx
160///             .context(SeekHandle)
161///             .seek(KafkaPosition::offset(partition, at))
162///             .await
163///             .is_err()
164///     {
165///         return HandlerOutcome::retry();
166///     }
167///     HandlerOutcome::ack()
168/// }
169///
170/// fn app() -> RustStream {
171///     RustStream::new(AppInfo::new("orders", "0.1.0"))
172///         .with_broker(KafkaBroker::new(["localhost:9092"]), |b| {
173///             b.include(reprocess.batch(nonzero!(100usize)));
174///         })
175/// }
176/// # }
177/// ```
178#[derive(Debug, Clone)]
179pub struct KafkaBatchContext {
180    seeker: Arc<KafkaSeeker>,
181}
182
183impl KafkaBatchContext {
184    /// The handle repositioning the subscription this batch came from.
185    #[must_use]
186    pub fn seeker(&self) -> &KafkaSeeker {
187        &self.seeker
188    }
189}
190
191impl BuildBatchContext<KafkaMessage> for KafkaBatchContext {
192    fn build(first: &KafkaMessage) -> Self {
193        Self {
194            seeker: first.seeker_handle(),
195        }
196    }
197}
198
199/// Zero-sized [`Field`] keys reading one [`KafkaContext`] field each.
200pub mod keys {
201    use bytes::Bytes;
202    use ruststream::{ContextField, Str};
203
204    use super::{Field, KafkaBatchContext, KafkaContext, KafkaPosition, KafkaSeeker};
205
206    use crate::eos::SourceOffset;
207
208    /// Reads the source topic name.
209    ///
210    /// Both forms hand over the name the subscription minted: the borrow is a [`Str`], so owning
211    /// it is a reference count rather than a copy.
212    #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
213    pub struct Topic;
214
215    impl Field<KafkaContext> for Topic {
216        type Value<'a> = &'a Str;
217
218        fn get(self, src: &KafkaContext) -> &Str {
219            src.topic_name()
220        }
221    }
222
223    impl ContextField for Topic {
224        type Context = KafkaContext;
225        type Value = Str;
226        fn read(self, src: &KafkaContext) -> Str {
227            src.shared_topic()
228        }
229    }
230
231    /// Reads the source partition.
232    #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
233    pub struct Partition;
234
235    impl Field<KafkaContext> for Partition {
236        type Value<'a> = i32;
237
238        fn get(self, src: &KafkaContext) -> i32 {
239            src.partition()
240        }
241    }
242
243    impl ContextField for Partition {
244        type Context = KafkaContext;
245        type Value = i32;
246        fn read(self, src: &KafkaContext) -> i32 {
247            src.partition()
248        }
249    }
250
251    /// Reads the record offset.
252    #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
253    pub struct Offset;
254
255    impl Field<KafkaContext> for Offset {
256        type Value<'a> = i64;
257
258        fn get(self, src: &KafkaContext) -> i64 {
259            src.offset()
260        }
261    }
262
263    impl ContextField for Offset {
264        type Context = KafkaContext;
265        type Value = i64;
266        fn read(self, src: &KafkaContext) -> i64 {
267            src.offset()
268        }
269    }
270
271    /// Reads the record timestamp in milliseconds since the epoch, when present.
272    #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
273    pub struct TimestampMillis;
274
275    impl Field<KafkaContext> for TimestampMillis {
276        type Value<'a> = Option<i64>;
277
278        fn get(self, src: &KafkaContext) -> Option<i64> {
279            src.timestamp_millis()
280        }
281    }
282
283    impl ContextField for TimestampMillis {
284        type Context = KafkaContext;
285        type Value = Option<i64>;
286        fn read(self, src: &KafkaContext) -> Option<i64> {
287            src.timestamp_millis()
288        }
289    }
290
291    /// Reads the record key, when present.
292    ///
293    /// The owned form is the delivery's own buffer: the first read promotes it to a shared block,
294    /// one allocation per delivery, and every read after that is a reference count.
295    #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
296    pub struct Key;
297
298    impl Field<KafkaContext> for Key {
299        type Value<'a> = Option<&'a [u8]>;
300
301        fn get(self, src: &KafkaContext) -> Option<&[u8]> {
302            src.key()
303        }
304    }
305
306    impl ContextField for Key {
307        type Context = KafkaContext;
308        type Value = Option<Bytes>;
309        fn read(self, src: &KafkaContext) -> Option<Bytes> {
310            src.shared_key()
311        }
312    }
313
314    /// Reads the delivery's source coordinates as one value, the form
315    /// [`EosPipeline::publish`](crate::EosPipeline::publish) takes.
316    #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
317    pub struct Source;
318
319    impl Field<KafkaContext> for Source {
320        type Value<'a> = SourceOffset;
321
322        fn get(self, src: &KafkaContext) -> SourceOffset {
323            SourceOffset::new(src.shared_topic(), src.partition(), src.offset())
324        }
325    }
326
327    impl ContextField for Source {
328        type Context = KafkaContext;
329        type Value = SourceOffset;
330        fn read(self, src: &KafkaContext) -> SourceOffset {
331            SourceOffset::new(src.shared_topic(), src.partition(), src.offset())
332        }
333    }
334
335    /// Reads this delivery's own log coordinates, in the form
336    /// [`Seeker::seek`](ruststream::Seeker::seek) takes: seeking to them redelivers exactly this
337    /// record.
338    ///
339    /// # Examples
340    ///
341    /// ```
342    /// use ruststream_rdkafka::prelude::*;
343    /// # #[derive(serde::Deserialize)]
344    /// # struct Order { id: u64 }
345    ///
346    /// /// Rewinds to the record it is holding when a downstream dependency is unavailable, so
347    /// /// the partition resumes here rather than from the group's committed position.
348    /// #[subscriber("orders")]
349    /// async fn place(
350    ///     order: &Order,
351    ///     Ctx(here): Ctx<Position>,
352    ///     Ctx(seeker): Ctx<SeekHandle>,
353    /// ) -> HandlerOutcome {
354    ///     if order.id == 0 && seeker.seek(here).await.is_err() {
355    ///         return HandlerOutcome::retry();
356    ///     }
357    ///     HandlerOutcome::ack()
358    /// }
359    /// ```
360    #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
361    pub struct Position;
362
363    impl Field<KafkaContext> for Position {
364        type Value<'a> = KafkaPosition;
365
366        fn get(self, src: &KafkaContext) -> KafkaPosition {
367            src.position()
368        }
369    }
370
371    impl ContextField for Position {
372        type Context = KafkaContext;
373        type Value = KafkaPosition;
374        fn read(self, src: &KafkaContext) -> KafkaPosition {
375            src.position()
376        }
377    }
378
379    /// Reads the subscription's reposition handle.
380    ///
381    /// The same key serves the per-delivery [`KafkaContext`] and the batch-scoped
382    /// [`KafkaBatchContext`], because a seek is a subscription operation either way.
383    ///
384    /// # Examples
385    ///
386    /// ```
387    /// use ruststream_rdkafka::prelude::*;
388    /// # #[derive(serde::Deserialize)]
389    /// # struct Job { id: u64, resume_at: Option<i64> }
390    ///
391    /// /// Jumps the partition past a run the producer marked unprocessable.
392    /// #[subscriber("jobs")]
393    /// async fn work(
394    ///     job: &Job,
395    ///     Ctx(partition): Ctx<Partition>,
396    ///     Ctx(seeker): Ctx<SeekHandle>,
397    /// ) -> HandlerOutcome {
398    ///     if let Some(resume_at) = job.resume_at
399    ///         && seeker
400    ///             .seek(KafkaPosition::offset(partition, resume_at))
401    ///             .await
402    ///             .is_err()
403    ///     {
404    ///         return HandlerOutcome::retry();
405    ///     }
406    ///     HandlerOutcome::ack()
407    /// }
408    /// ```
409    #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
410    pub struct SeekHandle;
411
412    impl Field<KafkaContext> for SeekHandle {
413        type Value<'a> = &'a KafkaSeeker;
414
415        fn get(self, src: &KafkaContext) -> &KafkaSeeker {
416            src.seeker()
417        }
418    }
419
420    impl ContextField for SeekHandle {
421        type Context = KafkaContext;
422        type Value = KafkaSeeker;
423        fn read(self, src: &KafkaContext) -> KafkaSeeker {
424            src.seeker().clone()
425        }
426    }
427
428    impl Field<KafkaBatchContext> for SeekHandle {
429        type Value<'a> = &'a KafkaSeeker;
430
431        fn get(self, src: &KafkaBatchContext) -> &KafkaSeeker {
432            src.seeker()
433        }
434    }
435}