trusty_console/webhook/schedule.rs
1//! Who may relay an entry, and when — the two guards that keep one delivery
2//! from becoming several relays.
3//!
4//! Why: without them the retry sweep and the request path race. A sweep tick
5//! landing inside the ≤5 s window while `ingest`'s own relay is still in flight
6//! lists the same `.json`, relays it again, and both attempts settle — one
7//! delivery, two relays, and with at-least-once semantics that is a duplicate
8//! the target sees. Separately, a sweep with no backoff re-relays every pending
9//! entry every tick and rewrites its whole body plus two `fsync`s each time,
10//! forever, which ADR-0034 §2's "Console retries with backoff" exists to
11//! prevent.
12//!
13//! What: [`ClaimSet`] is an in-process exclusion — one relay per entry path at a
14//! time, released on drop so a panicking relay cannot wedge the entry.
15//! [`BackoffPolicy`] decides whether an entry is *due*: a freshly spooled one is
16//! left alone for the relay timeout (the request path may still own it), and a
17//! previously-attempted one waits `base × 2^attempts`, capped, before the next
18//! try — stopping entirely at `max_attempts`.
19//!
20//! The two are independent on purpose. Backoff bounds cost over minutes and
21//! hours; the claim set closes the sub-second window backoff cannot see, since
22//! a first-attempt entry has no `last_attempt_at_unix_ms` to reason from.
23//!
24//! Test: `webhook/tests.rs` — `backoff_*` and the `sweep_*` concurrency cases,
25//! notably `sweep_does_not_relay_an_entry_the_request_path_is_still_relaying`.
26
27use std::collections::HashSet;
28use std::path::{Path, PathBuf};
29use std::sync::{Arc, Mutex};
30use std::time::Duration;
31
32use super::spool::SpoolEntry;
33
34/// Exclusive in-process ownership of one spool entry's relay.
35///
36/// Why: both `ingest` and the sweep can reach the same entry path. A claim held
37/// for the duration of the relay makes "someone is already relaying this" a
38/// checkable fact rather than a timing hope.
39/// What: a shared `HashSet<PathBuf>`. Locks are held only across the insert or
40/// remove, never across an `await`.
41///
42/// In-process only, and deliberately so: two console processes sharing one
43/// spool directory would still double-relay. That is out of scope here because
44/// console is a singleton per data directory, and the target-side
45/// `delivery_id` dedup step 4 owes is the real cross-process answer.
46///
47/// Test: `claim_set_refuses_a_second_claim_on_the_same_path`,
48/// `claim_set_releases_on_drop_even_when_the_holder_panics`.
49#[derive(Debug, Clone, Default)]
50pub struct ClaimSet {
51 held: Arc<Mutex<HashSet<PathBuf>>>,
52}
53
54impl ClaimSet {
55 /// A fresh, empty claim set.
56 pub fn new() -> Self {
57 Self::default()
58 }
59
60 /// Take exclusive ownership of `path`, or `None` if someone already has it.
61 ///
62 /// The returned guard releases on drop, including on panic and on an early
63 /// `?` return, so a failed relay cannot leave an entry permanently claimed.
64 ///
65 /// Test: `claim_set_refuses_a_second_claim_on_the_same_path`.
66 pub fn claim(&self, path: &Path) -> Option<Claim> {
67 let mut held = self.held.lock().unwrap_or_else(|e| e.into_inner());
68 if !held.insert(path.to_path_buf()) {
69 return None;
70 }
71 Some(Claim {
72 held: Arc::clone(&self.held),
73 path: path.to_path_buf(),
74 })
75 }
76
77 /// How many entries are claimed right now. Diagnostics and tests only.
78 pub fn len(&self) -> usize {
79 self.held.lock().unwrap_or_else(|e| e.into_inner()).len()
80 }
81
82 /// Whether nothing is claimed.
83 pub fn is_empty(&self) -> bool {
84 self.len() == 0
85 }
86}
87
88/// Ownership of one entry's relay, released when dropped.
89#[derive(Debug)]
90pub struct Claim {
91 held: Arc<Mutex<HashSet<PathBuf>>>,
92 path: PathBuf,
93}
94
95impl Drop for Claim {
96 fn drop(&mut self) {
97 self.held
98 .lock()
99 .unwrap_or_else(|e| e.into_inner())
100 .remove(&self.path);
101 }
102}
103
104/// When a pending entry becomes eligible for another relay attempt.
105///
106/// Why: ADR-0034 §2 says "Console retries with backoff" and the first cut had
107/// none — every pending entry was re-relayed every 60 s, each non-ack rewriting
108/// the full base64 body plus two `fsync`s. Until step 4 binds a listener that is
109/// every delivery, forever.
110/// What: a grace period for a never-attempted entry, exponential spacing keyed
111/// on `attempts`, a ceiling, and a hard stop. Pure — [`BackoffPolicy::is_due`]
112/// takes `now` so the rules are testable without sleeping.
113/// Test: `backoff_holds_off_a_freshly_spooled_entry`,
114/// `backoff_spacing_grows_with_attempts`, `backoff_respects_the_ceiling`,
115/// `backoff_stops_at_max_attempts`.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub struct BackoffPolicy {
118 /// How long a never-attempted entry is left alone after being spooled.
119 ///
120 /// Set to the relay timeout: within that window the request path that
121 /// spooled it may still be relaying it, and its claim has not necessarily
122 /// been taken yet at the instant the sweep lists the directory.
123 pub first_attempt_grace: Duration,
124 /// Spacing after the first failure; doubles per subsequent attempt.
125 pub base: Duration,
126 /// Upper bound on the spacing, however many attempts have failed.
127 pub ceiling: Duration,
128 /// After this many failed attempts the entry is never relayed again.
129 ///
130 /// It is NOT deleted — it stays on disk and keeps the health signal red,
131 /// because an undeliverable webhook is an operator problem, not garbage.
132 /// The cap exists so a permanently unrelayable entry stops costing a
133 /// full-body rewrite and two `fsync`s on every tick.
134 pub max_attempts: u32,
135}
136
137impl Default for BackoffPolicy {
138 /// 5 s grace, 30 s base doubling to a 1 h ceiling, giving up after 24
139 /// failures — roughly a day of retries for an entry that never lands.
140 fn default() -> Self {
141 Self {
142 first_attempt_grace: super::relay::DEFAULT_RELAY_TIMEOUT,
143 base: Duration::from_secs(30),
144 ceiling: Duration::from_secs(3600),
145 max_attempts: 24,
146 }
147 }
148}
149
150impl BackoffPolicy {
151 /// Spacing required after `attempts` failures.
152 ///
153 /// `base << (attempts - 1)`, saturating into [`BackoffPolicy::ceiling`].
154 /// The shift is bounded before it is applied, so a large attempt count
155 /// cannot overflow into a small delay.
156 ///
157 /// Test: `backoff_spacing_grows_with_attempts`, `backoff_respects_the_ceiling`.
158 pub fn delay_after(&self, attempts: u32) -> Duration {
159 if attempts == 0 {
160 return self.first_attempt_grace;
161 }
162 let shift = (attempts - 1).min(32);
163 let scaled = self
164 .base
165 .as_millis()
166 .saturating_mul(1u128 << shift)
167 .min(self.ceiling.as_millis());
168 Duration::from_millis(scaled.min(u128::from(u64::MAX)) as u64)
169 }
170
171 /// Whether `entry` may be relayed again at `now_unix_ms`.
172 ///
173 /// Why: the sweep's only admission test. Returning `false` leaves the entry
174 /// exactly where it is — pending, durable, and visible to the health scan —
175 /// so a "not due" entry is never a dropped one.
176 /// What: `false` past [`BackoffPolicy::max_attempts`]; otherwise the elapsed
177 /// time since the last attempt (or since receipt, for a never-attempted
178 /// entry) must meet [`BackoffPolicy::delay_after`].
179 /// Test: `backoff_holds_off_a_freshly_spooled_entry`,
180 /// `backoff_stops_at_max_attempts`, `backoff_admits_an_entry_past_its_delay`.
181 pub fn is_due(&self, entry: &SpoolEntry, now_unix_ms: u64) -> bool {
182 if entry.attempts >= self.max_attempts {
183 return false;
184 }
185 let since = entry
186 .last_attempt_at_unix_ms
187 .unwrap_or(entry.received_at_unix_ms);
188 let elapsed_ms = u128::from(now_unix_ms.saturating_sub(since));
189 elapsed_ms >= self.delay_after(entry.attempts).as_millis()
190 }
191
192 /// Whether `entry` has exhausted its retries and needs an operator.
193 ///
194 /// Distinguished from "not due yet" so the sweep can report the two
195 /// separately — one resolves itself, the other never will.
196 pub fn is_exhausted(&self, entry: &SpoolEntry) -> bool {
197 entry.attempts >= self.max_attempts
198 }
199}