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}