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    // ── Windowing: 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    // ── Windowing: rthrottle ──────────────────────────────────────────────
382
383    /// `rthrottle` drops items arriving faster than the interval.
384    #[tokio::test]
385    async fn test_rthrottle_drops_fast_items() {
386        tokio::time::pause();
387
388        let stream = from_slice(vec![1u32, 2, 3, 4, 5]);
389        let handle = tokio::spawn(async move {
390            stream
391                .rthrottle(Duration::from_millis(100))
392                .collect::<Vec<_>>()
393                .await
394        });
395
396        tokio::time::advance(Duration::from_millis(600)).await;
397        let result = handle.await.unwrap();
398
399        // The first item is always emitted; subsequent items are dropped
400        // because the stream is synchronous and all items arrive "instantly"
401        // before the interval can pass.
402        assert!(!result.is_empty());
403        assert_eq!(result[0], 1u32);
404        // All items after the first should have been throttled away.
405        assert!(result.len() < 5);
406    }
407
408    // ── Windowing: rtumbling_window ───────────────────────────────────────
409
410    /// `rtumbling_window` emits a non-empty window when the timer fires.
411    #[tokio::test]
412    async fn test_rtumbling_window_emits_on_timer() {
413        tokio::time::pause();
414
415        let (tx, rx) = tokio::sync::mpsc::channel::<u32>(16);
416        let stream = crate::source::from_channel(rx);
417
418        let handle = tokio::spawn(async move {
419            stream
420                .rtumbling_window(Duration::from_millis(100), 128)
421                .collect::<Vec<_>>()
422                .await
423        });
424
425        // Send items that should land in the first window.
426        tx.send(10).await.unwrap();
427        tx.send(20).await.unwrap();
428        tx.send(30).await.unwrap();
429        drop(tx);
430
431        tokio::time::advance(Duration::from_millis(200)).await;
432        let windows = handle.await.unwrap();
433
434        assert!(!windows.is_empty());
435        let all_items: Vec<u32> = windows.into_iter().flatten().collect();
436        let mut sorted = all_items;
437        sorted.sort_unstable();
438        assert_eq!(sorted, vec![10, 20, 30]);
439    }
440
441    /// `rtumbling_window` yields an empty stream when input is empty.
442    #[tokio::test]
443    async fn test_rtumbling_window_empty_input() {
444        tokio::time::pause();
445
446        let stream = from_slice::<u32>(vec![]);
447        let handle = tokio::spawn(async move {
448            stream
449                .rtumbling_window(Duration::from_millis(100), 128)
450                .collect::<Vec<_>>()
451                .await
452        });
453
454        tokio::time::advance(Duration::from_millis(200)).await;
455        let windows = handle.await.unwrap();
456        assert!(windows.is_empty());
457    }
458
459    #[tokio::test]
460    async fn test_rdistinct_filters_duplicates() {
461        let stream = from_slice(vec![1u32, 2, 2, 3, 1, 4]);
462        let values: Vec<u32> = stream.rdistinct().collect().await;
463        assert_eq!(values, vec![1, 2, 3, 4]);
464    }
465
466    #[tokio::test]
467    async fn test_rtake_and_rskip_compose() {
468        let stream = from_slice(vec![1u32, 2, 3, 4, 5]);
469        let values: Vec<u32> = stream.rskip(1).rtake(3).collect().await;
470        assert_eq!(values, vec![2, 3, 4]);
471    }
472
473    #[tokio::test]
474    async fn test_rpartition_splits_stream() {
475        let stream = from_slice(vec![1u32, 2, 3, 4, 5, 6]);
476        let (even_stream, odd_stream) = stream.rpartition(|value| value % 2 == 0);
477        let (evens, odds) = tokio::join!(
478            even_stream.collect::<Vec<_>>(),
479            odd_stream.collect::<Vec<_>>()
480        );
481        assert_eq!(evens, vec![2, 4, 6]);
482        assert_eq!(odds, vec![1, 3, 5]);
483    }
484
485    #[tokio::test]
486    async fn test_rsliding_window_emits_overlapping_windows() {
487        let stream = from_slice(vec![1u32, 2, 3, 4, 5]);
488        let windows: Vec<Vec<u32>> = stream.rsliding_window(3, 1).collect().await;
489        assert_eq!(windows, vec![vec![1, 2, 3], vec![2, 3, 4], vec![3, 4, 5]]);
490    }
491}