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