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,
16};
17use crate::worker::envelope::CompletionToken;
18use crate::worker::registry::{ConnectedWorkerRegistry, WorkerId};
19
20#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct InFlightActivity {
23 pub workflow_id: WorkflowId,
25 pub activity_id: ActivityId,
27 pub completion_token: CompletionToken,
29}
30
31#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct TaskLiveness {
34 pub worker_id: WorkerId,
36 pub workflow_id: WorkflowId,
38 pub activity_id: ActivityId,
40 pub completion_token: CompletionToken,
42 pub heartbeat_window: Duration,
44 pub last_heartbeat_at: Instant,
46 pub last_progress: Option<Payload>,
48}
49
50#[derive(Clone, Debug, Eq, PartialEq)]
52pub struct HeartbeatUpdate {
53 pub liveness: TaskLiveness,
55}
56
57#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct LostWorkerReport {
60 pub worker_id: WorkerId,
62 pub tasks: Vec<InFlightActivity>,
67 pub task_queue: Option<String>,
74}
75
76#[derive(Clone, Debug, Eq, Hash, PartialEq)]
77struct TaskKey(WorkerId, WorkflowId, ActivityId);
78
79#[derive(Debug, Default)]
80struct HeartbeatState {
81 tasks: HashMap<TaskKey, TaskLiveness>,
82 connections: HashMap<WorkerId, Instant>,
91 reachability: HashMap<WorkerId, Reachability>,
108}
109
110pub(crate) const DISPATCH_PROBATION_PINGS: u32 = 2;
132
133#[derive(Clone, Copy, Debug, Default)]
140struct Reachability {
141 consecutive_answers: u32,
142 proved_at: Option<Instant>,
143}
144
145impl Reachability {
146 fn is_proved(self, now: Instant, window: Duration) -> bool {
148 self.consecutive_answers >= DISPATCH_PROBATION_PINGS
149 && self.proved_at.is_some_and(|proved_at| {
150 now.checked_duration_since(proved_at)
151 .is_none_or(|elapsed| elapsed <= window)
152 })
153 }
154}
155
156#[derive(Clone, Debug)]
158pub struct HeartbeatTracker {
159 heartbeat_window: Duration,
160 inner: Arc<Mutex<HeartbeatState>>,
161 empty: Arc<Notify>,
162}
163
164impl HeartbeatTracker {
165 #[must_use]
167 pub fn new(heartbeat_window: Duration) -> Self {
168 Self {
169 heartbeat_window,
170 inner: Arc::new(Mutex::new(HeartbeatState::default())),
171 empty: Arc::new(Notify::new()),
172 }
173 }
174
175 pub fn register_connection(
196 &self,
197 worker_id: WorkerId,
198 now: Instant,
199 ) -> Result<(), ServerError> {
200 let mut state = self.state()?;
201 state.connections.insert(worker_id, now);
202 state
206 .reachability
207 .insert(worker_id, Reachability::default());
208 Ok(())
209 }
210
211 pub fn record_connection_activity(
226 &self,
227 worker_id: WorkerId,
228 now: Instant,
229 ) -> Result<bool, ServerError> {
230 let mut state = self.state()?;
231 let Some(last_activity) = state.connections.get_mut(&worker_id) else {
232 return Ok(false);
233 };
234 *last_activity = now;
235 Ok(true)
236 }
237
238 pub fn record_dispatch_reachability(
253 &self,
254 worker_id: WorkerId,
255 now: Instant,
256 ) -> Result<bool, ServerError> {
257 let mut state = self.state()?;
258 let Some(last_activity) = state.connections.get_mut(&worker_id) else {
259 return Ok(false);
260 };
261 *last_activity = now;
262 let standing = state.reachability.entry(worker_id).or_default();
263 standing.consecutive_answers = standing.consecutive_answers.saturating_add(1);
264 standing.proved_at = Some(now);
265 Ok(true)
266 }
267
268 pub fn record_dispatch_unreachable(&self, worker_id: WorkerId) -> Result<bool, ServerError> {
281 let mut state = self.state()?;
282 if !state.connections.contains_key(&worker_id) {
283 return Ok(false);
284 }
285 let standing = state.reachability.entry(worker_id).or_default();
286 standing.consecutive_answers = 0;
287 standing.proved_at = None;
288 Ok(true)
289 }
290
291 pub fn is_dispatch_reachable(
301 &self,
302 worker_id: WorkerId,
303 now: Instant,
304 ) -> Result<bool, ServerError> {
305 let state = self.state()?;
306 Ok(state
307 .reachability
308 .get(&worker_id)
309 .is_some_and(|standing| standing.is_proved(now, self.heartbeat_window)))
310 }
311
312 pub fn unreachable_workers(&self, now: Instant) -> Result<Vec<WorkerId>, ServerError> {
319 let state = self.state()?;
320 let mut workers = state
321 .reachability
322 .iter()
323 .filter(|(_, standing)| !standing.is_proved(now, self.heartbeat_window))
324 .map(|(worker_id, _)| *worker_id)
325 .collect::<Vec<_>>();
326 workers.sort_unstable();
327 Ok(workers)
328 }
329
330 pub fn unregister_connection(&self, worker_id: WorkerId) -> Result<(), ServerError> {
336 let mut state = self.state()?;
337 state.connections.remove(&worker_id);
338 state.reachability.remove(&worker_id);
339 Ok(())
340 }
341
342 pub fn track_task(
348 &self,
349 worker_id: WorkerId,
350 task: InFlightActivity,
351 now: Instant,
352 ) -> Result<(), ServerError> {
353 let key = TaskKey::new(
354 worker_id,
355 task.workflow_id.clone(),
356 task.activity_id.clone(),
357 );
358 let liveness = TaskLiveness {
359 worker_id,
360 workflow_id: task.workflow_id,
361 activity_id: task.activity_id,
362 completion_token: task.completion_token,
363 heartbeat_window: self.heartbeat_window,
364 last_heartbeat_at: now,
365 last_progress: None,
366 };
367 let mut state = self.state()?;
368 state.tasks.insert(key, liveness);
369 state.connections.insert(worker_id, now);
370 Ok(())
371 }
372
373 pub fn complete_task(
386 &self,
387 worker_id: WorkerId,
388 workflow_id: &WorkflowId,
389 activity_id: &ActivityId,
390 ) -> Result<bool, ServerError> {
391 let key = TaskKey::new(worker_id, workflow_id.clone(), activity_id.clone());
392 let (was_tracked, became_empty) = {
393 let mut state = self.state()?;
394 let was_tracked = state.tasks.remove(&key).is_some();
395 (was_tracked, state.tasks.is_empty())
396 };
397 if became_empty {
398 self.empty.notify_waiters();
399 }
400 Ok(was_tracked)
401 }
402
403 pub fn is_tracked(
412 &self,
413 worker_id: WorkerId,
414 workflow_id: &WorkflowId,
415 activity_id: &ActivityId,
416 ) -> Result<bool, ServerError> {
417 let key = TaskKey::new(worker_id, workflow_id.clone(), activity_id.clone());
418 Ok(self.state()?.tasks.contains_key(&key))
419 }
420
421 pub fn record_liveness(
431 &self,
432 worker_id: WorkerId,
433 workflow_id: &WorkflowId,
434 activity_id: &ActivityId,
435 now: Instant,
436 ) -> Result<bool, ServerError> {
437 let key = TaskKey::new(worker_id, workflow_id.clone(), activity_id.clone());
438 let mut state = self.state()?;
439 if !state.tasks.contains_key(&key) {
440 return Ok(false);
441 }
442 if let Some(last_activity) = state.connections.get_mut(&worker_id) {
443 *last_activity = now;
444 }
445 let Some(liveness) = state.tasks.get_mut(&key) else {
446 return Ok(false);
447 };
448 liveness.last_heartbeat_at = now;
449 Ok(true)
450 }
451
452 #[must_use]
456 pub const fn heartbeat_window(&self) -> Duration {
457 self.heartbeat_window
458 }
459
460 pub fn in_flight_count(&self) -> Result<usize, ServerError> {
466 Ok(self.state()?.tasks.len())
467 }
468
469 pub fn record_heartbeat(
481 &self,
482 worker_id: WorkerId,
483 heartbeat: ProtoHeartbeat,
484 now: Instant,
485 ) -> Result<HeartbeatUpdate, ServerError> {
486 let decoded = DecodedHeartbeat::try_from(heartbeat)?;
487 let key = TaskKey::new(worker_id, decoded.workflow_id, decoded.activity_id);
488 let mut state = self.state()?;
489 if !state.tasks.contains_key(&key) {
490 return Err(wire_error("heartbeat task is not in flight"));
491 }
492 if let Some(last_activity) = state.connections.get_mut(&worker_id) {
493 *last_activity = now;
494 }
495 let Some(liveness) = state.tasks.get_mut(&key) else {
496 return Err(wire_error("heartbeat task is not in flight"));
497 };
498 liveness.last_heartbeat_at = now;
499 if decoded.progress.is_some() {
500 liveness.last_progress = decoded.progress;
501 }
502 Ok(HeartbeatUpdate {
503 liveness: liveness.clone(),
504 })
505 }
506
507 pub fn is_live(
513 &self,
514 worker_id: WorkerId,
515 workflow_id: &WorkflowId,
516 activity_id: &ActivityId,
517 now: Instant,
518 ) -> Result<bool, ServerError> {
519 let key = TaskKey::new(worker_id, workflow_id.clone(), activity_id.clone());
520 let state = self.state()?;
521 let Some(liveness) = state.tasks.get(&key) else {
522 return Err(wire_error("heartbeat task is not in flight"));
523 };
524 Ok(!is_expired(liveness, now))
525 }
526
527 pub fn expired_workers(&self, now: Instant) -> Result<Vec<WorkerId>, ServerError> {
533 let state = self.state()?;
534 let mut seen = HashSet::new();
535 let mut workers = Vec::new();
536 for (worker_id, last_activity) in &state.connections {
537 if now
538 .checked_duration_since(*last_activity)
539 .is_some_and(|elapsed| elapsed > self.heartbeat_window)
540 && seen.insert(*worker_id)
541 {
542 workers.push(*worker_id);
543 }
544 }
545 for liveness in state.tasks.values() {
546 if is_expired(liveness, now) && seen.insert(liveness.worker_id) {
547 workers.push(liveness.worker_id);
548 }
549 }
550 workers.sort_unstable();
551 Ok(workers)
552 }
553
554 pub fn fail_expired_workers(
560 &self,
561 registry: &ConnectedWorkerRegistry,
562 sink: &impl ActivityCompletionSink,
563 now: Instant,
564 ) -> Result<Vec<LostWorkerReport>, ServerError> {
565 let mut reports = Vec::new();
566 for worker_id in self.expired_workers(now)? {
567 let report = self.fail_lost_worker(worker_id, registry, sink)?;
568 reports.push(report);
569 }
570 Ok(reports)
571 }
572
573 pub fn fail_disconnected_worker(
579 &self,
580 worker_id: WorkerId,
581 registry: &ConnectedWorkerRegistry,
582 sink: &impl ActivityCompletionSink,
583 ) -> Result<LostWorkerReport, ServerError> {
584 self.fail_lost_worker(worker_id, registry, sink)
585 }
586
587 pub fn fail_all_in_flight_workers(
593 &self,
594 registry: &ConnectedWorkerRegistry,
595 sink: &impl ActivityCompletionSink,
596 ) -> Result<Vec<LostWorkerReport>, ServerError> {
597 let worker_ids = {
598 let state = self.state()?;
599 let mut worker_ids = state
600 .tasks
601 .values()
602 .map(|liveness| liveness.worker_id)
603 .collect::<HashSet<_>>()
604 .into_iter()
605 .collect::<Vec<_>>();
606 worker_ids.sort_unstable();
607 worker_ids
608 };
609 let mut reports = Vec::new();
610 for worker_id in worker_ids {
611 let report = self.fail_lost_worker(worker_id, registry, sink)?;
612 if !report.tasks.is_empty() {
613 reports.push(report);
614 }
615 }
616 self.empty.notify_waiters();
617 Ok(reports)
618 }
619
620 pub fn park_disconnected_worker(
636 &self,
637 worker_id: WorkerId,
638 registry: &ConnectedWorkerRegistry,
639 sink: &impl ActivityCompletionSink,
640 ) -> Result<LostWorkerReport, ServerError> {
641 self.park_lost_worker(
642 worker_id,
643 registry,
644 sink,
645 aion_core::WorkerDeathReason::Disconnect,
646 )
647 }
648
649 pub fn park_all_in_flight_workers(
662 &self,
663 registry: &ConnectedWorkerRegistry,
664 sink: &impl ActivityCompletionSink,
665 ) -> Result<Vec<LostWorkerReport>, ServerError> {
666 let worker_ids = {
667 let state = self.state()?;
668 let mut worker_ids = state
669 .tasks
670 .values()
671 .map(|liveness| liveness.worker_id)
672 .collect::<HashSet<_>>()
673 .into_iter()
674 .collect::<Vec<_>>();
675 worker_ids.sort_unstable();
676 worker_ids
677 };
678 let mut reports = Vec::new();
679 for worker_id in worker_ids {
680 let report = self.park_lost_worker(
681 worker_id,
682 registry,
683 sink,
684 aion_core::WorkerDeathReason::Timeout,
685 )?;
686 if !report.tasks.is_empty() {
687 reports.push(report);
688 }
689 }
690 self.empty.notify_waiters();
691 Ok(reports)
692 }
693
694 fn park_lost_worker(
702 &self,
703 worker_id: WorkerId,
704 registry: &ConnectedWorkerRegistry,
705 sink: &impl ActivityCompletionSink,
706 reason: aion_core::WorkerDeathReason,
707 ) -> Result<LostWorkerReport, ServerError> {
708 let task_queue = task_queue_of(registry, worker_id);
709 registry.deregister_with_reason(worker_id, reason)?;
710 self.state()?.connections.remove(&worker_id);
711 let tasks = self.remove_worker_tasks(worker_id)?;
712 for task in &tasks {
713 sink.park_activity(&task.workflow_id, &task.activity_id)?;
714 info!(
715 worker_id = ?worker_id,
716 workflow_id = %task.workflow_id,
717 activity_id = %task.activity_id,
718 "activity parked for restart recovery"
719 );
720 }
721 Ok(LostWorkerReport {
722 worker_id,
723 tasks,
724 task_queue,
725 })
726 }
727
728 fn fail_lost_worker(
729 &self,
730 worker_id: WorkerId,
731 registry: &ConnectedWorkerRegistry,
732 sink: &impl ActivityCompletionSink,
733 ) -> Result<LostWorkerReport, ServerError> {
734 let task_queue = task_queue_of(registry, worker_id);
746 registry.deregister_with_reason(worker_id, aion_core::WorkerDeathReason::Timeout)?;
747 self.state()?.connections.remove(&worker_id);
748 let tasks = self.remove_worker_tasks(worker_id)?;
749 for task in &tasks {
750 sink.complete_activity(ActivityCompletion {
751 workflow_id: task.workflow_id.clone(),
752 activity_id: task.activity_id.clone(),
753 run_id: None,
754 completion_token: task.completion_token.clone(),
755 outcome: ActivityCompletionOutcome::WorkerLost { worker_id },
760 })?;
761 }
762 Ok(LostWorkerReport {
763 worker_id,
764 tasks,
765 task_queue,
766 })
767 }
768
769 fn remove_worker_tasks(
770 &self,
771 worker_id: WorkerId,
772 ) -> Result<Vec<InFlightActivity>, ServerError> {
773 let mut state = self.state()?;
774 let keys = state
775 .tasks
776 .keys()
777 .filter(|key| key.worker_id() == worker_id)
778 .cloned()
779 .collect::<Vec<_>>();
780 let mut tasks = Vec::with_capacity(keys.len());
781 for key in keys {
782 if let Some(liveness) = state.tasks.remove(&key) {
783 tasks.push(InFlightActivity {
784 workflow_id: liveness.workflow_id,
785 activity_id: liveness.activity_id,
786 completion_token: liveness.completion_token,
787 });
788 }
789 }
790 Ok(tasks)
791 }
792
793 fn state(&self) -> Result<MutexGuard<'_, HeartbeatState>, ServerError> {
794 self.inner
795 .lock()
796 .map_err(|_| ServerError::lock_poisoned("worker heartbeat tracker"))
797 }
798}
799
800#[must_use]
813pub fn sweep_interval(heartbeat_window: Duration) -> Duration {
814 const MINIMUM_PERIOD: Duration = Duration::from_millis(1);
817 const TARGET_FLOOR: Duration = Duration::from_secs(1);
820 let ceiling = heartbeat_window.max(MINIMUM_PERIOD);
821 (heartbeat_window / 4).clamp(TARGET_FLOOR.min(ceiling), ceiling)
823}
824
825pub struct HeartbeatSweeper<S> {
847 tracker: HeartbeatTracker,
848 registry: ConnectedWorkerRegistry,
849 sink: S,
850 drain: DrainState,
851 heartbeat_window: Duration,
852 interval: Duration,
853 queue_state: crate::worker::QueueServiceState,
858}
859
860impl<S> HeartbeatSweeper<S>
861where
862 S: ActivityCompletionSink + Send + Sync + 'static,
863{
864 #[must_use]
868 pub fn new(
869 tracker: HeartbeatTracker,
870 registry: ConnectedWorkerRegistry,
871 sink: S,
872 drain: DrainState,
873 heartbeat_window: Duration,
874 ) -> Self {
875 let interval = sweep_interval(heartbeat_window);
876 Self {
877 tracker,
878 registry,
879 sink,
880 drain,
881 heartbeat_window,
882 interval,
883 queue_state: crate::worker::QueueServiceState::default(),
884 }
885 }
886
887 #[must_use]
893 pub fn with_queue_state(mut self, queue_state: crate::worker::QueueServiceState) -> Self {
894 self.queue_state = queue_state;
895 self
896 }
897
898 pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
906 info!(
907 sweep_interval_ms = self.interval.as_millis(),
908 heartbeat_window_ms = self.heartbeat_window.as_millis(),
909 "worker heartbeat sweeper started"
910 );
911 let mut ticks = tokio::time::interval(self.interval);
912 ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
913 loop {
914 tokio::select! {
915 _ = ticks.tick() => {
916 if *shutdown.borrow() {
917 break;
918 }
919 self.sweep_once(Instant::now());
920 }
921 changed = shutdown.changed() => {
922 if changed.is_err() || *shutdown.borrow() {
925 break;
926 }
927 }
928 }
929 }
930 info!("worker heartbeat sweeper stopped");
931 }
932
933 fn sweep_once(&self, now: Instant) {
936 let reports = match self
937 .tracker
938 .fail_expired_workers(&self.registry, &self.sink, now)
939 {
940 Ok(reports) => reports,
941 Err(sweep_error) => {
942 error!(
943 error = %sweep_error,
944 "heartbeat expiry sweep failed; retrying next tick"
945 );
946 return;
947 }
948 };
949 for report in &reports {
950 let task_queue = report.task_queue.as_deref().unwrap_or("<unregistered>");
951 let parked = report
957 .task_queue
958 .as_deref()
959 .map(|queue| self.queue_state.parked_on_queue(queue))
960 .transpose()
961 .unwrap_or_else(|error| {
962 error!(%error, "could not read parked-dispatch count for a reaped worker");
963 None
964 })
965 .unwrap_or(0);
966 if report.tasks.is_empty() {
967 warn!(
968 worker_id = ?report.worker_id,
969 task_queue,
970 parked_dispatches = parked,
971 heartbeat_window_ms = self.heartbeat_window.as_millis(),
972 "idle worker connection lease expired; worker deregistered"
973 );
974 } else {
975 warn!(
976 worker_id = ?report.worker_id,
977 task_queue,
978 parked_dispatches = parked,
979 failed_tasks = report.tasks.len(),
980 heartbeat_window_ms = self.heartbeat_window.as_millis(),
981 "worker heartbeat window expired with in-flight activities; \
982 deregistered and surfaced as transport losses, to be \
983 re-dispatched attempt-neutrally"
984 );
985 }
986 }
987 if !reports.is_empty() {
988 self.drain.notify_activity_drained();
992 }
993 }
994}
995
996impl TaskKey {
997 fn new(worker_id: WorkerId, workflow_id: WorkflowId, activity_id: ActivityId) -> Self {
998 Self(worker_id, workflow_id, activity_id)
999 }
1000
1001 const fn worker_id(&self) -> WorkerId {
1002 self.0
1003 }
1004}
1005
1006struct DecodedHeartbeat {
1007 workflow_id: WorkflowId,
1008 activity_id: ActivityId,
1009 progress: Option<Payload>,
1010}
1011
1012impl TryFrom<ProtoHeartbeat> for DecodedHeartbeat {
1013 type Error = ServerError;
1014
1015 fn try_from(value: ProtoHeartbeat) -> Result<Self, Self::Error> {
1016 let workflow_id = value
1017 .workflow_id
1018 .ok_or_else(|| wire_error("heartbeat workflow id is missing"))
1019 .and_then(|id| WorkflowId::try_from(id).map_err(ServerError::from))?;
1020 let activity_id = value
1021 .activity_id
1022 .ok_or_else(|| wire_error("heartbeat activity id is missing"))
1023 .map(ActivityId::from)?;
1024 let progress = value
1025 .progress
1026 .map(Payload::try_from)
1027 .transpose()
1028 .map_err(ServerError::from)?;
1029 Ok(Self {
1030 workflow_id,
1031 activity_id,
1032 progress,
1033 })
1034 }
1035}
1036
1037fn task_queue_of(registry: &ConnectedWorkerRegistry, worker_id: WorkerId) -> Option<String> {
1042 registry
1043 .worker_by_id(worker_id)
1044 .ok()
1045 .flatten()
1046 .map(|handle| handle.task_queue().to_owned())
1047}
1048
1049fn is_expired(liveness: &TaskLiveness, now: Instant) -> bool {
1050 now.checked_duration_since(liveness.last_heartbeat_at)
1051 .is_some_and(|elapsed| elapsed > liveness.heartbeat_window)
1052}
1053
1054fn wire_error(message: &'static str) -> ServerError {
1055 ServerError::Wire {
1056 wire: WireError::backend(message),
1057 }
1058}
1059
1060#[cfg(test)]
1061mod reachability_tests {
1062 use std::time::{Duration, Instant};
1063
1064 use super::{DISPATCH_PROBATION_PINGS, HeartbeatTracker, ServerError, WorkerId};
1065
1066 const WINDOW: Duration = Duration::from_secs(30);
1067
1068 type TestResult = Result<(), ServerError>;
1073
1074 fn tracker_with_worker(now: Instant) -> Result<(HeartbeatTracker, WorkerId), ServerError> {
1075 let tracker = HeartbeatTracker::new(WINDOW);
1076 let worker = WorkerId::from_value(1);
1077 tracker.register_connection(worker, now)?;
1078 Ok((tracker, worker))
1079 }
1080
1081 fn serve_probation(
1086 tracker: &HeartbeatTracker,
1087 worker: WorkerId,
1088 at: Instant,
1089 ) -> Result<(), ServerError> {
1090 for _ in 0..DISPATCH_PROBATION_PINGS {
1091 assert!(
1092 tracker.record_dispatch_reachability(worker, at)?,
1093 "the worker must still be tracked while it serves its probation"
1094 );
1095 }
1096 Ok(())
1097 }
1098
1099 #[test]
1107 fn an_inbound_frame_cannot_prove_dispatch_reachability() -> TestResult {
1108 let start = Instant::now();
1109 let (tracker, worker) = tracker_with_worker(start)?;
1110 serve_probation(&tracker, worker, start)?;
1114 assert!(
1115 tracker.is_dispatch_reachable(worker, start)?,
1116 "precondition: the worker is eligible before the connection goes one-way"
1117 );
1118
1119 let much_later = start + WINDOW * 4;
1122 assert!(
1123 tracker.record_connection_activity(worker, much_later)?,
1124 "the worker is still tracked"
1125 );
1126
1127 assert!(
1128 !tracker.is_dispatch_reachable(worker, much_later)?,
1129 "a pump beat must NOT make a worker the server cannot push to look reachable"
1130 );
1131 assert_eq!(
1132 tracker.unreachable_workers(much_later)?,
1133 vec![worker],
1134 "the worker must be named unreachable however alive its process looks"
1135 );
1136 Ok(())
1137 }
1138
1139 #[test]
1142 fn an_answered_ping_does_prove_dispatch_reachability() -> TestResult {
1143 let start = Instant::now();
1144 let (tracker, worker) = tracker_with_worker(start)?;
1145
1146 let much_later = start + WINDOW * 4;
1147 serve_probation(&tracker, worker, much_later)?;
1148
1149 assert!(
1150 tracker.is_dispatch_reachable(worker, much_later)?,
1151 "answered pings are the one thing that proves the push leg works"
1152 );
1153 assert!(
1154 tracker.unreachable_workers(much_later)?.is_empty(),
1155 "a worker answering pings is never unreachable"
1156 );
1157 Ok(())
1158 }
1159
1160 #[test]
1165 fn registration_opens_a_probation_and_does_not_grant_eligibility() -> TestResult {
1166 let start = Instant::now();
1167 let (tracker, worker) = tracker_with_worker(start)?;
1168
1169 assert!(
1170 !tracker.is_dispatch_reachable(worker, start)?,
1171 "a brand-new connection has proved nothing about the push leg"
1172 );
1173 assert_eq!(
1174 tracker.unreachable_workers(start)?,
1175 vec![worker],
1176 "a worker serving its probation is carried in the census as unreachable"
1177 );
1178 Ok(())
1179 }
1180
1181 #[test]
1186 fn one_ping_short_of_the_probation_earns_nothing() -> TestResult {
1187 let start = Instant::now();
1188 let (tracker, worker) = tracker_with_worker(start)?;
1189
1190 for _ in 0..DISPATCH_PROBATION_PINGS - 1 {
1191 assert!(tracker.record_dispatch_reachability(worker, start)?);
1192 assert!(
1193 !tracker.is_dispatch_reachable(worker, start)?,
1194 "eligibility must not be granted before the probation is served in full"
1195 );
1196 }
1197
1198 assert!(tracker.record_dispatch_reachability(worker, start)?);
1199 assert!(
1200 tracker.is_dispatch_reachable(worker, start)?,
1201 "the ping that completes the probation must grant eligibility — otherwise this test \
1202 would pass on a tracker that never grants it at all"
1203 );
1204 Ok(())
1205 }
1206
1207 #[test]
1212 fn a_failed_probe_withdraws_eligibility_at_once_and_restarts_the_probation() -> TestResult {
1213 let start = Instant::now();
1214 let (tracker, worker) = tracker_with_worker(start)?;
1215 serve_probation(&tracker, worker, start)?;
1216 assert!(
1217 tracker.is_dispatch_reachable(worker, start)?,
1218 "precondition"
1219 );
1220
1221 assert!(
1222 tracker.record_dispatch_unreachable(worker)?,
1223 "the worker is still tracked"
1224 );
1225 assert!(
1226 !tracker.is_dispatch_reachable(worker, start)?,
1227 "a failed probe withdraws eligibility on the spot, inside the window"
1228 );
1229
1230 assert!(tracker.record_dispatch_reachability(worker, start)?);
1233 assert!(
1234 !tracker.is_dispatch_reachable(worker, start)?,
1235 "a single answer after a failure must not restore eligibility"
1236 );
1237 Ok(())
1238 }
1239
1240 #[test]
1245 fn a_link_that_answers_every_other_probe_never_becomes_eligible() -> TestResult {
1246 let start = Instant::now();
1247 let (tracker, worker) = tracker_with_worker(start)?;
1248
1249 for probe in 0..DISPATCH_PROBATION_PINGS * 10 {
1251 let now = start + Duration::from_millis(u64::from(probe));
1252 if probe % 2 == 0 {
1253 assert!(tracker.record_dispatch_reachability(worker, now)?);
1254 } else {
1255 assert!(tracker.record_dispatch_unreachable(worker)?);
1256 }
1257 assert!(
1258 !tracker.is_dispatch_reachable(worker, now)?,
1259 "a flapping link must never hold dispatch eligibility, at any probe (probe {probe})"
1260 );
1261 }
1262
1263 let now = start + Duration::from_secs(1);
1266 serve_probation(&tracker, worker, now)?;
1267 assert!(
1268 tracker.is_dispatch_reachable(worker, now)?,
1269 "consecutive answers must still earn eligibility"
1270 );
1271 Ok(())
1272 }
1273
1274 #[test]
1277 fn reachability_goes_stale_once_the_window_passes() -> TestResult {
1278 let start = Instant::now();
1279 let (tracker, worker) = tracker_with_worker(start)?;
1280 serve_probation(&tracker, worker, start)?;
1281
1282 assert!(
1283 tracker.is_dispatch_reachable(worker, start + WINDOW)?,
1284 "still inside the window"
1285 );
1286 assert!(
1287 !tracker.is_dispatch_reachable(worker, start + WINDOW + Duration::from_millis(1))?,
1288 "one millisecond past the window is stale"
1289 );
1290 Ok(())
1291 }
1292
1293 #[test]
1296 fn an_unregistered_worker_is_never_reachable_and_cannot_be_resurrected() -> TestResult {
1297 let start = Instant::now();
1298 let (tracker, worker) = tracker_with_worker(start)?;
1299 tracker.unregister_connection(worker)?;
1300
1301 assert!(
1302 !tracker.is_dispatch_reachable(worker, start)?,
1303 "a deregistered worker is not reachable"
1304 );
1305 assert!(
1306 !tracker.record_dispatch_reachability(worker, start)?,
1307 "a late pong must not resurrect a deregistered worker"
1308 );
1309 assert!(
1310 !tracker.record_dispatch_unreachable(worker)?,
1311 "a late probe FAILURE must not resurrect a deregistered worker either — the reset \
1312 path allocates an entry, so it has to refuse an untracked worker as firmly as the \
1313 success path does"
1314 );
1315 assert!(
1316 tracker.unreachable_workers(start)?.is_empty(),
1317 "an untracked worker is not carried in the census either"
1318 );
1319 Ok(())
1320 }
1321}
1322
1323#[cfg(test)]
1324mod tests {
1325 use std::sync::Mutex;
1326
1327 use aion_core::ContentType;
1328 use aion_proto::{ProtoActivityId, ProtoPayload, ProtoWorkflowId};
1329 use serde_json::json;
1330 use uuid::Uuid;
1331
1332 use crate::worker::registry::WorkerRegistration;
1333
1334 use super::*;
1335
1336 #[derive(Default)]
1337 struct RecordingSink {
1338 completions: Mutex<Vec<ActivityCompletion>>,
1339 parks: Mutex<Vec<(WorkflowId, ActivityId)>>,
1340 }
1341
1342 impl ActivityCompletionSink for RecordingSink {
1343 fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError> {
1344 self.completions
1345 .lock()
1346 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
1347 .push(completion);
1348 Ok(())
1349 }
1350
1351 fn park_activity(
1352 &self,
1353 workflow_id: &WorkflowId,
1354 activity_id: &ActivityId,
1355 ) -> Result<(), ServerError> {
1356 self.parks
1357 .lock()
1358 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
1359 .push((workflow_id.clone(), activity_id.clone()));
1360 Ok(())
1361 }
1362 }
1363
1364 fn workflow_id() -> WorkflowId {
1365 WorkflowId::new(Uuid::nil())
1366 }
1367
1368 fn activity_id(position: u64) -> ActivityId {
1369 ActivityId::from_sequence_position(position)
1370 }
1371
1372 fn payload(value: &serde_json::Value) -> Result<Payload, Box<dyn std::error::Error>> {
1373 Ok(Payload::from_json(value)?)
1374 }
1375
1376 fn heartbeat(
1377 workflow_id: WorkflowId,
1378 activity_id: ActivityId,
1379 progress: Option<Payload>,
1380 ) -> ProtoHeartbeat {
1381 ProtoHeartbeat {
1382 workflow_id: Some(ProtoWorkflowId::from(workflow_id)),
1383 activity_id: Some(ProtoActivityId::from(activity_id)),
1384 progress: progress.map(ProtoPayload::from),
1385 }
1386 }
1387
1388 fn registry_with_worker()
1389 -> Result<(ConnectedWorkerRegistry, WorkerRegistration, WorkerId), ServerError> {
1390 let registry = ConnectedWorkerRegistry::default();
1391 let (tx, _rx) = tokio::sync::mpsc::channel(1);
1392 let activity_types = [String::from("charge-card")];
1393 let registration = registry.register("tenant-a", activity_types.iter(), tx)?;
1394 let worker_id = registration
1395 .worker_id()
1396 .ok_or_else(|| ServerError::lock_poisoned("test worker registration"))?;
1397 Ok((registry, registration, worker_id))
1398 }
1399
1400 #[test]
1401 fn heartbeat_refresh_keeps_task_live_across_window() -> Result<(), Box<dyn std::error::Error>> {
1402 let window = Duration::from_secs(5);
1403 let tracker = HeartbeatTracker::new(window);
1404 let worker_id = WorkerIdForTest::registered()?;
1405 let workflow_id = workflow_id();
1406 let activity_id = activity_id(10);
1407 let start = Instant::now();
1408
1409 tracker.track_task(
1410 worker_id,
1411 InFlightActivity {
1412 workflow_id: workflow_id.clone(),
1413 activity_id: activity_id.clone(),
1414 completion_token: crate::worker::CompletionToken::for_test(),
1415 },
1416 start,
1417 )?;
1418 assert!(tracker.is_live(worker_id, &workflow_id, &activity_id, start + window)?);
1419
1420 let progress = payload(&json!({"percent": 50}))?;
1421 let update = tracker.record_heartbeat(
1422 worker_id,
1423 heartbeat(
1424 workflow_id.clone(),
1425 activity_id.clone(),
1426 Some(progress.clone()),
1427 ),
1428 start + window,
1429 )?;
1430
1431 assert_eq!(update.liveness.last_progress, Some(progress));
1432 assert!(tracker.is_live(
1433 worker_id,
1434 &workflow_id,
1435 &activity_id,
1436 start + window + window
1437 )?);
1438 assert!(tracker.expired_workers(start + window + window)?.is_empty());
1439 Ok(())
1440 }
1441
1442 #[test]
1443 fn missed_heartbeat_deregisters_worker_and_fails_in_flight_once()
1444 -> Result<(), Box<dyn std::error::Error>> {
1445 let (registry, _registration, worker_id) = registry_with_worker()?;
1446 let sink = RecordingSink::default();
1447 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
1448 let workflow_id = workflow_id();
1449 let activity_id = activity_id(11);
1450 let start = Instant::now();
1451
1452 tracker.track_task(
1453 worker_id,
1454 InFlightActivity {
1455 workflow_id: workflow_id.clone(),
1456 activity_id: activity_id.clone(),
1457 completion_token: crate::worker::CompletionToken::for_test(),
1458 },
1459 start,
1460 )?;
1461
1462 let reports =
1463 tracker.fail_expired_workers(®istry, &sink, start + Duration::from_secs(6))?;
1464 assert_eq!(reports.len(), 1);
1465 assert_eq!(reports[0].worker_id, worker_id);
1466 assert_eq!(reports[0].tasks.len(), 1);
1467 assert!(
1468 registry
1469 .workers_for("tenant-a", "default", "charge-card", None)?
1470 .is_empty()
1471 );
1472
1473 let second = tracker.fail_disconnected_worker(worker_id, ®istry, &sink)?;
1474 assert!(second.tasks.is_empty());
1475 let completions = sink
1476 .completions
1477 .lock()
1478 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
1479 assert_eq!(completions.len(), 1);
1480 assert_eq!(completions[0].workflow_id, workflow_id);
1481 assert_eq!(completions[0].activity_id, activity_id);
1482 match &completions[0].outcome {
1489 ActivityCompletionOutcome::WorkerLost { worker_id: lost } => {
1490 assert_eq!(*lost, worker_id);
1491 }
1492 other => {
1493 return Err(format!("expected a lost-worker outcome, got {other:?}").into());
1494 }
1495 }
1496 Ok(())
1497 }
1498
1499 #[test]
1500 fn disconnected_worker_fails_each_in_flight_task_once() -> Result<(), Box<dyn std::error::Error>>
1501 {
1502 let (registry, _registration, worker_id) = registry_with_worker()?;
1503 let sink = RecordingSink::default();
1504 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
1505 let workflow_id = workflow_id();
1506 let start = Instant::now();
1507
1508 tracker.track_task(
1509 worker_id,
1510 InFlightActivity {
1511 workflow_id: workflow_id.clone(),
1512 activity_id: activity_id(21),
1513 completion_token: crate::worker::CompletionToken::for_test(),
1514 },
1515 start,
1516 )?;
1517 tracker.track_task(
1518 worker_id,
1519 InFlightActivity {
1520 workflow_id,
1521 activity_id: activity_id(22),
1522 completion_token: crate::worker::CompletionToken::for_test(),
1523 },
1524 start,
1525 )?;
1526
1527 let report = tracker.fail_disconnected_worker(worker_id, ®istry, &sink)?;
1528 assert_eq!(report.tasks.len(), 2);
1529 assert!(
1530 registry
1531 .workers_for("tenant-a", "default", "charge-card", None)?
1532 .is_empty()
1533 );
1534
1535 let completions = sink
1536 .completions
1537 .lock()
1538 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
1539 assert_eq!(completions.len(), 2);
1540 assert!(completions.iter().all(|completion| matches!(
1541 &completion.outcome,
1542 ActivityCompletionOutcome::WorkerLost { .. }
1543 )));
1544 Ok(())
1545 }
1546
1547 #[test]
1553 fn park_disconnected_worker_parks_tasks_without_synthesizing_completions()
1554 -> Result<(), Box<dyn std::error::Error>> {
1555 let (registry, _registration, worker_id) = registry_with_worker()?;
1556 let sink = RecordingSink::default();
1557 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
1558 let workflow_id = workflow_id();
1559 let start = Instant::now();
1560 tracker.track_task(
1561 worker_id,
1562 InFlightActivity {
1563 workflow_id: workflow_id.clone(),
1564 activity_id: activity_id(60),
1565 completion_token: crate::worker::CompletionToken::for_test(),
1566 },
1567 start,
1568 )?;
1569 tracker.track_task(
1570 worker_id,
1571 InFlightActivity {
1572 workflow_id: workflow_id.clone(),
1573 activity_id: activity_id(61),
1574 completion_token: crate::worker::CompletionToken::for_test(),
1575 },
1576 start,
1577 )?;
1578
1579 let report = tracker.park_disconnected_worker(worker_id, ®istry, &sink)?;
1580 assert_eq!(report.tasks.len(), 2);
1581 assert_eq!(
1582 tracker.in_flight_count()?,
1583 0,
1584 "parking must remove every tracked task so drain accounting reaches zero"
1585 );
1586 assert!(
1587 registry
1588 .workers_for("tenant-a", "default", "charge-card", None)?
1589 .is_empty(),
1590 "the parked worker must be deregistered from routing"
1591 );
1592 let parks = sink
1593 .parks
1594 .lock()
1595 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
1596 assert_eq!(parks.len(), 2, "each task must be parked exactly once");
1597 drop(parks);
1598 assert!(
1599 sink.completions
1600 .lock()
1601 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
1602 .is_empty(),
1603 "parking must never synthesize an activity completion"
1604 );
1605
1606 let second = tracker.park_disconnected_worker(worker_id, ®istry, &sink)?;
1608 assert!(second.tasks.is_empty());
1609 let third = tracker.fail_disconnected_worker(worker_id, ®istry, &sink)?;
1610 assert!(third.tasks.is_empty());
1611 assert_eq!(
1612 sink.parks
1613 .lock()
1614 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
1615 .len(),
1616 2,
1617 "re-sweeping a parked worker must park nothing further"
1618 );
1619 assert!(
1620 sink.completions
1621 .lock()
1622 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
1623 .is_empty(),
1624 "a fail sweep after the park must fail nothing"
1625 );
1626 Ok(())
1627 }
1628
1629 #[tokio::test]
1633 async fn park_all_in_flight_workers_parks_everything_and_wakes_drain_waiters()
1634 -> Result<(), Box<dyn std::error::Error>> {
1635 let (registry, _registration, worker_id) = registry_with_worker()?;
1636 let sink = RecordingSink::default();
1637 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
1638 let workflow_id = workflow_id();
1639 tracker.track_task(
1640 worker_id,
1641 InFlightActivity {
1642 workflow_id: workflow_id.clone(),
1643 activity_id: activity_id(70),
1644 completion_token: crate::worker::CompletionToken::for_test(),
1645 },
1646 Instant::now(),
1647 )?;
1648 let notified = tracker.empty.notified();
1650 tokio::pin!(notified);
1651
1652 let reports = tracker.park_all_in_flight_workers(®istry, &sink)?;
1653 assert_eq!(reports.len(), 1);
1654 assert_eq!(reports[0].worker_id, worker_id);
1655 assert_eq!(reports[0].tasks.len(), 1);
1656 assert_eq!(tracker.in_flight_count()?, 0);
1657 assert_eq!(
1658 sink.parks
1659 .lock()
1660 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
1661 .len(),
1662 1
1663 );
1664 assert!(
1665 sink.completions
1666 .lock()
1667 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
1668 .is_empty(),
1669 "the bulk park must never synthesize a completion"
1670 );
1671 tokio::time::timeout(Duration::from_millis(200), notified)
1672 .await
1673 .map_err(|_| "the bulk park must wake drain waiters")?;
1674 Ok(())
1675 }
1676
1677 #[test]
1682 fn payload_free_heartbeat_refreshes_liveness_without_clearing_progress()
1683 -> Result<(), Box<dyn std::error::Error>> {
1684 let window = Duration::from_secs(5);
1685 let tracker = HeartbeatTracker::new(window);
1686 let worker_id = WorkerIdForTest::registered()?;
1687 let workflow_id = workflow_id();
1688 let activity_id = activity_id(12);
1689 let start = Instant::now();
1690
1691 tracker.track_task(
1692 worker_id,
1693 InFlightActivity {
1694 workflow_id: workflow_id.clone(),
1695 activity_id: activity_id.clone(),
1696 completion_token: crate::worker::CompletionToken::for_test(),
1697 },
1698 start,
1699 )?;
1700 let progress = payload(&json!({"percent": 80}))?;
1701 tracker.record_heartbeat(
1702 worker_id,
1703 heartbeat(
1704 workflow_id.clone(),
1705 activity_id.clone(),
1706 Some(progress.clone()),
1707 ),
1708 start + Duration::from_secs(1),
1709 )?;
1710
1711 let update = tracker.record_heartbeat(
1713 worker_id,
1714 heartbeat(workflow_id.clone(), activity_id.clone(), None),
1715 start + Duration::from_secs(4),
1716 )?;
1717
1718 assert_eq!(
1719 update.liveness.last_progress,
1720 Some(progress),
1721 "a payload-free liveness beat must not erase handler progress"
1722 );
1723 assert!(
1724 tracker.is_live(
1725 worker_id,
1726 &workflow_id,
1727 &activity_id,
1728 start + Duration::from_secs(8)
1729 )?,
1730 "the payload-free beat must still refresh the liveness stamp"
1731 );
1732 Ok(())
1733 }
1734
1735 #[test]
1736 fn malformed_heartbeat_missing_ids_is_wire_error() -> Result<(), Box<dyn std::error::Error>> {
1737 let worker_id = WorkerIdForTest::registered()?;
1738 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
1739 let missing = ProtoHeartbeat {
1740 workflow_id: None,
1741 activity_id: Some(ProtoActivityId::from(activity_id(30))),
1742 progress: None,
1743 };
1744
1745 let result = tracker.record_heartbeat(worker_id, missing, Instant::now());
1746 assert!(matches!(result, Err(ServerError::Wire { .. })));
1747 Ok(())
1748 }
1749
1750 #[test]
1751 fn heartbeat_progress_is_not_reported_as_activity_result()
1752 -> Result<(), Box<dyn std::error::Error>> {
1753 let sink = RecordingSink::default();
1754 let worker_id = WorkerIdForTest::registered()?;
1755 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
1756 let workflow_id = workflow_id();
1757 let activity_id = activity_id(40);
1758 let now = Instant::now();
1759
1760 tracker.track_task(
1761 worker_id,
1762 InFlightActivity {
1763 workflow_id: workflow_id.clone(),
1764 activity_id: activity_id.clone(),
1765 completion_token: crate::worker::CompletionToken::for_test(),
1766 },
1767 now,
1768 )?;
1769 tracker.record_heartbeat(
1770 worker_id,
1771 heartbeat(
1772 workflow_id,
1773 activity_id,
1774 Some(Payload::new(
1775 ContentType::Json,
1776 b"{\"progress\":1}".to_vec(),
1777 )),
1778 ),
1779 now,
1780 )?;
1781
1782 let completions = sink
1783 .completions
1784 .lock()
1785 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
1786 assert!(completions.is_empty());
1787 Ok(())
1788 }
1789
1790 struct WorkerIdForTest;
1791
1792 impl WorkerIdForTest {
1793 fn registered() -> Result<WorkerId, ServerError> {
1794 let (_registry, _registration, worker_id) = registry_with_worker()?;
1795 Ok(worker_id)
1796 }
1797 }
1798
1799 #[test]
1803 fn complete_task_reports_whether_the_entry_was_tracked()
1804 -> Result<(), Box<dyn std::error::Error>> {
1805 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
1806 let worker_id = WorkerIdForTest::registered()?;
1807 let workflow_id = workflow_id();
1808 let id = activity_id(50);
1809 tracker.track_task(
1810 worker_id,
1811 InFlightActivity {
1812 workflow_id: workflow_id.clone(),
1813 activity_id: id.clone(),
1814 completion_token: crate::worker::CompletionToken::for_test(),
1815 },
1816 Instant::now(),
1817 )?;
1818
1819 assert!(tracker.is_tracked(worker_id, &workflow_id, &id)?);
1820 assert!(
1821 tracker.complete_task(worker_id, &workflow_id, &id)?,
1822 "the first completion retires the tracked entry"
1823 );
1824 assert!(!tracker.is_tracked(worker_id, &workflow_id, &id)?);
1825 assert!(
1826 !tracker.complete_task(worker_id, &workflow_id, &id)?,
1827 "a second completion finds nothing to retire"
1828 );
1829 Ok(())
1830 }
1831
1832 #[test]
1836 fn record_liveness_refreshes_stamp_and_ignores_untracked_tasks()
1837 -> Result<(), Box<dyn std::error::Error>> {
1838 let window = Duration::from_secs(5);
1839 let tracker = HeartbeatTracker::new(window);
1840 let worker_id = WorkerIdForTest::registered()?;
1841 let workflow_id = workflow_id();
1842 let id = activity_id(51);
1843 let start = Instant::now();
1844 tracker.track_task(
1845 worker_id,
1846 InFlightActivity {
1847 workflow_id: workflow_id.clone(),
1848 activity_id: id.clone(),
1849 completion_token: crate::worker::CompletionToken::for_test(),
1850 },
1851 start,
1852 )?;
1853
1854 assert!(tracker.record_liveness(worker_id, &workflow_id, &id, start + window)?);
1856 assert!(tracker.is_live(worker_id, &workflow_id, &id, start + window + window)?);
1857 assert!(tracker.expired_workers(start + window + window)?.is_empty());
1858
1859 assert!(!tracker.record_liveness(
1862 worker_id,
1863 &workflow_id,
1864 &activity_id(52),
1865 start + window
1866 )?);
1867 Ok(())
1868 }
1869
1870 #[test]
1871 fn sweep_interval_is_quarter_window_clamped_to_one_second_and_window() {
1872 assert_eq!(
1874 sweep_interval(Duration::from_secs(30)),
1875 Duration::from_millis(7_500)
1876 );
1877 assert_eq!(
1879 sweep_interval(Duration::from_secs(2)),
1880 Duration::from_secs(1)
1881 );
1882 assert_eq!(
1884 sweep_interval(Duration::from_secs(3_600)),
1885 Duration::from_secs(900)
1886 );
1887 assert_eq!(
1890 sweep_interval(Duration::from_millis(200)),
1891 Duration::from_millis(200)
1892 );
1893 assert_eq!(sweep_interval(Duration::ZERO), Duration::from_millis(1));
1896 }
1897}