1use 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: RunId,
28 pub activity_type: String,
30 pub attempt: u32,
33 pub completion_token: String,
35 pub idempotency_key: String,
38 pub input: Payload,
40 pub labels: BTreeMap<String, String>,
45}
46
47impl TryFrom<ProtoActivityTask> for ActivityTask {
48 type Error = WorkerError;
49
50 fn try_from(value: ProtoActivityTask) -> Result<Self, Self::Error> {
51 let workflow_id = value
52 .workflow_id
53 .ok_or(MalformedActivityTask::MissingWorkflowId)
54 .and_then(|workflow_id| {
55 WorkflowId::try_from(workflow_id)
56 .map_err(|source| MalformedActivityTask::InvalidWorkflowId { source })
57 })
58 .map_err(WorkerError::decode)?;
59 let activity_id = value
60 .activity_id
61 .ok_or(MalformedActivityTask::MissingActivityId)
62 .map(ActivityId::from)
63 .map_err(WorkerError::decode)?;
64 let run_id = value
65 .run_id
66 .ok_or(MalformedActivityTask::MissingRunId)
67 .and_then(|run_id| {
68 RunId::try_from(run_id)
69 .map_err(|source| MalformedActivityTask::InvalidRunId { source })
70 })
71 .map_err(WorkerError::decode)?;
72 if value.activity_type.is_empty() {
73 return Err(WorkerError::decode(
74 MalformedActivityTask::MissingActivityType,
75 ));
76 }
77 let input = value
78 .input
79 .ok_or(MalformedActivityTask::MissingInput)
80 .and_then(|input| {
81 Payload::try_from(input)
82 .map_err(|source| MalformedActivityTask::InvalidInput { source })
83 })
84 .map_err(WorkerError::decode)?;
85
86 if value.attempt == 0 {
87 return Err(WorkerError::decode(MalformedActivityTask::MissingAttempt));
89 }
90 if value.completion_token.is_empty() {
91 return Err(WorkerError::decode(
92 MalformedActivityTask::MissingCompletionToken,
93 ));
94 }
95 if value.idempotency_key.is_empty() {
96 return Err(WorkerError::decode(
97 MalformedActivityTask::MissingIdempotencyKey,
98 ));
99 }
100
101 Ok(Self {
102 workflow_id,
103 activity_id,
104 run_id,
105 activity_type: value.activity_type,
106 attempt: value.attempt,
107 completion_token: value.completion_token,
108 idempotency_key: value.idempotency_key,
109 input,
110 labels: value.labels.into_iter().collect(),
111 })
112 }
113}
114
115#[derive(Debug, thiserror::Error)]
116enum MalformedActivityTask {
117 #[error("activity task workflow_id is missing")]
118 MissingWorkflowId,
119 #[error("activity task workflow_id is invalid: {source}")]
120 InvalidWorkflowId { source: aion_proto::WireError },
121 #[error("activity task activity_id is missing")]
122 MissingActivityId,
123 #[error("activity task activity_type is missing")]
124 MissingActivityType,
125 #[error("activity task input payload is missing")]
126 MissingInput,
127 #[error("activity task attempt is missing or zero (producer failed to stamp it)")]
128 MissingAttempt,
129 #[error("activity task completion_token is missing (server registration era is incompatible)")]
130 MissingCompletionToken,
131 #[error("activity task idempotency_key is missing (server registration era is incompatible)")]
132 MissingIdempotencyKey,
133 #[error("activity task input payload is invalid: {source}")]
134 InvalidInput { source: aion_proto::WireError },
135 #[error("activity task run_id is missing (server registration era is incompatible)")]
136 MissingRunId,
137 #[error("activity task run_id is invalid: {source}")]
138 InvalidRunId { source: aion_proto::WireError },
139}
140
141#[cfg(test)]
142mod tests {
143 use aion_core::{ActivityId, ContentType, Payload, WorkflowId};
144 use aion_proto::{
145 ProtoActivityId, ProtoActivityTask, ProtoPayload, ProtoRunId, ProtoWorkflowId,
146 };
147 use serde_json::json;
148
149 use super::ActivityTask;
150 use crate::WorkerError;
151
152 #[test]
153 fn decodes_proto_activity_task_preserving_payload_content_type()
154 -> Result<(), Box<dyn std::error::Error>> {
155 let workflow_id = WorkflowId::new_v4();
156 let activity_id = ActivityId::from_sequence_position(42);
157 let run_id = aion_core::RunId::new_v4();
158 let input_value = json!({"amount": 1250, "currency": "USD"});
159 let input = Payload::from_json(&input_value)?;
160 let proto = ProtoActivityTask {
161 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
162 activity_id: Some(ProtoActivityId::from(activity_id.clone())),
163 run_id: Some(ProtoRunId::from(run_id.clone())),
164 activity_type: String::from("charge-card"),
165 input: Some(ProtoPayload::from(input.clone())),
166 attempt: 3,
167 completion_token: String::from("generation-3"),
168 idempotency_key: String::from("effect-key"),
169 labels: [(String::from("brief"), String::from("IP-001"))]
170 .into_iter()
171 .collect(),
172 };
173
174 let task = ActivityTask::try_from(proto)?;
175
176 assert_eq!(task.workflow_id, workflow_id);
177 assert_eq!(task.activity_id, activity_id);
178 assert_eq!(task.run_id, run_id);
179 assert_eq!(task.activity_type, "charge-card");
180 assert_eq!(task.attempt, 3, "attempt must be read from the wire");
181 assert_eq!(task.input.content_type(), &ContentType::Json);
182 assert_eq!(task.input.bytes(), input.bytes());
183 assert_eq!(task.input.to_json()?, input_value);
184 assert_eq!(
185 task.labels.get("brief").map(String::as_str),
186 Some("IP-001"),
187 "display labels must decode from the wire"
188 );
189 Ok(())
190 }
191
192 #[test]
193 fn missing_required_field_maps_to_decode_error() {
194 let result = ActivityTask::try_from(ProtoActivityTask {
195 workflow_id: None,
196 activity_id: Some(ProtoActivityId::from(ActivityId::from_sequence_position(1))),
197 run_id: None,
198 activity_type: String::from("charge-card"),
199 input: Some(ProtoPayload::from(Payload::new(
200 ContentType::Json,
201 b"{}".to_vec(),
202 ))),
203 attempt: 1,
204 completion_token: String::from("generation-1"),
205 idempotency_key: String::from("effect-key"),
206 labels: std::collections::HashMap::new(),
207 });
208
209 assert!(matches!(result, Err(WorkerError::Decode { .. })));
210 }
211
212 #[test]
213 fn zero_attempt_is_a_malformed_task() -> Result<(), Box<dyn std::error::Error>> {
214 let result = ActivityTask::try_from(ProtoActivityTask {
215 workflow_id: Some(ProtoWorkflowId::from(WorkflowId::new_v4())),
216 activity_id: Some(ProtoActivityId::from(ActivityId::from_sequence_position(1))),
217 run_id: Some(ProtoRunId::from(aion_core::RunId::new_v4())),
218 activity_type: String::from("charge-card"),
219 input: Some(ProtoPayload::from(Payload::new(
220 ContentType::Json,
221 b"{}".to_vec(),
222 ))),
223 attempt: 0,
224 completion_token: String::from("generation-1"),
225 idempotency_key: String::from("effect-key"),
226 labels: std::collections::HashMap::new(),
227 });
228
229 let error = result
230 .err()
231 .ok_or("attempt 0 must be rejected as malformed")?;
232 assert!(matches!(error, WorkerError::Decode { .. }));
233 assert!(
234 error.to_string().contains("attempt"),
235 "error must name the attempt field: {error}"
236 );
237 Ok(())
238 }
239
240 #[test]
241 fn missing_fencing_fields_are_incompatible_tasks() -> Result<(), Box<dyn std::error::Error>> {
242 let valid = ProtoActivityTask {
243 workflow_id: Some(ProtoWorkflowId::from(WorkflowId::new_v4())),
244 activity_id: Some(ProtoActivityId::from(ActivityId::from_sequence_position(1))),
245 run_id: Some(ProtoRunId::from(aion_core::RunId::new_v4())),
246 activity_type: String::from("charge-card"),
247 input: Some(ProtoPayload::from(Payload::new(
248 ContentType::Json,
249 b"{}".to_vec(),
250 ))),
251 attempt: 1,
252 completion_token: String::from("generation-1"),
253 idempotency_key: String::from("effect-key"),
254 labels: std::collections::HashMap::new(),
255 };
256
257 let mut missing_token = valid.clone();
258 missing_token.completion_token.clear();
259 let token_error = ActivityTask::try_from(missing_token)
260 .err()
261 .ok_or("empty completion token must be rejected")?;
262 assert!(
263 token_error.to_string().contains("completion_token"),
264 "refusal must name the missing generation proof: {token_error}"
265 );
266
267 let mut missing_key = valid.clone();
268 missing_key.idempotency_key.clear();
269 let key_error = ActivityTask::try_from(missing_key)
270 .err()
271 .ok_or("empty idempotency key must be rejected")?;
272 assert!(
273 key_error.to_string().contains("idempotency_key"),
274 "refusal must name the missing external-effect key: {key_error}"
275 );
276
277 let mut missing_run = valid;
278 missing_run.run_id = None;
279 let run_error = ActivityTask::try_from(missing_run)
280 .err()
281 .ok_or("missing run id must be rejected")?;
282 assert!(
283 run_error.to_string().contains("run_id"),
284 "refusal must name the missing concrete run: {run_error}"
285 );
286 Ok(())
287 }
288}