Skip to main content

temporalio_sdk/
activities.rs

1//! Functionality related to defining and interacting with activities
2//!
3//!
4//! An example of defining an activity:
5//! ```
6//! use std::sync::{
7//!     Arc,
8//!     atomic::{AtomicUsize, Ordering},
9//! };
10//! use temporalio_macros::{activities, activity_definitions};
11//! use temporalio_sdk::activities::{ActivityContext, ActivityError};
12//!
13//! struct MyActivities {
14//!     counter: AtomicUsize,
15//! }
16//!
17//! #[activities]
18//! impl MyActivities {
19//!     #[activity]
20//!     async fn echo(_ctx: ActivityContext, e: String) -> Result<String, ActivityError> {
21//!         Ok(e)
22//!     }
23//!
24//!     #[activity]
25//!     async fn uses_self(self: Arc<Self>, _ctx: ActivityContext) -> Result<(), ActivityError> {
26//!         self.counter.fetch_add(1, Ordering::Relaxed);
27//!         Ok(())
28//!     }
29//! }
30//!
31//! // If you need to refer to an activity that is defined externally, in a different codebase or
32//! // possibly a different language, use `#[activity_definitions]`. Methods must omit the
33//! // `ActivityContext` parameter and have a body of `unimplemented!()`. Workflows can then call
34//! // these definitions just like real activities.
35//!
36//! struct ExternalActivities;
37//! #[activity_definitions]
38//! impl ExternalActivities {
39//!     #[activity(name = "foo")]
40//!     fn foo(_: String) -> Result<String, ActivityError> {
41//!         unimplemented!()
42//!     }
43//! }
44//! ```
45//!
46//! This will allows you to call the activity from workflow code still, but the actual function
47//! will never be invoked, since you won't have registered it with the worker.
48
49#[doc(inline)]
50pub use temporalio_macros::activities;
51
52use crate::{
53    OutgoingActivityError, OutgoingError,
54    interceptors::{
55        ActivityExecutionValue, ActivityInboundInterceptor, ExecuteActivityInput,
56        ExecuteActivityOutput, Next,
57    },
58    panic_formatter,
59};
60use futures_util::{
61    FutureExt,
62    future::{BoxFuture, ready},
63};
64use prost_types::{Duration, Timestamp};
65#[cfg(feature = "testing")]
66use std::any::Any;
67use std::{
68    collections::HashMap,
69    fmt::Debug,
70    panic::AssertUnwindSafe,
71    sync::Arc,
72    time::{Duration as StdDuration, SystemTime},
73};
74use temporalio_client::{Client, ClientOptions, Priority, WorkflowExecutionInfo, WorkflowHandle};
75pub use temporalio_common::ActivityError;
76use temporalio_common::{
77    ActivityDefinition, HasWorkflowDefinition, RetryPolicy,
78    data_converters::{
79        ActivitySerializationContext, DataConverter, DecodablePayloads, GenericPayloadConverter,
80        PayloadConversionError, PayloadConverter, RawValue, SerializationContext,
81        SerializationContextData, TemporalDeserializable, TemporalSerializable,
82    },
83    error::ApplicationFailure,
84    protos::{
85        coresdk::{ActivityHeartbeat, activity_result::ActivityExecutionResult, activity_task},
86        temporal::api::common::v1::Payload,
87        utilities::TryIntoOrNone,
88    },
89};
90use temporalio_sdk_core::Worker as CoreWorker;
91use tokio_util::sync::CancellationToken;
92
93#[cfg(feature = "testing")]
94pub(crate) type ActivityHeartbeatCallback = Arc<dyn Fn(Box<dyn Any>) + Send + Sync>;
95
96/// Used within activities to get info, heartbeat management etc.
97#[derive(Clone)]
98pub struct ActivityContext {
99    backend: ActivityContextBackend,
100    cancellation_token: CancellationToken,
101    heartbeat_details: ActivityHeartbeatDetails,
102    header_fields: HashMap<String, Payload>,
103    info: ActivityInfo,
104}
105
106#[derive(Clone)]
107enum ActivityContextBackend {
108    Worker {
109        worker: Arc<CoreWorker>,
110        client_options: ClientOptions,
111    },
112    #[cfg(feature = "testing")]
113    Test {
114        client: Option<Client>,
115        heartbeat_callback: Option<ActivityHeartbeatCallback>,
116    },
117}
118
119impl ActivityContextBackend {
120    async fn record_heartbeat<T>(
121        &self,
122        task_token: &[u8],
123        details: T,
124    ) -> Result<(), PayloadConversionError>
125    where
126        T: TemporalSerializable + 'static,
127    {
128        match self {
129            Self::Worker {
130                worker,
131                client_options,
132            } => {
133                let details = client_options
134                    .data_converter
135                    .to_payloads(
136                        &SerializationContextData::Activity(ActivitySerializationContext::new()),
137                        &details,
138                    )
139                    .await?;
140                worker.record_activity_heartbeat(ActivityHeartbeat {
141                    task_token: task_token.to_vec(),
142                    details,
143                });
144            }
145            #[cfg(feature = "testing")]
146            Self::Test {
147                heartbeat_callback, ..
148            } => {
149                if let Some(callback) = heartbeat_callback {
150                    callback(Box::new(details));
151                }
152            }
153        }
154        Ok(())
155    }
156
157    fn client(&self) -> Client {
158        match self {
159            Self::Worker {
160                worker,
161                client_options,
162            } => {
163                let connection = worker.get_client_connection().expect(
164                    "activity context client is unavailable because the worker was not created from a Temporal client",
165                );
166                Client::new(connection, client_options.clone())
167                    .expect("client construction from a worker connection should be infallible")
168            }
169            #[cfg(feature = "testing")]
170            Self::Test { client, .. } => client
171                .as_ref()
172                .expect("ActivityEnvironment was created without a Client. Pass one during construction to have one availalbe at runtime")
173                .clone(),
174        }
175    }
176}
177
178impl ActivityContext {
179    #[cfg(feature = "testing")]
180    pub(crate) fn new_for_test(
181        info: ActivityInfo,
182        header_fields: HashMap<String, Payload>,
183        payload_converter: PayloadConverter,
184        cancellation_token: CancellationToken,
185        heartbeat_details: Vec<Payload>,
186        client: Option<Client>,
187        heartbeat_callback: Option<ActivityHeartbeatCallback>,
188    ) -> Self {
189        let heartbeat_details = ActivityHeartbeatDetails::new(heartbeat_details, payload_converter);
190        Self {
191            backend: ActivityContextBackend::Test {
192                client,
193                heartbeat_callback,
194            },
195            cancellation_token,
196            heartbeat_details,
197            header_fields,
198            info,
199        }
200    }
201
202    pub(crate) fn new(
203        worker: Arc<CoreWorker>,
204        client_options: ClientOptions,
205        cancellation_token: CancellationToken,
206        task_queue: String,
207        task_token: Vec<u8>,
208        task: activity_task::Start,
209    ) -> (Self, Vec<Payload>) {
210        let activity_task::Start {
211            workflow_namespace,
212            workflow_type,
213            workflow_execution,
214            activity_id,
215            activity_type,
216            header_fields,
217            input,
218            heartbeat_details,
219            scheduled_time,
220            current_attempt_scheduled_time,
221            started_time,
222            attempt,
223            schedule_to_close_timeout,
224            start_to_close_timeout,
225            heartbeat_timeout,
226            retry_policy,
227            is_local,
228            priority,
229            run_id,
230        } = task;
231        let deadline = calculate_deadline(
232            scheduled_time.as_ref(),
233            started_time.as_ref(),
234            start_to_close_timeout.as_ref(),
235            schedule_to_close_timeout.as_ref(),
236        );
237        let heartbeat_details = ActivityHeartbeatDetails::new(
238            heartbeat_details,
239            client_options.data_converter.payload_converter().clone(),
240        );
241        let (workflow_id, workflow_run_id) = workflow_execution
242            .map(|we| (we.workflow_id, we.run_id))
243            .unzip();
244        let activity_run_id = (workflow_id.is_none() && !run_id.is_empty()).then_some(run_id);
245
246        (
247            ActivityContext {
248                backend: ActivityContextBackend::Worker {
249                    worker,
250                    client_options,
251                },
252                cancellation_token,
253                heartbeat_details,
254                header_fields,
255                info: ActivityInfo {
256                    task_token,
257                    task_queue,
258                    workflow_type: (!workflow_type.is_empty()).then_some(workflow_type),
259                    namespace: workflow_namespace,
260                    workflow_id,
261                    workflow_run_id,
262                    activity_id,
263                    activity_type,
264                    heartbeat_timeout: heartbeat_timeout.try_into_or_none(),
265                    scheduled_time: scheduled_time.try_into_or_none(),
266                    started_time: started_time.try_into_or_none(),
267                    deadline,
268                    attempt,
269                    current_attempt_scheduled_time: current_attempt_scheduled_time
270                        .try_into_or_none(),
271                    retry_policy: retry_policy.map(Into::into),
272                    is_local,
273                    priority: priority.map(Into::into).unwrap_or_default(),
274                    activity_run_id,
275                },
276            },
277            input,
278        )
279    }
280
281    /// Returns a future the completes if and when the activity this was called inside has been
282    /// cancelled
283    pub async fn cancelled(&self) {
284        self.cancellation_token.clone().cancelled().await
285    }
286
287    /// Returns true if this activity has already been cancelled
288    pub fn is_cancelled(&self) -> bool {
289        self.cancellation_token.is_cancelled()
290    }
291
292    /// Extract heartbeat details from last failed attempt. This is used in combination with retry
293    /// policy.
294    pub fn heartbeat_details(&self) -> &ActivityHeartbeatDetails {
295        &self.heartbeat_details
296    }
297
298    /// Record a heartbeat with typed progress details for the currently executing activity.
299    pub async fn record_heartbeat<T>(&self, details: T) -> Result<(), PayloadConversionError>
300    where
301        T: TemporalSerializable + 'static,
302    {
303        if !self.info.is_local {
304            self.backend
305                .record_heartbeat(&self.info.task_token, details)
306                .await?;
307        }
308        Ok(())
309    }
310
311    /// Returns activity info of the executing activity
312    pub fn info(&self) -> &ActivityInfo {
313        &self.info
314    }
315
316    /// Return a client targeting the same Temporal service and namespace as this activity's worker.
317    pub fn client(&self) -> Client {
318        self.backend.client()
319    }
320
321    /// Return a workflow handle for the workflow execution that started this activity, if any.
322    pub fn workflow_handle<W: HasWorkflowDefinition>(&self) -> Option<WorkflowHandle<Client, W>> {
323        let workflow_id = self.info.workflow_id.clone()?;
324        let run_id = self.info.workflow_run_id.clone();
325        let first_execution_run_id = run_id.clone();
326        let client = self.client();
327
328        Some(WorkflowHandle::new(
329            client.clone(),
330            WorkflowExecutionInfo::builder()
331                .namespace(client.options().namespace.clone())
332                .workflow_id(workflow_id)
333                .maybe_run_id(run_id)
334                .maybe_first_execution_run_id(first_execution_run_id)
335                .build(),
336        ))
337    }
338
339    /// Get headers attached to this activity
340    pub fn headers(&self) -> &HashMap<String, Payload> {
341        &self.header_fields
342    }
343
344    pub(crate) fn headers_mut(&mut self) -> &mut HashMap<String, Payload> {
345        &mut self.header_fields
346    }
347}
348
349/// Heartbeat details supplied by the previous activity attempt.
350#[derive(Clone, Debug)]
351#[non_exhaustive]
352pub struct ActivityHeartbeatDetails {
353    payloads: DecodablePayloads,
354}
355
356impl ActivityHeartbeatDetails {
357    fn new(payloads: Vec<Payload>, payload_converter: PayloadConverter) -> Self {
358        Self {
359            payloads: DecodablePayloads::new(
360                payloads,
361                payload_converter,
362                SerializationContextData::Activity(ActivitySerializationContext::new()),
363            ),
364        }
365    }
366
367    /// Deserialize the previous heartbeat details, or return `None` when there are none.
368    pub fn deserialize<T: TemporalDeserializable + 'static>(
369        &self,
370    ) -> Result<Option<T>, PayloadConversionError> {
371        if self.payloads.raw().is_empty() {
372            Ok(None)
373        } else {
374            self.payloads.deserialize().map(Some)
375        }
376    }
377
378    /// Returns the codec-decoded raw heartbeat payloads.
379    pub fn raw(&self) -> &[Payload] {
380        self.payloads.raw()
381    }
382
383    /// Consume these details and return their codec-decoded payloads.
384    pub fn into_raw(self) -> RawValue {
385        self.payloads.into_raw()
386    }
387}
388
389/// Various information about a specific activity attempt.
390#[derive(Clone, Debug)]
391#[non_exhaustive]
392pub struct ActivityInfo {
393    /// An opaque token representing a specific Activity task.
394    pub task_token: Vec<u8>,
395    /// The type of the workflow that invoked this activity. None for standalone activities.
396    pub workflow_type: Option<String>,
397    /// The namespace of this activity.
398    pub namespace: String,
399    /// ID of the workflow that invoked this activity. None for standalone activities.
400    pub workflow_id: Option<String>,
401    /// Run ID of the workflow that invoked this activity. None for standalone activities.
402    pub workflow_run_id: Option<String>,
403    /// The ID of this activity.
404    pub activity_id: String,
405    /// The type of this activity.
406    pub activity_type: String,
407    /// The task queue of this activity.
408    pub task_queue: String,
409    /// The interval within which this activity must heartbeat or be timed out.
410    pub heartbeat_timeout: Option<StdDuration>,
411    /// Time activity was scheduled by a workflow.
412    pub scheduled_time: Option<SystemTime>,
413    /// Time of activity start.
414    pub started_time: Option<SystemTime>,
415    /// Time of activity timeout.
416    pub deadline: Option<SystemTime>,
417    /// Attempt starts from 1, and increase by 1 for every retry, if retry policy is specified.
418    pub attempt: u32,
419    /// Time this attempt at the activity was scheduled.
420    pub current_attempt_scheduled_time: Option<SystemTime>,
421    /// The retry policy for this activity.
422    pub retry_policy: Option<RetryPolicy>,
423    /// Whether or not this is a local activity.
424    pub is_local: bool,
425    /// Priority of this activity. If unset uses [Priority::default].
426    pub priority: Priority,
427    /// Run ID of this activity execution. Only set for standalone activities.
428    pub activity_run_id: Option<String>,
429}
430
431/// Deadline calculation.  This is a port of
432/// https://github.com/temporalio/sdk-go/blob/8651550973088f27f678118f997839fb1bb9e62f/internal/activity.go#L225
433fn calculate_deadline(
434    scheduled_time: Option<&Timestamp>,
435    started_time: Option<&Timestamp>,
436    start_to_close_timeout: Option<&Duration>,
437    schedule_to_close_timeout: Option<&Duration>,
438) -> Option<SystemTime> {
439    match (
440        scheduled_time,
441        started_time,
442        start_to_close_timeout,
443        schedule_to_close_timeout,
444    ) {
445        (
446            Some(scheduled),
447            Some(started),
448            Some(start_to_close_timeout),
449            Some(schedule_to_close_timeout),
450        ) => {
451            let scheduled: SystemTime = maybe_convert_timestamp(scheduled)?;
452            let started: SystemTime = maybe_convert_timestamp(started)?;
453            let start_to_close_timeout: StdDuration = (*start_to_close_timeout).try_into().ok()?;
454            let schedule_to_close_timeout: StdDuration =
455                (*schedule_to_close_timeout).try_into().ok()?;
456
457            let start_to_close_deadline: SystemTime =
458                started.checked_add(start_to_close_timeout)?;
459            if schedule_to_close_timeout > StdDuration::ZERO {
460                let schedule_to_close_deadline =
461                    scheduled.checked_add(schedule_to_close_timeout)?;
462                // Minimum of the two deadlines.
463                if schedule_to_close_deadline < start_to_close_deadline {
464                    Some(schedule_to_close_deadline)
465                } else {
466                    Some(start_to_close_deadline)
467                }
468            } else {
469                Some(start_to_close_deadline)
470            }
471        }
472        _ => None,
473    }
474}
475
476/// Helper function lifted from prost_types::Timestamp implementation to prevent double cloning in
477/// error construction
478fn maybe_convert_timestamp(timestamp: &Timestamp) -> Option<SystemTime> {
479    let mut timestamp = *timestamp;
480    timestamp.normalize();
481
482    let system_time = if timestamp.seconds >= 0 {
483        std::time::UNIX_EPOCH.checked_add(StdDuration::from_secs(timestamp.seconds as u64))
484    } else {
485        std::time::UNIX_EPOCH.checked_sub(StdDuration::from_secs((-timestamp.seconds) as u64))
486    };
487
488    system_time.and_then(|system_time| {
489        system_time.checked_add(StdDuration::from_nanos(timestamp.nanos as u64))
490    })
491}
492
493pub(crate) type ActivityInvocation = Arc<
494    dyn Fn(
495            Vec<Payload>,
496            DataConverter,
497            ActivityContext,
498            Vec<Arc<dyn ActivityInboundInterceptor>>,
499        ) -> ExecuteActivityOutput<'static>
500        + Send
501        + Sync,
502>;
503
504fn call_execute_activity<'a>(
505    interceptors: &'a [Arc<dyn ActivityInboundInterceptor>],
506    input: ExecuteActivityInput,
507    next: Next<'a, ExecuteActivityInput, ExecuteActivityOutput<'a>>,
508) -> ExecuteActivityOutput<'a> {
509    if let Some((first, rest)) = interceptors.split_first() {
510        first.execute_activity(
511            input,
512            Next::new(move |input| call_execute_activity(rest, input, next)),
513        )
514    } else {
515        next.run(input)
516    }
517}
518
519/// Implemented by `#[activities]` for types that provide activity methods.
520///
521/// This trait supports registration and direct execution infrastructure. Applications normally
522/// use the generated implementation rather than implementing it manually.
523pub trait ActivityImplementer {
524    /// Register every activity method implemented by this type.
525    fn register_all(self: Arc<Self>, defs: &mut ActivityDefinitions);
526}
527
528/// Direct execution support generated for each activity marker by `#[activities]`.
529///
530/// Applications normally use the generated implementation rather than implementing this trait
531/// manually.
532pub trait ExecutableActivity: ActivityDefinition + Sized {
533    /// Type containing the activity implementation.
534    type Implementer: ActivityImplementer + Send + Sync + 'static;
535    /// Whether this activity requires an implementation instance.
536    const REQUIRES_INSTANCE: bool;
537    /// Return this activity's definition marker.
538    fn definition() -> Self;
539    /// Execute the activity with already-typed input.
540    fn execute(
541        receiver: Option<Arc<Self::Implementer>>,
542        ctx: ActivityContext,
543        input: Self::Input,
544    ) -> BoxFuture<'static, Result<Self::Output, ActivityError>>;
545}
546
547/// Contains activity registrations in a form ready for execution by workers.
548#[derive(Default, Clone)]
549pub struct ActivityDefinitions {
550    activities: HashMap<String, ActivityInvocation>,
551}
552
553impl ActivityDefinitions {
554    #[cfg(feature = "experimental")]
555    pub(crate) fn extend(&mut self, other: &Self) {
556        self.activities.extend(other.activities.clone());
557    }
558
559    /// Registers all activities on an activity implementer.
560    pub fn register_activities<AI: ActivityImplementer>(&mut self, instance: AI) -> &mut Self {
561        let arcd = Arc::new(instance);
562        AI::register_all(arcd, self);
563        self
564    }
565    /// Registers a specific activitiy.
566    pub fn register_activity<AD>(&mut self, instance: Arc<AD::Implementer>) -> &mut Self
567    where
568        AD: ActivityDefinition + ExecutableActivity,
569        AD::Input: Send + Sync,
570        AD::Output: Send + Sync,
571    {
572        self.activities.insert(
573            AD::definition().name().to_string(),
574            Arc::new(move |payloads, dc, c, activity_inbound_interceptors| {
575                let instance = instance.clone();
576                async move {
577                    // Codec application happens at the SDK/Core boundary, so activity
578                    // implementations work with the payload converter directly.
579                    let pc = dc.payload_converter();
580                    let context_data =
581                        SerializationContextData::Activity(ActivitySerializationContext::new());
582                    let ctx = SerializationContext::new(&context_data, pc);
583                    let input: AD::Input = pc.from_payloads(&ctx, payloads)?;
584                    let input = ExecuteActivityInput::new(c, Box::new(input));
585                    let leaf = activity_inbound_base::<AD>(instance);
586                    let activity_execution =
587                        call_execute_activity(&activity_inbound_interceptors, input, leaf);
588                    match AssertUnwindSafe(activity_execution).catch_unwind().await {
589                        Ok(output) => output,
590                        Err(panic) => Err(ApplicationFailure::new(anyhow::anyhow!(
591                            "Activity function panicked: {}",
592                            panic_formatter(panic)
593                        ))
594                        .into()),
595                    }
596                }
597                .boxed()
598            }),
599        );
600        self
601    }
602
603    pub(crate) fn is_empty(&self) -> bool {
604        self.activities.is_empty()
605    }
606
607    pub(crate) fn get(&self, act_type: &str) -> Option<ActivityInvocation> {
608        self.activities.get(act_type).cloned()
609    }
610
611    pub(crate) fn names(&self) -> Vec<String> {
612        let mut names: Vec<_> = self.activities.keys().cloned().collect();
613        names.sort_unstable();
614        names
615    }
616}
617
618fn activity_inbound_base<'a, AD>(
619    instance: Arc<AD::Implementer>,
620) -> Next<'a, ExecuteActivityInput, ExecuteActivityOutput<'a>>
621where
622    AD: ActivityDefinition + ExecutableActivity,
623    AD::Input: Send + Sync,
624    AD::Output: Send + Sync,
625{
626    Next::new(
627        move |input: ExecuteActivityInput| -> ExecuteActivityOutput<'a> {
628            let (activity_context, args) = input.into_parts();
629            let args = match args.downcast::<AD::Input>() {
630                Ok(args) => args,
631                Err(_) => {
632                    return ready(Err(ApplicationFailure::new(anyhow::anyhow!(
633                    "Activity inbound interceptor returned arguments with wrong concrete type for activity {}",
634                    AD::definition().name()
635                ))
636                .into()))
637                .boxed();
638                }
639            };
640
641            async move {
642                match AssertUnwindSafe(AD::execute(Some(instance), activity_context, *args))
643                    .catch_unwind()
644                    .await
645                {
646                    Ok(result) => {
647                        result.map(|output| Box::new(output) as Box<dyn ActivityExecutionValue>)
648                    }
649                    Err(panic) => Err(ApplicationFailure::new(anyhow::anyhow!(
650                        "Activity function panicked: {}",
651                        panic_formatter(panic)
652                    ))
653                    .into()),
654                }
655            }
656            .boxed()
657        },
658    )
659}
660
661pub(crate) fn activity_error_to_core_result(
662    dc: &DataConverter,
663    err: ActivityError,
664) -> ActivityExecutionResult {
665    match err {
666        ActivityError::Application(app) => ActivityExecutionResult::fail(dc.to_failure(
667            &SerializationContextData::Activity(ActivitySerializationContext::new()),
668            OutgoingError::Activity(OutgoingActivityError::Application(app)),
669        )),
670        ActivityError::Cancelled { details } => ActivityExecutionResult::cancel(dc.to_failure(
671            &SerializationContextData::Activity(ActivitySerializationContext::new()),
672            OutgoingError::Activity(OutgoingActivityError::Cancelled { details }),
673        )),
674        ActivityError::WillCompleteAsync => ActivityExecutionResult::will_complete_async(),
675        other => ActivityExecutionResult::fail(dc.to_failure(
676            &SerializationContextData::Activity(ActivitySerializationContext::new()),
677            OutgoingError::Activity(OutgoingActivityError::Application(Box::new(
678                ApplicationFailure::new(anyhow::anyhow!("Unsupported activity error: {other:?}")),
679            ))),
680        )),
681    }
682}
683
684impl Debug for ActivityDefinitions {
685    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
686        f.debug_struct("ActivityDefinitions")
687            .field("activities", &self.activities.keys())
688            .finish()
689    }
690}
691
692#[cfg(test)]
693mod test {
694    use super::*;
695    use rstest::rstest;
696    use temporalio_common::error::{ApplicationErrorCategory, ApplicationFailure};
697
698    #[test]
699    fn activity_heartbeat_details_support_typed_decoding() {
700        let payload_converter = PayloadConverter::default();
701        let payload = payload_converter
702            .to_payload(
703                &SerializationContext::new(
704                    &SerializationContextData::Activity(ActivitySerializationContext::new()),
705                    &payload_converter,
706                ),
707                &"progress".to_owned(),
708            )
709            .unwrap();
710        let details = ActivityHeartbeatDetails::new(vec![payload.clone()], payload_converter);
711
712        assert_eq!(details.raw(), &[payload]);
713        assert_eq!(
714            details.deserialize::<String>().unwrap(),
715            Some("progress".to_owned())
716        );
717    }
718
719    #[test]
720    fn empty_activity_heartbeat_details_decode_to_none() {
721        let details = ActivityHeartbeatDetails::new(Vec::new(), PayloadConverter::default());
722
723        assert_eq!(details.deserialize::<String>().unwrap(), None);
724        assert!(details.into_raw().payloads.is_empty());
725    }
726
727    #[rstest]
728    #[case(true)]
729    #[case(false)]
730    fn activity_error_conversion_is_not_lossy(#[case] non_retryable: bool) {
731        let original = ApplicationFailure::builder(anyhow::anyhow!("big boom"))
732            .type_name("BigBoom".to_owned())
733            .non_retryable(non_retryable)
734            .next_retry_delay(StdDuration::from_secs(3))
735            .category(ApplicationErrorCategory::Benign)
736            .details("details")
737            .build();
738        let err = ActivityError::from(original);
739        let ActivityError::Application(actual) = err else {
740            panic!("application failure should become app failure")
741        };
742        assert_eq!(actual.type_name(), Some("BigBoom"));
743        assert_eq!(actual.is_non_retryable(), non_retryable);
744        assert_eq!(actual.next_retry_delay(), Some(StdDuration::from_secs(3)));
745        assert_eq!(actual.category(), ApplicationErrorCategory::Benign);
746        assert_eq!(actual.to_string(), "big boom");
747    }
748
749    #[test]
750    fn activity_error_from_special_err_becomes_application() {
751        #[derive(Debug, PartialEq)]
752        struct MyError;
753
754        impl std::error::Error for MyError {}
755        impl std::fmt::Display for MyError {
756            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
757                f.write_str("MyError")
758            }
759        }
760
761        let err = ActivityError::from(MyError);
762        let ActivityError::Application(actual) = err else {
763            panic!("expected application failure, got {err:?}")
764        };
765        assert_eq!(actual.to_string(), "MyError");
766    }
767}