1use std::sync::Arc;
45
46use aion::ActivityDispatch;
47use aion_core::{ActivityError, ActivityErrorKind, ActivityId, ContentType, Payload, RunId};
48use aion_package::ActionBodyContract;
49use aion_store::OutboxRow;
50use async_trait::async_trait;
51
52use super::DeclaredCommandExecutor;
53use super::declared_body::{DeclaredBodyLookup, DeclaredBodySource, DispatchingRun};
54use super::declared_body_ambiguity::ambiguous_body_refusal;
55use super::dispatch::{ActivityCompletion, ActivityCompletionOutcome, ActivityCompletionSink};
56use super::envelope::{CompletionFences, CompletionToken};
57use super::outbox_dispatcher::OutboxRowDispatch;
58use crate::error::ServerError;
59
60const ROW_DISPATCH_CONFIG: &str = "{}";
64
65pub struct DeclaredBodyOutboxDispatch {
68 inner: Arc<dyn OutboxRowDispatch>,
69 bodies: DeclaredBodySource,
70 executor: Arc<DeclaredCommandExecutor>,
71 fences: CompletionFences,
72 sink: Arc<dyn ActivityCompletionSink + Send + Sync>,
73 handle: tokio::runtime::Handle,
74}
75
76impl std::fmt::Debug for DeclaredBodyOutboxDispatch {
77 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 formatter
79 .debug_struct("DeclaredBodyOutboxDispatch")
80 .field("bodies", &self.bodies)
81 .finish_non_exhaustive()
82 }
83}
84
85impl DeclaredBodyOutboxDispatch {
86 #[must_use]
92 pub fn new(
93 inner: Arc<dyn OutboxRowDispatch>,
94 bodies: DeclaredBodySource,
95 executor: Arc<DeclaredCommandExecutor>,
96 fences: CompletionFences,
97 sink: Arc<dyn ActivityCompletionSink + Send + Sync>,
98 handle: tokio::runtime::Handle,
99 ) -> Self {
100 Self {
101 inner,
102 bodies,
103 executor,
104 fences,
105 sink,
106 handle,
107 }
108 }
109
110 fn run_id(row: &OutboxRow) -> Result<RunId, ServerError> {
115 row.run_id.clone().ok_or_else(|| {
116 ServerError::worker_dispatch(
117 &row.namespace,
118 &row.activity_type,
119 format!(
120 "outbox row {} carries no run id; a declared body cannot be completed \
121 against a run it does not name",
122 row.dispatch_key
123 ),
124 )
125 })
126 }
127
128 fn request_for(row: &OutboxRow, run_id: RunId) -> Result<ActivityDispatch, String> {
132 let input = String::from_utf8(row.input.bytes().to_vec()).map_err(|_| {
133 "terminal:declared body input on the outbox row is not valid UTF-8".to_owned()
134 })?;
135 Ok(ActivityDispatch {
136 namespace: row.namespace.clone(),
137 task_queue: row.task_queue.clone(),
138 node: row.node.clone(),
139 workflow_id: row.workflow_id.clone(),
140 run_id,
141 activity_id: ActivityId::from_sequence_position(row.ordinal),
142 name: row.activity_type.clone(),
143 input,
144 config: ROW_DISPATCH_CONFIG.to_owned(),
145 attempt: row.started_attempt,
146 labels: std::collections::BTreeMap::new(),
147 advisory: false,
148 })
149 }
150
151 fn mint_token(&self, row: &OutboxRow, run_id: &RunId) -> Result<CompletionToken, ServerError> {
155 self.fences.issue(
156 &row.workflow_id,
157 run_id,
158 &ActivityId::from_sequence_position(row.ordinal),
159 row.started_attempt,
160 )
161 }
162
163 fn complete(
168 sink: &(dyn ActivityCompletionSink + Send + Sync),
169 row: &OutboxRow,
170 run_id: RunId,
171 token: CompletionToken,
172 ended: Result<String, String>,
173 ) -> Result<(), ServerError> {
174 let outcome = match ended {
175 Ok(encoded) => ActivityCompletionOutcome::Succeeded(Payload::new(
176 ContentType::Json,
177 encoded.into_bytes(),
178 )),
179 Err(reason) => ActivityCompletionOutcome::Failed(classify_failure(&reason)),
180 };
181 sink.complete_activity(ActivityCompletion {
182 workflow_id: row.workflow_id.clone(),
183 activity_id: ActivityId::from_sequence_position(row.ordinal),
184 run_id: Some(run_id),
185 completion_token: token,
186 outcome,
187 })
188 }
189
190 fn place(&self, row: &OutboxRow, contract: ActionBodyContract) -> Result<(), ServerError> {
195 let run_id = Self::run_id(row)?;
196 let token = self.mint_token(row, &run_id)?;
197 let request = match Self::request_for(row, run_id.clone()) {
198 Ok(request) => request,
199 Err(reason) => {
200 return Self::complete(self.sink.as_ref(), row, run_id, token, Err(reason));
204 }
205 };
206 tracing::info!(
207 operation = "declared_command_outbox_dispatch",
208 workflow_id = %row.workflow_id,
209 activity_id = %request.activity_id,
210 activity_name = %row.activity_type,
211 task_queue = %row.task_queue,
212 attempt = row.started_attempt,
213 "executing a declared action body at the server for an outbox (fan-out) row"
214 );
215 let executor = Arc::clone(&self.executor);
216 let sink = Arc::clone(&self.sink);
217 let fences = self.fences.clone();
218 let row = row.clone();
219 self.handle.spawn(async move {
220 let contract_for_run = contract;
221 let request_for_run = request;
222 let executor_for_run = Arc::clone(&executor);
223 let ended = tokio::task::spawn_blocking(move || {
224 executor_for_run.execute(&request_for_run, &contract_for_run)
225 })
226 .await
227 .unwrap_or_else(|join_error| {
228 Err(format!(
229 "terminal:the declared body's execution task ended abnormally: {join_error}"
230 ))
231 });
232 if ended
233 .as_ref()
234 .err()
235 .is_some_and(|reason| reason == aion::PARKED_ACTIVITY_REASON)
236 {
237 if let Err(error) = fences.revoke(
243 &row.workflow_id,
244 &ActivityId::from_sequence_position(row.ordinal),
245 &token,
246 ) {
247 tracing::warn!(
248 workflow_id = %row.workflow_id,
249 ordinal = row.ordinal,
250 %error,
251 "could not withdraw the completion token of a parked declared body"
252 );
253 }
254 tracing::info!(
255 operation = "declared_command_outbox_dispatch",
256 workflow_id = %row.workflow_id,
257 ordinal = row.ordinal,
258 "declared body parked: this server is draining and starts no new work"
259 );
260 return;
261 }
262 if let Err(error) = Self::complete(sink.as_ref(), &row, run_id, token, ended) {
263 tracing::error!(
264 operation = "declared_command_outbox_dispatch",
265 workflow_id = %row.workflow_id,
266 ordinal = row.ordinal,
267 %error,
268 "a declared body ran for an outbox row but its completion was refused"
269 );
270 }
271 });
272 Ok(())
273 }
274}
275
276fn classify_failure(reason: &str) -> ActivityError {
282 let (kind, message) = if let Some(rest) = reason.strip_prefix("terminal:") {
283 (ActivityErrorKind::Terminal, rest.to_owned())
284 } else if let Some(rest) = reason.strip_prefix("policy_refused:") {
285 (ActivityErrorKind::PolicyRefused, rest.to_owned())
286 } else if let Some(rest) = reason.strip_prefix("retryable:") {
287 (ActivityErrorKind::Retryable, rest.to_owned())
288 } else {
289 (ActivityErrorKind::Retryable, reason.to_owned())
290 };
291 ActivityError {
292 kind,
293 message,
294 details: None,
295 }
296}
297
298#[async_trait]
299impl OutboxRowDispatch for DeclaredBodyOutboxDispatch {
300 async fn dispatch(&self, row: &OutboxRow) -> Result<(), ServerError> {
301 let Some(run_id) = row.run_id.as_ref() else {
302 return self.inner.dispatch(row).await;
305 };
306 let run = DispatchingRun {
307 workflow_id: &row.workflow_id,
308 run_id,
309 };
310 match self
311 .bodies
312 .body_for(&row.task_queue, &row.activity_type, run)
313 {
314 DeclaredBodyLookup::None => self.inner.dispatch(row).await,
315 DeclaredBodyLookup::Unreadable(reason) => {
316 tracing::error!(
317 operation = "declared_command_outbox_dispatch",
318 workflow_id = %row.workflow_id,
319 activity_name = %row.activity_type,
320 task_queue = %row.task_queue,
321 %reason,
322 "declared-body catalog read failed; delegating the outbox row to the worker path"
323 );
324 self.inner.dispatch(row).await
325 }
326 DeclaredBodyLookup::Ambiguous { declaring } => {
327 let run_id = Self::run_id(row)?;
328 let token = self.mint_token(row, &run_id)?;
329 let refusal =
330 ambiguous_body_refusal(&row.activity_type, &row.task_queue, &declaring);
331 Self::complete(self.sink.as_ref(), row, run_id, token, Err(refusal))
332 }
333 DeclaredBodyLookup::Declared(contract) => self.place(row, contract),
334 }
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use std::sync::{Arc, Mutex};
341 use std::time::Duration;
342
343 use aion_core::{ActivityId, Payload, RunId, WorkflowId};
344 use aion_package::ActionBodyContract;
345 use aion_store::{OutboxRow, OutboxStatus};
346 use async_trait::async_trait;
347
348 use super::super::declared_body::{DeclaredBodies, DeclaredBodyLookup, DispatchingRun};
349 use super::super::declared_body_cancel::DeclaredCommandAttempts;
350 use super::super::workspace_root::WorkspaceRoot;
351 use super::*;
352
353 type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
354
355 struct RecordingInner {
358 rows: Arc<Mutex<Vec<String>>>,
359 }
360
361 #[async_trait]
362 impl OutboxRowDispatch for RecordingInner {
363 async fn dispatch(&self, row: &OutboxRow) -> Result<(), ServerError> {
364 self.rows
365 .lock()
366 .map_err(|_| ServerError::worker_dispatch("", "", "recording inner poisoned"))?
367 .push(row.dispatch_key.clone());
368 Err(ServerError::worker_dispatch(
369 &row.namespace,
370 &row.activity_type,
371 "no worker can currently serve this queue (NO_LIVE_POLLERS)",
372 ))
373 }
374 }
375
376 struct FixedBodies {
377 lookup: DeclaredBodyLookup,
378 }
379
380 impl DeclaredBodies for FixedBodies {
381 fn body_for(
382 &self,
383 _task_queue: &str,
384 _action: &str,
385 _run: DispatchingRun<'_>,
386 ) -> DeclaredBodyLookup {
387 self.lookup.clone()
388 }
389 }
390
391 #[derive(Default)]
393 struct RecordingSink {
394 completions: Arc<Mutex<Vec<ActivityCompletion>>>,
395 }
396
397 impl ActivityCompletionSink for RecordingSink {
398 fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError> {
399 self.completions
400 .lock()
401 .map_err(|_| ServerError::worker_dispatch("", "", "recording sink poisoned"))?
402 .push(completion);
403 Ok(())
404 }
405
406 fn park_activity(
407 &self,
408 _workflow_id: &WorkflowId,
409 _activity_id: &ActivityId,
410 ) -> Result<(), ServerError> {
411 Ok(())
412 }
413 }
414
415 const TRANSCRIPT_CAPACITY: std::num::NonZeroUsize = match std::num::NonZeroUsize::new(64) {
416 Some(capacity) => capacity,
417 None => unreachable!(),
418 };
419
420 fn executor() -> Arc<DeclaredCommandExecutor> {
421 let store: Arc<dyn aion_store::ObservabilityStore> =
422 Arc::new(aion_store::InMemoryObservabilityStore::default());
423 let transcript = crate::activity_publisher::ActivityEventPublisher::new(
424 store,
425 TRANSCRIPT_CAPACITY,
426 crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED,
427 );
428 Arc::new(DeclaredCommandExecutor::new(
429 DeclaredCommandAttempts::new(crate::shutdown::DrainState::default()),
430 tokio::runtime::Handle::current(),
431 WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
432 transcript,
433 ))
434 }
435
436 fn row(ordinal: u64, activity_type: &str, input: &str) -> OutboxRow {
437 let workflow_id = WorkflowId::new(uuid::Uuid::new_v4());
438 OutboxRow {
439 dispatch_key: format!("{workflow_id}:{ordinal}"),
440 workflow_id,
441 ordinal,
442 run_id: Some(RunId::new(uuid::Uuid::new_v4())),
443 namespace: "default".to_owned(),
444 task_queue: "json_box".to_owned(),
445 node: None,
446 activity_type: activity_type.to_owned(),
447 input: Payload::new(ContentType::Json, input.as_bytes().to_vec()),
448 status: OutboxStatus::Claimed,
449 attempt: 0,
450 started_attempt: 1,
451 visible_after: chrono::Utc::now(),
452 claimed_at: Some(chrono::Utc::now()),
453 failure_delivered: false,
454 }
455 }
456
457 struct Rig {
458 dispatch: DeclaredBodyOutboxDispatch,
459 inner_rows: Arc<Mutex<Vec<String>>>,
460 completions: Arc<Mutex<Vec<ActivityCompletion>>>,
461 fences: CompletionFences,
462 }
463
464 fn rig(lookup: DeclaredBodyLookup) -> Rig {
465 let inner_rows = Arc::new(Mutex::new(Vec::new()));
466 let bodies = DeclaredBodySource::default();
467 bodies.install(Arc::new(FixedBodies { lookup }));
468 let sink = RecordingSink::default();
469 let completions = Arc::clone(&sink.completions);
470 let fences = CompletionFences::default();
471 let dispatch = DeclaredBodyOutboxDispatch::new(
472 Arc::new(RecordingInner {
473 rows: Arc::clone(&inner_rows),
474 }),
475 bodies,
476 executor(),
477 fences.clone(),
478 Arc::new(sink),
479 tokio::runtime::Handle::current(),
480 );
481 Rig {
482 dispatch,
483 inner_rows,
484 completions,
485 fences,
486 }
487 }
488
489 async fn await_completions(
490 completions: &Arc<Mutex<Vec<ActivityCompletion>>>,
491 count: usize,
492 ) -> Result<Vec<ActivityCompletion>, Box<dyn std::error::Error + Send + Sync>> {
493 let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
494 loop {
495 let seen = completions
496 .lock()
497 .map_err(|_| "completions poisoned")?
498 .iter()
499 .map(|completion| ActivityCompletion {
500 workflow_id: completion.workflow_id.clone(),
501 activity_id: completion.activity_id.clone(),
502 run_id: completion.run_id.clone(),
503 completion_token: completion.completion_token.clone(),
504 outcome: completion.outcome.clone(),
505 })
506 .collect::<Vec<_>>();
507 if seen.len() >= count {
508 return Ok(seen);
509 }
510 if tokio::time::Instant::now() >= deadline {
511 return Err(format!("expected {count} completion(s), saw {}", seen.len()).into());
512 }
513 tokio::time::sleep(Duration::from_millis(20)).await;
514 }
515 }
516
517 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
522 async fn a_declared_body_row_executes_at_the_server_and_completes_through_the_sink()
523 -> TestResult {
524 let rig = rig(DeclaredBodyLookup::Declared(ActionBodyContract::Run {
525 command: "printf 'sha=%s' {{sha}}".to_owned(),
526 }));
527 let row = row(3, "size_of", r#"{"sha":"abc123"}"#);
528 rig.dispatch.dispatch(&row).await?;
529 assert!(
530 rig.inner_rows
531 .lock()
532 .map_err(|_| "inner poisoned")?
533 .is_empty(),
534 "a declared body must never reach the worker path"
535 );
536 let completions = await_completions(&rig.completions, 1).await?;
537 let completion = &completions[0];
538 assert_eq!(completion.workflow_id, row.workflow_id);
539 assert_eq!(
540 completion.activity_id,
541 ActivityId::from_sequence_position(3)
542 );
543 assert_eq!(completion.run_id, row.run_id);
544 let ActivityCompletionOutcome::Succeeded(payload) = &completion.outcome else {
545 return Err(format!("expected a success, got {:?}", completion.outcome).into());
546 };
547 let encoded = std::str::from_utf8(payload.bytes())?;
548 let outcome: serde_json::Value = serde_json::from_str(encoded)?;
549 assert_eq!(
550 outcome["stdout"], "sha=abc123",
551 "the body ran with the row's input bound"
552 );
553 assert_eq!(outcome["exit_code"], 0);
554 rig.fences.accept(
557 &row.workflow_id,
558 &completion.activity_id,
559 &completion.completion_token,
560 )?;
561 Ok(())
562 }
563
564 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
565 async fn a_row_without_a_body_goes_to_the_worker_path_untouched() -> TestResult {
566 let rig = rig(DeclaredBodyLookup::None);
567 let row = row(1, "size_of", r#"{"sha":"abc123"}"#);
568 let refused = rig.dispatch.dispatch(&row).await;
569 assert!(
570 refused.is_err(),
571 "the inner transport's refusal must surface as the row's"
572 );
573 assert_eq!(
574 *rig.inner_rows.lock().map_err(|_| "inner poisoned")?,
575 vec![row.dispatch_key.clone()]
576 );
577 assert!(
578 rig.completions
579 .lock()
580 .map_err(|_| "completions poisoned")?
581 .is_empty()
582 );
583 Ok(())
584 }
585
586 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
587 async fn an_unreadable_catalog_delegates_to_the_worker_path() -> TestResult {
588 let rig = rig(DeclaredBodyLookup::Unreadable("catalog offline".to_owned()));
589 let row = row(1, "size_of", r#"{"sha":"abc123"}"#);
590 let _ = rig.dispatch.dispatch(&row).await;
591 assert_eq!(
592 *rig.inner_rows.lock().map_err(|_| "inner poisoned")?,
593 vec![row.dispatch_key.clone()]
594 );
595 Ok(())
596 }
597
598 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
599 async fn an_ambiguous_body_is_a_terminal_failure_delivered_to_the_workflow() -> TestResult {
600 let rig = rig(DeclaredBodyLookup::Ambiguous {
601 declaring: Vec::new(),
602 });
603 let row = row(2, "size_of", r#"{"sha":"abc123"}"#);
604 rig.dispatch.dispatch(&row).await?;
605 assert!(
606 rig.inner_rows
607 .lock()
608 .map_err(|_| "inner poisoned")?
609 .is_empty()
610 );
611 let completions = await_completions(&rig.completions, 1).await?;
612 let ActivityCompletionOutcome::Failed(error) = &completions[0].outcome else {
613 return Err("expected a failure".into());
614 };
615 assert_eq!(error.kind, ActivityErrorKind::Terminal);
616 Ok(())
617 }
618
619 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
624 async fn a_failing_body_completes_as_the_executors_retryable_failure() -> TestResult {
625 let rig = rig(DeclaredBodyLookup::Declared(ActionBodyContract::Run {
626 command: "sh -c 'exit 7'".to_owned(),
627 }));
628 let row = row(4, "size_of", "{}");
629 rig.dispatch.dispatch(&row).await?;
630 let completions = await_completions(&rig.completions, 1).await?;
631 let ActivityCompletionOutcome::Failed(error) = &completions[0].outcome else {
632 return Err(format!("expected a failure, got {:?}", completions[0].outcome).into());
633 };
634 assert_eq!(error.kind, ActivityErrorKind::Retryable);
635 assert!(
636 error.message.contains("exited 7"),
637 "the executor's own sentence must travel: {}",
638 error.message
639 );
640 Ok(())
641 }
642
643 #[test]
644 fn failure_classification_follows_the_executor_prefixes() {
645 assert_eq!(
646 classify_failure("terminal:x").kind,
647 ActivityErrorKind::Terminal
648 );
649 assert_eq!(classify_failure("terminal:x").message, "x");
650 assert_eq!(
651 classify_failure("policy_refused:y").kind,
652 ActivityErrorKind::PolicyRefused
653 );
654 assert_eq!(
655 classify_failure("retryable:z").kind,
656 ActivityErrorKind::Retryable
657 );
658 let timeout = classify_failure("timeout:attempt outlived its bound");
659 assert_eq!(timeout.kind, ActivityErrorKind::Retryable);
660 assert_eq!(timeout.message, "timeout:attempt outlived its bound");
661 }
662}