Skip to main content

aion_server/worker/
heartbeat.rs

1//! Heartbeat window tracking and lost-worker failure surfacing.
2
3use std::collections::{HashMap, HashSet};
4use std::sync::{Arc, Mutex, MutexGuard};
5use std::time::{Duration, Instant};
6use tokio::sync::{Notify, watch};
7use tracing::{error, info, warn};
8
9use aion_core::{ActivityId, Payload, WorkflowId};
10use aion_proto::{ProtoHeartbeat, WireError};
11
12use crate::error::ServerError;
13use crate::shutdown::DrainState;
14use crate::worker::dispatch::{
15    ActivityCompletion, ActivityCompletionOutcome, ActivityCompletionSink, lost_worker_error,
16};
17use crate::worker::registry::{ConnectedWorkerRegistry, WorkerId};
18
19/// In-flight activity assigned to a connected worker.
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct InFlightActivity {
22    /// Owning workflow id.
23    pub workflow_id: WorkflowId,
24    /// Correlating activity id.
25    pub activity_id: ActivityId,
26}
27
28/// Observable liveness state for a single in-flight activity.
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct TaskLiveness {
31    /// Worker currently responsible for the task.
32    pub worker_id: WorkerId,
33    /// Owning workflow id.
34    pub workflow_id: WorkflowId,
35    /// Correlating activity id.
36    pub activity_id: ActivityId,
37    /// Operator-configured heartbeat window used for expiry checks.
38    pub heartbeat_window: Duration,
39    /// Monotonic timestamp of assignment or the most recent heartbeat.
40    pub last_heartbeat_at: Instant,
41    /// Optional worker progress from the most recent heartbeat.
42    pub last_progress: Option<Payload>,
43}
44
45/// Result of accepting a heartbeat for an in-flight task.
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct HeartbeatUpdate {
48    /// Updated liveness after recording the heartbeat.
49    pub liveness: TaskLiveness,
50}
51
52/// Tasks removed from tracking because a worker was declared lost.
53#[derive(Clone, Debug, Eq, PartialEq)]
54pub struct LostWorkerReport {
55    /// Lost worker removed from the connected-worker registry.
56    pub worker_id: WorkerId,
57    /// In-flight activities swept off the tracker: surfaced to the engine as
58    /// retryable failures on the `fail_*` paths, or parked for restart
59    /// recovery (nothing recorded, nothing delivered) on the graceful-drain
60    /// `park_*` paths (#207).
61    pub tasks: Vec<InFlightActivity>,
62}
63
64#[derive(Clone, Debug, Eq, Hash, PartialEq)]
65struct TaskKey(WorkerId, WorkflowId, ActivityId);
66
67#[derive(Debug, Default)]
68struct HeartbeatState {
69    tasks: HashMap<TaskKey, TaskLiveness>,
70}
71
72/// Per-task liveness tracker for remote-worker streams.
73#[derive(Clone, Debug)]
74pub struct HeartbeatTracker {
75    heartbeat_window: Duration,
76    inner: Arc<Mutex<HeartbeatState>>,
77    empty: Arc<Notify>,
78}
79
80impl HeartbeatTracker {
81    /// Build a tracker using the operator-supplied heartbeat window.
82    #[must_use]
83    pub fn new(heartbeat_window: Duration) -> Self {
84        Self {
85            heartbeat_window,
86            inner: Arc::new(Mutex::new(HeartbeatState::default())),
87            empty: Arc::new(Notify::new()),
88        }
89    }
90
91    /// Track a newly accepted in-flight activity for heartbeat expiry.
92    ///
93    /// # Errors
94    ///
95    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
96    pub fn track_task(
97        &self,
98        worker_id: WorkerId,
99        task: InFlightActivity,
100        now: Instant,
101    ) -> Result<(), ServerError> {
102        let key = TaskKey::new(
103            worker_id,
104            task.workflow_id.clone(),
105            task.activity_id.clone(),
106        );
107        let liveness = TaskLiveness {
108            worker_id,
109            workflow_id: task.workflow_id,
110            activity_id: task.activity_id,
111            heartbeat_window: self.heartbeat_window,
112            last_heartbeat_at: now,
113            last_progress: None,
114        };
115        self.state()?.tasks.insert(key, liveness);
116        Ok(())
117    }
118
119    /// Stop tracking a completed activity and wake drain waiters if this was the last task.
120    ///
121    /// Returns whether the task was still tracked when this ran: `true` means
122    /// THIS call retired the in-flight entry, `false` means another path (the
123    /// expiry sweep, a disconnect teardown, shutdown, or a completed dispatch)
124    /// already did. The liminal reply router uses that bool as its structural
125    /// gate for synthesizing a lost-worker failure — the exact mirror of the
126    /// gRPC sweep failing only still-tracked tasks.
127    ///
128    /// # Errors
129    ///
130    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
131    pub fn complete_task(
132        &self,
133        worker_id: WorkerId,
134        workflow_id: &WorkflowId,
135        activity_id: &ActivityId,
136    ) -> Result<bool, ServerError> {
137        let key = TaskKey::new(worker_id, workflow_id.clone(), activity_id.clone());
138        let (was_tracked, became_empty) = {
139            let mut state = self.state()?;
140            let was_tracked = state.tasks.remove(&key).is_some();
141            (was_tracked, state.tasks.is_empty())
142        };
143        if became_empty {
144            self.empty.notify_waiters();
145        }
146        Ok(was_tracked)
147    }
148
149    /// Whether the given in-flight task is still tracked (not yet completed,
150    /// swept, or drained). The liminal reply router polls this to bound its
151    /// wait: once the entry is gone the dispatch was resolved by another path,
152    /// so the router exits instead of parking on the connection forever.
153    ///
154    /// # Errors
155    ///
156    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
157    pub fn is_tracked(
158        &self,
159        worker_id: WorkerId,
160        workflow_id: &WorkflowId,
161        activity_id: &ActivityId,
162    ) -> Result<bool, ServerError> {
163        let key = TaskKey::new(worker_id, workflow_id.clone(), activity_id.clone());
164        Ok(self.state()?.tasks.contains_key(&key))
165    }
166
167    /// Refresh the liveness stamp of an in-flight task from a transport-level
168    /// liveness beat that carries no progress payload (the liminal worker's
169    /// automatic pump). Returns `true` when the task was tracked and refreshed,
170    /// `false` when it is not in flight — a benign outcome for a beat racing a
171    /// completion or covering an outbox dispatch the tracker never held.
172    ///
173    /// # Errors
174    ///
175    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
176    pub fn record_liveness(
177        &self,
178        worker_id: WorkerId,
179        workflow_id: &WorkflowId,
180        activity_id: &ActivityId,
181        now: Instant,
182    ) -> Result<bool, ServerError> {
183        let key = TaskKey::new(worker_id, workflow_id.clone(), activity_id.clone());
184        let mut state = self.state()?;
185        let Some(liveness) = state.tasks.get_mut(&key) else {
186            return Ok(false);
187        };
188        liveness.last_heartbeat_at = now;
189        Ok(true)
190    }
191
192    /// The operator-configured heartbeat window this tracker expires against.
193    /// The bridge stamps it onto each liminal dispatch so the worker's
194    /// automatic liveness pump beats at the matching quarter-window cadence.
195    #[must_use]
196    pub const fn heartbeat_window(&self) -> Duration {
197        self.heartbeat_window
198    }
199
200    /// Number of currently tracked in-flight activities.
201    ///
202    /// # Errors
203    ///
204    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
205    pub fn in_flight_count(&self) -> Result<usize, ServerError> {
206        Ok(self.state()?.tasks.len())
207    }
208
209    /// Record a worker heartbeat without completing the activity.
210    ///
211    /// Every heartbeat refreshes the task's liveness stamp. The progress
212    /// payload is only overwritten when the heartbeat CARRIES one: the worker
213    /// runtime's automatic liveness beats are payload-free and interleave
214    /// with explicit handler progress heartbeats, and a liveness beat must
215    /// never erase the handler's most recent progress report.
216    ///
217    /// # Errors
218    ///
219    /// Returns a stable wire error for malformed heartbeats or unknown in-flight tasks.
220    pub fn record_heartbeat(
221        &self,
222        worker_id: WorkerId,
223        heartbeat: ProtoHeartbeat,
224        now: Instant,
225    ) -> Result<HeartbeatUpdate, ServerError> {
226        let decoded = DecodedHeartbeat::try_from(heartbeat)?;
227        let key = TaskKey::new(worker_id, decoded.workflow_id, decoded.activity_id);
228        let mut state = self.state()?;
229        let Some(liveness) = state.tasks.get_mut(&key) else {
230            return Err(wire_error("heartbeat task is not in flight"));
231        };
232        liveness.last_heartbeat_at = now;
233        if decoded.progress.is_some() {
234            liveness.last_progress = decoded.progress;
235        }
236        Ok(HeartbeatUpdate {
237            liveness: liveness.clone(),
238        })
239    }
240
241    /// Return whether an in-flight task is still within its configured heartbeat window.
242    ///
243    /// # Errors
244    ///
245    /// Returns a stable wire error if the task is not tracked, or lock poison if state cannot be trusted.
246    pub fn is_live(
247        &self,
248        worker_id: WorkerId,
249        workflow_id: &WorkflowId,
250        activity_id: &ActivityId,
251        now: Instant,
252    ) -> Result<bool, ServerError> {
253        let key = TaskKey::new(worker_id, workflow_id.clone(), activity_id.clone());
254        let state = self.state()?;
255        let Some(liveness) = state.tasks.get(&key) else {
256            return Err(wire_error("heartbeat task is not in flight"));
257        };
258        Ok(!is_expired(liveness, now))
259    }
260
261    /// Return the workers that have at least one task beyond the configured heartbeat window.
262    ///
263    /// # Errors
264    ///
265    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
266    pub fn expired_workers(&self, now: Instant) -> Result<Vec<WorkerId>, ServerError> {
267        let state = self.state()?;
268        let mut seen = HashSet::new();
269        let mut workers = Vec::new();
270        for liveness in state.tasks.values() {
271            if is_expired(liveness, now) && seen.insert(liveness.worker_id) {
272                workers.push(liveness.worker_id);
273            }
274        }
275        workers.sort_unstable();
276        Ok(workers)
277    }
278
279    /// Mark all currently expired workers lost and fail their in-flight tasks through the engine sink.
280    ///
281    /// # Errors
282    ///
283    /// Returns registry, tracker, or sink errors without retrying or rescheduling activities.
284    pub fn fail_expired_workers(
285        &self,
286        registry: &ConnectedWorkerRegistry,
287        sink: &impl ActivityCompletionSink,
288        now: Instant,
289    ) -> Result<Vec<LostWorkerReport>, ServerError> {
290        let mut reports = Vec::new();
291        for worker_id in self.expired_workers(now)? {
292            let report = self.fail_lost_worker(worker_id, registry, sink)?;
293            if !report.tasks.is_empty() {
294                reports.push(report);
295            }
296        }
297        Ok(reports)
298    }
299
300    /// Mark a disconnected worker lost and fail its in-flight tasks through the engine sink.
301    ///
302    /// # Errors
303    ///
304    /// Returns registry, tracker, or sink errors without retrying or rescheduling activities.
305    pub fn fail_disconnected_worker(
306        &self,
307        worker_id: WorkerId,
308        registry: &ConnectedWorkerRegistry,
309        sink: &impl ActivityCompletionSink,
310    ) -> Result<LostWorkerReport, ServerError> {
311        self.fail_lost_worker(worker_id, registry, sink)
312    }
313
314    /// Mark every currently in-flight worker lost and fail all remaining tasks through the sink.
315    ///
316    /// # Errors
317    ///
318    /// Returns registry, tracker, or sink errors without retrying or rescheduling activities.
319    pub fn fail_all_in_flight_workers(
320        &self,
321        registry: &ConnectedWorkerRegistry,
322        sink: &impl ActivityCompletionSink,
323    ) -> Result<Vec<LostWorkerReport>, ServerError> {
324        let worker_ids = {
325            let state = self.state()?;
326            let mut worker_ids = state
327                .tasks
328                .values()
329                .map(|liveness| liveness.worker_id)
330                .collect::<HashSet<_>>()
331                .into_iter()
332                .collect::<Vec<_>>();
333            worker_ids.sort_unstable();
334            worker_ids
335        };
336        let mut reports = Vec::new();
337        for worker_id in worker_ids {
338            let report = self.fail_lost_worker(worker_id, registry, sink)?;
339            if !report.tasks.is_empty() {
340                reports.push(report);
341            }
342        }
343        self.empty.notify_waiters();
344        Ok(reports)
345    }
346
347    /// Park a drain-disconnected worker's in-flight tasks for restart recovery
348    /// (#207): deregister the worker, remove its tracked tasks, and resolve
349    /// each pending waiter through [`ActivityCompletionSink::park_activity`].
350    ///
351    /// The graceful-drain counterpart of [`Self::fail_disconnected_worker`]:
352    /// same deregister-before-collect ordering (same closed dispatch/disconnect
353    /// race), but NO completion is synthesized — the durable log keeps its
354    /// dangling scheduled/started trail, byte-equivalent to a kill -9, and
355    /// restart recovery re-dispatches it. Deregistered with the honest
356    /// [`WorkerDeathReason::Disconnect`](aion_core::WorkerDeathReason::Disconnect):
357    /// the transport genuinely dropped (the worker obeyed the drain request).
358    ///
359    /// # Errors
360    ///
361    /// Returns registry, tracker, or sink errors without retrying or rescheduling activities.
362    pub fn park_disconnected_worker(
363        &self,
364        worker_id: WorkerId,
365        registry: &ConnectedWorkerRegistry,
366        sink: &impl ActivityCompletionSink,
367    ) -> Result<LostWorkerReport, ServerError> {
368        self.park_lost_worker(
369            worker_id,
370            registry,
371            sink,
372            aion_core::WorkerDeathReason::Disconnect,
373        )
374    }
375
376    /// Park EVERY currently in-flight worker's tasks for restart recovery
377    /// (#207) — the drain-timeout backstop's bulk counterpart of
378    /// [`Self::fail_all_in_flight_workers`].
379    ///
380    /// Deregistered with the honest
381    /// [`WorkerDeathReason::Timeout`](aion_core::WorkerDeathReason::Timeout):
382    /// the drain window genuinely expired on these workers. Wakes drain waiters
383    /// after the sweep so `wait_for_empty` observes the emptied tracker.
384    ///
385    /// # Errors
386    ///
387    /// Returns registry, tracker, or sink errors without retrying or rescheduling activities.
388    pub fn park_all_in_flight_workers(
389        &self,
390        registry: &ConnectedWorkerRegistry,
391        sink: &impl ActivityCompletionSink,
392    ) -> Result<Vec<LostWorkerReport>, ServerError> {
393        let worker_ids = {
394            let state = self.state()?;
395            let mut worker_ids = state
396                .tasks
397                .values()
398                .map(|liveness| liveness.worker_id)
399                .collect::<HashSet<_>>()
400                .into_iter()
401                .collect::<Vec<_>>();
402            worker_ids.sort_unstable();
403            worker_ids
404        };
405        let mut reports = Vec::new();
406        for worker_id in worker_ids {
407            let report = self.park_lost_worker(
408                worker_id,
409                registry,
410                sink,
411                aion_core::WorkerDeathReason::Timeout,
412            )?;
413            if !report.tasks.is_empty() {
414                reports.push(report);
415            }
416        }
417        self.empty.notify_waiters();
418        Ok(reports)
419    }
420
421    /// Shared park core (#207), structured exactly like [`Self::fail_lost_worker`]
422    /// — deregister BEFORE collecting tasks (see that method's race note) — but
423    /// resolving each waiter with the ephemeral parked sentinel instead of
424    /// synthesizing a lost-worker `ActivityFailed`. Idempotent for the same
425    /// reasons: `deregister_with_reason` no-ops on an already-removed worker and
426    /// each task is removed as it parks, so a second sweep (park or fail) sees
427    /// an empty report and resolves nothing.
428    fn park_lost_worker(
429        &self,
430        worker_id: WorkerId,
431        registry: &ConnectedWorkerRegistry,
432        sink: &impl ActivityCompletionSink,
433        reason: aion_core::WorkerDeathReason,
434    ) -> Result<LostWorkerReport, ServerError> {
435        registry.deregister_with_reason(worker_id, reason)?;
436        let tasks = self.remove_worker_tasks(worker_id)?;
437        for task in &tasks {
438            sink.park_activity(&task.workflow_id, &task.activity_id)?;
439            info!(
440                worker_id = ?worker_id,
441                workflow_id = %task.workflow_id,
442                activity_id = %task.activity_id,
443                "activity parked for restart recovery"
444            );
445        }
446        Ok(LostWorkerReport { worker_id, tasks })
447    }
448
449    fn fail_lost_worker(
450        &self,
451        worker_id: WorkerId,
452        registry: &ConnectedWorkerRegistry,
453        sink: &impl ActivityCompletionSink,
454    ) -> Result<LostWorkerReport, ServerError> {
455        // Deregister BEFORE collecting tasks: the dispatch path tracks its
456        // task, sends, and then checks `registry.is_registered`. With this
457        // ordering, a dispatch that still sees the worker registered is
458        // guaranteed its tracked task is visible to any later sweep, so the
459        // unbounded completion wait always gets a lost-worker failure. (The
460        // reverse order leaves a window where a task tracked between the
461        // collection and the deregistration is never failed by anyone.)
462        // This is the liveness-timeout sweep: the proven reason is Timeout, the
463        // one finer-grained WS3 distinction this call site can honestly assert.
464        registry.deregister_with_reason(worker_id, aion_core::WorkerDeathReason::Timeout)?;
465        let tasks = self.remove_worker_tasks(worker_id)?;
466        for task in &tasks {
467            sink.complete_activity(ActivityCompletion {
468                workflow_id: task.workflow_id.clone(),
469                activity_id: task.activity_id.clone(),
470                run_id: None,
471                outcome: ActivityCompletionOutcome::Failed(lost_worker_error(worker_id)),
472            })?;
473        }
474        Ok(LostWorkerReport { worker_id, tasks })
475    }
476
477    fn remove_worker_tasks(
478        &self,
479        worker_id: WorkerId,
480    ) -> Result<Vec<InFlightActivity>, ServerError> {
481        let mut state = self.state()?;
482        let keys = state
483            .tasks
484            .keys()
485            .filter(|key| key.worker_id() == worker_id)
486            .cloned()
487            .collect::<Vec<_>>();
488        let mut tasks = Vec::with_capacity(keys.len());
489        for key in keys {
490            if let Some(liveness) = state.tasks.remove(&key) {
491                tasks.push(InFlightActivity {
492                    workflow_id: liveness.workflow_id,
493                    activity_id: liveness.activity_id,
494                });
495            }
496        }
497        Ok(tasks)
498    }
499
500    fn state(&self) -> Result<MutexGuard<'_, HeartbeatState>, ServerError> {
501        self.inner
502            .lock()
503            .map_err(|_| ServerError::lock_poisoned("worker heartbeat tracker"))
504    }
505}
506
507/// Sweep cadence derived from the operator's `worker.heartbeat_window`: a
508/// quarter of the window, clamped to `[1s, window]` (the default 30s window
509/// sweeps every 7.5s).
510///
511/// Deliberately derived rather than a separate config knob: the window is the
512/// operational contract ("a silent worker is dead after this long"), and the
513/// sweep cadence is an implementation detail of enforcing it — a quarter-window
514/// cadence bounds detection latency at `window + window/4` while keeping the
515/// sweep cheap. A window shorter than one second (test configurations) sweeps
516/// once per window rather than sub-second-spinning, and a zero window is
517/// floored at one millisecond because `tokio::time::interval` rejects a zero
518/// period.
519#[must_use]
520pub fn sweep_interval(heartbeat_window: Duration) -> Duration {
521    /// `tokio::time::interval` panics on a zero period, so even a
522    /// (misconfigured) zero window gets a positive cadence.
523    const MINIMUM_PERIOD: Duration = Duration::from_millis(1);
524    /// Target lower bound: sweeping more often than once a second buys no
525    /// meaningful detection latency against real heartbeat windows.
526    const TARGET_FLOOR: Duration = Duration::from_secs(1);
527    let ceiling = heartbeat_window.max(MINIMUM_PERIOD);
528    // The floor never exceeds the ceiling, so `clamp` cannot panic.
529    (heartbeat_window / 4).clamp(TARGET_FLOOR.min(ceiling), ceiling)
530}
531
532/// Production driver of [`HeartbeatTracker::fail_expired_workers`] (#176).
533///
534/// The tracker records per-task liveness, and the stream-teardown sweep fails a
535/// worker whose stream ENDS — but a worker whose stream stays open while its
536/// process wedges (stops heartbeating without disconnecting) was never expired
537/// by anything on the boot path, so its in-flight dispatches waited forever.
538/// This interval task is that missing caller: each tick fails every worker with
539/// a task beyond its heartbeat window, deregistering it with the provable
540/// [`WorkerDeathReason::Timeout`](aion_core::WorkerDeathReason::Timeout) and
541/// surfacing its tasks as retryable lost-worker failures through the shared
542/// completion sink. It shares the server's shutdown watch, so it drains with
543/// the transports (mirroring
544/// [`OutboxDispatcher::run`](crate::worker::OutboxDispatcher::run)).
545///
546/// Double-fail safety: this sweep and the stream-teardown path
547/// ([`HeartbeatTracker::fail_disconnected_worker`]) can both observe the same
548/// dead worker. Both funnel into the same idempotent core —
549/// `deregister_with_reason` is a no-op for an already-removed worker (no
550/// duplicate WS3 delta, no metrics double-count) and the tracker removes each
551/// task as it fails it — so whichever path runs second sees an empty report and
552/// never double-completes an activity.
553pub struct HeartbeatSweeper<S> {
554    tracker: HeartbeatTracker,
555    registry: ConnectedWorkerRegistry,
556    sink: S,
557    drain: DrainState,
558    heartbeat_window: Duration,
559    interval: Duration,
560}
561
562impl<S> HeartbeatSweeper<S>
563where
564    S: ActivityCompletionSink + Send + Sync + 'static,
565{
566    /// Build a sweeper over the server's shared liveness tracker, worker
567    /// registry, completion sink, and drain gate. The cadence is derived from
568    /// `heartbeat_window` by [`sweep_interval`].
569    #[must_use]
570    pub fn new(
571        tracker: HeartbeatTracker,
572        registry: ConnectedWorkerRegistry,
573        sink: S,
574        drain: DrainState,
575        heartbeat_window: Duration,
576    ) -> Self {
577        let interval = sweep_interval(heartbeat_window);
578        Self {
579            tracker,
580            registry,
581            sink,
582            drain,
583            heartbeat_window,
584            interval,
585        }
586    }
587
588    /// Run the expiry sweep until `shutdown` flips to `true`.
589    ///
590    /// A tracker/registry error during a sweep is logged and retried next tick
591    /// rather than tearing the task down — a transient failure must not
592    /// silently stop dead-worker detection. Shutdown is observed both while
593    /// waiting for the next tick and re-checked before each sweep, exactly like
594    /// the outbox dispatcher's run loop.
595    pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
596        info!(
597            sweep_interval_ms = self.interval.as_millis(),
598            heartbeat_window_ms = self.heartbeat_window.as_millis(),
599            "worker heartbeat sweeper started"
600        );
601        let mut ticks = tokio::time::interval(self.interval);
602        ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
603        loop {
604            tokio::select! {
605                _ = ticks.tick() => {
606                    if *shutdown.borrow() {
607                        break;
608                    }
609                    self.sweep_once(Instant::now());
610                }
611                changed = shutdown.changed() => {
612                    // A receive error means every sender dropped; treat that as
613                    // a shutdown request rather than spinning.
614                    if changed.is_err() || *shutdown.borrow() {
615                        break;
616                    }
617                }
618            }
619        }
620        info!("worker heartbeat sweeper stopped");
621    }
622
623    /// Fail every currently-expired worker once, logging each lost-worker
624    /// report at warn (mirroring the stream-teardown sweep's logging).
625    fn sweep_once(&self, now: Instant) {
626        let reports = match self
627            .tracker
628            .fail_expired_workers(&self.registry, &self.sink, now)
629        {
630            Ok(reports) => reports,
631            Err(sweep_error) => {
632                error!(
633                    error = %sweep_error,
634                    "heartbeat expiry sweep failed; retrying next tick"
635                );
636                return;
637            }
638        };
639        for report in &reports {
640            warn!(
641                worker_id = ?report.worker_id,
642                failed_tasks = report.tasks.len(),
643                "worker heartbeat window expired with in-flight activities; \
644                 deregistered and surfaced as retryable lost-worker failures"
645            );
646        }
647        if !reports.is_empty() {
648            // In-flight accounting may have just reached zero; wake any drain
649            // waiter so shutdown does not sit out its full timeout (mirrors
650            // the stream-teardown sweep).
651            self.drain.notify_activity_drained();
652        }
653    }
654}
655
656impl TaskKey {
657    fn new(worker_id: WorkerId, workflow_id: WorkflowId, activity_id: ActivityId) -> Self {
658        Self(worker_id, workflow_id, activity_id)
659    }
660
661    const fn worker_id(&self) -> WorkerId {
662        self.0
663    }
664}
665
666struct DecodedHeartbeat {
667    workflow_id: WorkflowId,
668    activity_id: ActivityId,
669    progress: Option<Payload>,
670}
671
672impl TryFrom<ProtoHeartbeat> for DecodedHeartbeat {
673    type Error = ServerError;
674
675    fn try_from(value: ProtoHeartbeat) -> Result<Self, Self::Error> {
676        let workflow_id = value
677            .workflow_id
678            .ok_or_else(|| wire_error("heartbeat workflow id is missing"))
679            .and_then(|id| WorkflowId::try_from(id).map_err(ServerError::from))?;
680        let activity_id = value
681            .activity_id
682            .ok_or_else(|| wire_error("heartbeat activity id is missing"))
683            .map(ActivityId::from)?;
684        let progress = value
685            .progress
686            .map(Payload::try_from)
687            .transpose()
688            .map_err(ServerError::from)?;
689        Ok(Self {
690            workflow_id,
691            activity_id,
692            progress,
693        })
694    }
695}
696
697fn is_expired(liveness: &TaskLiveness, now: Instant) -> bool {
698    now.checked_duration_since(liveness.last_heartbeat_at)
699        .is_some_and(|elapsed| elapsed > liveness.heartbeat_window)
700}
701
702fn wire_error(message: &'static str) -> ServerError {
703    ServerError::Wire {
704        wire: WireError::backend(message),
705    }
706}
707
708#[cfg(test)]
709mod tests {
710    use std::sync::Mutex;
711
712    use aion_core::{ActivityErrorKind, ContentType};
713    use aion_proto::{ProtoActivityId, ProtoPayload, ProtoWorkflowId};
714    use serde_json::json;
715    use uuid::Uuid;
716
717    use crate::worker::registry::WorkerRegistration;
718
719    use super::*;
720
721    #[derive(Default)]
722    struct RecordingSink {
723        completions: Mutex<Vec<ActivityCompletion>>,
724        parks: Mutex<Vec<(WorkflowId, ActivityId)>>,
725    }
726
727    impl ActivityCompletionSink for RecordingSink {
728        fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError> {
729            self.completions
730                .lock()
731                .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
732                .push(completion);
733            Ok(())
734        }
735
736        fn park_activity(
737            &self,
738            workflow_id: &WorkflowId,
739            activity_id: &ActivityId,
740        ) -> Result<(), ServerError> {
741            self.parks
742                .lock()
743                .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
744                .push((workflow_id.clone(), activity_id.clone()));
745            Ok(())
746        }
747    }
748
749    fn workflow_id() -> WorkflowId {
750        WorkflowId::new(Uuid::nil())
751    }
752
753    fn activity_id(position: u64) -> ActivityId {
754        ActivityId::from_sequence_position(position)
755    }
756
757    fn payload(value: &serde_json::Value) -> Result<Payload, Box<dyn std::error::Error>> {
758        Ok(Payload::from_json(value)?)
759    }
760
761    fn heartbeat(
762        workflow_id: WorkflowId,
763        activity_id: ActivityId,
764        progress: Option<Payload>,
765    ) -> ProtoHeartbeat {
766        ProtoHeartbeat {
767            workflow_id: Some(ProtoWorkflowId::from(workflow_id)),
768            activity_id: Some(ProtoActivityId::from(activity_id)),
769            progress: progress.map(ProtoPayload::from),
770        }
771    }
772
773    fn registry_with_worker()
774    -> Result<(ConnectedWorkerRegistry, WorkerRegistration, WorkerId), ServerError> {
775        let registry = ConnectedWorkerRegistry::default();
776        let (tx, _rx) = tokio::sync::mpsc::channel(1);
777        let activity_types = [String::from("charge-card")];
778        let registration = registry.register("tenant-a", activity_types.iter(), tx)?;
779        let worker_id = registration
780            .worker_id()
781            .ok_or_else(|| ServerError::lock_poisoned("test worker registration"))?;
782        Ok((registry, registration, worker_id))
783    }
784
785    #[test]
786    fn heartbeat_refresh_keeps_task_live_across_window() -> Result<(), Box<dyn std::error::Error>> {
787        let window = Duration::from_secs(5);
788        let tracker = HeartbeatTracker::new(window);
789        let worker_id = WorkerIdForTest::registered()?;
790        let workflow_id = workflow_id();
791        let activity_id = activity_id(10);
792        let start = Instant::now();
793
794        tracker.track_task(
795            worker_id,
796            InFlightActivity {
797                workflow_id: workflow_id.clone(),
798                activity_id: activity_id.clone(),
799            },
800            start,
801        )?;
802        assert!(tracker.is_live(worker_id, &workflow_id, &activity_id, start + window)?);
803
804        let progress = payload(&json!({"percent": 50}))?;
805        let update = tracker.record_heartbeat(
806            worker_id,
807            heartbeat(
808                workflow_id.clone(),
809                activity_id.clone(),
810                Some(progress.clone()),
811            ),
812            start + window,
813        )?;
814
815        assert_eq!(update.liveness.last_progress, Some(progress));
816        assert!(tracker.is_live(
817            worker_id,
818            &workflow_id,
819            &activity_id,
820            start + window + window
821        )?);
822        assert!(tracker.expired_workers(start + window + window)?.is_empty());
823        Ok(())
824    }
825
826    #[test]
827    fn missed_heartbeat_deregisters_worker_and_fails_in_flight_once()
828    -> Result<(), Box<dyn std::error::Error>> {
829        let (registry, _registration, worker_id) = registry_with_worker()?;
830        let sink = RecordingSink::default();
831        let tracker = HeartbeatTracker::new(Duration::from_secs(5));
832        let workflow_id = workflow_id();
833        let activity_id = activity_id(11);
834        let start = Instant::now();
835
836        tracker.track_task(
837            worker_id,
838            InFlightActivity {
839                workflow_id: workflow_id.clone(),
840                activity_id: activity_id.clone(),
841            },
842            start,
843        )?;
844
845        let reports =
846            tracker.fail_expired_workers(&registry, &sink, start + Duration::from_secs(6))?;
847        assert_eq!(reports.len(), 1);
848        assert_eq!(reports[0].worker_id, worker_id);
849        assert_eq!(reports[0].tasks.len(), 1);
850        assert!(
851            registry
852                .workers_for("tenant-a", "default", "charge-card", None)?
853                .is_empty()
854        );
855
856        let second = tracker.fail_disconnected_worker(worker_id, &registry, &sink)?;
857        assert!(second.tasks.is_empty());
858        let completions = sink
859            .completions
860            .lock()
861            .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
862        assert_eq!(completions.len(), 1);
863        assert_eq!(completions[0].workflow_id, workflow_id);
864        assert_eq!(completions[0].activity_id, activity_id);
865        match &completions[0].outcome {
866            ActivityCompletionOutcome::Failed(error) => {
867                assert_eq!(error.kind, ActivityErrorKind::Retryable);
868                assert!(error.is_retryable());
869            }
870            ActivityCompletionOutcome::Succeeded(_) => {
871                return Err("expected lost-worker failure".into());
872            }
873        }
874        Ok(())
875    }
876
877    #[test]
878    fn disconnected_worker_fails_each_in_flight_task_once() -> Result<(), Box<dyn std::error::Error>>
879    {
880        let (registry, _registration, worker_id) = registry_with_worker()?;
881        let sink = RecordingSink::default();
882        let tracker = HeartbeatTracker::new(Duration::from_secs(5));
883        let workflow_id = workflow_id();
884        let start = Instant::now();
885
886        tracker.track_task(
887            worker_id,
888            InFlightActivity {
889                workflow_id: workflow_id.clone(),
890                activity_id: activity_id(21),
891            },
892            start,
893        )?;
894        tracker.track_task(
895            worker_id,
896            InFlightActivity {
897                workflow_id,
898                activity_id: activity_id(22),
899            },
900            start,
901        )?;
902
903        let report = tracker.fail_disconnected_worker(worker_id, &registry, &sink)?;
904        assert_eq!(report.tasks.len(), 2);
905        assert!(
906            registry
907                .workers_for("tenant-a", "default", "charge-card", None)?
908                .is_empty()
909        );
910
911        let completions = sink
912            .completions
913            .lock()
914            .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
915        assert_eq!(completions.len(), 2);
916        assert!(completions.iter().all(|completion| matches!(
917            &completion.outcome,
918            ActivityCompletionOutcome::Failed(error)
919                if error.kind == ActivityErrorKind::Retryable && error.is_retryable()
920        )));
921        Ok(())
922    }
923
924    /// #207: parking a drain-disconnected worker removes its tasks, deregisters
925    /// it, and PARKS each task through the sink — zero completions synthesized,
926    /// so the durable log stays byte-equivalent to a kill -9. A second park (or
927    /// a later fail sweep) finds nothing: the idempotent-deregister discipline
928    /// the fail path already proves holds for parks too.
929    #[test]
930    fn park_disconnected_worker_parks_tasks_without_synthesizing_completions()
931    -> Result<(), Box<dyn std::error::Error>> {
932        let (registry, _registration, worker_id) = registry_with_worker()?;
933        let sink = RecordingSink::default();
934        let tracker = HeartbeatTracker::new(Duration::from_secs(5));
935        let workflow_id = workflow_id();
936        let start = Instant::now();
937        tracker.track_task(
938            worker_id,
939            InFlightActivity {
940                workflow_id: workflow_id.clone(),
941                activity_id: activity_id(60),
942            },
943            start,
944        )?;
945        tracker.track_task(
946            worker_id,
947            InFlightActivity {
948                workflow_id: workflow_id.clone(),
949                activity_id: activity_id(61),
950            },
951            start,
952        )?;
953
954        let report = tracker.park_disconnected_worker(worker_id, &registry, &sink)?;
955        assert_eq!(report.tasks.len(), 2);
956        assert_eq!(
957            tracker.in_flight_count()?,
958            0,
959            "parking must remove every tracked task so drain accounting reaches zero"
960        );
961        assert!(
962            registry
963                .workers_for("tenant-a", "default", "charge-card", None)?
964                .is_empty(),
965            "the parked worker must be deregistered from routing"
966        );
967        let parks = sink
968            .parks
969            .lock()
970            .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
971        assert_eq!(parks.len(), 2, "each task must be parked exactly once");
972        drop(parks);
973        assert!(
974            sink.completions
975                .lock()
976                .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
977                .is_empty(),
978            "parking must never synthesize an activity completion"
979        );
980
981        // Double-park and park-after-fail are no-ops: the idempotent core.
982        let second = tracker.park_disconnected_worker(worker_id, &registry, &sink)?;
983        assert!(second.tasks.is_empty());
984        let third = tracker.fail_disconnected_worker(worker_id, &registry, &sink)?;
985        assert!(third.tasks.is_empty());
986        assert_eq!(
987            sink.parks
988                .lock()
989                .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
990                .len(),
991            2,
992            "re-sweeping a parked worker must park nothing further"
993        );
994        assert!(
995            sink.completions
996                .lock()
997                .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
998                .is_empty(),
999            "a fail sweep after the park must fail nothing"
1000        );
1001        Ok(())
1002    }
1003
1004    /// #207 drain-timeout backstop: the bulk park removes every worker's tasks,
1005    /// parks each through the sink, and wakes drain waiters — never
1006    /// synthesizing a completion.
1007    #[tokio::test]
1008    async fn park_all_in_flight_workers_parks_everything_and_wakes_drain_waiters()
1009    -> Result<(), Box<dyn std::error::Error>> {
1010        let (registry, _registration, worker_id) = registry_with_worker()?;
1011        let sink = RecordingSink::default();
1012        let tracker = HeartbeatTracker::new(Duration::from_secs(5));
1013        let workflow_id = workflow_id();
1014        tracker.track_task(
1015            worker_id,
1016            InFlightActivity {
1017                workflow_id: workflow_id.clone(),
1018                activity_id: activity_id(70),
1019            },
1020            Instant::now(),
1021        )?;
1022        // Arm a waiter on the tracker's empty notify BEFORE the bulk park.
1023        let notified = tracker.empty.notified();
1024        tokio::pin!(notified);
1025
1026        let reports = tracker.park_all_in_flight_workers(&registry, &sink)?;
1027        assert_eq!(reports.len(), 1);
1028        assert_eq!(reports[0].worker_id, worker_id);
1029        assert_eq!(reports[0].tasks.len(), 1);
1030        assert_eq!(tracker.in_flight_count()?, 0);
1031        assert_eq!(
1032            sink.parks
1033                .lock()
1034                .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
1035                .len(),
1036            1
1037        );
1038        assert!(
1039            sink.completions
1040                .lock()
1041                .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
1042                .is_empty(),
1043            "the bulk park must never synthesize a completion"
1044        );
1045        tokio::time::timeout(Duration::from_millis(200), notified)
1046            .await
1047            .map_err(|_| "the bulk park must wake drain waiters")?;
1048        Ok(())
1049    }
1050
1051    /// The worker runtime's AUTOMATIC liveness beats carry no payload and
1052    /// interleave with explicit handler progress heartbeats: a payload-free
1053    /// beat must refresh the liveness stamp WITHOUT erasing the handler's
1054    /// most recent progress report.
1055    #[test]
1056    fn payload_free_heartbeat_refreshes_liveness_without_clearing_progress()
1057    -> Result<(), Box<dyn std::error::Error>> {
1058        let window = Duration::from_secs(5);
1059        let tracker = HeartbeatTracker::new(window);
1060        let worker_id = WorkerIdForTest::registered()?;
1061        let workflow_id = workflow_id();
1062        let activity_id = activity_id(12);
1063        let start = Instant::now();
1064
1065        tracker.track_task(
1066            worker_id,
1067            InFlightActivity {
1068                workflow_id: workflow_id.clone(),
1069                activity_id: activity_id.clone(),
1070            },
1071            start,
1072        )?;
1073        let progress = payload(&json!({"percent": 80}))?;
1074        tracker.record_heartbeat(
1075            worker_id,
1076            heartbeat(
1077                workflow_id.clone(),
1078                activity_id.clone(),
1079                Some(progress.clone()),
1080            ),
1081            start + Duration::from_secs(1),
1082        )?;
1083
1084        // An automatic liveness beat: no payload, later timestamp.
1085        let update = tracker.record_heartbeat(
1086            worker_id,
1087            heartbeat(workflow_id.clone(), activity_id.clone(), None),
1088            start + Duration::from_secs(4),
1089        )?;
1090
1091        assert_eq!(
1092            update.liveness.last_progress,
1093            Some(progress),
1094            "a payload-free liveness beat must not erase handler progress"
1095        );
1096        assert!(
1097            tracker.is_live(
1098                worker_id,
1099                &workflow_id,
1100                &activity_id,
1101                start + Duration::from_secs(8)
1102            )?,
1103            "the payload-free beat must still refresh the liveness stamp"
1104        );
1105        Ok(())
1106    }
1107
1108    #[test]
1109    fn malformed_heartbeat_missing_ids_is_wire_error() -> Result<(), Box<dyn std::error::Error>> {
1110        let worker_id = WorkerIdForTest::registered()?;
1111        let tracker = HeartbeatTracker::new(Duration::from_secs(5));
1112        let missing = ProtoHeartbeat {
1113            workflow_id: None,
1114            activity_id: Some(ProtoActivityId::from(activity_id(30))),
1115            progress: None,
1116        };
1117
1118        let result = tracker.record_heartbeat(worker_id, missing, Instant::now());
1119        assert!(matches!(result, Err(ServerError::Wire { .. })));
1120        Ok(())
1121    }
1122
1123    #[test]
1124    fn heartbeat_progress_is_not_reported_as_activity_result()
1125    -> Result<(), Box<dyn std::error::Error>> {
1126        let sink = RecordingSink::default();
1127        let worker_id = WorkerIdForTest::registered()?;
1128        let tracker = HeartbeatTracker::new(Duration::from_secs(5));
1129        let workflow_id = workflow_id();
1130        let activity_id = activity_id(40);
1131        let now = Instant::now();
1132
1133        tracker.track_task(
1134            worker_id,
1135            InFlightActivity {
1136                workflow_id: workflow_id.clone(),
1137                activity_id: activity_id.clone(),
1138            },
1139            now,
1140        )?;
1141        tracker.record_heartbeat(
1142            worker_id,
1143            heartbeat(
1144                workflow_id,
1145                activity_id,
1146                Some(Payload::new(
1147                    ContentType::Json,
1148                    b"{\"progress\":1}".to_vec(),
1149                )),
1150            ),
1151            now,
1152        )?;
1153
1154        let completions = sink
1155            .completions
1156            .lock()
1157            .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
1158        assert!(completions.is_empty());
1159        Ok(())
1160    }
1161
1162    struct WorkerIdForTest;
1163
1164    impl WorkerIdForTest {
1165        fn registered() -> Result<WorkerId, ServerError> {
1166            let (_registry, _registration, worker_id) = registry_with_worker()?;
1167            Ok(worker_id)
1168        }
1169    }
1170
1171    /// `complete_task` reports whether THIS call retired the entry — the
1172    /// structural gate the liminal reply router uses to synthesize a
1173    /// lost-worker failure only for a dispatch nobody else resolved.
1174    #[test]
1175    fn complete_task_reports_whether_the_entry_was_tracked()
1176    -> Result<(), Box<dyn std::error::Error>> {
1177        let tracker = HeartbeatTracker::new(Duration::from_secs(5));
1178        let worker_id = WorkerIdForTest::registered()?;
1179        let workflow_id = workflow_id();
1180        let id = activity_id(50);
1181        tracker.track_task(
1182            worker_id,
1183            InFlightActivity {
1184                workflow_id: workflow_id.clone(),
1185                activity_id: id.clone(),
1186            },
1187            Instant::now(),
1188        )?;
1189
1190        assert!(tracker.is_tracked(worker_id, &workflow_id, &id)?);
1191        assert!(
1192            tracker.complete_task(worker_id, &workflow_id, &id)?,
1193            "the first completion retires the tracked entry"
1194        );
1195        assert!(!tracker.is_tracked(worker_id, &workflow_id, &id)?);
1196        assert!(
1197            !tracker.complete_task(worker_id, &workflow_id, &id)?,
1198            "a second completion finds nothing to retire"
1199        );
1200        Ok(())
1201    }
1202
1203    /// A liveness beat (the liminal worker's automatic pump) refreshes the
1204    /// task's expiry stamp — keeping a genuinely-running over-window activity
1205    /// out of the sweep — and reports an untracked task benignly.
1206    #[test]
1207    fn record_liveness_refreshes_stamp_and_ignores_untracked_tasks()
1208    -> Result<(), Box<dyn std::error::Error>> {
1209        let window = Duration::from_secs(5);
1210        let tracker = HeartbeatTracker::new(window);
1211        let worker_id = WorkerIdForTest::registered()?;
1212        let workflow_id = workflow_id();
1213        let id = activity_id(51);
1214        let start = Instant::now();
1215        tracker.track_task(
1216            worker_id,
1217            InFlightActivity {
1218                workflow_id: workflow_id.clone(),
1219                activity_id: id.clone(),
1220            },
1221            start,
1222        )?;
1223
1224        // Beaten at the window edge, the task survives past the original expiry.
1225        assert!(tracker.record_liveness(worker_id, &workflow_id, &id, start + window)?);
1226        assert!(tracker.is_live(worker_id, &workflow_id, &id, start + window + window)?);
1227        assert!(tracker.expired_workers(start + window + window)?.is_empty());
1228
1229        // An untracked beat (an outbox dispatch, or a beat racing completion)
1230        // is a benign false, never an error.
1231        assert!(!tracker.record_liveness(
1232            worker_id,
1233            &workflow_id,
1234            &activity_id(52),
1235            start + window
1236        )?);
1237        Ok(())
1238    }
1239
1240    #[test]
1241    fn sweep_interval_is_quarter_window_clamped_to_one_second_and_window() {
1242        // The default 30s window sweeps every 7.5s (quarter-window).
1243        assert_eq!(
1244            sweep_interval(Duration::from_secs(30)),
1245            Duration::from_millis(7_500)
1246        );
1247        // A short window's quarter (500ms) is floored at 1s.
1248        assert_eq!(
1249            sweep_interval(Duration::from_secs(2)),
1250            Duration::from_secs(1)
1251        );
1252        // A very long window's quarter stays within the [1s, window] band.
1253        assert_eq!(
1254            sweep_interval(Duration::from_secs(3_600)),
1255            Duration::from_secs(900)
1256        );
1257        // A sub-second (test) window sweeps once per window, never spinning
1258        // sub-window nor waiting longer than the window itself.
1259        assert_eq!(
1260            sweep_interval(Duration::from_millis(200)),
1261            Duration::from_millis(200)
1262        );
1263        // A zero window is floored at the minimum positive period rather than
1264        // producing the zero interval `tokio::time::interval` rejects.
1265        assert_eq!(sweep_interval(Duration::ZERO), Duration::from_millis(1));
1266    }
1267}