Skip to main content

ruststream_pulsar/
context.rs

1//! The contexts a Pulsar subscription hands its handlers, and the keys that read them.
2//!
3//! Pulsar topics are a retained log, so the two things a body needs to reposition itself are
4//! per-delivery state: where this message sits ([`Position`]) and the handle that moves the
5//! subscription ([`SeekHandle`]). Both ride [`PulsarContext`], built once per delivery; a batch
6//! spans many deliveries, so [`PulsarBatchContext`] carries only the subscription-scoped half.
7
8use ruststream::{BuildBatchContext, BuildContext, ContextField, Field, Positioned};
9
10use crate::message::{PulsarMessage, PulsarPosition};
11use crate::subscriber::PulsarSeeker;
12
13/// The per-delivery context of a Pulsar subscription: this message's position in the topic's
14/// retained log, and the subscription's own seeker.
15///
16/// The runtime builds one per dispatched delivery (see [`BuildContext`]), and handlers read its
17/// fields by key - [`Position`] and [`SeekHandle`] - through the `Ctx` extractor or
18/// `ctx.context(..)`. A body that repositions its own subscription names this type as its `C`
19/// axis and needs nothing else.
20///
21/// Building it costs no allocation: the position is the delivery's message id, and the seeker
22/// is a clone of the channel to the subscription's driver task.
23///
24/// # Examples
25///
26/// ```
27/// # mod demo {
28/// use ruststream_pulsar::prelude::*;
29/// use serde::Deserialize;
30///
31/// #[derive(Deserialize)]
32/// struct Job {
33///     id: u64,
34///     rewind: bool,
35/// }
36///
37/// /// Logs where each job sits, and replays the retained backlog when a job asks for it.
38/// #[subscriber(PulsarSubscription::new("jobs", "workers"))]
39/// async fn work(job: &Job, ctx: &mut Context<'_, PulsarContext>) -> HandlerOutcome {
40///     tracing::info!(job = job.id, position = ?ctx.context(Position), "job received");
41///     if job.rewind
42///         && ctx
43///             .context(SeekHandle)
44///             .seek(PulsarPosition::earliest())
45///             .await
46///             .is_err()
47///     {
48///         return HandlerOutcome::retry();
49///     }
50///     HandlerOutcome::ack()
51/// }
52/// # }
53/// # fn main() {}
54/// ```
55#[derive(Debug)]
56pub struct PulsarContext {
57    position: PulsarPosition,
58    seeker: PulsarSeeker,
59}
60
61impl BuildContext<PulsarMessage> for PulsarContext {
62    fn build(msg: &PulsarMessage) -> Self {
63        Self {
64            position: Positioned::position(msg),
65            seeker: msg.seeker(),
66        }
67    }
68}
69
70/// The subscription-scoped batch context of a Pulsar subscription: its seeker, shared by every
71/// delivery of the batch.
72///
73/// The runtime builds one per dispatched batch from the batch's first delivery (see
74/// [`BuildBatchContext`]), and a batch body reads it by key - [`SeekHandle`] - through
75/// `ctx.context(..)`. A [`Position`] has no place here: a batch spans many deliveries, so where
76/// to seek rides the elements themselves (a `&[Message<H, T>]` batch reads it off each element's
77/// header contract). Keeping this a separate type from [`PulsarContext`] is what rejects a batch
78/// body asking for per-delivery fields at compile time.
79///
80/// Pulsar's client hands over one delivery at a time, so a batch here is assembled on the client
81/// from the size the mount site's `batch(n)` names. The body sees the same context, seeker and
82/// settlement as on a broker that batches on the wire.
83///
84/// # Examples
85///
86/// ```
87/// # mod demo {
88/// use ruststream_pulsar::prelude::*;
89/// use serde::Deserialize;
90///
91/// #[derive(Deserialize)]
92/// struct Job {
93///     id: u64,
94///     rewind: bool,
95/// }
96///
97/// /// A batch that carries the rewind marker replays the retained backlog once it is settled:
98/// /// the next batch opens at the beginning of the log.
99/// #[subscriber(PulsarSubscription::new("jobs.bulk", "bulk-workers"))]
100/// async fn work(jobs: &[Job], ctx: &mut Context<'_, PulsarBatchContext>) -> HandlerOutcome {
101///     let first = jobs.first().map(|job| job.id);
102///     tracing::info!(count = jobs.len(), ?first, "jobs received");
103///     if jobs.iter().any(|job| job.rewind)
104///         && ctx
105///             .context(SeekHandle)
106///             .seek(PulsarPosition::earliest())
107///             .await
108///             .is_err()
109///     {
110///         return HandlerOutcome::retry();
111///     }
112///     HandlerOutcome::ack()
113/// }
114///
115/// #[ruststream::app]
116/// fn app() -> impl App {
117///     RustStream::new(AppInfo::new("jobs", "0.1.0")).with_broker(
118///         PulsarBroker::new("pulsar://localhost:6650"),
119///         |b| {
120///             b.include(work.batch(nonzero!(100)));
121///         },
122///     )
123/// }
124/// # }
125/// # fn main() {}
126/// ```
127#[derive(Debug)]
128pub struct PulsarBatchContext {
129    seeker: PulsarSeeker,
130}
131
132impl BuildBatchContext<PulsarMessage> for PulsarBatchContext {
133    fn build(first: &PulsarMessage) -> Self {
134        Self {
135            seeker: first.seeker(),
136        }
137    }
138}
139
140/// The key reading this delivery's [`PulsarPosition`] out of [`PulsarContext`].
141///
142/// The value is the delivery's own message id, so it carries the framework's pinned contract:
143/// seeking to it redelivers exactly this message.
144///
145/// A message id is not `Copy`, so `ctx.context(Position)` borrows it and only the `Ctx<Position>`
146/// extractor - which must yield an owned value - copies one per delivery.
147///
148/// # Examples
149///
150/// ```
151/// use ruststream_pulsar::Position;
152/// use ruststream_pulsar::prelude::*;
153/// # #[derive(serde::Deserialize)]
154/// # struct Job { id: u64 }
155///
156/// #[subscriber(PulsarSubscription::new("jobs", "auditors"))]
157/// async fn audit(job: &Job, Ctx(at): Ctx<Position>) -> HandlerOutcome {
158///     tracing::info!(job = job.id, position = ?at, "job audited");
159///     HandlerOutcome::ack()
160/// }
161/// ```
162#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
163pub struct Position;
164
165impl ContextField for Position {
166    type Context = PulsarContext;
167    type Value = PulsarPosition;
168
169    fn read(self, src: &PulsarContext) -> PulsarPosition {
170        src.position.clone()
171    }
172}
173
174impl Field<PulsarContext> for Position {
175    type Value<'a> = &'a PulsarPosition;
176
177    fn get(self, src: &PulsarContext) -> &PulsarPosition {
178        &src.position
179    }
180}
181
182/// The key reading the subscription's [`PulsarSeeker`] out of [`PulsarContext`] (and out of
183/// [`PulsarBatchContext`] on the batch path): the reposition handle every delivery of the
184/// subscription shares.
185///
186/// One seek covers every topic and every partition of the subscription's consumer.
187///
188/// # Examples
189///
190/// ```
191/// use ruststream_pulsar::SeekHandle;
192/// use ruststream_pulsar::prelude::*;
193/// # #[derive(serde::Deserialize)]
194/// # struct Job { id: u64, poisoned_until: Option<u64> }
195///
196/// /// Skips forward past a region the producer marked poisoned.
197/// #[subscriber(PulsarSubscription::new("jobs", "workers"))]
198/// async fn work(job: &Job, Ctx(seeker): Ctx<SeekHandle>) -> HandlerOutcome {
199///     if let Some(resume_at) = job.poisoned_until
200///         && seeker.seek(PulsarPosition::timestamp(resume_at)).await.is_err()
201///     {
202///         return HandlerOutcome::retry();
203///     }
204///     HandlerOutcome::ack()
205/// }
206/// ```
207#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
208pub struct SeekHandle;
209
210impl ContextField for SeekHandle {
211    type Context = PulsarContext;
212    type Value = PulsarSeeker;
213
214    fn read(self, src: &PulsarContext) -> PulsarSeeker {
215        src.seeker.clone()
216    }
217}
218
219impl Field<PulsarContext> for SeekHandle {
220    type Value<'a> = &'a PulsarSeeker;
221
222    fn get(self, src: &PulsarContext) -> &PulsarSeeker {
223        &src.seeker
224    }
225}
226
227impl Field<PulsarBatchContext> for SeekHandle {
228    type Value<'a> = &'a PulsarSeeker;
229
230    fn get(self, src: &PulsarBatchContext) -> &PulsarSeeker {
231        &src.seeker
232    }
233}