Skip to main content

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