Skip to main content

soma_fleet/
fanout.rs

1use std::future::Future;
2use std::sync::Arc;
3use std::time::Duration;
4
5use futures::{StreamExt, stream};
6use tokio_util::sync::CancellationToken;
7
8use crate::{HostId, HostRecord};
9
10/// Bounds for concurrent target execution.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct FanoutPolicy {
13    max_concurrency: usize,
14    per_target_timeout: Duration,
15}
16
17impl FanoutPolicy {
18    /// Creates a bounded fanout policy.
19    pub fn new(
20        max_concurrency: usize,
21        per_target_timeout: Duration,
22    ) -> Result<Self, FanoutPolicyError> {
23        if max_concurrency == 0 {
24            return Err(FanoutPolicyError::ZeroConcurrency);
25        }
26        if per_target_timeout.is_zero() {
27            return Err(FanoutPolicyError::ZeroTimeout);
28        }
29        Ok(Self {
30            max_concurrency,
31            per_target_timeout,
32        })
33    }
34
35    /// Returns maximum in-flight targets.
36    #[must_use]
37    pub const fn max_concurrency(self) -> usize {
38        self.max_concurrency
39    }
40
41    /// Returns the timeout applied after one target begins execution.
42    #[must_use]
43    pub const fn per_target_timeout(self) -> Duration {
44        self.per_target_timeout
45    }
46}
47
48/// Invalid bounded fanout policy.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
50pub enum FanoutPolicyError {
51    /// At least one target must be allowed to execute.
52    #[error("fanout concurrency must be greater than zero")]
53    ZeroConcurrency,
54    /// Per-target timeout must be positive.
55    #[error("fanout per-target timeout must be greater than zero")]
56    ZeroTimeout,
57}
58
59/// Terminal classification for one target.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum TargetOutcomeKind<T, E> {
62    /// The target completed successfully.
63    Succeeded(T),
64    /// The target completed with a driver or operation error.
65    Failed(E),
66    /// Cancellation was observed before completion.
67    Cancelled,
68    /// The target exceeded the configured per-target timeout.
69    TimedOut,
70}
71
72/// Stable-order result for one target.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct TargetOutcome<T, E> {
75    index: usize,
76    host: HostId,
77    kind: TargetOutcomeKind<T, E>,
78}
79
80impl<T, E> TargetOutcome<T, E> {
81    /// Returns the original target index.
82    #[must_use]
83    pub const fn index(&self) -> usize {
84        self.index
85    }
86
87    /// Returns the target host identity.
88    #[must_use]
89    pub fn host(&self) -> &HostId {
90        &self.host
91    }
92
93    /// Returns the terminal classification.
94    #[must_use]
95    pub fn kind(&self) -> &TargetOutcomeKind<T, E> {
96        &self.kind
97    }
98
99    /// Consumes the outcome into its stable index, host, and terminal kind.
100    #[must_use]
101    pub fn into_parts(self) -> (usize, HostId, TargetOutcomeKind<T, E>) {
102        (self.index, self.host, self.kind)
103    }
104}
105
106/// Complete stable-order fanout report.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct FanoutReport<T, E> {
109    outcomes: Vec<TargetOutcome<T, E>>,
110}
111
112impl<T, E> FanoutReport<T, E> {
113    /// Returns outcomes in original target order.
114    #[must_use]
115    pub fn outcomes(&self) -> &[TargetOutcome<T, E>] {
116        &self.outcomes
117    }
118
119    /// Consumes the report and returns outcomes in original target order.
120    #[must_use]
121    pub fn into_outcomes(self) -> Vec<TargetOutcome<T, E>> {
122        self.outcomes
123    }
124
125    /// Returns successful target count.
126    #[must_use]
127    pub fn success_count(&self) -> usize {
128        self.outcomes
129            .iter()
130            .filter(|outcome| matches!(outcome.kind, TargetOutcomeKind::Succeeded(_)))
131            .count()
132    }
133
134    /// Returns failed target count.
135    #[must_use]
136    pub fn failure_count(&self) -> usize {
137        self.outcomes
138            .iter()
139            .filter(|outcome| matches!(outcome.kind, TargetOutcomeKind::Failed(_)))
140            .count()
141    }
142
143    /// Returns cancelled target count.
144    #[must_use]
145    pub fn cancelled_count(&self) -> usize {
146        self.outcomes
147            .iter()
148            .filter(|outcome| matches!(outcome.kind, TargetOutcomeKind::Cancelled))
149            .count()
150    }
151
152    /// Returns timed-out target count.
153    #[must_use]
154    pub fn timed_out_count(&self) -> usize {
155        self.outcomes
156            .iter()
157            .filter(|outcome| matches!(outcome.kind, TargetOutcomeKind::TimedOut))
158            .count()
159    }
160
161    /// Returns whether every target succeeded.
162    #[must_use]
163    pub fn all_succeeded(&self) -> bool {
164        self.success_count() == self.outcomes.len()
165    }
166}
167
168/// Cancellation-aware stable-order bounded fanout scheduler.
169#[derive(Debug, Clone, Copy)]
170pub struct FanoutScheduler {
171    policy: FanoutPolicy,
172}
173
174impl FanoutScheduler {
175    /// Creates a scheduler from validated bounds.
176    #[must_use]
177    pub const fn new(policy: FanoutPolicy) -> Self {
178        Self { policy }
179    }
180
181    /// Executes one operation for every target with bounded concurrency.
182    ///
183    /// Returned outcomes are sorted back into the caller's target order even
184    /// when faster targets complete first. Pending targets become cancelled
185    /// after the shared token is cancelled instead of disappearing.
186    pub async fn run<T, E, F, Fut>(
187        &self,
188        targets: Vec<HostRecord>,
189        cancellation: CancellationToken,
190        operation: F,
191    ) -> FanoutReport<T, E>
192    where
193        T: Send,
194        E: Send,
195        F: Fn(HostRecord, CancellationToken) -> Fut + Send + Sync,
196        Fut: Future<Output = Result<T, E>> + Send,
197    {
198        self.run_with_payload(
199            targets.into_iter().map(|host| (host, ())).collect(),
200            cancellation,
201            move |host, (), child| operation(host, child),
202        )
203        .await
204    }
205
206    /// Executes one operation for every host/payload pair with bounded concurrency.
207    ///
208    /// Payloads remain paired with their original target index, allowing callers
209    /// to fan out distinct requests to the same host without key-based races.
210    pub async fn run_with_payload<P, T, E, F, Fut>(
211        &self,
212        targets: Vec<(HostRecord, P)>,
213        cancellation: CancellationToken,
214        operation: F,
215    ) -> FanoutReport<T, E>
216    where
217        P: Send,
218        T: Send,
219        E: Send,
220        F: Fn(HostRecord, P, CancellationToken) -> Fut + Send + Sync,
221        Fut: Future<Output = Result<T, E>> + Send,
222    {
223        let operation = Arc::new(operation);
224        let timeout = self.policy.per_target_timeout;
225        let mut outcomes = stream::iter(targets.into_iter().enumerate())
226            .map(|(index, (host, payload))| {
227                let operation = Arc::clone(&operation);
228                let child = cancellation.child_token();
229                async move {
230                    let host_id = host.id().clone();
231                    let future = operation(host, payload, child.clone());
232                    let kind = tokio::select! {
233                        () = child.cancelled() => TargetOutcomeKind::Cancelled,
234                        result = tokio::time::timeout(timeout, future) => match result {
235                            Ok(Ok(value)) => TargetOutcomeKind::Succeeded(value),
236                            Ok(Err(error)) => TargetOutcomeKind::Failed(error),
237                            Err(_) => {
238                                child.cancel();
239                                TargetOutcomeKind::TimedOut
240                            }
241                        }
242                    };
243                    TargetOutcome {
244                        index,
245                        host: host_id,
246                        kind,
247                    }
248                }
249            })
250            .buffer_unordered(self.policy.max_concurrency)
251            .collect::<Vec<_>>()
252            .await;
253        outcomes.sort_by_key(TargetOutcome::index);
254        FanoutReport { outcomes }
255    }
256}
257
258#[cfg(test)]
259#[path = "fanout_tests.rs"]
260mod tests;