Skip to main content

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}