Skip to main content

aion_worker/protocol/
reconnect.rs

1//! Backoff reconnect, re-register, and re-report un-acked results.
2
3use 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/// Result or failure computed locally and not yet acknowledged by the engine.
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub enum PendingActivityReport {
18    /// Successful activity output to re-report until acknowledged.
19    Completed {
20        /// Workflow owning the activity.
21        workflow_id: WorkflowId,
22        /// Activity identifier used by AW for idempotent ingest.
23        activity_id: ActivityId,
24        /// Concrete workflow run to echo on re-report, when known.
25        run_id: Option<RunId>,
26        /// Opaque activity output payload.
27        output: Payload,
28    },
29    /// Explicitly classified activity failure to re-report until acknowledged.
30    Failed {
31        /// Workflow owning the activity.
32        workflow_id: WorkflowId,
33        /// Activity identifier used by AW for idempotent ingest.
34        activity_id: ActivityId,
35        /// Concrete workflow run to echo on re-report, when known.
36        run_id: Option<RunId>,
37        /// Classified activity error.
38        failure: ActivityError,
39    },
40}
41
42impl PendingActivityReport {
43    /// Returns the report's activity id key.
44    #[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    /// Returns the workflow owning the report's activity.
52    #[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
60/// Deterministic tracker key: activity ids are sequence positions scoped to
61/// one workflow, so distinct workflows legitimately collide on the bare
62/// position and must be keyed by workflow as well.
63type 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/// In-memory source of truth for locally reported results awaiting engine ack.
70#[derive(Clone, Debug, Default, PartialEq, Eq)]
71pub struct UnackedResultTracker {
72    reports: BTreeMap<PendingReportKey, PendingActivityReport>,
73}
74
75impl UnackedResultTracker {
76    /// Creates an empty tracker.
77    #[must_use]
78    pub const fn new() -> Self {
79        Self {
80            reports: BTreeMap::new(),
81        }
82    }
83
84    /// Records a report, replacing any earlier pending report for the same
85    /// workflow and activity id.
86    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    /// Drops a report once the engine explicitly acknowledges it.
92    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    /// Returns the number of unacknowledged reports.
102    #[must_use]
103    pub fn len(&self) -> usize {
104        self.reports.len()
105    }
106
107    /// Returns true when no reports are waiting for acknowledgement.
108    #[must_use]
109    pub fn is_empty(&self) -> bool {
110        self.reports.is_empty()
111    }
112
113    /// Gets a pending report by its workflow and activity id.
114    #[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    /// Returns a deterministic snapshot for re-reporting without holding a borrow.
125    #[must_use]
126    pub fn snapshot(&self) -> Vec<PendingActivityReport> {
127        self.reports.values().cloned().collect()
128    }
129}
130
131/// Validated reconnect backoff settings drawn from [`WorkerConfig`].
132#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct ReconnectBackoff {
134    initial: Duration,
135    max: Duration,
136    attempts: usize,
137}
138
139impl ReconnectBackoff {
140    /// Builds reconnect backoff from worker config.
141    ///
142    /// # Errors
143    ///
144    /// Returns [`WorkerError::Registration`] if delays or attempt counts are zero.
145    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    /// Returns the bounded exponential delay after `completed_failures` failures.
169    ///
170    /// The delay doubles per completed failure starting from the configured
171    /// initial backoff and is capped at the configured maximum backoff.
172    #[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    /// Returns the configured maximum number of reconnect attempts.
181    #[must_use]
182    pub const fn attempts(&self) -> usize {
183        self.attempts
184    }
185
186    /// Returns the configured maximum backoff delay cap.
187    ///
188    /// The run loop also uses this as its session-health threshold: the cap
189    /// is the policy's own definition of the longest pause, so an
190    /// established session that survives longer than it is demonstrably past
191    /// the flapping regime and resets the cumulative drop budget when it
192    /// eventually drops.
193    #[must_use]
194    pub const fn max_delay(&self) -> Duration {
195        self.max
196    }
197}
198
199/// Connects, handshakes, and registers a fresh gRPC worker session.
200///
201/// # Errors
202///
203/// Returns [`WorkerError`] if connection, handshake, or registration fails.
204pub 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
213/// Handshakes and registers an already-connected session.
214///
215/// # Errors
216///
217/// Returns [`WorkerError`] if handshake or registration fails.
218pub 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
232/// Reconnects with bounded exponential backoff using an injected session factory.
233///
234/// # Errors
235///
236/// Returns the last [`WorkerError`] after configured attempts are exhausted, or
237/// immediately when the failure is a non-retryable `PermissionDenied` /
238/// `Unauthenticated` denial, or if the config contains invalid zero reconnect
239/// settings.
240pub 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
261/// Testable reconnect helper with injectable sleep.
262///
263/// # Errors
264///
265/// Returns the last [`WorkerError`] after configured attempts are exhausted, or
266/// immediately — without consuming further attempts — when a failure is a
267/// non-retryable denial ([`WorkerError::is_retryable`] is false, i.e. the
268/// server answered `PermissionDenied` or `Unauthenticated`), or if the config
269/// contains invalid zero reconnect settings.
270pub 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
336/// Re-reports every unacknowledged result/failure before serving new work.
337///
338/// Server `ResultAck` frames clear entries mid-session, so the steady-state
339/// backlog is empty and this replay decays to the still-unacked residue.
340/// Each send carries the session's per-send deadline.
341///
342/// # Errors
343///
344/// Returns [`WorkerError`] if any re-report send fails. Entries are not removed
345/// by sending; only the explicit `ResultAck` acknowledgement clears the tracker.
346pub 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    /// Session whose registration is rejected by the server with a gRPC denial,
700    /// mirroring `aion-server` answering `PermissionDenied` for an ungranted
701    /// namespace.
702    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}