Skip to main content

aion_server/worker/
grpc_liveness.rs

1//! gRPC half of the worker dead-man switch: the transport liveness ping and
2//! the correlation registry its answers land in (#197).
3//!
4//! # The gap this closes
5//!
6//! Dispatch eligibility is a verdict of the liveness probe. Until this module
7//! existed the probe enumerated only liminal connections
8//! ([`LiminalConnectionNotifier::liveness_targets`](super::LiminalConnectionNotifier::liveness_targets)),
9//! so a `WorkerDelivery::Grpc` worker could never be asked a single question:
10//! its probation opened at registration, no answer could ever be banked against
11//! it, and [`ConnectedWorkerRegistry::select_worker`](super::ConnectedWorkerRegistry::select_worker)
12//! filtered it out for the life of the process. Registration succeeded and
13//! nothing was ever dispatched.
14//!
15//! # Why the ping rides the task stream
16//!
17//! The property the probe measures is "the server can reach THIS worker's
18//! dispatch path". Only a frame that travels the exact path a dispatch travels
19//! can measure it — which is why the ping is queued onto the same
20//! [`WorkerTaskSender`](super::registry::WorkerTaskSender) an
21//! [`ActivityTask`](aion_proto::ProtoActivityTask) is queued onto, and is
22//! answered by the SDK RUNTIME rather than by action code.
23//!
24//! The rejected alternative was channel-state-only liveness: treating an open
25//! sender, or an in-flight per-activity `Heartbeat`, as proof. Both re-create
26//! the exact failure this machinery was built against — run `dfd2117c`, where a
27//! worker's background pump kept its lease perfectly fresh while the server had
28//! been unable to push to it for fifteen minutes. A sender being open is a
29//! property of this process's memory; it is not a worker answering.
30//!
31//! # What a full channel means
32//!
33//! Admission is bounded by the probe cadence rather than refused instantly. A
34//! momentarily busy channel is not the same fact as a worker that has stopped
35//! reading its stream, and only the second is a dispatch-path failure — so the
36//! ping waits a whole cadence for a slot and reports
37//! [`PingFailure::Unaskable`](super::liveness::PingFailure::Unaskable)
38//! only if the channel never opened one. That is the same admission-refused
39//! class the liminal push reports when its per-connection pending-push cap is
40//! exhausted, and it says the same thing: a dispatch queued right now would be
41//! refused for the same reason.
42
43use std::collections::HashMap;
44use std::sync::{Arc, Mutex, MutexGuard};
45use std::time::Duration;
46
47use aion_proto::ProtoLivenessPing;
48use tokio::sync::oneshot;
49
50use super::liveness::PingFailure;
51use super::registry::{WorkerId, WorkerMessage, WorkerTaskSender};
52use crate::error::ServerError;
53
54/// One gRPC-delivered worker the probe pings on a round.
55///
56/// The liminal counterpart ([`LivenessTarget`](super::LivenessTarget)) carries a
57/// connection pid because a liminal worker is addressed by its connection. A
58/// gRPC worker has no such handle: its stream lives inside a tonic task and the
59/// registration's sender is the only way to reach it, so that sender IS the
60/// address.
61#[derive(Clone, Debug)]
62pub struct GrpcLivenessTarget {
63    /// Registry identity of the worker being probed.
64    pub worker_id: WorkerId,
65    /// The worker's stream delivery channel — the same one dispatches use.
66    pub sender: WorkerTaskSender,
67}
68
69/// One armed ping: the sequence the server is waiting to hear echoed, and the
70/// channel the inbound stream handler hands the echo back on.
71#[derive(Debug)]
72struct ArmedPing {
73    sequence: u64,
74    answer: oneshot::Sender<u64>,
75}
76
77/// Correlation registry joining a pushed [`ProtoLivenessPing`] to the
78/// `LivenessAnswer` frame that comes back up the worker's inbound stream.
79///
80/// The two halves run in different tasks and neither can see the other: the
81/// probe pushes from its own timer loop, and the answer arrives inside the
82/// tonic stream handler that owns the worker's inbound direction. This is the
83/// only object both hold.
84///
85/// At most ONE ping per worker is armed at a time. The probe's rounds never
86/// overlap and each waits out its own answer, so a second arm for the same
87/// worker means the previous round abandoned its ping — arming replaces it, and
88/// the abandoned receiver observes a closed channel rather than being left to
89/// accumulate.
90#[derive(Clone, Debug, Default)]
91pub struct GrpcLivenessWaiters {
92    inner: Arc<Mutex<HashMap<WorkerId, ArmedPing>>>,
93}
94
95impl GrpcLivenessWaiters {
96    /// Build an empty correlation registry.
97    #[must_use]
98    pub fn new() -> Self {
99        Self::default()
100    }
101
102    /// Arm a waiter for `sequence` on `worker_id`, returning the receiver the
103    /// probe awaits.
104    ///
105    /// Must be called BEFORE the ping is queued: arming afterwards leaves a
106    /// window in which a fast worker's answer arrives with nobody listening,
107    /// and the probe would then time out against a worker that answered.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`ServerError::LockPoisoned`] if the waiter map cannot be trusted.
112    pub fn arm(
113        &self,
114        worker_id: WorkerId,
115        sequence: u64,
116    ) -> Result<oneshot::Receiver<u64>, ServerError> {
117        let (answer, wait) = oneshot::channel();
118        self.waiters()?
119            .insert(worker_id, ArmedPing { sequence, answer });
120        Ok(wait)
121    }
122
123    /// Deliver an answer frame's echoed sequence to the armed waiter.
124    ///
125    /// Returns whether the answer was MATCHED to an armed ping. A `false`
126    /// return is one of two facts, both of which the caller logs rather than
127    /// swallows:
128    ///
129    /// - nothing was armed — an answer that arrived after its ping's cadence
130    ///   expired and the probe gave up, or after the worker was deregistered;
131    /// - an answer whose sequence is not the armed one — a stale echo from an
132    ///   earlier round.
133    ///
134    /// A stale echo deliberately does NOT consume the armed waiter. The
135    /// sequence exists so that a late answer cannot be counted as a fresh one;
136    /// consuming the waiter with a stale value would convert "this worker
137    /// answered late" into "this worker answered wrongly" and withdraw its
138    /// eligibility for the server's own timing.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`ServerError::LockPoisoned`] if the waiter map cannot be trusted.
143    pub fn answer(&self, worker_id: WorkerId, sequence: u64) -> Result<bool, ServerError> {
144        let mut waiters = self.waiters()?;
145        let Some(armed) = waiters.get(&worker_id) else {
146            return Ok(false);
147        };
148        if armed.sequence != sequence {
149            return Ok(false);
150        }
151        let Some(armed) = waiters.remove(&worker_id) else {
152            // Unreachable while the guard is held; handled rather than
153            // unwrapped because a probe that panics stops being a dead-man
154            // switch for the whole fleet.
155            return Ok(false);
156        };
157        drop(waiters);
158        // A send error means the probe already gave up and dropped its
159        // receiver: the ping is genuinely unanswered-in-time, and reporting it
160        // as matched would be a lie about a race we just lost.
161        Ok(armed.answer.send(sequence).is_ok())
162    }
163
164    /// Drop any armed waiter for a worker: its stream ended, or its ping's
165    /// cadence expired.
166    ///
167    /// Without this the map would grow one entry per departed worker forever on
168    /// a never-dying server, and a re-registered worker id could inherit a
169    /// stale sequence.
170    ///
171    /// # Errors
172    ///
173    /// Returns [`ServerError::LockPoisoned`] if the waiter map cannot be trusted.
174    pub fn disarm(&self, worker_id: WorkerId) -> Result<(), ServerError> {
175        self.waiters()?.remove(&worker_id);
176        Ok(())
177    }
178
179    fn waiters(&self) -> Result<MutexGuard<'_, HashMap<WorkerId, ArmedPing>>, ServerError> {
180        self.inner
181            .lock()
182            .map_err(|_| ServerError::lock_poisoned("grpc worker liveness waiters"))
183    }
184}
185
186/// Push one ping down a worker's task stream and wait for its correlated
187/// answer, with both halves bounded by `cadence`.
188///
189/// Ordering is load-bearing: admission is reserved first (so a full channel is
190/// diagnosed as `Unaskable` without a waiter ever being armed), then the waiter
191/// is armed, and only then does the frame go out. Arming after the send would
192/// race a fast worker's answer against an unarmed map.
193pub(super) async fn ping_grpc_worker(
194    waiters: &GrpcLivenessWaiters,
195    target: &GrpcLivenessTarget,
196    ping: ProtoLivenessPing,
197    cadence: Duration,
198) -> Result<u64, PingFailure> {
199    let sequence = ping.liveness_ping;
200    let permit = tokio::time::timeout(cadence, target.sender.reserve())
201        .await
202        .map_err(|_| {
203            PingFailure::Unaskable(format!(
204                "the worker's stream delivery channel stayed full for the whole {cadence:?} probe \
205                 cadence; a dispatch queued now would wait behind the same backlog"
206            ))
207        })?
208        .map_err(|error| {
209            PingFailure::Unaskable(format!(
210                "the worker's stream delivery channel is closed: {error}"
211            ))
212        })?;
213    let wait = waiters.arm(target.worker_id, sequence).map_err(|error| {
214        PingFailure::Unaskable(format!("could not arm the answer waiter: {error}"))
215    })?;
216    permit.send(WorkerMessage::LivenessPing(ping));
217    let answered = tokio::time::timeout(cadence, wait).await;
218    // Whatever happened, this round's waiter is spent: a later answer must find
219    // nothing armed rather than satisfy a ping the probe has already judged.
220    if let Err(error) = waiters.disarm(target.worker_id) {
221        tracing::warn!(
222            %error,
223            worker_id = target.worker_id.value(),
224            liveness_ping = sequence,
225            "could not disarm a spent gRPC liveness waiter; a later answer may be matched \
226             against a ping this round already judged"
227        );
228    }
229    match answered {
230        Ok(Ok(echoed)) => Ok(echoed),
231        Ok(Err(_)) => Err(PingFailure::Unanswered(String::from(
232            "the answer channel closed before the worker replied",
233        ))),
234        Err(_) => Err(PingFailure::Unanswered(format!(
235            "no answer arrived within the {cadence:?} probe cadence"
236        ))),
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use std::time::Duration;
243
244    use aion_proto::ProtoLivenessPing;
245
246    use super::{GrpcLivenessTarget, GrpcLivenessWaiters, ping_grpc_worker};
247    use crate::worker::registry::{WorkerId, WorkerMessage};
248
249    /// Tests propagate with `?` rather than unwrapping: a poisoned waiter map is
250    /// a real failure mode of the code under test and must surface as the typed
251    /// error it is, not as a panic message the test wrote.
252    type TestResult = Result<(), Box<dyn std::error::Error>>;
253
254    const CADENCE: Duration = Duration::from_millis(200);
255
256    fn ping(sequence: u64) -> ProtoLivenessPing {
257        ProtoLivenessPing {
258            liveness_ping: sequence,
259            silence_window_ms: 800,
260        }
261    }
262
263    /// The happy path through the real correlation seam: the ping lands on the
264    /// worker's stream channel and the echoed sequence comes back.
265    #[tokio::test]
266    async fn an_echoed_sequence_answers_the_armed_ping() -> TestResult {
267        let waiters = GrpcLivenessWaiters::new();
268        let (sender, mut stream) = tokio::sync::mpsc::channel(4);
269        let target = GrpcLivenessTarget {
270            worker_id: WorkerId::from_value(1),
271            sender,
272        };
273
274        let answering = {
275            let waiters = waiters.clone();
276            tokio::spawn(async move {
277                let Some(WorkerMessage::LivenessPing(received)) = stream.recv().await else {
278                    return Ok(false);
279                };
280                waiters.answer(WorkerId::from_value(1), received.liveness_ping)
281            })
282        };
283
284        let echoed = ping_grpc_worker(&waiters, &target, ping(9), CADENCE)
285            .await
286            .map_err(|failure| format!("ping failed: {failure:?}"))?;
287        assert_eq!(echoed, 9, "the probe must observe the sequence it sent");
288        assert!(
289            answering.await??,
290            "the answer must MATCH the armed ping, not merely be delivered"
291        );
292        Ok(())
293    }
294
295    /// A stale echo from an earlier round must not consume the armed waiter —
296    /// otherwise a late answer would be reported as a WRONG answer and withdraw
297    /// a healthy worker's eligibility for the server's own timing.
298    #[tokio::test]
299    async fn a_stale_sequence_neither_matches_nor_consumes_the_armed_waiter() -> TestResult {
300        let waiters = GrpcLivenessWaiters::new();
301        let worker = WorkerId::from_value(4);
302        let wait = waiters.arm(worker, 12)?;
303
304        assert!(
305            !waiters.answer(worker, 11)?,
306            "an answer echoing an earlier sequence is not an answer to this ping"
307        );
308        assert!(
309            waiters.answer(worker, 12)?,
310            "the armed ping must still be answerable after a stale echo was rejected"
311        );
312        assert_eq!(wait.await?, 12);
313        Ok(())
314    }
315
316    /// An answer for a worker with nothing armed is reported unmatched rather
317    /// than silently dropped: it is the caller's cue to log a frame the probe
318    /// had already given up on.
319    #[tokio::test]
320    async fn an_answer_with_nothing_armed_is_reported_unmatched() -> TestResult {
321        let waiters = GrpcLivenessWaiters::new();
322        assert!(!waiters.answer(WorkerId::from_value(2), 1)?);
323        Ok(())
324    }
325
326    /// A worker that never answers fails as UNANSWERED — the push was admitted,
327    /// so the fact is about the worker, not about this server's ability to ask.
328    #[tokio::test]
329    async fn silence_after_an_admitted_push_is_unanswered_not_unaskable() -> TestResult {
330        let waiters = GrpcLivenessWaiters::new();
331        let (sender, _stream) = tokio::sync::mpsc::channel(4);
332        let target = GrpcLivenessTarget {
333            worker_id: WorkerId::from_value(3),
334            sender,
335        };
336
337        let Err(failure) =
338            ping_grpc_worker(&waiters, &target, ping(1), Duration::from_millis(60)).await
339        else {
340            return Err("a worker that never answers must not report success".into());
341        };
342        assert!(
343            matches!(failure, super::PingFailure::Unanswered(_)),
344            "an admitted push that goes unanswered is evidence about the WORKER: {failure:?}"
345        );
346        Ok(())
347    }
348
349    /// A stream channel that never opens a slot fails as UNASKABLE — nothing
350    /// left the server, so the fact is about this server's reach, and the
351    /// waiter map must be left clean (arming happens only after admission).
352    #[tokio::test]
353    async fn a_channel_that_never_admits_is_unaskable_and_arms_nothing() -> TestResult {
354        let waiters = GrpcLivenessWaiters::new();
355        let (sender, _held) = tokio::sync::mpsc::channel(1);
356        sender.try_send(WorkerMessage::DrainRequest)?;
357        let worker = WorkerId::from_value(5);
358        let target = GrpcLivenessTarget {
359            worker_id: worker,
360            sender,
361        };
362
363        let Err(failure) =
364            ping_grpc_worker(&waiters, &target, ping(1), Duration::from_millis(60)).await
365        else {
366            return Err("a channel with no free slot cannot carry a ping".into());
367        };
368        assert!(
369            matches!(failure, super::PingFailure::Unaskable(_)),
370            "a refused admission is evidence about THIS SERVER's reach: {failure:?}"
371        );
372        assert!(
373            !waiters.answer(worker, 1)?,
374            "a ping that was never sent must leave no armed waiter behind"
375        );
376        Ok(())
377    }
378}