From 4c1271550e87b2280091c9d461d8826f6a2b2302 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Sun, 8 Mar 2026 19:21:42 +0000 Subject: [PATCH 1/5] Child exceptions --- src/ChildWorkflowStub.php | 33 ++++++++ src/WorkflowStub.php | 15 +++- tests/Feature/SagaChildWorkflowTest.php | 38 ++++++++++ .../TestChildExceptionThrowingWorkflow.php | 16 ++++ tests/Fixtures/TestSagaChildWorkflow.php | 33 ++++++++ .../Fixtures/TestSagaSingleChildWorkflow.php | 27 +++++++ tests/Unit/ChildWorkflowStubTest.php | 76 +++++++++++++++++++ tests/Unit/WorkflowStubTest.php | 58 +++++++++++++- 8 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/SagaChildWorkflowTest.php create mode 100644 tests/Fixtures/TestChildExceptionThrowingWorkflow.php create mode 100644 tests/Fixtures/TestSagaChildWorkflow.php create mode 100644 tests/Fixtures/TestSagaSingleChildWorkflow.php diff --git a/src/ChildWorkflowStub.php b/src/ChildWorkflowStub.php index b4eb6ee..149a625 100644 --- a/src/ChildWorkflowStub.php +++ b/src/ChildWorkflowStub.php @@ -8,8 +8,11 @@ use React\Promise\Deferred; use React\Promise\PromiseInterface; use function React\Promise\resolve; +use RuntimeException; +use Throwable; use Workflow\Exceptions\TransitionNotFound; use Workflow\Serializers\Serializer; +use Workflow\States\WorkflowFailedStatus; final class ChildWorkflowStub { @@ -54,6 +57,36 @@ public static function make($workflow, ...$arguments): PromiseInterface ->wherePivot('parent_index', $context->index) ->first(); + if ($storedChildWorkflow && $storedChildWorkflow->status::class === WorkflowFailedStatus::class) { + ++$context->index; + WorkflowStub::setContext($context); + $childException = $storedChildWorkflow->exceptions() + ->latest() + ->first(); + if ($childException) { + $exceptionData = Serializer::unserialize($childException->exception); + if ( + is_array($exceptionData) + && array_key_exists('class', $exceptionData) + && is_subclass_of($exceptionData['class'], Throwable::class) + ) { + try { + throw new $exceptionData['class']( + $exceptionData['message'] ?? '', + (int) ($exceptionData['code'] ?? 0) + ); + } catch (Throwable $throwable) { + throw new RuntimeException( + sprintf('[%s] %s', $exceptionData['class'], (string) ($exceptionData['message'] ?? '')), + (int) ($exceptionData['code'] ?? 0), + $throwable + ); + } + } + } + throw new RuntimeException('Child workflow ' . $workflow . ' failed'); + } + $childWorkflow = $storedChildWorkflow ? $storedChildWorkflow->toWorkflow() : WorkflowStub::make($workflow); $hasOptions = collect($arguments) diff --git a/src/WorkflowStub.php b/src/WorkflowStub.php index a8b401c..494e30f 100644 --- a/src/WorkflowStub.php +++ b/src/WorkflowStub.php @@ -288,9 +288,22 @@ public function fail($exception): void $this->storedWorkflow->parents() ->each(static function ($parentWorkflow) use ($exception) { + if ( + $parentWorkflow->pivot->parent_index === StoredWorkflow::CONTINUE_PARENT_INDEX + || $parentWorkflow->pivot->parent_index === StoredWorkflow::ACTIVE_WORKFLOW_INDEX + ) { + try { + $parentWorkflow->toWorkflow() + ->fail($exception); + } catch (TransitionNotFound) { + return; + } + return; + } + try { $parentWorkflow->toWorkflow() - ->fail($exception); + ->resume(); } catch (TransitionNotFound) { return; } diff --git a/tests/Feature/SagaChildWorkflowTest.php b/tests/Feature/SagaChildWorkflowTest.php new file mode 100644 index 0000000..b99255c --- /dev/null +++ b/tests/Feature/SagaChildWorkflowTest.php @@ -0,0 +1,38 @@ +start(); + + while ($workflow->running()); + + $this->assertSame(WorkflowCompletedStatus::class, $workflow->status()); + $this->assertSame('compensated', $workflow->output()); + } + + public function testParallelChildExceptionsTriggersCompensation(): void + { + $workflow = WorkflowStub::make(TestSagaChildWorkflow::class); + + $workflow->start(); + + while ($workflow->running()); + + $this->assertSame(WorkflowCompletedStatus::class, $workflow->status()); + $this->assertSame('compensated', $workflow->output()); + } +} diff --git a/tests/Fixtures/TestChildExceptionThrowingWorkflow.php b/tests/Fixtures/TestChildExceptionThrowingWorkflow.php new file mode 100644 index 0000000..9ad5c94 --- /dev/null +++ b/tests/Fixtures/TestChildExceptionThrowingWorkflow.php @@ -0,0 +1,16 @@ +addCompensation(static fn () => activity(TestUndoActivity::class)); + + $children = [ + child(TestChildExceptionThrowingWorkflow::class), + child(TestChildExceptionThrowingWorkflow::class), + child(TestChildExceptionThrowingWorkflow::class), + ]; + + yield all($children); + + return 'success'; + } catch (\Throwable $th) { + yield from $this->compensate(); + + return 'compensated'; + } + } +} diff --git a/tests/Fixtures/TestSagaSingleChildWorkflow.php b/tests/Fixtures/TestSagaSingleChildWorkflow.php new file mode 100644 index 0000000..fc78a31 --- /dev/null +++ b/tests/Fixtures/TestSagaSingleChildWorkflow.php @@ -0,0 +1,27 @@ +addCompensation(static fn () => activity(TestUndoActivity::class)); + + yield child(TestChildExceptionThrowingWorkflow::class); + + return 'success'; + } catch (\Throwable $th) { + yield from $this->compensate(); + + return 'compensated'; + } + } +} diff --git a/tests/Unit/ChildWorkflowStubTest.php b/tests/Unit/ChildWorkflowStubTest.php index af4ceb7..31118fd 100644 --- a/tests/Unit/ChildWorkflowStubTest.php +++ b/tests/Unit/ChildWorkflowStubTest.php @@ -121,6 +121,7 @@ public function startAsChild(...$arguments): void }; $storedChildWorkflow = Mockery::mock(); + $storedChildWorkflow->status = new \stdClass(); $storedChildWorkflow->shouldReceive('toWorkflow') ->once() ->andReturn($childWorkflow); @@ -198,6 +199,7 @@ static function (...$arguments) use ($parentStoredWorkflow): bool { ); $storedChildWorkflow = Mockery::mock(); + $storedChildWorkflow->status = new \stdClass(); $storedChildWorkflow->shouldReceive('toWorkflow') ->once() ->andReturnUsing(static function () use ($childWorkflow, $childContextStoredWorkflow) { @@ -271,6 +273,7 @@ static function (...$arguments) use ($parentStoredWorkflow): bool { ); $storedChildWorkflow = Mockery::mock(); + $storedChildWorkflow->status = new \stdClass(); $storedChildWorkflow->shouldReceive('toWorkflow') ->once() ->andReturn($childWorkflow); @@ -324,4 +327,77 @@ public function testAll(): void $this->assertSame(['test'], $result); } + + public function testThrowsExceptionWhenChildWorkflowFailed(): void + { + $workflow = WorkflowStub::load(WorkflowStub::make(TestParentWorkflow::class)->id()); + $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); + $storedWorkflow->update([ + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowPendingStatus::$name, + ]); + + $childWorkflow = WorkflowStub::make(TestChildWorkflow::class); + $storedChild = StoredWorkflow::findOrFail($childWorkflow->id()); + $storedChild->update([ + 'arguments' => Serializer::serialize([]), + 'status' => \Workflow\States\WorkflowFailedStatus::$name, + ]); + $storedChild->exceptions() + ->create([ + 'class' => TestChildWorkflow::class, + 'exception' => Serializer::serialize(new \Exception('child failed')), + ]); + $storedWorkflow->children() + ->attach($storedChild, [ + 'parent_index' => 0, + 'parent_now' => WorkflowStub::now(), + ]); + + WorkflowStub::setContext([ + 'storedWorkflow' => $storedWorkflow, + 'index' => 0, + 'now' => now(), + 'replaying' => false, + ]); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('child failed'); + + ChildWorkflowStub::make(TestChildWorkflow::class); + } + + public function testThrowsRuntimeExceptionWhenChildFailedWithoutException(): void + { + $workflow = WorkflowStub::load(WorkflowStub::make(TestParentWorkflow::class)->id()); + $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); + $storedWorkflow->update([ + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowPendingStatus::$name, + ]); + + $childWorkflow = WorkflowStub::make(TestChildWorkflow::class); + $storedChild = StoredWorkflow::findOrFail($childWorkflow->id()); + $storedChild->update([ + 'arguments' => Serializer::serialize([]), + 'status' => \Workflow\States\WorkflowFailedStatus::$name, + ]); + $storedWorkflow->children() + ->attach($storedChild, [ + 'parent_index' => 0, + 'parent_now' => WorkflowStub::now(), + ]); + + WorkflowStub::setContext([ + 'storedWorkflow' => $storedWorkflow, + 'index' => 0, + 'now' => now(), + 'replaying' => false, + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Child workflow ' . TestChildWorkflow::class . ' failed'); + + ChildWorkflowStub::make(TestChildWorkflow::class); + } } diff --git a/tests/Unit/WorkflowStubTest.php b/tests/Unit/WorkflowStubTest.php index ef91ec0..adac828 100644 --- a/tests/Unit/WorkflowStubTest.php +++ b/tests/Unit/WorkflowStubTest.php @@ -18,6 +18,7 @@ use Workflow\Signal; use Workflow\States\WorkflowCompletedStatus; use Workflow\States\WorkflowCreatedStatus; +use Workflow\States\WorkflowFailedStatus; use Workflow\States\WorkflowPendingStatus; use Workflow\States\WorkflowWaitingStatus; use Workflow\Timer; @@ -55,7 +56,6 @@ public function testMake(): void ]); $workflow->fail(new Exception('test')); $this->assertTrue($workflow->failed()); - $this->assertTrue($parentWorkflow->failed()); $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); $storedWorkflow->status = WorkflowCreatedStatus::class; @@ -460,4 +460,60 @@ public function testUpdateMethodReplaysStoredSignals(): void $result2 = $workflow->receive(); $this->assertSame('You said: second', $result2); } + + public function testFailPropagatesFailToContinueParent(): void + { + $parentWorkflow = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id()); + $storedParentWorkflow = StoredWorkflow::findOrFail($parentWorkflow->id()); + $storedParentWorkflow->update([ + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowPendingStatus::$name, + ]); + + $childStub = WorkflowStub::make(TestWorkflow::class); + $storedWorkflow = StoredWorkflow::findOrFail($childStub->id()); + $storedWorkflow->update([ + 'status' => WorkflowPendingStatus::$name, + ]); + + $storedWorkflow->parents() + ->attach($storedParentWorkflow, [ + 'parent_index' => StoredWorkflow::CONTINUE_PARENT_INDEX, + 'parent_now' => now(), + ]); + + $workflow = WorkflowStub::load($childStub->id()); + $workflow->fail(new Exception('continue parent fail')); + + $this->assertTrue($workflow->failed()); + $this->assertTrue($parentWorkflow->fresh()->failed()); + } + + public function testFailContinueParentHandlesTransitionNotFound(): void + { + $parentWorkflow = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id()); + $storedParentWorkflow = StoredWorkflow::findOrFail($parentWorkflow->id()); + $storedParentWorkflow->update([ + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowFailedStatus::$name, + ]); + + $childStub = WorkflowStub::make(TestWorkflow::class); + $storedWorkflow = StoredWorkflow::findOrFail($childStub->id()); + $storedWorkflow->update([ + 'status' => WorkflowPendingStatus::$name, + ]); + + $storedWorkflow->parents() + ->attach($storedParentWorkflow, [ + 'parent_index' => StoredWorkflow::CONTINUE_PARENT_INDEX, + 'parent_now' => now(), + ]); + + $workflow = WorkflowStub::load($childStub->id()); + $workflow->fail(new Exception('continue parent already failed')); + + $this->assertTrue($workflow->failed()); + $this->assertTrue($parentWorkflow->fresh()->failed()); + } } From 9143839f6baf4f71a5c4547cc6fb2d6d3983b436 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Sun, 8 Mar 2026 22:39:36 +0000 Subject: [PATCH 2/5] Child exceptions --- src/ChildWorkflowStub.php | 50 +++++++------------ src/WorkflowStub.php | 16 +++--- tests/Fixtures/TestRequiredArgException.php | 18 +++++++ tests/Unit/ChildWorkflowStubTest.php | 54 +++++++++------------ tests/Unit/WorkflowStubTest.php | 36 ++++++++++++++ 5 files changed, 104 insertions(+), 70 deletions(-) create mode 100644 tests/Fixtures/TestRequiredArgException.php diff --git a/src/ChildWorkflowStub.php b/src/ChildWorkflowStub.php index 149a625..7047c83 100644 --- a/src/ChildWorkflowStub.php +++ b/src/ChildWorkflowStub.php @@ -12,7 +12,6 @@ use Throwable; use Workflow\Exceptions\TransitionNotFound; use Workflow\Serializers\Serializer; -use Workflow\States\WorkflowFailedStatus; final class ChildWorkflowStub { @@ -49,7 +48,24 @@ public static function make($workflow, ...$arguments): PromiseInterface if ($log) { ++$context->index; WorkflowStub::setContext($context); - return resolve(Serializer::unserialize($log->result)); + $result = Serializer::unserialize($log->result); + if ( + is_array($result) + && array_key_exists('class', $result) + && is_subclass_of($result['class'], Throwable::class) + ) { + try { + $throwable = new $result['class']($result['message'] ?? '', (int) ($result['code'] ?? 0)); + } catch (Throwable $throwable) { + throw new RuntimeException( + sprintf('[%s] %s', $result['class'], (string) ($result['message'] ?? '')), + (int) ($result['code'] ?? 0), + $throwable + ); + } + throw $throwable; + } + return resolve($result); } if (! $context->replaying) { @@ -57,36 +73,6 @@ public static function make($workflow, ...$arguments): PromiseInterface ->wherePivot('parent_index', $context->index) ->first(); - if ($storedChildWorkflow && $storedChildWorkflow->status::class === WorkflowFailedStatus::class) { - ++$context->index; - WorkflowStub::setContext($context); - $childException = $storedChildWorkflow->exceptions() - ->latest() - ->first(); - if ($childException) { - $exceptionData = Serializer::unserialize($childException->exception); - if ( - is_array($exceptionData) - && array_key_exists('class', $exceptionData) - && is_subclass_of($exceptionData['class'], Throwable::class) - ) { - try { - throw new $exceptionData['class']( - $exceptionData['message'] ?? '', - (int) ($exceptionData['code'] ?? 0) - ); - } catch (Throwable $throwable) { - throw new RuntimeException( - sprintf('[%s] %s', $exceptionData['class'], (string) ($exceptionData['message'] ?? '')), - (int) ($exceptionData['code'] ?? 0), - $throwable - ); - } - } - } - throw new RuntimeException('Child workflow ' . $workflow . ' failed'); - } - $childWorkflow = $storedChildWorkflow ? $storedChildWorkflow->toWorkflow() : WorkflowStub::make($workflow); $hasOptions = collect($arguments) diff --git a/src/WorkflowStub.php b/src/WorkflowStub.php index 494e30f..81de502 100644 --- a/src/WorkflowStub.php +++ b/src/WorkflowStub.php @@ -301,12 +301,16 @@ public function fail($exception): void return; } - try { - $parentWorkflow->toWorkflow() - ->resume(); - } catch (TransitionNotFound) { - return; - } + Exception::dispatch( + $parentWorkflow->pivot->parent_index, + $parentWorkflow->pivot->parent_now, + $parentWorkflow, + [ + 'class' => get_class($exception), + 'message' => $exception->getMessage(), + 'code' => $exception->getCode(), + ] + ); }); } diff --git a/tests/Fixtures/TestRequiredArgException.php b/tests/Fixtures/TestRequiredArgException.php new file mode 100644 index 0000000..a42558b --- /dev/null +++ b/tests/Fixtures/TestRequiredArgException.php @@ -0,0 +1,18 @@ +status = new \stdClass(); $storedChildWorkflow->shouldReceive('toWorkflow') ->once() ->andReturn($childWorkflow); @@ -199,7 +198,6 @@ static function (...$arguments) use ($parentStoredWorkflow): bool { ); $storedChildWorkflow = Mockery::mock(); - $storedChildWorkflow->status = new \stdClass(); $storedChildWorkflow->shouldReceive('toWorkflow') ->once() ->andReturnUsing(static function () use ($childWorkflow, $childContextStoredWorkflow) { @@ -273,7 +271,6 @@ static function (...$arguments) use ($parentStoredWorkflow): bool { ); $storedChildWorkflow = Mockery::mock(); - $storedChildWorkflow->status = new \stdClass(); $storedChildWorkflow->shouldReceive('toWorkflow') ->once() ->andReturn($childWorkflow); @@ -328,7 +325,7 @@ public function testAll(): void $this->assertSame(['test'], $result); } - public function testThrowsExceptionWhenChildWorkflowFailed(): void + public function testThrowsExceptionWhenLogContainsException(): void { $workflow = WorkflowStub::load(WorkflowStub::make(TestParentWorkflow::class)->id()); $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); @@ -336,22 +333,16 @@ public function testThrowsExceptionWhenChildWorkflowFailed(): void 'arguments' => Serializer::serialize([]), 'status' => WorkflowPendingStatus::$name, ]); - - $childWorkflow = WorkflowStub::make(TestChildWorkflow::class); - $storedChild = StoredWorkflow::findOrFail($childWorkflow->id()); - $storedChild->update([ - 'arguments' => Serializer::serialize([]), - 'status' => \Workflow\States\WorkflowFailedStatus::$name, - ]); - $storedChild->exceptions() + $storedWorkflow->logs() ->create([ - 'class' => TestChildWorkflow::class, - 'exception' => Serializer::serialize(new \Exception('child failed')), - ]); - $storedWorkflow->children() - ->attach($storedChild, [ - 'parent_index' => 0, - 'parent_now' => WorkflowStub::now(), + 'index' => 0, + 'now' => WorkflowStub::now(), + 'class' => \Workflow\Exception::class, + 'result' => Serializer::serialize([ + 'class' => \Exception::class, + 'message' => 'child failed', + 'code' => 0, + ]), ]); WorkflowStub::setContext([ @@ -367,7 +358,7 @@ public function testThrowsExceptionWhenChildWorkflowFailed(): void ChildWorkflowStub::make(TestChildWorkflow::class); } - public function testThrowsRuntimeExceptionWhenChildFailedWithoutException(): void + public function testThrowsRuntimeExceptionWhenExceptionClassCannotBeInstantiated(): void { $workflow = WorkflowStub::load(WorkflowStub::make(TestParentWorkflow::class)->id()); $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); @@ -375,17 +366,16 @@ public function testThrowsRuntimeExceptionWhenChildFailedWithoutException(): voi 'arguments' => Serializer::serialize([]), 'status' => WorkflowPendingStatus::$name, ]); - - $childWorkflow = WorkflowStub::make(TestChildWorkflow::class); - $storedChild = StoredWorkflow::findOrFail($childWorkflow->id()); - $storedChild->update([ - 'arguments' => Serializer::serialize([]), - 'status' => \Workflow\States\WorkflowFailedStatus::$name, - ]); - $storedWorkflow->children() - ->attach($storedChild, [ - 'parent_index' => 0, - 'parent_now' => WorkflowStub::now(), + $storedWorkflow->logs() + ->create([ + 'index' => 0, + 'now' => WorkflowStub::now(), + 'class' => \Workflow\Exception::class, + 'result' => Serializer::serialize([ + 'class' => \Tests\Fixtures\TestRequiredArgException::class, + 'message' => 'bad type', + 'code' => 0, + ]), ]); WorkflowStub::setContext([ @@ -396,7 +386,7 @@ public function testThrowsRuntimeExceptionWhenChildFailedWithoutException(): voi ]); $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Child workflow ' . TestChildWorkflow::class . ' failed'); + $this->expectExceptionMessage('[Tests\Fixtures\TestRequiredArgException] bad type'); ChildWorkflowStub::make(TestChildWorkflow::class); } diff --git a/tests/Unit/WorkflowStubTest.php b/tests/Unit/WorkflowStubTest.php index adac828..cf6b7e6 100644 --- a/tests/Unit/WorkflowStubTest.php +++ b/tests/Unit/WorkflowStubTest.php @@ -516,4 +516,40 @@ public function testFailContinueParentHandlesTransitionNotFound(): void $this->assertTrue($workflow->failed()); $this->assertTrue($parentWorkflow->fresh()->failed()); } + + public function testFailDispatchesExceptionJobForNormalChildParent(): void + { + Queue::fake(); + + $parentWorkflow = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id()); + $storedParentWorkflow = StoredWorkflow::findOrFail($parentWorkflow->id()); + $storedParentWorkflow->update([ + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowPendingStatus::$name, + ]); + + $childStub = WorkflowStub::make(TestWorkflow::class); + $storedWorkflow = StoredWorkflow::findOrFail($childStub->id()); + $storedWorkflow->update([ + 'status' => WorkflowPendingStatus::$name, + ]); + + $storedWorkflow->parents() + ->attach($storedParentWorkflow, [ + 'parent_index' => 0, + 'parent_now' => now(), + ]); + + $workflow = WorkflowStub::load($childStub->id()); + $workflow->fail(new Exception('child workflow failed')); + + $this->assertTrue($workflow->failed()); + + Queue::assertPushed(\Workflow\Exception::class, static function ($job) use ($storedParentWorkflow) { + return $job->index === 0 + && $job->storedWorkflow->id === $storedParentWorkflow->id + && $job->exception['class'] === Exception::class + && $job->exception['message'] === 'child workflow failed'; + }); + } } From 3b8912573e86155f249f20b35aaac1f5f73c9dff Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Mon, 9 Mar 2026 03:49:18 +0000 Subject: [PATCH 3/5] Child exceptions --- src/ChildWorkflowStub.php | 45 +++++++++++----- src/WorkflowStub.php | 25 +++++++-- tests/Unit/ChildWorkflowStubTest.php | 76 +++++++++++++++++++++++++++- 3 files changed, 127 insertions(+), 19 deletions(-) diff --git a/src/ChildWorkflowStub.php b/src/ChildWorkflowStub.php index 7047c83..b851932 100644 --- a/src/ChildWorkflowStub.php +++ b/src/ChildWorkflowStub.php @@ -12,6 +12,7 @@ use Throwable; use Workflow\Exceptions\TransitionNotFound; use Workflow\Serializers\Serializer; +use Workflow\States\WorkflowFailedStatus; final class ChildWorkflowStub { @@ -46,26 +47,46 @@ public static function make($workflow, ...$arguments): PromiseInterface } if ($log) { - ++$context->index; - WorkflowStub::setContext($context); $result = Serializer::unserialize($log->result); if ( is_array($result) && array_key_exists('class', $result) && is_subclass_of($result['class'], Throwable::class) ) { - try { - $throwable = new $result['class']($result['message'] ?? '', (int) ($result['code'] ?? 0)); - } catch (Throwable $throwable) { - throw new RuntimeException( - sprintf('[%s] %s', $result['class'], (string) ($result['message'] ?? '')), - (int) ($result['code'] ?? 0), - $throwable - ); + if (! $context->replaying) { + $storedChildWorkflow = $context->storedWorkflow->children() + ->wherePivot('parent_index', $context->index) + ->first(); + if ($storedChildWorkflow && $storedChildWorkflow->status::class !== WorkflowFailedStatus::class) { + $log->delete(); + $log = null; + } + } + + if ($log) { + $context->storedWorkflow->logs() + ->where('index', '>', $context->index) + ->where('class', Exception::class) + ->delete(); + + ++$context->index; + WorkflowStub::setContext($context); + try { + $throwable = new $result['class']($result['message'] ?? '', (int) ($result['code'] ?? 0)); + } catch (Throwable $throwable) { + throw new RuntimeException( + sprintf('[%s] %s', $result['class'], (string) ($result['message'] ?? '')), + (int) ($result['code'] ?? 0), + $throwable + ); + } + throw $throwable; } - throw $throwable; + } else { + ++$context->index; + WorkflowStub::setContext($context); + return resolve($result); } - return resolve($result); } if (! $context->replaying) { diff --git a/src/WorkflowStub.php b/src/WorkflowStub.php index 81de502..5aabead 100644 --- a/src/WorkflowStub.php +++ b/src/WorkflowStub.php @@ -301,15 +301,30 @@ public function fail($exception): void return; } + $file = new SplFileObject($exception->getFile()); + $iterator = new LimitIterator($file, max(0, $exception->getLine() - 4), 7); + + $throwable = [ + 'class' => get_class($exception), + 'message' => $exception->getMessage(), + 'code' => $exception->getCode(), + 'line' => $exception->getLine(), + 'file' => $exception->getFile(), + 'trace' => collect($exception->getTrace()) + ->filter(static fn ($trace) => Serializer::serializable($trace)) + ->toArray(), + 'snippet' => array_slice(iterator_to_array($iterator), 0, 7), + ]; + + $parentWf = $parentWorkflow->toWorkflow(); + Exception::dispatch( $parentWorkflow->pivot->parent_index, $parentWorkflow->pivot->parent_now, $parentWorkflow, - [ - 'class' => get_class($exception), - 'message' => $exception->getMessage(), - 'code' => $exception->getCode(), - ] + $throwable, + $parentWf->connection(), + $parentWf->queue() ); }); } diff --git a/tests/Unit/ChildWorkflowStubTest.php b/tests/Unit/ChildWorkflowStubTest.php index 01ef414..21cbf07 100644 --- a/tests/Unit/ChildWorkflowStubTest.php +++ b/tests/Unit/ChildWorkflowStubTest.php @@ -121,6 +121,7 @@ public function startAsChild(...$arguments): void }; $storedChildWorkflow = Mockery::mock(); + $storedChildWorkflow->status = new \stdClass(); $storedChildWorkflow->shouldReceive('toWorkflow') ->once() ->andReturn($childWorkflow); @@ -198,6 +199,7 @@ static function (...$arguments) use ($parentStoredWorkflow): bool { ); $storedChildWorkflow = Mockery::mock(); + $storedChildWorkflow->status = new \stdClass(); $storedChildWorkflow->shouldReceive('toWorkflow') ->once() ->andReturnUsing(static function () use ($childWorkflow, $childContextStoredWorkflow) { @@ -271,6 +273,7 @@ static function (...$arguments) use ($parentStoredWorkflow): bool { ); $storedChildWorkflow = Mockery::mock(); + $storedChildWorkflow->status = new \stdClass(); $storedChildWorkflow->shouldReceive('toWorkflow') ->once() ->andReturn($childWorkflow); @@ -333,6 +336,18 @@ public function testThrowsExceptionWhenLogContainsException(): void 'arguments' => Serializer::serialize([]), 'status' => WorkflowPendingStatus::$name, ]); + + $childWorkflow = WorkflowStub::make(TestChildWorkflow::class); + $storedChild = StoredWorkflow::findOrFail($childWorkflow->id()); + $storedChild->update([ + 'arguments' => Serializer::serialize([]), + 'status' => \Workflow\States\WorkflowFailedStatus::$name, + ]); + $storedWorkflow->children() + ->attach($storedChild, [ + 'parent_index' => 0, + 'parent_now' => WorkflowStub::now(), + ]); $storedWorkflow->logs() ->create([ 'index' => 0, @@ -366,6 +381,18 @@ public function testThrowsRuntimeExceptionWhenExceptionClassCannotBeInstantiated 'arguments' => Serializer::serialize([]), 'status' => WorkflowPendingStatus::$name, ]); + + $childWorkflow = WorkflowStub::make(TestChildWorkflow::class); + $storedChild = StoredWorkflow::findOrFail($childWorkflow->id()); + $storedChild->update([ + 'arguments' => Serializer::serialize([]), + 'status' => \Workflow\States\WorkflowFailedStatus::$name, + ]); + $storedWorkflow->children() + ->attach($storedChild, [ + 'parent_index' => 0, + 'parent_now' => WorkflowStub::now(), + ]); $storedWorkflow->logs() ->create([ 'index' => 0, @@ -373,7 +400,7 @@ public function testThrowsRuntimeExceptionWhenExceptionClassCannotBeInstantiated 'class' => \Workflow\Exception::class, 'result' => Serializer::serialize([ 'class' => \Tests\Fixtures\TestRequiredArgException::class, - 'message' => 'bad type', + 'message' => 'bad', 'code' => 0, ]), ]); @@ -386,8 +413,53 @@ public function testThrowsRuntimeExceptionWhenExceptionClassCannotBeInstantiated ]); $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('[Tests\Fixtures\TestRequiredArgException] bad type'); + $this->expectExceptionMessage('[Tests\Fixtures\TestRequiredArgException] bad'); + + ChildWorkflowStub::make(TestChildWorkflow::class); + } + + public function testDeletesStaleExceptionLogWhenChildWasRetried(): void + { + $workflow = WorkflowStub::load(WorkflowStub::make(TestParentWorkflow::class)->id()); + $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); + $storedWorkflow->update([ + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowPendingStatus::$name, + ]); + + $childWorkflow = WorkflowStub::make(TestChildWorkflow::class); + $storedChild = StoredWorkflow::findOrFail($childWorkflow->id()); + $storedChild->update([ + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowPendingStatus::$name, + ]); + $storedWorkflow->children() + ->attach($storedChild, [ + 'parent_index' => 0, + 'parent_now' => WorkflowStub::now(), + ]); + $storedWorkflow->logs() + ->create([ + 'index' => 0, + 'now' => WorkflowStub::now(), + 'class' => \Workflow\Exception::class, + 'result' => Serializer::serialize([ + 'class' => \Exception::class, + 'message' => 'old failure', + 'code' => 0, + ]), + ]); + + WorkflowStub::setContext([ + 'storedWorkflow' => $storedWorkflow, + 'index' => 0, + 'now' => now(), + 'replaying' => false, + ]); ChildWorkflowStub::make(TestChildWorkflow::class); + + $this->assertSame(1, WorkflowStub::getContext()->index); + $this->assertSame(0, $storedWorkflow->logs()->count()); } } From 7d9d24f77de779cdaaa4df3865f16c30ca39ed34 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Mon, 9 Mar 2026 04:25:15 +0000 Subject: [PATCH 4/5] Child exceptions --- src/ChildWorkflowStub.php | 5 ----- src/Exception.php | 2 +- tests/Unit/ExceptionTest.php | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/ChildWorkflowStub.php b/src/ChildWorkflowStub.php index b851932..f3486f1 100644 --- a/src/ChildWorkflowStub.php +++ b/src/ChildWorkflowStub.php @@ -64,11 +64,6 @@ public static function make($workflow, ...$arguments): PromiseInterface } if ($log) { - $context->storedWorkflow->logs() - ->where('index', '>', $context->index) - ->where('class', Exception::class) - ->delete(); - ++$context->index; WorkflowStub::setContext($context); try { diff --git a/src/Exception.php b/src/Exception.php index c275652..de177e5 100644 --- a/src/Exception.php +++ b/src/Exception.php @@ -53,7 +53,7 @@ public function handle() try { if ($this->storedWorkflow->hasLogByIndex($this->index)) { $workflow->resume(); - } else { + } elseif (! $this->storedWorkflow->logs()->where('class', self::class)->exists()) { $workflow->next($this->index, $this->now, self::class, $this->exception); } } catch (TransitionNotFound) { diff --git a/tests/Unit/ExceptionTest.php b/tests/Unit/ExceptionTest.php index fa0d5d5..1f6cf3c 100644 --- a/tests/Unit/ExceptionTest.php +++ b/tests/Unit/ExceptionTest.php @@ -43,4 +43,37 @@ public function testExceptionWorkflowRunning(): void $this->assertSame(WorkflowRunningStatus::class, $workflow->status()); } + + public function testSkipsWriteWhenSiblingExceptionLogExists(): void + { + $workflow = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id()); + $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); + $storedWorkflow->update([ + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowRunningStatus::$name, + ]); + + $storedWorkflow->logs() + ->create([ + 'index' => 0, + 'now' => now() + ->toDateTimeString(), + 'class' => Exception::class, + 'result' => Serializer::serialize([ + 'class' => \Exception::class, + 'message' => 'first child failed', + 'code' => 0, + ]), + ]); + + $exception = new Exception(1, now()->toDateTimeString(), $storedWorkflow, [ + 'class' => \Exception::class, + 'message' => 'second child failed', + 'code' => 0, + ]); + $exception->handle(); + + $this->assertFalse($storedWorkflow->hasLogByIndex(1)); + $this->assertSame(1, $storedWorkflow->logs()->count()); + } } From df4a11b353dbbc2885e8a0cd44917dc3551d3ae1 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Mon, 9 Mar 2026 14:32:30 +0000 Subject: [PATCH 5/5] Child exceptions --- AGENTS.md | 9 +++ src/ChildWorkflowStub.php | 40 ++++------- tests/Feature/ParentWorkflowTest.php | 8 +++ tests/Feature/SagaWorkflowTest.php | 13 ++++ .../TestSagaParallelActivityWorkflow.php | 32 +++++++++ tests/Unit/ChildWorkflowStubTest.php | 67 ------------------- 6 files changed, 74 insertions(+), 95 deletions(-) create mode 100644 AGENTS.md create mode 100644 tests/Fixtures/TestSagaParallelActivityWorkflow.php diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..902a071 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# Quality Cycle + +Run these commands in order before considering any change complete: + +1. `composer ecs` — Fix code style (auto-fixes) +2. `composer stan` — Static analysis (must pass with no errors) +3. `composer unit` — Unit tests (must all pass) +4. `composer coverage` — Unit tests with coverage (must be 100%) +5. `composer feature` — Feature tests (must all pass) diff --git a/src/ChildWorkflowStub.php b/src/ChildWorkflowStub.php index f3486f1..7047c83 100644 --- a/src/ChildWorkflowStub.php +++ b/src/ChildWorkflowStub.php @@ -12,7 +12,6 @@ use Throwable; use Workflow\Exceptions\TransitionNotFound; use Workflow\Serializers\Serializer; -use Workflow\States\WorkflowFailedStatus; final class ChildWorkflowStub { @@ -47,41 +46,26 @@ public static function make($workflow, ...$arguments): PromiseInterface } if ($log) { + ++$context->index; + WorkflowStub::setContext($context); $result = Serializer::unserialize($log->result); if ( is_array($result) && array_key_exists('class', $result) && is_subclass_of($result['class'], Throwable::class) ) { - if (! $context->replaying) { - $storedChildWorkflow = $context->storedWorkflow->children() - ->wherePivot('parent_index', $context->index) - ->first(); - if ($storedChildWorkflow && $storedChildWorkflow->status::class !== WorkflowFailedStatus::class) { - $log->delete(); - $log = null; - } - } - - if ($log) { - ++$context->index; - WorkflowStub::setContext($context); - try { - $throwable = new $result['class']($result['message'] ?? '', (int) ($result['code'] ?? 0)); - } catch (Throwable $throwable) { - throw new RuntimeException( - sprintf('[%s] %s', $result['class'], (string) ($result['message'] ?? '')), - (int) ($result['code'] ?? 0), - $throwable - ); - } - throw $throwable; + try { + $throwable = new $result['class']($result['message'] ?? '', (int) ($result['code'] ?? 0)); + } catch (Throwable $throwable) { + throw new RuntimeException( + sprintf('[%s] %s', $result['class'], (string) ($result['message'] ?? '')), + (int) ($result['code'] ?? 0), + $throwable + ); } - } else { - ++$context->index; - WorkflowStub::setContext($context); - return resolve($result); + throw $throwable; } + return resolve($result); } if (! $context->replaying) { diff --git a/tests/Feature/ParentWorkflowTest.php b/tests/Feature/ParentWorkflowTest.php index 6ac7b76..1174567 100644 --- a/tests/Feature/ParentWorkflowTest.php +++ b/tests/Feature/ParentWorkflowTest.php @@ -53,10 +53,18 @@ public function testRetry(): void $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); $storedWorkflow->status = WorkflowCreatedStatus::class; $storedWorkflow->save(); + $storedWorkflow->logs() + ->delete(); + $storedWorkflow->exceptions() + ->delete(); $storedChildWorkflow = StoredWorkflow::findOrFail($workflow->id() + 1); $storedChildWorkflow->status = WorkflowCreatedStatus::class; $storedChildWorkflow->save(); + $storedChildWorkflow->logs() + ->delete(); + $storedChildWorkflow->exceptions() + ->delete(); $workflow->fresh() ->start(shouldThrow: false); diff --git a/tests/Feature/SagaWorkflowTest.php b/tests/Feature/SagaWorkflowTest.php index 93da264..d74e33f 100644 --- a/tests/Feature/SagaWorkflowTest.php +++ b/tests/Feature/SagaWorkflowTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature; use Tests\Fixtures\TestActivity; +use Tests\Fixtures\TestSagaParallelActivityWorkflow; use Tests\Fixtures\TestSagaWorkflow; use Tests\Fixtures\TestUndoActivity; use Tests\TestCase; @@ -48,4 +49,16 @@ public function testFailed(): void ->values() ->toArray()); } + + public function testParallelActivityExceptionsTriggersCompensation(): void + { + $workflow = WorkflowStub::make(TestSagaParallelActivityWorkflow::class); + + $workflow->start(); + + while ($workflow->running()); + + $this->assertSame(WorkflowCompletedStatus::class, $workflow->status()); + $this->assertSame('compensated', $workflow->output()); + } } diff --git a/tests/Fixtures/TestSagaParallelActivityWorkflow.php b/tests/Fixtures/TestSagaParallelActivityWorkflow.php new file mode 100644 index 0000000..3e048af --- /dev/null +++ b/tests/Fixtures/TestSagaParallelActivityWorkflow.php @@ -0,0 +1,32 @@ +addCompensation(static fn () => activity(TestUndoActivity::class)); + + yield ActivityStub::all([ + activity(TestSagaActivity::class), + activity(TestSagaActivity::class), + activity(TestSagaActivity::class), + ]); + + return 'success'; + } catch (\Throwable $th) { + yield from $this->compensate(); + } + + return 'compensated'; + } +} diff --git a/tests/Unit/ChildWorkflowStubTest.php b/tests/Unit/ChildWorkflowStubTest.php index 21cbf07..a9561ae 100644 --- a/tests/Unit/ChildWorkflowStubTest.php +++ b/tests/Unit/ChildWorkflowStubTest.php @@ -337,17 +337,6 @@ public function testThrowsExceptionWhenLogContainsException(): void 'status' => WorkflowPendingStatus::$name, ]); - $childWorkflow = WorkflowStub::make(TestChildWorkflow::class); - $storedChild = StoredWorkflow::findOrFail($childWorkflow->id()); - $storedChild->update([ - 'arguments' => Serializer::serialize([]), - 'status' => \Workflow\States\WorkflowFailedStatus::$name, - ]); - $storedWorkflow->children() - ->attach($storedChild, [ - 'parent_index' => 0, - 'parent_now' => WorkflowStub::now(), - ]); $storedWorkflow->logs() ->create([ 'index' => 0, @@ -382,17 +371,6 @@ public function testThrowsRuntimeExceptionWhenExceptionClassCannotBeInstantiated 'status' => WorkflowPendingStatus::$name, ]); - $childWorkflow = WorkflowStub::make(TestChildWorkflow::class); - $storedChild = StoredWorkflow::findOrFail($childWorkflow->id()); - $storedChild->update([ - 'arguments' => Serializer::serialize([]), - 'status' => \Workflow\States\WorkflowFailedStatus::$name, - ]); - $storedWorkflow->children() - ->attach($storedChild, [ - 'parent_index' => 0, - 'parent_now' => WorkflowStub::now(), - ]); $storedWorkflow->logs() ->create([ 'index' => 0, @@ -417,49 +395,4 @@ public function testThrowsRuntimeExceptionWhenExceptionClassCannotBeInstantiated ChildWorkflowStub::make(TestChildWorkflow::class); } - - public function testDeletesStaleExceptionLogWhenChildWasRetried(): void - { - $workflow = WorkflowStub::load(WorkflowStub::make(TestParentWorkflow::class)->id()); - $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); - $storedWorkflow->update([ - 'arguments' => Serializer::serialize([]), - 'status' => WorkflowPendingStatus::$name, - ]); - - $childWorkflow = WorkflowStub::make(TestChildWorkflow::class); - $storedChild = StoredWorkflow::findOrFail($childWorkflow->id()); - $storedChild->update([ - 'arguments' => Serializer::serialize([]), - 'status' => WorkflowPendingStatus::$name, - ]); - $storedWorkflow->children() - ->attach($storedChild, [ - 'parent_index' => 0, - 'parent_now' => WorkflowStub::now(), - ]); - $storedWorkflow->logs() - ->create([ - 'index' => 0, - 'now' => WorkflowStub::now(), - 'class' => \Workflow\Exception::class, - 'result' => Serializer::serialize([ - 'class' => \Exception::class, - 'message' => 'old failure', - 'code' => 0, - ]), - ]); - - WorkflowStub::setContext([ - 'storedWorkflow' => $storedWorkflow, - 'index' => 0, - 'now' => now(), - 'replaying' => false, - ]); - - ChildWorkflowStub::make(TestChildWorkflow::class); - - $this->assertSame(1, WorkflowStub::getContext()->index); - $this->assertSame(0, $storedWorkflow->logs()->count()); - } }