1use std::collections::{HashMap, HashSet};
4use std::sync::{Arc, Mutex, MutexGuard};
5use std::time::{Duration, Instant};
6use tokio::sync::{Notify, watch};
7use tracing::{error, info, warn};
8
9use aion_core::{ActivityId, Payload, WorkflowId};
10use aion_proto::{ProtoHeartbeat, WireError};
11
12use crate::error::ServerError;
13use crate::shutdown::DrainState;
14use crate::worker::dispatch::{
15 ActivityCompletion, ActivityCompletionOutcome, ActivityCompletionSink, lost_worker_error,
16};
17use crate::worker::registry::{ConnectedWorkerRegistry, WorkerId};
18
19#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct InFlightActivity {
22 pub workflow_id: WorkflowId,
24 pub activity_id: ActivityId,
26}
27
28#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct TaskLiveness {
31 pub worker_id: WorkerId,
33 pub workflow_id: WorkflowId,
35 pub activity_id: ActivityId,
37 pub heartbeat_window: Duration,
39 pub last_heartbeat_at: Instant,
41 pub last_progress: Option<Payload>,
43}
44
45#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct HeartbeatUpdate {
48 pub liveness: TaskLiveness,
50}
51
52#[derive(Clone, Debug, Eq, PartialEq)]
54pub struct LostWorkerReport {
55 pub worker_id: WorkerId,
57 pub tasks: Vec<InFlightActivity>,
62}
63
64#[derive(Clone, Debug, Eq, Hash, PartialEq)]
65struct TaskKey(WorkerId, WorkflowId, ActivityId);
66
67#[derive(Debug, Default)]
68struct HeartbeatState {
69 tasks: HashMap<TaskKey, TaskLiveness>,
70}
71
72#[derive(Clone, Debug)]
74pub struct HeartbeatTracker {
75 heartbeat_window: Duration,
76 inner: Arc<Mutex<HeartbeatState>>,
77 empty: Arc<Notify>,
78}
79
80impl HeartbeatTracker {
81 #[must_use]
83 pub fn new(heartbeat_window: Duration) -> Self {
84 Self {
85 heartbeat_window,
86 inner: Arc::new(Mutex::new(HeartbeatState::default())),
87 empty: Arc::new(Notify::new()),
88 }
89 }
90
91 pub fn track_task(
97 &self,
98 worker_id: WorkerId,
99 task: InFlightActivity,
100 now: Instant,
101 ) -> Result<(), ServerError> {
102 let key = TaskKey::new(
103 worker_id,
104 task.workflow_id.clone(),
105 task.activity_id.clone(),
106 );
107 let liveness = TaskLiveness {
108 worker_id,
109 workflow_id: task.workflow_id,
110 activity_id: task.activity_id,
111 heartbeat_window: self.heartbeat_window,
112 last_heartbeat_at: now,
113 last_progress: None,
114 };
115 self.state()?.tasks.insert(key, liveness);
116 Ok(())
117 }
118
119 pub fn complete_task(
132 &self,
133 worker_id: WorkerId,
134 workflow_id: &WorkflowId,
135 activity_id: &ActivityId,
136 ) -> Result<bool, ServerError> {
137 let key = TaskKey::new(worker_id, workflow_id.clone(), activity_id.clone());
138 let (was_tracked, became_empty) = {
139 let mut state = self.state()?;
140 let was_tracked = state.tasks.remove(&key).is_some();
141 (was_tracked, state.tasks.is_empty())
142 };
143 if became_empty {
144 self.empty.notify_waiters();
145 }
146 Ok(was_tracked)
147 }
148
149 pub fn is_tracked(
158 &self,
159 worker_id: WorkerId,
160 workflow_id: &WorkflowId,
161 activity_id: &ActivityId,
162 ) -> Result<bool, ServerError> {
163 let key = TaskKey::new(worker_id, workflow_id.clone(), activity_id.clone());
164 Ok(self.state()?.tasks.contains_key(&key))
165 }
166
167 pub fn record_liveness(
177 &self,
178 worker_id: WorkerId,
179 workflow_id: &WorkflowId,
180 activity_id: &ActivityId,
181 now: Instant,
182 ) -> Result<bool, ServerError> {
183 let key = TaskKey::new(worker_id, workflow_id.clone(), activity_id.clone());
184 let mut state = self.state()?;
185 let Some(liveness) = state.tasks.get_mut(&key) else {
186 return Ok(false);
187 };
188 liveness.last_heartbeat_at = now;
189 Ok(true)
190 }
191
192 #[must_use]
196 pub const fn heartbeat_window(&self) -> Duration {
197 self.heartbeat_window
198 }
199
200 pub fn in_flight_count(&self) -> Result<usize, ServerError> {
206 Ok(self.state()?.tasks.len())
207 }
208
209 pub fn record_heartbeat(
221 &self,
222 worker_id: WorkerId,
223 heartbeat: ProtoHeartbeat,
224 now: Instant,
225 ) -> Result<HeartbeatUpdate, ServerError> {
226 let decoded = DecodedHeartbeat::try_from(heartbeat)?;
227 let key = TaskKey::new(worker_id, decoded.workflow_id, decoded.activity_id);
228 let mut state = self.state()?;
229 let Some(liveness) = state.tasks.get_mut(&key) else {
230 return Err(wire_error("heartbeat task is not in flight"));
231 };
232 liveness.last_heartbeat_at = now;
233 if decoded.progress.is_some() {
234 liveness.last_progress = decoded.progress;
235 }
236 Ok(HeartbeatUpdate {
237 liveness: liveness.clone(),
238 })
239 }
240
241 pub fn is_live(
247 &self,
248 worker_id: WorkerId,
249 workflow_id: &WorkflowId,
250 activity_id: &ActivityId,
251 now: Instant,
252 ) -> Result<bool, ServerError> {
253 let key = TaskKey::new(worker_id, workflow_id.clone(), activity_id.clone());
254 let state = self.state()?;
255 let Some(liveness) = state.tasks.get(&key) else {
256 return Err(wire_error("heartbeat task is not in flight"));
257 };
258 Ok(!is_expired(liveness, now))
259 }
260
261 pub fn expired_workers(&self, now: Instant) -> Result<Vec<WorkerId>, ServerError> {
267 let state = self.state()?;
268 let mut seen = HashSet::new();
269 let mut workers = Vec::new();
270 for liveness in state.tasks.values() {
271 if is_expired(liveness, now) && seen.insert(liveness.worker_id) {
272 workers.push(liveness.worker_id);
273 }
274 }
275 workers.sort_unstable();
276 Ok(workers)
277 }
278
279 pub fn fail_expired_workers(
285 &self,
286 registry: &ConnectedWorkerRegistry,
287 sink: &impl ActivityCompletionSink,
288 now: Instant,
289 ) -> Result<Vec<LostWorkerReport>, ServerError> {
290 let mut reports = Vec::new();
291 for worker_id in self.expired_workers(now)? {
292 let report = self.fail_lost_worker(worker_id, registry, sink)?;
293 if !report.tasks.is_empty() {
294 reports.push(report);
295 }
296 }
297 Ok(reports)
298 }
299
300 pub fn fail_disconnected_worker(
306 &self,
307 worker_id: WorkerId,
308 registry: &ConnectedWorkerRegistry,
309 sink: &impl ActivityCompletionSink,
310 ) -> Result<LostWorkerReport, ServerError> {
311 self.fail_lost_worker(worker_id, registry, sink)
312 }
313
314 pub fn fail_all_in_flight_workers(
320 &self,
321 registry: &ConnectedWorkerRegistry,
322 sink: &impl ActivityCompletionSink,
323 ) -> Result<Vec<LostWorkerReport>, ServerError> {
324 let worker_ids = {
325 let state = self.state()?;
326 let mut worker_ids = state
327 .tasks
328 .values()
329 .map(|liveness| liveness.worker_id)
330 .collect::<HashSet<_>>()
331 .into_iter()
332 .collect::<Vec<_>>();
333 worker_ids.sort_unstable();
334 worker_ids
335 };
336 let mut reports = Vec::new();
337 for worker_id in worker_ids {
338 let report = self.fail_lost_worker(worker_id, registry, sink)?;
339 if !report.tasks.is_empty() {
340 reports.push(report);
341 }
342 }
343 self.empty.notify_waiters();
344 Ok(reports)
345 }
346
347 pub fn park_disconnected_worker(
363 &self,
364 worker_id: WorkerId,
365 registry: &ConnectedWorkerRegistry,
366 sink: &impl ActivityCompletionSink,
367 ) -> Result<LostWorkerReport, ServerError> {
368 self.park_lost_worker(
369 worker_id,
370 registry,
371 sink,
372 aion_core::WorkerDeathReason::Disconnect,
373 )
374 }
375
376 pub fn park_all_in_flight_workers(
389 &self,
390 registry: &ConnectedWorkerRegistry,
391 sink: &impl ActivityCompletionSink,
392 ) -> Result<Vec<LostWorkerReport>, ServerError> {
393 let worker_ids = {
394 let state = self.state()?;
395 let mut worker_ids = state
396 .tasks
397 .values()
398 .map(|liveness| liveness.worker_id)
399 .collect::<HashSet<_>>()
400 .into_iter()
401 .collect::<Vec<_>>();
402 worker_ids.sort_unstable();
403 worker_ids
404 };
405 let mut reports = Vec::new();
406 for worker_id in worker_ids {
407 let report = self.park_lost_worker(
408 worker_id,
409 registry,
410 sink,
411 aion_core::WorkerDeathReason::Timeout,
412 )?;
413 if !report.tasks.is_empty() {
414 reports.push(report);
415 }
416 }
417 self.empty.notify_waiters();
418 Ok(reports)
419 }
420
421 fn park_lost_worker(
429 &self,
430 worker_id: WorkerId,
431 registry: &ConnectedWorkerRegistry,
432 sink: &impl ActivityCompletionSink,
433 reason: aion_core::WorkerDeathReason,
434 ) -> Result<LostWorkerReport, ServerError> {
435 registry.deregister_with_reason(worker_id, reason)?;
436 let tasks = self.remove_worker_tasks(worker_id)?;
437 for task in &tasks {
438 sink.park_activity(&task.workflow_id, &task.activity_id)?;
439 info!(
440 worker_id = ?worker_id,
441 workflow_id = %task.workflow_id,
442 activity_id = %task.activity_id,
443 "activity parked for restart recovery"
444 );
445 }
446 Ok(LostWorkerReport { worker_id, tasks })
447 }
448
449 fn fail_lost_worker(
450 &self,
451 worker_id: WorkerId,
452 registry: &ConnectedWorkerRegistry,
453 sink: &impl ActivityCompletionSink,
454 ) -> Result<LostWorkerReport, ServerError> {
455 registry.deregister_with_reason(worker_id, aion_core::WorkerDeathReason::Timeout)?;
465 let tasks = self.remove_worker_tasks(worker_id)?;
466 for task in &tasks {
467 sink.complete_activity(ActivityCompletion {
468 workflow_id: task.workflow_id.clone(),
469 activity_id: task.activity_id.clone(),
470 run_id: None,
471 outcome: ActivityCompletionOutcome::Failed(lost_worker_error(worker_id)),
472 })?;
473 }
474 Ok(LostWorkerReport { worker_id, tasks })
475 }
476
477 fn remove_worker_tasks(
478 &self,
479 worker_id: WorkerId,
480 ) -> Result<Vec<InFlightActivity>, ServerError> {
481 let mut state = self.state()?;
482 let keys = state
483 .tasks
484 .keys()
485 .filter(|key| key.worker_id() == worker_id)
486 .cloned()
487 .collect::<Vec<_>>();
488 let mut tasks = Vec::with_capacity(keys.len());
489 for key in keys {
490 if let Some(liveness) = state.tasks.remove(&key) {
491 tasks.push(InFlightActivity {
492 workflow_id: liveness.workflow_id,
493 activity_id: liveness.activity_id,
494 });
495 }
496 }
497 Ok(tasks)
498 }
499
500 fn state(&self) -> Result<MutexGuard<'_, HeartbeatState>, ServerError> {
501 self.inner
502 .lock()
503 .map_err(|_| ServerError::lock_poisoned("worker heartbeat tracker"))
504 }
505}
506
507#[must_use]
520pub fn sweep_interval(heartbeat_window: Duration) -> Duration {
521 const MINIMUM_PERIOD: Duration = Duration::from_millis(1);
524 const TARGET_FLOOR: Duration = Duration::from_secs(1);
527 let ceiling = heartbeat_window.max(MINIMUM_PERIOD);
528 (heartbeat_window / 4).clamp(TARGET_FLOOR.min(ceiling), ceiling)
530}
531
532pub struct HeartbeatSweeper<S> {
554 tracker: HeartbeatTracker,
555 registry: ConnectedWorkerRegistry,
556 sink: S,
557 drain: DrainState,
558 heartbeat_window: Duration,
559 interval: Duration,
560}
561
562impl<S> HeartbeatSweeper<S>
563where
564 S: ActivityCompletionSink + Send + Sync + 'static,
565{
566 #[must_use]
570 pub fn new(
571 tracker: HeartbeatTracker,
572 registry: ConnectedWorkerRegistry,
573 sink: S,
574 drain: DrainState,
575 heartbeat_window: Duration,
576 ) -> Self {
577 let interval = sweep_interval(heartbeat_window);
578 Self {
579 tracker,
580 registry,
581 sink,
582 drain,
583 heartbeat_window,
584 interval,
585 }
586 }
587
588 pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
596 info!(
597 sweep_interval_ms = self.interval.as_millis(),
598 heartbeat_window_ms = self.heartbeat_window.as_millis(),
599 "worker heartbeat sweeper started"
600 );
601 let mut ticks = tokio::time::interval(self.interval);
602 ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
603 loop {
604 tokio::select! {
605 _ = ticks.tick() => {
606 if *shutdown.borrow() {
607 break;
608 }
609 self.sweep_once(Instant::now());
610 }
611 changed = shutdown.changed() => {
612 if changed.is_err() || *shutdown.borrow() {
615 break;
616 }
617 }
618 }
619 }
620 info!("worker heartbeat sweeper stopped");
621 }
622
623 fn sweep_once(&self, now: Instant) {
626 let reports = match self
627 .tracker
628 .fail_expired_workers(&self.registry, &self.sink, now)
629 {
630 Ok(reports) => reports,
631 Err(sweep_error) => {
632 error!(
633 error = %sweep_error,
634 "heartbeat expiry sweep failed; retrying next tick"
635 );
636 return;
637 }
638 };
639 for report in &reports {
640 warn!(
641 worker_id = ?report.worker_id,
642 failed_tasks = report.tasks.len(),
643 "worker heartbeat window expired with in-flight activities; \
644 deregistered and surfaced as retryable lost-worker failures"
645 );
646 }
647 if !reports.is_empty() {
648 self.drain.notify_activity_drained();
652 }
653 }
654}
655
656impl TaskKey {
657 fn new(worker_id: WorkerId, workflow_id: WorkflowId, activity_id: ActivityId) -> Self {
658 Self(worker_id, workflow_id, activity_id)
659 }
660
661 const fn worker_id(&self) -> WorkerId {
662 self.0
663 }
664}
665
666struct DecodedHeartbeat {
667 workflow_id: WorkflowId,
668 activity_id: ActivityId,
669 progress: Option<Payload>,
670}
671
672impl TryFrom<ProtoHeartbeat> for DecodedHeartbeat {
673 type Error = ServerError;
674
675 fn try_from(value: ProtoHeartbeat) -> Result<Self, Self::Error> {
676 let workflow_id = value
677 .workflow_id
678 .ok_or_else(|| wire_error("heartbeat workflow id is missing"))
679 .and_then(|id| WorkflowId::try_from(id).map_err(ServerError::from))?;
680 let activity_id = value
681 .activity_id
682 .ok_or_else(|| wire_error("heartbeat activity id is missing"))
683 .map(ActivityId::from)?;
684 let progress = value
685 .progress
686 .map(Payload::try_from)
687 .transpose()
688 .map_err(ServerError::from)?;
689 Ok(Self {
690 workflow_id,
691 activity_id,
692 progress,
693 })
694 }
695}
696
697fn is_expired(liveness: &TaskLiveness, now: Instant) -> bool {
698 now.checked_duration_since(liveness.last_heartbeat_at)
699 .is_some_and(|elapsed| elapsed > liveness.heartbeat_window)
700}
701
702fn wire_error(message: &'static str) -> ServerError {
703 ServerError::Wire {
704 wire: WireError::backend(message),
705 }
706}
707
708#[cfg(test)]
709mod tests {
710 use std::sync::Mutex;
711
712 use aion_core::{ActivityErrorKind, ContentType};
713 use aion_proto::{ProtoActivityId, ProtoPayload, ProtoWorkflowId};
714 use serde_json::json;
715 use uuid::Uuid;
716
717 use crate::worker::registry::WorkerRegistration;
718
719 use super::*;
720
721 #[derive(Default)]
722 struct RecordingSink {
723 completions: Mutex<Vec<ActivityCompletion>>,
724 parks: Mutex<Vec<(WorkflowId, ActivityId)>>,
725 }
726
727 impl ActivityCompletionSink for RecordingSink {
728 fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError> {
729 self.completions
730 .lock()
731 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
732 .push(completion);
733 Ok(())
734 }
735
736 fn park_activity(
737 &self,
738 workflow_id: &WorkflowId,
739 activity_id: &ActivityId,
740 ) -> Result<(), ServerError> {
741 self.parks
742 .lock()
743 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
744 .push((workflow_id.clone(), activity_id.clone()));
745 Ok(())
746 }
747 }
748
749 fn workflow_id() -> WorkflowId {
750 WorkflowId::new(Uuid::nil())
751 }
752
753 fn activity_id(position: u64) -> ActivityId {
754 ActivityId::from_sequence_position(position)
755 }
756
757 fn payload(value: &serde_json::Value) -> Result<Payload, Box<dyn std::error::Error>> {
758 Ok(Payload::from_json(value)?)
759 }
760
761 fn heartbeat(
762 workflow_id: WorkflowId,
763 activity_id: ActivityId,
764 progress: Option<Payload>,
765 ) -> ProtoHeartbeat {
766 ProtoHeartbeat {
767 workflow_id: Some(ProtoWorkflowId::from(workflow_id)),
768 activity_id: Some(ProtoActivityId::from(activity_id)),
769 progress: progress.map(ProtoPayload::from),
770 }
771 }
772
773 fn registry_with_worker()
774 -> Result<(ConnectedWorkerRegistry, WorkerRegistration, WorkerId), ServerError> {
775 let registry = ConnectedWorkerRegistry::default();
776 let (tx, _rx) = tokio::sync::mpsc::channel(1);
777 let activity_types = [String::from("charge-card")];
778 let registration = registry.register("tenant-a", activity_types.iter(), tx)?;
779 let worker_id = registration
780 .worker_id()
781 .ok_or_else(|| ServerError::lock_poisoned("test worker registration"))?;
782 Ok((registry, registration, worker_id))
783 }
784
785 #[test]
786 fn heartbeat_refresh_keeps_task_live_across_window() -> Result<(), Box<dyn std::error::Error>> {
787 let window = Duration::from_secs(5);
788 let tracker = HeartbeatTracker::new(window);
789 let worker_id = WorkerIdForTest::registered()?;
790 let workflow_id = workflow_id();
791 let activity_id = activity_id(10);
792 let start = Instant::now();
793
794 tracker.track_task(
795 worker_id,
796 InFlightActivity {
797 workflow_id: workflow_id.clone(),
798 activity_id: activity_id.clone(),
799 },
800 start,
801 )?;
802 assert!(tracker.is_live(worker_id, &workflow_id, &activity_id, start + window)?);
803
804 let progress = payload(&json!({"percent": 50}))?;
805 let update = tracker.record_heartbeat(
806 worker_id,
807 heartbeat(
808 workflow_id.clone(),
809 activity_id.clone(),
810 Some(progress.clone()),
811 ),
812 start + window,
813 )?;
814
815 assert_eq!(update.liveness.last_progress, Some(progress));
816 assert!(tracker.is_live(
817 worker_id,
818 &workflow_id,
819 &activity_id,
820 start + window + window
821 )?);
822 assert!(tracker.expired_workers(start + window + window)?.is_empty());
823 Ok(())
824 }
825
826 #[test]
827 fn missed_heartbeat_deregisters_worker_and_fails_in_flight_once()
828 -> Result<(), Box<dyn std::error::Error>> {
829 let (registry, _registration, worker_id) = registry_with_worker()?;
830 let sink = RecordingSink::default();
831 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
832 let workflow_id = workflow_id();
833 let activity_id = activity_id(11);
834 let start = Instant::now();
835
836 tracker.track_task(
837 worker_id,
838 InFlightActivity {
839 workflow_id: workflow_id.clone(),
840 activity_id: activity_id.clone(),
841 },
842 start,
843 )?;
844
845 let reports =
846 tracker.fail_expired_workers(®istry, &sink, start + Duration::from_secs(6))?;
847 assert_eq!(reports.len(), 1);
848 assert_eq!(reports[0].worker_id, worker_id);
849 assert_eq!(reports[0].tasks.len(), 1);
850 assert!(
851 registry
852 .workers_for("tenant-a", "default", "charge-card", None)?
853 .is_empty()
854 );
855
856 let second = tracker.fail_disconnected_worker(worker_id, ®istry, &sink)?;
857 assert!(second.tasks.is_empty());
858 let completions = sink
859 .completions
860 .lock()
861 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
862 assert_eq!(completions.len(), 1);
863 assert_eq!(completions[0].workflow_id, workflow_id);
864 assert_eq!(completions[0].activity_id, activity_id);
865 match &completions[0].outcome {
866 ActivityCompletionOutcome::Failed(error) => {
867 assert_eq!(error.kind, ActivityErrorKind::Retryable);
868 assert!(error.is_retryable());
869 }
870 ActivityCompletionOutcome::Succeeded(_) => {
871 return Err("expected lost-worker failure".into());
872 }
873 }
874 Ok(())
875 }
876
877 #[test]
878 fn disconnected_worker_fails_each_in_flight_task_once() -> Result<(), Box<dyn std::error::Error>>
879 {
880 let (registry, _registration, worker_id) = registry_with_worker()?;
881 let sink = RecordingSink::default();
882 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
883 let workflow_id = workflow_id();
884 let start = Instant::now();
885
886 tracker.track_task(
887 worker_id,
888 InFlightActivity {
889 workflow_id: workflow_id.clone(),
890 activity_id: activity_id(21),
891 },
892 start,
893 )?;
894 tracker.track_task(
895 worker_id,
896 InFlightActivity {
897 workflow_id,
898 activity_id: activity_id(22),
899 },
900 start,
901 )?;
902
903 let report = tracker.fail_disconnected_worker(worker_id, ®istry, &sink)?;
904 assert_eq!(report.tasks.len(), 2);
905 assert!(
906 registry
907 .workers_for("tenant-a", "default", "charge-card", None)?
908 .is_empty()
909 );
910
911 let completions = sink
912 .completions
913 .lock()
914 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
915 assert_eq!(completions.len(), 2);
916 assert!(completions.iter().all(|completion| matches!(
917 &completion.outcome,
918 ActivityCompletionOutcome::Failed(error)
919 if error.kind == ActivityErrorKind::Retryable && error.is_retryable()
920 )));
921 Ok(())
922 }
923
924 #[test]
930 fn park_disconnected_worker_parks_tasks_without_synthesizing_completions()
931 -> Result<(), Box<dyn std::error::Error>> {
932 let (registry, _registration, worker_id) = registry_with_worker()?;
933 let sink = RecordingSink::default();
934 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
935 let workflow_id = workflow_id();
936 let start = Instant::now();
937 tracker.track_task(
938 worker_id,
939 InFlightActivity {
940 workflow_id: workflow_id.clone(),
941 activity_id: activity_id(60),
942 },
943 start,
944 )?;
945 tracker.track_task(
946 worker_id,
947 InFlightActivity {
948 workflow_id: workflow_id.clone(),
949 activity_id: activity_id(61),
950 },
951 start,
952 )?;
953
954 let report = tracker.park_disconnected_worker(worker_id, ®istry, &sink)?;
955 assert_eq!(report.tasks.len(), 2);
956 assert_eq!(
957 tracker.in_flight_count()?,
958 0,
959 "parking must remove every tracked task so drain accounting reaches zero"
960 );
961 assert!(
962 registry
963 .workers_for("tenant-a", "default", "charge-card", None)?
964 .is_empty(),
965 "the parked worker must be deregistered from routing"
966 );
967 let parks = sink
968 .parks
969 .lock()
970 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
971 assert_eq!(parks.len(), 2, "each task must be parked exactly once");
972 drop(parks);
973 assert!(
974 sink.completions
975 .lock()
976 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
977 .is_empty(),
978 "parking must never synthesize an activity completion"
979 );
980
981 let second = tracker.park_disconnected_worker(worker_id, ®istry, &sink)?;
983 assert!(second.tasks.is_empty());
984 let third = tracker.fail_disconnected_worker(worker_id, ®istry, &sink)?;
985 assert!(third.tasks.is_empty());
986 assert_eq!(
987 sink.parks
988 .lock()
989 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
990 .len(),
991 2,
992 "re-sweeping a parked worker must park nothing further"
993 );
994 assert!(
995 sink.completions
996 .lock()
997 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
998 .is_empty(),
999 "a fail sweep after the park must fail nothing"
1000 );
1001 Ok(())
1002 }
1003
1004 #[tokio::test]
1008 async fn park_all_in_flight_workers_parks_everything_and_wakes_drain_waiters()
1009 -> Result<(), Box<dyn std::error::Error>> {
1010 let (registry, _registration, worker_id) = registry_with_worker()?;
1011 let sink = RecordingSink::default();
1012 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
1013 let workflow_id = workflow_id();
1014 tracker.track_task(
1015 worker_id,
1016 InFlightActivity {
1017 workflow_id: workflow_id.clone(),
1018 activity_id: activity_id(70),
1019 },
1020 Instant::now(),
1021 )?;
1022 let notified = tracker.empty.notified();
1024 tokio::pin!(notified);
1025
1026 let reports = tracker.park_all_in_flight_workers(®istry, &sink)?;
1027 assert_eq!(reports.len(), 1);
1028 assert_eq!(reports[0].worker_id, worker_id);
1029 assert_eq!(reports[0].tasks.len(), 1);
1030 assert_eq!(tracker.in_flight_count()?, 0);
1031 assert_eq!(
1032 sink.parks
1033 .lock()
1034 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
1035 .len(),
1036 1
1037 );
1038 assert!(
1039 sink.completions
1040 .lock()
1041 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
1042 .is_empty(),
1043 "the bulk park must never synthesize a completion"
1044 );
1045 tokio::time::timeout(Duration::from_millis(200), notified)
1046 .await
1047 .map_err(|_| "the bulk park must wake drain waiters")?;
1048 Ok(())
1049 }
1050
1051 #[test]
1056 fn payload_free_heartbeat_refreshes_liveness_without_clearing_progress()
1057 -> Result<(), Box<dyn std::error::Error>> {
1058 let window = Duration::from_secs(5);
1059 let tracker = HeartbeatTracker::new(window);
1060 let worker_id = WorkerIdForTest::registered()?;
1061 let workflow_id = workflow_id();
1062 let activity_id = activity_id(12);
1063 let start = Instant::now();
1064
1065 tracker.track_task(
1066 worker_id,
1067 InFlightActivity {
1068 workflow_id: workflow_id.clone(),
1069 activity_id: activity_id.clone(),
1070 },
1071 start,
1072 )?;
1073 let progress = payload(&json!({"percent": 80}))?;
1074 tracker.record_heartbeat(
1075 worker_id,
1076 heartbeat(
1077 workflow_id.clone(),
1078 activity_id.clone(),
1079 Some(progress.clone()),
1080 ),
1081 start + Duration::from_secs(1),
1082 )?;
1083
1084 let update = tracker.record_heartbeat(
1086 worker_id,
1087 heartbeat(workflow_id.clone(), activity_id.clone(), None),
1088 start + Duration::from_secs(4),
1089 )?;
1090
1091 assert_eq!(
1092 update.liveness.last_progress,
1093 Some(progress),
1094 "a payload-free liveness beat must not erase handler progress"
1095 );
1096 assert!(
1097 tracker.is_live(
1098 worker_id,
1099 &workflow_id,
1100 &activity_id,
1101 start + Duration::from_secs(8)
1102 )?,
1103 "the payload-free beat must still refresh the liveness stamp"
1104 );
1105 Ok(())
1106 }
1107
1108 #[test]
1109 fn malformed_heartbeat_missing_ids_is_wire_error() -> Result<(), Box<dyn std::error::Error>> {
1110 let worker_id = WorkerIdForTest::registered()?;
1111 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
1112 let missing = ProtoHeartbeat {
1113 workflow_id: None,
1114 activity_id: Some(ProtoActivityId::from(activity_id(30))),
1115 progress: None,
1116 };
1117
1118 let result = tracker.record_heartbeat(worker_id, missing, Instant::now());
1119 assert!(matches!(result, Err(ServerError::Wire { .. })));
1120 Ok(())
1121 }
1122
1123 #[test]
1124 fn heartbeat_progress_is_not_reported_as_activity_result()
1125 -> Result<(), Box<dyn std::error::Error>> {
1126 let sink = RecordingSink::default();
1127 let worker_id = WorkerIdForTest::registered()?;
1128 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
1129 let workflow_id = workflow_id();
1130 let activity_id = activity_id(40);
1131 let now = Instant::now();
1132
1133 tracker.track_task(
1134 worker_id,
1135 InFlightActivity {
1136 workflow_id: workflow_id.clone(),
1137 activity_id: activity_id.clone(),
1138 },
1139 now,
1140 )?;
1141 tracker.record_heartbeat(
1142 worker_id,
1143 heartbeat(
1144 workflow_id,
1145 activity_id,
1146 Some(Payload::new(
1147 ContentType::Json,
1148 b"{\"progress\":1}".to_vec(),
1149 )),
1150 ),
1151 now,
1152 )?;
1153
1154 let completions = sink
1155 .completions
1156 .lock()
1157 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
1158 assert!(completions.is_empty());
1159 Ok(())
1160 }
1161
1162 struct WorkerIdForTest;
1163
1164 impl WorkerIdForTest {
1165 fn registered() -> Result<WorkerId, ServerError> {
1166 let (_registry, _registration, worker_id) = registry_with_worker()?;
1167 Ok(worker_id)
1168 }
1169 }
1170
1171 #[test]
1175 fn complete_task_reports_whether_the_entry_was_tracked()
1176 -> Result<(), Box<dyn std::error::Error>> {
1177 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
1178 let worker_id = WorkerIdForTest::registered()?;
1179 let workflow_id = workflow_id();
1180 let id = activity_id(50);
1181 tracker.track_task(
1182 worker_id,
1183 InFlightActivity {
1184 workflow_id: workflow_id.clone(),
1185 activity_id: id.clone(),
1186 },
1187 Instant::now(),
1188 )?;
1189
1190 assert!(tracker.is_tracked(worker_id, &workflow_id, &id)?);
1191 assert!(
1192 tracker.complete_task(worker_id, &workflow_id, &id)?,
1193 "the first completion retires the tracked entry"
1194 );
1195 assert!(!tracker.is_tracked(worker_id, &workflow_id, &id)?);
1196 assert!(
1197 !tracker.complete_task(worker_id, &workflow_id, &id)?,
1198 "a second completion finds nothing to retire"
1199 );
1200 Ok(())
1201 }
1202
1203 #[test]
1207 fn record_liveness_refreshes_stamp_and_ignores_untracked_tasks()
1208 -> Result<(), Box<dyn std::error::Error>> {
1209 let window = Duration::from_secs(5);
1210 let tracker = HeartbeatTracker::new(window);
1211 let worker_id = WorkerIdForTest::registered()?;
1212 let workflow_id = workflow_id();
1213 let id = activity_id(51);
1214 let start = Instant::now();
1215 tracker.track_task(
1216 worker_id,
1217 InFlightActivity {
1218 workflow_id: workflow_id.clone(),
1219 activity_id: id.clone(),
1220 },
1221 start,
1222 )?;
1223
1224 assert!(tracker.record_liveness(worker_id, &workflow_id, &id, start + window)?);
1226 assert!(tracker.is_live(worker_id, &workflow_id, &id, start + window + window)?);
1227 assert!(tracker.expired_workers(start + window + window)?.is_empty());
1228
1229 assert!(!tracker.record_liveness(
1232 worker_id,
1233 &workflow_id,
1234 &activity_id(52),
1235 start + window
1236 )?);
1237 Ok(())
1238 }
1239
1240 #[test]
1241 fn sweep_interval_is_quarter_window_clamped_to_one_second_and_window() {
1242 assert_eq!(
1244 sweep_interval(Duration::from_secs(30)),
1245 Duration::from_millis(7_500)
1246 );
1247 assert_eq!(
1249 sweep_interval(Duration::from_secs(2)),
1250 Duration::from_secs(1)
1251 );
1252 assert_eq!(
1254 sweep_interval(Duration::from_secs(3_600)),
1255 Duration::from_secs(900)
1256 );
1257 assert_eq!(
1260 sweep_interval(Duration::from_millis(200)),
1261 Duration::from_millis(200)
1262 );
1263 assert_eq!(sweep_interval(Duration::ZERO), Duration::from_millis(1));
1266 }
1267}