Skip to main content

cano/task/
stream.rs

1//! # StreamTask — A Genuine Stream-Processing Model
2//!
3//! A [`StreamTask`] consumes an `impl Stream` **continuously**, processes each item, and
4//! flushes per-[`StreamWindow`] window — memory is bounded by the window (a
5//! [`Count`](StreamWindow::Count) window buffers at most that many outputs; a
6//! [`Duration`](StreamWindow::Duration) window buffers whatever the source yields during
7//! the interval) and downstream sees progress before the source ends. It terminates in
8//! one of three ways:
9//!
10//! - **Exhausted** — the source returns `None`: the partial window is flushed and
11//!   [`on_close`](StreamTask::on_close)`(Exhausted)` chooses the next state.
12//! - **Stop** — [`flush_window`](StreamTask::flush_window) returns [`WindowSignal::Stop`]:
13//!   transition to that result.
14//! - **Cancelled** — the workflow's [`CancellationToken`](crate::cancel::CancellationToken)
15//!   fires: cooperative drain — the in-flight window is flushed, its cursor is committed,
16//!   `on_close(Cancelled)` runs for cleanup (its returned state is *ignored*), and the run
17//!   ends as [`CanoError::Cancelled`](crate::error::CanoError::Cancelled) so a later
18//!   [`resume_from`](crate::workflow::Workflow::resume_from) continues from the committed
19//!   cursor. Cancel means "stop cleanly + resumable", not "transition onward". If the
20//!   drain flush itself fails, `on_close(Cancelled)` still runs (best-effort) and the
21//!   flush error is surfaced instead of `Cancelled`. Cancellation is observed **between
22//!   items** — set [`attempt_timeout`](crate::task::TaskConfig) to bound cancel latency
23//!   when `process_item` can hang. A workflow total timeout
24//!   ([`with_total_timeout`](crate::workflow::Workflow::with_total_timeout)) is delivered
25//!   through this same drain and surfaces as
26//!   [`CanoError::WorkflowTimeout`](crate::error::CanoError::WorkflowTimeout).
27//!
28//! ## Per-item error handling
29//!
30//! [`StreamErrorPolicy`] controls what happens when [`process_item`](StreamTask::process_item)
31//! fails:
32//!
33//! - **`FailFast`** — propagate the first error (default).
34//! - **`SkipAndContinue`** — drop the bad item and keep consuming (useful for poison
35//!   messages in queues or corrupt records in logs).
36//! - **`RetryOnError { max_errors }`** — tolerate up to `max_errors` *consecutive* errors
37//!   before failing; the counter resets on every success.
38//!
39//! ## Batch vs. stream
40//!
41//! This is **not** [`BatchTask`](crate::task::batch::BatchTask). Batch loads a *bounded*
42//! `Vec`, processes all of it, and aggregates **once** at the end — O(N) memory, one
43//! emission, requires the data to end. `StreamTask` is for *unbounded* / continuous
44//! sources (Kafka, SSE, file-tail, WebSocket): incremental per-window emission, bounded
45//! memory, runs until stopped, and **resumable** from a persisted cursor.
46//!
47//! ## Cursor persistence & resume
48//!
49//! Register with [`Workflow::register_stream`](crate::workflow::Workflow::register_stream)
50//! and attach a [`CheckpointStore`](crate::recovery::CheckpointStore) + a workflow id: the
51//! engine persists the cursor returned by the **last item of each flushed window** (as a
52//! [`RowKind::StepCursor`](crate::recovery::RowKind::StepCursor) row), and a resumed run
53//! re-opens the source from that position. Registering via plain
54//! [`Workflow::register`](crate::workflow::Workflow::register) runs the in-memory loop
55//! with **no** persistence and **no** cancellation — the companion `Task` path is for
56//! convenience / tests only.
57//!
58//! ## Idempotency (at-least-once)
59//!
60//! The FSM writes the state-entry checkpoint *before* running the task, so a resumed run
61//! re-enters the state and calls [`open`](StreamTask::open) again from the last committed
62//! cursor. The window *after* that cursor may be partially processed then replayed —
63//! [`open`](StreamTask::open), [`process_item`](StreamTask::process_item), and
64//! [`on_close`](StreamTask::on_close) **must be idempotent**. `config` defaults to
65//! [`TaskConfig::minimal()`] (no outer retry) because an outer retry would re-invoke
66//! `open()` and re-consume the stream; only [`attempt_timeout`](crate::task::TaskConfig)
67//! is honored — as a per-[`process_item`](StreamTask::process_item) bound.
68
69use crate::cancel::CancellationToken;
70use crate::error::CanoError;
71use crate::resource::Resources;
72use crate::task::{TaskConfig, TaskResult};
73use futures_util::Stream;
74use serde::Serialize;
75use serde::de::DeserializeOwned;
76use std::borrow::Cow;
77use std::fmt;
78use std::future::Future;
79use std::hash::Hash;
80use std::pin::Pin;
81use std::sync::Arc;
82
83// ---------------------------------------------------------------------------
84// Value types
85// ---------------------------------------------------------------------------
86
87/// Controls how the per-item windowed loop responds when
88/// [`process_item`](StreamTask::process_item) returns an [`Err`]. Modelled on
89/// [`PollErrorPolicy`](crate::task::poll::PollErrorPolicy), with an extra
90/// [`SkipAndContinue`](StreamErrorPolicy::SkipAndContinue) for poison-message handling.
91#[derive(Debug, Clone, PartialEq, Eq, Default)]
92pub enum StreamErrorPolicy {
93    /// Propagate the first item error — the loop stops and the run fails.
94    #[default]
95    FailFast,
96    /// Log/observe the bad item, drop it, and keep consuming. The skipped item's
97    /// cursor is not committed (the next good item advances it).
98    SkipAndContinue,
99    /// Tolerate up to `max_errors` **consecutive** item errors before failing. The
100    /// counter resets on every successfully processed item.
101    RetryOnError {
102        /// Maximum number of consecutive item errors before the loop fails.
103        max_errors: u32,
104    },
105}
106
107/// Tumbling-window trigger: how often [`flush_window`](StreamTask::flush_window) fires and
108/// how much the driver buffers. Defaults to per-item ([`Count(1)`](StreamWindow::Count));
109/// larger windows amortise flush + checkpoint cost.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum StreamWindow {
112    /// Flush after this many successfully processed items (clamped to a minimum of 1).
113    /// The cursor committed for this window is the `Cursor` of the last item processed
114    /// in the batch.
115    Count(usize),
116    /// Flush after this much wall-clock elapses, tumbling — clamped to a minimum of 1ms
117    /// (a zero duration would otherwise starve the source; see the clamp in the driver).
118    /// Empty windows are skipped (no [`flush_window`](StreamTask::flush_window) call) so
119    /// an idle source does not emit spurious empty flushes. When the window finally
120    /// contains items, the cursor committed is the `Cursor` of the last item processed
121    /// during that interval. Unlike [`Count`](StreamWindow::Count), buffering is bounded
122    /// only by what the source yields during the interval.
123    Duration(std::time::Duration),
124}
125
126/// Alias for [`StreamWindow::Count`] — emphasises the batching semantics.
127pub type StreamBatch = StreamWindow;
128
129/// The result of one [`flush_window`](StreamTask::flush_window) call.
130#[derive(Debug)]
131pub enum WindowSignal<TState> {
132    /// Keep consuming the stream.
133    Continue,
134    /// Stop and transition the FSM to this result. The driver commits the window's
135    /// cursor first.
136    Stop(TaskResult<TState>),
137}
138
139/// Why the consume loop is ending — passed to [`on_close`](StreamTask::on_close).
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum CloseReason {
142    /// The source stream returned `None`.
143    Exhausted,
144    /// The workflow's [`CancellationToken`](crate::cancel::CancellationToken) fired
145    /// (cooperative shutdown). The in-flight partial window was flushed first.
146    Cancelled,
147}
148
149// ---------------------------------------------------------------------------
150// StreamTask trait
151// ---------------------------------------------------------------------------
152
153/// A genuine stream-processing model: consume an `impl Stream` continuously, flush per
154/// window, run until cancelled/exhausted, and resume from a persisted cursor.
155///
156/// # Generic Types
157///
158/// - **`TState`**: The workflow state enum (`Clone + Debug + Send + Sync`).
159/// - **`TResourceKey`**: The resource-lookup key type (defaults to [`Cow<'static, str>`]).
160///
161/// # Associated Types
162///
163/// - **`Item`**: one element pulled from the source stream.
164/// - **`Output`**: the per-item result accumulated into a window.
165/// - **`Cursor`**: the resumable position; `Serialize + DeserializeOwned + Send + Sync + 'static`.
166///
167/// Prefer the inherent `#[task::stream(state = S)]` form, which infers `Item` from
168/// `process_item`'s owned `item` parameter and `Output` / `Cursor` from the `Ok` tuple of
169/// its return type.
170#[crate::task::stream]
171pub trait StreamTask<TState, TResourceKey = Cow<'static, str>>: Send + Sync
172where
173    TState: Clone + fmt::Debug + Send + Sync + 'static,
174    TResourceKey: Hash + Eq + Send + Sync + 'static,
175{
176    /// One element pulled from the source stream.
177    type Item: Send + 'static;
178    /// The per-item result accumulated into a window.
179    type Output: Send + 'static;
180    /// The resumable position, persisted as a cursor for crash-resume.
181    type Cursor: Serialize + DeserializeOwned + Send + Sync + 'static;
182
183    /// Windowing policy. Defaults to [`StreamWindow::Count(1)`] (flush per item).
184    fn window(&self) -> StreamWindow {
185        StreamWindow::Count(1)
186    }
187
188    /// Per-item error policy. Defaults to [`StreamErrorPolicy::FailFast`].
189    fn on_item_error(&self) -> StreamErrorPolicy {
190        StreamErrorPolicy::FailFast
191    }
192
193    /// Task configuration. Defaults to [`TaskConfig::minimal()`].
194    ///
195    /// Only [`attempt_timeout`](crate::task::TaskConfig) is applied — as a bound on each
196    /// [`process_item`](StreamTask::process_item) call (a timeout becomes an item error
197    /// governed by [`on_item_error`](StreamTask::on_item_error)). Because cancellation is
198    /// observed between items, `attempt_timeout` is also what bounds cancel latency when
199    /// `process_item` can hang. **Outer retry (`max_attempts`) is intentionally not
200    /// applied**: it would re-invoke [`open`](StreamTask::open) and re-consume the stream.
201    /// The per-item error policy, the `CancellationToken`, and the window loop are the
202    /// resilience surface.
203    fn config(&self) -> TaskConfig {
204        TaskConfig::minimal()
205    }
206
207    /// Human-readable identifier, reported to
208    /// [`WorkflowObserver`](crate::observer::WorkflowObserver) hooks.
209    fn name(&self) -> Cow<'static, str> {
210        Cow::Borrowed(std::any::type_name::<Self>())
211    }
212
213    /// Open (or resume) the source stream. `cursor` is the last committed position, or
214    /// `None` on a fresh run. Must be idempotent (see the module docs).
215    async fn open(
216        &self,
217        res: &Resources<TResourceKey>,
218        cursor: Option<Self::Cursor>,
219    ) -> Result<Pin<Box<dyn Stream<Item = Self::Item> + Send>>, CanoError>;
220
221    /// Process one item; return its output and the cursor reached by consuming it (the
222    /// position to commit once this item's window flushes).
223    async fn process_item(
224        &self,
225        res: &Resources<TResourceKey>,
226        item: Self::Item,
227    ) -> Result<(Self::Output, Self::Cursor), CanoError>;
228
229    /// Flush one full window: commit side effects, then decide whether to continue or
230    /// stop. The driver persists the window's cursor (the `Cursor` of the last item in
231    /// the window) **after** this method returns — if `Stop` is returned, the cursor is
232    /// committed and the FSM transitions; if `Continue` is returned, the cursor is
233    /// committed and consumption continues.
234    async fn flush_window(
235        &self,
236        res: &Resources<TResourceKey>,
237        outputs: Vec<Self::Output>,
238    ) -> Result<WindowSignal<TState>, CanoError>;
239
240    /// Close hook, called after the in-flight partial window has been flushed.
241    ///
242    /// - [`CloseReason::Exhausted`]: the returned [`TaskResult`] is the **next state**.
243    /// - [`CloseReason::Cancelled`]: a **cleanup** hook — the returned `TaskResult` is
244    ///   **ignored** and the run ends as
245    ///   [`CanoError::Cancelled`](crate::error::CanoError::Cancelled) (an `Err` returned
246    ///   here *is* propagated). Use it to release resources / commit final offsets. It
247    ///   runs even when the cancel-drain flush fails — best-effort: in that case its own
248    ///   error is dropped and the flush error is surfaced.
249    ///
250    /// **At-least-once:** like [`open`](StreamTask::open) / [`process_item`](StreamTask::process_item),
251    /// `on_close` runs once per run but may be **re-invoked on crash-resume** (a crash
252    /// between `on_close` and the cursor commit replays the boundary window). It **must be
253    /// idempotent** — e.g. committing final offsets here must tolerate a repeat.
254    ///
255    /// **Panic safety:** when registered via [`Workflow::register_stream`] (engine-driven
256    /// path), panics in `on_close` are caught and converted to [`CanoError`]. When
257    /// registered via plain [`Workflow::register`] (in-memory companion), panics propagate.
258    /// Always use `register_stream` for production workloads.
259    async fn on_close(
260        &self,
261        res: &Resources<TResourceKey>,
262        reason: CloseReason,
263    ) -> Result<TaskResult<TState>, CanoError>;
264
265    /// Drive the in-memory windowed loop (no cursor persistence, no cancellation). Used by
266    /// the macro-synthesised `impl Task::run` so a `StreamTask` can be
267    /// [`register`](crate::workflow::Workflow::register)ed like any task. The durable,
268    /// cancellable path is [`Workflow::register_stream`](crate::workflow::Workflow::register_stream).
269    ///
270    /// Written as a hand-desugared `fn` (not `async fn`) so no `for<'async_trait>` binder
271    /// is introduced; it returns the future produced by the crate-private driver.
272    #[doc(hidden)]
273    fn run_in_memory<'life0, 'life1, 'async_trait>(
274        &'life0 self,
275        res: &'life1 Resources<TResourceKey>,
276    ) -> Pin<Box<dyn Future<Output = Result<TaskResult<TState>, CanoError>> + Send + 'async_trait>>
277    where
278        'life0: 'async_trait,
279        'life1: 'async_trait,
280        Self: Sync + 'async_trait + Sized,
281    {
282        Box::pin(run_stream_in_memory(self, res))
283    }
284}
285
286// ---------------------------------------------------------------------------
287// drive_window — the single per-window loop body (shared by both drivers)
288// ---------------------------------------------------------------------------
289
290/// One window of consumption: returned per [`flush_window`](StreamTask::flush_window) or
291/// per terminal close. The cursor is the concrete `Cursor` of the last item in the window.
292pub(crate) enum WindowStep<TCursor, TState> {
293    /// A full window was flushed and the task asked to continue. Commit `cursor`.
294    Window { cursor: TCursor },
295    /// Natural termination: the stream ended or a window returned `Stop`. Commit
296    /// `final_cursor` (if any), then transition to `result`.
297    Done {
298        final_cursor: Option<TCursor>,
299        result: TaskResult<TState>,
300    },
301    /// The run was cancelled: the in-flight window was flushed and `on_close` ran for
302    /// cleanup. Commit `final_cursor` (if any), then end as
303    /// [`CanoError::Cancelled`](crate::error::CanoError::Cancelled) so
304    /// [`resume_from`](crate::workflow::Workflow::resume_from) continues from this position.
305    Cancelled { final_cursor: Option<TCursor> },
306}
307
308/// Floor applied to [`StreamWindow::Duration`] deadlines. A zero (or otherwise
309/// near-zero) duration would make the tick arm of the `select!` below always-ready,
310/// starving `stream.next()` in a busy-loop that never makes progress. 1ms is far
311/// below any realistic window and only matters for a misconfigured zero/near-zero
312/// duration.
313const MIN_DURATION_WINDOW: std::time::Duration = std::time::Duration::from_millis(1);
314
315/// Pull and process items until one window flushes (or the loop terminates). Shared by the
316/// in-memory companion and the engine-driven session — there is exactly one loop body.
317#[allow(clippy::too_many_arguments)]
318async fn drive_window<T, S, K>(
319    task: &T,
320    res: &Resources<K>,
321    stream: &mut Pin<Box<dyn Stream<Item = T::Item> + Send>>,
322    consecutive_errors: &mut u32,
323    window: &StreamWindow,
324    policy: &StreamErrorPolicy,
325    attempt_timeout: Option<std::time::Duration>,
326    token: &CancellationToken,
327) -> Result<WindowStep<T::Cursor, S>, CanoError>
328where
329    T: StreamTask<S, K> + ?Sized,
330    S: Clone + fmt::Debug + Send + Sync + 'static,
331    K: Hash + Eq + Send + Sync + 'static,
332{
333    use futures_util::StreamExt as _;
334
335    // One match: exactly one of these is `Some`. The duration is clamped once so the
336    // initial deadline and every re-arm agree.
337    let (count_limit, duration_len) = match window {
338        StreamWindow::Count(n) => (Some((*n).max(1)), None),
339        StreamWindow::Duration(d) => (None, Some((*d).max(MIN_DURATION_WINDOW))),
340    };
341    let mut deadline = duration_len.map(|len| tokio::time::Instant::now() + len);
342
343    let mut buf: Vec<T::Output> = Vec::new();
344    let mut last_cursor: Option<T::Cursor> = None;
345
346    loop {
347        // Count-window flush.
348        if let Some(limit) = count_limit
349            && buf.len() >= limit
350        {
351            return flush_full_window(task, res, std::mem::take(&mut buf), last_cursor).await;
352        }
353
354        // Resolves at the duration-window deadline, or never (count windows).
355        let tick = async {
356            match deadline {
357                Some(d) => tokio::time::sleep_until(d).await,
358                None => std::future::pending::<()>().await,
359            }
360        };
361
362        tokio::select! {
363            biased;
364            _ = token.cancelled() => {
365                // On cancel, flush the partial window (if any) and run `on_close` for
366                // cleanup; the run ends as `Cancelled` (resumable). `WindowSignal` from the
367                // cancel-drain flush is ignored — honouring `Stop` here would contradict
368                // that a cancelled run always surfaces `CanoError::Cancelled`.
369                if !buf.is_empty() {
370                    #[cfg(feature = "metrics")]
371                    crate::metrics::stream_window();
372
373                    let drain_flush = task.flush_window(res, std::mem::take(&mut buf)).await;
374                    if let Err(e) = drain_flush {
375                        // The drain flush failed: `on_close(Cancelled)` still gets its
376                        // cleanup shot (best-effort — its own error is dropped in favour
377                        // of the flush error), then the flush error is surfaced. The
378                        // window's cursor is NOT committed, so a resume replays the
379                        // window (at-least-once).
380                        let _ = task.on_close(res, CloseReason::Cancelled).await;
381                        return Err(e);
382                    }
383                }
384                // `on_close(Cancelled)` is a cleanup hook; its returned state is ignored —
385                // a cancelled run ends as `CanoError::Cancelled` (an `Err` it returns IS
386                // propagated). Resume continues from the committed `final_cursor`.
387                let _ = task.on_close(res, CloseReason::Cancelled).await?;
388                return Ok(WindowStep::Cancelled { final_cursor: last_cursor });
389            }
390            _ = tick => {
391                // Duration window elapsed.
392                if buf.is_empty() {
393                    // Empty tumbling window: re-arm the deadline and keep waiting.
394                    deadline = duration_len.map(|len| tokio::time::Instant::now() + len);
395                    continue;
396                }
397                return flush_full_window(task, res, std::mem::take(&mut buf), last_cursor).await;
398            }
399            item = stream.next() => {
400                match item {
401                    Some(item) => {
402                        // Bound a single `process_item` by `config().attempt_timeout` when set
403                        // (a hung source item is the realistic failure mode). A timeout becomes
404                        // an ordinary item error governed by `on_item_error()` below. Outer
405                        // retry (`max_attempts`) is intentionally NOT applied — the per-item
406                        // policy + the loop are the resilience surface.
407                        let processed = match attempt_timeout {
408                            Some(d) => match tokio::time::timeout(d, task.process_item(res, item)).await {
409                                Ok(inner) => inner,
410                                Err(_elapsed) => Err(CanoError::timeout(
411                                    "stream process_item exceeded attempt_timeout",
412                                )),
413                            },
414                            None => task.process_item(res, item).await,
415                        };
416                        match processed {
417                            Ok((out, cursor)) => {
418                                *consecutive_errors = 0;
419                                buf.push(out);
420                                last_cursor = Some(cursor);
421                                #[cfg(feature = "metrics")]
422                                crate::metrics::stream_items(1, 0);
423                            }
424                            Err(e) => {
425                                #[cfg(feature = "metrics")]
426                                crate::metrics::stream_items(0, 1);
427                                match policy {
428                                    StreamErrorPolicy::FailFast => return Err(e),
429                                    StreamErrorPolicy::SkipAndContinue => {}
430                                    StreamErrorPolicy::RetryOnError { max_errors } => {
431                                        *consecutive_errors += 1;
432                                        if *consecutive_errors > *max_errors {
433                                            return Err(e);
434                                        }
435                                    }
436                                }
437                            }
438                        }
439                    }
440                    None => {
441                        // Source exhausted: flush the final partial window. Honor a `Stop`
442                        // here (transition to it) just like a full window; on `Continue`
443                        // fall through to `on_close(Exhausted)` for the terminal transition.
444                        if !buf.is_empty() {
445                            #[cfg(feature = "metrics")]
446                            crate::metrics::stream_window();
447                            match task.flush_window(res, std::mem::take(&mut buf)).await? {
448                                WindowSignal::Stop(result) => {
449                                    return Ok(WindowStep::Done {
450                                        final_cursor: last_cursor,
451                                        result,
452                                    });
453                                }
454                                WindowSignal::Continue => {}
455                            }
456                        }
457                        let result = task.on_close(res, CloseReason::Exhausted).await?;
458                        return Ok(WindowStep::Done { final_cursor: last_cursor, result });
459                    }
460                }
461            }
462        }
463    }
464}
465
466/// Flush one non-empty window and map the task's [`WindowSignal`] onto the terminal
467/// [`WindowStep`]. Shared by the count-window and duration-window flush arms of
468/// [`drive_window`]: both commit the window's cursor either way — `Continue` carries it
469/// forward, `Stop` commits it before the FSM transitions.
470///
471/// Deliberately *not* used by the cancel drain (which ignores `Stop`, because a cancelled
472/// run always surfaces `CanoError::Cancelled`) nor by the exhausted path (where `Continue`
473/// falls through to `on_close(Exhausted)`); those arms have different signal semantics.
474async fn flush_full_window<T, S, K>(
475    task: &T,
476    res: &Resources<K>,
477    outputs: Vec<T::Output>,
478    last_cursor: Option<T::Cursor>,
479) -> Result<WindowStep<T::Cursor, S>, CanoError>
480where
481    T: StreamTask<S, K> + ?Sized,
482    S: Clone + fmt::Debug + Send + Sync + 'static,
483    K: Hash + Eq + Send + Sync + 'static,
484{
485    #[cfg(feature = "metrics")]
486    crate::metrics::stream_window();
487    Ok(match task.flush_window(res, outputs).await? {
488        WindowSignal::Continue => WindowStep::Window {
489            cursor: last_cursor.expect("a non-empty window always has a cursor"),
490        },
491        WindowSignal::Stop(result) => WindowStep::Done {
492            final_cursor: last_cursor,
493            result,
494        },
495    })
496}
497
498/// In-memory companion loop: drive windows with a disabled token (no cancellation), no
499/// cursor persistence. Backs [`StreamTask::run_in_memory`].
500async fn run_stream_in_memory<T, S, K>(
501    task: &T,
502    res: &Resources<K>,
503) -> Result<TaskResult<S>, CanoError>
504where
505    T: StreamTask<S, K> + ?Sized,
506    S: Clone + fmt::Debug + Send + Sync + 'static,
507    K: Hash + Eq + Send + Sync + 'static,
508{
509    let token = CancellationToken::disabled();
510    let window = task.window();
511    let policy = task.on_item_error();
512    let attempt_timeout = task.config().attempt_timeout;
513    let mut consecutive_errors: u32 = 0;
514
515    let result: Result<TaskResult<S>, CanoError> = async {
516        let mut stream = task.open(res, None).await?;
517        loop {
518            match drive_window(
519                task,
520                res,
521                &mut stream,
522                &mut consecutive_errors,
523                &window,
524                &policy,
525                attempt_timeout,
526                &token,
527            )
528            .await?
529            {
530                WindowStep::Window { .. } => continue,
531                WindowStep::Done { result, .. } => return Ok(result),
532                // Unreachable: the in-memory companion drives with a disabled token.
533                WindowStep::Cancelled { .. } => return Err(CanoError::cancelled()),
534            }
535        }
536    }
537    .await;
538
539    // The in-memory companion uses a disabled token, so it never cancels.
540    #[cfg(feature = "metrics")]
541    crate::metrics::stream_run(if result.is_ok() {
542        "completed"
543    } else {
544        "failed"
545    });
546    result
547}
548
549// ---------------------------------------------------------------------------
550// Type-erased infrastructure (for StateEntry::Stream / register_stream)
551// ---------------------------------------------------------------------------
552
553/// One erased window step: serialized cursor bytes in place of the concrete `Cursor`.
554pub enum ErasedWindowStep<TState> {
555    /// A full window flushed; persist `cursor` and continue.
556    Window { cursor: Vec<u8> },
557    /// Natural termination: persist `final_cursor` (if any) then transition to `result`.
558    Done {
559        final_cursor: Option<Vec<u8>>,
560        result: TaskResult<TState>,
561    },
562    /// Cancelled: persist `final_cursor` (if any) then end as `CanoError::Cancelled`.
563    Cancelled { final_cursor: Option<Vec<u8>> },
564}
565
566/// Future returned by [`ErasedStreamSession::next_window`].
567pub type WindowFuture<'a, TState> =
568    Pin<Box<dyn Future<Output = Result<ErasedWindowStep<TState>, CanoError>> + Send + 'a>>;
569
570/// Object-safe view of an opened stream session. The engine advances it one window at a
571/// time, persisting the returned cursor between windows.
572pub trait ErasedStreamSession<TState, TResourceKey>: Send
573where
574    TState: Clone + Send + Sync + 'static,
575    TResourceKey: Hash + Eq + Send + Sync + 'static,
576{
577    /// Consume until one window flushes (or the loop terminates).
578    fn next_window<'a>(
579        &'a mut self,
580        res: &'a Resources<TResourceKey>,
581        token: &'a CancellationToken,
582    ) -> WindowFuture<'a, TState>;
583}
584
585/// Future returned by [`ErasedStreamTask::open_session`].
586pub type OpenSessionFuture<'a, TState, TResourceKey> = Pin<
587    Box<
588        dyn Future<Output = Result<Box<dyn ErasedStreamSession<TState, TResourceKey>>, CanoError>>
589            + Send
590            + 'a,
591    >,
592>;
593
594/// Object-safe, type-erased view of a [`StreamTask`] for the engine's
595/// [`StateEntry::Stream`](crate::workflow::execution::StateEntry) path.
596pub trait ErasedStreamTask<TState, TResourceKey>: Send + Sync
597where
598    TState: Clone + Send + Sync + 'static,
599    TResourceKey: Hash + Eq + Send + Sync + 'static,
600{
601    fn name(&self) -> Cow<'static, str>;
602    /// Open (or resume) the source from `cursor_bytes`, returning a driven session.
603    /// `attempt_timeout` (from the registered `config()`) bounds each `process_item`.
604    fn open_session<'a>(
605        &'a self,
606        res: &'a Resources<TResourceKey>,
607        cursor_bytes: Option<Vec<u8>>,
608        attempt_timeout: Option<std::time::Duration>,
609    ) -> OpenSessionFuture<'a, TState, TResourceKey>;
610}
611
612/// An opened, concretely-typed stream session: owns the task handle + the stream + the
613/// per-stream error counter. Holds the single windowed loop body.
614struct StreamSession<T, S, K>
615where
616    T: StreamTask<S, K> + 'static,
617    S: Clone + fmt::Debug + Send + Sync + 'static,
618    K: Hash + Eq + Send + Sync + 'static,
619{
620    task: Arc<T>,
621    stream: Pin<Box<dyn Stream<Item = T::Item> + Send>>,
622    window: StreamWindow,
623    policy: StreamErrorPolicy,
624    attempt_timeout: Option<std::time::Duration>,
625    consecutive_errors: u32,
626}
627
628impl<T, S, K> ErasedStreamSession<S, K> for StreamSession<T, S, K>
629where
630    T: StreamTask<S, K> + 'static,
631    S: Clone + fmt::Debug + Send + Sync + 'static,
632    K: Hash + Eq + Send + Sync + 'static,
633{
634    fn next_window<'a>(
635        &'a mut self,
636        res: &'a Resources<K>,
637        token: &'a CancellationToken,
638    ) -> WindowFuture<'a, S> {
639        Box::pin(async move {
640            let step = drive_window(
641                &*self.task,
642                res,
643                &mut self.stream,
644                &mut self.consecutive_errors,
645                &self.window,
646                &self.policy,
647                self.attempt_timeout,
648                token,
649            )
650            .await?;
651            Ok(match step {
652                WindowStep::Window { cursor } => ErasedWindowStep::Window {
653                    cursor: encode_cursor(&cursor, &self.task.name())?,
654                },
655                WindowStep::Done {
656                    final_cursor,
657                    result,
658                } => ErasedWindowStep::Done {
659                    final_cursor: final_cursor
660                        .map(|c| encode_cursor(&c, &self.task.name()))
661                        .transpose()?,
662                    result,
663                },
664                WindowStep::Cancelled { final_cursor } => ErasedWindowStep::Cancelled {
665                    final_cursor: final_cursor
666                        .map(|c| encode_cursor(&c, &self.task.name()))
667                        .transpose()?,
668                },
669            })
670        })
671    }
672}
673
674/// Bridges a concrete [`StreamTask`] to the object-safe [`ErasedStreamTask`]. Handles
675/// `serde_json` cursor (de)serialization so the engine only sees `Vec<u8>`.
676pub(crate) struct StreamAdapter<T>(pub Arc<T>);
677
678impl<TState, TResourceKey, T> ErasedStreamTask<TState, TResourceKey> for StreamAdapter<T>
679where
680    TState: Clone + fmt::Debug + Send + Sync + 'static,
681    TResourceKey: Hash + Eq + Send + Sync + 'static,
682    T: StreamTask<TState, TResourceKey> + 'static,
683{
684    fn name(&self) -> Cow<'static, str> {
685        self.0.name()
686    }
687    fn open_session<'a>(
688        &'a self,
689        res: &'a Resources<TResourceKey>,
690        cursor_bytes: Option<Vec<u8>>,
691        attempt_timeout: Option<std::time::Duration>,
692    ) -> OpenSessionFuture<'a, TState, TResourceKey> {
693        Box::pin(async move {
694            let cursor: Option<T::Cursor> = match cursor_bytes {
695                None => None,
696                Some(ref b) => Some(serde_json::from_slice(b).map_err(|e| {
697                    CanoError::task_execution(format!(
698                        "deserialize stream cursor for `{}`: {e}",
699                        self.0.name()
700                    ))
701                })?),
702            };
703            let stream = self.0.open(res, cursor).await?;
704            let session = StreamSession {
705                task: Arc::clone(&self.0),
706                stream,
707                window: self.0.window(),
708                policy: self.0.on_item_error(),
709                attempt_timeout,
710                consecutive_errors: 0,
711            };
712            Ok(Box::new(session) as Box<dyn ErasedStreamSession<TState, TResourceKey>>)
713        })
714    }
715}
716
717fn encode_cursor<C: Serialize>(cursor: &C, task_name: &str) -> Result<Vec<u8>, CanoError> {
718    serde_json::to_vec(cursor).map_err(|e| {
719        CanoError::task_execution(format!("serialize stream cursor for `{task_name}`: {e}"))
720    })
721}
722
723// ---------------------------------------------------------------------------
724// Tests
725// ---------------------------------------------------------------------------
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730    use crate::task;
731    use crate::task::Task;
732    use futures_util::stream;
733    use std::sync::Mutex;
734
735    #[derive(Debug, Clone, PartialEq, Eq, Hash)]
736    enum Step {
737        Consume,
738        Done,
739    }
740
741    #[test]
742    fn value_type_defaults() {
743        assert_eq!(StreamErrorPolicy::default(), StreamErrorPolicy::FailFast);
744        let _ = StreamWindow::Count(8);
745        let _ = StreamWindow::Duration(std::time::Duration::from_millis(5));
746        assert_eq!(CloseReason::Exhausted, CloseReason::Exhausted);
747    }
748
749    // Note: in-crate impls use the trait-impl form (`impl StreamTask<S> for T`); the
750    // inherent form emits `::cano::` paths that don't resolve inside this crate. The
751    // inherent form is exercised in `cano-macros/tests/stream_task_impl.rs`.
752
753    #[derive(Default)]
754    struct Collector {
755        seen: Mutex<Vec<u32>>,
756        windows: Mutex<Vec<Vec<u32>>>,
757    }
758
759    #[task::stream]
760    impl StreamTask<Step> for Collector {
761        type Item = u32;
762        type Output = u32;
763        type Cursor = u64;
764
765        fn window(&self) -> StreamWindow {
766            StreamWindow::Count(2)
767        }
768
769        async fn open(
770            &self,
771            _res: &Resources,
772            _cursor: Option<u64>,
773        ) -> Result<Pin<Box<dyn Stream<Item = u32> + Send>>, CanoError> {
774            Ok(Box::pin(stream::iter(vec![10u32, 20, 30, 40, 50]))
775                as Pin<Box<dyn Stream<Item = u32> + Send>>)
776        }
777
778        async fn process_item(&self, _res: &Resources, item: u32) -> Result<(u32, u64), CanoError> {
779            self.seen.lock().unwrap().push(item);
780            Ok((item * 2, item as u64))
781        }
782
783        async fn flush_window(
784            &self,
785            _res: &Resources,
786            outputs: Vec<u32>,
787        ) -> Result<WindowSignal<Step>, CanoError> {
788            self.windows.lock().unwrap().push(outputs);
789            Ok(WindowSignal::Continue)
790        }
791
792        async fn on_close(
793            &self,
794            _res: &Resources,
795            _reason: CloseReason,
796        ) -> Result<TaskResult<Step>, CanoError> {
797            Ok(TaskResult::Single(Step::Done))
798        }
799    }
800
801    #[tokio::test]
802    async fn in_memory_windows_and_order() {
803        let task = Collector::default();
804        let res = Resources::new();
805        let result = Task::run(&task, &res).await.unwrap();
806        assert_eq!(result, TaskResult::Single(Step::Done));
807        assert_eq!(*task.seen.lock().unwrap(), vec![10, 20, 30, 40, 50]);
808        // Count(2): windows [20,40], [60,80], then the partial [100] flushed on close.
809        assert_eq!(
810            *task.windows.lock().unwrap(),
811            vec![vec![20u32, 40], vec![60, 80], vec![100]]
812        );
813    }
814
815    struct FailOnSecond {
816        policy: StreamErrorPolicy,
817        flushed: Mutex<Vec<u32>>,
818    }
819
820    #[task::stream]
821    impl StreamTask<Step> for FailOnSecond {
822        type Item = u32;
823        type Output = u32;
824        type Cursor = u64;
825
826        fn on_item_error(&self) -> StreamErrorPolicy {
827            self.policy.clone()
828        }
829
830        async fn open(
831            &self,
832            _res: &Resources,
833            _cursor: Option<u64>,
834        ) -> Result<Pin<Box<dyn Stream<Item = u32> + Send>>, CanoError> {
835            Ok(Box::pin(stream::iter(vec![1u32, 2, 3])) as Pin<Box<dyn Stream<Item = u32> + Send>>)
836        }
837
838        async fn process_item(&self, _res: &Resources, item: u32) -> Result<(u32, u64), CanoError> {
839            if item == 2 {
840                Err(CanoError::task_execution("item 2 failed"))
841            } else {
842                Ok((item, item as u64))
843            }
844        }
845
846        async fn flush_window(
847            &self,
848            _res: &Resources,
849            outputs: Vec<u32>,
850        ) -> Result<WindowSignal<Step>, CanoError> {
851            self.flushed.lock().unwrap().extend(outputs);
852            Ok(WindowSignal::Continue)
853        }
854
855        async fn on_close(
856            &self,
857            _res: &Resources,
858            _reason: CloseReason,
859        ) -> Result<TaskResult<Step>, CanoError> {
860            Ok(TaskResult::Single(Step::Done))
861        }
862    }
863
864    #[tokio::test]
865    async fn fail_fast_propagates() {
866        let task = FailOnSecond {
867            policy: StreamErrorPolicy::FailFast,
868            flushed: Mutex::new(Vec::new()),
869        };
870        let res = Resources::new();
871        let err = Task::run(&task, &res).await.unwrap_err();
872        assert!(matches!(err, CanoError::TaskExecution(_)));
873    }
874
875    #[tokio::test]
876    async fn skip_and_continue_drops_bad_item() {
877        let task = FailOnSecond {
878            policy: StreamErrorPolicy::SkipAndContinue,
879            flushed: Mutex::new(Vec::new()),
880        };
881        let res = Resources::new();
882        let result = Task::run(&task, &res).await.unwrap();
883        assert_eq!(result, TaskResult::Single(Step::Done));
884        // item 2 dropped; 1 and 3 survive.
885        assert_eq!(*task.flushed.lock().unwrap(), vec![1u32, 3]);
886    }
887
888    struct StopAfterFirst;
889
890    #[task::stream]
891    impl StreamTask<Step> for StopAfterFirst {
892        type Item = u32;
893        type Output = u32;
894        type Cursor = u64;
895
896        async fn open(
897            &self,
898            _res: &Resources,
899            _cursor: Option<u64>,
900        ) -> Result<Pin<Box<dyn Stream<Item = u32> + Send>>, CanoError> {
901            Ok(Box::pin(stream::iter(vec![1u32, 2, 3, 4]))
902                as Pin<Box<dyn Stream<Item = u32> + Send>>)
903        }
904
905        async fn process_item(&self, _res: &Resources, item: u32) -> Result<(u32, u64), CanoError> {
906            Ok((item, item as u64))
907        }
908
909        async fn flush_window(
910            &self,
911            _res: &Resources,
912            _outputs: Vec<u32>,
913        ) -> Result<WindowSignal<Step>, CanoError> {
914            // Window is Count(1); stop after the very first window.
915            Ok(WindowSignal::Stop(TaskResult::Single(Step::Done)))
916        }
917
918        async fn on_close(
919            &self,
920            _res: &Resources,
921            _reason: CloseReason,
922        ) -> Result<TaskResult<Step>, CanoError> {
923            panic!("on_close must not run when a window returns Stop");
924        }
925    }
926
927    #[tokio::test]
928    async fn window_stop_short_circuits() {
929        let res = Resources::new();
930        let result = Task::run(&StopAfterFirst, &res).await.unwrap();
931        assert_eq!(result, TaskResult::Single(Step::Done));
932    }
933
934    #[tokio::test]
935    async fn integrates_with_workflow_via_register() {
936        use crate::cancel::CancellationToken;
937        use crate::workflow::Workflow;
938
939        let workflow = Workflow::bare()
940            .register(Step::Consume, Collector::default())
941            .add_exit_state(Step::Done);
942        let result = workflow
943            .orchestrate(Step::Consume, CancellationToken::disabled())
944            .await
945            .unwrap();
946        assert_eq!(result, Step::Done);
947    }
948
949    // -----------------------------------------------------------------------
950    // Engine-path tests: register_stream + cancellation + cursor persistence
951    // -----------------------------------------------------------------------
952
953    use std::collections::HashMap;
954    use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
955
956    /// Minimal in-memory `CheckpointStore` for the resume test. `committed` records every
957    /// appended `StepCursor` blob at append time, so cursor assertions survive the log
958    /// `clear` that a *successfully completed* run performs.
959    #[derive(Default)]
960    struct InMemoryStore {
961        rows: Mutex<HashMap<String, Vec<crate::recovery::CheckpointRow>>>,
962        committed: Mutex<Vec<Vec<u8>>>,
963    }
964
965    #[crate::checkpoint_store]
966    impl crate::recovery::CheckpointStore for InMemoryStore {
967        async fn append(
968            &self,
969            workflow_id: &str,
970            row: crate::recovery::CheckpointRow,
971        ) -> Result<(), CanoError> {
972            if row.kind == crate::recovery::RowKind::StepCursor
973                && let Some(blob) = &row.output_blob
974            {
975                self.committed.lock().unwrap().push(blob.clone());
976            }
977            let mut g = self.rows.lock().unwrap();
978            let v = g.entry(workflow_id.to_string()).or_default();
979            if v.iter().any(|r| r.sequence == row.sequence) {
980                return Err(CanoError::checkpoint_store("duplicate sequence"));
981            }
982            v.push(row);
983            Ok(())
984        }
985        async fn load_run(
986            &self,
987            workflow_id: &str,
988        ) -> Result<Vec<crate::recovery::CheckpointRow>, CanoError> {
989            let g = self.rows.lock().unwrap();
990            let mut v = g.get(workflow_id).cloned().unwrap_or_default();
991            v.sort_by_key(|r| r.sequence);
992            Ok(v)
993        }
994        async fn clear(&self, workflow_id: &str) -> Result<(), CanoError> {
995            self.rows.lock().unwrap().remove(workflow_id);
996            Ok(())
997        }
998    }
999
1000    struct Forever {
1001        closed_cancelled: Arc<AtomicBool>,
1002        flushed_windows: Arc<AtomicU32>,
1003    }
1004
1005    #[task::stream]
1006    impl StreamTask<Step> for Forever {
1007        type Item = u64;
1008        type Output = u64;
1009        type Cursor = u64;
1010
1011        fn window(&self) -> StreamWindow {
1012            StreamWindow::Count(2)
1013        }
1014
1015        async fn open(
1016            &self,
1017            _res: &Resources,
1018            _cursor: Option<u64>,
1019        ) -> Result<Pin<Box<dyn Stream<Item = u64> + Send>>, CanoError> {
1020            // Effectively infinite source.
1021            Ok(Box::pin(stream::iter(0u64..)) as Pin<Box<dyn Stream<Item = u64> + Send>>)
1022        }
1023
1024        async fn process_item(&self, _res: &Resources, item: u64) -> Result<(u64, u64), CanoError> {
1025            tokio::time::sleep(std::time::Duration::from_millis(2)).await;
1026            Ok((item, item))
1027        }
1028
1029        async fn flush_window(
1030            &self,
1031            _res: &Resources,
1032            _outputs: Vec<u64>,
1033        ) -> Result<WindowSignal<Step>, CanoError> {
1034            self.flushed_windows.fetch_add(1, Ordering::SeqCst);
1035            Ok(WindowSignal::Continue)
1036        }
1037
1038        async fn on_close(
1039            &self,
1040            _res: &Resources,
1041            reason: CloseReason,
1042        ) -> Result<TaskResult<Step>, CanoError> {
1043            if reason == CloseReason::Cancelled {
1044                self.closed_cancelled.store(true, Ordering::SeqCst);
1045            }
1046            Ok(TaskResult::Single(Step::Done))
1047        }
1048    }
1049
1050    #[tokio::test]
1051    async fn cancel_drains_and_surfaces_cancelled() {
1052        use crate::cancel::CancellationToken;
1053        use crate::workflow::Workflow;
1054
1055        let closed = Arc::new(AtomicBool::new(false));
1056        let task = Forever {
1057            closed_cancelled: Arc::clone(&closed),
1058            flushed_windows: Arc::new(AtomicU32::new(0)),
1059        };
1060        let (handle, token) = CancellationToken::new();
1061        let workflow = Workflow::bare()
1062            .register_stream(Step::Consume, task)
1063            .add_exit_state(Step::Done);
1064
1065        tokio::spawn(async move {
1066            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
1067            handle.cancel();
1068        });
1069
1070        let result = workflow.orchestrate(Step::Consume, token).await;
1071        assert!(
1072            matches!(&result, Err(e) if e.category() == "cancelled"),
1073            "a cancelled stream must surface as cancelled, got {result:?}"
1074        );
1075        assert!(
1076            closed.load(Ordering::SeqCst),
1077            "on_close(Cancelled) must run (cooperative drain reached the close hook)"
1078        );
1079    }
1080
1081    struct Resumable {
1082        opened: Arc<Mutex<Vec<Option<u64>>>>,
1083        processed: Arc<Mutex<Vec<u64>>>,
1084        fail_third: Arc<AtomicBool>,
1085    }
1086
1087    #[task::stream]
1088    impl StreamTask<Step> for Resumable {
1089        type Item = u64;
1090        type Output = u64;
1091        type Cursor = u64;
1092
1093        fn window(&self) -> StreamWindow {
1094            StreamWindow::Count(2)
1095        }
1096
1097        async fn open(
1098            &self,
1099            _res: &Resources,
1100            cursor: Option<u64>,
1101        ) -> Result<Pin<Box<dyn Stream<Item = u64> + Send>>, CanoError> {
1102            self.opened.lock().unwrap().push(cursor);
1103            let start = cursor.map(|c| c + 1).unwrap_or(1);
1104            let items: Vec<u64> = (start..=6).collect();
1105            Ok(Box::pin(stream::iter(items)) as Pin<Box<dyn Stream<Item = u64> + Send>>)
1106        }
1107
1108        async fn process_item(&self, _res: &Resources, item: u64) -> Result<(u64, u64), CanoError> {
1109            self.processed.lock().unwrap().push(item);
1110            Ok((item, item)) // cursor == item id
1111        }
1112
1113        async fn flush_window(
1114            &self,
1115            _res: &Resources,
1116            outputs: Vec<u64>,
1117        ) -> Result<WindowSignal<Step>, CanoError> {
1118            // Simulate a crash flushing the [5,6] window — only on the first run.
1119            if outputs == vec![5u64, 6] && self.fail_third.swap(false, Ordering::SeqCst) {
1120                return Err(CanoError::task_execution("simulated crash in window [5,6]"));
1121            }
1122            Ok(WindowSignal::Continue)
1123        }
1124
1125        async fn on_close(
1126            &self,
1127            _res: &Resources,
1128            _reason: CloseReason,
1129        ) -> Result<TaskResult<Step>, CanoError> {
1130            Ok(TaskResult::Single(Step::Done))
1131        }
1132    }
1133
1134    #[tokio::test]
1135    async fn persists_cursor_and_resumes() {
1136        use crate::cancel::CancellationToken;
1137        use crate::recovery::{CheckpointStore, RowKind};
1138        use crate::workflow::Workflow;
1139
1140        let opened = Arc::new(Mutex::new(Vec::new()));
1141        let processed = Arc::new(Mutex::new(Vec::new()));
1142        let task = Resumable {
1143            opened: Arc::clone(&opened),
1144            processed: Arc::clone(&processed),
1145            fail_third: Arc::new(AtomicBool::new(true)),
1146        };
1147        let store = Arc::new(InMemoryStore::default());
1148        let workflow = Workflow::bare()
1149            .register_stream(Step::Consume, task)
1150            .add_exit_state(Step::Done)
1151            .with_checkpoint_store(store.clone())
1152            .with_workflow_id("resume-test");
1153
1154        // Run 1: fails flushing window [5,6].
1155        let r1 = workflow
1156            .orchestrate(Step::Consume, CancellationToken::disabled())
1157            .await;
1158        assert!(r1.is_err(), "run 1 should fail mid-stream: {r1:?}");
1159
1160        // Windows [1,2] (cursor 2) and [3,4] (cursor 4) committed; [5,6] failed.
1161        let rows = store.load_run("resume-test").await.unwrap();
1162        let cursors: Vec<u64> = rows
1163            .iter()
1164            .filter(|r| r.kind == RowKind::StepCursor)
1165            .map(|r| serde_json::from_slice::<u64>(r.output_blob.as_ref().unwrap()).unwrap())
1166            .collect();
1167        assert_eq!(
1168            cursors,
1169            vec![2, 4],
1170            "only fully-flushed windows commit a cursor"
1171        );
1172
1173        // Resume: re-open at cursor 4 and finish [5,6].
1174        let r2 = workflow
1175            .resume_from("resume-test", CancellationToken::disabled())
1176            .await
1177            .unwrap();
1178        assert_eq!(r2, Step::Done);
1179
1180        assert_eq!(*opened.lock().unwrap(), vec![None, Some(4)]);
1181        assert_eq!(
1182            *processed.lock().unwrap(),
1183            vec![1u64, 2, 3, 4, 5, 6, 5, 6],
1184            "resume reprocesses only the items after the committed cursor"
1185        );
1186    }
1187
1188    // -----------------------------------------------------------------------
1189    // Fix 1: WindowSignal::Stop from the terminal (exhaustion) partial flush is honored.
1190    // -----------------------------------------------------------------------
1191
1192    #[derive(Debug, Clone, PartialEq, Eq, Hash)]
1193    enum S3 {
1194        Consume,
1195        ViaStop,
1196        ViaClose,
1197    }
1198
1199    struct StopOnFinalWindow;
1200
1201    #[task::stream]
1202    impl StreamTask<S3> for StopOnFinalWindow {
1203        type Item = u32;
1204        type Output = u32;
1205        type Cursor = u64;
1206
1207        fn window(&self) -> StreamWindow {
1208            StreamWindow::Count(3) // never fills for a 2-item stream → terminal partial flush
1209        }
1210
1211        async fn open(
1212            &self,
1213            _res: &Resources,
1214            _cursor: Option<u64>,
1215        ) -> Result<Pin<Box<dyn Stream<Item = u32> + Send>>, CanoError> {
1216            Ok(Box::pin(stream::iter(vec![1u32, 2])) as Pin<Box<dyn Stream<Item = u32> + Send>>)
1217        }
1218
1219        async fn process_item(&self, _res: &Resources, item: u32) -> Result<(u32, u64), CanoError> {
1220            Ok((item, item as u64))
1221        }
1222
1223        async fn flush_window(
1224            &self,
1225            _res: &Resources,
1226            _outputs: Vec<u32>,
1227        ) -> Result<WindowSignal<S3>, CanoError> {
1228            Ok(WindowSignal::Stop(TaskResult::Single(S3::ViaStop)))
1229        }
1230
1231        async fn on_close(
1232            &self,
1233            _res: &Resources,
1234            _reason: CloseReason,
1235        ) -> Result<TaskResult<S3>, CanoError> {
1236            // Must NOT run: the terminal partial flush returned Stop.
1237            Ok(TaskResult::Single(S3::ViaClose))
1238        }
1239    }
1240
1241    #[tokio::test]
1242    async fn terminal_flush_stop_is_honored() {
1243        use crate::cancel::CancellationToken;
1244        use crate::workflow::Workflow;
1245
1246        let workflow = Workflow::bare()
1247            .register_stream(S3::Consume, StopOnFinalWindow)
1248            .add_exit_states([S3::ViaStop, S3::ViaClose]);
1249        let result = workflow
1250            .orchestrate(S3::Consume, CancellationToken::disabled())
1251            .await
1252            .unwrap();
1253        assert_eq!(
1254            result,
1255            S3::ViaStop,
1256            "a Stop from the final partial flush must win over on_close(Exhausted)"
1257        );
1258    }
1259
1260    // -----------------------------------------------------------------------
1261    // Fix 2: cooperative cancel fires on_cancelled exactly once.
1262    // -----------------------------------------------------------------------
1263
1264    #[derive(Default)]
1265    struct CancelCounter {
1266        cancels: AtomicU32,
1267    }
1268
1269    impl crate::observer::WorkflowObserver for CancelCounter {
1270        fn on_cancelled(&self, _state: &str) {
1271            self.cancels.fetch_add(1, Ordering::SeqCst);
1272        }
1273    }
1274
1275    #[tokio::test]
1276    async fn cancel_fires_on_cancelled_once() {
1277        use crate::cancel::CancellationToken;
1278        use crate::workflow::Workflow;
1279
1280        let task = Forever {
1281            closed_cancelled: Arc::new(AtomicBool::new(false)),
1282            flushed_windows: Arc::new(AtomicU32::new(0)),
1283        };
1284        let counter = Arc::new(CancelCounter::default());
1285        let (handle, token) = CancellationToken::new();
1286        let workflow = Workflow::bare()
1287            .register_stream(Step::Consume, task)
1288            .add_exit_state(Step::Done)
1289            .with_observer(counter.clone());
1290
1291        tokio::spawn(async move {
1292            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
1293            handle.cancel();
1294        });
1295
1296        let result = workflow.orchestrate(Step::Consume, token).await;
1297        assert!(matches!(&result, Err(e) if e.category() == "cancelled"));
1298        assert_eq!(
1299            counter.cancels.load(Ordering::SeqCst),
1300            1,
1301            "on_cancelled must fire exactly once on a stream cancel"
1302        );
1303    }
1304
1305    // -----------------------------------------------------------------------
1306    // Fix: StreamWindow::Duration(ZERO) must not busy-loop and starve the source.
1307    // -----------------------------------------------------------------------
1308
1309    struct ZeroWindow {
1310        seen: Arc<parking_lot::Mutex<Vec<u32>>>,
1311    }
1312
1313    #[task::stream]
1314    impl StreamTask<Step> for ZeroWindow {
1315        type Item = u32;
1316        type Output = u32;
1317        type Cursor = u64;
1318
1319        fn window(&self) -> StreamWindow {
1320            StreamWindow::Duration(std::time::Duration::ZERO)
1321        }
1322
1323        async fn open(
1324            &self,
1325            _res: &Resources,
1326            _cursor: Option<u64>,
1327        ) -> Result<Pin<Box<dyn Stream<Item = u32> + Send>>, CanoError> {
1328            Ok(Box::pin(stream::iter(vec![1u32, 2, 3])) as Pin<Box<dyn Stream<Item = u32> + Send>>)
1329        }
1330
1331        async fn process_item(&self, _res: &Resources, item: u32) -> Result<(u32, u64), CanoError> {
1332            self.seen.lock().push(item);
1333            Ok((item, item as u64))
1334        }
1335
1336        async fn flush_window(
1337            &self,
1338            _res: &Resources,
1339            _outputs: Vec<u32>,
1340        ) -> Result<WindowSignal<Step>, CanoError> {
1341            Ok(WindowSignal::Continue)
1342        }
1343
1344        async fn on_close(
1345            &self,
1346            _res: &Resources,
1347            _reason: CloseReason,
1348        ) -> Result<TaskResult<Step>, CanoError> {
1349            Ok(TaskResult::Single(Step::Done))
1350        }
1351    }
1352
1353    #[tokio::test]
1354    async fn duration_window_zero_is_clamped_not_livelocked() {
1355        use crate::cancel::CancellationToken;
1356        use crate::workflow::Workflow;
1357
1358        let seen = Arc::new(parking_lot::Mutex::new(Vec::new()));
1359        let workflow = Workflow::bare()
1360            .register_stream(Step::Consume, ZeroWindow { seen: seen.clone() })
1361            .add_exit_state(Step::Done);
1362
1363        // A zero-duration window's tick arm would otherwise always be ready in the
1364        // `select!`, starving `stream.next()` in a busy loop that never makes progress.
1365        // The driver clamps it to `MIN_DURATION_WINDOW`; bound the wait so a livelock
1366        // regression fails the test instead of hanging the suite.
1367        let result = tokio::time::timeout(
1368            std::time::Duration::from_secs(5),
1369            workflow.orchestrate(Step::Consume, CancellationToken::disabled()),
1370        )
1371        .await
1372        .expect("must not livelock on a zero-duration window");
1373
1374        assert!(matches!(result, Ok(Step::Done)), "got {result:?}");
1375        assert_eq!(&*seen.lock(), &[1, 2, 3]);
1376    }
1377
1378    // -----------------------------------------------------------------------
1379    // Fix: a cancel-drain flush error must still run on_close(Cancelled) before
1380    // surfacing (best-effort cleanup), rather than skipping straight past it.
1381    // -----------------------------------------------------------------------
1382
1383    struct FlushFailsOnDrain {
1384        on_close_reason: Arc<parking_lot::Mutex<Option<CloseReason>>>,
1385    }
1386
1387    #[task::stream]
1388    impl StreamTask<Step> for FlushFailsOnDrain {
1389        type Item = u64;
1390        type Output = u64;
1391        type Cursor = u64;
1392
1393        fn window(&self) -> StreamWindow {
1394            // Large enough that the count-window never flushes on its own — only the
1395            // cancel-drain (with a non-empty partial buffer) calls `flush_window`.
1396            StreamWindow::Count(1000)
1397        }
1398
1399        async fn open(
1400            &self,
1401            _res: &Resources,
1402            _cursor: Option<u64>,
1403        ) -> Result<Pin<Box<dyn Stream<Item = u64> + Send>>, CanoError> {
1404            Ok(Box::pin(stream::unfold(0u64, |n| async move {
1405                tokio::time::sleep(std::time::Duration::from_millis(2)).await;
1406                Some((n, n + 1))
1407            })) as Pin<Box<dyn Stream<Item = u64> + Send>>)
1408        }
1409
1410        async fn process_item(&self, _res: &Resources, item: u64) -> Result<(u64, u64), CanoError> {
1411            Ok((item, item))
1412        }
1413
1414        async fn flush_window(
1415            &self,
1416            _res: &Resources,
1417            _outputs: Vec<u64>,
1418        ) -> Result<WindowSignal<Step>, CanoError> {
1419            Err(CanoError::task_execution("flush failed during drain"))
1420        }
1421
1422        async fn on_close(
1423            &self,
1424            _res: &Resources,
1425            reason: CloseReason,
1426        ) -> Result<TaskResult<Step>, CanoError> {
1427            *self.on_close_reason.lock() = Some(reason);
1428            Ok(TaskResult::Single(Step::Done))
1429        }
1430    }
1431
1432    #[tokio::test]
1433    async fn cancel_drain_flush_error_still_runs_on_close() {
1434        use crate::cancel::CancellationToken;
1435        use crate::workflow::Workflow;
1436
1437        let on_close_reason = Arc::new(parking_lot::Mutex::new(None));
1438        let task = FlushFailsOnDrain {
1439            on_close_reason: on_close_reason.clone(),
1440        };
1441        let (handle, token) = CancellationToken::new();
1442        let workflow = Workflow::bare()
1443            .register_stream(Step::Consume, task)
1444            .add_exit_state(Step::Done);
1445
1446        tokio::spawn(async move {
1447            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
1448            handle.cancel();
1449        });
1450
1451        let result = workflow.orchestrate(Step::Consume, token).await;
1452        assert!(
1453            matches!(&result, Err(e) if e.category() == "task_execution"),
1454            "the drain flush error must be surfaced, not swallowed into `Cancelled`; got \
1455             {result:?}"
1456        );
1457        assert_eq!(
1458            *on_close_reason.lock(),
1459            Some(CloseReason::Cancelled),
1460            "on_close(Cancelled) must still run as best-effort cleanup even though the \
1461             drain flush failed"
1462        );
1463    }
1464
1465    // -----------------------------------------------------------------------
1466    // Fix: `with_total_timeout` must be honoured for a `Stream` state — folded into
1467    // the same cooperative drain as a real cancel, then reclassified as
1468    // `WorkflowTimeout` rather than `Cancelled`.
1469    // -----------------------------------------------------------------------
1470
1471    #[tokio::test]
1472    async fn total_timeout_flushes_commits_and_reclassifies() {
1473        use crate::cancel::CancellationToken;
1474        use crate::workflow::Workflow;
1475
1476        let task = Forever {
1477            closed_cancelled: Arc::new(AtomicBool::new(false)),
1478            flushed_windows: Arc::new(AtomicU32::new(0)),
1479        };
1480        let closed = Arc::clone(&task.closed_cancelled);
1481        let store = Arc::new(InMemoryStore::default());
1482        let workflow = Workflow::bare()
1483            .register_stream(Step::Consume, task)
1484            .add_exit_state(Step::Done)
1485            .with_checkpoint_store(store.clone())
1486            .with_workflow_id("total-timeout")
1487            .with_total_timeout(std::time::Duration::from_millis(30));
1488
1489        let result = tokio::time::timeout(
1490            std::time::Duration::from_secs(5),
1491            workflow.orchestrate(Step::Consume, CancellationToken::disabled()),
1492        )
1493        .await
1494        .expect("a Stream state must not silently defeat with_total_timeout");
1495
1496        assert!(
1497            matches!(&result, Err(e) if e.category() == "workflow_timeout"),
1498            "a total-timeout budget must be honoured for a Stream state, not silently \
1499             ignored; got {result:?}"
1500        );
1501        assert!(
1502            closed.load(Ordering::SeqCst),
1503            "on_close(Cancelled) must still run — the timeout is delivered through the \
1504             same cooperative drain as a real cancel"
1505        );
1506        assert!(
1507            !store.committed.lock().unwrap().is_empty(),
1508            "the in-flight window's cursor must be committed before the timeout surfaces"
1509        );
1510    }
1511
1512    // -----------------------------------------------------------------------
1513    // Fix: cancellation must be observable while `open()` is still in flight, not
1514    // just between windows.
1515    // -----------------------------------------------------------------------
1516
1517    struct HangingOpen {
1518        on_close_called: Arc<AtomicBool>,
1519    }
1520
1521    #[task::stream]
1522    impl StreamTask<Step> for HangingOpen {
1523        type Item = u64;
1524        type Output = u64;
1525        type Cursor = u64;
1526
1527        async fn open(
1528            &self,
1529            _res: &Resources,
1530            _cursor: Option<u64>,
1531        ) -> Result<Pin<Box<dyn Stream<Item = u64> + Send>>, CanoError> {
1532            // Never resolves on its own; only cancellation ends the run.
1533            std::future::pending::<()>().await;
1534            unreachable!("cancellation must interrupt a hung open() before this resolves")
1535        }
1536
1537        async fn process_item(&self, _res: &Resources, item: u64) -> Result<(u64, u64), CanoError> {
1538            Ok((item, item))
1539        }
1540
1541        async fn flush_window(
1542            &self,
1543            _res: &Resources,
1544            _outputs: Vec<u64>,
1545        ) -> Result<WindowSignal<Step>, CanoError> {
1546            Ok(WindowSignal::Continue)
1547        }
1548
1549        async fn on_close(
1550            &self,
1551            _res: &Resources,
1552            _reason: CloseReason,
1553        ) -> Result<TaskResult<Step>, CanoError> {
1554            self.on_close_called.store(true, Ordering::SeqCst);
1555            Ok(TaskResult::Single(Step::Done))
1556        }
1557    }
1558
1559    #[tokio::test]
1560    async fn cancel_during_open_session_is_cancellable_without_on_close() {
1561        use crate::cancel::CancellationToken;
1562        use crate::workflow::Workflow;
1563
1564        let on_close_called = Arc::new(AtomicBool::new(false));
1565        let (handle, token) = CancellationToken::new();
1566        let workflow = Workflow::bare()
1567            .register_stream(
1568                Step::Consume,
1569                HangingOpen {
1570                    on_close_called: on_close_called.clone(),
1571                },
1572            )
1573            .add_exit_state(Step::Done);
1574
1575        tokio::spawn(async move {
1576            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
1577            handle.cancel();
1578        });
1579
1580        let result = tokio::time::timeout(
1581            std::time::Duration::from_secs(5),
1582            workflow.orchestrate(Step::Consume, token),
1583        )
1584        .await
1585        .expect("a hung open() must still be cancellable");
1586
1587        assert!(
1588            matches!(&result, Err(e) if e.category() == "cancelled"),
1589            "got {result:?}"
1590        );
1591        assert!(
1592            !on_close_called.load(Ordering::SeqCst),
1593            "on_close must not run — open() never produced a session to close"
1594        );
1595    }
1596
1597    // -----------------------------------------------------------------------
1598    // Fix 4: config().attempt_timeout bounds each process_item.
1599    // -----------------------------------------------------------------------
1600
1601    struct SlowItem;
1602
1603    #[task::stream]
1604    impl StreamTask<Step> for SlowItem {
1605        type Item = u32;
1606        type Output = u32;
1607        type Cursor = u64;
1608
1609        fn config(&self) -> TaskConfig {
1610            TaskConfig::minimal().with_attempt_timeout(std::time::Duration::from_millis(10))
1611        }
1612
1613        async fn open(
1614            &self,
1615            _res: &Resources,
1616            _cursor: Option<u64>,
1617        ) -> Result<Pin<Box<dyn Stream<Item = u32> + Send>>, CanoError> {
1618            Ok(Box::pin(stream::iter(vec![1u32])) as Pin<Box<dyn Stream<Item = u32> + Send>>)
1619        }
1620
1621        async fn process_item(&self, _res: &Resources, item: u32) -> Result<(u32, u64), CanoError> {
1622            // Far longer than the 10ms attempt_timeout.
1623            tokio::time::sleep(std::time::Duration::from_secs(60)).await;
1624            Ok((item, item as u64))
1625        }
1626
1627        async fn flush_window(
1628            &self,
1629            _res: &Resources,
1630            _outputs: Vec<u32>,
1631        ) -> Result<WindowSignal<Step>, CanoError> {
1632            Ok(WindowSignal::Continue)
1633        }
1634
1635        async fn on_close(
1636            &self,
1637            _res: &Resources,
1638            _reason: CloseReason,
1639        ) -> Result<TaskResult<Step>, CanoError> {
1640            Ok(TaskResult::Single(Step::Done))
1641        }
1642    }
1643
1644    #[tokio::test]
1645    async fn attempt_timeout_bounds_process_item() {
1646        use crate::cancel::CancellationToken;
1647        use crate::workflow::Workflow;
1648
1649        let workflow = Workflow::bare()
1650            .register_stream(Step::Consume, SlowItem)
1651            .add_exit_state(Step::Done);
1652        // FailFast (default) → the timed-out item fails the run promptly.
1653        let result = tokio::time::timeout(
1654            std::time::Duration::from_secs(5),
1655            workflow.orchestrate(Step::Consume, CancellationToken::disabled()),
1656        )
1657        .await
1658        .expect("attempt_timeout must bound the hung process_item well under 5s");
1659        assert!(
1660            matches!(&result, Err(e) if e.category() == "timeout"),
1661            "a process_item exceeding attempt_timeout must surface a timeout error, got {result:?}"
1662        );
1663    }
1664
1665    // =======================================================================
1666    // Edge-case coverage (audited against drive_window + execute_stream_task).
1667    // Shared helpers first, then one section per behavioural dimension.
1668    // =======================================================================
1669
1670    /// A drop-safe channel-backed source. Polling `next()` borrows the receiver, so the
1671    /// driver's `select!` dropping the in-flight `next()` future (which happens every time a
1672    /// duration deadline wins the race) never loses a queued item — unlike
1673    /// `stream::unfold(rx, …)`, whose future *owns* the receiver and would close the channel
1674    /// on drop. This lets the duration-window tests feed items at controlled virtual times.
1675    struct RecvStream(tokio::sync::mpsc::UnboundedReceiver<u64>);
1676
1677    impl Stream for RecvStream {
1678        type Item = u64;
1679        fn poll_next(
1680            self: Pin<&mut Self>,
1681            cx: &mut std::task::Context<'_>,
1682        ) -> std::task::Poll<Option<u64>> {
1683            self.get_mut().0.poll_recv(cx)
1684        }
1685    }
1686
1687    /// The cursors (decoded as `u64`) committed during a run, in append order — captured at
1688    /// append time so they survive the log `clear` a completed run performs.
1689    fn step_cursors(store: &InMemoryStore) -> Vec<u64> {
1690        store
1691            .committed
1692            .lock()
1693            .unwrap()
1694            .iter()
1695            .map(|blob| serde_json::from_slice::<u64>(blob).unwrap())
1696            .collect()
1697    }
1698
1699    // -----------------------------------------------------------------------
1700    // Duration windowing — the entire tumbling-time path was undriven by tests.
1701    // -----------------------------------------------------------------------
1702
1703    /// A `Duration`-windowed source fed from a channel; records the contents of each flush.
1704    struct DurationSource {
1705        rx: Mutex<Option<tokio::sync::mpsc::UnboundedReceiver<u64>>>,
1706        windows: Arc<Mutex<Vec<Vec<u64>>>>,
1707    }
1708
1709    #[task::stream]
1710    impl StreamTask<Step> for DurationSource {
1711        type Item = u64;
1712        type Output = u64;
1713        type Cursor = u64;
1714
1715        fn window(&self) -> StreamWindow {
1716            StreamWindow::Duration(std::time::Duration::from_millis(50))
1717        }
1718
1719        async fn open(
1720            &self,
1721            _res: &Resources,
1722            _cursor: Option<u64>,
1723        ) -> Result<Pin<Box<dyn Stream<Item = u64> + Send>>, CanoError> {
1724            let rx = self.rx.lock().unwrap().take().expect("open called once");
1725            Ok(Box::pin(RecvStream(rx)) as Pin<Box<dyn Stream<Item = u64> + Send>>)
1726        }
1727
1728        async fn process_item(&self, _res: &Resources, item: u64) -> Result<(u64, u64), CanoError> {
1729            Ok((item, item))
1730        }
1731
1732        async fn flush_window(
1733            &self,
1734            _res: &Resources,
1735            outputs: Vec<u64>,
1736        ) -> Result<WindowSignal<Step>, CanoError> {
1737            self.windows.lock().unwrap().push(outputs);
1738            Ok(WindowSignal::Continue)
1739        }
1740
1741        async fn on_close(
1742            &self,
1743            _res: &Resources,
1744            _reason: CloseReason,
1745        ) -> Result<TaskResult<Step>, CanoError> {
1746            Ok(TaskResult::Single(Step::Done))
1747        }
1748    }
1749
1750    #[tokio::test(start_paused = true)]
1751    async fn duration_window_flushes_on_deadline_and_rearms() {
1752        use crate::cancel::CancellationToken;
1753        use crate::workflow::Workflow;
1754
1755        // 50ms windows; items arrive at +30ms each (1@30, 2@60, 3@90, 4@120) under paused
1756        // time. Deadlines tumble at 50/100/150ms → windows [1] (t=50), [2,3] (t=100), then
1757        // the channel closes at t=120 so [4] flushes as the terminal partial on exhaustion.
1758        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<u64>();
1759        let windows = Arc::new(Mutex::new(Vec::new()));
1760        let store = Arc::new(InMemoryStore::default());
1761        let task = DurationSource {
1762            rx: Mutex::new(Some(rx)),
1763            windows: Arc::clone(&windows),
1764        };
1765        let workflow = Workflow::bare()
1766            .register_stream(Step::Consume, task)
1767            .add_exit_state(Step::Done)
1768            .with_checkpoint_store(store.clone())
1769            .with_workflow_id("dur-rearm");
1770
1771        tokio::spawn(async move {
1772            for v in 1u64..=4 {
1773                tokio::time::sleep(std::time::Duration::from_millis(30)).await;
1774                let _ = tx.send(v);
1775            }
1776        });
1777
1778        let result = workflow
1779            .orchestrate(Step::Consume, CancellationToken::disabled())
1780            .await
1781            .unwrap();
1782        assert_eq!(result, Step::Done);
1783        assert_eq!(
1784            *windows.lock().unwrap(),
1785            vec![vec![1u64], vec![2, 3], vec![4]],
1786            "tumbling duration windows re-arm after each Continue flush"
1787        );
1788        assert_eq!(
1789            step_cursors(&store),
1790            vec![1u64, 3, 4],
1791            "each duration flush commits its last item's cursor"
1792        );
1793    }
1794
1795    #[tokio::test(start_paused = true)]
1796    async fn duration_window_skips_empty_intervals() {
1797        use crate::cancel::CancellationToken;
1798        use crate::workflow::Workflow;
1799
1800        // 50ms windows but the source is idle until +120ms. The deadlines at 50/100ms fire
1801        // with an empty buffer and must NOT emit a spurious flush — they re-arm and wait.
1802        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<u64>();
1803        let windows = Arc::new(Mutex::new(Vec::new()));
1804        let task = DurationSource {
1805            rx: Mutex::new(Some(rx)),
1806            windows: Arc::clone(&windows),
1807        };
1808        let workflow = Workflow::bare()
1809            .register_stream(Step::Consume, task)
1810            .add_exit_state(Step::Done);
1811
1812        tokio::spawn(async move {
1813            tokio::time::sleep(std::time::Duration::from_millis(120)).await;
1814            let _ = tx.send(1);
1815            // tx dropped here → channel closes, run exhausts.
1816        });
1817
1818        let result = workflow
1819            .orchestrate(Step::Consume, CancellationToken::disabled())
1820            .await
1821            .unwrap();
1822        assert_eq!(result, Step::Done);
1823        let w = windows.lock().unwrap();
1824        assert!(
1825            w.iter().all(|win| !win.is_empty()),
1826            "an idle duration window must never flush an empty buffer: {w:?}"
1827        );
1828        assert_eq!(
1829            *w,
1830            vec![vec![1u64]],
1831            "exactly one real window despite two elapsed-but-empty deadlines"
1832        );
1833    }
1834
1835    struct DurationStop {
1836        rx: Mutex<Option<tokio::sync::mpsc::UnboundedReceiver<u64>>>,
1837        on_close_ran: Arc<AtomicBool>,
1838    }
1839
1840    #[task::stream]
1841    impl StreamTask<S3> for DurationStop {
1842        type Item = u64;
1843        type Output = u64;
1844        type Cursor = u64;
1845
1846        fn window(&self) -> StreamWindow {
1847            StreamWindow::Duration(std::time::Duration::from_millis(50))
1848        }
1849
1850        async fn open(
1851            &self,
1852            _res: &Resources,
1853            _cursor: Option<u64>,
1854        ) -> Result<Pin<Box<dyn Stream<Item = u64> + Send>>, CanoError> {
1855            let rx = self.rx.lock().unwrap().take().expect("open called once");
1856            Ok(Box::pin(RecvStream(rx)) as Pin<Box<dyn Stream<Item = u64> + Send>>)
1857        }
1858
1859        async fn process_item(&self, _res: &Resources, item: u64) -> Result<(u64, u64), CanoError> {
1860            Ok((item, item))
1861        }
1862
1863        async fn flush_window(
1864            &self,
1865            _res: &Resources,
1866            _outputs: Vec<u64>,
1867        ) -> Result<WindowSignal<S3>, CanoError> {
1868            Ok(WindowSignal::Stop(TaskResult::Single(S3::ViaStop)))
1869        }
1870
1871        async fn on_close(
1872            &self,
1873            _res: &Resources,
1874            _reason: CloseReason,
1875        ) -> Result<TaskResult<S3>, CanoError> {
1876            self.on_close_ran.store(true, Ordering::SeqCst);
1877            Ok(TaskResult::Single(S3::ViaClose))
1878        }
1879    }
1880
1881    #[tokio::test(start_paused = true)]
1882    async fn duration_window_stop_transitions_without_close() {
1883        use crate::cancel::CancellationToken;
1884        use crate::workflow::Workflow;
1885
1886        // One item buffered, then the 50ms deadline fires and flush_window returns Stop:
1887        // the FSM must transition to that result, NOT fall through to on_close.
1888        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<u64>();
1889        let on_close_ran = Arc::new(AtomicBool::new(false));
1890        let task = DurationStop {
1891            rx: Mutex::new(Some(rx)),
1892            on_close_ran: Arc::clone(&on_close_ran),
1893        };
1894        let workflow = Workflow::bare()
1895            .register_stream(S3::Consume, task)
1896            .add_exit_states([S3::ViaStop, S3::ViaClose]);
1897
1898        tokio::spawn(async move {
1899            tokio::time::sleep(std::time::Duration::from_millis(30)).await;
1900            let _ = tx.send(1);
1901            // Hold the sender open past the deadline so the *duration* tick (not exhaustion)
1902            // drives the flush.
1903            tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
1904            drop(tx);
1905        });
1906
1907        let result = workflow
1908            .orchestrate(S3::Consume, CancellationToken::disabled())
1909            .await
1910            .unwrap();
1911        assert_eq!(
1912            result,
1913            S3::ViaStop,
1914            "a duration-window Stop wins over on_close"
1915        );
1916        assert!(
1917            !on_close_ran.load(Ordering::SeqCst),
1918            "on_close must not run when a duration window returns Stop"
1919        );
1920    }
1921
1922    // -----------------------------------------------------------------------
1923    // Per-item error policy: RetryOnError (previously untested) + timeout×policy.
1924    // -----------------------------------------------------------------------
1925
1926    /// Yields `1..=len`; `process_item` fails for any id in `fail`. Records flushed outputs.
1927    struct ScriptedErrors {
1928        len: u64,
1929        fail: Vec<u64>,
1930        policy: StreamErrorPolicy,
1931        flushed: Arc<Mutex<Vec<u64>>>,
1932    }
1933
1934    #[task::stream]
1935    impl StreamTask<Step> for ScriptedErrors {
1936        type Item = u64;
1937        type Output = u64;
1938        type Cursor = u64;
1939
1940        fn on_item_error(&self) -> StreamErrorPolicy {
1941            self.policy.clone()
1942        }
1943
1944        async fn open(
1945            &self,
1946            _res: &Resources,
1947            _cursor: Option<u64>,
1948        ) -> Result<Pin<Box<dyn Stream<Item = u64> + Send>>, CanoError> {
1949            let items: Vec<u64> = (1..=self.len).collect();
1950            Ok(Box::pin(stream::iter(items)) as Pin<Box<dyn Stream<Item = u64> + Send>>)
1951        }
1952
1953        async fn process_item(&self, _res: &Resources, item: u64) -> Result<(u64, u64), CanoError> {
1954            if self.fail.contains(&item) {
1955                Err(CanoError::task_execution(format!("item {item} failed")))
1956            } else {
1957                Ok((item, item))
1958            }
1959        }
1960
1961        async fn flush_window(
1962            &self,
1963            _res: &Resources,
1964            outputs: Vec<u64>,
1965        ) -> Result<WindowSignal<Step>, CanoError> {
1966            self.flushed.lock().unwrap().extend(outputs);
1967            Ok(WindowSignal::Continue)
1968        }
1969
1970        async fn on_close(
1971            &self,
1972            _res: &Resources,
1973            _reason: CloseReason,
1974        ) -> Result<TaskResult<Step>, CanoError> {
1975            Ok(TaskResult::Single(Step::Done))
1976        }
1977    }
1978
1979    #[tokio::test]
1980    async fn retry_on_error_tolerates_consecutive_within_max() {
1981        // max_errors=2; items 2 and 3 fail consecutively (count reaches 2, == max) then 4
1982        // succeeds → the run survives and the good items are flushed.
1983        let flushed = Arc::new(Mutex::new(Vec::new()));
1984        let task = ScriptedErrors {
1985            len: 4,
1986            fail: vec![2, 3],
1987            policy: StreamErrorPolicy::RetryOnError { max_errors: 2 },
1988            flushed: Arc::clone(&flushed),
1989        };
1990        let res = Resources::new();
1991        let result = Task::run(&task, &res).await.unwrap();
1992        assert_eq!(result, TaskResult::Single(Step::Done));
1993        assert_eq!(
1994            *flushed.lock().unwrap(),
1995            vec![1u64, 4],
1996            "only ok items flush"
1997        );
1998    }
1999
2000    #[tokio::test]
2001    async fn retry_on_error_fails_past_max() {
2002        // max_errors=1; two consecutive failures (count 1 then 2 > 1) fails the run.
2003        let task = ScriptedErrors {
2004            len: 3,
2005            fail: vec![2, 3],
2006            policy: StreamErrorPolicy::RetryOnError { max_errors: 1 },
2007            flushed: Arc::new(Mutex::new(Vec::new())),
2008        };
2009        let res = Resources::new();
2010        let err = Task::run(&task, &res).await.unwrap_err();
2011        assert_eq!(err.category(), "task_execution");
2012    }
2013
2014    #[tokio::test]
2015    async fn retry_on_error_counter_resets_on_success() {
2016        // max_errors=2; pattern ok,err,err,ok,err,err. Without the reset-on-success the 5th
2017        // item would push the count to 3 (>2) and fail; because item 4 resets it to 0, the
2018        // run completes — proving the tolerance is on *consecutive* errors only.
2019        let task = ScriptedErrors {
2020            len: 6,
2021            fail: vec![2, 3, 5, 6],
2022            policy: StreamErrorPolicy::RetryOnError { max_errors: 2 },
2023            flushed: Arc::new(Mutex::new(Vec::new())),
2024        };
2025        let res = Resources::new();
2026        let result = Task::run(&task, &res).await.unwrap();
2027        assert_eq!(result, TaskResult::Single(Step::Done));
2028    }
2029
2030    #[tokio::test]
2031    async fn retry_on_error_max_zero_fails_on_first() {
2032        // max_errors=0 behaves like FailFast: the first error (count 1 > 0) fails the run.
2033        let task = ScriptedErrors {
2034            len: 3,
2035            fail: vec![2],
2036            policy: StreamErrorPolicy::RetryOnError { max_errors: 0 },
2037            flushed: Arc::new(Mutex::new(Vec::new())),
2038        };
2039        let res = Resources::new();
2040        let err = Task::run(&task, &res).await.unwrap_err();
2041        assert_eq!(err.category(), "task_execution");
2042    }
2043
2044    /// `process_item` sleeps for `slow` items; an `attempt_timeout` turns that into a
2045    /// timeout item-error that the policy then governs.
2046    struct SlowUnderPolicy {
2047        policy: StreamErrorPolicy,
2048        flushed: Arc<Mutex<Vec<u64>>>,
2049    }
2050
2051    #[task::stream]
2052    impl StreamTask<Step> for SlowUnderPolicy {
2053        type Item = u64;
2054        type Output = u64;
2055        type Cursor = u64;
2056
2057        fn config(&self) -> TaskConfig {
2058            TaskConfig::minimal().with_attempt_timeout(std::time::Duration::from_millis(10))
2059        }
2060
2061        fn on_item_error(&self) -> StreamErrorPolicy {
2062            self.policy.clone()
2063        }
2064
2065        async fn open(
2066            &self,
2067            _res: &Resources,
2068            _cursor: Option<u64>,
2069        ) -> Result<Pin<Box<dyn Stream<Item = u64> + Send>>, CanoError> {
2070            Ok(Box::pin(stream::iter(vec![1u64, 2, 3])) as Pin<Box<dyn Stream<Item = u64> + Send>>)
2071        }
2072
2073        async fn process_item(&self, _res: &Resources, item: u64) -> Result<(u64, u64), CanoError> {
2074            if item == 1 {
2075                // Far longer than the 10ms attempt_timeout → a timeout item error.
2076                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
2077            }
2078            Ok((item, item))
2079        }
2080
2081        async fn flush_window(
2082            &self,
2083            _res: &Resources,
2084            outputs: Vec<u64>,
2085        ) -> Result<WindowSignal<Step>, CanoError> {
2086            self.flushed.lock().unwrap().extend(outputs);
2087            Ok(WindowSignal::Continue)
2088        }
2089
2090        async fn on_close(
2091            &self,
2092            _res: &Resources,
2093            _reason: CloseReason,
2094        ) -> Result<TaskResult<Step>, CanoError> {
2095            Ok(TaskResult::Single(Step::Done))
2096        }
2097    }
2098
2099    #[tokio::test]
2100    async fn timeout_item_skipped_under_skip_and_continue() {
2101        // The timed-out item 1 is treated as an ordinary item error and dropped; 2 and 3
2102        // process normally and the run completes.
2103        let flushed = Arc::new(Mutex::new(Vec::new()));
2104        let task = SlowUnderPolicy {
2105            policy: StreamErrorPolicy::SkipAndContinue,
2106            flushed: Arc::clone(&flushed),
2107        };
2108        let res = Resources::new();
2109        let result = Task::run(&task, &res).await.unwrap();
2110        assert_eq!(result, TaskResult::Single(Step::Done));
2111        assert_eq!(
2112            *flushed.lock().unwrap(),
2113            vec![2u64, 3],
2114            "the timed-out item is skipped, not fatal"
2115        );
2116    }
2117
2118    #[tokio::test]
2119    async fn timeout_item_counts_under_retry_on_error() {
2120        // With max_errors=0 the timeout item-error fails the run, surfacing as a timeout.
2121        let task = SlowUnderPolicy {
2122            policy: StreamErrorPolicy::RetryOnError { max_errors: 0 },
2123            flushed: Arc::new(Mutex::new(Vec::new())),
2124        };
2125        let res = Resources::new();
2126        let err = Task::run(&task, &res).await.unwrap_err();
2127        assert_eq!(err.category(), "timeout");
2128    }
2129
2130    // -----------------------------------------------------------------------
2131    // SkipAndContinue cursor + Count(0) clamp.
2132    // -----------------------------------------------------------------------
2133
2134    #[tokio::test]
2135    async fn skip_does_not_commit_bad_item_cursor() {
2136        use crate::cancel::CancellationToken;
2137        use crate::workflow::Workflow;
2138
2139        // Count(1) over [1,2,3] with item 2 failing under SkipAndContinue: cursors 1 and 3
2140        // commit but 2 never does — a skipped item does not advance the persisted cursor.
2141        let task = ScriptedErrors {
2142            len: 3,
2143            fail: vec![2],
2144            policy: StreamErrorPolicy::SkipAndContinue,
2145            flushed: Arc::new(Mutex::new(Vec::new())),
2146        };
2147        let store = Arc::new(InMemoryStore::default());
2148        let workflow = Workflow::bare()
2149            .register_stream(Step::Consume, task)
2150            .add_exit_state(Step::Done)
2151            .with_checkpoint_store(store.clone())
2152            .with_workflow_id("skip-cursor");
2153        let result = workflow
2154            .orchestrate(Step::Consume, CancellationToken::disabled())
2155            .await
2156            .unwrap();
2157        assert_eq!(result, Step::Done);
2158        assert_eq!(
2159            step_cursors(&store),
2160            vec![1u64, 3],
2161            "the skipped item's cursor (2) is never committed"
2162        );
2163    }
2164
2165    struct CountWindowSource {
2166        window: StreamWindow,
2167        windows: Arc<Mutex<Vec<Vec<u64>>>>,
2168    }
2169
2170    #[task::stream]
2171    impl StreamTask<Step> for CountWindowSource {
2172        type Item = u64;
2173        type Output = u64;
2174        type Cursor = u64;
2175
2176        fn window(&self) -> StreamWindow {
2177            self.window.clone()
2178        }
2179
2180        async fn open(
2181            &self,
2182            _res: &Resources,
2183            _cursor: Option<u64>,
2184        ) -> Result<Pin<Box<dyn Stream<Item = u64> + Send>>, CanoError> {
2185            Ok(Box::pin(stream::iter(vec![1u64, 2, 3])) as Pin<Box<dyn Stream<Item = u64> + Send>>)
2186        }
2187
2188        async fn process_item(&self, _res: &Resources, item: u64) -> Result<(u64, u64), CanoError> {
2189            Ok((item, item))
2190        }
2191
2192        async fn flush_window(
2193            &self,
2194            _res: &Resources,
2195            outputs: Vec<u64>,
2196        ) -> Result<WindowSignal<Step>, CanoError> {
2197            self.windows.lock().unwrap().push(outputs);
2198            Ok(WindowSignal::Continue)
2199        }
2200
2201        async fn on_close(
2202            &self,
2203            _res: &Resources,
2204            _reason: CloseReason,
2205        ) -> Result<TaskResult<Step>, CanoError> {
2206            Ok(TaskResult::Single(Step::Done))
2207        }
2208    }
2209
2210    #[tokio::test]
2211    async fn count_zero_window_clamps_to_per_item() {
2212        // Count(0) is clamped to a minimum of 1, so it flushes one item per window.
2213        let windows = Arc::new(Mutex::new(Vec::new()));
2214        let task = CountWindowSource {
2215            window: StreamWindow::Count(0),
2216            windows: Arc::clone(&windows),
2217        };
2218        let res = Resources::new();
2219        let result = Task::run(&task, &res).await.unwrap();
2220        assert_eq!(result, TaskResult::Single(Step::Done));
2221        assert_eq!(
2222            *windows.lock().unwrap(),
2223            vec![vec![1u64], vec![2], vec![3]],
2224            "Count(0) behaves like Count(1)"
2225        );
2226    }
2227
2228    // -----------------------------------------------------------------------
2229    // Natural termination & cursor commit (engine path).
2230    // -----------------------------------------------------------------------
2231
2232    /// Yields `1..=len`; cursor == item. Counts flushes so a missing/extra flush is visible.
2233    struct CountingFlush {
2234        len: u64,
2235        window: StreamWindow,
2236        flushes: Arc<Mutex<Vec<Vec<u64>>>>,
2237    }
2238
2239    #[task::stream]
2240    impl StreamTask<Step> for CountingFlush {
2241        type Item = u64;
2242        type Output = u64;
2243        type Cursor = u64;
2244
2245        fn window(&self) -> StreamWindow {
2246            self.window.clone()
2247        }
2248
2249        async fn open(
2250            &self,
2251            _res: &Resources,
2252            _cursor: Option<u64>,
2253        ) -> Result<Pin<Box<dyn Stream<Item = u64> + Send>>, CanoError> {
2254            let items: Vec<u64> = (1..=self.len).collect();
2255            Ok(Box::pin(stream::iter(items)) as Pin<Box<dyn Stream<Item = u64> + Send>>)
2256        }
2257
2258        async fn process_item(&self, _res: &Resources, item: u64) -> Result<(u64, u64), CanoError> {
2259            Ok((item, item))
2260        }
2261
2262        async fn flush_window(
2263            &self,
2264            _res: &Resources,
2265            outputs: Vec<u64>,
2266        ) -> Result<WindowSignal<Step>, CanoError> {
2267            self.flushes.lock().unwrap().push(outputs);
2268            Ok(WindowSignal::Continue)
2269        }
2270
2271        async fn on_close(
2272            &self,
2273            _res: &Resources,
2274            _reason: CloseReason,
2275        ) -> Result<TaskResult<Step>, CanoError> {
2276            Ok(TaskResult::Single(Step::Done))
2277        }
2278    }
2279
2280    #[tokio::test]
2281    async fn empty_stream_closes_without_flush_or_cursor() {
2282        use crate::cancel::CancellationToken;
2283        use crate::workflow::Workflow;
2284
2285        // open() yields nothing → on_close(Exhausted) runs, but no window flushes and no
2286        // cursor commits.
2287        let flushes = Arc::new(Mutex::new(Vec::new()));
2288        let task = CountingFlush {
2289            len: 0,
2290            window: StreamWindow::Count(2),
2291            flushes: Arc::clone(&flushes),
2292        };
2293        let store = Arc::new(InMemoryStore::default());
2294        let workflow = Workflow::bare()
2295            .register_stream(Step::Consume, task)
2296            .add_exit_state(Step::Done)
2297            .with_checkpoint_store(store.clone())
2298            .with_workflow_id("empty");
2299        let result = workflow
2300            .orchestrate(Step::Consume, CancellationToken::disabled())
2301            .await
2302            .unwrap();
2303        assert_eq!(result, Step::Done);
2304        assert!(
2305            flushes.lock().unwrap().is_empty(),
2306            "no flush for an empty source"
2307        );
2308        assert!(
2309            step_cursors(&store).is_empty(),
2310            "no cursor committed when nothing is processed"
2311        );
2312    }
2313
2314    #[tokio::test]
2315    async fn exhaust_exact_divide_commits_only_full_window_cursors() {
2316        use crate::cancel::CancellationToken;
2317        use crate::workflow::Workflow;
2318
2319        // Count(2) over exactly 4 items: windows [1,2] and [3,4] flush; exhaustion finds an
2320        // empty buffer so on_close runs without a third flush and no extra cursor commits.
2321        let flushes = Arc::new(Mutex::new(Vec::new()));
2322        let task = CountingFlush {
2323            len: 4,
2324            window: StreamWindow::Count(2),
2325            flushes: Arc::clone(&flushes),
2326        };
2327        let store = Arc::new(InMemoryStore::default());
2328        let workflow = Workflow::bare()
2329            .register_stream(Step::Consume, task)
2330            .add_exit_state(Step::Done)
2331            .with_checkpoint_store(store.clone())
2332            .with_workflow_id("exact");
2333        let result = workflow
2334            .orchestrate(Step::Consume, CancellationToken::disabled())
2335            .await
2336            .unwrap();
2337        assert_eq!(result, Step::Done);
2338        assert_eq!(*flushes.lock().unwrap(), vec![vec![1u64, 2], vec![3, 4]]);
2339        assert_eq!(step_cursors(&store), vec![2u64, 4]);
2340    }
2341
2342    #[tokio::test]
2343    async fn exhaust_partial_window_commits_its_cursor() {
2344        use crate::cancel::CancellationToken;
2345        use crate::workflow::Workflow;
2346
2347        // Count(2) over 5 items: [1,2], [3,4], then the terminal partial [5] flushes on
2348        // exhaustion and its cursor (5) commits before on_close transitions.
2349        let flushes = Arc::new(Mutex::new(Vec::new()));
2350        let task = CountingFlush {
2351            len: 5,
2352            window: StreamWindow::Count(2),
2353            flushes: Arc::clone(&flushes),
2354        };
2355        let store = Arc::new(InMemoryStore::default());
2356        let workflow = Workflow::bare()
2357            .register_stream(Step::Consume, task)
2358            .add_exit_state(Step::Done)
2359            .with_checkpoint_store(store.clone())
2360            .with_workflow_id("partial");
2361        let result = workflow
2362            .orchestrate(Step::Consume, CancellationToken::disabled())
2363            .await
2364            .unwrap();
2365        assert_eq!(result, Step::Done);
2366        assert_eq!(
2367            *flushes.lock().unwrap(),
2368            vec![vec![1u64, 2], vec![3, 4], vec![5]]
2369        );
2370        assert_eq!(step_cursors(&store), vec![2u64, 4, 5]);
2371    }
2372
2373    // -----------------------------------------------------------------------
2374    // Cancellation drain semantics.
2375    // -----------------------------------------------------------------------
2376
2377    /// Self-cancels from `process_item` once `cancel_after` items have been seen, so a
2378    /// partial (sub-window) buffer is in flight when the token fires. Records flushes/close.
2379    struct CancelMidWindow {
2380        handle: crate::cancel::CancellationHandle,
2381        cancel_after: u32,
2382        seen: AtomicU32,
2383        window: StreamWindow,
2384        stop_on_flush: bool,
2385        flushed: Arc<Mutex<Vec<Vec<u64>>>>,
2386        close_reason: Arc<Mutex<Option<CloseReason>>>,
2387    }
2388
2389    #[task::stream]
2390    impl StreamTask<S3> for CancelMidWindow {
2391        type Item = u64;
2392        type Output = u64;
2393        type Cursor = u64;
2394
2395        fn window(&self) -> StreamWindow {
2396            self.window.clone()
2397        }
2398
2399        async fn open(
2400            &self,
2401            _res: &Resources,
2402            _cursor: Option<u64>,
2403        ) -> Result<Pin<Box<dyn Stream<Item = u64> + Send>>, CanoError> {
2404            Ok(Box::pin(stream::iter(0u64..)) as Pin<Box<dyn Stream<Item = u64> + Send>>)
2405        }
2406
2407        async fn process_item(&self, _res: &Resources, item: u64) -> Result<(u64, u64), CanoError> {
2408            let n = self.seen.fetch_add(1, Ordering::SeqCst) + 1;
2409            if n == self.cancel_after {
2410                self.handle.cancel();
2411            }
2412            Ok((item, item))
2413        }
2414
2415        async fn flush_window(
2416            &self,
2417            _res: &Resources,
2418            outputs: Vec<u64>,
2419        ) -> Result<WindowSignal<S3>, CanoError> {
2420            self.flushed.lock().unwrap().push(outputs);
2421            if self.stop_on_flush {
2422                Ok(WindowSignal::Stop(TaskResult::Single(S3::ViaStop)))
2423            } else {
2424                Ok(WindowSignal::Continue)
2425            }
2426        }
2427
2428        async fn on_close(
2429            &self,
2430            _res: &Resources,
2431            reason: CloseReason,
2432        ) -> Result<TaskResult<S3>, CanoError> {
2433            *self.close_reason.lock().unwrap() = Some(reason);
2434            Ok(TaskResult::Single(S3::ViaClose))
2435        }
2436    }
2437
2438    #[tokio::test]
2439    async fn cancel_flushes_partial_in_flight_window() {
2440        use crate::cancel::CancellationToken;
2441        use crate::workflow::Workflow;
2442
2443        // Count(3) but cancel fires after the 2nd item: the in-flight partial [0,1] is
2444        // flushed, on_close(Cancelled) runs, and the run ends as cancelled. The biased
2445        // select also proves cancel wins over the ready 3rd stream item.
2446        let flushed = Arc::new(Mutex::new(Vec::new()));
2447        let close_reason = Arc::new(Mutex::new(None));
2448        let (handle, token) = CancellationToken::new();
2449        let task = CancelMidWindow {
2450            handle,
2451            cancel_after: 2,
2452            seen: AtomicU32::new(0),
2453            window: StreamWindow::Count(3),
2454            stop_on_flush: false,
2455            flushed: Arc::clone(&flushed),
2456            close_reason: Arc::clone(&close_reason),
2457        };
2458        let workflow = Workflow::bare()
2459            .register_stream(S3::Consume, task)
2460            .add_exit_states([S3::ViaStop, S3::ViaClose]);
2461        let result = workflow.orchestrate(S3::Consume, token).await;
2462        assert!(
2463            matches!(&result, Err(e) if e.category() == "cancelled"),
2464            "got {result:?}"
2465        );
2466        assert_eq!(
2467            *flushed.lock().unwrap(),
2468            vec![vec![0u64, 1]],
2469            "the partial window flushes once on cancel"
2470        );
2471        assert_eq!(*close_reason.lock().unwrap(), Some(CloseReason::Cancelled));
2472    }
2473
2474    #[tokio::test]
2475    async fn cancel_ignores_stop_from_partial_flush() {
2476        use crate::cancel::CancellationToken;
2477        use crate::workflow::Workflow;
2478
2479        // The cancel-drain flush returns Stop(ViaStop); it must be ignored — the run still
2480        // ends cancelled rather than transitioning to ViaStop.
2481        let (handle, token) = CancellationToken::new();
2482        let task = CancelMidWindow {
2483            handle,
2484            cancel_after: 2,
2485            seen: AtomicU32::new(0),
2486            window: StreamWindow::Count(3),
2487            stop_on_flush: true,
2488            flushed: Arc::new(Mutex::new(Vec::new())),
2489            close_reason: Arc::new(Mutex::new(None)),
2490        };
2491        let workflow = Workflow::bare()
2492            .register_stream(S3::Consume, task)
2493            .add_exit_states([S3::ViaStop, S3::ViaClose]);
2494        let result = workflow.orchestrate(S3::Consume, token).await;
2495        assert!(
2496            matches!(&result, Err(e) if e.category() == "cancelled"),
2497            "Stop from the cancel-drain flush must not transition, got {result:?}"
2498        );
2499    }
2500
2501    /// Self-cancels from `flush_window` (after a full window), so the next loop observes the
2502    /// cancel with an *empty* buffer. `on_close` may return an error to test propagation.
2503    struct CancelAfterWindow {
2504        handle: crate::cancel::CancellationHandle,
2505        flushes: AtomicU32,
2506        close_errors: bool,
2507        closed_cancelled: Arc<AtomicBool>,
2508    }
2509
2510    #[task::stream]
2511    impl StreamTask<Step> for CancelAfterWindow {
2512        type Item = u64;
2513        type Output = u64;
2514        type Cursor = u64;
2515
2516        async fn open(
2517            &self,
2518            _res: &Resources,
2519            _cursor: Option<u64>,
2520        ) -> Result<Pin<Box<dyn Stream<Item = u64> + Send>>, CanoError> {
2521            Ok(Box::pin(stream::iter(0u64..)) as Pin<Box<dyn Stream<Item = u64> + Send>>)
2522        }
2523
2524        async fn process_item(&self, _res: &Resources, item: u64) -> Result<(u64, u64), CanoError> {
2525            Ok((item, item))
2526        }
2527
2528        async fn flush_window(
2529            &self,
2530            _res: &Resources,
2531            _outputs: Vec<u64>,
2532        ) -> Result<WindowSignal<Step>, CanoError> {
2533            // Default Count(1): fire cancel after the first full window so the next iteration
2534            // drains with an empty buffer.
2535            self.flushes.fetch_add(1, Ordering::SeqCst);
2536            self.handle.cancel();
2537            Ok(WindowSignal::Continue)
2538        }
2539
2540        async fn on_close(
2541            &self,
2542            _res: &Resources,
2543            reason: CloseReason,
2544        ) -> Result<TaskResult<Step>, CanoError> {
2545            if reason == CloseReason::Cancelled {
2546                self.closed_cancelled.store(true, Ordering::SeqCst);
2547                if self.close_errors {
2548                    return Err(CanoError::task_execution("cleanup failed"));
2549                }
2550            }
2551            Ok(TaskResult::Single(Step::Done))
2552        }
2553    }
2554
2555    #[tokio::test]
2556    async fn cancel_with_empty_buffer_skips_flush_but_runs_close() {
2557        use crate::cancel::CancellationToken;
2558        use crate::workflow::Workflow;
2559
2560        let (handle, token) = CancellationToken::new();
2561        let closed = Arc::new(AtomicBool::new(false));
2562        let task = CancelAfterWindow {
2563            handle,
2564            flushes: AtomicU32::new(0),
2565            close_errors: false,
2566            closed_cancelled: Arc::clone(&closed),
2567        };
2568        let workflow = Workflow::bare()
2569            .register_stream(Step::Consume, task)
2570            .add_exit_state(Step::Done);
2571        let result = workflow.orchestrate(Step::Consume, token).await;
2572        assert!(
2573            matches!(&result, Err(e) if e.category() == "cancelled"),
2574            "got {result:?}"
2575        );
2576        assert!(
2577            closed.load(Ordering::SeqCst),
2578            "on_close(Cancelled) still runs with an empty buffer"
2579        );
2580    }
2581
2582    #[tokio::test]
2583    async fn cancel_propagates_on_close_error() {
2584        use crate::cancel::CancellationToken;
2585        use crate::workflow::Workflow;
2586
2587        // An Err from on_close(Cancelled) surfaces as that error, not as a generic cancel.
2588        let (handle, token) = CancellationToken::new();
2589        let task = CancelAfterWindow {
2590            handle,
2591            flushes: AtomicU32::new(0),
2592            close_errors: true,
2593            closed_cancelled: Arc::new(AtomicBool::new(false)),
2594        };
2595        let workflow = Workflow::bare()
2596            .register_stream(Step::Consume, task)
2597            .add_exit_state(Step::Done);
2598        let err = workflow
2599            .orchestrate(Step::Consume, token)
2600            .await
2601            .unwrap_err();
2602        assert_eq!(err.category(), "task_execution");
2603        assert!(err.to_string().contains("cleanup failed"), "got {err}");
2604    }
2605
2606    /// Cancels mid-window with a committed-cursor source so the cancel commits a final
2607    /// cursor; a fresh-cursor `open` lets resume continue from it.
2608    struct CancelThenResume {
2609        handle: crate::cancel::CancellationHandle,
2610        seen: AtomicU32,
2611        opened_cursors: Arc<Mutex<Vec<Option<u64>>>>,
2612    }
2613
2614    #[task::stream]
2615    impl StreamTask<Step> for CancelThenResume {
2616        type Item = u64;
2617        type Output = u64;
2618        type Cursor = u64;
2619
2620        fn window(&self) -> StreamWindow {
2621            StreamWindow::Count(3)
2622        }
2623
2624        async fn open(
2625            &self,
2626            _res: &Resources,
2627            cursor: Option<u64>,
2628        ) -> Result<Pin<Box<dyn Stream<Item = u64> + Send>>, CanoError> {
2629            self.opened_cursors.lock().unwrap().push(cursor);
2630            // First run: long source so cancel lands mid-window. Resume: empty tail → clean
2631            // exhaustion (the run is already past the cancel point).
2632            let items: Vec<u64> = match cursor {
2633                None => (0u64..1000).collect(),
2634                Some(_) => Vec::new(),
2635            };
2636            Ok(Box::pin(stream::iter(items)) as Pin<Box<dyn Stream<Item = u64> + Send>>)
2637        }
2638
2639        async fn process_item(&self, _res: &Resources, item: u64) -> Result<(u64, u64), CanoError> {
2640            let n = self.seen.fetch_add(1, Ordering::SeqCst) + 1;
2641            if n == 2 {
2642                self.handle.cancel();
2643            }
2644            Ok((item, item))
2645        }
2646
2647        async fn flush_window(
2648            &self,
2649            _res: &Resources,
2650            _outputs: Vec<u64>,
2651        ) -> Result<WindowSignal<Step>, CanoError> {
2652            Ok(WindowSignal::Continue)
2653        }
2654
2655        async fn on_close(
2656            &self,
2657            _res: &Resources,
2658            _reason: CloseReason,
2659        ) -> Result<TaskResult<Step>, CanoError> {
2660            Ok(TaskResult::Single(Step::Done))
2661        }
2662    }
2663
2664    #[tokio::test]
2665    async fn cancel_commits_partial_cursor_and_resumes() {
2666        use crate::cancel::CancellationToken;
2667        use crate::workflow::Workflow;
2668
2669        let (handle, token) = CancellationToken::new();
2670        let opened = Arc::new(Mutex::new(Vec::new()));
2671        let task = CancelThenResume {
2672            handle,
2673            seen: AtomicU32::new(0),
2674            opened_cursors: Arc::clone(&opened),
2675        };
2676        let store = Arc::new(InMemoryStore::default());
2677        let workflow = Workflow::bare()
2678            .register_stream(Step::Consume, task)
2679            .add_exit_state(Step::Done)
2680            .with_checkpoint_store(store.clone())
2681            .with_workflow_id("cancel-resume");
2682
2683        // Run 1: cancel after the 2nd item; the partial window [0,1] flushes and commits
2684        // cursor 1.
2685        let r1 = workflow.orchestrate(Step::Consume, token).await;
2686        assert!(
2687            matches!(&r1, Err(e) if e.category() == "cancelled"),
2688            "got {r1:?}"
2689        );
2690        assert_eq!(
2691            step_cursors(&store),
2692            vec![1u64],
2693            "the cancelled run commits the in-flight window's final cursor"
2694        );
2695
2696        // Resume: re-open at cursor 1, exhaust the empty tail, finish.
2697        let r2 = workflow
2698            .resume_from("cancel-resume", CancellationToken::disabled())
2699            .await
2700            .unwrap();
2701        assert_eq!(r2, Step::Done);
2702        assert_eq!(*opened.lock().unwrap(), vec![None, Some(1)]);
2703    }
2704
2705    // -----------------------------------------------------------------------
2706    // Engine-arm error paths: Split rejection, corrupt cursor, append failure, panic.
2707    // -----------------------------------------------------------------------
2708
2709    struct SplitOnClose;
2710
2711    #[task::stream]
2712    impl StreamTask<Step> for SplitOnClose {
2713        type Item = u64;
2714        type Output = u64;
2715        type Cursor = u64;
2716
2717        async fn open(
2718            &self,
2719            _res: &Resources,
2720            _cursor: Option<u64>,
2721        ) -> Result<Pin<Box<dyn Stream<Item = u64> + Send>>, CanoError> {
2722            Ok(Box::pin(stream::iter(vec![1u64])) as Pin<Box<dyn Stream<Item = u64> + Send>>)
2723        }
2724
2725        async fn process_item(&self, _res: &Resources, item: u64) -> Result<(u64, u64), CanoError> {
2726            Ok((item, item))
2727        }
2728
2729        async fn flush_window(
2730            &self,
2731            _res: &Resources,
2732            _outputs: Vec<u64>,
2733        ) -> Result<WindowSignal<Step>, CanoError> {
2734            Ok(WindowSignal::Continue)
2735        }
2736
2737        async fn on_close(
2738            &self,
2739            _res: &Resources,
2740            _reason: CloseReason,
2741        ) -> Result<TaskResult<Step>, CanoError> {
2742            Ok(TaskResult::Split(vec![Step::Done, Step::Done]))
2743        }
2744    }
2745
2746    #[tokio::test]
2747    async fn stream_split_result_is_rejected() {
2748        use crate::cancel::CancellationToken;
2749        use crate::workflow::Workflow;
2750
2751        let workflow = Workflow::bare()
2752            .register_stream(Step::Consume, SplitOnClose)
2753            .add_exit_state(Step::Done);
2754        let err = workflow
2755            .orchestrate(Step::Consume, CancellationToken::disabled())
2756            .await
2757            .unwrap_err();
2758        assert_eq!(err.category(), "workflow");
2759        assert!(err.to_string().contains("split"), "got {err}");
2760    }
2761
2762    /// Count(1) over [1,2]; the [2] window flush fails so run 1 crashes after committing
2763    /// cursor 1 — giving a StepCursor row to corrupt before resume.
2764    struct CrashAfterFirstWindow;
2765
2766    #[task::stream]
2767    impl StreamTask<Step> for CrashAfterFirstWindow {
2768        type Item = u64;
2769        type Output = u64;
2770        type Cursor = u64;
2771
2772        async fn open(
2773            &self,
2774            _res: &Resources,
2775            _cursor: Option<u64>,
2776        ) -> Result<Pin<Box<dyn Stream<Item = u64> + Send>>, CanoError> {
2777            Ok(Box::pin(stream::iter(vec![1u64, 2])) as Pin<Box<dyn Stream<Item = u64> + Send>>)
2778        }
2779
2780        async fn process_item(&self, _res: &Resources, item: u64) -> Result<(u64, u64), CanoError> {
2781            Ok((item, item))
2782        }
2783
2784        async fn flush_window(
2785            &self,
2786            _res: &Resources,
2787            outputs: Vec<u64>,
2788        ) -> Result<WindowSignal<Step>, CanoError> {
2789            if outputs == vec![2u64] {
2790                return Err(CanoError::task_execution("crash"));
2791            }
2792            Ok(WindowSignal::Continue)
2793        }
2794
2795        async fn on_close(
2796            &self,
2797            _res: &Resources,
2798            _reason: CloseReason,
2799        ) -> Result<TaskResult<Step>, CanoError> {
2800            Ok(TaskResult::Single(Step::Done))
2801        }
2802    }
2803
2804    #[tokio::test]
2805    async fn corrupt_cursor_fails_to_deserialize_on_resume() {
2806        use crate::cancel::CancellationToken;
2807        use crate::workflow::Workflow;
2808
2809        let store = Arc::new(InMemoryStore::default());
2810        let workflow = Workflow::bare()
2811            .register_stream(Step::Consume, CrashAfterFirstWindow)
2812            .add_exit_state(Step::Done)
2813            .with_checkpoint_store(store.clone())
2814            .with_workflow_id("corrupt");
2815
2816        // Run 1 commits cursor 1 then crashes flushing [2]; the log is kept for resume.
2817        let r1 = workflow
2818            .orchestrate(Step::Consume, CancellationToken::disabled())
2819            .await;
2820        assert!(r1.is_err(), "run 1 should crash: {r1:?}");
2821
2822        // Corrupt the committed cursor blob to invalid JSON.
2823        {
2824            let mut g = store.rows.lock().unwrap();
2825            for row in g.get_mut("corrupt").unwrap().iter_mut() {
2826                if row.kind == crate::recovery::RowKind::StepCursor {
2827                    row.output_blob = Some(b"not-json".to_vec());
2828                }
2829            }
2830        }
2831
2832        let err = workflow
2833            .resume_from("corrupt", CancellationToken::disabled())
2834            .await
2835            .unwrap_err();
2836        assert_eq!(err.category(), "task_execution");
2837        assert!(
2838            err.to_string().contains("deserialize stream cursor"),
2839            "got {err}"
2840        );
2841    }
2842
2843    /// A store that accepts `StateEntry` rows but rejects every `StepCursor` append.
2844    #[derive(Default)]
2845    struct CursorAppendFails;
2846
2847    #[crate::checkpoint_store]
2848    impl crate::recovery::CheckpointStore for CursorAppendFails {
2849        async fn append(
2850            &self,
2851            _workflow_id: &str,
2852            row: crate::recovery::CheckpointRow,
2853        ) -> Result<(), CanoError> {
2854            if row.kind == crate::recovery::RowKind::StepCursor {
2855                Err(CanoError::checkpoint_store("disk full"))
2856            } else {
2857                Ok(())
2858            }
2859        }
2860        async fn load_run(
2861            &self,
2862            _workflow_id: &str,
2863        ) -> Result<Vec<crate::recovery::CheckpointRow>, CanoError> {
2864            Ok(Vec::new())
2865        }
2866        async fn clear(&self, _workflow_id: &str) -> Result<(), CanoError> {
2867            Ok(())
2868        }
2869    }
2870
2871    #[tokio::test]
2872    async fn checkpoint_append_failure_surfaces() {
2873        use crate::cancel::CancellationToken;
2874        use crate::workflow::Workflow;
2875
2876        let task = CountingFlush {
2877            len: 3,
2878            window: StreamWindow::Count(1),
2879            flushes: Arc::new(Mutex::new(Vec::new())),
2880        };
2881        let workflow = Workflow::bare()
2882            .register_stream(Step::Consume, task)
2883            .add_exit_state(Step::Done)
2884            .with_checkpoint_store(Arc::new(CursorAppendFails))
2885            .with_workflow_id("append-fail");
2886        let err = workflow
2887            .orchestrate(Step::Consume, CancellationToken::disabled())
2888            .await
2889            .unwrap_err();
2890        assert_eq!(err.category(), "checkpoint_store");
2891        assert!(
2892            err.to_string().contains("append stream cursor checkpoint"),
2893            "got {err}"
2894        );
2895    }
2896
2897    struct PanicInFlush;
2898
2899    #[task::stream]
2900    impl StreamTask<Step> for PanicInFlush {
2901        type Item = u64;
2902        type Output = u64;
2903        type Cursor = u64;
2904
2905        async fn open(
2906            &self,
2907            _res: &Resources,
2908            _cursor: Option<u64>,
2909        ) -> Result<Pin<Box<dyn Stream<Item = u64> + Send>>, CanoError> {
2910            Ok(Box::pin(stream::iter(vec![1u64])) as Pin<Box<dyn Stream<Item = u64> + Send>>)
2911        }
2912
2913        async fn process_item(&self, _res: &Resources, item: u64) -> Result<(u64, u64), CanoError> {
2914            Ok((item, item))
2915        }
2916
2917        async fn flush_window(
2918            &self,
2919            _res: &Resources,
2920            _outputs: Vec<u64>,
2921        ) -> Result<WindowSignal<Step>, CanoError> {
2922            panic!("boom in flush_window");
2923        }
2924
2925        async fn on_close(
2926            &self,
2927            _res: &Resources,
2928            _reason: CloseReason,
2929        ) -> Result<TaskResult<Step>, CanoError> {
2930            Ok(TaskResult::Single(Step::Done))
2931        }
2932    }
2933
2934    #[tokio::test]
2935    async fn panic_in_callback_becomes_error() {
2936        use crate::cancel::CancellationToken;
2937        use crate::workflow::Workflow;
2938
2939        // The engine wraps the session in catch_panic_to_error: a panic becomes a CanoError
2940        // (so resource teardown runs) instead of unwinding past the FSM.
2941        let workflow = Workflow::bare()
2942            .register_stream(Step::Consume, PanicInFlush)
2943            .add_exit_state(Step::Done);
2944        let err = workflow
2945            .orchestrate(Step::Consume, CancellationToken::disabled())
2946            .await
2947            .unwrap_err();
2948        assert_eq!(err.category(), "task_execution");
2949        assert!(err.to_string().contains("panic"), "got {err}");
2950    }
2951
2952    // -----------------------------------------------------------------------
2953    // Config / observer surface.
2954    // -----------------------------------------------------------------------
2955
2956    /// Fails every item under FailFast, counting how many times `open` is invoked.
2957    struct AlwaysFails {
2958        opened: Arc<AtomicU32>,
2959    }
2960
2961    #[task::stream]
2962    impl StreamTask<Step> for AlwaysFails {
2963        type Item = u64;
2964        type Output = u64;
2965        type Cursor = u64;
2966
2967        fn config(&self) -> TaskConfig {
2968            // max_attempts = 3 — must NOT be applied to a stream (no re-open / re-consume).
2969            TaskConfig::minimal().with_fixed_retry(2, std::time::Duration::from_millis(1))
2970        }
2971
2972        async fn open(
2973            &self,
2974            _res: &Resources,
2975            _cursor: Option<u64>,
2976        ) -> Result<Pin<Box<dyn Stream<Item = u64> + Send>>, CanoError> {
2977            self.opened.fetch_add(1, Ordering::SeqCst);
2978            Ok(Box::pin(stream::iter(vec![1u64])) as Pin<Box<dyn Stream<Item = u64> + Send>>)
2979        }
2980
2981        async fn process_item(
2982            &self,
2983            _res: &Resources,
2984            _item: u64,
2985        ) -> Result<(u64, u64), CanoError> {
2986            Err(CanoError::task_execution("always fails"))
2987        }
2988
2989        async fn flush_window(
2990            &self,
2991            _res: &Resources,
2992            _outputs: Vec<u64>,
2993        ) -> Result<WindowSignal<Step>, CanoError> {
2994            Ok(WindowSignal::Continue)
2995        }
2996
2997        async fn on_close(
2998            &self,
2999            _res: &Resources,
3000            _reason: CloseReason,
3001        ) -> Result<TaskResult<Step>, CanoError> {
3002            Ok(TaskResult::Single(Step::Done))
3003        }
3004    }
3005
3006    #[tokio::test]
3007    async fn outer_retry_not_applied_open_called_once() {
3008        use crate::cancel::CancellationToken;
3009        use crate::workflow::Workflow;
3010
3011        let opened = Arc::new(AtomicU32::new(0));
3012        let task = AlwaysFails {
3013            opened: Arc::clone(&opened),
3014        };
3015        let workflow = Workflow::bare()
3016            .register_stream(Step::Consume, task)
3017            .add_exit_state(Step::Done);
3018        let result = workflow
3019            .orchestrate(Step::Consume, CancellationToken::disabled())
3020            .await;
3021        assert!(result.is_err(), "FailFast item error fails the run");
3022        assert_eq!(
3023            opened.load(Ordering::SeqCst),
3024            1,
3025            "config().max_attempts must not re-open/re-consume the stream"
3026        );
3027    }
3028
3029    /// Records the task id passed to each observer hook, in order.
3030    #[derive(Default)]
3031    struct EventLog {
3032        events: Mutex<Vec<String>>,
3033    }
3034
3035    impl crate::observer::WorkflowObserver for EventLog {
3036        fn on_task_start(&self, task_id: &str) {
3037            self.events.lock().unwrap().push(format!("start:{task_id}"));
3038        }
3039        fn on_task_success(&self, task_id: &str) {
3040            self.events
3041                .lock()
3042                .unwrap()
3043                .push(format!("success:{task_id}"));
3044        }
3045        fn on_task_failure(&self, _task_id: &str, _err: &CanoError) {
3046            self.events.lock().unwrap().push("failure".to_string());
3047        }
3048        fn on_cancelled(&self, _state: &str) {
3049            self.events.lock().unwrap().push("cancelled".to_string());
3050        }
3051    }
3052
3053    struct NamedExhaust;
3054
3055    #[task::stream]
3056    impl StreamTask<Step> for NamedExhaust {
3057        type Item = u64;
3058        type Output = u64;
3059        type Cursor = u64;
3060
3061        fn name(&self) -> Cow<'static, str> {
3062            Cow::Borrowed("my-custom-stream")
3063        }
3064
3065        async fn open(
3066            &self,
3067            _res: &Resources,
3068            _cursor: Option<u64>,
3069        ) -> Result<Pin<Box<dyn Stream<Item = u64> + Send>>, CanoError> {
3070            Ok(Box::pin(stream::iter(vec![1u64])) as Pin<Box<dyn Stream<Item = u64> + Send>>)
3071        }
3072
3073        async fn process_item(&self, _res: &Resources, item: u64) -> Result<(u64, u64), CanoError> {
3074            Ok((item, item))
3075        }
3076
3077        async fn flush_window(
3078            &self,
3079            _res: &Resources,
3080            _outputs: Vec<u64>,
3081        ) -> Result<WindowSignal<Step>, CanoError> {
3082            Ok(WindowSignal::Continue)
3083        }
3084
3085        async fn on_close(
3086            &self,
3087            _res: &Resources,
3088            _reason: CloseReason,
3089        ) -> Result<TaskResult<Step>, CanoError> {
3090            Ok(TaskResult::Single(Step::Done))
3091        }
3092    }
3093
3094    #[tokio::test]
3095    async fn name_override_forwarded_to_observer() {
3096        use crate::cancel::CancellationToken;
3097        use crate::workflow::Workflow;
3098
3099        let log = Arc::new(EventLog::default());
3100        let workflow = Workflow::bare()
3101            .register_stream(Step::Consume, NamedExhaust)
3102            .add_exit_state(Step::Done)
3103            .with_observer(log.clone());
3104        workflow
3105            .orchestrate(Step::Consume, CancellationToken::disabled())
3106            .await
3107            .unwrap();
3108        let events = log.events.lock().unwrap();
3109        assert_eq!(
3110            *events,
3111            vec![
3112                "start:my-custom-stream".to_string(),
3113                "success:my-custom-stream".to_string()
3114            ],
3115            "the StreamTask name() override reaches observer hooks"
3116        );
3117    }
3118
3119    #[tokio::test]
3120    async fn cancel_fires_full_observer_sequence() {
3121        use crate::cancel::CancellationToken;
3122        use crate::workflow::Workflow;
3123
3124        // A cancelled stream fires on_task_start, then (since cancel surfaces as Err)
3125        // on_task_failure, then on_cancelled — in that order, exactly once each.
3126        let (handle, token) = CancellationToken::new();
3127        let task = CancelAfterWindow {
3128            handle,
3129            flushes: AtomicU32::new(0),
3130            close_errors: false,
3131            closed_cancelled: Arc::new(AtomicBool::new(false)),
3132        };
3133        let log = Arc::new(EventLog::default());
3134        let workflow = Workflow::bare()
3135            .register_stream(Step::Consume, task)
3136            .add_exit_state(Step::Done)
3137            .with_observer(log.clone());
3138        let result = workflow.orchestrate(Step::Consume, token).await;
3139        assert!(matches!(&result, Err(e) if e.category() == "cancelled"));
3140        let events = log.events.lock().unwrap();
3141        assert_eq!(events.len(), 3, "exactly three hooks fire, got {events:?}");
3142        assert!(
3143            events[0].starts_with("start:") && events[0].contains("CancelAfterWindow"),
3144            "first hook is on_task_start, got {events:?}"
3145        );
3146        assert_eq!(
3147            &events[1..],
3148            &["failure".to_string(), "cancelled".to_string()],
3149            "cancel fires start → failure → cancelled, got {events:?}"
3150        );
3151    }
3152
3153    #[tokio::test]
3154    async fn register_stream_without_store_completes() {
3155        use crate::cancel::CancellationToken;
3156        use crate::workflow::Workflow;
3157
3158        // register_stream with neither a checkpoint store nor a workflow id: cursor
3159        // persistence is simply skipped and the run completes normally.
3160        let task = CountingFlush {
3161            len: 3,
3162            window: StreamWindow::Count(2),
3163            flushes: Arc::new(Mutex::new(Vec::new())),
3164        };
3165        let workflow = Workflow::bare()
3166            .register_stream(Step::Consume, task)
3167            .add_exit_state(Step::Done);
3168        let result = workflow
3169            .orchestrate(Step::Consume, CancellationToken::disabled())
3170            .await
3171            .unwrap();
3172        assert_eq!(result, Step::Done);
3173    }
3174
3175    // -----------------------------------------------------------------------
3176    // Duration window tests.
3177    // -----------------------------------------------------------------------
3178
3179    /// Emits items with controlled delays so duration windows can be tested
3180    /// deterministically using `tokio::time::pause()`.
3181    struct DelayedSource {
3182        emit_delay: std::time::Duration,
3183        items: Vec<u64>,
3184    }
3185
3186    #[task::stream]
3187    impl StreamTask<Step> for DelayedSource {
3188        type Item = u64;
3189        type Output = u64;
3190        type Cursor = u64;
3191
3192        fn window(&self) -> StreamWindow {
3193            StreamWindow::Duration(std::time::Duration::from_millis(50))
3194        }
3195
3196        async fn open(
3197            &self,
3198            _res: &Resources,
3199            _cursor: Option<u64>,
3200        ) -> Result<Pin<Box<dyn Stream<Item = u64> + Send>>, CanoError> {
3201            let items = self.items.clone();
3202            let delay = self.emit_delay;
3203            Ok(Box::pin(stream::unfold(
3204                (items.into_iter(), delay),
3205                |(mut iter, d)| async move {
3206                    let item = iter.next()?;
3207                    Some((item, (iter, d)))
3208                },
3209            )) as Pin<Box<dyn Stream<Item = u64> + Send>>)
3210        }
3211
3212        async fn process_item(&self, _res: &Resources, item: u64) -> Result<(u64, u64), CanoError> {
3213            Ok((item, item))
3214        }
3215
3216        async fn flush_window(
3217            &self,
3218            _res: &Resources,
3219            _outputs: Vec<u64>,
3220        ) -> Result<WindowSignal<Step>, CanoError> {
3221            Ok(WindowSignal::Continue)
3222        }
3223
3224        async fn on_close(
3225            &self,
3226            _res: &Resources,
3227            _reason: CloseReason,
3228        ) -> Result<TaskResult<Step>, CanoError> {
3229            Ok(TaskResult::Single(Step::Done))
3230        }
3231    }
3232
3233    #[tokio::test]
3234    async fn duration_window_flushes_on_elapsed_time() {
3235        use crate::cancel::CancellationToken;
3236        use crate::workflow::Workflow;
3237
3238        // Three items emitted 10ms apart with a 50ms duration window: all three should
3239        // land in the first window and flush together.
3240        tokio::time::pause();
3241        let task = DelayedSource {
3242            emit_delay: std::time::Duration::from_millis(10),
3243            items: vec![1, 2, 3],
3244        };
3245        let workflow = Workflow::bare()
3246            .register_stream(Step::Consume, task)
3247            .add_exit_state(Step::Done);
3248        let result = workflow
3249            .orchestrate(Step::Consume, CancellationToken::disabled())
3250            .await
3251            .unwrap();
3252        assert_eq!(result, Step::Done);
3253    }
3254
3255    // -----------------------------------------------------------------------
3256    // Crash-resume mid-stream test.
3257    // -----------------------------------------------------------------------
3258
3259    #[tokio::test]
3260    async fn crash_resume_commits_cursor_between_windows() {
3261        use crate::cancel::CancellationToken;
3262        use crate::recovery::{CheckpointRow, CheckpointStore};
3263        use crate::workflow::Workflow;
3264
3265        /// A store that returns a pre-existing cursor (simulating a crash after the
3266        /// first window committed). Cursor appends succeed normally so the resumed run
3267        /// can complete.
3268        #[derive(Default)]
3269        struct PreExistingCursor;
3270
3271        #[crate::checkpoint_store]
3272        impl CheckpointStore for PreExistingCursor {
3273            async fn append(
3274                &self,
3275                _workflow_id: &str,
3276                _row: CheckpointRow,
3277            ) -> Result<(), CanoError> {
3278                Ok(())
3279            }
3280            async fn load_run(&self, _workflow_id: &str) -> Result<Vec<CheckpointRow>, CanoError> {
3281                // Simulate a crash that committed cursor 2 after the first window.
3282                let cursor_bytes = serde_json::to_vec(&2u64).unwrap();
3283                Ok(vec![
3284                    CheckpointRow::new(1, "Consume", "ResumeFromCursor").with_workflow_version(0),
3285                    CheckpointRow::new(2, "Consume", "ResumeFromCursor")
3286                        .with_cursor(cursor_bytes)
3287                        .with_workflow_version(0),
3288                ])
3289            }
3290            async fn clear(&self, _workflow_id: &str) -> Result<(), CanoError> {
3291                Ok(())
3292            }
3293        }
3294
3295        /// Opens a stream that continues from the resumed cursor.
3296        struct ResumeFromCursor {
3297            opened_cursor: std::sync::Arc<std::sync::Mutex<Option<u64>>>,
3298        }
3299
3300        #[task::stream]
3301        impl StreamTask<Step> for ResumeFromCursor {
3302            type Item = u64;
3303            type Output = u64;
3304            type Cursor = u64;
3305
3306            async fn open(
3307                &self,
3308                _res: &Resources,
3309                cursor: Option<u64>,
3310            ) -> Result<Pin<Box<dyn Stream<Item = u64> + Send>>, CanoError> {
3311                *self.opened_cursor.lock().unwrap() = cursor;
3312                // Resume: continue from cursor + 1.
3313                let start = cursor.unwrap_or(0) + 1;
3314                Ok(Box::pin(stream::iter(vec![start, start + 1, start + 2]))
3315                    as Pin<Box<dyn Stream<Item = u64> + Send>>)
3316            }
3317
3318            async fn process_item(
3319                &self,
3320                _res: &Resources,
3321                item: u64,
3322            ) -> Result<(u64, u64), CanoError> {
3323                Ok((item, item))
3324            }
3325
3326            async fn flush_window(
3327                &self,
3328                _res: &Resources,
3329                _outputs: Vec<u64>,
3330            ) -> Result<WindowSignal<Step>, CanoError> {
3331                Ok(WindowSignal::Continue)
3332            }
3333
3334            async fn on_close(
3335                &self,
3336                _res: &Resources,
3337                _reason: CloseReason,
3338            ) -> Result<TaskResult<Step>, CanoError> {
3339                Ok(TaskResult::Single(Step::Done))
3340            }
3341        }
3342
3343        let opened_cursor = std::sync::Arc::new(std::sync::Mutex::new(None::<u64>));
3344        let task = ResumeFromCursor {
3345            opened_cursor: std::sync::Arc::clone(&opened_cursor),
3346        };
3347        let workflow = Workflow::bare()
3348            .register_stream(Step::Consume, task)
3349            .add_exit_state(Step::Done)
3350            .with_checkpoint_store(Arc::new(PreExistingCursor))
3351            .with_workflow_id("crash-resume");
3352
3353        // The synthetic log has cursor 2 committed; resume should pick it up.
3354        let result = workflow
3355            .resume_from("crash-resume", CancellationToken::disabled())
3356            .await
3357            .unwrap();
3358        assert_eq!(result, Step::Done);
3359        // The resumed run opened from cursor 2 (the last committed position).
3360        assert_eq!(*opened_cursor.lock().unwrap(), Some(2));
3361    }
3362}
3363
3364#[cfg(all(test, feature = "metrics"))]
3365mod metrics_tests {
3366    use super::*;
3367    use crate::cancel::CancellationToken;
3368    use crate::metrics::test_support::*;
3369    use crate::task;
3370    use crate::task::Task;
3371    use crate::workflow::Workflow;
3372    use futures_util::stream;
3373
3374    #[derive(Debug, Clone, PartialEq, Eq, Hash)]
3375    enum St {
3376        Consume,
3377        Done,
3378    }
3379
3380    struct FiveItems;
3381
3382    #[task::stream]
3383    impl StreamTask<St> for FiveItems {
3384        type Item = u32;
3385        type Output = u32;
3386        type Cursor = u64;
3387
3388        fn window(&self) -> StreamWindow {
3389            StreamWindow::Count(2)
3390        }
3391
3392        async fn open(
3393            &self,
3394            _res: &Resources,
3395            _cursor: Option<u64>,
3396        ) -> Result<Pin<Box<dyn Stream<Item = u32> + Send>>, CanoError> {
3397            Ok(Box::pin(stream::iter(vec![1u32, 2, 3, 4, 5]))
3398                as Pin<Box<dyn Stream<Item = u32> + Send>>)
3399        }
3400
3401        async fn process_item(&self, _res: &Resources, item: u32) -> Result<(u32, u64), CanoError> {
3402            Ok((item, item as u64))
3403        }
3404
3405        async fn flush_window(
3406            &self,
3407            _res: &Resources,
3408            _outputs: Vec<u32>,
3409        ) -> Result<WindowSignal<St>, CanoError> {
3410            Ok(WindowSignal::Continue)
3411        }
3412
3413        async fn on_close(
3414            &self,
3415            _res: &Resources,
3416            _reason: CloseReason,
3417        ) -> Result<TaskResult<St>, CanoError> {
3418            Ok(TaskResult::Single(St::Done))
3419        }
3420    }
3421
3422    #[test]
3423    fn stream_metrics_counted_correctly() {
3424        let (result, rows) = run_with_recorder(|| async {
3425            let workflow = Workflow::bare()
3426                .register_stream(St::Consume, FiveItems)
3427                .add_exit_state(St::Done);
3428            workflow
3429                .orchestrate(St::Consume, CancellationToken::disabled())
3430                .await
3431        });
3432        assert!(result.is_ok(), "workflow should succeed: {result:?}");
3433        assert_eq!(
3434            counter(&rows, "cano_stream_runs_total", &[("outcome", "completed")]),
3435            1,
3436            "one completed stream run"
3437        );
3438        // Count(2) over 5 items → windows [1,2], [3,4], then partial [5] on close.
3439        assert_eq!(
3440            counter(&rows, "cano_stream_windows_total", &[]),
3441            3,
3442            "three windows flushed"
3443        );
3444        assert_eq!(
3445            counter(&rows, "cano_stream_items_total", &[("result", "ok")]),
3446            5,
3447            "five ok items"
3448        );
3449    }
3450
3451    /// Cancels itself after the first window (deterministic — no spawn/sleep).
3452    struct SelfCancel {
3453        handle: crate::cancel::CancellationHandle,
3454    }
3455
3456    #[task::stream]
3457    impl StreamTask<St> for SelfCancel {
3458        type Item = u32;
3459        type Output = u32;
3460        type Cursor = u64;
3461
3462        async fn open(
3463            &self,
3464            _res: &Resources,
3465            _cursor: Option<u64>,
3466        ) -> Result<Pin<Box<dyn Stream<Item = u32> + Send>>, CanoError> {
3467            Ok(Box::pin(stream::iter(0u32..)) as Pin<Box<dyn Stream<Item = u32> + Send>>)
3468        }
3469
3470        async fn process_item(&self, _res: &Resources, item: u32) -> Result<(u32, u64), CanoError> {
3471            Ok((item, item as u64))
3472        }
3473
3474        async fn flush_window(
3475            &self,
3476            _res: &Resources,
3477            _outputs: Vec<u32>,
3478        ) -> Result<WindowSignal<St>, CanoError> {
3479            // Default window is Count(1); fire cancel after the first window — the next
3480            // loop iteration observes it and drains cooperatively.
3481            self.handle.cancel();
3482            Ok(WindowSignal::Continue)
3483        }
3484
3485        async fn on_close(
3486            &self,
3487            _res: &Resources,
3488            _reason: CloseReason,
3489        ) -> Result<TaskResult<St>, CanoError> {
3490            Ok(TaskResult::Single(St::Done))
3491        }
3492    }
3493
3494    #[test]
3495    fn cancelled_stream_records_cancelled_outcome() {
3496        let (handle, token) = CancellationToken::new();
3497        let (result, rows) = run_with_recorder(|| async move {
3498            let workflow = Workflow::bare()
3499                .register_stream(St::Consume, SelfCancel { handle })
3500                .add_exit_state(St::Done);
3501            workflow.orchestrate(St::Consume, token).await
3502        });
3503        assert!(result.is_err(), "a cancelled run is Err: {result:?}");
3504        assert_eq!(
3505            counter(&rows, "cano_stream_runs_total", &[("outcome", "cancelled")]),
3506            1,
3507            "a cooperative cancel is recorded as cancelled, not failed"
3508        );
3509    }
3510
3511    /// Infinite source; used with a near-zero `with_total_timeout` so the *deadline*
3512    /// (not a real `CancellationToken`) drives the drain — the outcome must reclassify
3513    /// as `workflow_timeout`, not `cancelled`.
3514    struct NeverStops;
3515
3516    #[task::stream]
3517    impl StreamTask<St> for NeverStops {
3518        type Item = u32;
3519        type Output = u32;
3520        type Cursor = u64;
3521
3522        async fn open(
3523            &self,
3524            _res: &Resources,
3525            _cursor: Option<u64>,
3526        ) -> Result<Pin<Box<dyn Stream<Item = u32> + Send>>, CanoError> {
3527            Ok(Box::pin(stream::iter(0u32..)) as Pin<Box<dyn Stream<Item = u32> + Send>>)
3528        }
3529
3530        async fn process_item(&self, _res: &Resources, item: u32) -> Result<(u32, u64), CanoError> {
3531            // `stream::iter(0u32..)` and this method are both instantly-ready with no
3532            // internal `.await` suspension; with the default `Count(1)` window that would
3533            // make `drive_window` never actually return `Poll::Pending`, starving the
3534            // runtime and preventing the deadline-forwarder task (spawned for
3535            // `with_total_timeout`) from ever being polled. Yield explicitly so the
3536            // scheduler gets a turn each item — the same role `Forever`'s real 2ms sleep
3537            // plays elsewhere in this file, without adding wall-clock delay here.
3538            tokio::task::yield_now().await;
3539            Ok((item, item as u64))
3540        }
3541
3542        async fn flush_window(
3543            &self,
3544            _res: &Resources,
3545            _outputs: Vec<u32>,
3546        ) -> Result<WindowSignal<St>, CanoError> {
3547            Ok(WindowSignal::Continue)
3548        }
3549
3550        async fn on_close(
3551            &self,
3552            _res: &Resources,
3553            _reason: CloseReason,
3554        ) -> Result<TaskResult<St>, CanoError> {
3555            Ok(TaskResult::Single(St::Done))
3556        }
3557    }
3558
3559    #[test]
3560    fn total_timeout_stream_records_failed_not_cancelled() {
3561        let (result, rows) = run_with_recorder(|| async {
3562            let workflow = Workflow::bare()
3563                .register_stream(St::Consume, NeverStops)
3564                .add_exit_state(St::Done)
3565                .with_total_timeout(std::time::Duration::from_nanos(1));
3566            workflow
3567                .orchestrate(St::Consume, CancellationToken::disabled())
3568                .await
3569        });
3570        assert!(
3571            matches!(&result, Err(e) if e.category() == "workflow_timeout"),
3572            "a tripped total-timeout budget must surface as WorkflowTimeout: {result:?}"
3573        );
3574        assert_eq!(
3575            counter(&rows, "cano_stream_runs_total", &[("outcome", "failed")]),
3576            1,
3577            "a total-timeout trip is recorded as failed, not cancelled — `cancelled` is \
3578             reserved for a real CancellationToken firing"
3579        );
3580        assert_eq!(
3581            counter_opt(&rows, "cano_stream_runs_total", &[("outcome", "cancelled")]),
3582            None,
3583            "must not also be recorded as cancelled"
3584        );
3585    }
3586
3587    /// FailFast over [1,2] with item 2 failing — one ok item, one err item, then a failed run.
3588    struct FailSecond;
3589
3590    #[task::stream]
3591    impl StreamTask<St> for FailSecond {
3592        type Item = u32;
3593        type Output = u32;
3594        type Cursor = u64;
3595
3596        async fn open(
3597            &self,
3598            _res: &Resources,
3599            _cursor: Option<u64>,
3600        ) -> Result<Pin<Box<dyn Stream<Item = u32> + Send>>, CanoError> {
3601            Ok(Box::pin(stream::iter(vec![1u32, 2])) as Pin<Box<dyn Stream<Item = u32> + Send>>)
3602        }
3603
3604        async fn process_item(&self, _res: &Resources, item: u32) -> Result<(u32, u64), CanoError> {
3605            if item == 2 {
3606                Err(CanoError::task_execution("boom"))
3607            } else {
3608                Ok((item, item as u64))
3609            }
3610        }
3611
3612        async fn flush_window(
3613            &self,
3614            _res: &Resources,
3615            _outputs: Vec<u32>,
3616        ) -> Result<WindowSignal<St>, CanoError> {
3617            Ok(WindowSignal::Continue)
3618        }
3619
3620        async fn on_close(
3621            &self,
3622            _res: &Resources,
3623            _reason: CloseReason,
3624        ) -> Result<TaskResult<St>, CanoError> {
3625            Ok(TaskResult::Single(St::Done))
3626        }
3627    }
3628
3629    #[test]
3630    fn failed_stream_records_failed_outcome_and_err_item() {
3631        let (result, rows) = run_with_recorder(|| async {
3632            let workflow = Workflow::bare()
3633                .register_stream(St::Consume, FailSecond)
3634                .add_exit_state(St::Done);
3635            workflow
3636                .orchestrate(St::Consume, CancellationToken::disabled())
3637                .await
3638        });
3639        assert!(
3640            result.is_err(),
3641            "FailFast item error fails the run: {result:?}"
3642        );
3643        assert_eq!(
3644            counter(&rows, "cano_stream_runs_total", &[("outcome", "failed")]),
3645            1,
3646            "a genuine error is recorded as failed"
3647        );
3648        assert_eq!(
3649            counter(&rows, "cano_stream_items_total", &[("result", "ok")]),
3650            1,
3651            "item 1 processed ok"
3652        );
3653        assert_eq!(
3654            counter(&rows, "cano_stream_items_total", &[("result", "err")]),
3655            1,
3656            "item 2 recorded as an err item"
3657        );
3658    }
3659
3660    #[test]
3661    fn inmemory_completed_records_completed_outcome() {
3662        // The companion Task::run path (Workflow::register) also emits the run outcome.
3663        let (result, rows) = run_with_recorder(|| async {
3664            let res = Resources::new();
3665            Task::run(&FiveItems, &res).await
3666        });
3667        assert!(result.is_ok(), "{result:?}");
3668        assert_eq!(
3669            counter(&rows, "cano_stream_runs_total", &[("outcome", "completed")]),
3670            1,
3671            "the in-memory companion records a completed run"
3672        );
3673    }
3674
3675    #[test]
3676    fn inmemory_failed_records_failed_outcome() {
3677        let (result, rows) = run_with_recorder(|| async {
3678            let res = Resources::new();
3679            Task::run(&FailSecond, &res).await
3680        });
3681        assert!(result.is_err(), "{result:?}");
3682        assert_eq!(
3683            counter(&rows, "cano_stream_runs_total", &[("outcome", "failed")]),
3684            1,
3685            "the in-memory companion records a failed run (never cancelled)"
3686        );
3687    }
3688}