22
33namespace SolutionForest \WorkflowEngine \Core ;
44
5- use Exception ;
65use SolutionForest \WorkflowEngine \Contracts \EventDispatcher ;
76use SolutionForest \WorkflowEngine \Contracts \Logger ;
87use SolutionForest \WorkflowEngine \Contracts \WorkflowAction ;
@@ -121,7 +120,7 @@ public function execute(WorkflowInstance $instance): void
121120 {
122121 try {
123122 $ this ->processWorkflow ($ instance );
124- } catch (Exception $ e ) {
123+ } catch (\ Throwable $ e ) {
125124 $ this ->logger ->error ('Workflow execution failed ' , [
126125 'workflow_id ' => $ instance ->getId (),
127126 'workflow_name ' => $ instance ->getDefinition ()->getName (),
@@ -134,16 +133,18 @@ public function execute(WorkflowInstance $instance): void
134133 $ this ->stateManager ->setError ($ instance , $ e ->getMessage ());
135134 $ this ->eventDispatcher ->dispatch (new WorkflowFailedEvent ($ instance , $ e ));
136135
137- // Re-throw the original exception to maintain the error context
136+ // Re-throw the original throwable to maintain the error context
138137 throw $ e ;
139138 }
140139 }
141140
142141 /**
143- * Process workflow execution by managing state transitions and step execution .
142+ * Process workflow execution by iterating over runnable steps until none remain .
144143 *
145- * This private method handles the core workflow processing logic, including
146- * state management, step scheduling, and completion detection.
144+ * Each iteration asks the instance for its next runnable steps, executes them,
145+ * and loops again. The loop is bounded by the total number of steps in the
146+ * definition (x2 to allow for conditional skips) to defend against
147+ * pathological definitions that would otherwise loop forever.
147148 *
148149 * @param WorkflowInstance $instance The workflow instance to process
149150 *
@@ -158,36 +159,60 @@ private function processWorkflow(WorkflowInstance $instance): void
158159 $ this ->stateManager ->save ($ instance );
159160 }
160161
161- // Get next steps to execute
162- $ nextSteps = $ instance ->getNextSteps ();
162+ $ totalSteps = count ($ instance ->getDefinition ()->getSteps ());
163+ // Upper bound on iterations: each step can be visited at most once as a
164+ // runnable step and once as a skip. The +1 guards the degenerate zero-step
165+ // workflow from tripping the safety check immediately.
166+ $ maxIterations = max (1 , $ totalSteps * 2 + 1 );
167+ $ iterations = 0 ;
168+
169+ while (true ) {
170+ if (++$ iterations > $ maxIterations ) {
171+ throw new \RuntimeException (
172+ "Workflow ' {$ instance ->getId ()}' exceeded maximum execution iterations ( {$ maxIterations }); " .
173+ 'this usually indicates a cycle in the transition graph. '
174+ );
175+ }
163176
164- if (empty ($ nextSteps )) {
165- // Workflow completed successfully
166- $ instance ->setState (WorkflowState::COMPLETED );
167- $ this ->stateManager ->save ($ instance );
168- $ this ->eventDispatcher ->dispatch (new WorkflowCompletedEvent ($ instance ));
177+ $ nextSteps = $ instance ->getNextSteps ();
169178
170- $ this ->logger ->info ('Workflow completed successfully ' , [
171- 'workflow_id ' => $ instance ->getId (),
172- 'workflow_name ' => $ instance ->getDefinition ()->getName (),
173- 'completed_steps ' => count ($ instance ->getCompletedSteps ()),
174- 'execution_time ' => abs ($ instance ->getUpdatedAt ()->getTimestamp () - $ instance ->getCreatedAt ()->getTimestamp ()).'s ' ,
175- ]);
179+ if (empty ($ nextSteps )) {
180+ // Workflow completed successfully
181+ $ instance ->setState (WorkflowState::COMPLETED );
182+ $ this ->stateManager ->save ($ instance );
183+ $ this ->eventDispatcher ->dispatch (new WorkflowCompletedEvent ($ instance ));
176184
177- return ;
178- }
185+ $ this ->logger ->info ('Workflow completed successfully ' , [
186+ 'workflow_id ' => $ instance ->getId (),
187+ 'workflow_name ' => $ instance ->getDefinition ()->getName (),
188+ 'completed_steps ' => count ($ instance ->getCompletedSteps ()),
189+ 'execution_time ' => abs ($ instance ->getUpdatedAt ()->getTimestamp () - $ instance ->getCreatedAt ()->getTimestamp ()).'s ' ,
190+ ]);
179191
180- // Execute each next step
181- foreach ($ nextSteps as $ step ) {
182- if ($ instance ->isStepCompleted ($ step ->getId ())) {
183- continue ; // Skip already completed steps
192+ return ;
184193 }
185194
186- if (! $ instance ->canExecuteStep ($ step ->getId ())) {
187- continue ; // Skip steps that can't be executed yet
195+ $ progressed = false ;
196+
197+ foreach ($ nextSteps as $ step ) {
198+ if ($ instance ->isStepCompleted ($ step ->getId ())) {
199+ continue ; // Skip already completed steps
200+ }
201+
202+ if (! $ instance ->canExecuteStep ($ step ->getId ())) {
203+ continue ; // Skip steps that can't be executed yet
204+ }
205+
206+ $ this ->executeStep ($ instance , $ step );
207+ $ progressed = true ;
188208 }
189209
190- $ this ->executeStep ($ instance , $ step );
210+ // If no steps made progress this iteration, the workflow is stuck
211+ // (e.g. all next steps were blocked on unmet prerequisites). Exit
212+ // the loop and let the next resume() reattempt.
213+ if (! $ progressed ) {
214+ return ;
215+ }
191216 }
192217 }
193218
@@ -206,6 +231,22 @@ private function processWorkflow(WorkflowInstance $instance): void
206231 */
207232 private function executeStep (WorkflowInstance $ instance , Step $ step ): void
208233 {
234+ // Evaluate step conditions. Steps whose conditions don't match the current
235+ // workflow data are skipped (marked completed without running the action)
236+ // so that downstream transitions continue to flow.
237+ if (! $ step ->canExecute ($ instance ->getData ())) {
238+ $ this ->logger ->info ('Skipping workflow step; conditions not met ' , [
239+ 'workflow_id ' => $ instance ->getId (),
240+ 'step_id ' => $ step ->getId (),
241+ 'conditions ' => $ step ->getConditions (),
242+ ]);
243+
244+ $ instance ->setCurrentStepId ($ step ->getId ());
245+ $ this ->stateManager ->markStepCompleted ($ instance , $ step ->getId ());
246+
247+ return ;
248+ }
249+
209250 $ this ->logger ->info ('Executing workflow step ' , [
210251 'workflow_id ' => $ instance ->getId (),
211252 'workflow_name ' => $ instance ->getDefinition ()->getName (),
@@ -230,11 +271,7 @@ private function executeStep(WorkflowInstance $instance, Step $step): void
230271 'workflow_id ' => $ instance ->getId (),
231272 'step_id ' => $ step ->getId (),
232273 ]);
233-
234- // Continue execution recursively
235- $ this ->processWorkflow ($ instance );
236-
237- } catch (Exception $ e ) {
274+ } catch (\Throwable $ e ) {
238275 $ context = new WorkflowContext (
239276 workflowId: $ instance ->getId (),
240277 stepId: $ step ->getId (),
@@ -243,13 +280,11 @@ private function executeStep(WorkflowInstance $instance, Step $step): void
243280 instance: $ instance
244281 );
245282
246- // Create detailed step execution exception
247- $ stepException = match (true ) {
248- $ e instanceof ActionNotFoundException => $ e ,
249- str_contains ($ e ->getMessage (), 'does not exist ' ) => ActionNotFoundException ::classNotFound ($ step ->getActionClass (), $ step , $ context ),
250- str_contains ($ e ->getMessage (), 'must implement ' ) => ActionNotFoundException ::invalidInterface ($ step ->getActionClass (), $ step , $ context ),
251- default => StepExecutionException ::fromException ($ e , $ step , $ context )
252- };
283+ // Wrap non-typed throwables in a StepExecutionException while preserving
284+ // ActionNotFoundException (and other domain exceptions) as-is.
285+ $ stepException = $ e instanceof ActionNotFoundException
286+ ? $ e
287+ : StepExecutionException ::fromException ($ e , $ step , $ context );
253288
254289 $ this ->logger ->error ('Workflow step execution failed ' , [
255290 'workflow_id ' => $ instance ->getId (),
@@ -278,6 +313,13 @@ private function executeStep(WorkflowInstance $instance, Step $step): void
278313 * @throws ActionNotFoundException If the action class doesn't exist
279314 * @throws StepExecutionException If all retry attempts are exhausted
280315 */
316+ /**
317+ * Maximum backoff sleep between retries, in microseconds. Caps the
318+ * exponential growth so a misconfigured step cannot block a worker for
319+ * minutes.
320+ */
321+ private const MAX_BACKOFF_MICROSECONDS = 2_000_000 ; // 2 seconds
322+
281323 private function executeActionWithRetry (WorkflowInstance $ instance , Step $ step ): void
282324 {
283325 $ maxAttempts = $ step ->getRetryAttempts () + 1 ; // +1 for initial attempt
@@ -289,16 +331,12 @@ private function executeActionWithRetry(WorkflowInstance $instance, Step $step):
289331 return ;
290332 }
291333
292- $ lastException = null ;
293-
294334 for ($ attempt = 1 ; $ attempt <= $ maxAttempts ; $ attempt ++) {
295335 try {
296336 $ this ->executeAction ($ instance , $ step );
297337
298338 return ; // Success — exit retry loop
299- } catch (\Exception $ e ) {
300- $ lastException = $ e ;
301-
339+ } catch (\Throwable $ e ) {
302340 if ($ attempt === $ maxAttempts ) {
303341 $ this ->logger ->error ('Step failed after all retry attempts ' , [
304342 'workflow_id ' => $ instance ->getId (),
@@ -311,11 +349,14 @@ private function executeActionWithRetry(WorkflowInstance $instance, Step $step):
311349 throw $ e ; // Final attempt failed — propagate
312350 }
313351
352+ $ backoffMicroseconds = $ this ->calculateBackoff ($ attempt );
353+
314354 $ this ->logger ->warning ('Step failed, retrying ' , [
315355 'workflow_id ' => $ instance ->getId (),
316356 'step_id ' => $ step ->getId (),
317357 'attempt ' => $ attempt ,
318358 'max_attempts ' => $ maxAttempts ,
359+ 'backoff_ms ' => (int ) ($ backoffMicroseconds / 1000 ),
319360 'error ' => $ e ->getMessage (),
320361 ]);
321362
@@ -327,51 +368,73 @@ private function executeActionWithRetry(WorkflowInstance $instance, Step $step):
327368 $ e
328369 ));
329370
330- // Exponential backoff: 100ms, 200ms, 400ms... (keep short for a library)
331- $ backoffMicroseconds = (int ) (100000 * pow (2 , $ attempt - 1 ));
332371 usleep ($ backoffMicroseconds );
333372 }
334373 }
335374 }
336375
376+ /**
377+ * Calculate exponential backoff delay between retry attempts.
378+ *
379+ * Doubles each attempt starting at 100ms, capped at MAX_BACKOFF_MICROSECONDS
380+ * to prevent runaway worker blocking.
381+ *
382+ * @param int $attempt 1-based attempt number
383+ * @return int Delay in microseconds
384+ */
385+ private function calculateBackoff (int $ attempt ): int
386+ {
387+ $ base = 100_000 ; // 100ms
388+ $ delay = (int ) ($ base * (2 ** ($ attempt - 1 )));
389+
390+ return min ($ delay , self ::MAX_BACKOFF_MICROSECONDS );
391+ }
392+
337393 /**
338394 * Execute a callback with a timeout constraint.
339395 *
340- * Uses pcntl_alarm when available, otherwise logs a warning and executes without timeout.
396+ * Uses pcntl_alarm when the pcntl extension is loaded. pcntl is generally
397+ * only available under the CLI SAPI, so for web/FPM contexts this method
398+ * logs a warning and runs the callback unbounded — long-running workflow
399+ * steps should be dispatched via a queue worker instead.
341400 *
342401 * @param callable $callback The callback to execute
343402 * @param int $timeoutSeconds Maximum execution time in seconds
344403 * @return mixed The callback's return value
345404 *
346- * @throws StepExecutionException If the timeout is exceeded
405+ * @throws \RuntimeException If the timeout is exceeded while running under pcntl
347406 */
348407 private function executeWithTimeout (callable $ callback , int $ timeoutSeconds ): mixed
349408 {
350- if (! function_exists ('pcntl_alarm ' ) || ! function_exists ('pcntl_signal ' )) {
409+ if (! function_exists ('pcntl_alarm ' ) || ! function_exists ('pcntl_signal ' ) || ! function_exists ( ' pcntl_async_signals ' ) ) {
351410 $ this ->logger ->warning ('pcntl extension not available, timeout not enforced ' , [
352411 'timeout_seconds ' => $ timeoutSeconds ,
412+ 'hint ' => 'Execute workflows via CLI or queue workers to enforce step timeouts. ' ,
353413 ]);
354414
355415 return $ callback ();
356416 }
357417
358- pcntl_signal (SIGALRM , function () use ($ timeoutSeconds ) {
418+ // Ensure the signal handler runs at the VM tick rather than waiting for
419+ // an explicit pcntl_signal_dispatch() call. Without this, SIGALRM can
420+ // be delivered unpredictably or not at all.
421+ $ previousAsync = pcntl_async_signals (true );
422+ $ previousHandler = pcntl_signal_get_handler (SIGALRM );
423+
424+ pcntl_signal (SIGALRM , function () use ($ timeoutSeconds ): never {
359425 throw new \RuntimeException ("Step execution timed out after {$ timeoutSeconds } seconds " );
360426 });
361427
362428 pcntl_alarm ($ timeoutSeconds );
363429
364430 try {
365- $ result = $ callback ();
366- pcntl_alarm (0 );
367-
368- return $ result ;
369- } catch (\Exception $ e ) {
370- pcntl_alarm (0 );
371-
372- throw $ e ;
431+ return $ callback ();
373432 } finally {
374- pcntl_signal (SIGALRM , SIG_DFL );
433+ // Always clear the alarm and restore the previous signal handler,
434+ // even if the callback threw or the alarm fired.
435+ pcntl_alarm (0 );
436+ pcntl_signal (SIGALRM , $ previousHandler ?: SIG_DFL );
437+ pcntl_async_signals ($ previousAsync );
375438 }
376439 }
377440
0 commit comments