aion_server/worker/heartbeat/sweep.rs
1//! The expiry sweep's nomination read and the positive-fact predicates that
2//! acquit a nominated worker.
3//!
4//! Split out of `heartbeat.rs`: deciding WHICH workers a sweep deregisters is
5//! a self-contained read over the connection clock plus two corroborating
6//! facts, and keeping it beside the tracker's mutation surface pushed that
7//! file past the per-file length budget.
8
9use std::time::Instant;
10
11use crate::error::ServerError;
12use crate::worker::registry::{ConnectedWorkerRegistry, WorkerId};
13
14use super::{HeartbeatTracker, TaskLiveness};
15
16impl HeartbeatTracker {
17 /// Return the workers this sweep will DEREGISTER: those whose silence is
18 /// corroborated by a push leg that is no longer open.
19 ///
20 /// # One clock nominates; two positive facts acquit
21 ///
22 /// This read used to consult two silence clocks — the connection clock and
23 /// the PER-TASK beat clock — and NOTHING about whether the server still held
24 /// a channel to the worker. Both inputs are facts about ABSENCE, so neither
25 /// could tell a worker that is gone from one that is busy, and a worker
26 /// running at its full concurrency looked identical to a dead one. It was
27 /// then deregistered while connected and holding live work, its in-flight
28 /// activities were surfaced as transport losses, and the queue census — with
29 /// the worker gone from the pool — reported that no worker was connected
30 /// about a worker on an open stream.
31 ///
32 /// Two things changed. The per-task clock no longer nominates anybody:
33 /// `is_expired` is a predicate about ONE activity and was being used to
34 /// condemn a whole process, so a single lapsed beat took down every other
35 /// activity that worker was holding.
36 ///
37 /// # What now resolves a lapsed per-task beat — and what does not
38 ///
39 /// Stated exactly, because the honest answer is narrower than "something
40 /// else handles it" and an earlier draft of this doc promised a mechanism
41 /// "below" that does not exist.
42 ///
43 /// Three things still end a stuck activity, none of them this sweep:
44 ///
45 /// - the CONNECTION clock, when the worker itself also goes quiet — this
46 /// function nominates it, and `fail_expired_workers` then fails every
47 /// activity it was holding;
48 /// - the engine seam's authored per-attempt bound, measured from the lease
49 /// (`aion::LeaseSignal`), where an operator authored one;
50 /// - the outbox row's own attempt budget, backoff and dead-letter, once the
51 /// dispatch call returns.
52 ///
53 /// What is left uncovered, and is a real gap rather than a tidy hand-off: an
54 /// activity whose beat lapses while its worker KEEPS answering. The worker
55 /// stays in service, correctly — it is demonstrably alive — and no mechanism
56 /// fails that single activity. `is_expired` now reaches production through
57 /// nothing at all: its only reader is [`HeartbeatTracker::is_live`], which
58 /// has no production caller. Before this change the whole-worker reap was
59 /// what (brutally, and wrongly) covered that case.
60 ///
61 /// This is recorded rather than fixed here because inventing a per-activity
62 /// deadline is a semantics decision about how long work may run without
63 /// reporting, and that belongs to whoever owns the activity timeout — not to
64 /// a sweep that was being asked to do it as a side effect. It is tracked as
65 /// a server-lane issue; the number is deliberately not written here, because
66 /// an issue reference in a doc comment rots the moment the issue moves.
67 ///
68 /// And the surviving clock alone no longer condemns anybody either. Silence
69 /// is the SUSPICION; the verdict needs a second, positive fact to survive,
70 /// and the worker keeps its registration unless BOTH of these hold:
71 ///
72 /// - **The push leg is still open.** [`WorkerHandle::is_connected`] asks
73 /// the transport, not the noise floor: gRPC answers from its stream
74 /// sender, liminal from its supervisor's connection table. A worker with
75 /// no leg left has nothing that could carry a beat, and reaping it is the
76 /// honest reading of both facts together.
77 /// - **The server has PROVED it can reach that leg.** An open channel is
78 /// not an answering worker — the server can hold a perfectly healthy
79 /// looking `mpsc` to a wedged process indefinitely, which is the whole
80 /// reason the liveness ping exists. So a connected worker is spared only
81 /// while its dispatch path is proved reachable.
82 ///
83 /// # Why BOTH, and what each one alone would cost
84 ///
85 /// Connection alone is not enough: on gRPC a sender closes only when the
86 /// stream task ends, and that is already the teardown sweep's business. A
87 /// reaper gated on connection alone would therefore never fire for a gRPC
88 /// worker at all, and a WEDGED process — stream open, nothing moving,
89 /// in-flight work that will never complete — would hold its activities for
90 /// ever. That is the failure #176 was written to end, and it must not be
91 /// re-opened by fixing a different one. The connection clock is what catches
92 /// it: a wedged process performs neither of the two acts that refresh it, so
93 /// it is nominated within one window with its reachability unproved. That is
94 /// why dropping the per-task arm costs #176 nothing.
95 ///
96 /// Reachability alone is not enough either: it is a measurement with a
97 /// cadence, and a worker mid-probation or one probe behind has not yet
98 /// proved anything about a link that is fine.
99 ///
100 /// # The reachability conjunct is currently DEFENSIVE, and here is why
101 ///
102 /// Say this rather than let a future reader assume it is load-bearing. Every
103 /// call that proves reachability — `record_dispatch_reachability` — also
104 /// refreshes the connection clock, and so do `record_heartbeat` and
105 /// `record_liveness`. Since the only nomination is a lapsed connection
106 /// clock, a worker that reaches this gate has by construction not been
107 /// proved recently either: the two clocks are advanced by the same calls, so
108 /// "overdue but provably reachable" is not a state this tracker can be in
109 /// today. The conjunct therefore cannot currently spare anybody the
110 /// connection arm would have condemned.
111 ///
112 /// It stays because it is the correct predicate and it is free. If a future
113 /// change proves reachability without touching the connection clock — a
114 /// side-channel probe, a transport that answers without traffic — this gate
115 /// is already the right one, and its absence would be a silent re-opening of
116 /// the reap-a-busy-worker hole rather than a visible one.
117 ///
118 /// Together they say what the sweep actually needs: this worker's leg is
119 /// open AND the server has recently pushed something down it that came
120 /// back, so its silence is BUSY, not gone. That is exactly the state a
121 /// worker at its full concurrency is now in — the receive loop no longer
122 /// parks on admission, so a saturated worker answers pings while it works —
123 /// and it is exactly the state the old code could not see.
124 ///
125 /// # What a connected, unreachable worker gets first
126 ///
127 /// Dispatch INELIGIBILITY, published by the liveness probe on this same
128 /// evidence and several probe cadences before this sweep can fire: the
129 /// worker stays registered, keeps its in-flight work, and is sent nothing
130 /// new. This method neither reads nor re-derives that verdict — one reaper,
131 /// one eligibility verdict, and each reaches its own conclusion from the
132 /// evidence it owns.
133 ///
134 /// A read failure on either input yields the reaping answer for that
135 /// worker, which is the conservative direction: it can only fall back to
136 /// the behaviour this method had before it could ask at all, never invent a
137 /// reap the silence clocks did not already ask for.
138 ///
139 /// # Errors
140 ///
141 /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
142 pub fn expired_workers(
143 &self,
144 registry: &ConnectedWorkerRegistry,
145 now: Instant,
146 ) -> Result<Vec<WorkerId>, ServerError> {
147 let candidates = {
148 let state = self.state()?;
149 // ONE candidate source: the CONNECTION silence clock. A worker whose
150 // connection has been quiet past the window is suspected; nothing
151 // else nominates anybody.
152 //
153 // The per-task clock used to nominate too, and that was the outage's
154 // own mechanism wearing a second hat: `is_expired` is a predicate
155 // about ONE activity, and using it to condemn the whole worker meant
156 // a single lapsed beat deregistered a connected process along with
157 // every other activity it was holding. A per-task fact stays a
158 // per-task fact — the sweep still names those tasks below, and
159 // whatever fails an individual activity still does.
160 //
161 // Nothing is lost by dropping it. `state.connections` is refreshed
162 // only by what the WORKER does — `record_heartbeat` and
163 // `record_liveness` — so a wedged process refreshes nothing, goes
164 // stale within one window, and is nominated here with its
165 // reachability unproved. That is #176's case, decided entirely by
166 // this arm.
167 state
168 .connections
169 .iter()
170 .filter(|(_, last_activity)| {
171 now.checked_duration_since(**last_activity)
172 .is_some_and(|elapsed| elapsed > self.heartbeat_window)
173 })
174 .map(|(worker_id, _)| *worker_id)
175 .collect::<Vec<_>>()
176 };
177 let mut workers = Vec::new();
178 for worker_id in candidates {
179 if is_still_connected(registry, worker_id) && self.is_provably_reachable(worker_id, now)
180 {
181 continue;
182 }
183 workers.push(worker_id);
184 }
185 workers.sort_unstable();
186 Ok(workers)
187 }
188
189 /// Whether the server has PROVED it can reach this worker's dispatch path,
190 /// reading through the expiry sweep's own error policy.
191 ///
192 /// [`Self::is_dispatch_reachable`] is the fact; this is what the sweep does
193 /// when it cannot read it. An unreadable tracker answers "not proved",
194 /// which reaps — the same conservative direction the connection check
195 /// takes, and the behaviour this sweep had before it could consult
196 /// reachability at all.
197 fn is_provably_reachable(&self, worker_id: WorkerId, now: Instant) -> bool {
198 match self.is_dispatch_reachable(worker_id, now) {
199 Ok(reachable) => reachable,
200 Err(error) => {
201 tracing::error!(
202 worker_id = worker_id.value(),
203 %error,
204 "worker reachability is unreadable; treating this worker's dispatch path as \
205 unproved, which is the reading the expiry sweep had before it could ask"
206 );
207 false
208 }
209 }
210 }
211}
212
213/// Whether the server still holds an open push leg to `worker_id`.
214///
215/// An UNREGISTERED worker answers `false`: there is no leg because there is no
216/// registration, and a worker already out of the registry is not one this sweep
217/// needs to protect. A poisoned registry lock also answers `false`, which is the
218/// conservative direction — it degrades this decision to what it was before the
219/// connected fact existed, and never manufactures a reap that the two silence
220/// clocks did not already ask for.
221pub(super) fn is_still_connected(registry: &ConnectedWorkerRegistry, worker_id: WorkerId) -> bool {
222 match registry.worker_by_id(worker_id) {
223 Ok(worker) => worker.is_some_and(|worker| worker.is_connected()),
224 Err(error) => {
225 tracing::error!(
226 worker_id = worker_id.value(),
227 %error,
228 "connected-worker registry is unreadable; judging this worker's push leg closed, \
229 which is the reading the expiry sweep had before it could ask at all"
230 );
231 false
232 }
233 }
234}
235
236/// Whether ONE activity's beat has lapsed past its own heartbeat window.
237pub(super) fn is_expired(liveness: &TaskLiveness, now: Instant) -> bool {
238 now.checked_duration_since(liveness.last_heartbeat_at)
239 .is_some_and(|elapsed| elapsed > liveness.heartbeat_window)
240}