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