Skip to main content

aion_server/worker/
liminal_liveness.rs

1//! Server half of the liminal connection dead-man switch: the liveness probe.
2//!
3//! # The gap this closes
4//!
5//! The server's connection lease ([`HeartbeatTracker`]) is advanced by frames a
6//! worker SENDS, and an idle worker sends nothing. So on 2026-07-29 a healthy,
7//! connected, idle worker's lease expired 37 seconds after its last activity —
8//! "idle worker connection lease expired; worker deregistered" — the worker was
9//! never told, kept believing it was connected, and the next dispatch parked
10//! forever on a queue nobody was serving. The same absent mechanism cost a live
11//! run 28 minutes of mutual blindness when a link died mid-activity: the server
12//! pushed into a socket nobody was reading and the worker blocked on a socket
13//! nobody was writing.
14//!
15//! # The probe
16//!
17//! [`LivenessProbe`] pushes a [`LivenessPing`] to every liminal-connected worker
18//! on a fixed cadence and waits for the correlated [`LivenessPong`]. One
19//! exchange proves both legs:
20//!
21//! - The PONG proves the worker is alive to the server, and its arrival advances
22//!   the worker's connection lease — so an idle-but-alive connection is no
23//!   longer "idle" at the lease layer and the idle expiry cannot fire on it.
24//!   That is the structural death of the first failure above.
25//! - The PING's arrival proves the server is alive to the worker, whose own
26//!   dead-man switch (`aion-worker`'s `liminal_liveness`) declares the link dead
27//!   when pings stop. That is the death of the second.
28//!
29//! A ping that is not answered inside the cadence is logged LOUDLY with the
30//! worker's identity and queue, and withdraws the worker's DISPATCH
31//! ELIGIBILITY once the silence outlasts the window.
32//!
33//! # Why eligibility, and not the connection lease
34//!
35//! This module used to claim that an unanswered ping let "a genuinely dead
36//! worker's lease run down" so the expiry sweep would reap it. **That was
37//! false, and run `dfd2117c` proved it**: the server could not push to a worker
38//! for fifteen minutes and the worker never lost its lease, because the
39//! worker-side liveness pump beats from a background task and keeps refreshing
40//! it whatever the worker's serve loop is doing.
41//!
42//! Two different facts were collapsed into one lease:
43//!
44//! - **the worker process is alive** — proven by anything the worker sends,
45//!   pump included, on the worker-to-server direction;
46//! - **the server can reach the worker's dispatch path** — proven only by an
47//!   answered ping, on the server-to-worker direction.
48//!
49//! Only the second is a dispatch precondition, and
50//! [`LiminalWorkerDelivery::push_payload_with_deadline`] already says why the
51//! ping is the only thing that can prove it: it rides "the exact path a
52//! dispatch would take, not a parallel one that could be healthy while the real
53//! one is not." The pump is exactly such a parallel channel, so it must not
54//! feed the fact the ping exists to establish.
55//!
56//! So the probe advances a SEPARATE reachability clock
57//! ([`HeartbeatTracker::record_dispatch_reachability`]) and publishes an
58//! eligibility verdict the dispatch selector honours. The connection lease and
59//! its expiry sweep are untouched and still own process liveness — there is
60//! still deliberately no second reaper, and an unreachable worker is excluded
61//! from dispatch rather than torn down.
62//!
63//! # No knobs
64//!
65//! Both timings are DERIVED from the operator's existing
66//! `worker.heartbeat_window` — the one place they already declared what silence
67//! means:
68//!
69//! - the ping cadence is [`sweep_interval`] of that window (a quarter of it,
70//!   clamped to `[1s, window]`), the identical derivation the expiry sweeper
71//!   uses, so a healthy connection is refreshed four times per window and the
72//!   idle lease has no chance to expire;
73//! - the window the worker is told to expect is the heartbeat window ITSELF, so
74//!   both ends of the link declare death on exactly the same operator contract.
75//!
76//! Nothing here is separately configurable, and the worker holds no copy of the
77//! window: it is carried on every ping.
78//!
79//! # Both transports, one probation (#197)
80//!
81//! This module is named for the transport it was built against, but
82//! [`LivenessProbe`] is no longer liminal-only. A round enumerates liminal
83//! connections AND gRPC-delivered registrations
84//! ([`grpc_liveness`](super::grpc_liveness)), pings each over ITS OWN
85//! transport, and feeds every answer into the SAME
86//! [`HeartbeatTracker`] probation and the SAME
87//! [`publish_reachability_verdict`](LivenessProbe::publish_reachability_verdict)
88//! — one [`DISPATCH_PROBATION_PINGS`], one eligibility set, one set of
89//! announcement transitions, no per-transport constant and no exemption.
90//!
91//! Only the wire differs: a liminal worker is pinged with the serde
92//! [`LivenessPing`] on its connection, a gRPC worker with the protobuf
93//! [`ProtoLivenessPing`](aion_proto::ProtoLivenessPing) on its task stream. The
94//! fact each establishes is identical, which is why the verdict is.
95//!
96//! The accepted cost of riding the task stream is that stream death is not a
97//! separate signal: a closed stream simply makes the NEXT ping unanswerable, so
98//! withdrawal latency is bounded by the probe cadence rather than being
99//! instantaneous. That is deliberate — a special-case fast path for stream
100//! death would be a second, softer route into the eligibility verdict, and the
101//! registry's own disconnect teardown already owns deregistration.
102
103use std::collections::BTreeSet;
104use std::sync::Arc;
105use std::time::{Duration, Instant};
106
107use serde::{Deserialize, Serialize};
108use tokio::sync::watch;
109use tracing::{info, warn};
110
111use super::grpc_liveness::{GrpcLivenessTarget, GrpcLivenessWaiters, ping_grpc_worker};
112use super::heartbeat::{
113    DISPATCH_PROBATION_PINGS, DispatchExclusion, ExcludedWorker, HeartbeatTracker, sweep_interval,
114};
115use super::liminal_transport::{LiminalConnectionNotifier, LiminalWorkerDelivery};
116use super::liveness::{PingFailure, ProbedTransport};
117use super::registry::{ConnectedWorkerRegistry, WorkerDelivery, WorkerId};
118
119/// Wire liveness ping the server pushes on an established liminal connection.
120///
121/// Field-for-field mirror of `aion-worker`'s `liminal_liveness::LivenessPing`
122/// (same serde field names), the same cross-crate contract the
123/// dispatch/response and intervention pairs pin. `liveness_ping` is also the
124/// worker's demux discriminator: no other pushed frame carries it, and a ping
125/// carries none of the fields a dispatch or intervention requires.
126#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
127pub struct LivenessPing {
128    /// Monotonic ping sequence within this connection, echoed on the answer.
129    pub liveness_ping: u64,
130    /// How long the worker may hear NOTHING on this connection before it must
131    /// declare the link dead — this server's `worker.heartbeat_window`, carried
132    /// on the wire so the worker never holds a second copy of it.
133    pub silence_window_ms: u64,
134}
135
136/// Wire answer the worker replies with, echoing the ping's sequence.
137///
138/// Field-for-field mirror of `aion-worker`'s `liminal_liveness::LivenessPong`.
139#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
140pub struct LivenessPong {
141    /// The sequence of the ping being answered, echoed verbatim.
142    pub liveness_pong: u64,
143}
144
145/// One connection the probe pings on a round: the connection pid, the worker it
146/// registered, and the push leg to reach it.
147#[derive(Clone, Debug)]
148pub struct LivenessTarget {
149    /// Liminal connection process id the worker is addressed on.
150    pub pid: u64,
151    /// Registry identity of the worker that registered on this connection.
152    pub worker_id: WorkerId,
153    /// Push leg used to deliver the ping and await its answer.
154    pub delivery: LiminalWorkerDelivery,
155}
156
157/// The production driver of the liminal connection dead-man switch.
158///
159/// Shares the server's shutdown watch, so it drains with the transports exactly
160/// like [`HeartbeatSweeper`](super::HeartbeatSweeper) and the outbox dispatcher.
161pub struct LivenessProbe {
162    /// The liminal connection census, or `None` on a boot that hosts no liminal
163    /// listener. `None` is not "no liveness": the gRPC half below still runs.
164    notifier: Option<Arc<LiminalConnectionNotifier>>,
165    /// The gRPC answer-correlation registry, or `None` when this probe has no
166    /// channel to receive gRPC answers on.
167    ///
168    /// Structural rather than a flag: a probe with no correlation registry
169    /// cannot hear a gRPC answer, so it must not push gRPC pings either — every
170    /// one would time out and hold healthy workers off dispatch. The production
171    /// wiring
172    /// ([`ServerState::spawn_liminal_liveness_probe`](crate::ServerState::spawn_liminal_liveness_probe))
173    /// always supplies one; [`LivenessProbe::new`] is the liminal-only
174    /// construction for callers with no gRPC fleet.
175    grpc: Option<GrpcLivenessWaiters>,
176    tracker: HeartbeatTracker,
177    registry: ConnectedWorkerRegistry,
178    cadence: Duration,
179    silence_window: Duration,
180}
181
182impl std::fmt::Debug for LivenessProbe {
183    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        formatter
185            .debug_struct("LivenessProbe")
186            .field("cadence", &self.cadence)
187            .field("silence_window", &self.silence_window)
188            .finish_non_exhaustive()
189    }
190}
191
192impl LivenessProbe {
193    /// Build a probe over the notifier that owns the liminal connections, the
194    /// shared liveness tracker whose leases a pong refreshes, and the registry
195    /// the WARN lines resolve a worker's queue through.
196    ///
197    /// Both timings derive from `heartbeat_window` (see the module docs); there
198    /// is no separate configuration surface.
199    /// This construction probes the LIMINAL transport only: it carries no gRPC
200    /// answer-correlation registry, so it does not enumerate gRPC deliveries.
201    /// Use [`Self::across_transports`] for the production probe.
202    #[must_use]
203    pub fn new(
204        notifier: Arc<LiminalConnectionNotifier>,
205        tracker: HeartbeatTracker,
206        registry: ConnectedWorkerRegistry,
207        heartbeat_window: Duration,
208    ) -> Self {
209        Self::across_transports(Some(notifier), None, tracker, registry, heartbeat_window)
210    }
211
212    /// Build the probe that covers EVERY transport a worker may be delivered
213    /// over (#197).
214    ///
215    /// `notifier` is `None` on a boot with no liminal listener; `grpc` is the
216    /// correlation registry the gRPC stream handler delivers answers into, and
217    /// is `None` only for a probe that must not push gRPC pings it could never
218    /// hear the answers to.
219    ///
220    /// There is exactly ONE probe per server. Two would each publish a whole
221    /// eligibility set over the other's — the verdict is a replacement, not a
222    /// merge — so the last writer would silently erase the other transport's
223    /// findings every cadence.
224    #[must_use]
225    pub fn across_transports(
226        notifier: Option<Arc<LiminalConnectionNotifier>>,
227        grpc: Option<GrpcLivenessWaiters>,
228        tracker: HeartbeatTracker,
229        registry: ConnectedWorkerRegistry,
230        heartbeat_window: Duration,
231    ) -> Self {
232        Self {
233            notifier,
234            grpc,
235            tracker,
236            registry,
237            cadence: sweep_interval(heartbeat_window),
238            silence_window: heartbeat_window,
239        }
240    }
241
242    /// The interval between probe rounds.
243    #[must_use]
244    pub const fn cadence(&self) -> Duration {
245        self.cadence
246    }
247
248    /// The silence window this probe declares to every worker it pings.
249    #[must_use]
250    pub const fn silence_window(&self) -> Duration {
251        self.silence_window
252    }
253
254    /// Run the probe until `shutdown` flips to `true`.
255    ///
256    /// Rounds never overlap: each tick's pings are awaited to completion (each
257    /// bounded by the cadence) before the next round starts, and a missed tick
258    /// is skipped rather than queued.
259    ///
260    /// That bounds the concurrent WAITS to one per connection. It was once
261    /// claimed to bound the outstanding PUSHES to one as well, "so it can never
262    /// crowd out real dispatches against liminal's per-connection pending-push
263    /// cap." **That claim was false and the failure it denied is exactly what
264    /// happened** on run `dfd2117c`: a push slot is not released by the caller
265    /// giving up, only by a consumed reply, a deadline expiry, or a connection
266    /// close. Awaiting a round to completion ends the wait, not the slot. So
267    /// abandoning one unanswered no-deadline ping per round leaked one slot per
268    /// round — 32 of them, then total refusal of every push on that connection.
269    ///
270    /// The bound is now real because
271    /// [`LiminalWorkerDelivery::push_payload_with_deadline`] attaches the
272    /// cadence as the push's own reply deadline, so an unanswered ping's slot
273    /// expires and RELEASES its cap admission instead of accumulating.
274    pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
275        info!(
276            cadence_ms = self.cadence.as_millis(),
277            silence_window_ms = self.silence_window.as_millis(),
278            "worker liveness probe started (liminal + grpc)"
279        );
280        let mut sequence = 0_u64;
281        let mut ticks = tokio::time::interval(self.cadence);
282        ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
283        loop {
284            tokio::select! {
285                _ = ticks.tick() => {
286                    if *shutdown.borrow() {
287                        break;
288                    }
289                    sequence = sequence.saturating_add(1);
290                    self.probe_once(sequence).await;
291                }
292                changed = shutdown.changed() => {
293                    // A receive error means every sender dropped; treat that as
294                    // a shutdown request rather than spinning.
295                    if changed.is_err() || *shutdown.borrow() {
296                        break;
297                    }
298                }
299            }
300        }
301        info!("worker liveness probe stopped");
302    }
303
304    /// Ping every reachable worker once — liminal connections and gRPC task
305    /// streams alike, concurrently — apply each answer to the worker's dispatch
306    /// probation, and publish ONE verdict for the round.
307    ///
308    /// The two transports are pinged over their own wires, CONCURRENTLY with
309    /// each other, and are otherwise indistinguishable from here down: the same
310    /// probation, the same tracker, the same single
311    /// [`Self::publish_reachability_verdict`] call. A round with
312    /// no target of either kind publishes nothing — there is no verdict to form
313    /// about an empty fleet, and publishing an empty exclusion set would be
314    /// indistinguishable from clearing one.
315    ///
316    /// Public because it is the whole unit of work: [`Self::run`] is a timer
317    /// around it and adds nothing but the schedule. Driving rounds directly is
318    /// how a test states a probation in ROUNDS rather than in wall-clock
319    /// sleeps, which is the difference between asserting the law and asserting
320    /// this machine's timing.
321    ///
322    /// `sequence` must advance between rounds: it is the echo a worker's answer
323    /// is matched against, and repeating one would let a previous round's late
324    /// answer satisfy this round's ping.
325    pub async fn probe_once(&self, sequence: u64) {
326        let liminal = self
327            .notifier
328            .as_ref()
329            .map_or_else(Vec::new, |notifier| notifier.liveness_targets());
330        let grpc = self.grpc_targets();
331        if liminal.is_empty() && grpc.is_empty() {
332            return;
333        }
334        let silence_window_ms = u64::try_from(self.silence_window.as_millis()).unwrap_or(u64::MAX);
335        // Joined, not sequenced. Each half bounds its own pings by the cadence,
336        // so awaiting them in turn would let a round of unanswered gRPC pings
337        // push the NEXT liminal ping a full cadence late — stretching the gap
338        // between a healthy liminal connection's refreshes toward three
339        // quarters of the heartbeat window and eroding the four-refreshes-per-
340        // window margin this module's timings are derived to guarantee. The
341        // halves share no state until their answers are applied, so there is
342        // nothing to serialize.
343        tokio::join!(
344            self.probe_liminal(sequence, silence_window_ms, liminal),
345            self.probe_grpc(sequence, silence_window_ms, grpc),
346        );
347        self.publish_reachability_verdict(sequence);
348    }
349
350    /// This round's gRPC targets, or none when this probe carries no answer
351    /// channel (see [`Self::grpc`]). A registry read failure is LOUD and yields
352    /// no targets: the alternative is pinging a fleet the probe cannot describe.
353    fn grpc_targets(&self) -> Vec<GrpcLivenessTarget> {
354        let Some(_) = self.grpc.as_ref() else {
355            return Vec::new();
356        };
357        match self.registry.grpc_liveness_targets() {
358            Ok(targets) => targets,
359            Err(error) => {
360                warn!(
361                    %error,
362                    "could not enumerate gRPC-delivered workers for this liveness round; they \
363                     go unprobed and will therefore not clear their dispatch probation"
364                );
365                Vec::new()
366            }
367        }
368    }
369
370    /// Push the serde ping to every liminal connection concurrently.
371    async fn probe_liminal(
372        &self,
373        sequence: u64,
374        silence_window_ms: u64,
375        targets: Vec<LivenessTarget>,
376    ) {
377        if targets.is_empty() {
378            return;
379        }
380        let ping = LivenessPing {
381            liveness_ping: sequence,
382            silence_window_ms,
383        };
384        let payload = match serde_json::to_vec(&ping) {
385            Ok(payload) => payload,
386            Err(error) => {
387                // Structurally unreachable (two integers), but a probe that
388                // cannot encode its own ping must say so rather than silently
389                // stop being a dead-man switch.
390                warn!(%error, "liminal liveness probe could not encode its ping; skipping round");
391                return;
392            }
393        };
394        let deadline = self.cadence;
395        let answers = targets.into_iter().map(|target| {
396            let payload = payload.clone();
397            async move {
398                let outcome = tokio::task::spawn_blocking(move || {
399                    ping_one(&target.delivery, payload, deadline)
400                })
401                .await;
402                (target.pid, target.worker_id, outcome)
403            }
404        });
405        for (pid, worker_id, outcome) in futures::future::join_all(answers).await {
406            self.apply_answer(pid, worker_id, sequence, outcome);
407        }
408    }
409
410    /// Push the protobuf ping down every gRPC task stream concurrently.
411    async fn probe_grpc(
412        &self,
413        sequence: u64,
414        silence_window_ms: u64,
415        targets: Vec<GrpcLivenessTarget>,
416    ) {
417        let (Some(waiters), false) = (self.grpc.as_ref(), targets.is_empty()) else {
418            return;
419        };
420        let deadline = self.cadence;
421        let answers = targets.iter().map(|target| async move {
422            let outcome = ping_grpc_worker(
423                waiters,
424                target,
425                aion_proto::ProtoLivenessPing {
426                    liveness_ping: sequence,
427                    silence_window_ms,
428                },
429                deadline,
430            )
431            .await;
432            (target.worker_id, outcome)
433        });
434        for (worker_id, outcome) in futures::future::join_all(answers).await {
435            self.apply_grpc_answer(worker_id, sequence, outcome);
436        }
437    }
438
439    /// Publish this round's reachability verdict to the registry, so dispatch
440    /// selection skips workers the server cannot reach.
441    ///
442    /// Runs after every round, including rounds where every ping succeeded —
443    /// that is what RESTORES eligibility to a worker whose pings have started
444    /// answering again. Recovery must not need a separate trigger.
445    ///
446    /// This is the half that makes the switch able to fire at all. A worker's
447    /// connection lease is refreshed by anything it sends, including its own
448    /// background liveness pump, so a worker whose dispatch path is completely
449    /// dead can look perfectly alive indefinitely. Reachability is tracked
450    /// separately and only an answered ping advances it, so the pump can no
451    /// longer hold dispatch eligibility open against failing pings.
452    ///
453    /// # What this says out loud, and why it changed
454    ///
455    /// Until 2026-08-05 this announced exactly one transition — the withdrawal
456    /// — in one sentence that was FALSE in the commonest case. Every healthy
457    /// worker start logged `WITHDRAWING DISPATCH ELIGIBILITY … the server has
458    /// not been able to reach its dispatch path within the window`, because
459    /// registration opens an unserved probation and the first round after it
460    /// always finds the probation unserved. At that instant the server had
461    /// reached the worker — one answer was already banked — and the window had
462    /// nothing to do with it. The remedy sentence was wrong too: it promised
463    /// eligibility back after "one ping", when
464    /// [`DISPATCH_PROBATION_PINGS`](super::heartbeat::DISPATCH_PROBATION_PINGS)
465    /// consecutive answers are required and one was already in hand.
466    ///
467    /// The restoration a few seconds later was silent, so an operator saw the
468    /// alarm and never the all-clear. On Tom's server that read as a broken
469    /// worker and was reported to him as a caveat on a fix that was in fact
470    /// working. An alarm that fires on every ordinary connect carries no
471    /// information; a resolution nobody announces cannot cancel it.
472    ///
473    /// So the three transitions are now distinguished and all three are said:
474    /// the probation opening (ordinary, INFO), the loss of eligibility that was
475    /// actually held (an incident, WARN), and the recovery (INFO).
476    ///
477    /// # A KNOWN FALSE RED IN THE WITHDRAWAL, STATED PLAINLY
478    ///
479    /// A gRPC worker running at its full concurrency stops reading its task
480    /// stream: the worker runtime's receive loop waits for a concurrency permit
481    /// before it reads the next frame, so a ping already on the wire is not
482    /// answered until a permit frees. A worker saturated for longer than one
483    /// probe cadence therefore LOOKS silent while it is in fact working, and
484    /// this WARN will say its dispatch path is unreachable when the truth is
485    /// that it is busy. The line is honest about what was measured — no answer
486    /// arrived — and wrong about what that means.
487    ///
488    /// It is bounded and self-healing, and no operator action is required:
489    /// eligibility is withdrawn, so no further dispatch is sent to that worker;
490    /// the worker finishes its in-flight activities and its permits free; it
491    /// reads and answers the next ping; it serves its two-ping probation and
492    /// returns to the eligible set with the INFO all-clear. In-flight work is
493    /// untouched throughout — this verdict governs who may be SELECTED, never
494    /// what happens to work already dispatched.
495    ///
496    /// The fix is on the admission path (the receive loop must read a frame
497    /// before it waits for a permit), which is a change to how work is
498    /// ACCEPTED, not to how liveness is measured, and it is board item #206.
499    /// This paragraph is struck by the change that lands it.
500    ///
501    /// It is written here rather than left to the reviewer's notes because a
502    /// verdict must not lie about what it measured: an operator reading a
503    /// withdrawal for a saturated worker deserves to find, at the place the
504    /// reason is minted, that this case is known, is not their fault, and needs
505    /// nothing from them.
506    fn publish_reachability_verdict(&self, sequence: u64) {
507        let now = Instant::now();
508        let unreachable = match self.tracker.unreachable_workers(now) {
509            Ok(workers) => workers,
510            Err(error) => {
511                warn!(
512                    %error,
513                    liveness_ping = sequence,
514                    "could not read liminal worker reachability; leaving the previous dispatch \
515                     eligibility verdict in place rather than guessing"
516                );
517                return;
518            }
519        };
520        let excluded_now: BTreeSet<WorkerId> = unreachable
521            .iter()
522            .map(|excluded| excluded.worker_id)
523            .collect();
524        // The previous verdict is what makes a transition a transition. If it
525        // cannot be read the announcements are SKIPPED rather than guessed —
526        // assuming an empty previous set would re-announce every standing
527        // exclusion as though it had just happened. The verdict itself still
528        // publishes below: gating dispatch is the load-bearing half, and it
529        // must not be dropped because the narration failed.
530        match self.registry.dispatch_ineligible() {
531            Ok(previously_excluded) => {
532                self.announce_transitions(
533                    sequence,
534                    &previously_excluded,
535                    &unreachable,
536                    &excluded_now,
537                );
538            }
539            Err(error) => warn!(
540                %error,
541                liveness_ping = sequence,
542                "could not read the published liminal dispatch eligibility set; this round's \
543                 eligibility changes go UNANNOUNCED, though the verdict itself is still published"
544            ),
545        }
546        if let Err(error) = self.registry.set_dispatch_ineligible(excluded_now) {
547            warn!(
548                %error,
549                liveness_ping = sequence,
550                "could not publish liminal worker dispatch eligibility; selection keeps the \
551                 previous verdict"
552            );
553        }
554    }
555
556    /// Say what changed this round — and only what changed, so a persistently
557    /// excluded worker does not re-log every cadence.
558    fn announce_transitions(
559        &self,
560        sequence: u64,
561        previously_excluded: &BTreeSet<WorkerId>,
562        unreachable: &[ExcludedWorker],
563        excluded_now: &BTreeSet<WorkerId>,
564    ) {
565        for excluded in unreachable {
566            let Some(announcement) = transition(
567                previously_excluded.contains(&excluded.worker_id),
568                Some(excluded.exclusion),
569            ) else {
570                continue;
571            };
572            self.say(sequence, excluded.worker_id, announcement);
573        }
574        for worker_id in previously_excluded.difference(excluded_now) {
575            // A worker that DEPARTED is not a worker that recovered. The
576            // registry read is the discriminator: an entry no longer in it left
577            // the fleet, and announcing its recovery would be a fabrication.
578            let Some(task_queue) = self.task_queue_of(*worker_id) else {
579                continue;
580            };
581            let Some(announcement) = transition(true, None) else {
582                continue;
583            };
584            self.say_with_queue(sequence, *worker_id, &task_queue, announcement);
585        }
586    }
587
588    /// Emit one announcement, resolving the worker's queue for the line.
589    fn say(&self, sequence: u64, worker_id: WorkerId, announcement: Announcement) {
590        let task_queue = self
591            .task_queue_of(worker_id)
592            .unwrap_or_else(|| "<unregistered>".to_owned());
593        self.say_with_queue(sequence, worker_id, &task_queue, announcement);
594    }
595
596    /// Emit one announcement against an already-resolved queue.
597    fn say_with_queue(
598        &self,
599        sequence: u64,
600        worker_id: WorkerId,
601        task_queue: &str,
602        announcement: Announcement,
603    ) {
604        let transport = self.transport_of(worker_id);
605        match announcement {
606            Announcement::ProbationOpened { answers } => info!(
607                worker_id = worker_id.value(),
608                task_queue,
609                transport,
610                liveness_ping = sequence,
611                answers_banked = answers,
612                answers_required = DISPATCH_PROBATION_PINGS,
613                "worker is SERVING ITS DISPATCH PROBATION: it has answered {answers} of \
614                 {DISPATCH_PROBATION_PINGS} consecutive liveness pings since it connected, and is \
615                 not selected for dispatch until the run is complete. This is the ordinary cost of \
616                 connecting — every healthy worker start passes through it, on every transport — \
617                 not a fault, and not a statement that anything is unreachable"
618            ),
619            Announcement::EligibilityWithdrawn => warn!(
620                worker_id = worker_id.value(),
621                task_queue,
622                transport,
623                liveness_ping = sequence,
624                answers_required = DISPATCH_PROBATION_PINGS,
625                silence_window_ms = self.silence_window.as_millis(),
626                "WITHDRAWING DISPATCH ELIGIBILITY from worker: it had PROVED its dispatch path \
627                 reachable on this connection and the server can no longer prove it — either a \
628                 liveness ping failed or the last proof aged out of the window. It stays \
629                 registered and keeps its in-flight work, and becomes eligible again after \
630                 {DISPATCH_PROBATION_PINGS} consecutive answered pings"
631            ),
632            Announcement::EligibilityRestored => info!(
633                worker_id = worker_id.value(),
634                task_queue,
635                transport,
636                liveness_ping = sequence,
637                "DISPATCH ELIGIBILITY RESTORED to worker: it has answered a full run of \
638                 consecutive liveness pings, so the server can again prove it reaches this \
639                 worker's dispatch path. Dispatch selection includes it from now"
640            ),
641        }
642    }
643
644    /// The transport a worker is delivered over, for the announcement line.
645    ///
646    /// `"<unregistered>"` when the worker has left the registry or the registry
647    /// cannot be read — a departed worker's transport is genuinely unknowable
648    /// here, and naming one would be a fabrication.
649    fn transport_of(&self, worker_id: WorkerId) -> &'static str {
650        match self.registry.worker_by_id(worker_id) {
651            Ok(Some(handle)) => match handle.delivery() {
652                WorkerDelivery::Grpc(_) => ProbedTransport::Grpc.name(),
653                #[cfg(feature = "liminal-transport")]
654                WorkerDelivery::Liminal(_) => ProbedTransport::Liminal { pid: 0 }.name(),
655            },
656            Ok(None) | Err(_) => "<unregistered>",
657        }
658    }
659
660    /// Apply one connection's ping outcome: a correct answer advances dispatch
661    /// reachability, anything else is logged LOUDLY, proves nothing, and RESETS
662    /// the probation — proof of reachability must be a consecutive run.
663    fn apply_answer(
664        &self,
665        pid: u64,
666        worker_id: WorkerId,
667        sequence: u64,
668        outcome: Result<Result<LivenessPong, PingFailure>, tokio::task::JoinError>,
669    ) {
670        let failure = match outcome {
671            Err(join_error) => {
672                PingFailure::Unanswered(format!("ping task failed to run: {join_error}"))
673            }
674            Ok(Err(failure)) => failure,
675            Ok(Ok(pong)) if pong.liveness_pong != sequence => PingFailure::Unanswered(format!(
676                "worker answered with mismatched sequence {}",
677                pong.liveness_pong
678            )),
679            Ok(Ok(_)) => {
680                self.record_reachable(ProbedTransport::Liminal { pid }, worker_id);
681                return;
682            }
683        };
684        self.record_unreachable(
685            ProbedTransport::Liminal { pid },
686            worker_id,
687            sequence,
688            failure,
689        );
690    }
691
692    /// The gRPC counterpart of [`Self::apply_answer`] (#197).
693    ///
694    /// Deliberately the same two outcomes fed into the same two recorders: the
695    /// only gRPC-specific step is that a mismatched echo cannot reach here at
696    /// all, because the correlation registry
697    /// ([`GrpcLivenessWaiters::answer`](super::grpc_liveness::GrpcLivenessWaiters::answer))
698    /// refuses to match one — so an echo that arrives is by construction the
699    /// sequence that was sent.
700    fn apply_grpc_answer(
701        &self,
702        worker_id: WorkerId,
703        sequence: u64,
704        outcome: Result<u64, PingFailure>,
705    ) {
706        match outcome {
707            Ok(echoed) if echoed == sequence => {
708                self.record_reachable(ProbedTransport::Grpc, worker_id);
709            }
710            Ok(echoed) => self.record_unreachable(
711                ProbedTransport::Grpc,
712                worker_id,
713                sequence,
714                PingFailure::Unanswered(format!(
715                    "worker answered with mismatched sequence {echoed}"
716                )),
717            ),
718            Err(failure) => {
719                self.record_unreachable(ProbedTransport::Grpc, worker_id, sequence, failure);
720            }
721        }
722    }
723
724    /// Bank one answered ping: the ONE thing that proves the server can reach
725    /// this worker's DISPATCH path.
726    ///
727    /// Only an answer advances reachability — an inbound frame proves the
728    /// opposite direction and cannot stand in for it. A `false` return from the
729    /// tracker means the worker was already deregistered (an answer racing a
730    /// reap); an answer must never resurrect it.
731    fn record_reachable(&self, transport: ProbedTransport, worker_id: WorkerId) {
732        if let Err(error) = self
733            .tracker
734            .record_dispatch_reachability(worker_id, Instant::now())
735        {
736            warn!(
737                %error,
738                connection_pid = transport.pid(),
739                transport = transport.name(),
740                worker_id = worker_id.value(),
741                "failed to record worker dispatch reachability from a liveness answer"
742            );
743        }
744    }
745
746    /// Record one FAILED ping and say which of the two failures it was.
747    ///
748    /// A probation is CONSECUTIVE, so any failure resets it to zero. Without
749    /// this the counter would be cumulative, and a link that answers one probe
750    /// in three would still accrue its way to eligibility and then flap in and
751    /// out of it forever — which is exactly the defect the probation exists to
752    /// stop. Both failure classes reset: whether we could not ask or the worker
753    /// did not answer, the run of answers is broken either way. `Ok(false)`
754    /// means the worker was already deregistered; nothing to reset.
755    fn record_unreachable(
756        &self,
757        transport: ProbedTransport,
758        worker_id: WorkerId,
759        sequence: u64,
760        failure: PingFailure,
761    ) {
762        if let Err(error) = self.tracker.record_dispatch_unreachable(worker_id) {
763            warn!(
764                %error,
765                connection_pid = transport.pid(),
766                transport = transport.name(),
767                worker_id = worker_id.value(),
768                "failed to reset worker dispatch probation after a failed liveness ping; \
769                 its eligibility may outlive the proof that earned it"
770            );
771        }
772        // The two failures are DIFFERENT FACTS and the operator must be able to
773        // tell them apart: one is about the worker, the other is about us.
774        let task_queue = self
775            .task_queue_of(worker_id)
776            .unwrap_or_else(|| "<unregistered>".to_owned());
777        let transport_name = transport.name();
778        match failure {
779            PingFailure::Unaskable(reason) => warn!(
780                connection_pid = transport.pid(),
781                transport = transport_name,
782                worker_id = worker_id.value(),
783                task_queue = %task_queue,
784                liveness_ping = sequence,
785                reason = %reason,
786                silence_window_ms = self.silence_window.as_millis(),
787                "THE SERVER COULD NOT ASK this {transport_name} worker for liveness — the push \
788                 itself was refused, so nothing was sent and the worker has no idea it was probed. \
789                 A dispatch would be refused by the same channel for the same reason. This says \
790                 nothing about whether the worker is healthy; it says this server cannot currently \
791                 reach it. Its dispatch eligibility is withdrawn NOW and it must answer a full run \
792                 of consecutive pings to earn it back"
793            ),
794            PingFailure::Unanswered(reason) => warn!(
795                connection_pid = transport.pid(),
796                transport = transport_name,
797                worker_id = worker_id.value(),
798                task_queue = %task_queue,
799                liveness_ping = sequence,
800                reason = %reason,
801                silence_window_ms = self.silence_window.as_millis(),
802                "{transport_name} worker did not answer its liveness ping; the server could not \
803                 prove it can reach this worker's dispatch path, so its dispatch eligibility is \
804                 withdrawn NOW and it must answer a full run of consecutive pings to earn it back \
805                 — an unanswered probe is direct evidence about the push leg, not mere silence. \
806                 NOTE: the worker's connection lease may still be fresh — its liveness pump beats \
807                 from a background task and keeps proving the process is alive — so do NOT expect \
808                 an expiry sweep to reap it"
809            ),
810        }
811    }
812
813    /// The task queue a worker is registered on, for the WARN line. `None` when
814    /// the worker is no longer in the registry (already reaped).
815    fn task_queue_of(&self, worker_id: WorkerId) -> Option<String> {
816        self.registry
817            .worker_by_id(worker_id)
818            .ok()
819            .flatten()
820            .map(|handle| handle.task_queue().to_owned())
821    }
822}
823
824/// Push one ping and block for its correlated answer, bounded by `deadline`.
825///
826/// Runs on a blocking thread: the liminal push/await pair is thread-based, not
827/// async. Every failure is rendered as a reason string, because at this layer
828/// the distinction that matters is "answered" vs "did not answer" — the typed
829/// error text rides into the WARN verbatim.
830/// A change in one worker's dispatch standing that the operator must be told
831/// about — never the standing state itself, so a persistently excluded worker
832/// does not re-log every cadence.
833#[derive(Clone, Copy, Debug, PartialEq, Eq)]
834enum Announcement {
835    /// A freshly connected worker began serving its probation. Ordinary.
836    ProbationOpened {
837        /// Consecutive answers banked when the probation was announced.
838        answers: u32,
839    },
840    /// A worker that HELD eligibility lost it. An incident.
841    EligibilityWithdrawn,
842    /// A worker that was excluded is dispatchable again.
843    EligibilityRestored,
844}
845
846/// The whole truth table for one worker's standing between two rounds.
847///
848/// Pure and total on purpose: the four inputs are exhaustively enumerated in
849/// the tests, which is the only way to be sure the alarming half and the
850/// reassuring half are both reachable. The old code had no such function — the
851/// decision was inlined and only ever produced one of the three lines, so the
852/// missing two were invisible.
853const fn transition(
854    was_excluded: bool,
855    now_excluded: Option<DispatchExclusion>,
856) -> Option<Announcement> {
857    match (was_excluded, now_excluded) {
858        // Newly excluded. WHICH exclusion decides whether this is news.
859        (false, Some(DispatchExclusion::OpeningProbation { answers })) => {
860            Some(Announcement::ProbationOpened { answers })
861        }
862        (false, Some(DispatchExclusion::ReachabilityLost)) => {
863            Some(Announcement::EligibilityWithdrawn)
864        }
865        // Left the exclusion set: the all-clear.
866        (true, None) => Some(Announcement::EligibilityRestored),
867        // No CHANGE in standing, by either route: a worker still excluded (its
868        // exclusion was announced when it began, and repeating it every cadence
869        // is how a log stops being read), or one that was eligible and stayed
870        // eligible. Both are silence, for different reasons.
871        (true, Some(_)) | (false, None) => None,
872    }
873}
874
875fn ping_one(
876    delivery: &LiminalWorkerDelivery,
877    payload: Vec<u8>,
878    deadline: Duration,
879) -> Result<LivenessPong, PingFailure> {
880    // The deadline is attached to the PUSH, not just to the wait. Without it the
881    // reply slot is reclaimed only by a consumed reply or a connection close, so
882    // abandoning an unanswered ping every cadence leaks one slot per round until
883    // the connection's push cap is exhausted and nothing — ping, dispatch or
884    // intervention — can be pushed to that worker again.
885    let awaiter = delivery
886        .push_payload_with_deadline(payload, deadline)
887        .map_err(|error| PingFailure::Unaskable(error.to_string()))?;
888    let reply = awaiter
889        .receive(deadline)
890        .map_err(|error| PingFailure::Unanswered(format!("no answer arrived: {error}")))?;
891    serde_json::from_slice(&reply)
892        .map_err(|error| PingFailure::Unanswered(format!("answer could not be decoded: {error}")))
893}
894
895#[cfg(test)]
896mod tests {
897    use std::sync::Arc;
898    use std::time::{Duration, Instant};
899
900    use super::super::heartbeat::{DISPATCH_PROBATION_PINGS, HeartbeatTracker};
901    use super::super::liminal_transport::LiminalConnectionNotifier;
902    use super::super::registry::{ConnectedWorkerRegistry, WorkerId};
903    use super::{
904        Announcement, DispatchExclusion, LivenessPing, LivenessPong, LivenessProbe, PingFailure,
905        transition,
906    };
907
908    /// The WHOLE truth table, enumerated. Four inputs, and every one of them is
909    /// asserted here — which is the only way to be sure both the alarming and
910    /// the reassuring outcomes are reachable.
911    ///
912    /// The version of this logic that shipped until 2026-08-05 could emit
913    /// exactly one of these lines. The other two were not wrong; they did not
914    /// exist, so an operator saw the withdrawal on every healthy worker start
915    /// and never saw the recovery that followed seconds later.
916    #[test]
917    fn every_standing_change_has_exactly_one_announcement() {
918        assert_eq!(
919            transition(
920                false,
921                Some(DispatchExclusion::OpeningProbation { answers: 1 })
922            ),
923            Some(Announcement::ProbationOpened { answers: 1 }),
924            "a fresh connection serving its probation is ORDINARY and must not be announced as a \
925             worker the server cannot reach"
926        );
927        assert_eq!(
928            transition(false, Some(DispatchExclusion::ReachabilityLost)),
929            Some(Announcement::EligibilityWithdrawn),
930            "losing eligibility that was actually held is the incident the WARN exists for"
931        );
932        assert_eq!(
933            transition(true, None),
934            Some(Announcement::EligibilityRestored),
935            "the all-clear must be said out loud — an alarm nobody cancels is read as ongoing"
936        );
937        assert_eq!(
938            transition(true, Some(DispatchExclusion::ReachabilityLost)),
939            None,
940            "a standing exclusion must not re-log every cadence"
941        );
942        assert_eq!(
943            transition(
944                true,
945                Some(DispatchExclusion::OpeningProbation { answers: 0 })
946            ),
947            None,
948            "including while a still-excluded worker is still serving its probation"
949        );
950        assert_eq!(
951            transition(false, None),
952            None,
953            "an eligible worker that stayed eligible is not news"
954        );
955    }
956
957    /// The ping/pong pair round-trips with stable field names — the cross-crate
958    /// wire contract with `aion-worker`'s mirror of these types. A drift here is
959    /// a wire break, so the exact JSON is pinned.
960    #[test]
961    fn the_liveness_pair_round_trips_through_json() -> Result<(), serde_json::Error> {
962        let ping = LivenessPing {
963            liveness_ping: 7,
964            silence_window_ms: 30_000,
965        };
966        let encoded = serde_json::to_string(&ping)?;
967        assert_eq!(encoded, r#"{"liveness_ping":7,"silence_window_ms":30000}"#);
968        assert_eq!(serde_json::from_str::<LivenessPing>(&encoded)?, ping);
969
970        let answer = LivenessPong { liveness_pong: 7 };
971        let encoded = serde_json::to_string(&answer)?;
972        assert_eq!(encoded, r#"{"liveness_pong":7}"#);
973        assert_eq!(serde_json::from_str::<LivenessPong>(&encoded)?, answer);
974        Ok(())
975    }
976
977    /// A ping decodes as NEITHER of the other two frames that share the push
978    /// channel, and neither of them decodes as a ping — the demux contract the
979    /// worker's serve loop relies on.
980    #[test]
981    fn a_liveness_ping_is_disjoint_from_the_other_pushed_frames() -> Result<(), serde_json::Error> {
982        let ping = serde_json::to_vec(&LivenessPing {
983            liveness_ping: 1,
984            silence_window_ms: 1_000,
985        })?;
986        assert!(
987            serde_json::from_slice::<super::super::liminal_transport::DispatchRequest>(&ping)
988                .is_err(),
989            "a liveness ping must never decode as a dispatch"
990        );
991        assert!(
992            serde_json::from_slice::<super::super::liminal_transport::InterventionRequest>(&ping)
993                .is_err(),
994            "a liveness ping must never decode as an intervention"
995        );
996        Ok(())
997    }
998
999    /// Both timings derive from the operator's heartbeat window, with no
1000    /// separate knob: the cadence is the sweeper's quarter-window derivation and
1001    /// the declared silence window is the heartbeat window itself.
1002    #[test]
1003    fn probe_timings_derive_from_the_heartbeat_window() {
1004        let window = Duration::from_secs(30);
1005        assert_eq!(super::sweep_interval(window), Duration::from_millis(7_500));
1006        // The declared window IS the operator's window; the cadence divides it,
1007        // so a healthy connection is refreshed four times per window.
1008        assert!(super::sweep_interval(window) * 4 <= window);
1009    }
1010
1011    /// 🔴 THE WIRING PIN. The probation lives in the tracker, but only the probe
1012    /// can tell it a ping FAILED — and a probe that recorded successes and
1013    /// dropped failures would compile, log its WARN lines exactly as it does
1014    /// now, and leave the probation permanently unreset. The eligibility bug
1015    /// would be silently back with every test in `heartbeat` still green,
1016    /// because those tests drive the tracker directly and never go through this
1017    /// seam.
1018    ///
1019    /// Every failure class is asserted individually. The fourth arm — a
1020    /// `JoinError` from the ping task — converges on the same
1021    /// `PingFailure::Unanswered` path these two take, one line above the reset.
1022    #[test]
1023    fn every_failed_probe_class_withdraws_dispatch_eligibility() {
1024        let window = Duration::from_secs(30);
1025        let worker = WorkerId::from_value(1);
1026        let start = Instant::now();
1027
1028        // Each class gets its own probe and its own served probation, so a class
1029        // cannot pass by inheriting the withdrawal an earlier class performed.
1030        let failures = [
1031            (
1032                "the push was refused, so nothing was even asked",
1033                Ok(Err(PingFailure::Unaskable("push refused".to_owned()))),
1034            ),
1035            (
1036                "the ping was sent and no answer came back",
1037                Ok(Err(PingFailure::Unanswered("no answer arrived".to_owned()))),
1038            ),
1039            (
1040                "an answer came back carrying the wrong sequence",
1041                Ok(Ok(LivenessPong { liveness_pong: 99 })),
1042            ),
1043        ];
1044
1045        for (class, outcome) in failures {
1046            let registry = ConnectedWorkerRegistry::default();
1047            let tracker = HeartbeatTracker::new(window);
1048            let probe = LivenessProbe::new(
1049                Arc::new(LiminalConnectionNotifier::new(registry.clone())),
1050                tracker.clone(),
1051                registry,
1052                window,
1053            );
1054
1055            assert!(
1056                tracker.register_connection(worker, start).is_ok(),
1057                "tracker registration must succeed for {class}"
1058            );
1059            for _ in 0..DISPATCH_PROBATION_PINGS {
1060                assert!(
1061                    tracker
1062                        .record_dispatch_reachability(worker, start)
1063                        .is_ok_and(|tracked| tracked),
1064                    "the worker serves its probation before {class}"
1065                );
1066            }
1067            assert!(
1068                tracker
1069                    .is_dispatch_reachable(worker, start)
1070                    .is_ok_and(|reachable| reachable),
1071                "precondition: the worker is eligible before {class}"
1072            );
1073
1074            // Sequence 1 is the ping that was sent; the mismatched-answer case
1075            // deliberately answers 99.
1076            probe.apply_answer(7, worker, 1, outcome);
1077
1078            assert!(
1079                tracker
1080                    .is_dispatch_reachable(worker, start)
1081                    .is_ok_and(|reachable| !reachable),
1082                "the probe must withdraw dispatch eligibility when {class} — otherwise the \
1083                 probation never resets and a one-way link keeps its eligibility forever"
1084            );
1085        }
1086    }
1087
1088    /// The control for the pin above: the SUCCESS path through the same seam
1089    /// must keep eligibility. Without it, a probe that withdrew eligibility on
1090    /// every outcome — including healthy answers — would satisfy every
1091    /// assertion above and strand every worker on the fleet.
1092    #[test]
1093    fn an_answered_probe_keeps_dispatch_eligibility_through_the_same_seam() {
1094        let window = Duration::from_secs(30);
1095        let worker = WorkerId::from_value(1);
1096        let start = Instant::now();
1097        let registry = ConnectedWorkerRegistry::default();
1098        let tracker = HeartbeatTracker::new(window);
1099        let probe = LivenessProbe::new(
1100            Arc::new(LiminalConnectionNotifier::new(registry.clone())),
1101            tracker.clone(),
1102            registry,
1103            window,
1104        );
1105
1106        assert!(tracker.register_connection(worker, start).is_ok());
1107        for sequence in 1..=u64::from(DISPATCH_PROBATION_PINGS) {
1108            probe.apply_answer(
1109                7,
1110                worker,
1111                sequence,
1112                Ok(Ok(LivenessPong {
1113                    liveness_pong: sequence,
1114                })),
1115            );
1116        }
1117
1118        assert!(
1119            tracker
1120                .is_dispatch_reachable(worker, start)
1121                .is_ok_and(|reachable| reachable),
1122            "answered probes must EARN eligibility through the probe seam, not merely fail to \
1123             withdraw it"
1124        );
1125    }
1126}