Skip to main content

tuwunel_service/federation/
peer.rs

1//! Per-server reachability store backed by the `servername_status` CF.
2//!
3//! Each failure writes one row keyed `(servername, bucket)` with
4//! `bucket = now.as_secs() / window_secs`; the tuple codec joins the parts with
5//! `ser::SEP`, so the on-disk key is `servername || SEP || u64_be(bucket)`. The
6//! value is the [`Classification`] byte, optionally trailed by the failure
7//! instant as `u64_be` seconds. Two failures in one window collide on the same
8//! key (a correct collision: the window is the coalescing quantum) and two
9//! failures in different windows produce two rows, so a failure is always a
10//! blind write and never a read-modify-write.
11//!
12//! `should_attempt` scans a server's rows: the newest failure is the backoff
13//! anchor (its recorded instant) and the window span between the oldest and
14//! newest surviving rows is the streak, so the gate and the `earliest_retry`
15//! it reports are one comparison and stay coherent when the clock crosses a
16//! window boundary. `record_success` and `note_peer_alive` clear the whole
17//! prefix, so a recovered or reachable peer is immediately attemptable again.
18//!
19//! `window_secs` is sourced from `sender_timeout` at service build time so the
20//! peer-status curve does not drift from the sender's existing quadratic
21//! backoff when both observe the same peer.
22
23use std::{
24	collections::BTreeMap,
25	time::{Duration, SystemTime, UNIX_EPOCH},
26};
27
28use futures::{Stream, StreamExt};
29use http::StatusCode;
30use ruma::{OwnedServerName, ServerName, api::error::ErrorBody};
31use tuwunel_core::{
32	Error, implement,
33	utils::{
34		stream::{ReadyExt, TryIgnore},
35		time::now_secs,
36	},
37};
38use tuwunel_database::Interfix;
39
40/// Backoff ceiling, matching `sender_retry_backoff_limit`'s 24h default.
41pub(super) const MAX_BACKOFF: Duration = Duration::from_hours(24);
42
43/// Permanence classification supplied alongside a failure.
44#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
45pub enum Classification {
46	#[default]
47	Transient,
48	Permanent,
49}
50
51impl Classification {
52	/// Unknown bytes downgrade to `Transient`; a future encoding can only
53	/// soften a verdict, never wrongly escalate one against an old binary.
54	#[inline]
55	#[must_use]
56	fn from_byte(byte: u8) -> Self {
57		match byte {
58			| 1 => Self::Permanent,
59			| _ => Self::Transient,
60		}
61	}
62}
63
64impl From<Classification> for u8 {
65	#[inline]
66	fn from(c: Classification) -> Self {
67		match c {
68			| Classification::Transient => 0,
69			| Classification::Permanent => 1,
70		}
71	}
72}
73
74/// Verdict for [`Service::should_attempt`].
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub enum ShouldAttempt {
77	Yes,
78	No {
79		earliest_retry: SystemTime,
80	},
81
82	/// Eligible but should be sorted to the back of any candidate list
83	/// rather than skipped outright.
84	Deprioritize,
85}
86
87/// Latest-failure state feeding the pure [`attempt_verdict`] decision.
88pub(super) struct Backoff {
89	pub(super) class: Classification,
90
91	/// Failure instant the delay is measured from (seconds since the epoch).
92	pub(super) anchor_secs: u64,
93
94	pub(super) streak: u32,
95
96	/// Current time (seconds since the epoch); injected for testability.
97	pub(super) now: u64,
98
99	pub(super) window_secs: u64,
100	pub(super) grace_secs: u64,
101}
102
103/// Fold state accumulated over one server's failure rows.
104#[derive(Clone, Copy)]
105pub(super) struct Streak {
106	pub(super) class: Classification,
107	pub(super) anchor_secs: u64,
108	pub(super) oldest_bucket: u64,
109	pub(super) latest_bucket: u64,
110}
111
112/// Admin-facing summary of a peer's current failure streak, seconds since the
113/// epoch.
114#[derive(Clone, Copy, Debug)]
115pub struct PeerBackoff {
116	/// Newest failure instant, the backoff anchor.
117	pub anchor_secs: u64,
118
119	/// Start of the oldest surviving failure bucket.
120	pub oldest_secs: u64,
121
122	/// Backoff delay measured from the anchor.
123	pub delay_secs: u64,
124}
125
126#[implement(super::Service)]
127pub async fn record_success(&self, server: &ServerName) {
128	self.statuses
129		.del_prefix(&(server, Interfix))
130		.await;
131}
132
133/// Clears a peer's failure rows after it has proven reachable via inbound
134/// activity, reporting whether any were present so the caller flushes only for
135/// a peer that was actually sad. The healthy-peer miss writes no tombstone.
136#[implement(super::Service)]
137#[tracing::instrument(
138	level = "trace",
139	skip(self),
140	fields(
141		%server,
142	),
143)]
144pub async fn note_peer_alive(&self, server: &ServerName) -> bool {
145	let sad = self.peer_has_failures(server).await;
146
147	if sad {
148		self.statuses
149			.del_prefix(&(server, Interfix))
150			.await;
151	}
152
153	sad
154}
155
156/// Whether the reachability store holds any failure rows for this peer.
157#[implement(super::Service)]
158#[tracing::instrument(
159	level = "trace",
160	skip(self),
161	fields(
162		%server,
163	),
164)]
165pub async fn peer_has_failures(&self, server: &ServerName) -> bool {
166	self.statuses
167		.stream_prefix_raw(&(server, Interfix))
168		.ignore_err()
169		.ready_any(|_| true)
170		.await
171}
172
173#[implement(super::Service)]
174pub fn record_failure(&self, server: &ServerName, classification: Classification) {
175	// Raw-value additive extension; old one-byte rows stay readable.
176	let mut value = [0_u8; 9];
177	value[0] = u8::from(classification);
178	value[1..].copy_from_slice(&now_secs().to_be_bytes());
179
180	self.statuses
181		.put_raw((server, self.current_bucket()), value);
182}
183
184#[implement(super::Service)]
185#[tracing::instrument(skip(self), fields(%server), level = "trace")]
186pub async fn should_attempt(&self, server: &ServerName) -> ShouldAttempt {
187	let Some(streak) = self.peer_streak(server).await else {
188		return ShouldAttempt::Yes;
189	};
190
191	attempt_verdict(&self.backoff(streak))
192}
193
194/// Admin-facing backoff summary for one server, `None` when it has no failure
195/// rows.
196#[implement(super::Service)]
197pub async fn peer_backoff(&self, server: &ServerName) -> Option<PeerBackoff> {
198	self.peer_streak(server)
199		.await
200		.map(|streak| self.peer_backoff_from(streak))
201}
202
203/// Admin-facing backoff summary for every server with failure rows, in one
204/// pass over the reachability store. Rows group by server on disk, so a run of
205/// one server's buckets folds in place.
206#[implement(super::Service)]
207pub async fn peer_backoffs(&self) -> BTreeMap<OwnedServerName, PeerBackoff> {
208	let window_secs = self.window_secs;
209
210	self.statuses
211		.stream()
212		.ignore_err()
213		.ready_fold(
214			Vec::<(OwnedServerName, Streak)>::new(),
215			|mut runs, ((server, bucket), value): ((&ServerName, u64), &[u8])| {
216				match runs.last_mut() {
217					| Some((last, streak)) if *last == *server =>
218						*streak = fold_streak(window_secs, Some(*streak), bucket, value),
219					| _ => runs
220						.push((server.to_owned(), fold_streak(window_secs, None, bucket, value))),
221				}
222
223				runs
224			},
225		)
226		.await
227		.into_iter()
228		.map(|(server, streak)| (server, self.peer_backoff_from(streak)))
229		.collect()
230}
231
232/// Yields one tuple per populated bucket, ordered by `(server, bucket_start)`,
233/// backing the admin `peer-status snapshot` table.
234#[implement(super::Service)]
235pub fn peer_snapshot(
236	&self,
237) -> impl Stream<Item = (&ServerName, SystemTime, Classification)> + Send + '_ {
238	self.statuses.stream().ignore_err().map(
239		move |((server, bucket), value): ((&ServerName, u64), &[u8])| {
240			(server, self.bucket_start(bucket), classify(value))
241		},
242	)
243}
244
245#[implement(super::Service)]
246#[inline]
247#[must_use]
248fn current_bucket(&self) -> u64 {
249	now_secs()
250		.checked_div(self.window_secs.max(1))
251		.unwrap_or(0)
252}
253
254/// Wall-clock instant at the start of `bucket`.
255#[implement(super::Service)]
256#[inline]
257#[must_use]
258fn bucket_start(&self, bucket: u64) -> SystemTime {
259	let offset = bucket.saturating_mul(self.window_secs);
260
261	UNIX_EPOCH
262		.checked_add(Duration::from_secs(offset))
263		.unwrap_or(UNIX_EPOCH)
264}
265
266#[implement(super::Service)]
267#[inline]
268#[must_use]
269fn streak(&self, latest_bucket: u64, oldest_bucket: u64) -> u32 {
270	let span = latest_bucket
271		.saturating_sub(oldest_bucket)
272		.saturating_add(1);
273
274	u32::try_from(span)
275		.unwrap_or(u32::MAX)
276		.min(self.n_max)
277}
278
279/// Folds a server's failure rows into its streak, `None` when it has none.
280#[implement(super::Service)]
281async fn peer_streak(&self, server: &ServerName) -> Option<Streak> {
282	let window_secs = self.window_secs;
283
284	self.statuses
285		.stream_prefix(&(server, Interfix))
286		.ignore_err()
287		.ready_fold(None, |state, ((_, bucket), value): ((&ServerName, u64), &[u8])| {
288			Some(fold_streak(window_secs, state, bucket, value))
289		})
290		.await
291}
292
293/// Builds the pure backoff state from a server's failure streak.
294#[implement(super::Service)]
295fn backoff(&self, run: Streak) -> Backoff {
296	Backoff {
297		class: run.class,
298		anchor_secs: run.anchor_secs,
299		streak: self.streak(run.latest_bucket, run.oldest_bucket),
300		now: now_secs(),
301		window_secs: self.window_secs,
302		grace_secs: self.grace.as_secs(),
303	}
304}
305
306/// Projects a failure streak onto the admin-facing summary.
307#[implement(super::Service)]
308fn peer_backoff_from(&self, streak: Streak) -> PeerBackoff {
309	PeerBackoff {
310		anchor_secs: streak.anchor_secs,
311		oldest_secs: streak
312			.oldest_bucket
313			.saturating_mul(self.window_secs),
314		delay_secs: self.backoff(streak).delay_secs(),
315	}
316}
317
318/// Pure backoff verdict from a peer's latest failure state: attemptable once
319/// the delay past the anchor has elapsed.
320#[must_use]
321pub(super) fn attempt_verdict(backoff: &Backoff) -> ShouldAttempt {
322	let earliest_secs = backoff
323		.anchor_secs
324		.saturating_add(backoff.delay_secs());
325
326	if backoff.now >= earliest_secs {
327		return ShouldAttempt::Yes;
328	}
329
330	ShouldAttempt::No {
331		earliest_retry: UNIX_EPOCH
332			.checked_add(Duration::from_secs(earliest_secs))
333			.unwrap_or_else(SystemTime::now),
334	}
335}
336
337impl Backoff {
338	/// Backoff delay in seconds. `Permanent` and the saturating
339	/// `window * streak^2` curve both cap at [`MAX_BACKOFF`]; a lone
340	/// `Transient` failure gets the `grace` tier when it is enabled.
341	#[must_use]
342	pub(super) fn delay_secs(&self) -> u64 {
343		let max_backoff = MAX_BACKOFF.as_secs();
344
345		match self.class {
346			| Classification::Permanent => max_backoff,
347			| Classification::Transient if self.streak <= 1 && self.grace_secs != 0 =>
348				self.grace_secs.min(max_backoff),
349			| Classification::Transient => self
350				.window_secs
351				.saturating_mul(u64::from(self.streak))
352				.saturating_mul(u64::from(self.streak))
353				.min(max_backoff),
354		}
355	}
356}
357
358/// Folds one failure row into a server's running streak: the newest row sets
359/// the class and anchor, the oldest bucket is retained.
360#[must_use]
361pub(super) fn fold_streak(
362	window_secs: u64,
363	state: Option<Streak>,
364	bucket: u64,
365	value: &[u8],
366) -> Streak {
367	let anchor_secs = failure_secs(value).unwrap_or_else(|| bucket.saturating_mul(window_secs));
368
369	let oldest_bucket = state.map_or(bucket, |streak| streak.oldest_bucket);
370
371	Streak {
372		class: classify(value),
373		anchor_secs,
374		oldest_bucket,
375		latest_bucket: bucket,
376	}
377}
378
379#[inline]
380#[must_use]
381pub(super) fn classify(bytes: &[u8]) -> Classification {
382	bytes
383		.first()
384		.copied()
385		.map_or(Classification::Transient, Classification::from_byte)
386}
387
388/// Failure instant (seconds since the epoch) recorded after the classification
389/// byte; old single-byte rows carry no timestamp and yield `None`.
390#[must_use]
391pub(super) fn failure_secs(bytes: &[u8]) -> Option<u64> {
392	bytes
393		.get(1..9)
394		.and_then(|tail| tail.try_into().ok())
395		.map(u64::from_be_bytes)
396}
397
398/// Classifies a failed federation attempt for the peer-reachability store, or
399/// `None` when it carries no reachability signal. An HTTP response proves the
400/// peer reachable, so a content-level 4xx (a forbidden invite, a 403 backfill)
401/// must not count against it; only 5xx or an explicit rate-limit (429) records
402/// `Transient`. A 410 is the exception: a Matrix server never returns it for
403/// one endpoint and not another, so a received 410 is a proxy operator
404/// deliberately signaling the peer is gone, and records `Permanent`. A non-JSON
405/// body is the other exception: it means a proxy or CDN answered rather than
406/// the homeserver, so it signals a stale route, not peer content, and records
407/// `Transient` to place the eviction that follows behind the backoff gate.
408/// Transport failures carry no response and are always transient.
409#[must_use]
410pub(super) fn classify_error(error: &Error) -> Option<Classification> {
411	let Error::Federation(_, response) = error else {
412		return Some(Classification::Transient);
413	};
414
415	let status = response.status_code;
416
417	match status {
418		| _ if status == StatusCode::GONE => Some(Classification::Permanent),
419		| _ if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS =>
420			Some(Classification::Transient),
421		| _ if matches!(response.body, ErrorBody::NotJson { .. }) =>
422			Some(Classification::Transient),
423		| _ => None,
424	}
425}