Skip to main content

rskit_stream/
lib.rs

1//! Foundational async stream toolkit.
2//!
3//! `rskit-stream` owns the opinion-free, layer-zero building blocks for the
4//! "observe → fan-out → consume" graph that recurs across config reloads,
5//! service discovery, cache invalidation, secret rotation, and message
6//! consumers. Sources, the bounded fan-out bus, cancellable consumer tasks,
7//! and the `futures::Stream` extension operators that chain them all live
8//! together at the foundation where any higher layer can reuse them without
9//! inverting the layer order.
10//!
11//! Sequential, named-step workflows (run N steps, report progress, cancel)
12//! are a different concern and live in `rskit-chain`.
13
14#![warn(missing_docs)]
15
16/// Bounded fan-out broadcaster source (`Broadcaster<T>`).
17pub mod broadcaster;
18/// Extension trait adding `rskit` operators to any `Stream`.
19pub mod ext;
20/// Higher-level stream operators (map, filter, fan-out, windowing, etc.).
21pub mod operators;
22/// Terminal sink combinators (`collect`, `drain`, `for_each`).
23pub mod sink;
24/// Stream source constructors (`from_slice`, `from_fn`, `from_channel`).
25pub mod source;
26/// Cancellable owned tasks (`SpawnedTask`, `TaskGroup`).
27pub mod task;
28
29pub use broadcaster::{BroadcastStream, Broadcaster, DEFAULT_BROADCAST_BUFFER};
30pub use ext::RskitStreamExt;
31pub use operators::combine::{concat, merge};
32pub use sink::{collect, drain, for_each};
33pub use source::{from_channel, from_fn, from_slice};
34pub use task::{SpawnedTask, TaskGroup};
35
36pub use tokio_util::sync::CancellationToken;
37
38#[cfg(test)]
39mod tests {
40    use parking_lot::Mutex;
41    use std::sync::Arc;
42    use std::time::Duration;
43
44    use futures::StreamExt as _;
45
46    use crate::{RskitStreamExt, from_fn, from_slice, merge};
47
48    // ── Sources ───────────────────────────────────────────────────────────
49
50    /// `from_slice` must yield every item in the original order.
51    #[tokio::test]
52    async fn test_from_slice_yields_all_in_order() {
53        let items = vec![1u32, 2, 3, 4, 5];
54        let stream = from_slice(items.clone());
55        let collected: Vec<u32> = stream.collect().await;
56        assert_eq!(collected, items);
57    }
58
59    /// `from_slice` with an empty vec yields nothing.
60    #[tokio::test]
61    async fn test_from_slice_empty() {
62        let stream = from_slice::<u32>(vec![]);
63        let collected: Vec<u32> = stream.collect().await;
64        assert!(collected.is_empty());
65    }
66
67    /// `from_fn` calls the function repeatedly and stops when it returns `None`.
68    #[tokio::test]
69    async fn test_from_fn_yields_until_none() {
70        let counter = Arc::new(Mutex::new(0u32));
71        let c = counter.clone();
72        let stream = from_fn(move || {
73            let c = c.clone();
74            async move {
75                let mut guard = c.lock();
76                let next = if *guard < 5 {
77                    let val = *guard;
78                    *guard += 1;
79                    Some(val)
80                } else {
81                    None
82                };
83                drop(guard);
84                next
85            }
86        });
87        let collected: Vec<u32> = stream.collect().await;
88        assert_eq!(collected, vec![0, 1, 2, 3, 4]);
89    }
90
91    /// `from_fn` that immediately returns `None` yields nothing.
92    #[tokio::test]
93    async fn test_from_fn_immediate_none() {
94        let stream = from_fn(|| async { None::<u32> });
95        let collected: Vec<u32> = stream.collect().await;
96        assert!(collected.is_empty());
97    }
98
99    /// `merge` interleaves two streams; the combined set of items must match.
100    #[tokio::test]
101    async fn test_merge_set_equality() {
102        let s1 = from_slice(vec![1u32, 3, 5]);
103        let s2 = from_slice(vec![2u32, 4, 6]);
104        let mut combined: Vec<u32> = merge(s1, s2).collect().await;
105        combined.sort_unstable();
106        assert_eq!(combined, vec![1, 2, 3, 4, 5, 6]);
107    }
108
109    /// `merge` of two empty streams yields nothing.
110    #[tokio::test]
111    async fn test_merge_both_empty() {
112        let s1 = from_slice::<u32>(vec![]);
113        let s2 = from_slice::<u32>(vec![]);
114        let combined: Vec<u32> = merge(s1, s2).collect().await;
115        assert!(combined.is_empty());
116    }
117
118    // ── RskitStreamExt::rmap ──────────────────────────────────────────────
119
120    /// `rmap` transforms each item via an async fallible function.
121    #[tokio::test]
122    async fn test_rmap_transforms_items() {
123        let stream = from_slice(vec![1u32, 2, 3]);
124        let results: Vec<_> = stream
125            .rmap(|x| async move { Ok::<u32, rskit_errors::AppError>(x * 10) })
126            .collect()
127            .await;
128        let values: Vec<u32> = results.into_iter().map(|r| r.unwrap()).collect();
129        assert_eq!(values, vec![10, 20, 30]);
130    }
131
132    /// `rmap` propagates errors returned by the function.
133    #[tokio::test]
134    async fn test_rmap_propagates_error() {
135        let stream = from_slice(vec![1u32, 2, 3]);
136        let results: Vec<_> = stream
137            .rmap(|x| async move {
138                if x == 2 {
139                    Err(rskit_errors::AppError::new(
140                        rskit_errors::ErrorCode::Internal,
141                        "bad item",
142                    ))
143                } else {
144                    Ok(x)
145                }
146            })
147            .collect()
148            .await;
149        assert!(results[0].is_ok());
150        assert!(results[1].is_err());
151        assert!(results[2].is_ok());
152    }
153
154    // ── RskitStreamExt::rfilter ───────────────────────────────────────────
155
156    /// `rfilter` keeps only items satisfying the predicate.
157    #[tokio::test]
158    async fn test_rfilter_keeps_matching_items() {
159        let stream = from_slice(vec![1u32, 2, 3, 4, 5, 6]);
160        let evens: Vec<u32> = stream.rfilter(|x| x % 2 == 0).collect().await;
161        assert_eq!(evens, vec![2, 4, 6]);
162    }
163
164    /// `rfilter` with a predicate that matches nothing yields an empty stream.
165    #[tokio::test]
166    async fn test_rfilter_no_match_yields_empty() {
167        let stream = from_slice(vec![1u32, 3, 5]);
168        let result: Vec<u32> = stream.rfilter(|x| x % 2 == 0).collect().await;
169        assert!(result.is_empty());
170    }
171
172    // ── RskitStreamExt::rtap ──────────────────────────────────────────────
173
174    /// `rtap` calls the side-effect for every item and passes items through unchanged.
175    #[tokio::test]
176    async fn test_rtap_calls_side_effect_and_passes_through() {
177        let seen = Arc::new(Mutex::new(Vec::<u32>::new()));
178        let seen_clone = seen.clone();
179
180        let stream = from_slice(vec![10u32, 20, 30]);
181        let output: Vec<u32> = stream
182            .rtap(move |x| {
183                let seen = seen_clone.clone();
184                let val = *x;
185                async move {
186                    seen.lock().push(val);
187                }
188            })
189            .collect()
190            .await;
191
192        assert_eq!(output, vec![10, 20, 30]);
193        assert_eq!(*seen.lock(), vec![10, 20, 30]);
194    }
195
196    // ── RskitStreamExt::rreduce ───────────────────────────────────────────
197
198    /// `rreduce` folds the entire stream into a single accumulated value.
199    #[tokio::test]
200    async fn test_rreduce_folds_to_single_value() {
201        let stream = from_slice(vec![1u32, 2, 3, 4, 5]);
202        let sum = stream.rreduce(0u32, |acc, x| acc + x).await;
203        assert_eq!(sum, 15);
204    }
205
206    /// `rreduce` on an empty stream returns the initial accumulator.
207    #[tokio::test]
208    async fn test_rreduce_empty_stream_returns_init() {
209        let stream = from_slice::<u32>(vec![]);
210        let result = stream.rreduce(42u32, |acc, x| acc + x).await;
211        assert_eq!(result, 42);
212    }
213
214    // ── RskitStreamExt::rparallel ─────────────────────────────────────────
215
216    /// `rparallel` processes items concurrently and collects all results.
217    #[tokio::test]
218    async fn test_rparallel_collects_all_results() {
219        let stream = from_slice(vec![1u32, 2, 3, 4, 5]);
220        let mut results: Vec<u32> = stream
221            .rparallel(
222                3,
223                |x| async move { Ok::<u32, rskit_errors::AppError>(x * 2) },
224            )
225            .collect::<Vec<_>>()
226            .await
227            .into_iter()
228            .map(|r| r.unwrap())
229            .collect();
230        results.sort_unstable();
231        assert_eq!(results, vec![2, 4, 6, 8, 10]);
232    }
233
234    /// `rparallel` propagates errors from the worker function.
235    #[tokio::test]
236    async fn test_rparallel_propagates_errors() {
237        let stream = from_slice(vec![1u32, 2, 3]);
238        let results: Vec<_> = stream
239            .rparallel(2, |x| async move {
240                if x == 2 {
241                    Err(rskit_errors::AppError::new(
242                        rskit_errors::ErrorCode::Internal,
243                        "parallel error",
244                    ))
245                } else {
246                    Ok(x)
247                }
248            })
249            .collect()
250            .await;
251        let error_count = results.iter().filter(|r| r.is_err()).count();
252        assert_eq!(error_count, 1);
253    }
254
255    // ── RskitStreamExt::rfan_out ──────────────────────────────────────────
256
257    /// `rfan_out` applies N functions to each item and collects results in order.
258    ///
259    /// We use non-capturing closures (which are Copy + Clone) so the
260    /// `F: Clone` bound on `rfan_out` is satisfied without unstable features.
261    #[tokio::test]
262    async fn test_rfan_out_applies_all_functions() {
263        // Non-capturing closures are Copy, so they satisfy Clone.
264        let add_one = |x: u32| std::future::ready(Ok::<u32, rskit_errors::AppError>(x + 1));
265        let mul_two = |x: u32| std::future::ready(Ok::<u32, rskit_errors::AppError>(x * 2));
266
267        // First check: single add_one function
268        let stream_a = from_slice(vec![5u32, 10u32]);
269        let res_a: Vec<_> = stream_a.rfan_out(1, vec![add_one]).collect().await;
270        let res_a: Vec<Vec<_>> = res_a.into_iter().map(Result::unwrap).collect();
271        assert_eq!(res_a[0][0], 6u32);
272        assert_eq!(res_a[1][0], 11u32);
273
274        // Second check: two homogeneous functions of the same concrete type
275        let stream_b = from_slice(vec![5u32, 10u32]);
276        let res_b: Vec<_> = stream_b.rfan_out(2, vec![add_one, mul_two]).collect().await;
277        let res_b: Vec<Vec<_>> = res_b.into_iter().map(Result::unwrap).collect();
278        // item 5  → [5+1=6, 5*2=10]
279        assert_eq!(res_b[0][0], 6u32);
280        assert_eq!(res_b[0][1], 10u32);
281        // item 10 → [10+1=11, 10*2=20]
282        assert_eq!(res_b[1][0], 11u32);
283        assert_eq!(res_b[1][1], 20u32);
284    }
285
286    /// `rfan_out` with a single function behaves like rmap.
287    #[tokio::test]
288    async fn test_rfan_out_single_function() {
289        let stream = from_slice(vec![3u32, 7u32]);
290        // Non-capturing closure is Copy + Clone.
291        let f = |x: u32| std::future::ready(Ok::<u32, rskit_errors::AppError>(x + 100));
292        let results: Vec<_> = stream.rfan_out(1, vec![f]).collect().await;
293        let results: Vec<Vec<_>> = results.into_iter().map(Result::unwrap).collect();
294        assert_eq!(results.len(), 2);
295        assert_eq!(results[0][0], 103u32);
296        assert_eq!(results[1][0], 107u32);
297    }
298
299    // ── Windowing: rbatch ─────────────────────────────────────────────────
300
301    /// `rbatch` with size=3 produces batches of exactly 3 items when enough arrive.
302    #[tokio::test]
303    async fn test_rbatch_exact_size_batches() {
304        tokio::time::pause();
305
306        let stream = from_slice(vec![1u32, 2, 3, 4, 5, 6]);
307        let handle = tokio::spawn(async move {
308            stream
309                .rbatch(3, Duration::from_millis(500))
310                .collect::<Vec<_>>()
311                .await
312        });
313
314        tokio::time::advance(Duration::from_millis(600)).await;
315        let batches = handle.await.unwrap();
316
317        assert_eq!(batches.len(), 2);
318        assert_eq!(batches[0], vec![1, 2, 3]);
319        assert_eq!(batches[1], vec![4, 5, 6]);
320    }
321
322    /// `rbatch` flushes a partial batch on timeout.
323    #[tokio::test]
324    async fn test_rbatch_partial_flush_on_timeout() {
325        tokio::time::pause();
326
327        // Channel-based stream so we can control item arrival timing.
328        let (tx, rx) = tokio::sync::mpsc::channel::<u32>(16);
329        let stream = crate::source::from_channel(rx);
330
331        let handle = tokio::spawn(async move {
332            stream
333                .rbatch(10, Duration::from_millis(100))
334                .collect::<Vec<_>>()
335                .await
336        });
337
338        // Send 2 items then let the timeout fire.
339        tx.send(1).await.unwrap();
340        tx.send(2).await.unwrap();
341        drop(tx); // close channel after items sent
342
343        tokio::time::advance(Duration::from_millis(200)).await;
344        let batches = handle.await.unwrap();
345
346        assert_eq!(batches.len(), 1);
347        assert_eq!(batches[0], vec![1, 2]);
348    }
349
350    // ── Rate: rdebounce ───────────────────────────────────────────────────
351
352    /// `rdebounce` only emits the last item when the quiet window expires.
353    #[tokio::test]
354    async fn test_rdebounce_emits_last_item() {
355        tokio::time::pause();
356
357        let (tx, rx) = tokio::sync::mpsc::channel::<u32>(16);
358        let stream = crate::source::from_channel(rx);
359
360        let handle = tokio::spawn(async move {
361            stream
362                .rdebounce(Duration::from_millis(100))
363                .collect::<Vec<_>>()
364                .await
365        });
366
367        // Three rapid items — only the last should pass through.
368        tx.send(1).await.unwrap();
369        tx.send(2).await.unwrap();
370        tx.send(3).await.unwrap();
371        drop(tx);
372
373        tokio::time::advance(Duration::from_millis(200)).await;
374        let result = handle.await.unwrap();
375
376        // After the channel closes, the pending item must be flushed.
377        assert!(!result.is_empty());
378        assert_eq!(*result.last().unwrap(), 3u32);
379    }
380
381    // ── Rate: rdebounce_batch ─────────────────────────────────────────────
382
383    /// A burst of items with no quiet gap collapses into one trailing-edge
384    /// batch, in arrival order. Delays live inside the source so `start_paused`
385    /// auto-advances the clock deterministically.
386    #[tokio::test(start_paused = true)]
387    async fn test_rdebounce_batch_collapses_a_burst() {
388        let source = async_stream::stream! {
389            yield 1u32;
390            yield 2u32;
391            // Still inside the 100ms window: only pushes the deadline out.
392            tokio::time::sleep(Duration::from_millis(50)).await;
393            yield 3u32;
394        };
395        let result: Vec<Vec<u32>> = source
396            .rdebounce_batch(Duration::from_millis(100), 1024)
397            .collect()
398            .await;
399        assert_eq!(result, vec![vec![1u32, 2, 3]]);
400    }
401
402    /// Items separated by a quiet gap wider than the window land in separate
403    /// batches.
404    #[tokio::test(start_paused = true)]
405    async fn test_rdebounce_batch_splits_separate_windows() {
406        let source = async_stream::stream! {
407            yield 1u32;
408            tokio::time::sleep(Duration::from_millis(150)).await;
409            yield 2u32;
410            tokio::time::sleep(Duration::from_millis(150)).await;
411        };
412        let result: Vec<Vec<u32>> = source
413            .rdebounce_batch(Duration::from_millis(100), 1024)
414            .collect()
415            .await;
416        assert_eq!(result, vec![vec![1u32], vec![2u32]]);
417    }
418
419    /// The source closing before the quiet window elapses still flushes the
420    /// pending, not-yet-emitted window.
421    #[tokio::test(start_paused = true)]
422    async fn test_rdebounce_batch_flushes_pending_on_close() {
423        let source = async_stream::stream! { yield 7u32; };
424        let result: Vec<Vec<u32>> = source
425            .rdebounce_batch(Duration::from_millis(100), 1024)
426            .collect()
427            .await;
428        assert_eq!(result, vec![vec![7u32]]);
429    }
430
431    /// The `max_items` safety cap force-flushes mid-burst so a sustained input
432    /// rate faster than the quiet window cannot grow the buffer without bound.
433    #[tokio::test(start_paused = true)]
434    async fn test_rdebounce_batch_force_flushes_at_cap() {
435        // A burst of 5 items with no quiet gap: a cap of 2 must split it into
436        // [1,2], [3,4], then flush the trailing [5] on close.
437        let source = async_stream::stream! {
438            for value in 1u32..=5 {
439                yield value;
440            }
441        };
442        let result: Vec<Vec<u32>> = source
443            .rdebounce_batch(Duration::from_millis(100), 2)
444            .collect()
445            .await;
446        assert_eq!(result, vec![vec![1u32, 2], vec![3u32, 4], vec![5u32]]);
447    }
448
449    /// Dropping the consumer (the debounced stream) tears the pipeline down: it
450    /// owns the source, so the source receiver is dropped and further sends fail.
451    #[tokio::test]
452    async fn test_rdebounce_batch_consumer_drop_ends_pipeline() {
453        let (tx, rx) = tokio::sync::mpsc::channel::<u32>(16);
454        let batched =
455            crate::source::from_channel(rx).rdebounce_batch(Duration::from_millis(100), 1024);
456        drop(batched); // Consumer gone before any window is emitted.
457
458        assert!(
459            tx.send(0).await.is_err(),
460            "dropping the debounced stream must close the source"
461        );
462    }
463
464    // ── Rate: rthrottle ───────────────────────────────────────────────────
465
466    /// `rthrottle` drops items arriving faster than the interval.
467    #[tokio::test]
468    async fn test_rthrottle_drops_fast_items() {
469        tokio::time::pause();
470
471        let stream = from_slice(vec![1u32, 2, 3, 4, 5]);
472        let handle = tokio::spawn(async move {
473            stream
474                .rthrottle(Duration::from_millis(100))
475                .collect::<Vec<_>>()
476                .await
477        });
478
479        tokio::time::advance(Duration::from_millis(600)).await;
480        let result = handle.await.unwrap();
481
482        // The first item is always emitted; subsequent items are dropped
483        // because the stream is synchronous and all items arrive "instantly"
484        // before the interval can pass.
485        assert!(!result.is_empty());
486        assert_eq!(result[0], 1u32);
487        // All items after the first should have been throttled away.
488        assert!(result.len() < 5);
489    }
490
491    /// `rthrottle` with an extreme interval still emits the first item without
492    /// panicking (guards against an underflowing `now - interval` seed).
493    #[tokio::test]
494    async fn test_rthrottle_huge_interval_emits_first_without_panic() {
495        tokio::time::pause();
496
497        let stream = from_slice(vec![1u32, 2, 3]);
498        let result = stream
499            .rthrottle(Duration::from_secs(u64::MAX))
500            .collect::<Vec<_>>()
501            .await;
502
503        assert_eq!(result, vec![1u32]);
504    }
505
506    // ── Windowing: rtumbling_window ───────────────────────────────────────
507
508    /// `rtumbling_window` emits a non-empty window when the timer fires.
509    #[tokio::test]
510    async fn test_rtumbling_window_emits_on_timer() {
511        tokio::time::pause();
512
513        let (tx, rx) = tokio::sync::mpsc::channel::<u32>(16);
514        let stream = crate::source::from_channel(rx);
515
516        let handle = tokio::spawn(async move {
517            stream
518                .rtumbling_window(Duration::from_millis(100), 128)
519                .collect::<Vec<_>>()
520                .await
521        });
522
523        // Send items that should land in the first window.
524        tx.send(10).await.unwrap();
525        tx.send(20).await.unwrap();
526        tx.send(30).await.unwrap();
527        drop(tx);
528
529        tokio::time::advance(Duration::from_millis(200)).await;
530        let windows = handle.await.unwrap();
531
532        assert!(!windows.is_empty());
533        let all_items: Vec<u32> = windows.into_iter().flatten().collect();
534        let mut sorted = all_items;
535        sorted.sort_unstable();
536        assert_eq!(sorted, vec![10, 20, 30]);
537    }
538
539    /// `rtumbling_window` yields an empty stream when input is empty.
540    #[tokio::test]
541    async fn test_rtumbling_window_empty_input() {
542        tokio::time::pause();
543
544        let stream = from_slice::<u32>(vec![]);
545        let handle = tokio::spawn(async move {
546            stream
547                .rtumbling_window(Duration::from_millis(100), 128)
548                .collect::<Vec<_>>()
549                .await
550        });
551
552        tokio::time::advance(Duration::from_millis(200)).await;
553        let windows = handle.await.unwrap();
554        assert!(windows.is_empty());
555    }
556
557    #[tokio::test]
558    async fn test_rdistinct_filters_duplicates() {
559        let stream = from_slice(vec![1u32, 2, 2, 3, 1, 4]);
560        let values: Vec<u32> = stream.rdistinct().collect().await;
561        assert_eq!(values, vec![1, 2, 3, 4]);
562    }
563
564    #[tokio::test]
565    async fn test_rtake_and_rskip_compose() {
566        let stream = from_slice(vec![1u32, 2, 3, 4, 5]);
567        let values: Vec<u32> = stream.rskip(1).rtake(3).collect().await;
568        assert_eq!(values, vec![2, 3, 4]);
569    }
570
571    #[tokio::test]
572    async fn test_rpartition_splits_stream() {
573        let stream = from_slice(vec![1u32, 2, 3, 4, 5, 6]);
574        let (even_stream, odd_stream) = stream.rpartition(|value| value % 2 == 0);
575        let (evens, odds) = tokio::join!(
576            even_stream.collect::<Vec<_>>(),
577            odd_stream.collect::<Vec<_>>()
578        );
579        assert_eq!(evens, vec![2, 4, 6]);
580        assert_eq!(odds, vec![1, 3, 5]);
581    }
582
583    #[tokio::test]
584    async fn test_rsliding_window_emits_overlapping_windows() {
585        let stream = from_slice(vec![1u32, 2, 3, 4, 5]);
586        let windows: Vec<Vec<u32>> = stream.rsliding_window(3, 1).collect().await;
587        assert_eq!(windows, vec![vec![1, 2, 3], vec![2, 3, 4], vec![3, 4, 5]]);
588    }
589}