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}