1use std::collections::{BTreeMap, BTreeSet};
4use std::future::Future;
5use std::time::Duration;
6
7use aion_core::{ActivityError, ActivityId, Payload, RunId, WorkflowId};
8use tracing::{debug, error, warn};
9use uuid::Uuid;
10
11use crate::config::WorkerConfig;
12use crate::error::WorkerError;
13use crate::protocol::{GrpcWorkerSession, WorkerSession};
14
15#[derive(Clone, Debug, PartialEq, Eq)]
17pub enum PendingActivityReport {
18 Completed {
20 workflow_id: WorkflowId,
22 activity_id: ActivityId,
24 run_id: Option<RunId>,
26 output: Payload,
28 },
29 Failed {
31 workflow_id: WorkflowId,
33 activity_id: ActivityId,
35 run_id: Option<RunId>,
37 failure: ActivityError,
39 },
40}
41
42impl PendingActivityReport {
43 #[must_use]
45 pub const fn activity_id(&self) -> &ActivityId {
46 match self {
47 Self::Completed { activity_id, .. } | Self::Failed { activity_id, .. } => activity_id,
48 }
49 }
50
51 #[must_use]
53 pub const fn workflow_id(&self) -> &WorkflowId {
54 match self {
55 Self::Completed { workflow_id, .. } | Self::Failed { workflow_id, .. } => workflow_id,
56 }
57 }
58}
59
60type PendingReportKey = (Uuid, u64);
64
65fn pending_report_key(workflow_id: &WorkflowId, activity_id: &ActivityId) -> PendingReportKey {
66 (workflow_id.as_uuid(), activity_id.sequence_position())
67}
68
69#[derive(Clone, Debug, Default, PartialEq, Eq)]
71pub struct UnackedResultTracker {
72 reports: BTreeMap<PendingReportKey, PendingActivityReport>,
73}
74
75impl UnackedResultTracker {
76 #[must_use]
78 pub const fn new() -> Self {
79 Self {
80 reports: BTreeMap::new(),
81 }
82 }
83
84 pub fn record(&mut self, report: PendingActivityReport) {
87 let key = pending_report_key(report.workflow_id(), report.activity_id());
88 self.reports.insert(key, report);
89 }
90
91 pub fn acknowledge(
93 &mut self,
94 workflow_id: &WorkflowId,
95 activity_id: &ActivityId,
96 ) -> Option<PendingActivityReport> {
97 self.reports
98 .remove(&pending_report_key(workflow_id, activity_id))
99 }
100
101 #[must_use]
103 pub fn len(&self) -> usize {
104 self.reports.len()
105 }
106
107 #[must_use]
109 pub fn is_empty(&self) -> bool {
110 self.reports.is_empty()
111 }
112
113 #[must_use]
115 pub fn get(
116 &self,
117 workflow_id: &WorkflowId,
118 activity_id: &ActivityId,
119 ) -> Option<&PendingActivityReport> {
120 self.reports
121 .get(&pending_report_key(workflow_id, activity_id))
122 }
123
124 #[must_use]
126 pub fn snapshot(&self) -> Vec<PendingActivityReport> {
127 self.reports.values().cloned().collect()
128 }
129}
130
131#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct ReconnectBackoff {
134 initial: Duration,
135 max: Duration,
136 attempts: usize,
137}
138
139impl ReconnectBackoff {
140 pub fn from_config(config: &WorkerConfig) -> Result<Self, WorkerError> {
146 if config.reconnect.initial_backoff.is_zero() {
147 return Err(WorkerError::registration(InvalidReconnectBackoff {
148 message: String::from("reconnect initial_backoff must be greater than zero"),
149 }));
150 }
151 if config.reconnect.max_backoff.is_zero() {
152 return Err(WorkerError::registration(InvalidReconnectBackoff {
153 message: String::from("reconnect max_backoff must be greater than zero"),
154 }));
155 }
156 if config.reconnect.max_attempts == 0 {
157 return Err(WorkerError::registration(InvalidReconnectBackoff {
158 message: String::from("reconnect max_attempts must be greater than zero"),
159 }));
160 }
161 Ok(Self {
162 initial: config.reconnect.initial_backoff,
163 max: config.reconnect.max_backoff,
164 attempts: config.reconnect.max_attempts,
165 })
166 }
167
168 #[must_use]
173 pub fn delay_for_attempt(&self, completed_failures: usize) -> Duration {
174 let bounded_shift = completed_failures.saturating_sub(1).min(31);
175 let shift = u32::try_from(bounded_shift).map_or(31, |shift| shift);
176 let factor = 1_u32.checked_shl(shift).map_or(u32::MAX, |factor| factor);
177 self.initial.saturating_mul(factor).min(self.max)
178 }
179
180 #[must_use]
182 pub const fn attempts(&self) -> usize {
183 self.attempts
184 }
185
186 #[must_use]
194 pub const fn max_delay(&self) -> Duration {
195 self.max
196 }
197}
198
199pub async fn connect_registered_grpc_session(
205 config: &WorkerConfig,
206 activity_types: Vec<String>,
207 available_handlers: &BTreeSet<String>,
208) -> Result<GrpcWorkerSession, WorkerError> {
209 let session = GrpcWorkerSession::connect(config.clone()).await?;
210 register_connected_session(session, config, activity_types, available_handlers).await
211}
212
213pub async fn register_connected_session<S>(
219 mut session: S,
220 config: &WorkerConfig,
221 activity_types: Vec<String>,
222 available_handlers: &BTreeSet<String>,
223) -> Result<S, WorkerError>
224where
225 S: WorkerSession,
226{
227 session.handshake(config).await?;
228 session.register(activity_types, available_handlers).await?;
229 Ok(session)
230}
231
232pub async fn reconnect_with_backoff<S, F, Fut>(
241 config: &WorkerConfig,
242 activity_types: Vec<String>,
243 available_handlers: &BTreeSet<String>,
244 connect: F,
245) -> Result<S, WorkerError>
246where
247 S: WorkerSession,
248 F: FnMut() -> Fut,
249 Fut: Future<Output = Result<S, WorkerError>>,
250{
251 reconnect_with_sleep(
252 config,
253 activity_types,
254 available_handlers,
255 connect,
256 tokio::time::sleep,
257 )
258 .await
259}
260
261pub async fn reconnect_with_sleep<S, F, Fut, Sleep, SleepFut>(
271 config: &WorkerConfig,
272 activity_types: Vec<String>,
273 available_handlers: &BTreeSet<String>,
274 mut connect: F,
275 mut sleep: Sleep,
276) -> Result<S, WorkerError>
277where
278 S: WorkerSession,
279 F: FnMut() -> Fut,
280 Fut: Future<Output = Result<S, WorkerError>>,
281 Sleep: FnMut(Duration) -> SleepFut,
282 SleepFut: Future<Output = ()>,
283{
284 let backoff = ReconnectBackoff::from_config(config)?;
285
286 for attempt in 1..=backoff.attempts() {
287 debug!(attempt, "attempting worker reconnect");
288 let result = match connect().await {
289 Ok(session) => {
290 register_connected_session(
291 session,
292 config,
293 activity_types.clone(),
294 available_handlers,
295 )
296 .await
297 }
298 Err(error) => Err(error),
299 };
300
301 match result {
302 Ok(session) => {
303 debug!(attempt, "worker reconnect succeeded");
304 return Ok(session);
305 }
306 Err(error) => {
307 if !error.is_retryable() {
308 error!(
309 attempt,
310 error = %error,
311 "worker reconnect denied by server; not retrying"
312 );
313 return Err(error);
314 }
315 if attempt == backoff.attempts() {
316 error!(attempt, error = %error, "worker reconnect attempts exhausted");
317 return Err(error);
318 }
319 let delay = backoff.delay_for_attempt(attempt);
320 warn!(
321 attempt,
322 delay_ms = delay.as_millis(),
323 error = %error,
324 "worker reconnect failed; backing off"
325 );
326 sleep(delay).await;
327 }
328 }
329 }
330
331 Err(WorkerError::registration(InvalidReconnectBackoff {
332 message: String::from("reconnect_max_attempts must be greater than zero"),
333 }))
334}
335
336pub async fn re_report_unacked<S>(
347 tracker: &UnackedResultTracker,
348 session: &mut S,
349) -> Result<(), WorkerError>
350where
351 S: WorkerSession,
352{
353 for report in tracker.snapshot() {
354 match report {
355 PendingActivityReport::Completed {
356 workflow_id,
357 activity_id,
358 run_id,
359 output,
360 } => {
361 debug!(
362 workflow_id = %workflow_id,
363 activity_id = activity_id.sequence_position(),
364 "re-reporting unacknowledged activity result"
365 );
366 session
367 .report_result(workflow_id, activity_id, run_id, output)
368 .await?;
369 }
370 PendingActivityReport::Failed {
371 workflow_id,
372 activity_id,
373 run_id,
374 failure,
375 } => {
376 debug!(
377 workflow_id = %workflow_id,
378 activity_id = activity_id.sequence_position(),
379 "re-reporting unacknowledged activity failure"
380 );
381 session
382 .report_failure(workflow_id, activity_id, run_id, failure)
383 .await?;
384 }
385 }
386 }
387 Ok(())
388}
389
390#[derive(Debug, thiserror::Error)]
391#[error("{message}")]
392struct InvalidReconnectBackoff {
393 message: String,
394}
395
396#[cfg(test)]
397mod tests {
398 use std::cell::RefCell;
399 use std::collections::BTreeSet;
400 use std::rc::Rc;
401 use std::time::Duration;
402
403 use aion_core::{
404 ActivityError, ActivityErrorKind, ActivityId, ContentType, Payload, RunId, WorkflowId,
405 };
406 use async_trait::async_trait;
407 use futures::stream;
408
409 use super::{
410 PendingActivityReport, UnackedResultTracker, re_report_unacked, reconnect_with_sleep,
411 };
412 use crate::error::WorkerError;
413 use crate::protocol::{
414 WorkerSession, WorkerSessionEvent, WorkerTaskStream, validate_activity_handlers,
415 };
416 use crate::{ReconnectConfig, WorkerConfig};
417
418 #[test]
419 fn tracker_records_reports_and_acknowledges_by_workflow_and_activity_id() {
420 let workflow_id = WorkflowId::new_v4();
421 let first_id = ActivityId::from_sequence_position(1);
422 let second_id = ActivityId::from_sequence_position(2);
423 let mut tracker = UnackedResultTracker::new();
424
425 tracker.record(PendingActivityReport::Completed {
426 workflow_id: workflow_id.clone(),
427 activity_id: first_id.clone(),
428 run_id: None,
429 output: Payload::new(ContentType::Json, b"{\"first\":true}".to_vec()),
430 });
431 tracker.record(PendingActivityReport::Completed {
432 workflow_id: workflow_id.clone(),
433 activity_id: second_id.clone(),
434 run_id: None,
435 output: Payload::new(ContentType::Json, b"{\"second\":true}".to_vec()),
436 });
437
438 assert_eq!(tracker.len(), 2);
439 assert!(tracker.acknowledge(&workflow_id, &first_id).is_some());
440 assert_eq!(tracker.len(), 1);
441 assert!(tracker.get(&workflow_id, &second_id).is_some());
442 assert!(tracker.get(&workflow_id, &first_id).is_none());
443 }
444
445 #[test]
446 fn tracker_keeps_reports_for_distinct_workflows_at_the_same_sequence_position() {
447 let first_workflow = WorkflowId::new_v4();
448 let second_workflow = WorkflowId::new_v4();
449 let activity_id = ActivityId::from_sequence_position(3);
450 let mut tracker = UnackedResultTracker::new();
451
452 tracker.record(PendingActivityReport::Completed {
453 workflow_id: first_workflow.clone(),
454 activity_id: activity_id.clone(),
455 run_id: None,
456 output: Payload::new(ContentType::Json, b"{\"workflow\":\"a\"}".to_vec()),
457 });
458 tracker.record(PendingActivityReport::Completed {
459 workflow_id: second_workflow.clone(),
460 activity_id: activity_id.clone(),
461 run_id: None,
462 output: Payload::new(ContentType::Json, b"{\"workflow\":\"b\"}".to_vec()),
463 });
464
465 assert_eq!(tracker.len(), 2);
466 assert!(tracker.get(&first_workflow, &activity_id).is_some());
467 assert!(tracker.get(&second_workflow, &activity_id).is_some());
468 assert!(
469 tracker.acknowledge(&first_workflow, &activity_id).is_some(),
470 "acknowledging one workflow's report must not require the other's"
471 );
472 assert_eq!(tracker.len(), 1);
473 assert!(tracker.get(&second_workflow, &activity_id).is_some());
474 }
475
476 #[tokio::test]
477 async fn reconnect_fails_once_then_handshakes_and_registers() -> Result<(), WorkerError> {
478 let config = test_config();
479 let attempts = Rc::new(RefCell::new(0usize));
480 let sleeps = Rc::new(RefCell::new(Vec::new()));
481 let activity_types = vec![String::from("charge-card")];
482 let handlers = activity_types.iter().cloned().collect::<BTreeSet<_>>();
483 let attempts_for_connect = Rc::clone(&attempts);
484 let sleeps_for_sleep = Rc::clone(&sleeps);
485
486 let session = reconnect_with_sleep(
487 &config,
488 activity_types.clone(),
489 &handlers,
490 move || {
491 let attempts_for_connect = Rc::clone(&attempts_for_connect);
492 async move {
493 let mut attempts = attempts_for_connect.borrow_mut();
494 *attempts += 1;
495 if *attempts == 1 {
496 Err(WorkerError::Transport {
497 source: tonic::Status::unavailable("disconnected"),
498 })
499 } else {
500 Ok(ReconnectFakeSession::default())
501 }
502 }
503 },
504 move |delay| {
505 let sleeps_for_sleep = Rc::clone(&sleeps_for_sleep);
506 async move {
507 sleeps_for_sleep.borrow_mut().push(delay);
508 }
509 },
510 )
511 .await?;
512
513 assert_eq!(*attempts.borrow(), 2);
514 assert_eq!(*sleeps.borrow(), vec![Duration::from_millis(5)]);
515 assert_eq!(session.handshakes, vec![String::from("worker-a")]);
516 assert_eq!(session.registrations, vec![activity_types]);
517 Ok(())
518 }
519
520 #[tokio::test]
521 async fn permission_denied_registration_stops_after_one_attempt() {
522 let config = test_config();
523 let attempts = Rc::new(RefCell::new(0usize));
524 let sleeps = Rc::new(RefCell::new(Vec::new()));
525 let activity_types = vec![String::from("charge-card")];
526 let handlers = activity_types.iter().cloned().collect::<BTreeSet<_>>();
527 let attempts_for_connect = Rc::clone(&attempts);
528 let sleeps_for_sleep = Rc::clone(&sleeps);
529
530 let result = reconnect_with_sleep(
531 &config,
532 activity_types,
533 &handlers,
534 move || {
535 let attempts_for_connect = Rc::clone(&attempts_for_connect);
536 async move {
537 *attempts_for_connect.borrow_mut() += 1;
538 Ok(DeniedRegistrationSession {
539 denial: tonic::Status::permission_denied(
540 "namespace `payments` is not granted to subject `worker-a`",
541 ),
542 })
543 }
544 },
545 move |delay| {
546 let sleeps_for_sleep = Rc::clone(&sleeps_for_sleep);
547 async move {
548 sleeps_for_sleep.borrow_mut().push(delay);
549 }
550 },
551 )
552 .await;
553
554 assert!(result.is_err());
555 let Err(error) = result else { return };
556 assert_eq!(*attempts.borrow(), 1);
557 assert!(sleeps.borrow().is_empty());
558 assert!(!error.is_retryable());
559 assert!(matches!(
560 error.grpc_status().map(tonic::Status::code),
561 Some(tonic::Code::PermissionDenied)
562 ));
563 assert_eq!(
564 error.grpc_status().map(tonic::Status::message),
565 Some("namespace `payments` is not granted to subject `worker-a`")
566 );
567 assert!(
568 error
569 .to_string()
570 .contains("namespace `payments` is not granted to subject `worker-a`")
571 );
572 }
573
574 #[tokio::test]
575 async fn unauthenticated_handshake_stops_after_one_attempt() {
576 let config = test_config();
577 let attempts = Rc::new(RefCell::new(0usize));
578 let sleeps = Rc::new(RefCell::new(Vec::new()));
579 let activity_types = vec![String::from("charge-card")];
580 let handlers = activity_types.iter().cloned().collect::<BTreeSet<_>>();
581 let attempts_for_connect = Rc::clone(&attempts);
582 let sleeps_for_sleep = Rc::clone(&sleeps);
583
584 let result = reconnect_with_sleep(
585 &config,
586 activity_types,
587 &handlers,
588 move || {
589 let attempts_for_connect = Rc::clone(&attempts_for_connect);
590 async move {
591 *attempts_for_connect.borrow_mut() += 1;
592 Err::<ReconnectFakeSession, _>(WorkerError::Handshake {
593 source: tonic::Status::unauthenticated("worker credentials were rejected"),
594 })
595 }
596 },
597 move |delay| {
598 let sleeps_for_sleep = Rc::clone(&sleeps_for_sleep);
599 async move {
600 sleeps_for_sleep.borrow_mut().push(delay);
601 }
602 },
603 )
604 .await;
605
606 assert!(result.is_err());
607 let Err(error) = result else { return };
608 assert_eq!(*attempts.borrow(), 1);
609 assert!(sleeps.borrow().is_empty());
610 assert!(!error.is_retryable());
611 assert!(matches!(
612 error.grpc_status().map(tonic::Status::code),
613 Some(tonic::Code::Unauthenticated)
614 ));
615 assert!(
616 error
617 .to_string()
618 .contains("worker credentials were rejected")
619 );
620 }
621
622 #[tokio::test]
623 async fn unavailable_transport_retries_until_attempts_exhausted() {
624 let config = test_config();
625 let attempts = Rc::new(RefCell::new(0usize));
626 let sleeps = Rc::new(RefCell::new(Vec::new()));
627 let activity_types = vec![String::from("charge-card")];
628 let handlers = activity_types.iter().cloned().collect::<BTreeSet<_>>();
629 let attempts_for_connect = Rc::clone(&attempts);
630 let sleeps_for_sleep = Rc::clone(&sleeps);
631
632 let result = reconnect_with_sleep(
633 &config,
634 activity_types,
635 &handlers,
636 move || {
637 let attempts_for_connect = Rc::clone(&attempts_for_connect);
638 async move {
639 *attempts_for_connect.borrow_mut() += 1;
640 Err::<ReconnectFakeSession, _>(WorkerError::Transport {
641 source: tonic::Status::unavailable("engine unreachable"),
642 })
643 }
644 },
645 move |delay| {
646 let sleeps_for_sleep = Rc::clone(&sleeps_for_sleep);
647 async move {
648 sleeps_for_sleep.borrow_mut().push(delay);
649 }
650 },
651 )
652 .await;
653
654 assert!(result.is_err());
655 let Err(error) = result else { return };
656 assert_eq!(*attempts.borrow(), 3);
657 assert_eq!(
658 *sleeps.borrow(),
659 vec![Duration::from_millis(5), Duration::from_millis(10)]
660 );
661 assert!(error.is_retryable());
662 assert!(matches!(
663 error.grpc_status().map(tonic::Status::code),
664 Some(tonic::Code::Unavailable)
665 ));
666 }
667
668 #[tokio::test]
669 async fn re_reports_unacked_reports_without_removing_them() -> Result<(), WorkerError> {
670 let workflow_id = WorkflowId::new_v4();
671 let activity_id = ActivityId::from_sequence_position(7);
672 let output = Payload::new(ContentType::Json, b"{}".to_vec());
673 let mut tracker = UnackedResultTracker::new();
674 tracker.record(PendingActivityReport::Completed {
675 workflow_id: workflow_id.clone(),
676 activity_id: activity_id.clone(),
677 run_id: None,
678 output: output.clone(),
679 });
680 let mut session = ReconnectFakeSession::default();
681
682 re_report_unacked(&tracker, &mut session).await?;
683
684 assert_eq!(tracker.len(), 1);
685 assert_eq!(
686 session.reports,
687 vec![RecordedReport::Completed(workflow_id, activity_id, output)]
688 );
689 Ok(())
690 }
691
692 #[derive(Default)]
693 struct ReconnectFakeSession {
694 handshakes: Vec<String>,
695 registrations: Vec<Vec<String>>,
696 reports: Vec<RecordedReport>,
697 }
698
699 struct DeniedRegistrationSession {
703 denial: tonic::Status,
704 }
705
706 #[async_trait]
707 impl WorkerSession for DeniedRegistrationSession {
708 async fn handshake(&mut self, _config: &WorkerConfig) -> Result<(), WorkerError> {
709 Ok(())
710 }
711
712 async fn register(
713 &mut self,
714 activity_types: Vec<String>,
715 available_handlers: &BTreeSet<String>,
716 ) -> Result<(), WorkerError> {
717 validate_activity_handlers(&activity_types, available_handlers)?;
718 Err(WorkerError::Registration {
719 source: Box::new(self.denial.clone()),
720 })
721 }
722
723 fn receive_tasks(&mut self) -> WorkerTaskStream {
724 Box::pin(stream::empty::<Result<WorkerSessionEvent, WorkerError>>())
725 }
726
727 async fn report_result(
728 &mut self,
729 workflow_id: WorkflowId,
730 activity_id: ActivityId,
731 run_id: Option<RunId>,
732 result: Payload,
733 ) -> Result<(), WorkerError> {
734 drop((workflow_id, activity_id, run_id, result));
735 Err(WorkerError::Registration {
736 source: Box::new(self.denial.clone()),
737 })
738 }
739
740 async fn report_failure(
741 &mut self,
742 workflow_id: WorkflowId,
743 activity_id: ActivityId,
744 run_id: Option<RunId>,
745 failure: ActivityError,
746 ) -> Result<(), WorkerError> {
747 drop((workflow_id, activity_id, run_id, failure));
748 Err(WorkerError::Registration {
749 source: Box::new(self.denial.clone()),
750 })
751 }
752
753 async fn send_heartbeat(
754 &mut self,
755 workflow_id: WorkflowId,
756 activity_id: ActivityId,
757 progress: Option<Payload>,
758 ) -> Result<(), WorkerError> {
759 drop((workflow_id, activity_id, progress));
760 Err(WorkerError::Registration {
761 source: Box::new(self.denial.clone()),
762 })
763 }
764 }
765
766 #[derive(Clone, Debug, PartialEq, Eq)]
767 enum RecordedReport {
768 Completed(WorkflowId, ActivityId, Payload),
769 Failed(WorkflowId, ActivityId, ActivityError),
770 }
771
772 #[async_trait]
773 impl WorkerSession for ReconnectFakeSession {
774 async fn handshake(&mut self, config: &WorkerConfig) -> Result<(), WorkerError> {
775 self.handshakes.push(config.identity.clone());
776 Ok(())
777 }
778
779 async fn register(
780 &mut self,
781 activity_types: Vec<String>,
782 available_handlers: &BTreeSet<String>,
783 ) -> Result<(), WorkerError> {
784 validate_activity_handlers(&activity_types, available_handlers)?;
785 self.registrations.push(activity_types);
786 Ok(())
787 }
788
789 fn receive_tasks(&mut self) -> WorkerTaskStream {
790 Box::pin(stream::empty::<Result<WorkerSessionEvent, WorkerError>>())
791 }
792
793 async fn report_result(
794 &mut self,
795 workflow_id: WorkflowId,
796 activity_id: ActivityId,
797 run_id: Option<RunId>,
798 result: Payload,
799 ) -> Result<(), WorkerError> {
800 let _ = run_id;
801 self.reports
802 .push(RecordedReport::Completed(workflow_id, activity_id, result));
803 Ok(())
804 }
805
806 async fn report_failure(
807 &mut self,
808 workflow_id: WorkflowId,
809 activity_id: ActivityId,
810 run_id: Option<RunId>,
811 failure: ActivityError,
812 ) -> Result<(), WorkerError> {
813 let _ = run_id;
814 self.reports
815 .push(RecordedReport::Failed(workflow_id, activity_id, failure));
816 Ok(())
817 }
818
819 async fn send_heartbeat(
820 &mut self,
821 workflow_id: WorkflowId,
822 activity_id: ActivityId,
823 progress: Option<Payload>,
824 ) -> Result<(), WorkerError> {
825 drop((workflow_id, activity_id, progress));
826 Ok(())
827 }
828 }
829
830 fn test_config() -> WorkerConfig {
831 WorkerConfig::new(
832 "http://127.0.0.1:50051",
833 "payments",
834 "worker-a",
835 2,
836 ReconnectConfig::new(Duration::from_millis(5), Duration::from_millis(20), 3),
837 None,
838 )
839 }
840
841 fn terminal_failure() -> ActivityError {
842 ActivityError {
843 kind: ActivityErrorKind::Terminal,
844 message: String::from("terminal"),
845 details: None,
846 }
847 }
848
849 #[test]
850 fn tracker_replaces_existing_activity_report() {
851 let workflow_id = WorkflowId::new_v4();
852 let activity_id = ActivityId::from_sequence_position(9);
853 let mut tracker = UnackedResultTracker::new();
854 tracker.record(PendingActivityReport::Completed {
855 workflow_id: workflow_id.clone(),
856 activity_id: activity_id.clone(),
857 run_id: None,
858 output: Payload::new(ContentType::Json, b"{}".to_vec()),
859 });
860 tracker.record(PendingActivityReport::Failed {
861 workflow_id: workflow_id.clone(),
862 activity_id: activity_id.clone(),
863 run_id: None,
864 failure: terminal_failure(),
865 });
866
867 assert_eq!(tracker.len(), 1);
868 assert!(matches!(
869 tracker.get(&workflow_id, &activity_id),
870 Some(PendingActivityReport::Failed { .. })
871 ));
872 }
873}