tuwunel_service/federation/
feds.rs1mod fold;
7#[cfg(test)]
8mod tests;
9
10use std::{num::NonZeroUsize, time::Duration};
11
12use futures::{Stream, StreamExt, future::Either};
13use ruma::{OwnedServerName, RoomId, ServerName, api::OutgoingRequest};
14use tokio::time::{Instant, timeout};
15use tuwunel_core::{
16 Error, Result, implement,
17 utils::{
18 math::effective_cap,
19 stream::{BroadbandExt, ReadyExt},
20 },
21};
22
23pub use self::fold::{Faults, Grid, Origins, OutcomeExt, Tally};
28use super::{
29 Classification,
30 scheme::{FedAuth, FedPath},
31};
32
33const WIDTH_DEFAULT: NonZeroUsize = NonZeroUsize::new(32).expect("width is nonzero");
34const TIMEOUT_DEFAULT: Duration = Duration::from_secs(15);
35
36#[derive(Clone, Copy, Debug, Default)]
42pub struct Opts {
43 pub width: Option<NonZeroUsize>,
45
46 pub timeout: Option<Duration>,
48
49 pub sweep_deadline: Option<Duration>,
51
52 pub exclude_self: bool,
54
55 pub record: Record,
57}
58
59#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
64pub enum Record {
65 #[default]
67 Observe,
68
69 Contribute,
71}
72
73#[derive(Debug)]
78pub struct Outcome<R> {
79 pub origin: OwnedServerName,
81
82 pub elapsed: Duration,
84
85 pub result: Result<R, Fault>,
87}
88
89#[derive(Debug)]
94pub enum Fault {
95 Elapsed,
97
98 NotAttempted,
100
101 Backoff {
103 class: Classification,
105
106 age: Duration,
108
109 retry: Duration,
111 },
112
113 Error(Error),
115}
116
117#[implement(super::Service)]
122pub fn for_room<'a, F, R>(
123 &'a self,
124 room_id: &'a RoomId,
125 make: F,
126 opts: Opts,
127) -> impl Stream<Item = Outcome<R::IncomingResponse>> + Send + 'a
128where
129 F: Fn(&ServerName) -> R + Send + 'a,
130 R: OutgoingRequest + Send + 'a,
131 R::IncomingResponse: Send,
132 R::Authentication: FedAuth,
133 R::PathBuilder: FedPath,
134{
135 let dests = self
136 .services
137 .state_cache
138 .room_servers(room_id)
139 .ready_filter(move |server| {
140 !opts.exclude_self || !self.services.globals.server_is_ours(server)
141 })
142 .map(ToOwned::to_owned);
143
144 self.fanout_to(dests, make, opts)
145}
146
147#[implement(super::Service)]
152pub fn fanout_to<'a, D, F, R>(
153 &'a self,
154 dests: D,
155 make: F,
156 opts: Opts,
157) -> impl Stream<Item = Outcome<R::IncomingResponse>> + Send + 'a
158where
159 D: Stream<Item = OwnedServerName> + Send + 'a,
160 F: Fn(&ServerName) -> R + Send + 'a,
161 R: OutgoingRequest + Send + 'a,
162 R::IncomingResponse: Send,
163 R::Authentication: FedAuth,
164 R::PathBuilder: FedPath,
165{
166 let pairs = dests.map(move |origin| {
167 let request = make(&origin);
168
169 (origin, request)
170 });
171
172 self.fanout(pairs, opts)
173}
174
175#[implement(super::Service)]
181#[expect(
182 closure_returning_async_block,
183 reason = "the capturing async closure would not implement the reusable Fn bound"
184)]
185pub fn fanout<'a, S, R>(
186 &'a self,
187 pairs: S,
188 opts: Opts,
189) -> impl Stream<Item = Outcome<R::IncomingResponse>> + Send + 'a
190where
191 S: Stream<Item = (OwnedServerName, R)> + Send + 'a,
192 R: OutgoingRequest + Send + 'a,
193 R::IncomingResponse: Send,
194 R::Authentication: FedAuth,
195 R::PathBuilder: FedPath,
196{
197 let config = &self.services.server.config;
198 let opts = resolve_opts(opts, config.feds_max_width, config.feds_timeout);
199 let client = &self.services.client.federation;
200 let record = opts.record;
201
202 fanout_with(
203 pairs,
204 move |dest, request| async move {
205 match record {
206 | Record::Observe =>
207 self.execute_uncounted_allow_self(client, &dest, request)
208 .await,
209 | Record::Contribute =>
210 self.execute_on_allow_self(client, &dest, request)
211 .await,
212 }
213 },
214 opts,
215 )
216 .inspect(move |outcome| {
217 if record == Record::Contribute && matches!(&outcome.result, Err(Fault::Elapsed)) {
218 self.record_failure(&outcome.origin, Classification::Transient);
219 }
220 })
221}
222
223fn resolve_opts(opts: Opts, config_width: usize, config_timeout: u64) -> Opts {
224 let width = if opts.width.is_none() && config_width == 0 {
225 WIDTH_DEFAULT
226 } else {
227 NonZeroUsize::new(effective_cap(opts.width, config_width))
228 .expect("effective cap is nonzero")
229 };
230
231 Opts {
232 width: Some(width),
233 timeout: Some(
234 opts.timeout
235 .unwrap_or_else(|| Duration::from_secs(config_timeout)),
236 ),
237 ..opts
238 }
239}
240
241pub fn fanout_with<S, I, T, F, Fut>(
246 pairs: S,
247 send: F,
248 opts: Opts,
249) -> impl Stream<Item = Outcome<T>> + Send
250where
251 S: Stream<Item = (OwnedServerName, I)> + Send,
252 F: Fn(OwnedServerName, I) -> Fut + Send,
253 Fut: Future<Output = Result<T>> + Send,
254 I: Send,
255 T: Send,
256{
257 let width = opts.width.unwrap_or(WIDTH_DEFAULT).get();
258 let request_timeout = opts.timeout.unwrap_or(TIMEOUT_DEFAULT);
259 let deadline = opts
260 .sweep_deadline
261 .and_then(|duration| Instant::now().checked_add(duration));
262
263 pairs
264 .map(move |(origin, payload)| {
265 let start = Instant::now();
266
267 if deadline.is_some_and(|deadline| start >= deadline) {
268 Either::Left(Outcome {
269 origin,
270 elapsed: Duration::ZERO,
271 result: Err(Fault::NotAttempted),
272 })
273 } else {
274 let remaining = deadline.map_or(request_timeout, |deadline| {
275 deadline
276 .saturating_duration_since(start)
277 .min(request_timeout)
278 });
279 let future = send(origin.clone(), payload);
280
281 Either::Right((origin, start, remaining, future))
282 }
283 })
284 .broadn_then(width, async move |request| match request {
285 | Either::Left(outcome) => outcome,
286 | Either::Right((origin, start, remaining, future)) => {
287 let result = match timeout(remaining, future).await {
288 | Ok(result) => result.map_err(Fault::Error),
289 | Err(_elapsed) => Err(Fault::Elapsed),
290 };
291
292 Outcome { origin, elapsed: start.elapsed(), result }
293 },
294 })
295}