1use 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
92pub enum StreamErrorPolicy {
93 #[default]
95 FailFast,
96 SkipAndContinue,
99 RetryOnError {
102 max_errors: u32,
104 },
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum StreamWindow {
112 Count(usize),
116 Duration(std::time::Duration),
124}
125
126pub type StreamBatch = StreamWindow;
128
129#[derive(Debug)]
131pub enum WindowSignal<TState> {
132 Continue,
134 Stop(TaskResult<TState>),
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum CloseReason {
142 Exhausted,
144 Cancelled,
147}
148
149#[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 type Item: Send + 'static;
178 type Output: Send + 'static;
180 type Cursor: Serialize + DeserializeOwned + Send + Sync + 'static;
182
183 fn window(&self) -> StreamWindow {
185 StreamWindow::Count(1)
186 }
187
188 fn on_item_error(&self) -> StreamErrorPolicy {
190 StreamErrorPolicy::FailFast
191 }
192
193 fn config(&self) -> TaskConfig {
204 TaskConfig::minimal()
205 }
206
207 fn name(&self) -> Cow<'static, str> {
210 Cow::Borrowed(std::any::type_name::<Self>())
211 }
212
213 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 async fn process_item(
224 &self,
225 res: &Resources<TResourceKey>,
226 item: Self::Item,
227 ) -> Result<(Self::Output, Self::Cursor), CanoError>;
228
229 async fn flush_window(
235 &self,
236 res: &Resources<TResourceKey>,
237 outputs: Vec<Self::Output>,
238 ) -> Result<WindowSignal<TState>, CanoError>;
239
240 async fn on_close(
260 &self,
261 res: &Resources<TResourceKey>,
262 reason: CloseReason,
263 ) -> Result<TaskResult<TState>, CanoError>;
264
265 #[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
286pub(crate) enum WindowStep<TCursor, TState> {
293 Window { cursor: TCursor },
295 Done {
298 final_cursor: Option<TCursor>,
299 result: TaskResult<TState>,
300 },
301 Cancelled { final_cursor: Option<TCursor> },
306}
307
308const MIN_DURATION_WINDOW: std::time::Duration = std::time::Duration::from_millis(1);
314
315#[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 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 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 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 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 let _ = task.on_close(res, CloseReason::Cancelled).await;
381 return Err(e);
382 }
383 }
384 let _ = task.on_close(res, CloseReason::Cancelled).await?;
388 return Ok(WindowStep::Cancelled { final_cursor: last_cursor });
389 }
390 _ = tick => {
391 if buf.is_empty() {
393 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 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 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
466async 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
498async 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 WindowStep::Cancelled { .. } => return Err(CanoError::cancelled()),
534 }
535 }
536 }
537 .await;
538
539 #[cfg(feature = "metrics")]
541 crate::metrics::stream_run(if result.is_ok() {
542 "completed"
543 } else {
544 "failed"
545 });
546 result
547}
548
549pub enum ErasedWindowStep<TState> {
555 Window { cursor: Vec<u8> },
557 Done {
559 final_cursor: Option<Vec<u8>>,
560 result: TaskResult<TState>,
561 },
562 Cancelled { final_cursor: Option<Vec<u8>> },
564}
565
566pub type WindowFuture<'a, TState> =
568 Pin<Box<dyn Future<Output = Result<ErasedWindowStep<TState>, CanoError>> + Send + 'a>>;
569
570pub trait ErasedStreamSession<TState, TResourceKey>: Send
573where
574 TState: Clone + Send + Sync + 'static,
575 TResourceKey: Hash + Eq + Send + Sync + 'static,
576{
577 fn next_window<'a>(
579 &'a mut self,
580 res: &'a Resources<TResourceKey>,
581 token: &'a CancellationToken,
582 ) -> WindowFuture<'a, TState>;
583}
584
585pub 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
594pub 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 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
612struct 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
674pub(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#[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 #[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 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 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 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 use std::collections::HashMap;
954 use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
955
956 #[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 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)) }
1112
1113 async fn flush_window(
1114 &self,
1115 _res: &Resources,
1116 outputs: Vec<u64>,
1117 ) -> Result<WindowSignal<Step>, CanoError> {
1118 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 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 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 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 #[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) }
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 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 #[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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 });
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 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 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 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 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 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 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 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 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 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 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 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 #[tokio::test]
2135 async fn skip_does_not_commit_bad_item_cursor() {
2136 use crate::cancel::CancellationToken;
2137 use crate::workflow::Workflow;
2138
2139 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let r1 = workflow
2818 .orchestrate(Step::Consume, CancellationToken::disabled())
2819 .await;
2820 assert!(r1.is_err(), "run 1 should crash: {r1:?}");
2821
2822 {
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 #[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 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 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 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 #[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 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 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 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 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 #[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 #[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 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 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 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 let result = workflow
3355 .resume_from("crash-resume", CancellationToken::disabled())
3356 .await
3357 .unwrap();
3358 assert_eq!(result, Step::Done);
3359 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 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 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 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 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 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 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 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}