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}