1use std::any::Any;
4use std::collections::{BTreeMap, BTreeSet};
5use std::future::Future;
6use std::panic::AssertUnwindSafe;
7use std::pin::Pin;
8
9use aion_core::{ActivityError, ActivityErrorKind, Payload};
10use async_trait::async_trait;
11use futures::FutureExt;
12use serde::Serialize;
13use serde::de::DeserializeOwned;
14use tracing::error;
15
16use crate::context::ActivityContext;
17use crate::error::{MissingActivityHandler, WorkerError};
18use crate::protocol::ActivityTask;
19use crate::runtime::loop_::{ActivityDispatcher, DispatchOutcome};
20
21#[derive(Clone, Debug, PartialEq, Eq)]
23pub enum Classification {
24 Retryable,
26 PolicyRefused,
28 Terminal,
30}
31
32#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
34#[error("{message}")]
35pub struct ActivityFailure {
36 classification: Classification,
37 message: String,
38 detail: Option<Payload>,
39}
40
41impl ActivityFailure {
42 #[must_use]
44 pub fn retryable(message: impl Into<String>) -> Self {
45 Self::new(Classification::Retryable, message, None)
46 }
47
48 #[must_use]
50 pub fn policy_refused(message: impl Into<String>) -> Self {
51 Self::new(Classification::PolicyRefused, message, None)
52 }
53
54 #[must_use]
56 pub fn terminal(message: impl Into<String>) -> Self {
57 Self::new(Classification::Terminal, message, None)
58 }
59
60 #[must_use]
62 pub fn with_detail(mut self, detail: Payload) -> Self {
63 self.detail = Some(detail);
64 self
65 }
66
67 #[must_use]
69 pub const fn classification(&self) -> &Classification {
70 &self.classification
71 }
72
73 #[must_use]
75 pub fn message(&self) -> &str {
76 &self.message
77 }
78
79 #[must_use]
81 pub const fn detail(&self) -> Option<&Payload> {
82 self.detail.as_ref()
83 }
84
85 fn new(
86 classification: Classification,
87 message: impl Into<String>,
88 detail: Option<Payload>,
89 ) -> Self {
90 Self {
91 classification,
92 message: message.into(),
93 detail,
94 }
95 }
96}
97
98impl From<Classification> for ActivityErrorKind {
99 fn from(value: Classification) -> Self {
100 match value {
101 Classification::Retryable => Self::Retryable,
102 Classification::PolicyRefused => Self::PolicyRefused,
103 Classification::Terminal => Self::Terminal,
104 }
105 }
106}
107
108impl From<ActivityFailure> for ActivityError {
109 fn from(value: ActivityFailure) -> Self {
110 Self {
111 kind: ActivityErrorKind::from(value.classification),
112 message: value.message,
113 details: value.detail,
114 }
115 }
116}
117
118pub type HandlerFuture<'context, Output> =
120 Pin<Box<dyn Future<Output = Result<Output, ActivityFailure>> + Send + 'context>>;
121
122type BoxedHandler<Input, Output> = Box<
123 dyn for<'context> Fn(Input, &'context ActivityContext) -> HandlerFuture<'context, Output>
124 + Send
125 + Sync,
126>;
127
128#[derive(Default)]
130pub struct ActivityRegistry {
131 handlers: BTreeMap<String, Box<dyn ErasedActivityHandler>>,
132 descriptors: BTreeMap<String, aion_package::ActivityDescriptor>,
133}
134
135impl ActivityRegistry {
136 #[must_use]
138 pub fn new() -> Self {
139 Self::default()
140 }
141
142 pub fn register_activity<Input, Output, Handler>(
148 mut self,
149 activity_type: impl Into<String>,
150 handler: Handler,
151 ) -> Result<Self, WorkerError>
152 where
153 Input: Serialize + DeserializeOwned + Send + Sync + 'static,
154 Output: Serialize + Send + Sync + 'static,
155 Handler: for<'context> Fn(Input, &'context ActivityContext) -> HandlerFuture<'context, Output>
156 + Send
157 + Sync
158 + 'static,
159 {
160 let activity_type = activity_type.into();
161 if self.handlers.contains_key(&activity_type) {
162 return Err(WorkerError::registration(DuplicateActivityType {
163 activity_type,
164 }));
165 }
166 self.handlers
167 .insert(activity_type, Box::new(TypedHandler::new(handler)));
168 Ok(self)
169 }
170
171 pub fn register_activity_with_contract<Input, Output, Handler>(
179 mut self,
180 activity_type: impl Into<String>,
181 handler: Handler,
182 ) -> Result<Self, WorkerError>
183 where
184 Input: Serialize + DeserializeOwned + schemars::JsonSchema + Send + Sync + 'static,
185 Output: Serialize + schemars::JsonSchema + Send + Sync + 'static,
186 Handler: for<'context> Fn(Input, &'context ActivityContext) -> HandlerFuture<'context, Output>
187 + Send
188 + Sync
189 + 'static,
190 {
191 let activity_type = activity_type.into();
192 let descriptor = activity_descriptor::<Input, Output>(activity_type.clone())?;
193 self = self.register_activity(activity_type.clone(), handler)?;
194 self.descriptors.insert(activity_type, descriptor);
195 Ok(self)
196 }
197
198 pub fn register_activity_with_descriptor<Input, Output, Handler>(
219 mut self,
220 activity_type: impl Into<String>,
221 descriptor: aion_package::ActivityDescriptor,
222 handler: Handler,
223 ) -> Result<Self, WorkerError>
224 where
225 Input: Serialize + DeserializeOwned + Send + Sync + 'static,
226 Output: Serialize + Send + Sync + 'static,
227 Handler: for<'context> Fn(Input, &'context ActivityContext) -> HandlerFuture<'context, Output>
228 + Send
229 + Sync
230 + 'static,
231 {
232 let activity_type = activity_type.into();
233 if descriptor.name != activity_type {
234 return Err(WorkerError::registration(DescriptorNameMismatch {
235 activity_type,
236 descriptor: descriptor.name,
237 }));
238 }
239 self = self.register_activity(activity_type.clone(), handler)?;
240 self.descriptors.insert(activity_type, descriptor);
241 Ok(self)
242 }
243
244 #[must_use]
246 pub fn is_empty(&self) -> bool {
247 self.handlers.is_empty()
248 }
249
250 #[must_use]
252 pub fn activity_types(&self) -> BTreeSet<String> {
253 self.handlers.keys().cloned().collect()
254 }
255
256 #[must_use]
258 pub fn activity_descriptors(&self) -> Vec<aion_package::ActivityDescriptor> {
259 self.descriptors.values().cloned().collect()
260 }
261}
262
263pub fn activity_descriptor<Input, Output>(
270 name: impl Into<String>,
271) -> Result<aion_package::ActivityDescriptor, WorkerError>
272where
273 Input: schemars::JsonSchema,
274 Output: schemars::JsonSchema,
275{
276 let schema = || {
277 schemars::generate::SchemaSettings::draft2020_12()
278 .with(|settings| {
279 settings.meta_schema = None;
283 settings.inline_subschemas = true;
284 })
285 .into_generator()
286 };
287 Ok(aion_package::ActivityDescriptor {
288 name: name.into(),
289 input_schema: serde_json::to_value(schema().into_root_schema_for::<Input>())
290 .map_err(WorkerError::encode)?,
291 output_schema: serde_json::to_value(schema().into_root_schema_for::<Output>())
292 .map_err(WorkerError::encode)?,
293 })
294}
295
296#[async_trait]
297impl ActivityDispatcher for ActivityRegistry {
298 async fn dispatch(
299 &self,
300 task: ActivityTask,
301 context: ActivityContext,
302 ) -> Result<DispatchOutcome, WorkerError> {
303 let Some(handler) = self.handlers.get(&task.activity_type) else {
304 return Err(WorkerError::registration(MissingActivityHandler {
305 activity_type: task.activity_type,
306 }));
307 };
308 handler.dispatch(task, context).await
309 }
310
311 fn activity_types(&self) -> BTreeSet<String> {
312 self.activity_types()
313 }
314}
315
316pub type TypedActivityDispatcher = ActivityRegistry;
318
319pub fn decode_payload<T>(payload: &Payload) -> Result<T, WorkerError>
326where
327 T: DeserializeOwned,
328{
329 let value = payload.to_json().map_err(WorkerError::decode)?;
330 serde_json::from_value(value).map_err(WorkerError::decode)
331}
332
333pub fn encode_payload<T>(value: &T) -> Result<Payload, WorkerError>
339where
340 T: Serialize,
341{
342 let value = serde_json::to_value(value).map_err(WorkerError::encode)?;
343 Payload::from_json(&value).map_err(WorkerError::encode)
344}
345
346#[async_trait]
347trait ErasedActivityHandler: Send + Sync {
348 async fn dispatch(
349 &self,
350 task: ActivityTask,
351 context: ActivityContext,
352 ) -> Result<DispatchOutcome, WorkerError>;
353}
354
355struct TypedHandler<Input, Output> {
356 handler: BoxedHandler<Input, Output>,
357}
358
359impl<Input, Output> TypedHandler<Input, Output> {
360 fn new(
361 handler: impl for<'context> Fn(
362 Input,
363 &'context ActivityContext,
364 ) -> HandlerFuture<'context, Output>
365 + Send
366 + Sync
367 + 'static,
368 ) -> Self {
369 Self {
370 handler: Box::new(handler),
371 }
372 }
373}
374
375#[async_trait]
376impl<Input, Output> ErasedActivityHandler for TypedHandler<Input, Output>
377where
378 Input: DeserializeOwned + Send + Sync + 'static,
379 Output: Serialize + Send + Sync + 'static,
380{
381 async fn dispatch(
382 &self,
383 task: ActivityTask,
384 context: ActivityContext,
385 ) -> Result<DispatchOutcome, WorkerError> {
386 let input = match decode_payload::<Input>(&task.input) {
387 Ok(input) => input,
388 Err(error) => {
389 error!(
390 activity_type = %task.activity_type,
391 activity_id = task.activity_id.sequence_position(),
392 attempt = task.attempt,
393 error = %error,
394 "failed to decode activity input; reporting terminal activity failure"
395 );
396 let failure =
397 ActivityFailure::terminal(format!("failed to decode activity input: {error}"));
398 return Ok(DispatchOutcome::Failed {
399 failure: ActivityError::from(failure),
400 });
401 }
402 };
403 let handler_future =
404 match std::panic::catch_unwind(AssertUnwindSafe(|| (self.handler)(input, &context))) {
405 Ok(handler_future) => handler_future,
406 Err(panic) => return Ok(panic_failure(&task, &panic)),
407 };
408 let handler_result = AssertUnwindSafe(handler_future).catch_unwind().await;
409 let outcome = match handler_result {
410 Ok(Ok(output)) => DispatchOutcome::Completed {
411 output: encode_payload(&output)?,
412 },
413 Ok(Err(failure)) => DispatchOutcome::Failed {
414 failure: ActivityError::from(failure),
415 },
416 Err(panic) => panic_failure(&task, &panic),
417 };
418 Ok(outcome)
419 }
420}
421
422fn panic_failure(task: &ActivityTask, panic: &Box<dyn Any + Send>) -> DispatchOutcome {
423 let message = panic_message(panic);
424 error!(
425 activity_type = %task.activity_type,
426 activity_id = task.activity_id.sequence_position(),
427 attempt = task.attempt,
428 panic = %message,
429 "activity handler panicked; reporting retryable activity failure"
430 );
431 DispatchOutcome::Failed {
432 failure: ActivityError::from(ActivityFailure::retryable(format!(
433 "activity handler panicked: {message}"
434 ))),
435 }
436}
437
438fn panic_message(panic: &Box<dyn Any + Send>) -> String {
439 if let Some(message) = panic.downcast_ref::<&str>() {
440 return (*message).to_owned();
441 }
442 if let Some(message) = panic.downcast_ref::<String>() {
443 return message.clone();
444 }
445 String::from("unknown panic payload")
446}
447
448#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
450#[error("activity type `{activity_type}` already has a registered handler")]
451pub struct DuplicateActivityType {
452 pub activity_type: String,
454}
455
456#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
463#[error(
464 "activity type `{activity_type}` was registered with a descriptor naming `{descriptor}`; a descriptor must name the activity it describes"
465)]
466pub struct DescriptorNameMismatch {
467 pub activity_type: String,
469 pub descriptor: String,
471}
472
473#[cfg(test)]
474mod tests {
475 use aion_core::{ActivityError, ActivityId, ContentType, WorkflowId};
476 use aion_proto::{
477 ProtoActivityError, ProtoActivityErrorKind, ProtoActivityId, ProtoActivityTask,
478 ProtoPayload, ProtoWorkflowId,
479 };
480 use serde::{Deserialize, Serialize};
481
482 use super::{ActivityFailure, ActivityRegistry, decode_payload, encode_payload};
483 use crate::WorkerError;
484 use crate::runtime::{ActivityDispatcher, DispatchOutcome};
485
486 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487 struct TestInput {
488 value: i32,
489 }
490
491 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
492 struct TestOutput {
493 doubled: i32,
494 }
495
496 #[test]
497 fn retryable_and_terminal_failures_map_to_distinct_wire_classifications() {
498 let retryable = ActivityFailure::retryable("temporary outage");
499 let terminal = ActivityFailure::terminal("invalid request");
500
501 let retryable_core = ActivityError::from(retryable);
502 let terminal_core = ActivityError::from(terminal);
503 let retryable_wire = ProtoActivityError::from(retryable_core);
504 let terminal_wire = ProtoActivityError::from(terminal_core);
505
506 assert_eq!(
507 retryable_wire.kind,
508 ProtoActivityErrorKind::Retryable as i32
509 );
510 assert_eq!(terminal_wire.kind, ProtoActivityErrorKind::Terminal as i32);
511 }
512
513 #[tokio::test]
514 async fn typed_activity_round_trips_through_registry() -> Result<(), WorkerError> {
515 let registry =
516 ActivityRegistry::new().register_activity("double", |input: TestInput, context| {
517 Box::pin(async move {
518 assert_eq!(context.attempt(), 1);
519 Ok(TestOutput {
520 doubled: input.value * 2,
521 })
522 })
523 })?;
524 let workflow_id = WorkflowId::new_v4();
528 let run_id = aion_core::RunId::new_v4();
529 let activity_id = ActivityId::from_sequence_position(99);
530 let task = proto_task(
531 "double",
532 &TestInput { value: 21 },
533 &workflow_id,
534 &run_id,
535 &activity_id,
536 )?;
537 let (context, cancellation) =
538 crate::ActivityContext::for_workflow(workflow_id, run_id, activity_id, 1, None);
539 drop(cancellation);
540
541 let outcome = registry.dispatch(task.try_into()?, context).await?;
542
543 let DispatchOutcome::Completed { output } = outcome else {
544 return Err(WorkerError::decode(UnexpectedFailure));
545 };
546 assert_eq!(output.content_type(), &ContentType::Json);
547 let decoded: TestOutput = decode_payload(&output)?;
548 assert_eq!(decoded, TestOutput { doubled: 42 });
549 Ok(())
550 }
551
552 #[test]
553 fn duplicate_activity_registration_is_rejected() -> Result<(), WorkerError> {
554 let registry =
555 ActivityRegistry::new().register_activity("double", |input: TestInput, context| {
556 Box::pin(async move {
557 let _ = context;
558 Ok(TestOutput {
559 doubled: input.value * 2,
560 })
561 })
562 })?;
563
564 let error = registry
565 .register_activity("double", |input: TestInput, context| {
566 Box::pin(async move {
567 let _ = context;
568 Ok(TestOutput {
569 doubled: input.value,
570 })
571 })
572 })
573 .err()
574 .ok_or_else(|| WorkerError::decode(UnexpectedFailure))?;
575
576 assert!(
577 error
578 .to_string()
579 .contains("already has a registered handler")
580 );
581 Ok(())
582 }
583
584 fn proto_task(
585 activity_type: &str,
586 input: &TestInput,
587 workflow_id: &WorkflowId,
588 run_id: &aion_core::RunId,
589 activity_id: &ActivityId,
590 ) -> Result<ProtoActivityTask, WorkerError> {
591 Ok(ProtoActivityTask {
592 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
593 activity_id: Some(ProtoActivityId::from(activity_id.clone())),
594 run_id: Some(aion_proto::ProtoRunId::from(run_id.clone())),
595 activity_type: activity_type.to_owned(),
596 input: Some(ProtoPayload::from(encode_payload(&input)?)),
597 attempt: 1,
598 completion_token: String::from("generation-1"),
599 idempotency_key: String::from("effect-key"),
600 labels: std::collections::HashMap::new(),
601 })
602 }
603
604 #[derive(Debug, thiserror::Error)]
605 #[error("expected completed activity outcome")]
606 struct UnexpectedFailure;
607}