|
1 | 1 | use alloy::primitives::{B256, U64}; |
2 | 2 |
|
3 | 3 | use futures::future::join_all; |
4 | | -use futures::StreamExt; |
| 4 | +use futures::stream::FuturesUnordered; |
| 5 | +use futures::{poll, StreamExt}; |
5 | 6 | use std::{collections::HashMap, sync::Arc, time::Duration}; |
6 | 7 | use tokio::sync::Semaphore; |
7 | 8 | use tokio::{ |
@@ -99,25 +100,26 @@ async fn process_event_logs( |
99 | 100 |
|
100 | 101 | let mut logs_stream = |
101 | 102 | fetch_logs_stream(Arc::clone(&config), force_no_live_indexing, reorg_coordinator); |
102 | | - let mut tasks = Vec::new(); |
| 103 | + // Drain inline so handles don't accumulate during infinite live indexing. |
| 104 | + let mut in_flight: FuturesUnordered<JoinHandle<()>> = FuturesUnordered::new(); |
103 | 105 |
|
104 | 106 | while let Some(result) = logs_stream.next().await { |
105 | 107 | let task = handle_logs_result(Arc::clone(&config), callback_permits.clone(), result) |
106 | 108 | .await |
107 | 109 | .map_err(|e| Box::new(ProviderError::CustomError(e.to_string())))?; |
108 | 110 |
|
109 | 111 | if block_until_indexed { |
110 | | - task.await.map_err(|e| Box::new(ProviderError::CustomError(e.to_string())))?; |
| 112 | + task.await.map_err(|e| Box::new(ProviderError::BatchRequestFailed(e)))?; |
111 | 113 | } else { |
112 | | - tasks.push(task); |
| 114 | + in_flight.push(task); |
| 115 | + while let std::task::Poll::Ready(Some(joined)) = poll!(in_flight.next()) { |
| 116 | + joined.map_err(|e| Box::new(ProviderError::BatchRequestFailed(e)))?; |
| 117 | + } |
113 | 118 | } |
114 | 119 | } |
115 | 120 |
|
116 | | - // Wait for all remaining tasks to complete |
117 | | - if !tasks.is_empty() { |
118 | | - futures::future::try_join_all(tasks) |
119 | | - .await |
120 | | - .map_err(|e| Box::new(ProviderError::CustomError(e.to_string())))?; |
| 121 | + while let Some(joined) = in_flight.next().await { |
| 122 | + joined.map_err(|e| Box::new(ProviderError::BatchRequestFailed(e)))?; |
121 | 123 | } |
122 | 124 |
|
123 | 125 | Ok(()) |
@@ -775,3 +777,78 @@ async fn handle_logs_result( |
775 | 777 | } |
776 | 778 | } |
777 | 779 | } |
| 780 | + |
| 781 | +#[cfg(test)] |
| 782 | +mod tests { |
| 783 | + //! Regression tests for the in-flight `JoinHandle` handling in |
| 784 | + //! `process_event_logs`. Each test exercises the exact pattern used |
| 785 | + //! there (push + non-blocking drain + final await-drain) so a future |
| 786 | + //! refactor that silently reintroduces the leak will trip a test. |
| 787 | +
|
| 788 | + use super::*; |
| 789 | + use std::sync::{ |
| 790 | + atomic::{AtomicUsize, Ordering}, |
| 791 | + Arc, |
| 792 | + }; |
| 793 | + use std::task::Poll; |
| 794 | + |
| 795 | + #[tokio::test] |
| 796 | + async fn inline_drain_keeps_queue_bounded_under_live_indexing() { |
| 797 | + let mut in_flight: FuturesUnordered<JoinHandle<()>> = FuturesUnordered::new(); |
| 798 | + let completed = Arc::new(AtomicUsize::new(0)); |
| 799 | + let mut drained_inline = 0usize; |
| 800 | + |
| 801 | + for _ in 0..500 { |
| 802 | + let completed = Arc::clone(&completed); |
| 803 | + in_flight.push(tokio::spawn(async move { |
| 804 | + completed.fetch_add(1, Ordering::SeqCst); |
| 805 | + })); |
| 806 | + tokio::task::yield_now().await; |
| 807 | + while let Poll::Ready(Some(joined)) = poll!(in_flight.next()) { |
| 808 | + joined.expect("task should not fail"); |
| 809 | + drained_inline += 1; |
| 810 | + } |
| 811 | + } |
| 812 | + |
| 813 | + while let Some(joined) = in_flight.next().await { |
| 814 | + joined.expect("task should not fail"); |
| 815 | + } |
| 816 | + |
| 817 | + assert!( |
| 818 | + drained_inline > 0, |
| 819 | + "inline drain never observed a completed task; test did not exercise the live drain path" |
| 820 | + ); |
| 821 | + assert_eq!(completed.load(Ordering::SeqCst), 500, "all spawned tasks should complete"); |
| 822 | + assert_eq!(in_flight.len(), 0, "final drain should empty the queue"); |
| 823 | + } |
| 824 | + |
| 825 | + #[tokio::test] |
| 826 | + async fn final_drain_awaits_pending_tasks_after_stream_ends() { |
| 827 | + let (tx, rx) = tokio::sync::oneshot::channel(); |
| 828 | + let mut in_flight: FuturesUnordered<JoinHandle<()>> = FuturesUnordered::new(); |
| 829 | + in_flight.push(tokio::spawn(async move { |
| 830 | + rx.await.expect("oneshot sender dropped"); |
| 831 | + })); |
| 832 | + |
| 833 | + while let Poll::Ready(Some(joined)) = poll!(in_flight.next()) { |
| 834 | + joined.expect("task should not fail"); |
| 835 | + } |
| 836 | + assert_eq!(in_flight.len(), 1, "non-blocking drain must leave pending tasks in place"); |
| 837 | + |
| 838 | + tx.send(()).expect("receiver dropped"); |
| 839 | + while let Some(joined) = in_flight.next().await { |
| 840 | + joined.expect("task should not fail"); |
| 841 | + } |
| 842 | + assert_eq!(in_flight.len(), 0); |
| 843 | + } |
| 844 | + |
| 845 | + #[tokio::test] |
| 846 | + async fn panic_in_spawned_task_surfaces_as_join_error() { |
| 847 | + let mut in_flight: FuturesUnordered<JoinHandle<()>> = FuturesUnordered::new(); |
| 848 | + in_flight.push(tokio::spawn(async { panic!("boom") })); |
| 849 | + |
| 850 | + let result = in_flight.next().await.expect("task should complete"); |
| 851 | + let err = result.expect_err("panicking task should yield a JoinError"); |
| 852 | + assert!(err.is_panic(), "expected panic cause, got: {err:?}"); |
| 853 | + } |
| 854 | +} |
0 commit comments