aion_server/worker/heartbeat.rs
1//! Heartbeat window tracking and lost-worker failure surfacing.
2
3use chrono::{DateTime, Utc};
4use std::collections::{HashMap, HashSet};
5use std::sync::{Arc, Mutex, MutexGuard};
6use std::time::{Duration, Instant};
7use tokio::sync::{Notify, watch};
8use tracing::{error, info, warn};
9
10use aion_core::{ActivityId, Payload, WorkflowId};
11use aion_proto::{ProtoHeartbeat, WireError};
12
13use crate::error::ServerError;
14use crate::shutdown::DrainState;
15use crate::worker::dispatch::{
16 ActivityCompletion, ActivityCompletionOutcome, ActivityCompletionSink,
17};
18use crate::worker::envelope::CompletionToken;
19use crate::worker::registry::{ConnectedWorkerRegistry, WorkerId};
20
21mod handover;
22mod sweep;
23
24use sweep::is_expired;
25
26/// In-flight activity assigned to a connected worker.
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct InFlightActivity {
29 /// Owning workflow id.
30 pub workflow_id: WorkflowId,
31 /// Correlating activity id.
32 pub activity_id: ActivityId,
33 /// One-based delivery attempt this dispatch carries — the third axis of the
34 /// `(workflow, activity, attempt)` identity the transcript, the intervention
35 /// index, and history all key on.
36 ///
37 /// It is NOT part of the tracker's key: a completion is addressed by the
38 /// worker, workflow, and activity together, and widening the key would
39 /// break that addressing. It is carried so a reader asking about a SPECIFIC
40 /// attempt — the live describe join asking whose progress note this is —
41 /// can tell a tracked entry for the attempt it asked about from a lingering
42 /// entry for a superseded one, instead of attributing one attempt's note to
43 /// another.
44 pub attempt: u32,
45 /// Generation authorized to receive a result or synthesized loss.
46 pub completion_token: CompletionToken,
47}
48
49/// Observable liveness state for a single in-flight activity.
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct TaskLiveness {
52 /// Worker currently responsible for the task.
53 pub worker_id: WorkerId,
54 /// Owning workflow id.
55 pub workflow_id: WorkflowId,
56 /// Correlating activity id.
57 pub activity_id: ActivityId,
58 /// One-based delivery attempt this tracked dispatch carries (see
59 /// [`InFlightActivity::attempt`]).
60 pub attempt: u32,
61 /// Generation authorized to receive a result or synthesized loss.
62 pub completion_token: CompletionToken,
63 /// Operator-configured heartbeat window used for expiry checks.
64 pub heartbeat_window: Duration,
65 /// Monotonic timestamp of assignment or the most recent heartbeat.
66 pub last_heartbeat_at: Instant,
67 /// Optional worker progress from the most recent heartbeat.
68 pub last_progress: Option<Payload>,
69 /// Wall-clock instant this process received the heartbeat that carried
70 /// [`Self::last_progress`]. `None` while no progress has been reported.
71 ///
72 /// A wall clock rather than the tracked [`Instant`] because this one is
73 /// REPORTED to operators, and a monotonic instant means nothing outside the
74 /// process that minted it. Server-side observability stamping only — the
75 /// determinism boundary governs workflow-visible time, and nothing here is
76 /// workflow-visible.
77 pub last_progress_at: Option<DateTime<Utc>>,
78}
79
80/// Result of accepting a heartbeat for an in-flight task.
81#[derive(Clone, Debug, Eq, PartialEq)]
82pub struct HeartbeatUpdate {
83 /// Updated liveness after recording the heartbeat.
84 pub liveness: TaskLiveness,
85}
86
87/// Tasks removed from tracking because a worker was declared lost.
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct LostWorkerReport {
90 /// Lost worker removed from the connected-worker registry.
91 pub worker_id: WorkerId,
92 /// In-flight activities swept off the tracker: surfaced to the engine as
93 /// retryable failures on the `fail_*` paths, or parked for restart
94 /// recovery (nothing recorded, nothing delivered) on the graceful-drain
95 /// `park_*` paths (#207).
96 pub tasks: Vec<InFlightActivity>,
97 /// Task queue the lost worker was serving, captured from the registry
98 /// BEFORE deregistration (afterwards the handle is gone and the queue is
99 /// unknowable). `None` when the worker was already absent from the registry.
100 ///
101 /// This is what lets the deregistration log name the queue an operator has
102 /// to act on, rather than an opaque worker id.
103 pub task_queue: Option<String>,
104}
105
106#[derive(Clone, Debug, Eq, Hash, PartialEq)]
107struct TaskKey(WorkerId, WorkflowId, ActivityId);
108
109#[derive(Debug, Default)]
110struct HeartbeatState {
111 tasks: HashMap<TaskKey, TaskLiveness>,
112 /// Last frame observed on each live worker connection.
113 ///
114 /// This is the PROCESS-IS-ALIVE fact, and only that. It is advanced by
115 /// anything the worker sends — including the worker-side liveness pump,
116 /// which beats from a background task regardless of what its serve loop is
117 /// doing. A fresh entry here means "that process is running and its
118 /// worker-to-server direction works". It does NOT mean the server can
119 /// reach it.
120 connections: HashMap<WorkerId, Instant>,
121 /// Last time the server PROVED it can reach this worker's dispatch path,
122 /// i.e. the last answered liveness ping.
123 ///
124 /// This is the SERVER-CAN-REACH-THE-WORKER fact, and it is the only one
125 /// that is a dispatch precondition. It is advanced ONLY by an answered
126 /// ping, never by an inbound frame, because only the ping rides the same
127 /// server-to-worker leg a dispatch does.
128 ///
129 /// The two facts are separate because collapsing them hid a total outage:
130 /// on run `dfd2117c` the server could not push to a worker for fifteen
131 /// minutes while the worker's pump kept the single old lease perfectly
132 /// fresh, so the dead-man switch could not fire for the one failure it
133 /// exists to detect. `liminal_transport`'s own doc already forbids this —
134 /// the ping proves "the exact path a dispatch would take, not a parallel
135 /// one that could be healthy while the real one is not" — and the pump
136 /// feeding the same lease was exactly that parallel channel.
137 reachability: HashMap<WorkerId, Reachability>,
138}
139
140/// How many CONSECUTIVE answered pings re-admit a worker to dispatch.
141///
142/// A connection is a channel to prove reachability ON, never proof of it: the
143/// registration handshake's ack is SENT by the server, and a sent ack is not a
144/// received one — exactly the inference this whole lane exists to stop making.
145/// So eligibility is earned by measurement, on every connection including the
146/// first, and a redial re-seeds the measurement OPPORTUNITY rather than the
147/// verdict.
148///
149/// Why two and not one: one success re-admits a link that answered once by luck
150/// — a race, a buffer that happened to drain — so a link answering one probe in
151/// three would flap in and out of eligibility indefinitely, which is the defect
152/// this constant exists to remove rather than slow down. Two consecutive
153/// successes is the smallest number that distinguishes "answered" from
154/// "answering".
155///
156/// Why not three or more: the cost is paid on EVERY connect, in probe cadences.
157/// At the probe's cadence a fresh worker is undispatchable for `K` cadences
158/// while its first dispatches park, and that latency is charged to every honest
159/// worker to catch a dishonest one. Two buys the discrimination; three buys
160/// only delay.
161pub(crate) const DISPATCH_PROBATION_PINGS: u32 = 2;
162
163/// A worker's dispatch-path standing: how many consecutive pings it has
164/// answered, and when the most recent one landed.
165///
166/// `proved_at` is `None` until the probation is served, so a worker on
167/// probation is not merely stale — it has no proof at all, which is the honest
168/// description of a connection nothing has been measured on yet.
169#[derive(Clone, Copy, Debug, Default)]
170struct Reachability {
171 consecutive_answers: u32,
172 proved_at: Option<Instant>,
173 /// Whether this worker has EVER held dispatch eligibility on this
174 /// connection. Not a duplicate of the two fields above: they describe the
175 /// current standing, this describes the connection's history, and only the
176 /// history separates a worker still serving its opening probation from one
177 /// that earned eligibility and then lost it.
178 ///
179 /// Deliberately NOT cleared by [`HeartbeatTracker::record_dispatch_unreachable`]
180 /// — a failed ping ends the current proof, it does not un-happen the proof
181 /// that came before it. Cleared only by
182 /// [`HeartbeatTracker::register_connection`], because a new connection is a
183 /// new measurement and nothing earned on the old one carries across.
184 ever_proved: bool,
185}
186
187impl Reachability {
188 /// Whether the probation is served and the proof is still inside `window`.
189 fn is_proved(self, now: Instant, window: Duration) -> bool {
190 self.consecutive_answers >= DISPATCH_PROBATION_PINGS
191 && self.proved_at.is_some_and(|proved_at| {
192 now.checked_duration_since(proved_at)
193 .is_none_or(|elapsed| elapsed <= window)
194 })
195 }
196
197 /// Why this standing does not currently permit dispatch.
198 ///
199 /// Only meaningful when [`Self::is_proved`] is false; the caller pairs them
200 /// so the classification and the membership test can never disagree about
201 /// which workers are excluded.
202 fn exclusion(self) -> DispatchExclusion {
203 if self.ever_proved {
204 DispatchExclusion::ReachabilityLost
205 } else {
206 DispatchExclusion::OpeningProbation {
207 answers: self.consecutive_answers,
208 }
209 }
210 }
211}
212
213/// Why a worker is currently excluded from dispatch selection.
214///
215/// These are DIFFERENT FACTS and an operator must be able to tell them apart —
216/// the same standard this module already holds the two ping failures to. One is
217/// the ordinary cost of connecting; the other is an incident. Reported as one
218/// value alongside the exclusion itself so nothing has to re-derive the reason
219/// from a second reading of the same state.
220#[derive(Clone, Copy, Debug, PartialEq, Eq)]
221pub enum DispatchExclusion {
222 /// The worker registered and has not yet answered
223 /// [`DISPATCH_PROBATION_PINGS`] consecutive pings, so eligibility has never
224 /// been earned on this connection. Expected on EVERY connect, including a
225 /// perfectly healthy one — this is the probation being served, not a fault.
226 OpeningProbation {
227 /// Consecutive answers banked so far, out of [`DISPATCH_PROBATION_PINGS`].
228 answers: u32,
229 },
230 /// The worker held dispatch eligibility on this connection and no longer
231 /// does: either a ping failed and restarted its probation, or the last
232 /// proof aged out of the heartbeat window. This one is an incident.
233 ReachabilityLost,
234}
235
236/// One worker excluded from dispatch, with the reason it is excluded.
237#[derive(Clone, Copy, Debug, PartialEq, Eq)]
238pub struct ExcludedWorker {
239 /// The worker selection must skip.
240 pub worker_id: WorkerId,
241 /// Why it is being skipped.
242 pub exclusion: DispatchExclusion,
243}
244
245/// Per-task liveness tracker for remote-worker streams.
246///
247/// It is also the server's ONLY store of worker progress notes, and that store
248/// is VOLATILE: notes live here and nowhere else, so a restart loses every note
249/// ever reported. [`Self::notes_held_since`] is the evidence a reader needs to
250/// tell "this worker has said nothing" from "this process was not running when
251/// the attempt began" — see `crate::worker::attempt_progress`.
252#[derive(Clone, Debug)]
253pub struct HeartbeatTracker {
254 heartbeat_window: Duration,
255 inner: Arc<Mutex<HeartbeatState>>,
256 empty: Arc<Notify>,
257 notes_held_since: DateTime<Utc>,
258}
259
260impl HeartbeatTracker {
261 /// Build a tracker using the operator-supplied heartbeat window.
262 #[must_use]
263 pub fn new(heartbeat_window: Duration) -> Self {
264 Self {
265 heartbeat_window,
266 inner: Arc::new(Mutex::new(HeartbeatState::default())),
267 empty: Arc::new(Notify::new()),
268 // The instant this volatile note store began holding notes. Read
269 // here rather than injected because it is exactly the tracker's own
270 // construction instant — there is no other value it could be, and a
271 // caller passing a different one would be reporting a fiction.
272 notes_held_since: Utc::now(),
273 }
274 }
275
276 /// When this tracker began holding progress notes.
277 ///
278 /// An attempt dispatched before this instant left its notes in a process
279 /// that no longer exists, so their absence here says nothing about what the
280 /// worker reported.
281 #[must_use]
282 pub const fn notes_held_since(&self) -> DateTime<Utc> {
283 self.notes_held_since
284 }
285
286 /// Start the connection-level lease for a newly registered worker, and open
287 /// its dispatch probation.
288 ///
289 /// The connection lease starts fresh — the worker's process is plainly
290 /// alive, it just registered. Dispatch reachability does NOT: a new
291 /// connection is a channel to prove reachability on, not proof of it.
292 ///
293 /// This deliberately reverses an earlier reading of mine, that the
294 /// registration handshake is "itself a completed server-to-worker round
295 /// trip". The ack is SENT by the server; nothing reports that it was
296 /// RECEIVED. Treating a send as a delivery is the same inference this lane
297 /// exists to stop making, and left unfixed it meant a worker the server
298 /// could never reach would re-seed itself on every redial and cycle in and
299 /// out of eligibility forever instead of settling out.
300 ///
301 /// See [`DISPATCH_PROBATION_PINGS`].
302 ///
303 /// # Errors
304 ///
305 /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
306 pub fn register_connection(
307 &self,
308 worker_id: WorkerId,
309 now: Instant,
310 ) -> Result<(), ServerError> {
311 let mut state = self.state()?;
312 state.connections.insert(worker_id, now);
313 // A fresh, UNSERVED probation: zero answers, no proof. Inserted rather
314 // than left absent so the worker is carried by `unreachable_workers`
315 // and is therefore explicitly excluded, not merely unknown.
316 state
317 .reachability
318 .insert(worker_id, Reachability::default());
319 Ok(())
320 }
321
322 /// Advance a worker's connection lease after receiving any frame.
323 ///
324 /// Records ONLY that the worker's process is alive. It deliberately does
325 /// NOT advance dispatch reachability: an inbound frame — a heartbeat, a
326 /// pump beat, a completion — proves the worker-to-server direction and
327 /// says nothing about whether the server can push to it. Use
328 /// [`Self::record_dispatch_reachability`] for the fact that gates dispatch.
329 ///
330 /// Returns `false` if the worker has already been removed from lease tracking;
331 /// a frame racing deregistration must not resurrect it.
332 ///
333 /// # Errors
334 ///
335 /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
336 pub fn record_connection_activity(
337 &self,
338 worker_id: WorkerId,
339 now: Instant,
340 ) -> Result<bool, ServerError> {
341 let mut state = self.state()?;
342 let Some(last_activity) = state.connections.get_mut(&worker_id) else {
343 return Ok(false);
344 };
345 *last_activity = now;
346 Ok(true)
347 }
348
349 /// Record proof that the server can reach this worker's dispatch path — an
350 /// ANSWERED liveness ping, and nothing else.
351 ///
352 /// Advances both facts, because an answered ping proves both: the worker
353 /// received a server push (reachability) and replied to it (alive). It also
354 /// serves one ping of the dispatch probation; eligibility returns once
355 /// [`DISPATCH_PROBATION_PINGS`] consecutive answers have landed.
356 ///
357 /// Returns `false` if the worker has already been removed from lease
358 /// tracking; a pong racing a reap must not resurrect it.
359 ///
360 /// # Errors
361 ///
362 /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
363 pub fn record_dispatch_reachability(
364 &self,
365 worker_id: WorkerId,
366 now: Instant,
367 ) -> Result<bool, ServerError> {
368 let mut state = self.state()?;
369 let Some(last_activity) = state.connections.get_mut(&worker_id) else {
370 return Ok(false);
371 };
372 *last_activity = now;
373 let standing = state.reachability.entry(worker_id).or_default();
374 standing.consecutive_answers = standing.consecutive_answers.saturating_add(1);
375 standing.proved_at = Some(now);
376 if standing.consecutive_answers >= DISPATCH_PROBATION_PINGS {
377 // The probation is served. Recording it here — at the one place a
378 // probation can complete — is what lets a later exclusion say
379 // whether eligibility was ever held, without a second copy of the
380 // threshold anywhere else.
381 standing.ever_proved = true;
382 }
383 Ok(true)
384 }
385
386 /// Record that a liveness ping went UNANSWERED: the probation restarts.
387 ///
388 /// This is what makes the probation consecutive rather than cumulative. A
389 /// link answering one probe in three would otherwise accumulate its way to
390 /// eligibility and keep it, which is the flapping this design removes.
391 ///
392 /// Returns `false` if the worker has already been removed from lease
393 /// tracking.
394 ///
395 /// # Errors
396 ///
397 /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
398 pub fn record_dispatch_unreachable(&self, worker_id: WorkerId) -> Result<bool, ServerError> {
399 let mut state = self.state()?;
400 if !state.connections.contains_key(&worker_id) {
401 return Ok(false);
402 }
403 let standing = state.reachability.entry(worker_id).or_default();
404 standing.consecutive_answers = 0;
405 standing.proved_at = None;
406 // `ever_proved` deliberately survives: this connection DID earn
407 // eligibility once, and that is what makes the loss an incident rather
408 // than the ordinary cost of connecting.
409 Ok(true)
410 }
411
412 /// Whether the server has proved, within the heartbeat window, that it can
413 /// reach this worker's dispatch path — probation served AND the proof still
414 /// fresh.
415 ///
416 /// An untracked worker is not reachable: absence of proof is not proof.
417 ///
418 /// # Errors
419 ///
420 /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
421 pub fn is_dispatch_reachable(
422 &self,
423 worker_id: WorkerId,
424 now: Instant,
425 ) -> Result<bool, ServerError> {
426 let state = self.state()?;
427 Ok(state
428 .reachability
429 .get(&worker_id)
430 .is_some_and(|standing| standing.is_proved(now, self.heartbeat_window)))
431 }
432
433 /// Every tracked worker the server has NOT been able to reach within the
434 /// heartbeat window, regardless of how alive its process looks, each paired
435 /// with WHY it is excluded.
436 ///
437 /// The reason travels with the membership rather than being recomputed by
438 /// the caller, so the set that gates dispatch and the reason an operator is
439 /// told can never describe different states.
440 ///
441 /// # Errors
442 ///
443 /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
444 pub fn unreachable_workers(&self, now: Instant) -> Result<Vec<ExcludedWorker>, ServerError> {
445 let state = self.state()?;
446 let mut workers = state
447 .reachability
448 .iter()
449 .filter(|(_, standing)| !standing.is_proved(now, self.heartbeat_window))
450 .map(|(worker_id, standing)| ExcludedWorker {
451 worker_id: *worker_id,
452 exclusion: standing.exclusion(),
453 })
454 .collect::<Vec<_>>();
455 workers.sort_unstable_by_key(|excluded| excluded.worker_id);
456 Ok(workers)
457 }
458
459 /// End connection-lease tracking when a transport closes normally.
460 ///
461 /// # Errors
462 ///
463 /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
464 pub fn unregister_connection(&self, worker_id: WorkerId) -> Result<(), ServerError> {
465 let mut state = self.state()?;
466 state.connections.remove(&worker_id);
467 state.reachability.remove(&worker_id);
468 Ok(())
469 }
470
471 /// Track a newly accepted in-flight activity for heartbeat expiry.
472 ///
473 /// # The reservation, and why it is taken by value
474 ///
475 /// `reservation` is the capacity slot the caller claimed at selection.
476 /// Passing it here is how the slot is HANDED OVER rather than re-counted:
477 /// this call commits it and skips its own increment, so the worker's
478 /// `in_flight` goes 1 → 1 instead of 1 → 2 → 1.
479 ///
480 /// That intermediate value was not harmless. The tracker incremented under
481 /// one registry lock and the reservation released under another, and a
482 /// concurrent leg landing between them was refused a slot the worker
483 /// demonstrably had — measured at `in_flight = 5` on a worker advertising 4,
484 /// on a fan sized exactly to its pool.
485 ///
486 /// Taken BY VALUE so the transfer cannot be half-done: this call either
487 /// commits the reservation or drops it, and a dropped one still releases.
488 /// `None` is for callers that never reserved — the in-process façades, and
489 /// any path tracking work it did not select.
490 ///
491 /// # Errors
492 ///
493 /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
494 pub fn track_task(
495 &self,
496 worker_id: WorkerId,
497 task: InFlightActivity,
498 now: Instant,
499 registry: &ConnectedWorkerRegistry,
500 reservation: Option<crate::worker::registry::DispatchReservation>,
501 ) -> Result<(), ServerError> {
502 let key = TaskKey::new(
503 worker_id,
504 task.workflow_id.clone(),
505 task.activity_id.clone(),
506 );
507 let liveness = TaskLiveness {
508 worker_id,
509 workflow_id: task.workflow_id,
510 activity_id: task.activity_id,
511 attempt: task.attempt,
512 completion_token: task.completion_token,
513 heartbeat_window: self.heartbeat_window,
514 last_heartbeat_at: now,
515 last_progress: None,
516 last_progress_at: None,
517 };
518 // A DISPATCH IS NOT EVIDENCE THE WORKER IS ALIVE, so this no longer
519 // refreshes `state.connections`.
520 //
521 // It used to. That made the server's own act of pushing work advance the
522 // clock it later reads to decide whether the worker is still there: a
523 // worker that had stopped answering could be kept out of the expiry
524 // sweep indefinitely by nothing more than the server continuing to
525 // dispatch to it. Liveness is proven by what the WORKER does — a
526 // heartbeat (`record_heartbeat`) or a liveness answer
527 // (`record_liveness`) — and by nothing the server does to it.
528 let first_tracking = {
529 let mut state = self.state()?;
530 state.tasks.insert(key, liveness).is_none()
531 };
532 // GATED ON THE INSERT, because the two writes have different
533 // idempotencies. `tasks` is keyed by `(worker, workflow, activity)` and
534 // an insert over a live key OVERWRITES — one entry before, one entry
535 // after — while the registry count increments unconditionally. Tracking
536 // the same key twice without an intervening `complete_task` therefore
537 // used to leak a slot permanently: the count went up twice and could
538 // only ever come down once, so the worker's usable capacity shrank until
539 // it deregistered.
540 //
541 // `registry` is an argument precisely so the liveness record and the
542 // number selection reads are written by ONE call and cannot be updated
543 // apart. Gating keeps that property and makes them agree on how many
544 // times one dispatch counts.
545 handover::settle_reserved_slot(first_tracking, worker_id, registry, reservation)
546 }
547
548 /// Stop tracking a completed activity and wake drain waiters if this was the last task.
549 ///
550 /// Returns whether the task was still tracked when this ran: `true` means
551 /// THIS call retired the in-flight entry, `false` means another path (the
552 /// expiry sweep, a disconnect teardown, shutdown, or a completed dispatch)
553 /// already did. The liminal reply router uses that bool as its structural
554 /// gate for synthesizing a lost-worker failure — the exact mirror of the
555 /// gRPC sweep failing only still-tracked tasks.
556 ///
557 /// # Errors
558 ///
559 /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
560 pub fn complete_task(
561 &self,
562 worker_id: WorkerId,
563 workflow_id: &WorkflowId,
564 activity_id: &ActivityId,
565 registry: &ConnectedWorkerRegistry,
566 ) -> Result<bool, ServerError> {
567 let key = TaskKey::new(worker_id, workflow_id.clone(), activity_id.clone());
568 let (was_tracked, became_empty) = {
569 let mut state = self.state()?;
570 let was_tracked = state.tasks.remove(&key).is_some();
571 (was_tracked, state.tasks.is_empty())
572 };
573 if was_tracked {
574 // Only THIS call's removal frees a slot. A second caller finding
575 // the entry already gone must not decrement again, or a worker
576 // would be credited with capacity it does not have. The liminal
577 // delivery guard (`AbandonedDispatchGuard`) leans on exactly this
578 // when it fires beside the caller's own untrack. The registry
579 // side also wakes every dispatch parked on a full pool — the freed
580 // slot is the only event such a dispatch is waiting for.
581 registry.record_dispatch_finished(worker_id)?;
582 }
583 if became_empty {
584 self.empty.notify_waiters();
585 }
586 Ok(was_tracked)
587 }
588
589 /// Whether the given in-flight task is still tracked (not yet completed,
590 /// swept, or drained). The liminal reply router polls this to bound its
591 /// wait: once the entry is gone the dispatch was resolved by another path,
592 /// so the router exits instead of parking on the connection forever.
593 ///
594 /// # Errors
595 ///
596 /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
597 pub fn is_tracked(
598 &self,
599 worker_id: WorkerId,
600 workflow_id: &WorkflowId,
601 activity_id: &ActivityId,
602 ) -> Result<bool, ServerError> {
603 let key = TaskKey::new(worker_id, workflow_id.clone(), activity_id.clone());
604 Ok(self.state()?.tasks.contains_key(&key))
605 }
606
607 /// Refresh the liveness stamp of an in-flight task from a transport-level
608 /// liveness beat that carries no progress payload (the liminal worker's
609 /// automatic pump). Returns `true` when the task was tracked and refreshed,
610 /// `false` when it is not in flight — a benign outcome for a beat racing a
611 /// completion or covering an outbox dispatch the tracker never held.
612 ///
613 /// # Errors
614 ///
615 /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
616 pub fn record_liveness(
617 &self,
618 worker_id: WorkerId,
619 workflow_id: &WorkflowId,
620 activity_id: &ActivityId,
621 now: Instant,
622 ) -> Result<bool, ServerError> {
623 let key = TaskKey::new(worker_id, workflow_id.clone(), activity_id.clone());
624 let mut state = self.state()?;
625 if !state.tasks.contains_key(&key) {
626 return Ok(false);
627 }
628 if let Some(last_activity) = state.connections.get_mut(&worker_id) {
629 *last_activity = now;
630 }
631 let Some(liveness) = state.tasks.get_mut(&key) else {
632 return Ok(false);
633 };
634 liveness.last_heartbeat_at = now;
635 Ok(true)
636 }
637
638 /// The operator-configured heartbeat window this tracker expires against.
639 /// The bridge stamps it onto each liminal dispatch so the worker's
640 /// automatic liveness pump beats at the matching quarter-window cadence.
641 #[must_use]
642 pub const fn heartbeat_window(&self) -> Duration {
643 self.heartbeat_window
644 }
645
646 /// Number of currently tracked in-flight activities.
647 ///
648 /// # Errors
649 ///
650 /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
651 pub fn in_flight_count(&self) -> Result<usize, ServerError> {
652 Ok(self.state()?.tasks.len())
653 }
654
655 /// Number of in-flight activities tracked for ONE worker.
656 ///
657 /// Beside [`Self::in_flight_count`] rather than replacing it: that answer is
658 /// deliberately global (the drain gate asks "is anything still running"),
659 /// and widening it to mean per-worker would make every existing caller read
660 /// a different question than the one it asked.
661 ///
662 /// This is the tracker's own count, derived from the task map it owns. The
663 /// registry keeps a projection of the same fact for selection to read
664 /// without taking this lock, and the two are written by one call
665 /// ([`Self::track_task`] / [`Self::complete_task`]). This accessor is what
666 /// lets a test hold them against each other instead of trusting that.
667 ///
668 /// # Errors
669 ///
670 /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
671 pub fn in_flight_for_worker(&self, worker_id: WorkerId) -> Result<usize, ServerError> {
672 Ok(self
673 .state()?
674 .tasks
675 .keys()
676 .filter(|key| key.worker_id() == worker_id)
677 .count())
678 }
679
680 /// Record a worker heartbeat without completing the activity.
681 ///
682 /// Every heartbeat refreshes the task's liveness stamp. The progress
683 /// payload is only overwritten when the heartbeat CARRIES one: the worker
684 /// runtime's automatic liveness beats are payload-free and interleave
685 /// with explicit handler progress heartbeats, and a liveness beat must
686 /// never erase the handler's most recent progress report.
687 ///
688 /// # Errors
689 ///
690 /// Returns a stable wire error for malformed heartbeats or unknown in-flight tasks.
691 pub fn record_heartbeat(
692 &self,
693 worker_id: WorkerId,
694 heartbeat: ProtoHeartbeat,
695 now: Instant,
696 ) -> Result<HeartbeatUpdate, ServerError> {
697 let decoded = DecodedHeartbeat::try_from(heartbeat)?;
698 let key = TaskKey::new(worker_id, decoded.workflow_id, decoded.activity_id);
699 let mut state = self.state()?;
700 if !state.tasks.contains_key(&key) {
701 return Err(wire_error("heartbeat task is not in flight"));
702 }
703 if let Some(last_activity) = state.connections.get_mut(&worker_id) {
704 *last_activity = now;
705 }
706 let Some(liveness) = state.tasks.get_mut(&key) else {
707 return Err(wire_error("heartbeat task is not in flight"));
708 };
709 liveness.last_heartbeat_at = now;
710 if decoded.progress.is_some() {
711 liveness.last_progress = decoded.progress;
712 liveness.last_progress_at = Some(Utc::now());
713 }
714 Ok(HeartbeatUpdate {
715 liveness: liveness.clone(),
716 })
717 }
718
719 /// Return whether an in-flight task is still within its configured heartbeat window.
720 ///
721 /// # Errors
722 ///
723 /// Returns a stable wire error if the task is not tracked, or lock poison if state cannot be trusted.
724 pub fn is_live(
725 &self,
726 worker_id: WorkerId,
727 workflow_id: &WorkflowId,
728 activity_id: &ActivityId,
729 now: Instant,
730 ) -> Result<bool, ServerError> {
731 let key = TaskKey::new(worker_id, workflow_id.clone(), activity_id.clone());
732 let state = self.state()?;
733 let Some(liveness) = state.tasks.get(&key) else {
734 return Err(wire_error("heartbeat task is not in flight"));
735 };
736 Ok(!is_expired(liveness, now))
737 }
738
739 /// Mark all currently expired workers lost and fail their in-flight tasks through the engine sink.
740 ///
741 /// # Errors
742 ///
743 /// Returns registry, tracker, or sink errors without retrying or rescheduling activities.
744 pub fn fail_expired_workers(
745 &self,
746 registry: &ConnectedWorkerRegistry,
747 sink: &impl ActivityCompletionSink,
748 now: Instant,
749 ) -> Result<Vec<LostWorkerReport>, ServerError> {
750 let mut reports = Vec::new();
751 for worker_id in self.expired_workers(registry, now)? {
752 let report = self.fail_lost_worker(worker_id, registry, sink)?;
753 reports.push(report);
754 }
755 Ok(reports)
756 }
757
758 /// Mark a disconnected worker lost and fail its in-flight tasks through the engine sink.
759 ///
760 /// # Errors
761 ///
762 /// Returns registry, tracker, or sink errors without retrying or rescheduling activities.
763 pub fn fail_disconnected_worker(
764 &self,
765 worker_id: WorkerId,
766 registry: &ConnectedWorkerRegistry,
767 sink: &impl ActivityCompletionSink,
768 ) -> Result<LostWorkerReport, ServerError> {
769 self.fail_lost_worker(worker_id, registry, sink)
770 }
771
772 /// Mark every currently in-flight worker lost and fail all remaining tasks through the sink.
773 ///
774 /// # Errors
775 ///
776 /// Returns registry, tracker, or sink errors without retrying or rescheduling activities.
777 pub fn fail_all_in_flight_workers(
778 &self,
779 registry: &ConnectedWorkerRegistry,
780 sink: &impl ActivityCompletionSink,
781 ) -> Result<Vec<LostWorkerReport>, ServerError> {
782 let worker_ids = {
783 let state = self.state()?;
784 let mut worker_ids = state
785 .tasks
786 .values()
787 .map(|liveness| liveness.worker_id)
788 .collect::<HashSet<_>>()
789 .into_iter()
790 .collect::<Vec<_>>();
791 worker_ids.sort_unstable();
792 worker_ids
793 };
794 let mut reports = Vec::new();
795 for worker_id in worker_ids {
796 let report = self.fail_lost_worker(worker_id, registry, sink)?;
797 if !report.tasks.is_empty() {
798 reports.push(report);
799 }
800 }
801 self.empty.notify_waiters();
802 Ok(reports)
803 }
804
805 /// Park a drain-disconnected worker's in-flight tasks for restart recovery
806 /// (#207): deregister the worker, remove its tracked tasks, and resolve
807 /// each pending waiter through [`ActivityCompletionSink::park_activity`].
808 ///
809 /// The graceful-drain counterpart of [`Self::fail_disconnected_worker`]:
810 /// same deregister-before-collect ordering (same closed dispatch/disconnect
811 /// race), but NO completion is synthesized — the durable log keeps its
812 /// dangling scheduled/started trail, byte-equivalent to a kill -9, and
813 /// restart recovery re-dispatches it. Deregistered with the honest
814 /// [`WorkerDeathReason::Disconnect`](aion_core::WorkerDeathReason::Disconnect):
815 /// the transport genuinely dropped (the worker obeyed the drain request).
816 ///
817 /// # Errors
818 ///
819 /// Returns registry, tracker, or sink errors without retrying or rescheduling activities.
820 pub fn park_disconnected_worker(
821 &self,
822 worker_id: WorkerId,
823 registry: &ConnectedWorkerRegistry,
824 sink: &impl ActivityCompletionSink,
825 ) -> Result<LostWorkerReport, ServerError> {
826 self.park_lost_worker(
827 worker_id,
828 registry,
829 sink,
830 aion_core::WorkerDeathReason::Disconnect,
831 )
832 }
833
834 /// Park EVERY currently in-flight worker's tasks for restart recovery
835 /// (#207) — the drain-timeout backstop's bulk counterpart of
836 /// [`Self::fail_all_in_flight_workers`].
837 ///
838 /// Deregistered with the honest
839 /// [`WorkerDeathReason::Timeout`](aion_core::WorkerDeathReason::Timeout):
840 /// the drain window genuinely expired on these workers. Wakes drain waiters
841 /// after the sweep so `wait_for_empty` observes the emptied tracker.
842 ///
843 /// # Errors
844 ///
845 /// Returns registry, tracker, or sink errors without retrying or rescheduling activities.
846 pub fn park_all_in_flight_workers(
847 &self,
848 registry: &ConnectedWorkerRegistry,
849 sink: &impl ActivityCompletionSink,
850 ) -> Result<Vec<LostWorkerReport>, ServerError> {
851 let worker_ids = {
852 let state = self.state()?;
853 let mut worker_ids = state
854 .tasks
855 .values()
856 .map(|liveness| liveness.worker_id)
857 .collect::<HashSet<_>>()
858 .into_iter()
859 .collect::<Vec<_>>();
860 worker_ids.sort_unstable();
861 worker_ids
862 };
863 let mut reports = Vec::new();
864 for worker_id in worker_ids {
865 let report = self.park_lost_worker(
866 worker_id,
867 registry,
868 sink,
869 aion_core::WorkerDeathReason::Timeout,
870 )?;
871 if !report.tasks.is_empty() {
872 reports.push(report);
873 }
874 }
875 self.empty.notify_waiters();
876 Ok(reports)
877 }
878
879 /// Shared park core (#207), structured exactly like [`Self::fail_lost_worker`]
880 /// — deregister BEFORE collecting tasks (see that method's race note) — but
881 /// resolving each waiter with the ephemeral parked sentinel instead of
882 /// synthesizing a lost-worker `ActivityFailed`. Idempotent for the same
883 /// reasons: `deregister_with_reason` no-ops on an already-removed worker and
884 /// each task is removed as it parks, so a second sweep (park or fail) sees
885 /// an empty report and resolves nothing.
886 fn park_lost_worker(
887 &self,
888 worker_id: WorkerId,
889 registry: &ConnectedWorkerRegistry,
890 sink: &impl ActivityCompletionSink,
891 reason: aion_core::WorkerDeathReason,
892 ) -> Result<LostWorkerReport, ServerError> {
893 let task_queue = task_queue_of(registry, worker_id);
894 registry.deregister_with_reason(worker_id, reason)?;
895 self.state()?.connections.remove(&worker_id);
896 let tasks = self.remove_worker_tasks(worker_id)?;
897 for task in &tasks {
898 sink.park_activity(&task.workflow_id, &task.activity_id)?;
899 info!(
900 worker_id = ?worker_id,
901 workflow_id = %task.workflow_id,
902 activity_id = %task.activity_id,
903 "activity parked for restart recovery"
904 );
905 }
906 Ok(LostWorkerReport {
907 worker_id,
908 tasks,
909 task_queue,
910 })
911 }
912
913 fn fail_lost_worker(
914 &self,
915 worker_id: WorkerId,
916 registry: &ConnectedWorkerRegistry,
917 sink: &impl ActivityCompletionSink,
918 ) -> Result<LostWorkerReport, ServerError> {
919 // Deregister BEFORE collecting tasks: the dispatch path tracks its
920 // task, sends, and then checks `registry.is_registered`. With this
921 // ordering, a dispatch that still sees the worker registered is
922 // guaranteed its tracked task is visible to any later sweep, so the
923 // unbounded completion wait always gets a lost-worker failure. (The
924 // reverse order leaves a window where a task tracked between the
925 // collection and the deregistration is never failed by anyone.)
926 // This is the liveness-timeout sweep: the proven reason is Timeout, the
927 // one finer-grained WS3 distinction this call site can honestly assert.
928 // The queue is read BEFORE the deregistration below, because afterwards
929 // the handle is gone and the log could no longer name it.
930 let task_queue = task_queue_of(registry, worker_id);
931 registry.deregister_with_reason(worker_id, aion_core::WorkerDeathReason::Timeout)?;
932 self.state()?.connections.remove(&worker_id);
933 let tasks = self.remove_worker_tasks(worker_id)?;
934 for task in &tasks {
935 sink.complete_activity(ActivityCompletion {
936 workflow_id: task.workflow_id.clone(),
937 activity_id: task.activity_id.clone(),
938 run_id: None,
939 completion_token: task.completion_token.clone(),
940 // A TRANSPORT-domain loss, not an activity failure: the
941 // activity never executed to a result. The sink classifies it
942 // (and applies the transport's own re-dispatch budget); this
943 // sweep only reports what it observed.
944 outcome: ActivityCompletionOutcome::WorkerLost { worker_id },
945 })?;
946 }
947 Ok(LostWorkerReport {
948 worker_id,
949 tasks,
950 task_queue,
951 })
952 }
953
954 fn remove_worker_tasks(
955 &self,
956 worker_id: WorkerId,
957 ) -> Result<Vec<InFlightActivity>, ServerError> {
958 let mut state = self.state()?;
959 let keys = state
960 .tasks
961 .keys()
962 .filter(|key| key.worker_id() == worker_id)
963 .cloned()
964 .collect::<Vec<_>>();
965 let mut tasks = Vec::with_capacity(keys.len());
966 for key in keys {
967 if let Some(liveness) = state.tasks.remove(&key) {
968 tasks.push(InFlightActivity {
969 workflow_id: liveness.workflow_id,
970 activity_id: liveness.activity_id,
971 attempt: liveness.attempt,
972 completion_token: liveness.completion_token,
973 });
974 }
975 }
976 Ok(tasks)
977 }
978
979 /// Every tracked in-flight entry for `(workflow, activity, attempt)`.
980 ///
981 /// Read from the SAME map the heartbeat path writes, so what a reader is
982 /// told and what the dispatch path knows cannot drift. Usually zero or one
983 /// entry; a within-attempt failover can briefly have a dying owner and its
984 /// adopter both tracked, which is why this returns them all and leaves the
985 /// choice to the caller (`crate::worker::attempt_progress`) rather than
986 /// silently picking one here.
987 ///
988 /// # Errors
989 ///
990 /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
991 pub(in crate::worker) fn attempt_entries(
992 &self,
993 workflow_id: &WorkflowId,
994 activity_id: &ActivityId,
995 attempt: u32,
996 ) -> Result<Vec<TaskLiveness>, ServerError> {
997 Ok(self
998 .state()?
999 .tasks
1000 .values()
1001 .filter(|liveness| {
1002 &liveness.workflow_id == workflow_id
1003 && &liveness.activity_id == activity_id
1004 && liveness.attempt == attempt
1005 })
1006 .cloned()
1007 .collect())
1008 }
1009
1010 /// Every activity of `workflow_id` a connected worker is holding RIGHT NOW
1011 /// (#233).
1012 ///
1013 /// A tracked entry means a live worker owns that activity: entries are
1014 /// added at dispatch ([`Self::track_task`]) and removed on completion,
1015 /// disconnect, or heartbeat expiry. That is what makes this — rather than
1016 /// the durable outbox row, which records no worker at all — the authority
1017 /// on where a cancellation has to be sent. The worker id here is present
1018 /// membership, not a recorded historical fact, so it cannot go stale the
1019 /// way a worker id written down at claim time would.
1020 ///
1021 /// A within-attempt failover can briefly show a dying owner AND its adopter
1022 /// for one activity. Both are returned: a cancel is addressed to a
1023 /// `(workflow, activity)` key that either worker may or may not still hold,
1024 /// and a worker that does not hold it ignores the message, so asking both
1025 /// is correct and asking one would be a guess.
1026 ///
1027 /// # Errors
1028 ///
1029 /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
1030 pub fn in_flight_for_workflow(
1031 &self,
1032 workflow_id: &WorkflowId,
1033 ) -> Result<Vec<TaskLiveness>, ServerError> {
1034 Ok(self
1035 .state()?
1036 .tasks
1037 .values()
1038 .filter(|liveness| &liveness.workflow_id == workflow_id)
1039 .cloned()
1040 .collect())
1041 }
1042
1043 fn state(&self) -> Result<MutexGuard<'_, HeartbeatState>, ServerError> {
1044 self.inner
1045 .lock()
1046 .map_err(|_| ServerError::lock_poisoned("worker heartbeat tracker"))
1047 }
1048
1049 /// Poisons the tracker's state lock, for tests that pin the fail-open
1050 /// contract of paths that must never let a poisoned tracker withhold a
1051 /// completion. Unwinds across a held guard inside `catch_unwind`, which is
1052 /// exactly what real poison is.
1053 #[cfg(test)]
1054 pub(crate) fn poison_for_tests(&self) {
1055 let inner = Arc::clone(&self.inner);
1056 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
1057 let _guard = inner.lock();
1058 // `resume_unwind` raises the unwind directly (no panic hook, no
1059 // panic machinery in a non-test path): the guard drops while the
1060 // thread is unwinding, which is precisely how a mutex is poisoned.
1061 std::panic::resume_unwind(Box::new(
1062 "poisoning the heartbeat tracker for a fail-open test",
1063 ));
1064 }));
1065 }
1066}
1067
1068/// Sweep cadence derived from the operator's `worker.heartbeat_window`: a
1069/// quarter of the window, clamped to `[1s, window]` (the default 30s window
1070/// sweeps every 7.5s).
1071///
1072/// Deliberately derived rather than a separate config knob: the window is the
1073/// operational contract ("a silent worker is dead after this long"), and the
1074/// sweep cadence is an implementation detail of enforcing it — a quarter-window
1075/// cadence bounds detection latency at `window + window/4` while keeping the
1076/// sweep cheap. A window shorter than one second (test configurations) sweeps
1077/// once per window rather than sub-second-spinning, and a zero window is
1078/// floored at one millisecond because `tokio::time::interval` rejects a zero
1079/// period.
1080#[must_use]
1081pub fn sweep_interval(heartbeat_window: Duration) -> Duration {
1082 /// `tokio::time::interval` panics on a zero period, so even a
1083 /// (misconfigured) zero window gets a positive cadence.
1084 const MINIMUM_PERIOD: Duration = Duration::from_millis(1);
1085 /// Target lower bound: sweeping more often than once a second buys no
1086 /// meaningful detection latency against real heartbeat windows.
1087 const TARGET_FLOOR: Duration = Duration::from_secs(1);
1088 let ceiling = heartbeat_window.max(MINIMUM_PERIOD);
1089 // The floor never exceeds the ceiling, so `clamp` cannot panic.
1090 (heartbeat_window / 4).clamp(TARGET_FLOOR.min(ceiling), ceiling)
1091}
1092
1093/// Production driver of [`HeartbeatTracker::fail_expired_workers`] (#176).
1094///
1095/// The tracker records connection and per-task liveness, while the stream-teardown
1096/// sweep fails a worker whose stream ENDS. A worker whose stream stays open while
1097/// its process wedges is caught by the connection lease even when it is idle.
1098/// This interval task expires every silent connection or task, deregistering it
1099/// with the provable
1100/// [`WorkerDeathReason::Timeout`](aion_core::WorkerDeathReason::Timeout) and
1101/// surfacing its tasks as TRANSPORT losses through the shared completion sink
1102/// — the `lost:` class the engine re-dispatches attempt-neutrally, never the
1103/// action's retry vocabulary. It shares the server's shutdown watch, so it drains with
1104/// the transports (mirroring
1105/// [`OutboxDispatcher::run`](crate::worker::OutboxDispatcher::run)).
1106///
1107/// Double-fail safety: this sweep and the stream-teardown path
1108/// ([`HeartbeatTracker::fail_disconnected_worker`]) can both observe the same
1109/// dead worker. Both funnel into the same idempotent core —
1110/// `deregister_with_reason` is a no-op for an already-removed worker (no
1111/// duplicate WS3 delta, no metrics double-count) and the tracker removes each
1112/// task as it fails it — so whichever path runs second sees an empty report and
1113/// never double-completes an activity.
1114pub struct HeartbeatSweeper<S> {
1115 tracker: HeartbeatTracker,
1116 registry: ConnectedWorkerRegistry,
1117 sink: S,
1118 drain: DrainState,
1119 heartbeat_window: Duration,
1120 interval: Duration,
1121 /// Live unserved-queue state, read only to state the CONSEQUENCE of a
1122 /// deregistration in the same log line as its cause: how many dispatches are
1123 /// already parked on the queue the reaped worker was serving. Default-empty
1124 /// in wirings that have no queue service, where the count reads zero.
1125 queue_state: crate::worker::QueueServiceState,
1126}
1127
1128impl<S> HeartbeatSweeper<S>
1129where
1130 S: ActivityCompletionSink + Send + Sync + 'static,
1131{
1132 /// Build a sweeper over the server's shared liveness tracker, worker
1133 /// registry, completion sink, and drain gate. The cadence is derived from
1134 /// `heartbeat_window` by [`sweep_interval`].
1135 #[must_use]
1136 pub fn new(
1137 tracker: HeartbeatTracker,
1138 registry: ConnectedWorkerRegistry,
1139 sink: S,
1140 drain: DrainState,
1141 heartbeat_window: Duration,
1142 ) -> Self {
1143 let interval = sweep_interval(heartbeat_window);
1144 Self {
1145 tracker,
1146 registry,
1147 sink,
1148 drain,
1149 heartbeat_window,
1150 interval,
1151 queue_state: crate::worker::QueueServiceState::default(),
1152 }
1153 }
1154
1155 /// Share the live unserved-queue state so a deregistration log can state how
1156 /// many dispatches are already parked on the queue the reaped worker served.
1157 ///
1158 /// Without it the count reads zero — honest for a wiring with no queue
1159 /// service, and never a reason to withhold the deregistration itself.
1160 #[must_use]
1161 pub fn with_queue_state(mut self, queue_state: crate::worker::QueueServiceState) -> Self {
1162 self.queue_state = queue_state;
1163 self
1164 }
1165
1166 /// Run the expiry sweep until `shutdown` flips to `true`.
1167 ///
1168 /// A tracker/registry error during a sweep is logged and retried next tick
1169 /// rather than tearing the task down — a transient failure must not
1170 /// silently stop dead-worker detection. Shutdown is observed both while
1171 /// waiting for the next tick and re-checked before each sweep, exactly like
1172 /// the outbox dispatcher's run loop.
1173 pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
1174 info!(
1175 sweep_interval_ms = self.interval.as_millis(),
1176 heartbeat_window_ms = self.heartbeat_window.as_millis(),
1177 "worker heartbeat sweeper started"
1178 );
1179 let mut ticks = tokio::time::interval(self.interval);
1180 ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1181 loop {
1182 tokio::select! {
1183 _ = ticks.tick() => {
1184 if *shutdown.borrow() {
1185 break;
1186 }
1187 self.sweep_once(Instant::now());
1188 }
1189 changed = shutdown.changed() => {
1190 // A receive error means every sender dropped; treat that as
1191 // a shutdown request rather than spinning.
1192 if changed.is_err() || *shutdown.borrow() {
1193 break;
1194 }
1195 }
1196 }
1197 }
1198 info!("worker heartbeat sweeper stopped");
1199 }
1200
1201 /// Fail every currently-expired worker once, logging each lost-worker
1202 /// report at warn (mirroring the stream-teardown sweep's logging).
1203 fn sweep_once(&self, now: Instant) {
1204 let reports = match self
1205 .tracker
1206 .fail_expired_workers(&self.registry, &self.sink, now)
1207 {
1208 Ok(reports) => reports,
1209 Err(sweep_error) => {
1210 error!(
1211 error = %sweep_error,
1212 "heartbeat expiry sweep failed; retrying next tick"
1213 );
1214 return;
1215 }
1216 };
1217 for report in &reports {
1218 let task_queue = report.task_queue.as_deref().unwrap_or("<unregistered>");
1219 // The consequence, stated with the cause: a queue whose worker just
1220 // died and which already holds parked dispatches is an alertable
1221 // condition, and the operator should not have to join two log lines
1222 // to see it. A read failure reports `None` rather than suppressing
1223 // the deregistration line.
1224 let parked = report
1225 .task_queue
1226 .as_deref()
1227 .map(|queue| self.queue_state.parked_on_queue(queue))
1228 .transpose()
1229 .unwrap_or_else(|error| {
1230 error!(%error, "could not read parked-dispatch count for a reaped worker");
1231 None
1232 })
1233 .unwrap_or(0);
1234 if report.tasks.is_empty() {
1235 warn!(
1236 worker_id = ?report.worker_id,
1237 task_queue,
1238 parked_dispatches = parked,
1239 heartbeat_window_ms = self.heartbeat_window.as_millis(),
1240 "idle worker connection lease expired; worker deregistered"
1241 );
1242 } else {
1243 warn!(
1244 worker_id = ?report.worker_id,
1245 task_queue,
1246 parked_dispatches = parked,
1247 failed_tasks = report.tasks.len(),
1248 heartbeat_window_ms = self.heartbeat_window.as_millis(),
1249 "worker heartbeat window expired with in-flight activities; \
1250 deregistered and surfaced as transport losses, to be \
1251 re-dispatched attempt-neutrally"
1252 );
1253 }
1254 }
1255 if !reports.is_empty() {
1256 // In-flight accounting may have just reached zero; wake any drain
1257 // waiter so shutdown does not sit out its full timeout (mirrors
1258 // the stream-teardown sweep).
1259 self.drain.notify_activity_drained();
1260 }
1261 }
1262}
1263
1264impl TaskKey {
1265 fn new(worker_id: WorkerId, workflow_id: WorkflowId, activity_id: ActivityId) -> Self {
1266 Self(worker_id, workflow_id, activity_id)
1267 }
1268
1269 const fn worker_id(&self) -> WorkerId {
1270 self.0
1271 }
1272}
1273
1274struct DecodedHeartbeat {
1275 workflow_id: WorkflowId,
1276 activity_id: ActivityId,
1277 progress: Option<Payload>,
1278}
1279
1280impl TryFrom<ProtoHeartbeat> for DecodedHeartbeat {
1281 type Error = ServerError;
1282
1283 fn try_from(value: ProtoHeartbeat) -> Result<Self, Self::Error> {
1284 let workflow_id = value
1285 .workflow_id
1286 .ok_or_else(|| wire_error("heartbeat workflow id is missing"))
1287 .and_then(|id| WorkflowId::try_from(id).map_err(ServerError::from))?;
1288 let activity_id = value
1289 .activity_id
1290 .ok_or_else(|| wire_error("heartbeat activity id is missing"))
1291 .map(ActivityId::from)?;
1292 let progress = value
1293 .progress
1294 .map(Payload::try_from)
1295 .transpose()
1296 .map_err(ServerError::from)?;
1297 Ok(Self {
1298 workflow_id,
1299 activity_id,
1300 progress,
1301 })
1302 }
1303}
1304
1305/// The task queue a still-registered worker serves, for the deregistration log.
1306///
1307/// A registry read failure (poisoned lock) yields `None` rather than aborting
1308/// the sweep: losing the queue NAME must never stop a dead worker being reaped.
1309fn task_queue_of(registry: &ConnectedWorkerRegistry, worker_id: WorkerId) -> Option<String> {
1310 registry
1311 .worker_by_id(worker_id)
1312 .ok()
1313 .flatten()
1314 .map(|handle| handle.task_queue().to_owned())
1315}
1316
1317fn wire_error(message: &'static str) -> ServerError {
1318 ServerError::Wire {
1319 wire: WireError::backend(message),
1320 }
1321}
1322
1323#[cfg(test)]
1324mod reachability_tests {
1325 use std::time::{Duration, Instant};
1326
1327 use super::{
1328 DISPATCH_PROBATION_PINGS, DispatchExclusion, ExcludedWorker, HeartbeatTracker, ServerError,
1329 WorkerId,
1330 };
1331
1332 const WINDOW: Duration = Duration::from_secs(30);
1333
1334 /// Every test returns `Result` and uses `?` rather than unwrapping: a lock
1335 /// fault inside the tracker is a real failure mode of the code under test,
1336 /// and it should surface as a failed test carrying the typed error, not as a
1337 /// panic message written by the test.
1338 type TestResult = Result<(), ServerError>;
1339
1340 fn tracker_with_worker(now: Instant) -> Result<(HeartbeatTracker, WorkerId), ServerError> {
1341 let tracker = HeartbeatTracker::new(WINDOW);
1342 let worker = WorkerId::from_value(1);
1343 tracker.register_connection(worker, now)?;
1344 Ok((tracker, worker))
1345 }
1346
1347 /// Answer the full probation, so the worker is genuinely eligible. Tests
1348 /// about staleness, pump beats, or withdrawal must start from a worker that
1349 /// HAS eligibility — otherwise they pass on a worker that never had any and
1350 /// prove nothing about the behaviour they name.
1351 fn serve_probation(
1352 tracker: &HeartbeatTracker,
1353 worker: WorkerId,
1354 at: Instant,
1355 ) -> Result<(), ServerError> {
1356 for _ in 0..DISPATCH_PROBATION_PINGS {
1357 assert!(
1358 tracker.record_dispatch_reachability(worker, at)?,
1359 "the worker must still be tracked while it serves its probation"
1360 );
1361 }
1362 Ok(())
1363 }
1364
1365 /// THE REGRESSION. This is the defect that made run `dfd2117c` invisible:
1366 /// the worker's liveness pump beat from a background task, refreshed the one
1367 /// shared lease, and the dead-man switch could not fire while the server had
1368 /// been unable to push to that worker for fifteen minutes.
1369 ///
1370 /// An inbound frame must prove the worker is ALIVE and must NOT prove the
1371 /// server can REACH it.
1372 #[test]
1373 fn an_inbound_frame_cannot_prove_dispatch_reachability() -> TestResult {
1374 let start = Instant::now();
1375 let (tracker, worker) = tracker_with_worker(start)?;
1376 // The worker STARTS eligible, earned honestly. Without this the test
1377 // would pass on a worker that never had eligibility to lose, which says
1378 // nothing about whether a pump beat can preserve it.
1379 serve_probation(&tracker, worker, start)?;
1380 assert!(
1381 tracker.is_dispatch_reachable(worker, start)?,
1382 "precondition: the worker is eligible before the connection goes one-way"
1383 );
1384
1385 // Well past the window, with the pump beating throughout — exactly what
1386 // a busy worker on a poisoned connection looks like.
1387 let much_later = start + WINDOW * 4;
1388 assert!(
1389 tracker.record_connection_activity(worker, much_later)?,
1390 "the worker is still tracked"
1391 );
1392
1393 assert!(
1394 !tracker.is_dispatch_reachable(worker, much_later)?,
1395 "a pump beat must NOT make a worker the server cannot push to look reachable"
1396 );
1397 assert_eq!(
1398 tracker.unreachable_workers(much_later)?,
1399 vec![ExcludedWorker {
1400 worker_id: worker,
1401 // It HELD eligibility (served above) and lost it to a stale
1402 // proof. Classifying this as an opening probation would tell an
1403 // operator a poisoned connection is an ordinary worker start.
1404 exclusion: DispatchExclusion::ReachabilityLost,
1405 }],
1406 "the worker must be named unreachable however alive its process looks"
1407 );
1408 Ok(())
1409 }
1410
1411 /// The control for the test above: without it, a tracker that reported
1412 /// EVERYTHING unreachable would satisfy that assertion and prove nothing.
1413 #[test]
1414 fn an_answered_ping_does_prove_dispatch_reachability() -> TestResult {
1415 let start = Instant::now();
1416 let (tracker, worker) = tracker_with_worker(start)?;
1417
1418 let much_later = start + WINDOW * 4;
1419 serve_probation(&tracker, worker, much_later)?;
1420
1421 assert!(
1422 tracker.is_dispatch_reachable(worker, much_later)?,
1423 "answered pings are the one thing that proves the push leg works"
1424 );
1425 assert!(
1426 tracker.unreachable_workers(much_later)?.is_empty(),
1427 "a worker answering pings is never unreachable"
1428 );
1429 Ok(())
1430 }
1431
1432 /// Registration opens a PROBATION and grants nothing. The handshake ack is
1433 /// SENT by this server; nothing reports that it was RECEIVED, so a
1434 /// connection is a channel, not proof that the channel carries. Eligibility
1435 /// is earned by answered pings only.
1436 #[test]
1437 fn registration_opens_a_probation_and_does_not_grant_eligibility() -> TestResult {
1438 let start = Instant::now();
1439 let (tracker, worker) = tracker_with_worker(start)?;
1440
1441 assert!(
1442 !tracker.is_dispatch_reachable(worker, start)?,
1443 "a brand-new connection has proved nothing about the push leg"
1444 );
1445 assert_eq!(
1446 tracker.unreachable_workers(start)?,
1447 vec![ExcludedWorker {
1448 worker_id: worker,
1449 // And it is carried as a PROBATION, not as a reachability
1450 // failure. This is the distinction that stopped an ordinary
1451 // worker start from being announced to the operator as an
1452 // unreachable dispatch path.
1453 exclusion: DispatchExclusion::OpeningProbation { answers: 0 },
1454 }],
1455 "a worker serving its probation is carried in the census as unreachable"
1456 );
1457 Ok(())
1458 }
1459
1460 /// The case that produced a FALSE ALARM on every healthy worker start.
1461 ///
1462 /// One answer banked out of two: the server has demonstrably reached this
1463 /// worker — moments ago — and is merely waiting for the second consecutive
1464 /// answer. Reporting that as a reachability failure told Tom's operator log
1465 /// his worker's dispatch path was dead when the opposite had just been
1466 /// measured. The exclusion is real; the REASON is an opening probation.
1467 #[test]
1468 fn a_part_served_probation_is_a_probation_and_not_a_reachability_failure() -> TestResult {
1469 let start = Instant::now();
1470 let (tracker, worker) = tracker_with_worker(start)?;
1471 const {
1472 assert!(
1473 DISPATCH_PROBATION_PINGS > 1,
1474 "this test is only meaningful while the probation takes more than one answer"
1475 );
1476 }
1477 assert!(tracker.record_dispatch_reachability(worker, start)?);
1478
1479 assert_eq!(
1480 tracker.unreachable_workers(start)?,
1481 vec![ExcludedWorker {
1482 worker_id: worker,
1483 exclusion: DispatchExclusion::OpeningProbation { answers: 1 },
1484 }],
1485 "a worker that has answered part of its opening probation is still excluded, but it \
1486 must not be described as one the server cannot reach — it answered"
1487 );
1488 Ok(())
1489 }
1490
1491 /// The other side of the same discrimination, and the control for the test
1492 /// above: once eligibility has actually been HELD, losing it is an incident
1493 /// and must classify differently. Without this, a classifier that answered
1494 /// `OpeningProbation` unconditionally would satisfy the test above.
1495 #[test]
1496 fn losing_held_eligibility_is_reported_as_a_loss_not_as_a_fresh_probation() -> TestResult {
1497 let start = Instant::now();
1498 let (tracker, worker) = tracker_with_worker(start)?;
1499 serve_probation(&tracker, worker, start)?;
1500 assert!(
1501 tracker.is_dispatch_reachable(worker, start)?,
1502 "precondition: eligibility was genuinely held before it was lost"
1503 );
1504
1505 assert!(
1506 tracker.record_dispatch_unreachable(worker)?,
1507 "the worker is still tracked when its ping fails"
1508 );
1509
1510 assert_eq!(
1511 tracker.unreachable_workers(start)?,
1512 vec![ExcludedWorker {
1513 worker_id: worker,
1514 exclusion: DispatchExclusion::ReachabilityLost,
1515 }],
1516 "a failed ping on a worker that HAD eligibility is an incident, and must not be \
1517 filed as the ordinary probation every fresh connection serves"
1518 );
1519 Ok(())
1520 }
1521
1522 /// A REDIAL is a new measurement. The previous connection's proof must not
1523 /// make the new connection's ordinary probation look like an incident —
1524 /// otherwise every reconnect of a healthy worker would raise the alarm that
1525 /// is supposed to mean something has gone wrong.
1526 #[test]
1527 fn a_reconnect_starts_a_fresh_probation_not_a_lost_eligibility() -> TestResult {
1528 let start = Instant::now();
1529 let (tracker, worker) = tracker_with_worker(start)?;
1530 serve_probation(&tracker, worker, start)?;
1531
1532 tracker.unregister_connection(worker)?;
1533 tracker.register_connection(worker, start)?;
1534
1535 assert_eq!(
1536 tracker.unreachable_workers(start)?,
1537 vec![ExcludedWorker {
1538 worker_id: worker,
1539 exclusion: DispatchExclusion::OpeningProbation { answers: 0 },
1540 }],
1541 "nothing earned on the old connection carries across to the new one"
1542 );
1543 Ok(())
1544 }
1545
1546 /// The probation must be SERVED IN FULL. One answered ping can be luck — a
1547 /// link that answers one probe in three would otherwise accrue eligibility
1548 /// and then flap. This pins the boundary from below: K-1 answers is not
1549 /// enough, and the very next one is.
1550 #[test]
1551 fn one_ping_short_of_the_probation_earns_nothing() -> TestResult {
1552 let start = Instant::now();
1553 let (tracker, worker) = tracker_with_worker(start)?;
1554
1555 for _ in 0..DISPATCH_PROBATION_PINGS - 1 {
1556 assert!(tracker.record_dispatch_reachability(worker, start)?);
1557 assert!(
1558 !tracker.is_dispatch_reachable(worker, start)?,
1559 "eligibility must not be granted before the probation is served in full"
1560 );
1561 }
1562
1563 assert!(tracker.record_dispatch_reachability(worker, start)?);
1564 assert!(
1565 tracker.is_dispatch_reachable(worker, start)?,
1566 "the ping that completes the probation must grant eligibility — otherwise this test \
1567 would pass on a tracker that never grants it at all"
1568 );
1569 Ok(())
1570 }
1571
1572 /// A failed probe RESETS the run. Eligibility is withdrawn immediately, not
1573 /// when the window later expires: an unanswered probe is direct evidence
1574 /// about the push leg, and direct negative evidence must weigh at least as
1575 /// much as silence.
1576 #[test]
1577 fn a_failed_probe_withdraws_eligibility_at_once_and_restarts_the_probation() -> TestResult {
1578 let start = Instant::now();
1579 let (tracker, worker) = tracker_with_worker(start)?;
1580 serve_probation(&tracker, worker, start)?;
1581 assert!(
1582 tracker.is_dispatch_reachable(worker, start)?,
1583 "precondition"
1584 );
1585
1586 assert!(
1587 tracker.record_dispatch_unreachable(worker)?,
1588 "the worker is still tracked"
1589 );
1590 assert!(
1591 !tracker.is_dispatch_reachable(worker, start)?,
1592 "a failed probe withdraws eligibility on the spot, inside the window"
1593 );
1594
1595 // And the run restarts from zero rather than resuming: one answer does
1596 // not restore what a full probation earned.
1597 assert!(tracker.record_dispatch_reachability(worker, start)?);
1598 assert!(
1599 !tracker.is_dispatch_reachable(worker, start)?,
1600 "a single answer after a failure must not restore eligibility"
1601 );
1602 Ok(())
1603 }
1604
1605 /// 🔴 THE FLAPPING PIN. A link that answers every other probe must NEVER
1606 /// become eligible. Cumulative counting would let it accrue, and eligibility
1607 /// would switch on and off under a running fleet — the intermittent evidence
1608 /// that costs hours to attribute. Consecutiveness is what forbids it.
1609 #[test]
1610 fn a_link_that_answers_every_other_probe_never_becomes_eligible() -> TestResult {
1611 let start = Instant::now();
1612 let (tracker, worker) = tracker_with_worker(start)?;
1613
1614 // Far more probes than the probation demands, alternating.
1615 for probe in 0..DISPATCH_PROBATION_PINGS * 10 {
1616 let now = start + Duration::from_millis(u64::from(probe));
1617 if probe % 2 == 0 {
1618 assert!(tracker.record_dispatch_reachability(worker, now)?);
1619 } else {
1620 assert!(tracker.record_dispatch_unreachable(worker)?);
1621 }
1622 assert!(
1623 !tracker.is_dispatch_reachable(worker, now)?,
1624 "a flapping link must never hold dispatch eligibility, at any probe (probe {probe})"
1625 );
1626 }
1627
1628 // The control: the same worker, answering consecutively, DOES become
1629 // eligible — so this test cannot pass on a tracker that grants nothing.
1630 let now = start + Duration::from_secs(1);
1631 serve_probation(&tracker, worker, now)?;
1632 assert!(
1633 tracker.is_dispatch_reachable(worker, now)?,
1634 "consecutive answers must still earn eligibility"
1635 );
1636 Ok(())
1637 }
1638
1639 /// Reachability must EXPIRE on its own clock. If it were only ever advanced
1640 /// and never allowed to go stale, eligibility could never be withdrawn.
1641 #[test]
1642 fn reachability_goes_stale_once_the_window_passes() -> TestResult {
1643 let start = Instant::now();
1644 let (tracker, worker) = tracker_with_worker(start)?;
1645 serve_probation(&tracker, worker, start)?;
1646
1647 assert!(
1648 tracker.is_dispatch_reachable(worker, start + WINDOW)?,
1649 "still inside the window"
1650 );
1651 assert!(
1652 !tracker.is_dispatch_reachable(worker, start + WINDOW + Duration::from_millis(1))?,
1653 "one millisecond past the window is stale"
1654 );
1655 Ok(())
1656 }
1657
1658 /// An untracked worker is not reachable: absence of proof is not proof. A
1659 /// pong racing a reap must not resurrect it either.
1660 #[test]
1661 fn an_unregistered_worker_is_never_reachable_and_cannot_be_resurrected() -> TestResult {
1662 let start = Instant::now();
1663 let (tracker, worker) = tracker_with_worker(start)?;
1664 tracker.unregister_connection(worker)?;
1665
1666 assert!(
1667 !tracker.is_dispatch_reachable(worker, start)?,
1668 "a deregistered worker is not reachable"
1669 );
1670 assert!(
1671 !tracker.record_dispatch_reachability(worker, start)?,
1672 "a late pong must not resurrect a deregistered worker"
1673 );
1674 assert!(
1675 !tracker.record_dispatch_unreachable(worker)?,
1676 "a late probe FAILURE must not resurrect a deregistered worker either — the reset \
1677 path allocates an entry, so it has to refuse an untracked worker as firmly as the \
1678 success path does"
1679 );
1680 assert!(
1681 tracker.unreachable_workers(start)?.is_empty(),
1682 "an untracked worker is not carried in the census either"
1683 );
1684 Ok(())
1685 }
1686}
1687
1688#[cfg(test)]
1689mod tests {
1690 use std::sync::Mutex;
1691
1692 use aion_core::ContentType;
1693 use aion_proto::{ProtoActivityId, ProtoPayload, ProtoWorkflowId};
1694 use serde_json::json;
1695 use uuid::Uuid;
1696
1697 use crate::worker::registry::WorkerRegistration;
1698
1699 use super::*;
1700
1701 /// aion#204 D1: the reservation→tracker handover never publishes a count
1702 /// higher than the work the worker is holding.
1703 ///
1704 /// The handover used to be two acts under two separate registry locks —
1705 /// `track_task` incremented, then the caller dropped the reservation and
1706 /// decremented — so between them `in_flight` read ONE HIGHER than the truth.
1707 /// `reserve_worker` refuses on `held >= advertised`, so a concurrent leg
1708 /// landing in that window was told the pool was full when it was not.
1709 /// Measured in the field at `in_flight = 5` on a worker advertising 4, on a
1710 /// fan sized exactly to its pool — where the margin is zero and one
1711 /// collision is enough.
1712 ///
1713 /// One dispatch is in flight at a time here, so a correct handover can never
1714 /// publish more than 1. An observer hammers the count throughout; under the
1715 /// old two-act handover it sees 2. The observer is a free-running thread, so
1716 /// under a loaded suite it may not be scheduled during the microseconds a
1717 /// round holds its slot and see nothing at all — a peak of 0 is a starved
1718 /// observer, not a finding (measured 2026-09-01 on a 16-core box: 2 of 4
1719 /// full-suite runs). So the two halves are pinned separately: the observer
1720 /// proves the count never exceeds 1, and the round itself reads the count
1721 /// after its handover and requires exactly 1 — the published truth.
1722 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1723 async fn the_handover_never_publishes_a_count_above_the_real_work() -> Result<(), ServerError> {
1724 /// Enough rounds that the two-lock window is hit; it is microseconds
1725 /// wide, so a handful would prove nothing.
1726 const ROUNDS: usize = 400;
1727
1728 let worker = RegisteredTestWorker::connected()?;
1729 let tracker = HeartbeatTracker::new(Duration::from_secs(30));
1730 let registry = worker.registry.clone();
1731 let worker_id = worker.worker_id;
1732
1733 let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1734 let peak = Arc::new(std::sync::atomic::AtomicU32::new(0));
1735 let observer = {
1736 let (registry, stop, peak) = (registry.clone(), Arc::clone(&stop), Arc::clone(&peak));
1737 std::thread::spawn(move || {
1738 while !stop.load(std::sync::atomic::Ordering::SeqCst) {
1739 if let Ok(held) = registry.in_flight_for_worker(worker_id) {
1740 peak.fetch_max(held, std::sync::atomic::Ordering::SeqCst);
1741 }
1742 }
1743 })
1744 };
1745
1746 for round in 0..ROUNDS {
1747 let activity_id = activity_id(u64::try_from(round).unwrap_or(0));
1748 let reservation = registry
1749 .reserve_worker(worker_id)?
1750 .ok_or_else(|| wire_error("a worker with a free slot must reserve"))?;
1751 tracker.track_task(
1752 worker_id,
1753 InFlightActivity {
1754 workflow_id: workflow_id(),
1755 activity_id: activity_id.clone(),
1756 attempt: 1,
1757 completion_token: crate::worker::CompletionToken::for_test(),
1758 },
1759 Instant::now(),
1760 ®istry,
1761 Some(reservation),
1762 )?;
1763 assert_eq!(
1764 registry.in_flight_for_worker(worker_id)?,
1765 1,
1766 "after the handover the count must read exactly the one dispatch in flight"
1767 );
1768 assert!(tracker.complete_task(worker_id, &workflow_id(), &activity_id, ®istry)?);
1769 }
1770
1771 stop.store(true, std::sync::atomic::Ordering::SeqCst);
1772 observer
1773 .join()
1774 .map_err(|_| wire_error("the observing thread panicked"))?;
1775
1776 let observed_peak = peak.load(std::sync::atomic::Ordering::SeqCst);
1777 assert!(
1778 observed_peak <= 1,
1779 "the count passed through {observed_peak}, higher than the one dispatch in flight; a \
1780 concurrent selection landing in that window is refused a slot the worker has"
1781 );
1782 assert_eq!(
1783 registry.in_flight_for_worker(worker_id)?,
1784 0,
1785 "and every round must have given its slot back"
1786 );
1787 Ok(())
1788 }
1789
1790 /// Item 9: tracking ONE dispatch twice must not spend TWO slots.
1791 ///
1792 /// `state.tasks` is keyed by `(worker, workflow, activity)` and an insert
1793 /// over a live key OVERWRITES — one entry before, one entry after — while
1794 /// the registry's capacity count increments. Ungated, tracking the same key
1795 /// twice without an intervening `complete_task` therefore counted twice and
1796 /// could only ever be decremented once, so the worker's usable capacity
1797 /// shrank by one permanently: the entry is cleared only when the worker
1798 /// deregisters.
1799 ///
1800 /// The release half is the vacuity control. If the second track had been
1801 /// dropped entirely rather than merely not double-counted, one completion
1802 /// would still have to return the count to zero — and it does.
1803 #[tokio::test]
1804 async fn tracking_one_dispatch_twice_spends_one_slot() -> Result<(), ServerError> {
1805 let worker = RegisteredTestWorker::connected()?;
1806 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
1807 let workflow_id = workflow_id();
1808 let activity_id = activity_id(21);
1809 let now = Instant::now();
1810 let task = || InFlightActivity {
1811 workflow_id: workflow_id.clone(),
1812 activity_id: activity_id.clone(),
1813 attempt: 1,
1814 completion_token: crate::worker::CompletionToken::for_test(),
1815 };
1816
1817 tracker.track_task(worker.worker_id, task(), now, &worker.registry, None)?;
1818 tracker.track_task(worker.worker_id, task(), now, &worker.registry, None)?;
1819 assert_eq!(
1820 worker.registry.in_flight_for_worker(worker.worker_id)?,
1821 1,
1822 "one tracked dispatch is one spent slot however many times it is tracked; counting \
1823 the redelivery again leaks a slot for the life of the registration"
1824 );
1825
1826 assert!(tracker.complete_task(
1827 worker.worker_id,
1828 &workflow_id,
1829 &activity_id,
1830 &worker.registry
1831 )?);
1832 assert_eq!(
1833 worker.registry.in_flight_for_worker(worker.worker_id)?,
1834 0,
1835 "and one completion must return the slot: the counts agree on how many times one \
1836 dispatch counts"
1837 );
1838 Ok(())
1839 }
1840
1841 #[derive(Default)]
1842 struct RecordingSink {
1843 completions: Mutex<Vec<ActivityCompletion>>,
1844 parks: Mutex<Vec<(WorkflowId, ActivityId)>>,
1845 }
1846
1847 impl ActivityCompletionSink for RecordingSink {
1848 fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError> {
1849 self.completions
1850 .lock()
1851 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
1852 .push(completion);
1853 Ok(())
1854 }
1855
1856 fn park_activity(
1857 &self,
1858 workflow_id: &WorkflowId,
1859 activity_id: &ActivityId,
1860 ) -> Result<(), ServerError> {
1861 self.parks
1862 .lock()
1863 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
1864 .push((workflow_id.clone(), activity_id.clone()));
1865 Ok(())
1866 }
1867 }
1868
1869 fn workflow_id() -> WorkflowId {
1870 WorkflowId::new(Uuid::nil())
1871 }
1872
1873 fn activity_id(position: u64) -> ActivityId {
1874 ActivityId::from_sequence_position(position)
1875 }
1876
1877 fn payload(value: &serde_json::Value) -> Result<Payload, Box<dyn std::error::Error>> {
1878 Ok(Payload::from_json(value)?)
1879 }
1880
1881 fn heartbeat(
1882 workflow_id: WorkflowId,
1883 activity_id: ActivityId,
1884 progress: Option<Payload>,
1885 ) -> ProtoHeartbeat {
1886 ProtoHeartbeat {
1887 workflow_id: Some(ProtoWorkflowId::from(workflow_id)),
1888 activity_id: Some(ProtoActivityId::from(activity_id)),
1889 progress: progress.map(ProtoPayload::from),
1890 }
1891 }
1892
1893 /// A registered worker holding everything that keeps it ALIVE: the
1894 /// registry it is in, the registration guard, its worker id, and — the
1895 /// input the expiry sweep now reads — the stream receiver whose presence
1896 /// is the connected fact.
1897 ///
1898 /// The receiver is held rather than dropped at construction because the
1899 /// two are now different worlds. A worker with a live receiver is
1900 /// CONNECTED, and silence from it means busy or wedged, never gone; one
1901 /// whose receiver has been dropped has no push leg left, and silence from
1902 /// it means exactly what the sweep used to assume of both. A test that
1903 /// wants the second says so with [`Self::disconnect`], instead of getting
1904 /// it for free from a helper that happened to drop the receiver.
1905 struct RegisteredTestWorker {
1906 registry: ConnectedWorkerRegistry,
1907 /// Held for its Drop: dropping it deregisters the worker.
1908 _registration: WorkerRegistration,
1909 /// `None` once the push leg has been closed on purpose.
1910 receiver: Option<tokio::sync::mpsc::Receiver<crate::worker::registry::WorkerMessage>>,
1911 worker_id: WorkerId,
1912 }
1913
1914 impl RegisteredTestWorker {
1915 /// A worker registered and CONNECTED: the server still holds an open
1916 /// channel to it.
1917 fn connected() -> Result<Self, ServerError> {
1918 let registry = ConnectedWorkerRegistry::default();
1919 let (tx, rx) = tokio::sync::mpsc::channel(1);
1920 let activity_types = [String::from("charge-card")];
1921 let registration = registry.register(
1922 "tenant-a",
1923 activity_types.iter(),
1924 tx,
1925 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
1926 )?;
1927 let worker_id = registration
1928 .worker_id()
1929 .ok_or_else(|| ServerError::lock_poisoned("test worker registration"))?;
1930 Ok(Self {
1931 registry,
1932 _registration: registration,
1933 receiver: Some(rx),
1934 worker_id,
1935 })
1936 }
1937
1938 /// Close the push leg without touching the registration — the shape of
1939 /// a worker whose process is genuinely gone while the server has not
1940 /// noticed yet.
1941 fn disconnect(&mut self) {
1942 self.receiver = None;
1943 }
1944 }
1945
1946 #[test]
1947 fn heartbeat_refresh_keeps_task_live_across_window() -> Result<(), Box<dyn std::error::Error>> {
1948 let window = Duration::from_secs(5);
1949 let tracker = HeartbeatTracker::new(window);
1950 let worker = RegisteredTestWorker::connected()?;
1951 let (registry, worker_id) = (&worker.registry, worker.worker_id);
1952 let workflow_id = workflow_id();
1953 let activity_id = activity_id(10);
1954 let start = Instant::now();
1955
1956 tracker.track_task(
1957 worker_id,
1958 InFlightActivity {
1959 workflow_id: workflow_id.clone(),
1960 activity_id: activity_id.clone(),
1961 attempt: 1,
1962 completion_token: crate::worker::CompletionToken::for_test(),
1963 },
1964 start,
1965 registry,
1966 None,
1967 )?;
1968 assert!(tracker.is_live(worker_id, &workflow_id, &activity_id, start + window)?);
1969
1970 let progress = payload(&json!({"percent": 50}))?;
1971 let update = tracker.record_heartbeat(
1972 worker_id,
1973 heartbeat(
1974 workflow_id.clone(),
1975 activity_id.clone(),
1976 Some(progress.clone()),
1977 ),
1978 start + window,
1979 )?;
1980
1981 assert_eq!(update.liveness.last_progress, Some(progress));
1982 assert!(tracker.is_live(
1983 worker_id,
1984 &workflow_id,
1985 &activity_id,
1986 start + window + window
1987 )?);
1988 assert!(
1989 tracker
1990 .expired_workers(registry, start + window + window)?
1991 .is_empty()
1992 );
1993 Ok(())
1994 }
1995
1996 #[test]
1997 fn missed_heartbeat_deregisters_worker_and_fails_in_flight_once()
1998 -> Result<(), Box<dyn std::error::Error>> {
1999 let mut worker = RegisteredTestWorker::connected()?;
2000 // This sweep's subject is a worker that is GONE, so its push leg is
2001 // closed on purpose: a worker whose channel is still open and whose
2002 // silence is merely saturation is no longer reaped, and a test that
2003 // left the leg open would be asserting the behaviour this landing
2004 // removes.
2005 worker.disconnect();
2006 let (registry, worker_id) = (&worker.registry, worker.worker_id);
2007 let sink = RecordingSink::default();
2008 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
2009 let workflow_id = workflow_id();
2010 let activity_id = activity_id(11);
2011 let start = Instant::now();
2012
2013 // REGISTERED, explicitly. `track_task` used to create this entry as a
2014 // side effect, which meant the server's own act of dispatching started
2015 // the clock it later read to decide whether the worker was still there.
2016 // It no longer does, so the connection is recorded where it actually
2017 // happens in production: when the worker registers, before anything is
2018 // dispatched to it.
2019 tracker.register_connection(worker_id, start)?;
2020 tracker.track_task(
2021 worker_id,
2022 InFlightActivity {
2023 workflow_id: workflow_id.clone(),
2024 activity_id: activity_id.clone(),
2025 attempt: 1,
2026 completion_token: crate::worker::CompletionToken::for_test(),
2027 },
2028 start,
2029 registry,
2030 None,
2031 )?;
2032
2033 let reports =
2034 tracker.fail_expired_workers(registry, &sink, start + Duration::from_secs(6))?;
2035 assert_eq!(reports.len(), 1);
2036 assert_eq!(reports[0].worker_id, worker_id);
2037 assert_eq!(reports[0].tasks.len(), 1);
2038 assert!(
2039 registry
2040 .workers_for("tenant-a", "default", "charge-card", None)?
2041 .is_empty()
2042 );
2043
2044 let second = tracker.fail_disconnected_worker(worker_id, registry, &sink)?;
2045 assert!(second.tasks.is_empty());
2046 let completions = sink
2047 .completions
2048 .lock()
2049 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
2050 assert_eq!(completions.len(), 1);
2051 assert_eq!(completions[0].workflow_id, workflow_id);
2052 assert_eq!(completions[0].activity_id, activity_id);
2053 // The sweep reports a TRANSPORT-domain loss, not an activity failure:
2054 // the activity never executed to a result, so the sink (not this sweep)
2055 // classifies it and applies the transport's own re-dispatch budget.
2056 // Before this distinction existed the sweep synthesized a `Retryable`
2057 // `ActivityError` that the engine then delivered as a TERMINAL failure
2058 // whenever the activity carried no authored retry policy.
2059 match &completions[0].outcome {
2060 ActivityCompletionOutcome::WorkerLost { worker_id: lost } => {
2061 assert_eq!(*lost, worker_id);
2062 }
2063 other => {
2064 return Err(format!("expected a lost-worker outcome, got {other:?}").into());
2065 }
2066 }
2067 Ok(())
2068 }
2069
2070 #[test]
2071 fn disconnected_worker_fails_each_in_flight_task_once() -> Result<(), Box<dyn std::error::Error>>
2072 {
2073 let mut worker = RegisteredTestWorker::connected()?;
2074 // This sweep's subject is a worker that is GONE, so its push leg is
2075 // closed on purpose: a worker whose channel is still open and whose
2076 // silence is merely saturation is no longer reaped, and a test that
2077 // left the leg open would be asserting the behaviour this landing
2078 // removes.
2079 worker.disconnect();
2080 let (registry, worker_id) = (&worker.registry, worker.worker_id);
2081 let sink = RecordingSink::default();
2082 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
2083 let workflow_id = workflow_id();
2084 let start = Instant::now();
2085
2086 tracker.track_task(
2087 worker_id,
2088 InFlightActivity {
2089 workflow_id: workflow_id.clone(),
2090 activity_id: activity_id(21),
2091 attempt: 1,
2092 completion_token: crate::worker::CompletionToken::for_test(),
2093 },
2094 start,
2095 registry,
2096 None,
2097 )?;
2098 tracker.track_task(
2099 worker_id,
2100 InFlightActivity {
2101 workflow_id,
2102 activity_id: activity_id(22),
2103 attempt: 1,
2104 completion_token: crate::worker::CompletionToken::for_test(),
2105 },
2106 start,
2107 registry,
2108 None,
2109 )?;
2110
2111 let report = tracker.fail_disconnected_worker(worker_id, registry, &sink)?;
2112 assert_eq!(report.tasks.len(), 2);
2113 assert!(
2114 registry
2115 .workers_for("tenant-a", "default", "charge-card", None)?
2116 .is_empty()
2117 );
2118
2119 let completions = sink
2120 .completions
2121 .lock()
2122 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
2123 assert_eq!(completions.len(), 2);
2124 assert!(completions.iter().all(|completion| matches!(
2125 &completion.outcome,
2126 ActivityCompletionOutcome::WorkerLost { .. }
2127 )));
2128 Ok(())
2129 }
2130
2131 /// #207: parking a drain-disconnected worker removes its tasks, deregisters
2132 /// it, and PARKS each task through the sink — zero completions synthesized,
2133 /// so the durable log stays byte-equivalent to a kill -9. A second park (or
2134 /// a later fail sweep) finds nothing: the idempotent-deregister discipline
2135 /// the fail path already proves holds for parks too.
2136 #[test]
2137 fn park_disconnected_worker_parks_tasks_without_synthesizing_completions()
2138 -> Result<(), Box<dyn std::error::Error>> {
2139 let mut worker = RegisteredTestWorker::connected()?;
2140 // This sweep's subject is a worker that is GONE, so its push leg is
2141 // closed on purpose: a worker whose channel is still open and whose
2142 // silence is merely saturation is no longer reaped, and a test that
2143 // left the leg open would be asserting the behaviour this landing
2144 // removes.
2145 worker.disconnect();
2146 let (registry, worker_id) = (&worker.registry, worker.worker_id);
2147 let sink = RecordingSink::default();
2148 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
2149 let workflow_id = workflow_id();
2150 let start = Instant::now();
2151 tracker.track_task(
2152 worker_id,
2153 InFlightActivity {
2154 workflow_id: workflow_id.clone(),
2155 activity_id: activity_id(60),
2156 attempt: 1,
2157 completion_token: crate::worker::CompletionToken::for_test(),
2158 },
2159 start,
2160 registry,
2161 None,
2162 )?;
2163 tracker.track_task(
2164 worker_id,
2165 InFlightActivity {
2166 workflow_id: workflow_id.clone(),
2167 activity_id: activity_id(61),
2168 attempt: 1,
2169 completion_token: crate::worker::CompletionToken::for_test(),
2170 },
2171 start,
2172 registry,
2173 None,
2174 )?;
2175
2176 let report = tracker.park_disconnected_worker(worker_id, registry, &sink)?;
2177 assert_eq!(report.tasks.len(), 2);
2178 assert_eq!(
2179 tracker.in_flight_count()?,
2180 0,
2181 "parking must remove every tracked task so drain accounting reaches zero"
2182 );
2183 assert!(
2184 registry
2185 .workers_for("tenant-a", "default", "charge-card", None)?
2186 .is_empty(),
2187 "the parked worker must be deregistered from routing"
2188 );
2189 let parks = sink
2190 .parks
2191 .lock()
2192 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
2193 assert_eq!(parks.len(), 2, "each task must be parked exactly once");
2194 drop(parks);
2195 assert!(
2196 sink.completions
2197 .lock()
2198 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
2199 .is_empty(),
2200 "parking must never synthesize an activity completion"
2201 );
2202
2203 // Double-park and park-after-fail are no-ops: the idempotent core.
2204 let second = tracker.park_disconnected_worker(worker_id, registry, &sink)?;
2205 assert!(second.tasks.is_empty());
2206 let third = tracker.fail_disconnected_worker(worker_id, registry, &sink)?;
2207 assert!(third.tasks.is_empty());
2208 assert_eq!(
2209 sink.parks
2210 .lock()
2211 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
2212 .len(),
2213 2,
2214 "re-sweeping a parked worker must park nothing further"
2215 );
2216 assert!(
2217 sink.completions
2218 .lock()
2219 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
2220 .is_empty(),
2221 "a fail sweep after the park must fail nothing"
2222 );
2223 Ok(())
2224 }
2225
2226 /// #207 drain-timeout backstop: the bulk park removes every worker's tasks,
2227 /// parks each through the sink, and wakes drain waiters — never
2228 /// synthesizing a completion.
2229 #[tokio::test]
2230 async fn park_all_in_flight_workers_parks_everything_and_wakes_drain_waiters()
2231 -> Result<(), Box<dyn std::error::Error>> {
2232 let mut worker = RegisteredTestWorker::connected()?;
2233 // This sweep's subject is a worker that is GONE, so its push leg is
2234 // closed on purpose: a worker whose channel is still open and whose
2235 // silence is merely saturation is no longer reaped, and a test that
2236 // left the leg open would be asserting the behaviour this landing
2237 // removes.
2238 worker.disconnect();
2239 let (registry, worker_id) = (&worker.registry, worker.worker_id);
2240 let sink = RecordingSink::default();
2241 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
2242 let workflow_id = workflow_id();
2243 tracker.track_task(
2244 worker_id,
2245 InFlightActivity {
2246 workflow_id: workflow_id.clone(),
2247 activity_id: activity_id(70),
2248 attempt: 1,
2249 completion_token: crate::worker::CompletionToken::for_test(),
2250 },
2251 Instant::now(),
2252 registry,
2253 None,
2254 )?;
2255 // Arm a waiter on the tracker's empty notify BEFORE the bulk park.
2256 let notified = tracker.empty.notified();
2257 tokio::pin!(notified);
2258
2259 let reports = tracker.park_all_in_flight_workers(registry, &sink)?;
2260 assert_eq!(reports.len(), 1);
2261 assert_eq!(reports[0].worker_id, worker_id);
2262 assert_eq!(reports[0].tasks.len(), 1);
2263 assert_eq!(tracker.in_flight_count()?, 0);
2264 assert_eq!(
2265 sink.parks
2266 .lock()
2267 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
2268 .len(),
2269 1
2270 );
2271 assert!(
2272 sink.completions
2273 .lock()
2274 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
2275 .is_empty(),
2276 "the bulk park must never synthesize a completion"
2277 );
2278 tokio::time::timeout(Duration::from_millis(200), notified)
2279 .await
2280 .map_err(|_| "the bulk park must wake drain waiters")?;
2281 Ok(())
2282 }
2283
2284 /// The worker runtime's AUTOMATIC liveness beats carry no payload and
2285 /// interleave with explicit handler progress heartbeats: a payload-free
2286 /// beat must refresh the liveness stamp WITHOUT erasing the handler's
2287 /// most recent progress report.
2288 #[test]
2289 fn payload_free_heartbeat_refreshes_liveness_without_clearing_progress()
2290 -> Result<(), Box<dyn std::error::Error>> {
2291 let window = Duration::from_secs(5);
2292 let tracker = HeartbeatTracker::new(window);
2293 let worker = RegisteredTestWorker::connected()?;
2294 let (registry, worker_id) = (&worker.registry, worker.worker_id);
2295 let workflow_id = workflow_id();
2296 let activity_id = activity_id(12);
2297 let start = Instant::now();
2298
2299 tracker.track_task(
2300 worker_id,
2301 InFlightActivity {
2302 workflow_id: workflow_id.clone(),
2303 activity_id: activity_id.clone(),
2304 attempt: 1,
2305 completion_token: crate::worker::CompletionToken::for_test(),
2306 },
2307 start,
2308 registry,
2309 None,
2310 )?;
2311 let progress = payload(&json!({"percent": 80}))?;
2312 tracker.record_heartbeat(
2313 worker_id,
2314 heartbeat(
2315 workflow_id.clone(),
2316 activity_id.clone(),
2317 Some(progress.clone()),
2318 ),
2319 start + Duration::from_secs(1),
2320 )?;
2321
2322 // An automatic liveness beat: no payload, later timestamp.
2323 let update = tracker.record_heartbeat(
2324 worker_id,
2325 heartbeat(workflow_id.clone(), activity_id.clone(), None),
2326 start + Duration::from_secs(4),
2327 )?;
2328
2329 assert_eq!(
2330 update.liveness.last_progress,
2331 Some(progress),
2332 "a payload-free liveness beat must not erase handler progress"
2333 );
2334 assert!(
2335 tracker.is_live(
2336 worker_id,
2337 &workflow_id,
2338 &activity_id,
2339 start + Duration::from_secs(8)
2340 )?,
2341 "the payload-free beat must still refresh the liveness stamp"
2342 );
2343 Ok(())
2344 }
2345
2346 #[test]
2347 fn malformed_heartbeat_missing_ids_is_wire_error() -> Result<(), Box<dyn std::error::Error>> {
2348 let worker = RegisteredTestWorker::connected()?;
2349 let worker_id = worker.worker_id;
2350 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
2351 let missing = ProtoHeartbeat {
2352 workflow_id: None,
2353 activity_id: Some(ProtoActivityId::from(activity_id(30))),
2354 progress: None,
2355 };
2356
2357 let result = tracker.record_heartbeat(worker_id, missing, Instant::now());
2358 assert!(matches!(result, Err(ServerError::Wire { .. })));
2359 Ok(())
2360 }
2361
2362 #[test]
2363 fn heartbeat_progress_is_not_reported_as_activity_result()
2364 -> Result<(), Box<dyn std::error::Error>> {
2365 let sink = RecordingSink::default();
2366 let worker = RegisteredTestWorker::connected()?;
2367 let (registry, worker_id) = (&worker.registry, worker.worker_id);
2368 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
2369 let workflow_id = workflow_id();
2370 let activity_id = activity_id(40);
2371 let now = Instant::now();
2372
2373 tracker.track_task(
2374 worker_id,
2375 InFlightActivity {
2376 workflow_id: workflow_id.clone(),
2377 activity_id: activity_id.clone(),
2378 attempt: 1,
2379 completion_token: crate::worker::CompletionToken::for_test(),
2380 },
2381 now,
2382 registry,
2383 None,
2384 )?;
2385 tracker.record_heartbeat(
2386 worker_id,
2387 heartbeat(
2388 workflow_id,
2389 activity_id,
2390 Some(Payload::new(
2391 ContentType::Json,
2392 b"{\"progress\":1}".to_vec(),
2393 )),
2394 ),
2395 now,
2396 )?;
2397
2398 let completions = sink
2399 .completions
2400 .lock()
2401 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
2402 assert!(completions.is_empty());
2403 Ok(())
2404 }
2405
2406 /// 🔴 aion#204's ARM ONE: a worker whose per-task beat has lapsed, whose
2407 /// push leg is OPEN, and whose dispatch path the server has PROVED it can
2408 /// reach, is NOT deregistered. It is busy.
2409 ///
2410 /// This is the case the sweep used to get wrong, and getting it wrong cost
2411 /// a thirty-seven minute outage. A gRPC worker running at its full
2412 /// concurrency stopped reading its stream, so its per-task beats lapsed;
2413 /// the sweep read that lapse as death, deregistered a connected worker
2414 /// holding live work, surfaced its in-flight activities as transport
2415 /// losses, and left the queue census reporting an empty pool that was
2416 /// never empty.
2417 ///
2418 /// `is_expired` is a PER-TASK predicate. One lapsed beat says something
2419 /// about one activity; it has never been a statement about the worker.
2420 #[test]
2421 fn a_reachable_connected_worker_is_not_reaped_for_a_lapsed_beat()
2422 -> Result<(), Box<dyn std::error::Error>> {
2423 let window = Duration::from_secs(5);
2424 let tracker = HeartbeatTracker::new(window);
2425 let worker = RegisteredTestWorker::connected()?;
2426 let (registry, worker_id) = (&worker.registry, worker.worker_id);
2427 let start = Instant::now();
2428 tracker.register_connection(worker_id, start)?;
2429 tracker.track_task(
2430 worker_id,
2431 InFlightActivity {
2432 workflow_id: workflow_id(),
2433 activity_id: activity_id(60),
2434 attempt: 1,
2435 completion_token: crate::worker::CompletionToken::for_test(),
2436 },
2437 start,
2438 registry,
2439 None,
2440 )?;
2441 // The worker answers its probation, and keeps answering — which is what
2442 // a busy worker now does, because its receive loop no longer parks on
2443 // admission. The proof has a window of its own, so a probe answered
2444 // only once at the start would age out and this test would be
2445 // measuring staleness rather than saturation.
2446 let later = start + window + window;
2447 for answered_at in [start, later] {
2448 for _ in 0..DISPATCH_PROBATION_PINGS {
2449 assert!(tracker.record_dispatch_reachability(worker_id, answered_at)?);
2450 }
2451 }
2452
2453 // Well past the per-task window, and past the connection window too.
2454 assert!(
2455 tracker.expired_workers(registry, later)?.is_empty(),
2456 "a worker with an open push leg the server can PROVE it reaches is busy, not lost: \
2457 deregistering it is what emptied a pool that was never empty"
2458 );
2459 assert!(
2460 registry.is_registered(worker_id)?,
2461 "and it must still be in the registry, keeping its in-flight work"
2462 );
2463 Ok(())
2464 }
2465
2466 /// 🔴 aion#204's ARM TWO, and the reason the fix is not simply "stop
2467 /// reaping": a worker whose leg is open but which the server CANNOT prove
2468 /// it reaches is still reaped.
2469 ///
2470 /// This is #176's wedged process — stream open, nothing moving, in-flight
2471 /// work that will never complete — and an open `mpsc` sender says nothing
2472 /// about it. Gating the sweep on connection alone would have re-opened that
2473 /// failure while closing the other one, and on gRPC it would have disabled
2474 /// the sweep entirely: a sender closes only when the stream task ends, and
2475 /// that is already the teardown sweep's business.
2476 #[test]
2477 fn a_connected_worker_the_server_cannot_reach_is_still_reaped()
2478 -> Result<(), Box<dyn std::error::Error>> {
2479 let window = Duration::from_secs(5);
2480 let tracker = HeartbeatTracker::new(window);
2481 let worker = RegisteredTestWorker::connected()?;
2482 let (registry, worker_id) = (&worker.registry, worker.worker_id);
2483 let start = Instant::now();
2484 tracker.register_connection(worker_id, start)?;
2485 tracker.track_task(
2486 worker_id,
2487 InFlightActivity {
2488 workflow_id: workflow_id(),
2489 activity_id: activity_id(61),
2490 attempt: 1,
2491 completion_token: crate::worker::CompletionToken::for_test(),
2492 },
2493 start,
2494 registry,
2495 None,
2496 )?;
2497 // No answered ping: the leg is open and nothing has come back on it.
2498 let later = start + window + window;
2499 assert_eq!(
2500 tracker.expired_workers(registry, later)?,
2501 vec![worker_id],
2502 "a wedged process holds a healthy-looking channel indefinitely; if silence on an \
2503 unproved leg did not reap, its in-flight work would wait for ever"
2504 );
2505 Ok(())
2506 }
2507
2508 /// The third arm, and the control for the first two: a worker with NO push
2509 /// leg left is reaped whatever its reachability history says.
2510 ///
2511 /// Without this, the first test would be equally satisfied by a sweep that
2512 /// had simply stopped reaping.
2513 #[test]
2514 fn a_disconnected_worker_is_reaped_even_after_proving_reachability()
2515 -> Result<(), Box<dyn std::error::Error>> {
2516 let window = Duration::from_secs(5);
2517 let tracker = HeartbeatTracker::new(window);
2518 let mut worker = RegisteredTestWorker::connected()?;
2519 let worker_id = worker.worker_id;
2520 let start = Instant::now();
2521 let later = start + window + window;
2522 tracker.register_connection(worker_id, start)?;
2523 // Proved, at `start` and NOT at the sweep instant — and that is forced,
2524 // not a preference. `record_dispatch_reachability` refreshes the
2525 // CONNECTION clock as well as the proof, so a worker proved at `later`
2526 // is not silent at `later` and is never nominated in the first place.
2527 // "Provably reachable right now AND overdue right now" is not a
2528 // reachable state: one call advances both clocks together.
2529 //
2530 // What this test can still say, and does, is that a worker which HAS
2531 // proved reachability is reaped once it goes quiet and its leg closes —
2532 // a past proof is not a standing acquittal.
2533 for _ in 0..DISPATCH_PROBATION_PINGS {
2534 assert!(tracker.record_dispatch_reachability(worker_id, start)?);
2535 }
2536 tracker.track_task(
2537 worker_id,
2538 InFlightActivity {
2539 workflow_id: workflow_id(),
2540 activity_id: activity_id(62),
2541 attempt: 1,
2542 completion_token: crate::worker::CompletionToken::for_test(),
2543 },
2544 start,
2545 &worker.registry,
2546 None,
2547 )?;
2548 // Still connected and still inside its window at `start + window`: the
2549 // connection clock has not lapsed, so nothing nominates it.
2550 assert!(
2551 tracker
2552 .expired_workers(&worker.registry, start + window)?
2553 .is_empty(),
2554 "a worker inside its window is not a candidate at all"
2555 );
2556
2557 worker.disconnect();
2558 assert_eq!(
2559 tracker.expired_workers(&worker.registry, later)?,
2560 vec![worker_id],
2561 "once the push leg is gone there is nothing left to hear a beat on, and the proof \
2562 that used to hold is about a link that no longer exists"
2563 );
2564 Ok(())
2565 }
2566
2567 /// `complete_task` reports whether THIS call retired the entry — the
2568 /// structural gate the liminal reply router uses to synthesize a
2569 /// lost-worker failure only for a dispatch nobody else resolved.
2570 #[test]
2571 fn complete_task_reports_whether_the_entry_was_tracked()
2572 -> Result<(), Box<dyn std::error::Error>> {
2573 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
2574 let worker = RegisteredTestWorker::connected()?;
2575 let (registry, worker_id) = (&worker.registry, worker.worker_id);
2576 let workflow_id = workflow_id();
2577 let id = activity_id(50);
2578 tracker.track_task(
2579 worker_id,
2580 InFlightActivity {
2581 workflow_id: workflow_id.clone(),
2582 activity_id: id.clone(),
2583 attempt: 1,
2584 completion_token: crate::worker::CompletionToken::for_test(),
2585 },
2586 Instant::now(),
2587 registry,
2588 None,
2589 )?;
2590
2591 assert!(tracker.is_tracked(worker_id, &workflow_id, &id)?);
2592 assert!(
2593 tracker.complete_task(worker_id, &workflow_id, &id, registry)?,
2594 "the first completion retires the tracked entry"
2595 );
2596 assert!(!tracker.is_tracked(worker_id, &workflow_id, &id)?);
2597 assert!(
2598 !tracker.complete_task(worker_id, &workflow_id, &id, registry)?,
2599 "a second completion finds nothing to retire"
2600 );
2601 Ok(())
2602 }
2603
2604 /// A liveness beat (the liminal worker's automatic pump) refreshes the
2605 /// task's expiry stamp — keeping a genuinely-running over-window activity
2606 /// out of the sweep — and reports an untracked task benignly.
2607 #[test]
2608 fn record_liveness_refreshes_stamp_and_ignores_untracked_tasks()
2609 -> Result<(), Box<dyn std::error::Error>> {
2610 let window = Duration::from_secs(5);
2611 let tracker = HeartbeatTracker::new(window);
2612 let worker = RegisteredTestWorker::connected()?;
2613 let (registry, worker_id) = (&worker.registry, worker.worker_id);
2614 let workflow_id = workflow_id();
2615 let id = activity_id(51);
2616 let start = Instant::now();
2617 tracker.track_task(
2618 worker_id,
2619 InFlightActivity {
2620 workflow_id: workflow_id.clone(),
2621 activity_id: id.clone(),
2622 attempt: 1,
2623 completion_token: crate::worker::CompletionToken::for_test(),
2624 },
2625 start,
2626 registry,
2627 None,
2628 )?;
2629
2630 // Beaten at the window edge, the task survives past the original expiry.
2631 assert!(tracker.record_liveness(worker_id, &workflow_id, &id, start + window)?);
2632 assert!(tracker.is_live(worker_id, &workflow_id, &id, start + window + window)?);
2633 assert!(
2634 tracker
2635 .expired_workers(registry, start + window + window)?
2636 .is_empty()
2637 );
2638
2639 // An untracked beat (an outbox dispatch, or a beat racing completion)
2640 // is a benign false, never an error.
2641 assert!(!tracker.record_liveness(
2642 worker_id,
2643 &workflow_id,
2644 &activity_id(52),
2645 start + window
2646 )?);
2647 Ok(())
2648 }
2649
2650 #[test]
2651 fn sweep_interval_is_quarter_window_clamped_to_one_second_and_window() {
2652 // The default 30s window sweeps every 7.5s (quarter-window).
2653 assert_eq!(
2654 sweep_interval(Duration::from_secs(30)),
2655 Duration::from_millis(7_500)
2656 );
2657 // A short window's quarter (500ms) is floored at 1s.
2658 assert_eq!(
2659 sweep_interval(Duration::from_secs(2)),
2660 Duration::from_secs(1)
2661 );
2662 // A very long window's quarter stays within the [1s, window] band.
2663 assert_eq!(
2664 sweep_interval(Duration::from_secs(3_600)),
2665 Duration::from_secs(900)
2666 );
2667 // A sub-second (test) window sweeps once per window, never spinning
2668 // sub-window nor waiting longer than the window itself.
2669 assert_eq!(
2670 sweep_interval(Duration::from_millis(200)),
2671 Duration::from_millis(200)
2672 );
2673 // A zero window is floored at the minimum positive period rather than
2674 // producing the zero interval `tokio::time::interval` rejects.
2675 assert_eq!(sweep_interval(Duration::ZERO), Duration::from_millis(1));
2676 }
2677}