88use SolutionForest \WorkflowEngine \Contracts \WorkflowAction ;
99use SolutionForest \WorkflowEngine \Events \StepCompletedEvent ;
1010use SolutionForest \WorkflowEngine \Events \StepFailedEvent ;
11+ use SolutionForest \WorkflowEngine \Events \StepRetriedEvent ;
1112use SolutionForest \WorkflowEngine \Events \WorkflowCompletedEvent ;
1213use SolutionForest \WorkflowEngine \Events \WorkflowFailedEvent ;
1314use SolutionForest \WorkflowEngine \Exceptions \ActionNotFoundException ;
1415use SolutionForest \WorkflowEngine \Exceptions \StepExecutionException ;
1516use SolutionForest \WorkflowEngine \Support \NullEventDispatcher ;
1617use SolutionForest \WorkflowEngine \Support \NullLogger ;
18+ use SolutionForest \WorkflowEngine \Support \Timeout ;
1719
1820/**
1921 * Workflow executor responsible for running workflow steps and managing execution flow.
@@ -150,8 +152,8 @@ public function execute(WorkflowInstance $instance): void
150152 */
151153 private function processWorkflow (WorkflowInstance $ instance ): void
152154 {
153- // If workflow is not running, start it
154- if ($ instance ->getState () === WorkflowState::PENDING ) {
155+ // If workflow is not running, transition it to running
156+ if (in_array ( $ instance ->getState (), [ WorkflowState::PENDING , WorkflowState:: PAUSED , WorkflowState:: WAITING ]) ) {
155157 $ instance ->setState (WorkflowState::RUNNING );
156158 $ this ->stateManager ->save ($ instance );
157159 }
@@ -217,7 +219,7 @@ private function executeStep(WorkflowInstance $instance, Step $step): void
217219
218220 try {
219221 if ($ step ->hasAction ()) {
220- $ this ->executeAction ($ instance , $ step );
222+ $ this ->executeActionWithRetry ($ instance , $ step );
221223 }
222224
223225 // Mark step as completed
@@ -227,7 +229,6 @@ private function executeStep(WorkflowInstance $instance, Step $step): void
227229 $ this ->logger ->info ('Workflow step completed successfully ' , [
228230 'workflow_id ' => $ instance ->getId (),
229231 'step_id ' => $ step ->getId (),
230- 'step_duration ' => 'calculated_in_future_version ' , // TODO: Add timing
231232 ]);
232233
233234 // Continue execution recursively
@@ -268,6 +269,112 @@ private function executeStep(WorkflowInstance $instance, Step $step): void
268269 }
269270 }
270271
272+ /**
273+ * Execute a step's action with retry logic.
274+ *
275+ * @param WorkflowInstance $instance The workflow instance
276+ * @param Step $step The step to execute
277+ *
278+ * @throws ActionNotFoundException If the action class doesn't exist
279+ * @throws StepExecutionException If all retry attempts are exhausted
280+ */
281+ private function executeActionWithRetry (WorkflowInstance $ instance , Step $ step ): void
282+ {
283+ $ maxAttempts = $ step ->getRetryAttempts () + 1 ; // +1 for initial attempt
284+
285+ if ($ maxAttempts <= 1 ) {
286+ // No retries configured, execute directly
287+ $ this ->executeAction ($ instance , $ step );
288+
289+ return ;
290+ }
291+
292+ $ lastException = null ;
293+
294+ for ($ attempt = 1 ; $ attempt <= $ maxAttempts ; $ attempt ++) {
295+ try {
296+ $ this ->executeAction ($ instance , $ step );
297+
298+ return ; // Success — exit retry loop
299+ } catch (\Exception $ e ) {
300+ $ lastException = $ e ;
301+
302+ if ($ attempt === $ maxAttempts ) {
303+ $ this ->logger ->error ('Step failed after all retry attempts ' , [
304+ 'workflow_id ' => $ instance ->getId (),
305+ 'step_id ' => $ step ->getId (),
306+ 'attempts ' => $ attempt ,
307+ 'max_attempts ' => $ maxAttempts ,
308+ 'error ' => $ e ->getMessage (),
309+ ]);
310+
311+ throw $ e ; // Final attempt failed — propagate
312+ }
313+
314+ $ this ->logger ->warning ('Step failed, retrying ' , [
315+ 'workflow_id ' => $ instance ->getId (),
316+ 'step_id ' => $ step ->getId (),
317+ 'attempt ' => $ attempt ,
318+ 'max_attempts ' => $ maxAttempts ,
319+ 'error ' => $ e ->getMessage (),
320+ ]);
321+
322+ $ this ->eventDispatcher ->dispatch (new StepRetriedEvent (
323+ $ instance ,
324+ $ step ,
325+ $ attempt ,
326+ $ maxAttempts ,
327+ $ e
328+ ));
329+
330+ // Exponential backoff: 100ms, 200ms, 400ms... (keep short for a library)
331+ $ backoffMicroseconds = (int ) (100000 * pow (2 , $ attempt - 1 ));
332+ usleep ($ backoffMicroseconds );
333+ }
334+ }
335+ }
336+
337+ /**
338+ * Execute a callback with a timeout constraint.
339+ *
340+ * Uses pcntl_alarm when available, otherwise logs a warning and executes without timeout.
341+ *
342+ * @param callable $callback The callback to execute
343+ * @param int $timeoutSeconds Maximum execution time in seconds
344+ * @return mixed The callback's return value
345+ *
346+ * @throws StepExecutionException If the timeout is exceeded
347+ */
348+ private function executeWithTimeout (callable $ callback , int $ timeoutSeconds ): mixed
349+ {
350+ if (! function_exists ('pcntl_alarm ' ) || ! function_exists ('pcntl_signal ' )) {
351+ $ this ->logger ->warning ('pcntl extension not available, timeout not enforced ' , [
352+ 'timeout_seconds ' => $ timeoutSeconds ,
353+ ]);
354+
355+ return $ callback ();
356+ }
357+
358+ pcntl_signal (SIGALRM , function () use ($ timeoutSeconds ) {
359+ throw new \RuntimeException ("Step execution timed out after {$ timeoutSeconds } seconds " );
360+ });
361+
362+ pcntl_alarm ($ timeoutSeconds );
363+
364+ try {
365+ $ result = $ callback ();
366+ pcntl_alarm (0 );
367+
368+ return $ result ;
369+ } catch (\Exception $ e ) {
370+ pcntl_alarm (0 );
371+
372+ throw $ e ;
373+ } finally {
374+ pcntl_signal (SIGALRM , SIG_DFL );
375+ }
376+ }
377+
271378 /**
272379 * Execute the action associated with a workflow step.
273380 *
@@ -319,7 +426,16 @@ private function executeAction(WorkflowInstance $instance, Step $step): void
319426 instance: $ instance
320427 );
321428
322- $ result = $ action ->execute ($ context );
429+ $ timeoutValue = $ step ->getTimeout ();
430+ if ($ timeoutValue !== null ) {
431+ $ timeoutSeconds = Timeout::toSeconds ($ timeoutValue );
432+ $ result = $ this ->executeWithTimeout (
433+ fn () => $ action ->execute ($ context ),
434+ $ timeoutSeconds
435+ );
436+ } else {
437+ $ result = $ action ->execute ($ context );
438+ }
323439
324440 if ($ result ->isSuccess ()) {
325441 // Merge any output data from the action
0 commit comments