Skip to main content

obeli_sk_executor/
executor.rs

1use crate::worker::{
2    FatalError, RunFinished, Worker, WorkerContext, WorkerError, WorkerResult, WorkerResultOk,
3};
4use assert_matches::assert_matches;
5use chrono::{DateTime, Utc};
6use concepts::prefixed_ulid::{DeploymentId, RunId};
7use concepts::storage::{
8    AppendEventsToExecution, AppendRequest, AppendResponseToExecution, DbConnection,
9    DbErrorGeneric, DbErrorWrite, DbErrorWriteNonRetriable, DbExecutor, DbPool, ExecutionLog,
10    LockedExecution, Unlocked,
11};
12use concepts::time::{ClockFn, Sleep};
13use concepts::{
14    ComponentId, ComponentRetryConfig, ComponentType, FunctionMetadata, StrVariant,
15    SupportedFunctionReturnValue,
16};
17use concepts::{ExecutionFailureKind, JoinSetId};
18use concepts::{ExecutionId, FunctionFqn, prefixed_ulid::ExecutorId};
19use concepts::{
20    FinishedExecutionFailure,
21    storage::{ExecutionRequest, Version},
22};
23use std::{
24    sync::{
25        Arc,
26        atomic::{AtomicBool, Ordering},
27    },
28    time::Duration,
29};
30use tokio::task::JoinHandle;
31use tracing::{Instrument, Level, Span, debug, error, info, info_span, instrument, trace, warn};
32
33#[derive(Debug, Clone)]
34pub struct ExecConfig {
35    pub lock_expiry: Duration,
36    pub tick_sleep: Duration,
37    pub batch_size: u32,
38    pub component_id: ComponentId,
39    pub task_limiter_global: Option<Arc<tokio::sync::Semaphore>>,
40    pub task_limiter_local: Option<Arc<tokio::sync::Semaphore>>,
41    pub executor_id: ExecutorId,
42    pub retry_config: ComponentRetryConfig,
43    pub locking_strategy: LockingStrategy,
44}
45
46pub struct ExecTask {
47    worker: Arc<dyn Worker>,
48    pub config: ExecConfig,
49    clock_fn: Box<dyn ClockFn>, // Used for obtaining current time when the execution finishes.
50    db_pool: Arc<dyn DbPool>,
51    locking_strategy_holder: LockingStrategyHolder,
52    worker_count_tx: tokio::sync::watch::Sender<usize>,
53    executor_close_watcher: tokio::sync::watch::Receiver<bool>,
54}
55
56#[derive(derive_more::Debug, Default)]
57pub struct ExecutionProgress {
58    #[debug(skip)]
59    #[allow(dead_code)]
60    executions: Vec<(ExecutionId, JoinHandle<()>)>,
61}
62
63impl ExecutionProgress {
64    #[cfg(feature = "test")]
65    pub async fn wait_for_tasks(self) -> Vec<ExecutionId> {
66        let mut vec = Vec::new();
67        for (exe, join_handle) in self.executions {
68            vec.push(exe);
69            join_handle.await.unwrap();
70        }
71        vec
72    }
73}
74
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub enum WorkerType {
77    Activity,
78    Workflow,
79}
80
81#[derive(derive_more::Debug)]
82pub struct ExecutorTaskHandle {
83    // Signals close() -> event loop to shut down
84    #[debug(skip)]
85    is_closing: Arc<AtomicBool>,
86    #[debug(skip)]
87    join_handle: JoinHandle<()>,
88    component_id: ComponentId,
89    executor_id: ExecutorId,
90    deployment_id: DeploymentId,
91    // Signals close() -> workers to shut down
92    executor_closing_signal_sender: tokio::sync::watch::Sender<bool>,
93    /// Tracks the number of worker tasks currently in-flight.
94    worker_count_rx: tokio::sync::watch::Receiver<usize>,
95}
96
97pub struct WorkerTasksHandle {
98    component_id: ComponentId,
99    executor_id: ExecutorId,
100    deployment_id: DeploymentId,
101    // Signals close() -> workers to shut down
102    executor_closing_signal_sender: tokio::sync::watch::Sender<bool>,
103    /// Tracks the number of worker tasks currently in-flight.
104    worker_count_rx: tokio::sync::watch::Receiver<usize>,
105}
106
107impl ExecutorTaskHandle {
108    /// Shut down the executor task, return worker tasks handle untouched.
109    #[instrument(name = "executor.close", skip_all, fields(executor_id = %self.executor_id, component_id = %self.component_id,
110        deployment_id = %self.deployment_id))]
111    pub async fn close_outer_task(mut self) -> WorkerTasksHandle {
112        debug!("Gracefully closing executor task");
113        self.is_closing.store(true, Ordering::Relaxed);
114
115        loop {
116            tokio::select! {
117                () = tokio::time::sleep(Duration::from_secs(5)) => {
118                    info!("Waiting for executor to shut down");
119                }
120                _ = &mut self.join_handle => {
121                    break;
122                }
123            }
124        }
125        let worker_tasks = *self.worker_count_rx.borrow();
126        debug!("Gracefully closed executor task, {worker_tasks} workers are in progress");
127        WorkerTasksHandle {
128            component_id: self.component_id,
129            executor_id: self.executor_id,
130            deployment_id: self.deployment_id,
131            executor_closing_signal_sender: self.executor_closing_signal_sender,
132            worker_count_rx: self.worker_count_rx,
133        }
134    }
135
136    #[must_use]
137    pub fn component_id(&self) -> &ComponentId {
138        &self.component_id
139    }
140}
141
142impl WorkerTasksHandle {
143    /// Signal to worker tasks, then wait for all tasks to finish.
144    #[instrument(name = "WorkerTasksHandle.close", skip_all, fields(executor_id = %self.executor_id, component_id = %self.component_id,
145        deployment_id = %self.deployment_id))]
146    pub async fn close(self) {
147        debug!("Signaling worker tasks to unlock");
148        let _ = self.executor_closing_signal_sender.send(true);
149        let mut worker_count_rx = self.worker_count_rx.clone();
150        loop {
151            tokio::select! {
152                () = tokio::time::sleep(Duration::from_secs(1)) => {
153                    info!("Waiting for {} workers to shut down", *self.worker_count_rx.borrow());
154                }
155                _ = worker_count_rx.wait_for(|&count| count == 0) => {
156                    break;
157                }
158            }
159        }
160        debug!("All worker tasks are finished");
161    }
162}
163
164#[cfg(feature = "test")]
165pub fn extract_exported_ffqns_noext_test(worker: &dyn Worker) -> Arc<[FunctionFqn]> {
166    extract_exported_ffqns_noext(worker)
167}
168
169fn extract_exported_ffqns_noext(worker: &dyn Worker) -> Arc<[FunctionFqn]> {
170    worker
171        .exported_functions_noext()
172        .iter()
173        .map(|FunctionMetadata { ffqn, .. }| ffqn.clone())
174        .collect::<Arc<_>>()
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum LockingStrategy {
179    ByFfqns,
180    ByComponentDigest,
181    Auto,
182}
183impl LockingStrategy {
184    fn holder(&self, ffqns: Arc<[FunctionFqn]>) -> LockingStrategyHolder {
185        match self {
186            LockingStrategy::ByFfqns => LockingStrategyHolder::ByFfqns(ffqns),
187            LockingStrategy::ByComponentDigest => LockingStrategyHolder::ByComponentDigest,
188            LockingStrategy::Auto => LockingStrategyHolder::Auto { ffqns },
189        }
190    }
191}
192
193enum LockingStrategyHolder {
194    ByFfqns(Arc<[FunctionFqn]>),
195    ByComponentDigest,
196    Auto { ffqns: Arc<[FunctionFqn]> },
197}
198
199#[derive(Default)]
200#[expect(dead_code)] // Stored permits limit semaphores until dropped.
201struct TaskLimiterPermit {
202    global: Option<tokio::sync::OwnedSemaphorePermit>,
203    local: Option<tokio::sync::OwnedSemaphorePermit>,
204}
205
206impl ExecTask {
207    #[cfg(feature = "test")]
208    pub fn new_test(
209        config: ExecConfig,
210        worker: Arc<dyn Worker>,
211        clock_fn: Box<dyn ClockFn>,
212        db_pool: Arc<dyn DbPool>,
213        ffqns: Arc<[FunctionFqn]>,
214    ) -> (Self, tokio::sync::watch::Sender<bool>) {
215        let (worker_count_tx, _) = tokio::sync::watch::channel(0usize);
216        let (close_tx, executor_close_watcher) = tokio::sync::watch::channel(false);
217        (
218            ExecTask {
219                worker,
220                locking_strategy_holder: config.locking_strategy.holder(ffqns),
221                config,
222                clock_fn,
223                db_pool,
224                worker_count_tx,
225                executor_close_watcher,
226            },
227            close_tx,
228        )
229    }
230
231    #[cfg(feature = "test")]
232    pub fn new_all_ffqns_test(
233        worker: Arc<dyn Worker>,
234        config: ExecConfig,
235        clock_fn: Box<dyn ClockFn>,
236        db_pool: Arc<dyn DbPool>,
237    ) -> (Self, tokio::sync::watch::Sender<bool>) {
238        let ffqns = extract_exported_ffqns_noext(worker.as_ref());
239        let (worker_count_tx, _) = tokio::sync::watch::channel(0usize);
240        let (close_tx, executor_close_watcher) = tokio::sync::watch::channel(false);
241        (
242            Self {
243                worker,
244                locking_strategy_holder: config.locking_strategy.holder(ffqns),
245                config,
246                clock_fn,
247                db_pool,
248                worker_count_tx,
249                executor_close_watcher,
250            },
251            close_tx,
252        )
253    }
254
255    #[cfg(feature = "test")]
256    pub fn new_all_ffqns_test_with_close_watcher(
257        worker: Arc<dyn Worker>,
258        config: ExecConfig,
259        clock_fn: Box<dyn ClockFn>,
260        db_pool: Arc<dyn DbPool>,
261        executor_close_watcher: tokio::sync::watch::Receiver<bool>,
262    ) -> Self {
263        let ffqns = extract_exported_ffqns_noext(worker.as_ref());
264        let (worker_count_tx, _) = tokio::sync::watch::channel(0usize);
265        Self {
266            worker,
267            locking_strategy_holder: config.locking_strategy.holder(ffqns),
268            config,
269            clock_fn,
270            db_pool,
271            worker_count_tx,
272            executor_close_watcher,
273        }
274    }
275
276    /// Spawn new tokio worker locking executions and starting worker tasks.
277    pub fn spawn_new(
278        deployment_id: DeploymentId,
279        worker: Arc<dyn Worker>,
280        config: ExecConfig,
281        clock_fn: Box<dyn ClockFn>,
282        db_pool: Arc<dyn DbPool>,
283        sleep: impl Sleep + Clone + 'static,
284    ) -> ExecutorTaskHandle {
285        let is_closing = Arc::new(AtomicBool::default());
286        let is_closing_inner = is_closing.clone();
287        let ffqns = extract_exported_ffqns_noext(worker.as_ref());
288        let component_id = config.component_id.clone();
289        let executor_id = config.executor_id;
290        let (worker_count_tx, worker_count_rx) = tokio::sync::watch::channel(0);
291        let (executor_closing_signal_sender, executor_close_watcher) =
292            tokio::sync::watch::channel(false);
293        let join_handle = tokio::spawn(async move {
294            debug!(executor_id = %config.executor_id, component_id = %config.component_id, "Spawned executor");
295            let lock_strategy_holder = config.locking_strategy.holder(ffqns);
296            let task = ExecTask {
297                worker,
298                config,
299                db_pool,
300                locking_strategy_holder: lock_strategy_holder,
301                clock_fn: clock_fn.clone_box(),
302                worker_count_tx,
303                executor_close_watcher,
304            };
305            let mut old_err = None;
306            while !is_closing_inner.load(Ordering::Relaxed) {
307                let res = task.db_pool.db_exec_conn().await;
308                let res = log_err_if_new(res, &mut old_err);
309                if let Ok(db_exec) = res {
310                    let _ = task
311                        .tick(
312                            db_exec.as_ref(),
313                            clock_fn.now(),
314                            RunId::generate(),
315                            deployment_id,
316                        )
317                        .await;
318                    let timeout_fut = {
319                        let sleep = sleep.clone();
320                        Box::pin(async move { sleep.sleep(task.config.tick_sleep).await })
321                    };
322                    match &task.locking_strategy_holder {
323                        LockingStrategyHolder::ByFfqns(ffqns) => {
324                            db_exec
325                                .wait_for_pending_by_ffqn(
326                                    clock_fn.now(),
327                                    ffqns.clone(),
328                                    None,
329                                    timeout_fut,
330                                )
331                                .await;
332                        }
333                        LockingStrategyHolder::ByComponentDigest => {
334                            db_exec
335                                .wait_for_pending_by_component_digest(
336                                    clock_fn.now(),
337                                    &task.config.component_id.component_digest,
338                                    timeout_fut,
339                                )
340                                .await;
341                        }
342                        LockingStrategyHolder::Auto { ffqns } => {
343                            db_exec
344                                .wait_for_pending_by_ffqn(
345                                    clock_fn.now(),
346                                    ffqns.clone(),
347                                    Some(task.config.component_id.component_digest.clone()),
348                                    timeout_fut,
349                                )
350                                .await;
351                        }
352                    }
353                } else {
354                    sleep.sleep(task.config.tick_sleep).await;
355                }
356            }
357        });
358        ExecutorTaskHandle {
359            is_closing,
360            join_handle,
361            component_id,
362            executor_id,
363            deployment_id,
364            executor_closing_signal_sender,
365            worker_count_rx,
366        }
367    }
368
369    fn acquire_task_permits(&self) -> Vec<TaskLimiterPermit> {
370        let mut locks = Vec::with_capacity(
371            usize::try_from(self.config.batch_size).expect("16 bit systems are unsupported"),
372        );
373        for _ in 0..self.config.batch_size {
374            match (
375                &self.config.task_limiter_global,
376                &self.config.task_limiter_local,
377            ) {
378                (Some(global), Some(local)) => {
379                    if let Ok(global) = global.clone().try_acquire_owned()
380                        && let Ok(local) = local.clone().try_acquire_owned()
381                    {
382                        locks.push(TaskLimiterPermit {
383                            global: Some(global),
384                            local: Some(local),
385                        });
386                    } else {
387                        break;
388                    }
389                }
390                (Some(global), None) => {
391                    if let Ok(global) = global.clone().try_acquire_owned() {
392                        locks.push(TaskLimiterPermit {
393                            global: Some(global),
394                            local: None,
395                        });
396                    } else {
397                        break;
398                    }
399                }
400                (None, Some(local)) => {
401                    if let Ok(local) = local.clone().try_acquire_owned() {
402                        locks.push(TaskLimiterPermit {
403                            global: None,
404                            local: Some(local),
405                        });
406                    } else {
407                        break;
408                    }
409                }
410                (None, None) => {
411                    locks.push(TaskLimiterPermit::default());
412                }
413            }
414        }
415        locks
416    }
417
418    #[cfg(feature = "test")]
419    pub async fn tick_test(&self, executed_at: DateTime<Utc>, run_id: RunId) -> ExecutionProgress {
420        use concepts::prefixed_ulid::DEPLOYMENT_ID_DUMMY;
421
422        let db_exec = self.db_pool.db_exec_conn().await.unwrap();
423        self.tick(db_exec.as_ref(), executed_at, run_id, DEPLOYMENT_ID_DUMMY)
424            .await
425            .unwrap()
426    }
427
428    #[cfg(feature = "test")]
429    pub async fn tick_test_await(
430        &self,
431        executed_at: DateTime<Utc>,
432        run_id: RunId,
433    ) -> Vec<ExecutionId> {
434        use concepts::prefixed_ulid::DEPLOYMENT_ID_DUMMY;
435
436        let db_exec = self.db_pool.db_exec_conn().await.unwrap();
437        self.tick(db_exec.as_ref(), executed_at, run_id, DEPLOYMENT_ID_DUMMY)
438            .await
439            .unwrap()
440            .wait_for_tasks()
441            .await
442    }
443
444    #[instrument(level = Level::TRACE, name = "executor.tick" skip_all, fields(executor_id = %self.config.executor_id, component_id = %self.config.component_id))]
445    async fn tick(
446        &self,
447        db_exec: &dyn DbExecutor,
448        executed_at: DateTime<Utc>,
449        run_id: RunId,
450        deployment_id: DeploymentId,
451    ) -> Result<ExecutionProgress, DbErrorWrite> {
452        let locked_executions = {
453            let mut permits = self.acquire_task_permits();
454            if permits.is_empty() {
455                return Ok(ExecutionProgress::default());
456            }
457            let lock_expires_at = executed_at + self.config.lock_expiry;
458            let batch_size = u32::try_from(permits.len()).expect("ExecConfig.batch_size is u32");
459            let locked_executions = match &self.locking_strategy_holder {
460                LockingStrategyHolder::Auto { ffqns } => {
461                    db_exec
462                        .lock_pending_by_ffqns_auto(
463                            batch_size,
464                            executed_at, // fetch expiring before now
465                            ffqns.clone(),
466                            executed_at, // created at
467                            self.config.component_id.clone(),
468                            deployment_id,
469                            self.config.executor_id,
470                            lock_expires_at,
471                            run_id,
472                            self.config.retry_config,
473                        )
474                        .await?
475                }
476                LockingStrategyHolder::ByFfqns(ffqns) => {
477                    db_exec
478                        .lock_pending_by_ffqns(
479                            batch_size,
480                            executed_at, // fetch expiring before now
481                            ffqns.clone(),
482                            executed_at, // created at
483                            self.config.component_id.clone(),
484                            deployment_id,
485                            self.config.executor_id,
486                            lock_expires_at,
487                            run_id,
488                            self.config.retry_config,
489                        )
490                        .await?
491                }
492                LockingStrategyHolder::ByComponentDigest => {
493                    db_exec
494                        .lock_pending_by_component_digest(
495                            batch_size,
496                            executed_at, // pending_at_or_sooner
497                            &self.config.component_id,
498                            deployment_id,
499                            executed_at, // created at
500                            self.config.executor_id,
501                            lock_expires_at,
502                            run_id,
503                            self.config.retry_config,
504                        )
505                        .await?
506                }
507            };
508            // Drop permits if too many were allocated.
509            while permits.len() > locked_executions.len() {
510                permits.pop();
511            }
512            assert_eq!(permits.len(), locked_executions.len());
513            locked_executions.into_iter().zip(permits)
514        };
515
516        let mut executions = Vec::with_capacity(locked_executions.len());
517        for (locked_execution, permit) in locked_executions {
518            let execution_id = locked_execution.execution_id.clone();
519            let join_handle = {
520                let worker = self.worker.clone();
521                let db_pool = self.db_pool.clone();
522                let clock_fn = self.clock_fn.clone_box();
523                let worker_span = info_span!(parent: None, "worker",
524                    "otel.name" = format!("worker {}", locked_execution.ffqn),
525                    %execution_id, %run_id,
526                    ffqn = %locked_execution.ffqn,
527                    executor_id = %self.config.executor_id,
528                    component_id = %self.config.component_id,
529                    %deployment_id,
530                );
531                locked_execution.metadata.enrich(&worker_span);
532                let component_type = self.config.component_id.component_type;
533                let worker_count_tx = self.worker_count_tx.clone();
534                worker_count_tx.send_modify(|n| *n += 1);
535                let executor_close_watcher = self.executor_close_watcher.clone();
536                tokio::spawn({
537                    let worker_span2 = worker_span.clone();
538                    let retry_config = self.config.retry_config;
539                    async move {
540                        let _permit = permit;
541                        let res = Self::run_worker(
542                            component_type,
543                            worker,
544                            db_pool,
545                            clock_fn,
546                            locked_execution,
547                            retry_config,
548                            worker_span2,
549                            executor_close_watcher
550                        )
551                        .await;
552                        if let Err(db_error) = res {
553                            error!("Got db error `{db_error:?}`, expecting watcher to mark execution as timed out");
554                        }
555                        worker_count_tx.send_modify(|n| *n -= 1);
556                    }
557                    .instrument(worker_span)
558                })
559            };
560            executions.push((execution_id, join_handle));
561        }
562        Ok(ExecutionProgress { executions })
563    }
564
565    #[expect(clippy::too_many_arguments)]
566    async fn run_worker(
567        component_type: ComponentType,
568        worker: Arc<dyn Worker>,
569        db_pool: Arc<dyn DbPool>,
570        clock_fn: Box<dyn ClockFn>,
571        locked_execution: LockedExecution,
572        retry_config: ComponentRetryConfig,
573        worker_span: Span,
574        executor_close_watcher: tokio::sync::watch::Receiver<bool>,
575    ) -> Result<(), DbErrorWrite> {
576        debug!("Worker::run starting");
577        trace!(
578            version = %locked_execution.next_version,
579            params = ?locked_execution.params,
580            event_history = ?locked_execution.event_history,
581            "Worker::run starting"
582        );
583        let can_be_retried = ExecutionLog::can_be_retried_after(
584            locked_execution.intermittent_event_count + 1,
585            retry_config.max_retries,
586            retry_config.retry_exp_backoff,
587        );
588        let unlock_expiry_on_limit_reached =
589            ExecutionLog::compute_retry_duration_when_retrying_forever(
590                locked_execution.intermittent_event_count + 1,
591                retry_config.retry_exp_backoff,
592            );
593        let parent = locked_execution.parent.clone();
594        let execution_id = locked_execution.execution_id.clone();
595        let ctx = WorkerContext {
596            execution_id: locked_execution.execution_id.clone(),
597            metadata: locked_execution.metadata,
598            component_digest: locked_execution.component_digest,
599            ffqn: locked_execution.ffqn,
600            params: locked_execution.params,
601            event_history: locked_execution.event_history,
602            responses: locked_execution.responses,
603            parent: parent.clone(),
604            version: locked_execution.next_version,
605            can_be_retried: can_be_retried.is_some(),
606            locked_event: locked_execution.locked_event,
607            worker_span,
608            executor_close_watcher,
609        };
610        let worker_result = worker.run(ctx).await;
611        debug!("Worker::run finished {worker_result:?}");
612        let result_obtained_at = clock_fn.now();
613        if component_type == ComponentType::Activity
614            && matches!(
615                &worker_result,
616                WorkerResult::Err(WorkerError::FatalError(FatalError::Cancelled, _))
617            )
618        {
619            let db_conn = db_pool.connection().await?;
620            if let Some(append) = Self::cancelled_activity_to_append(
621                db_conn.as_ref(),
622                execution_id,
623                result_obtained_at,
624            )
625            .await?
626            {
627                trace!("Appending activity cancellation {append:?}");
628                append.append(db_conn.as_ref()).await?;
629            }
630            return Ok(());
631        }
632        match Self::worker_result_to_execution_event(
633            component_type,
634            execution_id,
635            worker_result,
636            result_obtained_at,
637            parent,
638            can_be_retried,
639            unlock_expiry_on_limit_reached,
640        )? {
641            Some(append) => {
642                trace!("Appending {append:?}");
643                let db_exec = db_pool.db_exec_conn().await?;
644                append.append(db_exec.as_ref()).await
645            }
646            None => Ok(()),
647        }
648    }
649
650    async fn cancelled_activity_to_append(
651        db_conn: &dyn DbConnection,
652        execution_id: ExecutionId,
653        result_obtained_at: DateTime<Utc>,
654    ) -> Result<Option<Append>, DbErrorWrite> {
655        let log = db_conn
656            .get(&execution_id)
657            .await
658            .map_err(DbErrorWrite::from)?;
659        if log.is_finished() {
660            return Ok(None);
661        }
662        if !log.pending_state.is_cancelling() {
663            warn!(
664                %execution_id,
665                "Activity reported cancellation after teardown, but storage is not cancelling"
666            );
667            return Ok(None);
668        }
669        let result = SupportedFunctionReturnValue::ExecutionFailure(FinishedExecutionFailure {
670            reason: None,
671            kind: ExecutionFailureKind::Cancelled,
672            detail: None,
673        });
674        let child_finished =
675            log.parent().map(
676                |(parent_execution_id, parent_join_set)| ChildFinishedResponse {
677                    parent_execution_id,
678                    parent_join_set,
679                    result: result.clone(),
680                },
681            );
682        Ok(Some(Append {
683            created_at: result_obtained_at,
684            primary_event: AppendRequest {
685                created_at: result_obtained_at,
686                event: ExecutionRequest::Finished {
687                    retval: result,
688                    http_client_traces: None,
689                },
690            },
691            execution_id,
692            version: log.next_version,
693            child_finished,
694        }))
695    }
696
697    /// Map the `WorkerError` to an optional append event
698    fn worker_result_to_execution_event(
699        component_type: ComponentType,
700        execution_id: ExecutionId,
701        worker_result: WorkerResult,
702        result_obtained_at: DateTime<Utc>,
703        parent: Option<(ExecutionId, JoinSetId)>,
704        can_be_retried: Option<Duration>,
705        unlock_expiry_on_limit_reached: Duration,
706    ) -> Result<Option<Append>, DbErrorWrite> {
707        Ok(match worker_result {
708            WorkerResult::Ok(WorkerResultOk::RunFinished(RunFinished {
709                retval: ref retval @ SupportedFunctionReturnValue::Err(ref result_err),
710                version,
711                http_client_traces,
712            })) if component_type == ComponentType::Activity
713                && can_be_retried.is_some()
714                && !retval.is_permanent_variant() =>
715            {
716                // Interpret returned `err` variant as a retry request, unless it is a permanent variant
717                let detail = serde_json::to_string(result_err)
718                    .expect("SupportedFunctionReturnValue should be serializable to JSON");
719                let duration = can_be_retried.expect(
720                    "ActivityReturnedError must not be returned when retries are exhausted",
721                );
722                let expires_at = result_obtained_at + duration;
723                debug!("Retrying ActivityReturnedError after {duration:?} at {expires_at}");
724                let primary_event = ExecutionRequest::TemporarilyFailed {
725                    backoff_expires_at: expires_at,
726                    reason: StrVariant::Static("activity finished with error"),
727                    detail: Some(detail),
728                    http_client_traces,
729                };
730                Some(Append {
731                    created_at: result_obtained_at,
732                    primary_event: AppendRequest {
733                        created_at: result_obtained_at,
734                        event: primary_event,
735                    },
736                    execution_id,
737                    version,
738                    child_finished: None,
739                })
740            }
741
742            WorkerResult::Ok(WorkerResultOk::RunFinished(RunFinished {
743                retval: result,
744                version,
745                http_client_traces,
746            })) => {
747                info!("Execution finished: {result}");
748                let child_finished =
749                    parent.map(
750                        |(parent_execution_id, parent_join_set)| ChildFinishedResponse {
751                            parent_execution_id,
752                            parent_join_set,
753                            result: result.clone(),
754                        },
755                    );
756                let primary_event = AppendRequest {
757                    created_at: result_obtained_at,
758                    event: ExecutionRequest::Finished {
759                        retval: result,
760                        http_client_traces,
761                    },
762                };
763
764                Some(Append {
765                    created_at: result_obtained_at,
766                    primary_event,
767                    execution_id,
768                    version,
769                    child_finished,
770                })
771            }
772
773            WorkerResult::Ok(WorkerResultOk::DbUpdatedByWorkerOrWatcher) => None,
774
775            WorkerResult::Err(err) => {
776                let reason_generic = err.to_string(); // Override with err's reason if no information is lost.
777
778                let (primary_event, child_finished, version) = match err {
779                    WorkerError::ExecutorClosing(version) => {
780                        let primary_event = ExecutionRequest::Unlocked(Unlocked {
781                            unlocked_at: result_obtained_at,
782                            reason: "executor closing".into(),
783                        });
784                        (primary_event, None, version)
785                    }
786                    WorkerError::TemporaryTimeout {
787                        http_client_traces,
788                        version,
789                    } => {
790                        if let Some(duration) = can_be_retried {
791                            let backoff_expires_at = result_obtained_at + duration;
792                            info!(
793                                "Temporary timeout, retrying after {duration:?} at {backoff_expires_at}"
794                            );
795                            (
796                                ExecutionRequest::TemporarilyTimedOut {
797                                    backoff_expires_at,
798                                    http_client_traces,
799                                },
800                                None,
801                                version,
802                            )
803                        } else {
804                            info!("Execution timed out");
805                            let result = SupportedFunctionReturnValue::ExecutionFailure(
806                                FinishedExecutionFailure {
807                                    kind: ExecutionFailureKind::TimedOut,
808                                    reason: None,
809                                    detail: None,
810                                },
811                            );
812                            let child_finished =
813                                parent.map(|(parent_execution_id, parent_join_set)| {
814                                    ChildFinishedResponse {
815                                        parent_execution_id,
816                                        parent_join_set,
817                                        result: result.clone(),
818                                    }
819                                });
820                            (
821                                ExecutionRequest::Finished {
822                                    retval: result,
823                                    http_client_traces,
824                                },
825                                child_finished,
826                                version,
827                            )
828                        }
829                    }
830                    WorkerError::DbError(db_error) => {
831                        return Err(db_error);
832                    }
833                    WorkerError::ActivityTrap {
834                        reason: _, // reason_generic contains trap_kind + reason
835                        trap_kind,
836                        detail,
837                        version,
838                        http_client_traces,
839                    } => {
840                        if let Some(duration) = can_be_retried {
841                            let expires_at = result_obtained_at + duration;
842                            debug!(
843                                "Retrying activity with `{trap_kind}` execution after {duration:?} at {expires_at}"
844                            );
845                            (
846                                ExecutionRequest::TemporarilyFailed {
847                                    reason: StrVariant::from(reason_generic),
848                                    backoff_expires_at: expires_at,
849                                    detail,
850                                    http_client_traces,
851                                },
852                                None,
853                                version,
854                            )
855                        } else {
856                            info!(
857                                "Activity with `{trap_kind}` marked as permanent failure - {reason_generic}"
858                            );
859                            let result = SupportedFunctionReturnValue::ExecutionFailure(
860                                FinishedExecutionFailure {
861                                    reason: Some(reason_generic),
862                                    kind: ExecutionFailureKind::Uncategorized,
863                                    detail,
864                                },
865                            );
866                            let child_finished =
867                                parent.map(|(parent_execution_id, parent_join_set)| {
868                                    ChildFinishedResponse {
869                                        parent_execution_id,
870                                        parent_join_set,
871                                        result: result.clone(),
872                                    }
873                                });
874                            (
875                                ExecutionRequest::Finished {
876                                    retval: result,
877                                    http_client_traces,
878                                },
879                                child_finished,
880                                version,
881                            )
882                        }
883                    }
884                    WorkerError::LimitReached {
885                        reason,
886                        version: new_version,
887                    } => {
888                        let expires_at = result_obtained_at + unlock_expiry_on_limit_reached;
889                        warn!(
890                            "Limit reached: {reason}, unlocking after {unlock_expiry_on_limit_reached:?} at {expires_at}"
891                        );
892                        (
893                            ExecutionRequest::Unlocked(Unlocked {
894                                unlocked_at: expires_at,
895                                reason: StrVariant::from(reason),
896                            }),
897                            None,
898                            new_version,
899                        )
900                    }
901                    WorkerError::FatalError(FatalError::Cancelled, _version) => {
902                        unreachable!(
903                            "activity Cancelled is handled before this match; workflows are cancelled via the driver and never return FatalError::Cancelled"
904                        )
905                    }
906                    WorkerError::FatalError(fatal_error, version) => {
907                        warn!("Fatal worker error - {fatal_error:?}");
908                        let result = SupportedFunctionReturnValue::ExecutionFailure(
909                            FinishedExecutionFailure::from(fatal_error),
910                        );
911                        let child_finished =
912                            parent.map(|(parent_execution_id, parent_join_set)| {
913                                ChildFinishedResponse {
914                                    parent_execution_id,
915                                    parent_join_set,
916                                    result: result.clone(),
917                                }
918                            });
919                        (
920                            ExecutionRequest::Finished {
921                                retval: result,
922                                http_client_traces: None,
923                            },
924                            child_finished,
925                            version,
926                        )
927                    }
928                };
929                Some(Append {
930                    created_at: result_obtained_at,
931                    primary_event: AppendRequest {
932                        created_at: result_obtained_at,
933                        event: primary_event,
934                    },
935                    execution_id,
936                    version,
937                    child_finished,
938                })
939            }
940        })
941    }
942}
943
944#[derive(Debug, Clone)]
945pub(crate) struct ChildFinishedResponse {
946    pub(crate) parent_execution_id: ExecutionId,
947    pub(crate) parent_join_set: JoinSetId,
948    pub(crate) result: SupportedFunctionReturnValue,
949}
950
951#[derive(Debug, Clone)]
952pub(crate) struct Append {
953    pub(crate) created_at: DateTime<Utc>,
954    pub(crate) primary_event: AppendRequest,
955    pub(crate) execution_id: ExecutionId,
956    pub(crate) version: Version,
957    pub(crate) child_finished: Option<ChildFinishedResponse>,
958}
959
960impl Append {
961    pub(crate) async fn append(self, db_exec: &dyn DbExecutor) -> Result<(), DbErrorWrite> {
962        if let Some(child_finished) = self.child_finished {
963            assert_matches!(
964                &self.primary_event,
965                AppendRequest {
966                    event: ExecutionRequest::Finished { .. },
967                    ..
968                }
969            );
970            let child_execution_id = assert_matches!(self.execution_id.clone(), ExecutionId::Derived(derived) => derived);
971            let events = AppendEventsToExecution {
972                execution_id: self.execution_id,
973                version: self.version.clone(),
974                batch: vec![self.primary_event],
975            };
976            let response = AppendResponseToExecution {
977                parent_execution_id: child_finished.parent_execution_id,
978                created_at: self.created_at,
979                join_set_id: child_finished.parent_join_set,
980                child_execution_id,
981                finished_version: self.version, // Since self.primary_event is a finished event, the version will remain the same.
982                result: child_finished.result,
983            };
984
985            db_exec
986                .append_batch_respond_to_parent(events, response, self.created_at)
987                .await?;
988        } else {
989            let res = db_exec
990                .append(self.execution_id, self.version, self.primary_event)
991                .await;
992            match res {
993                Ok(_)
994                | Err(DbErrorWrite::NonRetriable(
995                    DbErrorWriteNonRetriable::UnlockedCannotBeAppended(_),
996                )) => {
997                    // Ignore unsuccessful `Unlocked`
998                }
999                Err(err) => return Err(err),
1000            }
1001        }
1002        Ok(())
1003    }
1004}
1005
1006fn log_err_if_new<T>(
1007    res: Result<T, DbErrorGeneric>,
1008    old_err: &mut Option<DbErrorGeneric>,
1009) -> Result<T, ()> {
1010    match (res, &old_err) {
1011        (Ok(ok), _) => {
1012            *old_err = None;
1013            Ok(ok)
1014        }
1015        (Err(err), Some(old)) if err == *old => Err(()),
1016        (Err(err), _) => {
1017            warn!("Tick failed: {err:?}");
1018            *old_err = Some(err);
1019            Err(())
1020        }
1021    }
1022}
1023
1024#[cfg(any(test, feature = "test"))]
1025pub mod simple_worker {
1026    use crate::worker::{Worker, WorkerContext, WorkerResult};
1027    use async_trait::async_trait;
1028    use concepts::{
1029        FunctionFqn, FunctionMetadata, ParameterTypes, RETURN_TYPE_DUMMY,
1030        storage::{HistoryEvent, Version},
1031    };
1032    use indexmap::IndexMap;
1033    use std::sync::Arc;
1034    use tracing::trace;
1035
1036    pub(crate) const FFQN_SOME: FunctionFqn = FunctionFqn::new_static("ns:pkg/ifc", "fn");
1037    pub type SimpleWorkerResultMap =
1038        Arc<std::sync::Mutex<IndexMap<Version, (Vec<HistoryEvent>, WorkerResult)>>>;
1039
1040    #[derive(Clone, Debug)]
1041    pub struct SimpleWorker {
1042        pub worker_results_rev: SimpleWorkerResultMap,
1043        pub ffqn: FunctionFqn,
1044        exported: [FunctionMetadata; 1],
1045    }
1046
1047    impl SimpleWorker {
1048        #[must_use]
1049        pub fn with_single_result(res: WorkerResult) -> Self {
1050            Self::with_worker_results_rev(Arc::new(std::sync::Mutex::new(IndexMap::from([(
1051                Version::new(2),
1052                (vec![], res),
1053            )]))))
1054        }
1055
1056        #[must_use]
1057        pub fn with_ffqn(self, ffqn: FunctionFqn) -> Self {
1058            Self {
1059                worker_results_rev: self.worker_results_rev,
1060                exported: [FunctionMetadata {
1061                    ffqn: ffqn.clone(),
1062                    parameter_types: ParameterTypes::default(),
1063                    return_type: RETURN_TYPE_DUMMY,
1064                    extension: None,
1065                    submittable: true,
1066                }],
1067                ffqn,
1068            }
1069        }
1070
1071        #[must_use]
1072        pub fn with_worker_results_rev(worker_results_rev: SimpleWorkerResultMap) -> Self {
1073            Self {
1074                worker_results_rev,
1075                ffqn: FFQN_SOME,
1076                exported: [FunctionMetadata {
1077                    ffqn: FFQN_SOME,
1078                    parameter_types: ParameterTypes::default(),
1079                    return_type: RETURN_TYPE_DUMMY,
1080                    extension: None,
1081                    submittable: true,
1082                }],
1083            }
1084        }
1085    }
1086
1087    #[async_trait]
1088    impl Worker for SimpleWorker {
1089        async fn run(&self, ctx: WorkerContext) -> WorkerResult {
1090            let (expected_version, (expected_eh, worker_result)) =
1091                self.worker_results_rev.lock().unwrap().pop().unwrap();
1092            trace!(%expected_version, version = %ctx.version, ?expected_eh, eh = ?ctx.event_history, "Running SimpleWorker");
1093            assert_eq!(expected_version, ctx.version);
1094            assert_eq!(
1095                expected_eh,
1096                ctx.event_history
1097                    .iter()
1098                    .map(|(event, _version)| event.clone())
1099                    .collect::<Vec<_>>()
1100            );
1101            worker_result
1102        }
1103
1104        fn exported_functions_noext(&self) -> &[FunctionMetadata] {
1105            &self.exported
1106        }
1107    }
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112    use self::simple_worker::SimpleWorker;
1113    use super::*;
1114    use crate::{expired_timers_watcher, worker::WorkerResult};
1115    use assert_matches::assert_matches;
1116    use async_trait::async_trait;
1117    use concepts::prefixed_ulid::DEPLOYMENT_ID_DUMMY;
1118    use concepts::storage::{
1119        CreateRequest, DbConnectionTest, JoinSetRequest, JoinSetResponse, JoinSetResponseEvent,
1120    };
1121    use concepts::storage::{DbPoolCloseable, LockedBy};
1122    use concepts::storage::{
1123        ExecutionEvent, ExecutionRequest, HistoryEvent, PendingState, PendingStatePendingAt,
1124    };
1125    use concepts::time::{ConstClock, Now};
1126    use concepts::{
1127        FunctionMetadata, JoinSetKind, ParameterTypes, Params, RETURN_TYPE_DUMMY,
1128        SUPPORTED_RETURN_VALUE_OK_EMPTY, StrVariant, SupportedFunctionReturnValue, TrapKind,
1129    };
1130    use db_tests::Database;
1131    use indexmap::IndexMap;
1132    use rstest::rstest;
1133    use simple_worker::FFQN_SOME;
1134    use std::{fmt::Debug, future::Future, ops::Deref, sync::Arc};
1135    use test_db_macro::expand_enum_database;
1136    use test_utils::set_up;
1137    use test_utils::sim_clock::SimClock;
1138
1139    pub(crate) const FFQN_CHILD: FunctionFqn = FunctionFqn::new_static("ns:pkg/ifc", "fn-child");
1140
1141    async fn tick_fn<W: Worker + Debug>(
1142        config: ExecConfig,
1143        clock_fn: Box<dyn ClockFn>,
1144        db_pool: Arc<dyn DbPool>,
1145        worker: Arc<W>,
1146        executed_at: DateTime<Utc>,
1147    ) -> Vec<ExecutionId> {
1148        trace!("Ticking with {worker:?}");
1149        let ffqns = super::extract_exported_ffqns_noext(worker.as_ref());
1150        let (executor, _close_tx) = ExecTask::new_test(config, worker, clock_fn, db_pool, ffqns);
1151        executor
1152            .tick_test_await(executed_at, RunId::generate())
1153            .await
1154    }
1155
1156    #[expand_enum_database]
1157    #[rstest]
1158    #[tokio::test]
1159    async fn execute_simple_lifecycle_tick_based(
1160        database: Database,
1161        #[values(LockingStrategy::ByFfqns, LockingStrategy::ByComponentDigest)]
1162        locking_strategy: LockingStrategy,
1163    ) {
1164        set_up();
1165        let created_at = Now.now();
1166        let (_guard, db_pool, db_close) = database.set_up().await;
1167        let db_connection = db_pool.connection_test().await.unwrap();
1168        execute_simple_lifecycle_tick_based_inner(
1169            db_connection.as_ref(),
1170            db_pool.clone(),
1171            Box::new(ConstClock(created_at)),
1172            locking_strategy,
1173        )
1174        .await;
1175        drop(db_connection);
1176        db_close.close().await;
1177    }
1178
1179    async fn execute_simple_lifecycle_tick_based_inner(
1180        db_connection: &dyn DbConnectionTest,
1181        db_pool: Arc<dyn DbPool>,
1182        clock_fn: Box<dyn ClockFn>,
1183        locking_strategy: LockingStrategy,
1184    ) {
1185        let created_at = clock_fn.now();
1186        let exec_config = ExecConfig {
1187            batch_size: 1,
1188            lock_expiry: Duration::from_secs(1),
1189            tick_sleep: Duration::from_millis(100),
1190            component_id: ComponentId::dummy_activity(),
1191            task_limiter_global: None,
1192            task_limiter_local: None,
1193            executor_id: ExecutorId::generate(),
1194            retry_config: ComponentRetryConfig::ZERO,
1195            locking_strategy,
1196        };
1197
1198        let execution_log = create_and_tick(
1199            CreateAndTickConfig {
1200                execution_id: ExecutionId::generate(),
1201                created_at,
1202                executed_at: created_at,
1203            },
1204            clock_fn,
1205            db_connection,
1206            db_pool,
1207            exec_config,
1208            Arc::new(SimpleWorker::with_single_result(WorkerResult::Ok(
1209                WorkerResultOk::RunFinished(RunFinished {
1210                    retval: SUPPORTED_RETURN_VALUE_OK_EMPTY,
1211                    version: Version::new(2),
1212                    http_client_traces: None,
1213                }),
1214            ))),
1215            tick_fn,
1216        )
1217        .await;
1218        assert_matches!(
1219            execution_log.events.get(2).unwrap(),
1220            ExecutionEvent {
1221                event: ExecutionRequest::Finished {
1222                    retval: SupportedFunctionReturnValue::Ok(None),
1223                    http_client_traces: None
1224                },
1225                created_at: _,
1226                backtrace_id: None,
1227                version: Version(2),
1228            }
1229        );
1230    }
1231
1232    #[rstest]
1233    #[tokio::test]
1234    async fn execute_simple_lifecycle_task_based_mem(
1235        #[values(LockingStrategy::ByFfqns, LockingStrategy::ByComponentDigest)]
1236        locking_strategy: LockingStrategy,
1237    ) {
1238        set_up();
1239        let created_at = Now.now();
1240        let clock_fn = Box::new(ConstClock(created_at));
1241        let (_guard, db_pool, db_close) = Database::Sqlite.set_up().await;
1242        let exec_config = ExecConfig {
1243            batch_size: 1,
1244            lock_expiry: Duration::from_secs(1),
1245            tick_sleep: Duration::ZERO,
1246            component_id: ComponentId::dummy_activity(),
1247            task_limiter_global: None,
1248            task_limiter_local: None,
1249            executor_id: ExecutorId::generate(),
1250            retry_config: ComponentRetryConfig::ZERO,
1251            locking_strategy,
1252        };
1253
1254        let worker = Arc::new(SimpleWorker::with_single_result(WorkerResult::Ok(
1255            WorkerResultOk::RunFinished(RunFinished {
1256                retval: SUPPORTED_RETURN_VALUE_OK_EMPTY,
1257                version: Version::new(2),
1258                http_client_traces: None,
1259            }),
1260        )));
1261        let db_connection = db_pool.connection_test().await.unwrap();
1262
1263        let execution_log = create_and_tick(
1264            CreateAndTickConfig {
1265                execution_id: ExecutionId::generate(),
1266                created_at,
1267                executed_at: created_at,
1268            },
1269            clock_fn,
1270            db_connection.as_ref(),
1271            db_pool,
1272            exec_config,
1273            worker,
1274            tick_fn,
1275        )
1276        .await;
1277        assert_matches!(
1278            execution_log.events.get(2).unwrap(),
1279            ExecutionEvent {
1280                event: ExecutionRequest::Finished {
1281                    retval: SupportedFunctionReturnValue::Ok(None),
1282                    http_client_traces: None
1283                },
1284                created_at: _,
1285                backtrace_id: None,
1286                version: Version(2),
1287            }
1288        );
1289        db_close.close().await;
1290    }
1291
1292    struct CreateAndTickConfig {
1293        execution_id: ExecutionId,
1294        created_at: DateTime<Utc>,
1295        executed_at: DateTime<Utc>,
1296    }
1297
1298    async fn create_and_tick<
1299        W: Worker,
1300        T: FnMut(ExecConfig, Box<dyn ClockFn>, Arc<dyn DbPool>, Arc<W>, DateTime<Utc>) -> F,
1301        F: Future<Output = Vec<ExecutionId>>,
1302    >(
1303        config: CreateAndTickConfig,
1304        clock_fn: Box<dyn ClockFn>,
1305        db_connection: &dyn DbConnectionTest,
1306        db_pool: Arc<dyn DbPool>,
1307        exec_config: ExecConfig,
1308        worker: Arc<W>,
1309        mut tick: T,
1310    ) -> ExecutionLog {
1311        // Create an execution
1312        db_connection
1313            .create(CreateRequest {
1314                created_at: config.created_at,
1315                execution_id: config.execution_id.clone(),
1316                ffqn: FFQN_SOME,
1317                params: Params::empty(),
1318                parent: None,
1319                metadata: concepts::ExecutionMetadata::empty(),
1320                scheduled_at: config.created_at,
1321                component_id: ComponentId::dummy_activity(),
1322                deployment_id: DEPLOYMENT_ID_DUMMY,
1323                scheduled_by: None,
1324                paused: false,
1325            })
1326            .await
1327            .unwrap();
1328        // execute!
1329        tick(exec_config, clock_fn, db_pool, worker, config.executed_at).await;
1330        let execution_log = db_connection.get(&config.execution_id).await.unwrap();
1331        debug!("Execution history after tick: {execution_log:?}");
1332        // check that DB contains Created and Locked events.
1333        let actually_created_at = assert_matches!(
1334            execution_log.events.first().unwrap(),
1335            ExecutionEvent {
1336                event: ExecutionRequest::Created { .. },
1337                created_at: actually_created_at,
1338                backtrace_id: None,
1339                version: Version(0),
1340            }
1341            => *actually_created_at
1342        );
1343        assert_eq!(config.created_at, actually_created_at);
1344        let locked_at = assert_matches!(
1345            execution_log.events.get(1).unwrap(),
1346            ExecutionEvent {
1347                event: ExecutionRequest::Locked { .. },
1348                created_at: locked_at,
1349                backtrace_id: None,
1350                version: Version(1),
1351            } if config.created_at <= *locked_at
1352            => *locked_at
1353        );
1354        assert_matches!(execution_log.events.get(2).unwrap(), ExecutionEvent {
1355            event: _,
1356            created_at: executed_at,
1357            backtrace_id: None,
1358            version: Version(2),
1359        } if *executed_at >= locked_at);
1360        execution_log
1361    }
1362
1363    #[rstest]
1364    #[tokio::test]
1365    async fn activity_trap_should_trigger_an_execution_retry(
1366        #[values(LockingStrategy::ByFfqns, LockingStrategy::ByComponentDigest)]
1367        locking_strategy: LockingStrategy,
1368    ) {
1369        set_up();
1370        let sim_clock = SimClock::default();
1371        let (_guard, db_pool, db_close) = Database::Sqlite.set_up().await;
1372        let retry_exp_backoff = Duration::from_millis(100);
1373        let retry_config = ComponentRetryConfig {
1374            max_retries: Some(1),
1375            retry_exp_backoff,
1376        };
1377        let exec_config = ExecConfig {
1378            batch_size: 1,
1379            lock_expiry: Duration::from_secs(1),
1380            tick_sleep: Duration::ZERO,
1381            component_id: ComponentId::dummy_activity(),
1382            task_limiter_global: None,
1383            task_limiter_local: None,
1384            executor_id: ExecutorId::generate(),
1385            retry_config,
1386            locking_strategy,
1387        };
1388        let expected_reason = "error reason";
1389        let expected_detail = "error detail";
1390        let worker = Arc::new(SimpleWorker::with_single_result(WorkerResult::Err(
1391            WorkerError::ActivityTrap {
1392                reason: expected_reason.to_string(),
1393                trap_kind: concepts::TrapKind::Trap,
1394                detail: Some(expected_detail.to_string()),
1395                version: Version::new(2),
1396                http_client_traces: None,
1397            },
1398        )));
1399        debug!(now = %sim_clock.now(), "Creating an execution that should fail");
1400        let db_connection = db_pool.connection_test().await.unwrap();
1401        let execution_log = create_and_tick(
1402            CreateAndTickConfig {
1403                execution_id: ExecutionId::generate(),
1404                created_at: sim_clock.now(),
1405                executed_at: sim_clock.now(),
1406            },
1407            sim_clock.clone_box(),
1408            db_connection.as_ref(),
1409            db_pool.clone(),
1410            exec_config.clone(),
1411            worker,
1412            tick_fn,
1413        )
1414        .await;
1415        assert_eq!(3, execution_log.events.len());
1416        {
1417            let (reason, detail, at, expires_at) = assert_matches!(
1418                &execution_log.events.get(2).unwrap(),
1419                ExecutionEvent {
1420                    event: ExecutionRequest::TemporarilyFailed {
1421                        reason,
1422                        detail,
1423                        backoff_expires_at,
1424                        http_client_traces: None,
1425                    },
1426                    created_at: at,
1427                    backtrace_id: None,
1428                    version: Version(2),
1429                }
1430                => (reason, detail, *at, *backoff_expires_at)
1431            );
1432            assert_eq!(format!("activity trap: {expected_reason}"), reason.deref());
1433            assert_eq!(Some(expected_detail), detail.as_deref());
1434            assert_eq!(at, sim_clock.now());
1435            assert_eq!(sim_clock.now() + retry_config.retry_exp_backoff, expires_at);
1436        }
1437        let worker = Arc::new(SimpleWorker::with_worker_results_rev(Arc::new(
1438            std::sync::Mutex::new(IndexMap::from([(
1439                Version::new(4),
1440                (
1441                    vec![],
1442                    WorkerResult::Ok(WorkerResultOk::RunFinished(RunFinished {
1443                        retval: SUPPORTED_RETURN_VALUE_OK_EMPTY,
1444                        version: Version::new(4),
1445                        http_client_traces: None,
1446                    })),
1447                ),
1448            )])),
1449        )));
1450        // noop until `retry_exp_backoff` expires
1451        assert!(
1452            tick_fn(
1453                exec_config.clone(),
1454                sim_clock.clone_box(),
1455                db_pool.clone(),
1456                worker.clone(),
1457                sim_clock.now(),
1458            )
1459            .await
1460            .is_empty()
1461        );
1462        // tick again to finish the execution
1463        sim_clock.move_time_forward(retry_config.retry_exp_backoff);
1464        tick_fn(
1465            exec_config,
1466            sim_clock.clone_box(),
1467            db_pool.clone(),
1468            worker,
1469            sim_clock.now(),
1470        )
1471        .await;
1472        let execution_log = {
1473            let db_connection = db_pool.connection_test().await.unwrap();
1474            db_connection
1475                .get(&execution_log.execution_id)
1476                .await
1477                .unwrap()
1478        };
1479        debug!(now = %sim_clock.now(), "Execution history after second tick: {execution_log:?}");
1480        assert_matches!(
1481            execution_log.events.get(3).unwrap(),
1482            ExecutionEvent {
1483                event: ExecutionRequest::Locked { .. },
1484                created_at: at,
1485                backtrace_id: None,
1486                version: Version(3),
1487            } if *at == sim_clock.now()
1488        );
1489        assert_matches!(
1490            execution_log.events.get(4).unwrap(),
1491            ExecutionEvent {
1492                event: ExecutionRequest::Finished {
1493                    retval: SupportedFunctionReturnValue::Ok(None),
1494                    http_client_traces: None
1495                },
1496                created_at: finished_at,
1497                backtrace_id: None,
1498                version: Version(4),
1499            } if *finished_at == sim_clock.now()
1500        );
1501        db_close.close().await;
1502    }
1503
1504    #[rstest]
1505    #[tokio::test]
1506    async fn activity_trap_should_not_be_retried_if_no_retries_are_set(
1507        #[values(LockingStrategy::ByFfqns, LockingStrategy::ByComponentDigest)]
1508        locking_strategy: LockingStrategy,
1509    ) {
1510        set_up();
1511        let created_at = Now.now();
1512        let clock_fn = Box::new(ConstClock(created_at));
1513        let (_guard, db_pool, db_close) = Database::Sqlite.set_up().await;
1514        let exec_config = ExecConfig {
1515            batch_size: 1,
1516            lock_expiry: Duration::from_secs(1),
1517            tick_sleep: Duration::ZERO,
1518            component_id: ComponentId::dummy_activity(),
1519            task_limiter_global: None,
1520            task_limiter_local: None,
1521            executor_id: ExecutorId::generate(),
1522            retry_config: ComponentRetryConfig::ZERO,
1523            locking_strategy,
1524        };
1525
1526        let reason = "error reason";
1527        let expected_reason = format!("activity trap: {reason}");
1528        let expected_detail = "error detail";
1529        let worker = Arc::new(SimpleWorker::with_single_result(WorkerResult::Err(
1530            WorkerError::ActivityTrap {
1531                reason: reason.to_string(),
1532                trap_kind: concepts::TrapKind::Trap,
1533                detail: Some(expected_detail.to_string()),
1534                version: Version::new(2),
1535                http_client_traces: None,
1536            },
1537        )));
1538        let execution_log = create_and_tick(
1539            CreateAndTickConfig {
1540                execution_id: ExecutionId::generate(),
1541                created_at,
1542                executed_at: created_at,
1543            },
1544            clock_fn,
1545            db_pool.connection_test().await.unwrap().as_ref(),
1546            db_pool.clone(),
1547            exec_config.clone(),
1548            worker,
1549            tick_fn,
1550        )
1551        .await;
1552        assert_eq!(3, execution_log.events.len());
1553        let (reason, kind, detail) = assert_matches!(
1554            &execution_log.events.get(2).unwrap(),
1555            ExecutionEvent {
1556                event: ExecutionRequest::Finished{
1557                    retval: SupportedFunctionReturnValue::ExecutionFailure(FinishedExecutionFailure{reason, kind, detail}),
1558                    http_client_traces: None
1559                },
1560                created_at: at,
1561                backtrace_id: None,
1562                version: Version(2),
1563            } if *at == created_at
1564            => (reason, kind, detail)
1565        );
1566
1567        assert_eq!(Some(expected_reason), *reason);
1568        assert_eq!(Some(expected_detail), detail.as_deref());
1569        assert_eq!(ExecutionFailureKind::Uncategorized, *kind);
1570
1571        db_close.close().await;
1572    }
1573
1574    #[rstest]
1575    #[tokio::test]
1576    async fn child_execution_permanently_failed_should_notify_parent_permanent_failure(
1577        #[values(LockingStrategy::ByFfqns, LockingStrategy::ByComponentDigest)]
1578        locking_strategy: LockingStrategy,
1579    ) {
1580        let worker_error = WorkerError::ActivityTrap {
1581            reason: "error reason".to_string(),
1582            trap_kind: TrapKind::Trap,
1583            detail: Some("detail".to_string()),
1584            version: Version::new(2),
1585            http_client_traces: None,
1586        };
1587        let expected_child_err = FinishedExecutionFailure {
1588            kind: ExecutionFailureKind::Uncategorized,
1589            reason: Some("activity trap: error reason".to_string()),
1590            detail: Some("detail".to_string()),
1591        };
1592        child_execution_permanently_failed_should_notify_parent(
1593            WorkerResult::Err(worker_error),
1594            expected_child_err,
1595            locking_strategy,
1596        )
1597        .await;
1598    }
1599
1600    #[rstest]
1601    #[tokio::test]
1602    async fn child_execution_permanently_failed_handled_by_watcher_should_notify_parent_timeout(
1603        #[values(LockingStrategy::ByFfqns, LockingStrategy::ByComponentDigest)]
1604        locking_strategy: LockingStrategy,
1605    ) {
1606        let expected_child_err = FinishedExecutionFailure {
1607            kind: ExecutionFailureKind::TimedOut,
1608            reason: None,
1609            detail: None,
1610        };
1611        child_execution_permanently_failed_should_notify_parent(
1612            WorkerResult::Ok(WorkerResultOk::DbUpdatedByWorkerOrWatcher),
1613            expected_child_err,
1614            locking_strategy,
1615        )
1616        .await;
1617    }
1618
1619    async fn child_execution_permanently_failed_should_notify_parent(
1620        worker_result: WorkerResult,
1621        expected_child_err: FinishedExecutionFailure,
1622        locking_strategy: LockingStrategy,
1623    ) {
1624        use concepts::storage::JoinSetResponseEventOuter;
1625        const LOCK_EXPIRY: Duration = Duration::from_secs(1);
1626
1627        set_up();
1628        let sim_clock = SimClock::default();
1629        let (_guard, db_pool, db_close) = Database::Sqlite.set_up().await;
1630
1631        let parent_worker = Arc::new(SimpleWorker::with_single_result(WorkerResult::Ok(
1632            WorkerResultOk::DbUpdatedByWorkerOrWatcher,
1633        )));
1634        let parent_execution_id = ExecutionId::generate();
1635        db_pool
1636            .connection()
1637            .await
1638            .unwrap()
1639            .create(CreateRequest {
1640                created_at: sim_clock.now(),
1641                execution_id: parent_execution_id.clone(),
1642                ffqn: FFQN_SOME,
1643                params: Params::empty(),
1644                parent: None,
1645                metadata: concepts::ExecutionMetadata::empty(),
1646                scheduled_at: sim_clock.now(),
1647                component_id: ComponentId::dummy_activity(),
1648                deployment_id: DEPLOYMENT_ID_DUMMY,
1649                scheduled_by: None,
1650                paused: false,
1651            })
1652            .await
1653            .unwrap();
1654        let parent_executor_id = ExecutorId::generate();
1655        tick_fn(
1656            ExecConfig {
1657                batch_size: 1,
1658                lock_expiry: LOCK_EXPIRY,
1659                tick_sleep: Duration::ZERO,
1660                component_id: ComponentId::dummy_activity(),
1661                task_limiter_global: None,
1662                task_limiter_local: None,
1663                executor_id: parent_executor_id,
1664                retry_config: ComponentRetryConfig::ZERO,
1665                locking_strategy,
1666            },
1667            sim_clock.clone_box(),
1668            db_pool.clone(),
1669            parent_worker,
1670            sim_clock.now(),
1671        )
1672        .await;
1673
1674        let join_set_id = JoinSetId::new(JoinSetKind::OneOff, StrVariant::empty()).unwrap();
1675        let child_execution_id = parent_execution_id.next_level(&join_set_id);
1676        // executor does not append anything, this should have been written by the worker:
1677        {
1678            let params = Params::empty();
1679            let child = CreateRequest {
1680                created_at: sim_clock.now(),
1681                execution_id: ExecutionId::Derived(child_execution_id.clone()),
1682                ffqn: FFQN_CHILD,
1683                params: params.clone(),
1684                parent: Some((parent_execution_id.clone(), join_set_id.clone())),
1685                metadata: concepts::ExecutionMetadata::empty(),
1686                scheduled_at: sim_clock.now(),
1687                component_id: ComponentId::dummy_activity(),
1688                deployment_id: DEPLOYMENT_ID_DUMMY,
1689                scheduled_by: None,
1690                paused: false,
1691            };
1692            let current_time = sim_clock.now();
1693            let join_set = AppendRequest {
1694                created_at: current_time,
1695                event: ExecutionRequest::HistoryEvent {
1696                    event: HistoryEvent::JoinSetCreate {
1697                        join_set_id: join_set_id.clone(),
1698                    },
1699                },
1700            };
1701            let child_exec_req = AppendRequest {
1702                created_at: current_time,
1703                event: ExecutionRequest::HistoryEvent {
1704                    event: HistoryEvent::JoinSetRequest {
1705                        join_set_id: join_set_id.clone(),
1706                        request: JoinSetRequest::ChildExecutionRequest {
1707                            child_execution_id: child_execution_id.clone(),
1708                            target_ffqn: FFQN_CHILD,
1709                            params,
1710                            result: Ok(()),
1711                        },
1712                    },
1713                },
1714            };
1715            let join_next = AppendRequest {
1716                created_at: current_time,
1717                event: ExecutionRequest::HistoryEvent {
1718                    event: HistoryEvent::JoinNext {
1719                        join_set_id: join_set_id.clone(),
1720                        run_expires_at: sim_clock.now(),
1721                        closing: false,
1722                        requested_ffqn: Some(FFQN_CHILD),
1723                    },
1724                },
1725            };
1726            db_pool
1727                .connection()
1728                .await
1729                .unwrap()
1730                .append_batch_create_new_execution(
1731                    current_time,
1732                    vec![join_set, child_exec_req, join_next],
1733                    parent_execution_id.clone(),
1734                    Version::new(2),
1735                    vec![child],
1736                    vec![],
1737                )
1738                .await
1739                .unwrap();
1740        }
1741
1742        let child_worker =
1743            Arc::new(SimpleWorker::with_single_result(worker_result).with_ffqn(FFQN_CHILD));
1744
1745        // execute the child
1746        tick_fn(
1747            ExecConfig {
1748                batch_size: 1,
1749                lock_expiry: LOCK_EXPIRY,
1750                tick_sleep: Duration::ZERO,
1751                component_id: ComponentId::dummy_activity(),
1752                task_limiter_global: None,
1753                task_limiter_local: None,
1754                executor_id: ExecutorId::generate(),
1755                retry_config: ComponentRetryConfig::ZERO,
1756                locking_strategy,
1757            },
1758            sim_clock.clone_box(),
1759            db_pool.clone(),
1760            child_worker,
1761            sim_clock.now(),
1762        )
1763        .await;
1764        if matches!(expected_child_err.kind, ExecutionFailureKind::TimedOut) {
1765            // In case of timeout, let the timers watcher handle it
1766            sim_clock.move_time_forward(LOCK_EXPIRY);
1767            expired_timers_watcher::tick(
1768                db_pool.connection().await.unwrap().as_ref(),
1769                sim_clock.now(),
1770            )
1771            .await
1772            .unwrap();
1773        }
1774        let child_log = db_pool
1775            .connection_test()
1776            .await
1777            .unwrap()
1778            .get(&ExecutionId::Derived(child_execution_id.clone()))
1779            .await
1780            .unwrap();
1781        assert!(child_log.pending_state.is_finished());
1782        assert_eq!(
1783            Version(2),
1784            child_log.next_version,
1785            "created = 0, locked = 1, with_single_result = 2"
1786        );
1787        assert_eq!(
1788            ExecutionRequest::Finished {
1789                retval: SupportedFunctionReturnValue::ExecutionFailure(expected_child_err),
1790                http_client_traces: None
1791            },
1792            child_log.last_event().event
1793        );
1794        let parent_log = db_pool
1795            .connection_test()
1796            .await
1797            .unwrap()
1798            .get(&parent_execution_id)
1799            .await
1800            .unwrap();
1801        assert_matches!(
1802            parent_log.pending_state,
1803            PendingState::PendingAt(PendingStatePendingAt {
1804                scheduled_at,
1805                last_lock: Some(LockedBy { executor_id: found_executor_id, run_id: _}),
1806            }) if scheduled_at == sim_clock.now() && found_executor_id == parent_executor_id,
1807            "parent should be back to pending"
1808        );
1809        let (found_join_set_id, found_child_execution_id, child_finished_version, found_result) = assert_matches!(
1810            parent_log.responses.last().map(|resp| &resp.event),
1811            Some(JoinSetResponseEventOuter{
1812                created_at: at,
1813                event: JoinSetResponseEvent{
1814                    join_set_id: found_join_set_id,
1815                    event: JoinSetResponse::ChildExecutionFinished {
1816                        child_execution_id: found_child_execution_id,
1817                        finished_version,
1818                        result: found_result,
1819                    }
1820                }
1821            })
1822             if *at == sim_clock.now()
1823            => (found_join_set_id, found_child_execution_id, finished_version, found_result)
1824        );
1825        assert_eq!(join_set_id, *found_join_set_id);
1826        assert_eq!(child_execution_id, *found_child_execution_id);
1827        assert_eq!(child_log.next_version, *child_finished_version);
1828        assert_matches!(
1829            found_result,
1830            SupportedFunctionReturnValue::ExecutionFailure(_)
1831        );
1832
1833        db_close.close().await;
1834    }
1835
1836    #[derive(Clone, Debug)]
1837    struct SleepyWorker {
1838        duration: Duration,
1839        result: SupportedFunctionReturnValue,
1840        exported: [FunctionMetadata; 1],
1841    }
1842
1843    #[async_trait]
1844    impl Worker for SleepyWorker {
1845        async fn run(&self, ctx: WorkerContext) -> WorkerResult {
1846            tokio::time::sleep(self.duration).await;
1847            WorkerResult::Ok(WorkerResultOk::RunFinished(RunFinished {
1848                retval: self.result.clone(),
1849                version: ctx.version,
1850                http_client_traces: None,
1851            }))
1852        }
1853
1854        fn exported_functions_noext(&self) -> &[FunctionMetadata] {
1855            &self.exported
1856        }
1857    }
1858
1859    #[rstest]
1860    #[tokio::test]
1861    async fn hanging_lock_should_be_cleaned_and_execution_retried(
1862        #[values(LockingStrategy::ByFfqns, LockingStrategy::ByComponentDigest)]
1863        locking_strategy: LockingStrategy,
1864    ) {
1865        set_up();
1866        let sim_clock = SimClock::default();
1867        let (_guard, db_pool, db_close) = Database::Sqlite.set_up().await;
1868        let lock_expiry = Duration::from_millis(100);
1869        let timeout_duration = Duration::from_millis(300);
1870        let retry_config = ComponentRetryConfig {
1871            max_retries: Some(1),
1872            retry_exp_backoff: timeout_duration,
1873        };
1874        let exec_config = ExecConfig {
1875            batch_size: 1,
1876            lock_expiry,
1877            tick_sleep: Duration::ZERO,
1878            component_id: ComponentId::dummy_activity(),
1879            task_limiter_global: None,
1880            task_limiter_local: None,
1881            executor_id: ExecutorId::generate(),
1882            retry_config,
1883            locking_strategy,
1884        };
1885
1886        let worker = Arc::new(SleepyWorker {
1887            duration: lock_expiry + Duration::from_millis(1), // sleep more than allowed by the lock expiry
1888            result: SUPPORTED_RETURN_VALUE_OK_EMPTY,
1889            exported: [FunctionMetadata {
1890                ffqn: FFQN_SOME,
1891                parameter_types: ParameterTypes::default(),
1892                return_type: RETURN_TYPE_DUMMY,
1893                extension: None,
1894                submittable: true,
1895            }],
1896        });
1897        // Create an execution
1898        let execution_id = ExecutionId::generate();
1899        let db_connection = db_pool.connection_test().await.unwrap();
1900        db_connection
1901            .create(CreateRequest {
1902                created_at: sim_clock.now(),
1903                execution_id: execution_id.clone(),
1904                ffqn: FFQN_SOME,
1905                params: Params::empty(),
1906                parent: None,
1907                metadata: concepts::ExecutionMetadata::empty(),
1908                scheduled_at: sim_clock.now(),
1909                component_id: ComponentId::dummy_activity(),
1910                deployment_id: DEPLOYMENT_ID_DUMMY,
1911                scheduled_by: None,
1912                paused: false,
1913            })
1914            .await
1915            .unwrap();
1916
1917        let ffqns = super::extract_exported_ffqns_noext(worker.as_ref());
1918        let (executor, _close_tx) = ExecTask::new_test(
1919            exec_config.clone(),
1920            worker,
1921            sim_clock.clone_box(),
1922            db_pool.clone(),
1923            ffqns,
1924        );
1925        let db_exec = db_pool.db_exec_conn().await.unwrap();
1926        let mut first_execution_progress = executor
1927            .tick(
1928                db_exec.as_ref(),
1929                sim_clock.now(),
1930                RunId::generate(),
1931                DEPLOYMENT_ID_DUMMY,
1932            )
1933            .await
1934            .unwrap();
1935        assert_eq!(1, first_execution_progress.executions.len());
1936        // Started hanging, wait for lock expiry.
1937        sim_clock.move_time_forward(lock_expiry);
1938        // cleanup should be called
1939        let now_after_first_lock_expiry = sim_clock.now();
1940        {
1941            debug!(now = %now_after_first_lock_expiry, "Expecting an expired lock");
1942            let cleanup_progress = executor
1943                .tick(
1944                    db_pool.db_exec_conn().await.unwrap().as_ref(),
1945                    now_after_first_lock_expiry,
1946                    RunId::generate(),
1947                    DEPLOYMENT_ID_DUMMY,
1948                )
1949                .await
1950                .unwrap();
1951            assert!(cleanup_progress.executions.is_empty());
1952        }
1953        {
1954            let expired_locks = expired_timers_watcher::tick(
1955                db_pool.connection().await.unwrap().as_ref(),
1956                now_after_first_lock_expiry,
1957            )
1958            .await
1959            .unwrap()
1960            .expired_locks;
1961            assert_eq!(1, expired_locks);
1962        }
1963        assert!(
1964            !first_execution_progress
1965                .executions
1966                .pop()
1967                .unwrap()
1968                .1
1969                .is_finished()
1970        );
1971
1972        let execution_log = db_connection.get(&execution_id).await.unwrap();
1973        let expected_first_timeout_expiry = now_after_first_lock_expiry + timeout_duration;
1974        assert_matches!(
1975            &execution_log.events.get(2).unwrap(),
1976            ExecutionEvent {
1977                event: ExecutionRequest::TemporarilyTimedOut { backoff_expires_at, .. },
1978                created_at: at,
1979                backtrace_id: None,
1980                version: Version(2),
1981            } if *at == now_after_first_lock_expiry && *backoff_expires_at == expected_first_timeout_expiry
1982        );
1983        assert_matches!(
1984            execution_log.pending_state,
1985            PendingState::PendingAt(PendingStatePendingAt {
1986                scheduled_at: found_scheduled_by,
1987                last_lock: Some(LockedBy {
1988                    executor_id: found_executor_id,
1989                    run_id: _,
1990                }),
1991            }) if found_scheduled_by == expected_first_timeout_expiry && found_executor_id == exec_config.executor_id
1992        );
1993        sim_clock.move_time_forward(timeout_duration);
1994        let now_after_first_timeout = sim_clock.now();
1995        debug!(now = %now_after_first_timeout, "Second execution should hang again and result in a permanent timeout");
1996
1997        let mut second_execution_progress = executor
1998            .tick(
1999                db_pool.db_exec_conn().await.unwrap().as_ref(),
2000                now_after_first_timeout,
2001                RunId::generate(),
2002                DEPLOYMENT_ID_DUMMY,
2003            )
2004            .await
2005            .unwrap();
2006        assert_eq!(1, second_execution_progress.executions.len());
2007
2008        // Started hanging, wait for lock expiry.
2009        sim_clock.move_time_forward(lock_expiry);
2010        // cleanup should be called
2011        let now_after_second_lock_expiry = sim_clock.now();
2012        debug!(now = %now_after_second_lock_expiry, "Expecting the second lock to be expired");
2013        {
2014            let cleanup_progress = executor
2015                .tick(
2016                    db_pool.db_exec_conn().await.unwrap().as_ref(),
2017                    now_after_second_lock_expiry,
2018                    RunId::generate(),
2019                    DEPLOYMENT_ID_DUMMY,
2020                )
2021                .await
2022                .unwrap();
2023            assert!(cleanup_progress.executions.is_empty());
2024        }
2025        {
2026            let expired_locks = expired_timers_watcher::tick(
2027                db_pool.connection().await.unwrap().as_ref(),
2028                now_after_second_lock_expiry,
2029            )
2030            .await
2031            .unwrap()
2032            .expired_locks;
2033            assert_eq!(1, expired_locks);
2034        }
2035        assert!(
2036            !second_execution_progress
2037                .executions
2038                .pop()
2039                .unwrap()
2040                .1
2041                .is_finished()
2042        );
2043
2044        drop(db_connection);
2045        drop(executor);
2046        db_close.close().await;
2047    }
2048}