Skip to main content

oxide_batch/
item_stream.rs

1//! The M6 `ItemStream` open/update/close contract (Gate C).
2//!
3//! An `ItemStream` is a component-scoped durable-state participant in the
4//! chunk lifecycle: it restores its last committed [`ComponentStateEnvelope`]
5//! before item work begins, prepares a candidate update at the commit
6//! boundary, and closes after the step attempt's terminal outcome is known.
7//! It is registered against a namespace ([`crate::ComponentStreamIdentity`])
8//! at the point a chunk step is assembled -- the same way a reader,
9//! processor, or writer's restart-relevant identity is declared through
10//! [`crate::ChunkComponentRevisions`] rather than self-described by the
11//! trait.
12//!
13//! The contract follows ADR-0008's shape exactly: one generic trait with an
14//! explicit call lifetime and an opaque `impl Future` return, no
15//! `async-trait`, no `Box::pin` in the public signature. Erasure is a
16//! concrete [`BoxedStream`] handle over a private, dyn-compatible mirror,
17//! mirroring [`crate::BoxedReader`]/[`crate::BoxedProcessor`]/
18//! [`crate::BoxedWriter`] -- not a second public trait.
19
20use std::error::Error;
21use std::fmt;
22use std::future::Future;
23
24use crate::{ComponentStateEnvelope, FailureCategory, StopToken};
25
26/// Borrowed call state for [`ItemStream::open`].
27#[derive(Clone, Copy, Debug)]
28pub struct StreamOpenContext<'a> {
29    inherited: Option<&'a ComponentStateEnvelope>,
30    stop: &'a StopToken,
31}
32
33impl<'a> StreamOpenContext<'a> {
34    /// Constructs a stream-open call scope.
35    ///
36    /// `inherited` is the last committed envelope for this stream's
37    /// namespace, already checksum-verified, decoded-schema/codec-matched,
38    /// and migrated by the runtime. `None` means initial execution, not
39    /// corruption: a corrupt or unsupported committed envelope fails before
40    /// any stream is ever opened with it.
41    #[must_use]
42    pub const fn new(inherited: Option<&'a ComponentStateEnvelope>, stop: &'a StopToken) -> Self {
43        Self { inherited, stop }
44    }
45
46    /// Borrows the last committed state for this stream's namespace, if any.
47    #[must_use]
48    pub const fn inherited_state(self) -> Option<&'a ComponentStateEnvelope> {
49        self.inherited
50    }
51
52    /// Borrows the cooperative stop token.
53    #[must_use]
54    pub const fn stop_token(self) -> &'a StopToken {
55        self.stop
56    }
57}
58
59/// One stream-open call outcome.
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61#[non_exhaustive]
62pub enum StreamOpenOutcome {
63    /// A last committed envelope was restored.
64    Restored,
65    /// No committed state existed: initial execution, not corruption.
66    Initial,
67}
68
69/// Borrowed call state for [`ItemStream::update`].
70#[derive(Clone, Copy, Debug)]
71pub struct StreamUpdateContext<'a> {
72    stop: &'a StopToken,
73}
74
75impl<'a> StreamUpdateContext<'a> {
76    /// Constructs a stream-update call scope.
77    #[must_use]
78    pub const fn new(stop: &'a StopToken) -> Self {
79        Self { stop }
80    }
81
82    /// Borrows the cooperative stop token.
83    #[must_use]
84    pub const fn stop_token(self) -> &'a StopToken {
85        self.stop
86    }
87}
88
89/// The coarse, non-sensitive runtime outcome an [`ItemStream`] observes at
90/// close.
91///
92/// Deliberately excludes the full [`crate::ChunkExecutionReport`]: a stream's
93/// close must react to whether the step committed, failed, stopped, or
94/// reached an unknown outcome, never to internal report machinery or other
95/// components' data.
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97#[non_exhaustive]
98pub enum StreamRuntimeOutcome {
99    /// The step attempt's work committed durably.
100    Committed,
101    /// The step attempt ended in a known, typed failure.
102    Failed,
103    /// Cooperative stop ended the step attempt.
104    Stopped,
105    /// The step attempt's commit outcome is unknown.
106    Unknown,
107    /// A nested resource this stream owns reached its own logical boundary
108    /// (a delegate reader exhausted, or a delegate writer rolled over)
109    /// while the *enclosing* step attempt's terminal outcome is not yet
110    /// known.
111    ///
112    /// Only ever reported by a component that nests other [`ItemStream`]s
113    /// and must close a retiring delegate ahead of its own outer close --
114    /// for example
115    /// [`crate::item_components::multi_resource::MultiResourceReader`]/
116    /// [`crate::item_components::multi_resource::MultiResourceWriter`]
117    /// closing the resource they are transitioning away from. The
118    /// top-level chunk runtime itself never reports this variant: it is
119    /// strictly weaker than [`Self::Committed`] (the retiring resource's
120    /// own prior work may already be durable, but the step attempt that
121    /// observed its boundary has not yet reached a terminal outcome), so a
122    /// delegate must not treat it as proof of a durable commit.
123    ResourceBoundary,
124}
125
126/// Borrowed call state for [`ItemStream::close`].
127#[derive(Clone, Copy, Debug)]
128pub struct StreamCloseContext<'a> {
129    stop: &'a StopToken,
130    outcome: StreamRuntimeOutcome,
131}
132
133impl<'a> StreamCloseContext<'a> {
134    /// Constructs a stream-close call scope.
135    #[must_use]
136    pub const fn new(stop: &'a StopToken, outcome: StreamRuntimeOutcome) -> Self {
137        Self { stop, outcome }
138    }
139
140    /// Borrows the cooperative stop token.
141    #[must_use]
142    pub const fn stop_token(self) -> &'a StopToken {
143        self.stop
144    }
145
146    /// Returns the step attempt's coarse terminal outcome.
147    #[must_use]
148    pub const fn outcome(self) -> StreamRuntimeOutcome {
149        self.outcome
150    }
151}
152
153/// One stream-close call outcome.
154#[derive(Clone, Copy, Debug, Eq, PartialEq)]
155#[non_exhaustive]
156pub enum StreamCloseOutcome {
157    /// The stream closed.
158    Closed,
159}
160
161macro_rules! stream_error {
162    ($name:ident, $message:literal) => {
163        #[doc = $message]
164        ///
165        /// The adapter translates its own typed error into a stable
166        /// [`FailureCategory`] at this boundary. The payload, display text,
167        /// and source chain are dropped, so classification never inspects
168        /// them.
169        #[derive(Clone, Copy, Debug, Eq, PartialEq)]
170        pub struct $name {
171            category: FailureCategory,
172        }
173
174        impl $name {
175            /// Constructs a value-redacted [`FailureCategory::UserComponent`]
176            /// failure.
177            #[must_use]
178            pub const fn new() -> Self {
179                Self {
180                    category: FailureCategory::UserComponent,
181                }
182            }
183
184            /// Constructs a failure that declares its own stable category.
185            #[must_use]
186            pub const fn with_category(category: FailureCategory) -> Self {
187                Self { category }
188            }
189
190            /// Classifies an arbitrary user error without retaining its
191            /// payload or display text.
192            #[must_use]
193            pub fn from_error(error: impl Error + Send + Sync + 'static) -> Self {
194                drop(error);
195                Self::new()
196            }
197
198            /// Returns the stable category supplied by the adapter.
199            #[must_use]
200            pub const fn category(self) -> FailureCategory {
201                self.category
202            }
203        }
204
205        impl Default for $name {
206            fn default() -> Self {
207                Self::new()
208            }
209        }
210
211        impl fmt::Display for $name {
212            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
213                formatter.write_str($message)
214            }
215        }
216
217        impl Error for $name {}
218    };
219}
220
221stream_error!(StreamOpenError, "item stream open failed");
222stream_error!(StreamUpdateError, "item stream update failed");
223stream_error!(StreamCloseError, "item stream close failed");
224
225/// One namespaced, durably checkpointed component-state participant.
226///
227/// # Lifecycle
228///
229/// - [`open`](Self::open) executes before any reader, processor, or writer
230///   call in the step attempt. It receives only the last *committed*
231///   envelope for this stream's namespace, or `None` for initial execution.
232///   A required restoration failure here prevents every component
233///   invocation in the attempt.
234/// - [`update`](Self::update) runs once per committing chunk attempt, after
235///   the writer has accepted the chunk and before the durable commit -- never
236///   per item. Its result becomes authoritative only if the containing chunk
237///   transaction commits; a rollback leaves the previously committed
238///   envelope authoritative, and an update failure prevents the candidate
239///   commit.
240/// - [`close`](Self::close) runs once per step attempt for every stream that
241///   opened successfully, regardless of how the attempt ends. A close
242///   failure never suppresses another stream's close attempt and never
243///   erases an earlier primary failure or already-committed chunks.
244///
245/// # Restartability
246///
247/// A stream backed by a [`crate::ComponentStateCodec`] whose
248/// [`crate::RestartabilityDeclaration`] is
249/// [`NotRestartable`](crate::RestartabilityDeclaration::NotRestartable)
250/// makes its owning step unable to claim restartability, independent of
251/// whether the step's reader checkpoint is itself restartable.
252///
253/// # Sensitivity
254///
255/// Committed and candidate envelopes redact their payload unconditionally in
256/// `Debug`/`Display` (see [`ComponentStateEnvelope`]); an implementation must
257/// not reintroduce the raw payload into an error, log, or trace.
258#[diagnostic::on_unimplemented(
259    message = "`{Self}` is not an OxideBatch `ItemStream`",
260    label = "this component cannot participate in the ItemStream contract",
261    note = "implement `ItemStream` with `async fn open`, `async fn update`, and `async fn close`",
262    note = "the returned futures must be `Send`: do not hold a non-`Send` value across an await"
263)]
264pub trait ItemStream: Send + Sync {
265    /// Restores last committed state, or begins initial execution.
266    ///
267    /// # Errors
268    ///
269    /// Returns [`StreamOpenError`] when required restoration fails. No
270    /// reader, processor, or writer call starts for this step attempt when
271    /// any registered stream's `open` fails.
272    fn open<'a>(
273        &'a self,
274        context: StreamOpenContext<'a>,
275    ) -> impl Future<Output = Result<StreamOpenOutcome, StreamOpenError>> + Send + 'a;
276
277    /// Produces the candidate component-state envelope for the chunk about
278    /// to commit.
279    ///
280    /// # Errors
281    ///
282    /// Returns [`StreamUpdateError`] when the candidate cannot be prepared.
283    /// This prevents the containing chunk's candidate commit.
284    fn update<'a>(
285        &'a self,
286        context: StreamUpdateContext<'a>,
287    ) -> impl Future<Output = Result<ComponentStateEnvelope, StreamUpdateError>> + Send + 'a;
288
289    /// Runs once for every stream that opened successfully in this step
290    /// attempt.
291    ///
292    /// # Errors
293    ///
294    /// Returns [`StreamCloseError`] when close fails. A close failure never
295    /// skips the close attempt on another already-opened stream, and never
296    /// erases an earlier primary failure or already-committed chunks.
297    fn close<'a>(
298        &'a self,
299        context: StreamCloseContext<'a>,
300    ) -> impl Future<Output = Result<StreamCloseOutcome, StreamCloseError>> + Send + 'a;
301}
302
303/// The dyn-compatible mirror of the public `ItemStream` contract.
304///
305/// Nothing here is exported. Its only implementor is the blanket impl below,
306/// so no external crate can observe or depend on this shape, and the single
307/// `Box::pin` per call is the only boxing this erasure boundary introduces --
308/// exactly the discipline ADR-0008 fixes for the item reader/processor/writer
309/// contract.
310mod sealed {
311    use super::{
312        ItemStream, StreamCloseContext, StreamCloseError, StreamCloseOutcome, StreamOpenContext,
313        StreamOpenError, StreamOpenOutcome, StreamUpdateContext, StreamUpdateError,
314    };
315    use crate::{BoxFuture, ComponentStateEnvelope};
316
317    pub trait StreamObject: Send + Sync {
318        fn open_boxed<'a>(
319            &'a self,
320            context: StreamOpenContext<'a>,
321        ) -> BoxFuture<'a, Result<StreamOpenOutcome, StreamOpenError>>;
322
323        fn update_boxed<'a>(
324            &'a self,
325            context: StreamUpdateContext<'a>,
326        ) -> BoxFuture<'a, Result<ComponentStateEnvelope, StreamUpdateError>>;
327
328        fn close_boxed<'a>(
329            &'a self,
330            context: StreamCloseContext<'a>,
331        ) -> BoxFuture<'a, Result<StreamCloseOutcome, StreamCloseError>>;
332    }
333
334    impl<S: ItemStream> StreamObject for S {
335        fn open_boxed<'a>(
336            &'a self,
337            context: StreamOpenContext<'a>,
338        ) -> BoxFuture<'a, Result<StreamOpenOutcome, StreamOpenError>> {
339            Box::pin(self.open(context))
340        }
341
342        fn update_boxed<'a>(
343            &'a self,
344            context: StreamUpdateContext<'a>,
345        ) -> BoxFuture<'a, Result<ComponentStateEnvelope, StreamUpdateError>> {
346            Box::pin(self.update(context))
347        }
348
349        fn close_boxed<'a>(
350            &'a self,
351            context: StreamCloseContext<'a>,
352        ) -> BoxFuture<'a, Result<StreamCloseOutcome, StreamCloseError>> {
353            Box::pin(self.close(context))
354        }
355    }
356}
357
358/// A component-state stream of any concrete type, behind one dynamic
359/// dispatch.
360///
361/// Constructing one is the explicit, greppable point where a registered
362/// stream stops being monomorphized. Erasure does not change the stream's
363/// namespace, definition identity, checkpoint selection, or restart
364/// semantics: representation is never restart-relevant (ADR-0008).
365pub struct BoxedStream(Box<dyn sealed::StreamObject>);
366
367impl BoxedStream {
368    /// Erases a concrete stream.
369    pub fn new<S: ItemStream + 'static>(stream: S) -> Self {
370        Self(Box::new(stream))
371    }
372}
373
374impl ItemStream for BoxedStream {
375    fn open<'a>(
376        &'a self,
377        context: StreamOpenContext<'a>,
378    ) -> impl Future<Output = Result<StreamOpenOutcome, StreamOpenError>> + Send + 'a {
379        self.0.open_boxed(context)
380    }
381
382    fn update<'a>(
383        &'a self,
384        context: StreamUpdateContext<'a>,
385    ) -> impl Future<Output = Result<ComponentStateEnvelope, StreamUpdateError>> + Send + 'a {
386        self.0.update_boxed(context)
387    }
388
389    fn close<'a>(
390        &'a self,
391        context: StreamCloseContext<'a>,
392    ) -> impl Future<Output = Result<StreamCloseOutcome, StreamCloseError>> + Send + 'a {
393        self.0.close_boxed(context)
394    }
395}