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}