aion_worker/protocol/
task.rs1use std::collections::BTreeMap;
4
5use aion_core::{ActivityId, Payload, RunId, WorkflowId};
6use aion_proto::ProtoActivityTask;
7
8use crate::error::WorkerError;
9
10#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct ActivityTask {
13 pub workflow_id: WorkflowId,
15 pub activity_id: ActivityId,
17 pub run_id: Option<RunId>,
19 pub activity_type: String,
21 pub attempt: u32,
24 pub input: Payload,
26 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 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}