Skip to main content

aion_worker/protocol/
task.rs

1//! `ActivityTask` decode and `TaskResult`/`TaskFailure` encode.
2
3use std::collections::BTreeMap;
4
5use aion_core::{ActivityId, Payload, RunId, WorkflowId};
6use aion_proto::ProtoActivityTask;
7
8use crate::error::WorkerError;
9
10/// SDK-level activity task envelope decoded from the AW-owned worker proto.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct ActivityTask {
13    /// Owning workflow id, required later when reporting this task's outcome.
14    pub workflow_id: WorkflowId,
15    /// Activity id correlating reports and heartbeats with this task.
16    pub activity_id: ActivityId,
17    /// Concrete workflow run that staged this task, when provided by the server.
18    pub run_id: Option<RunId>,
19    /// Registered activity type name requested by the engine.
20    pub activity_type: String,
21    /// One-based delivery attempt stamped by the dispatching engine seam and
22    /// read from the wire. Zero is malformed and rejected at decode.
23    pub attempt: u32,
24    /// Opaque activity input payload, preserving its content-type tag.
25    pub input: Payload,
26    /// Human-meaningful display labels the workflow attached to the activity
27    /// (for example `brief=IP-001`). Display metadata only — surfaced in the
28    /// worker's logs. `BTreeMap` keeps the rendered order stable; empty when
29    /// the workflow attached none.
30    pub labels: BTreeMap<String, String>,
31}
32
33impl TryFrom<ProtoActivityTask> for ActivityTask {
34    type Error = WorkerError;
35
36    fn try_from(value: ProtoActivityTask) -> Result<Self, Self::Error> {
37        let workflow_id = value
38            .workflow_id
39            .ok_or(MalformedActivityTask::MissingWorkflowId)
40            .and_then(|workflow_id| {
41                WorkflowId::try_from(workflow_id)
42                    .map_err(|source| MalformedActivityTask::InvalidWorkflowId { source })
43            })
44            .map_err(WorkerError::decode)?;
45        let activity_id = value
46            .activity_id
47            .ok_or(MalformedActivityTask::MissingActivityId)
48            .map(ActivityId::from)
49            .map_err(WorkerError::decode)?;
50        let run_id = value
51            .run_id
52            .map(|run_id| {
53                RunId::try_from(run_id)
54                    .map_err(|source| MalformedActivityTask::InvalidRunId { source })
55            })
56            .transpose()
57            .map_err(WorkerError::decode)?;
58        if value.activity_type.is_empty() {
59            return Err(WorkerError::decode(
60                MalformedActivityTask::MissingActivityType,
61            ));
62        }
63        let input = value
64            .input
65            .ok_or(MalformedActivityTask::MissingInput)
66            .and_then(|input| {
67                Payload::try_from(input)
68                    .map_err(|source| MalformedActivityTask::InvalidInput { source })
69            })
70            .map_err(WorkerError::decode)?;
71
72        if value.attempt == 0 {
73            // proto3 zero default = the producer failed to stamp the attempt.
74            return Err(WorkerError::decode(MalformedActivityTask::MissingAttempt));
75        }
76
77        Ok(Self {
78            workflow_id,
79            activity_id,
80            run_id,
81            activity_type: value.activity_type,
82            attempt: value.attempt,
83            input,
84            labels: value.labels.into_iter().collect(),
85        })
86    }
87}
88
89#[derive(Debug, thiserror::Error)]
90enum MalformedActivityTask {
91    #[error("activity task workflow_id is missing")]
92    MissingWorkflowId,
93    #[error("activity task workflow_id is invalid: {source}")]
94    InvalidWorkflowId { source: aion_proto::WireError },
95    #[error("activity task activity_id is missing")]
96    MissingActivityId,
97    #[error("activity task activity_type is missing")]
98    MissingActivityType,
99    #[error("activity task input payload is missing")]
100    MissingInput,
101    #[error("activity task attempt is missing or zero (producer failed to stamp it)")]
102    MissingAttempt,
103    #[error("activity task input payload is invalid: {source}")]
104    InvalidInput { source: aion_proto::WireError },
105    #[error("activity task run_id is invalid: {source}")]
106    InvalidRunId { source: aion_proto::WireError },
107}
108
109#[cfg(test)]
110mod tests {
111    use aion_core::{ActivityId, ContentType, Payload, WorkflowId};
112    use aion_proto::{
113        ProtoActivityId, ProtoActivityTask, ProtoPayload, ProtoRunId, ProtoWorkflowId,
114    };
115    use serde_json::json;
116
117    use super::ActivityTask;
118    use crate::WorkerError;
119
120    #[test]
121    fn decodes_proto_activity_task_preserving_payload_content_type()
122    -> Result<(), Box<dyn std::error::Error>> {
123        let workflow_id = WorkflowId::new_v4();
124        let activity_id = ActivityId::from_sequence_position(42);
125        let run_id = aion_core::RunId::new_v4();
126        let input_value = json!({"amount": 1250, "currency": "USD"});
127        let input = Payload::from_json(&input_value)?;
128        let proto = ProtoActivityTask {
129            workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
130            activity_id: Some(ProtoActivityId::from(activity_id.clone())),
131            run_id: Some(ProtoRunId::from(run_id.clone())),
132            activity_type: String::from("charge-card"),
133            input: Some(ProtoPayload::from(input.clone())),
134            attempt: 3,
135            labels: [(String::from("brief"), String::from("IP-001"))]
136                .into_iter()
137                .collect(),
138        };
139
140        let task = ActivityTask::try_from(proto)?;
141
142        assert_eq!(task.workflow_id, workflow_id);
143        assert_eq!(task.activity_id, activity_id);
144        assert_eq!(task.run_id, Some(run_id));
145        assert_eq!(task.activity_type, "charge-card");
146        assert_eq!(task.attempt, 3, "attempt must be read from the wire");
147        assert_eq!(task.input.content_type(), &ContentType::Json);
148        assert_eq!(task.input.bytes(), input.bytes());
149        assert_eq!(task.input.to_json()?, input_value);
150        assert_eq!(
151            task.labels.get("brief").map(String::as_str),
152            Some("IP-001"),
153            "display labels must decode from the wire"
154        );
155        Ok(())
156    }
157
158    #[test]
159    fn missing_required_field_maps_to_decode_error() {
160        let result = ActivityTask::try_from(ProtoActivityTask {
161            workflow_id: None,
162            activity_id: Some(ProtoActivityId::from(ActivityId::from_sequence_position(1))),
163            run_id: None,
164            activity_type: String::from("charge-card"),
165            input: Some(ProtoPayload::from(Payload::new(
166                ContentType::Json,
167                b"{}".to_vec(),
168            ))),
169            attempt: 1,
170            labels: std::collections::HashMap::new(),
171        });
172
173        assert!(matches!(result, Err(WorkerError::Decode { .. })));
174    }
175
176    #[test]
177    fn zero_attempt_is_a_malformed_task() {
178        let result = ActivityTask::try_from(ProtoActivityTask {
179            workflow_id: Some(ProtoWorkflowId::from(WorkflowId::new_v4())),
180            activity_id: Some(ProtoActivityId::from(ActivityId::from_sequence_position(1))),
181            run_id: None,
182            activity_type: String::from("charge-card"),
183            input: Some(ProtoPayload::from(Payload::new(
184                ContentType::Json,
185                b"{}".to_vec(),
186            ))),
187            attempt: 0,
188            labels: std::collections::HashMap::new(),
189        });
190
191        let Err(error) = result else {
192            unreachable!("attempt 0 must be rejected as malformed");
193        };
194        assert!(matches!(error, WorkerError::Decode { .. }));
195        assert!(
196            error.to_string().contains("attempt"),
197            "error must name the attempt field: {error}"
198        );
199    }
200}