Skip to main content

tuwunel_service/rooms/event_handler/
backoff.rs

1use std::{
2	ops::Range,
3	sync::atomic::{AtomicU64, Ordering},
4	time::Duration,
5};
6
7use futures::FutureExt;
8use ruma::EventId;
9use tuwunel_core::{
10	Error, implement,
11	utils::{
12		BoolExt, continue_exponential_backoff,
13		stream::{ReadyExt, TryIgnore},
14		time::now_secs,
15	},
16};
17use tuwunel_database::{Ignore, Interfix};
18
19#[cfg(test)]
20mod tests;
21
22/// Bucket width in seconds. Records within one bucket collide onto a single key
23/// (`<=` the smallest call-site backoff floor), coalescing concurrent failures.
24const QUANTUM: u64 = 60;
25
26/// Accumulated `Pending` records at which the rate brake engages.
27const SUPPRESS_AFTER: u32 = 3;
28
29/// Retry window for the `Upgrade` context.
30///
31/// Bounds how often a re-delivered event repeats the full upgrade. A
32/// soft-failed event is re-evaluated on this widening schedule rather than
33/// rejected forever, so a lapsed policy-server refusal heals.
34pub(super) const UPGRADE_RETRY: Range<Duration> =
35	Duration::from_mins(5)..Duration::from_hours(24);
36
37/// Federation step that recorded a decision; the key's leading discriminant.
38#[derive(Clone, Copy)]
39pub(super) enum Context {
40	Fetch = 0,
41	Auth = 1,
42	Upgrade = 2,
43	Incoming = 3,
44}
45
46impl From<Context> for u8 {
47	#[inline]
48	fn from(context: Context) -> Self {
49		match context {
50			| Context::Fetch => 0,
51			| Context::Auth => 1,
52			| Context::Upgrade => 2,
53			| Context::Incoming => 3,
54		}
55	}
56}
57
58/// Permanence of a recorded decision. Unknown discriminants decode to the
59/// weakest (`Pending`) so a future encoding can only soften, never wrongly
60/// escalate, a verdict against an old binary. `Permanent` is never written by
61/// this store.
62#[derive(Clone, Copy, Default)]
63pub(super) enum Disposition {
64	#[default]
65	Pending = 0,
66	Transient = 1,
67	Permanent = 2,
68}
69
70/// Verdict from consulting the store before a federation step.
71pub(super) enum Suppression {
72	/// No row is recorded for the event in this context.
73	Absent,
74
75	/// Rows are recorded but no hold is in force.
76	Allow,
77
78	/// A hold is in force; skip the step.
79	Deny,
80}
81
82#[derive(Default)]
83struct Summary {
84	total: u32,
85	pending: u32,
86	latest_secs: u64,
87	latest_class: Disposition,
88}
89
90/// Snapshot of process-lifetime verdicts from the backoff store, per federation
91/// step.
92///
93/// Every lookup that completes lands in exactly one verdict of its step; one
94/// dropped before the store answers lands in none. Each counter is loaded
95/// independently, so totals within one snapshot need not reconcile while
96/// lookups are in flight.
97#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
98pub struct BackoffMetrics {
99	/// Lookups before an event or its auth chain is fetched over federation.
100	pub fetch: Verdicts,
101
102	/// Lookups before handling a fetched event from an auth chain.
103	pub auth: Verdicts,
104
105	/// Lookups before upgrading a previous event, or re-weighing a soft-failed
106	/// one.
107	pub upgrade: Verdicts,
108
109	/// Lookups before walking a gapped incoming event's previous events.
110	pub incoming: Verdicts,
111}
112
113/// Verdict counts for one federation step.
114///
115/// Each completed lookup of the step increments exactly one field, so
116/// [`Verdicts::lookups`] is the step's lookup count.
117#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
118pub struct Verdicts {
119	/// Lookups that found no row for the event.
120	pub absent: u64,
121
122	/// Lookups that found rows but no hold in force.
123	pub allowed: u64,
124
125	/// Lookups that found a hold in force and skipped the step.
126	pub denied: u64,
127}
128
129#[derive(Default)]
130pub(super) struct BackoffCounters {
131	fetch: VerdictCounters,
132	auth: VerdictCounters,
133	upgrade: VerdictCounters,
134	incoming: VerdictCounters,
135}
136
137#[derive(Default)]
138struct VerdictCounters {
139	absent: AtomicU64,
140	allowed: AtomicU64,
141	denied: AtomicU64,
142}
143
144impl From<u64> for Disposition {
145	#[inline]
146	fn from(disc: u64) -> Self {
147		match disc {
148			| 1 => Self::Transient,
149			| 2 => Self::Permanent,
150			| _ => Self::Pending,
151		}
152	}
153}
154
155impl From<Disposition> for u64 {
156	#[inline]
157	fn from(disposition: Disposition) -> Self {
158		match disposition {
159			| Disposition::Pending => 0,
160			| Disposition::Transient => 1,
161			| Disposition::Permanent => 2,
162		}
163	}
164}
165
166impl Suppression {
167	#[inline]
168	pub(super) fn is_deny(&self) -> bool { matches!(self, Self::Deny) }
169}
170
171impl Summary {
172	fn tally(mut self, (_, (class, secs)): (Ignore, (u64, u64))) -> Self {
173		let class = Disposition::from(class);
174
175		self.total = self.total.saturating_add(1);
176		if matches!(class, Disposition::Pending) {
177			self.pending = self.pending.saturating_add(1);
178		}
179
180		if secs >= self.latest_secs {
181			self.latest_secs = secs;
182			self.latest_class = class;
183		}
184
185		self
186	}
187}
188
189impl Verdicts {
190	/// Lookups of the step, whatever their verdict.
191	///
192	/// Saturates at `u64::MAX` rather than wrapping.
193	#[inline]
194	#[must_use]
195	pub fn lookups(&self) -> u64 {
196		self.absent
197			.saturating_add(self.allowed)
198			.saturating_add(self.denied)
199	}
200}
201
202/// Read a snapshot of backoff verdict totals.
203///
204/// Reading does not reset the totals.
205#[implement(super::Service)]
206#[inline]
207#[must_use]
208pub fn backoff_metrics(&self) -> BackoffMetrics { self.backoff.snapshot() }
209
210#[implement(BackoffCounters)]
211fn snapshot(&self) -> BackoffMetrics {
212	BackoffMetrics {
213		fetch: self.fetch.snapshot(),
214		auth: self.auth.snapshot(),
215		upgrade: self.upgrade.snapshot(),
216		incoming: self.incoming.snapshot(),
217	}
218}
219
220impl VerdictCounters {
221	fn snapshot(&self) -> Verdicts {
222		Verdicts {
223			absent: self.absent.load(Ordering::Relaxed),
224			allowed: self.allowed.load(Ordering::Relaxed),
225			denied: self.denied.load(Ordering::Relaxed),
226		}
227	}
228}
229
230/// Record a federation attempt before a cancellable await, so a premature
231/// cancellation still leaves a `Pending` row behind to rate-gate against.
232///
233/// Returns the attempt's bucket for `record_completion` to settle.
234#[implement(super::Service)]
235pub(super) fn record_attempt(&self, ctx: Context, event_id: &EventId) -> u32 {
236	let bucket = current_bucket();
237
238	self.record_outcome_at(ctx, event_id, bucket, Disposition::Pending);
239	bucket
240}
241
242/// Settle a step once its work has finished.
243///
244/// An appended event clears what the context holds for it: only the attempt's
245/// own row when nothing preceded it, every row otherwise. A failure is recorded
246/// as `Transient` in the bucket of the attempt it settles, replacing that
247/// attempt's `Pending` row, or in the current bucket when rows preceded the
248/// step but no attempt was written. A withheld event, a failure from an
249/// interruption or shutdown, or a step with no rows and no attempt records
250/// nothing. Rows an earlier gapped pass left behind outlive a later append
251/// that found no gap, until the store reaps them.
252#[implement(super::Service)]
253pub(super) async fn record_completion(
254	&self,
255	ctx: Context,
256	event_id: &EventId,
257	standing: Suppression,
258	attempt: Option<u32>,
259	appended: Result<bool, &Error>,
260) {
261	match (appended, standing, attempt) {
262		| (_, Suppression::Absent, None) | (Ok(false), ..) => {},
263		| (Err(error), ..) if error.is_interrupted() || self.services.server.is_stopping() => {},
264		| (Ok(true), Suppression::Absent, Some(bucket)) =>
265			self.clear_outcome_at(ctx, event_id, bucket),
266		| (Ok(true), ..) => self.record_success(ctx, event_id).await,
267		| (Err(_), _, attempt) => {
268			let bucket = attempt.unwrap_or_else(current_bucket);
269
270			self.record_outcome_at(ctx, event_id, bucket, Disposition::Transient);
271		},
272	}
273}
274
275#[implement(super::Service)]
276pub(super) fn record_outcome(&self, ctx: Context, event_id: &EventId, disposition: Disposition) {
277	self.record_outcome_at(ctx, event_id, current_bucket(), disposition);
278}
279
280/// Clears the backoffs recorded against an event's delivery.
281///
282/// The soft-fail marker and these backoffs gate the same retry, so operator
283/// recovery has to drop all of them before the event is next evaluated,
284/// whether by a redelivery or as the prev of a later event.
285#[implement(super::Service)]
286pub async fn clear_delivery_backoff(&self, event_id: &EventId) {
287	self.record_success(Context::Upgrade, event_id)
288		.await;
289
290	self.record_success(Context::Incoming, event_id)
291		.await;
292}
293
294#[implement(super::Service)]
295pub(super) async fn record_success(&self, ctx: Context, event_id: &EventId) {
296	self.db
297		.eventid_backoff
298		.del_prefix(&(u8::from(ctx), event_id, Interfix))
299		.await;
300}
301
302/// Consult the store before a federation step, counting the verdict.
303///
304/// The verdict is counted when the store answers, so a lookup dropped before
305/// then counts nothing.
306#[implement(super::Service)]
307pub(super) fn is_suppressed(
308	&self,
309	ctx: Context,
310	event_id: &EventId,
311	range: Range<Duration>,
312) -> impl Future<Output = Suppression> + Send {
313	self.verdict(ctx, event_id, range)
314		.inspect(move |verdict| self.backoff.count(ctx, verdict))
315}
316
317#[implement(super::Service)]
318async fn verdict(&self, ctx: Context, event_id: &EventId, range: Range<Duration>) -> Suppression {
319	let summary = self
320		.db
321		.eventid_backoff
322		.stream_prefix::<Ignore, (u64, u64), _>(&(u8::from(ctx), event_id, Interfix))
323		.ignore_err()
324		.ready_fold(Summary::default(), Summary::tally)
325		.await;
326
327	if summary.total == 0 {
328		return Suppression::Absent;
329	}
330
331	if matches!(summary.latest_class, Disposition::Permanent) {
332		return Suppression::Deny;
333	}
334
335	let elapsed = Duration::from_secs(now_secs().saturating_sub(summary.latest_secs));
336	let (tries, rate_ok) = match summary.latest_class {
337		| Disposition::Pending => (summary.pending, summary.pending >= SUPPRESS_AFTER),
338		| _ => (summary.total, true),
339	};
340
341	let deny = rate_ok && continue_exponential_backoff(range.start, range.end, elapsed, tries);
342
343	deny.map_or(Suppression::Allow, || Suppression::Deny)
344}
345
346#[implement(BackoffCounters)]
347fn count(&self, ctx: Context, verdict: &Suppression) {
348	let counters = match ctx {
349		| Context::Fetch => &self.fetch,
350		| Context::Auth => &self.auth,
351		| Context::Upgrade => &self.upgrade,
352		| Context::Incoming => &self.incoming,
353	};
354
355	let counter = match verdict {
356		| Suppression::Absent => &counters.absent,
357		| Suppression::Allow => &counters.allowed,
358		| Suppression::Deny => &counters.denied,
359	};
360
361	counter.fetch_add(1, Ordering::Relaxed);
362}
363
364fn current_bucket() -> u32 { u32::try_from(now_secs() / QUANTUM).unwrap_or(u32::MAX) }
365
366#[implement(super::Service)]
367fn record_outcome_at(
368	&self,
369	ctx: Context,
370	event_id: &EventId,
371	bucket: u32,
372	disposition: Disposition,
373) {
374	self.db
375		.eventid_backoff
376		.put((u8::from(ctx), event_id, bucket), (u64::from(disposition), now_secs()));
377}
378
379#[implement(super::Service)]
380fn clear_outcome_at(&self, ctx: Context, event_id: &EventId, bucket: u32) {
381	self.db
382		.eventid_backoff
383		.del((u8::from(ctx), event_id, bucket));
384}