Skip to main content

ruststream_sea_file/
context.rs

1//! The file transport's typed contexts and their compile-time keys.
2//!
3//! A stream file is a replayable log, so a handler running on it can reposition its own
4//! subscription. The delivery's position and the subscription's reposition handle travel as
5//! fields of a context the runtime builds per delivery, and handlers read them by key -
6//! [`Ctx`](ruststream::runtime::Ctx) on the attribute path, `ctx.context(..)` on the manual one.
7//!
8//! Standard input has no retained log, so nothing here applies to it: its deliveries carry no
9//! reposition handle, which is what makes a stdio mount of a seeking handler a compile error.
10//!
11//! The [Position](https://powersemmi.github.io/ruststream-sea-file/latest/components/positions/position/),
12//! [Seek](https://powersemmi.github.io/ruststream-sea-file/latest/components/positions/seek/) and
13//! [FileBatchContext](https://powersemmi.github.io/ruststream-sea-file/latest/components/positions/batch-context/)
14//! pages show them in a service.
15
16use ruststream::{BuildBatchContext, BuildContext, ContextField, Field};
17
18use crate::message::FilePosition;
19use crate::subscriber::{FileMessage, FileSeeker};
20
21/// The file transport's per-delivery context: this delivery's position in the stream file, and
22/// the subscription's own seeker.
23///
24/// The runtime builds one per delivery, and handlers read its fields by key - [`Position`] and
25/// [`SeekHandle`]. A handler that reads both, or that reads them on the manual path, names this
26/// type as its context.
27///
28/// # Examples
29///
30/// ```
31/// use ruststream_sea_file::file::prelude::*;
32/// use serde::Deserialize;
33///
34/// #[derive(Debug, Deserialize)]
35/// struct Job {
36///     id: u64,
37/// }
38///
39/// #[subscriber(FileStream::new("jobs"))]
40/// async fn work(job: &Job, ctx: &mut Context<'_, FileContext>) -> HandlerOutcome {
41///     if job.id == 0 && ctx.context(SeekHandle).seek(FilePosition::end()).await.is_err() {
42///         return HandlerOutcome::retry();
43///     }
44///     tracing::info!(job.id, position = ?ctx.context(Position), "worked");
45///     HandlerOutcome::ack()
46/// }
47///
48/// #[ruststream::app]
49/// fn app() -> impl App {
50///     RustStream::new(AppInfo::new("jobs", "0.1.0"))
51///         .with_broker(FileBroker::new("/tmp/jobs.ss"), |b| {
52///             b.include(work);
53///         })
54/// }
55/// ```
56#[derive(Debug)]
57pub struct FileContext {
58    position: FilePosition,
59    seeker: FileSeeker,
60}
61
62impl BuildContext<FileMessage> for FileContext {
63    fn build(msg: &FileMessage) -> Self {
64        Self {
65            position: ruststream::Positioned::position(msg),
66            // A clone of the subscription's pre-minted handle: reference-count bumps only,
67            // nothing allocated per delivery.
68            seeker: msg.seeker().clone(),
69        }
70    }
71}
72
73/// The file transport's subscription-scoped batch context: the subscription's own seeker, shared
74/// by every delivery of the batch.
75///
76/// The runtime builds one per dispatched batch from the batch's first delivery, and a batch body
77/// reads it by key - [`SeekHandle`]. Per-delivery data (a [`Position`]) has no place here: a
78/// batch spans many deliveries, so the position a body reacts to rides the elements themselves,
79/// and keeping this a separate type from [`FileContext`] is what rejects a batch body asking for
80/// per-delivery fields at compile time.
81///
82/// # Examples
83///
84/// The batch settles first, so a seek from its body applies to the batches after it:
85///
86/// ```
87/// use ruststream_sea_file::file::prelude::*;
88/// use serde::Deserialize;
89///
90/// #[derive(Debug, Deserialize)]
91/// struct Job {
92///     id: u64,
93///     resume_at: Option<u64>,
94/// }
95///
96/// #[subscriber(FileStream::new("jobs"), start_at(FilePosition::beginning()))]
97/// async fn drain(jobs: &[Job], ctx: &mut Context<'_, FileBatchContext>) -> HandlerOutcome {
98///     tracing::info!(jobs = jobs.len(), "drained");
99///     let Some(resume_at) = jobs.iter().find_map(|job| job.resume_at) else {
100///         return HandlerOutcome::ack();
101///     };
102///     if let Err(error) = ctx
103///         .context(SeekHandle)
104///         .seek(FilePosition::sequence(resume_at))
105///         .await
106///     {
107///         tracing::warn!(resume_at, %error, "seek refused");
108///     }
109///     HandlerOutcome::ack()
110/// }
111///
112/// #[ruststream::app]
113/// fn app() -> impl App {
114///     RustStream::new(AppInfo::new("worker", "0.1.0"))
115///         .with_broker(FileBroker::new("/tmp/jobs.ss"), |b| {
116///             b.include(drain.batch(nonzero!(3)));
117///         })
118/// }
119/// ```
120///
121/// A batch body that asks for a per-delivery position does not compile: [`Position`] is a key of
122/// [`FileContext`] alone.
123///
124/// ```compile_fail,E0277
125/// use ruststream_sea_file::file::prelude::*;
126/// use serde::Deserialize;
127///
128/// #[derive(Debug, Deserialize)]
129/// struct Job {
130///     id: u64,
131/// }
132///
133/// #[subscriber(FileStream::new("jobs"))]
134/// async fn drain(jobs: &[Job], ctx: &mut Context<'_, FileBatchContext>) -> HandlerOutcome {
135///     let first: FilePosition = ctx.context(Position);
136///     tracing::info!(jobs = jobs.len(), ?first, "drained");
137///     HandlerOutcome::ack()
138/// }
139/// ```
140#[derive(Debug)]
141pub struct FileBatchContext {
142    seeker: FileSeeker,
143}
144
145impl BuildBatchContext<FileMessage> for FileBatchContext {
146    fn build(first: &FileMessage) -> Self {
147        Self {
148            seeker: first.seeker().clone(),
149        }
150    }
151}
152
153/// The key reading this delivery's [`FilePosition`] out of [`FileContext`].
154///
155/// The value is the pinned form: seeking back to it redelivers exactly this message.
156///
157/// # Examples
158///
159/// ```
160/// use ruststream_sea_file::file::prelude::*;
161/// # #[derive(serde::Deserialize)]
162/// # struct Job { id: u64 }
163///
164/// #[subscriber(FileStream::new("jobs"))]
165/// async fn audit(job: &Job, Ctx(at): Ctx<Position>) -> HandlerOutcome {
166///     tracing::info!(job.id, position = ?at, "audited");
167///     HandlerOutcome::ack()
168/// }
169/// ```
170#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
171pub struct Position;
172
173impl ContextField for Position {
174    type Context = FileContext;
175    type Value = FilePosition;
176
177    fn read(self, src: &FileContext) -> FilePosition {
178        src.position
179    }
180}
181
182impl Field<FileContext> for Position {
183    type Value<'a> = FilePosition;
184
185    fn get(self, src: &FileContext) -> FilePosition {
186        src.position
187    }
188}
189
190/// The key reading the subscription's [`FileSeeker`] out of [`FileContext`] or
191/// [`FileBatchContext`]: the reposition handle, resolved once when the subscription opens and
192/// carried by every delivery.
193///
194/// # Examples
195///
196/// ```
197/// use ruststream_sea_file::file::prelude::*;
198/// # #[derive(serde::Deserialize)]
199/// # struct Job { id: u64, poisoned: bool }
200///
201/// /// Skips to the live tail when the producer marks a poisoned region.
202/// #[subscriber(FileStream::new("jobs"))]
203/// async fn work(job: &Job, Ctx(seeker): Ctx<SeekHandle>) -> HandlerOutcome {
204///     if job.poisoned && seeker.seek(FilePosition::end()).await.is_err() {
205///         return HandlerOutcome::retry();
206///     }
207///     HandlerOutcome::ack()
208/// }
209/// ```
210///
211/// Standard input keeps no log to move within, so a handler that reads the key does not compile
212/// against [`StdioBroker`](crate::StdioBroker):
213///
214/// ```compile_fail,E0277
215/// use ruststream_sea_file::prelude::*;
216/// use serde::Deserialize;
217///
218/// #[derive(Debug, Deserialize)]
219/// struct Job {
220///     id: u64,
221/// }
222///
223/// #[subscriber("jobs")]
224/// async fn work(job: &Job, Ctx(seeker): Ctx<SeekHandle>) -> HandlerOutcome {
225///     if job.id == 0 && seeker.seek(FilePosition::end()).await.is_err() {
226///         return HandlerOutcome::retry();
227///     }
228///     HandlerOutcome::ack()
229/// }
230///
231/// #[ruststream::app]
232/// fn app() -> impl App {
233///     RustStream::new(AppInfo::new("pipeline", "0.1.0")).with_broker(StdioBroker::new(), |b| {
234///         b.include(work);
235///     })
236/// }
237/// ```
238#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
239pub struct SeekHandle;
240
241impl ContextField for SeekHandle {
242    type Context = FileContext;
243    type Value = FileSeeker;
244
245    fn read(self, src: &FileContext) -> FileSeeker {
246        src.seeker.clone()
247    }
248}
249
250impl Field<FileContext> for SeekHandle {
251    type Value<'a> = &'a FileSeeker;
252
253    fn get(self, src: &FileContext) -> &FileSeeker {
254        &src.seeker
255    }
256}
257
258impl Field<FileBatchContext> for SeekHandle {
259    type Value<'a> = &'a FileSeeker;
260
261    fn get(self, src: &FileBatchContext) -> &FileSeeker {
262        &src.seeker
263    }
264}