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 b4eb6ee..7047c83 100644 --- a/src/ChildWorkflowStub.php +++ b/src/ChildWorkflowStub.php @@ -8,6 +8,8 @@ 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; @@ -46,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) { 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/src/WorkflowStub.php b/src/WorkflowStub.php index a8b401c..5aabead 100644 --- a/src/WorkflowStub.php +++ b/src/WorkflowStub.php @@ -288,12 +288,44 @@ public function fail($exception): void $this->storedWorkflow->parents() ->each(static function ($parentWorkflow) use ($exception) { - try { - $parentWorkflow->toWorkflow() - ->fail($exception); - } catch (TransitionNotFound) { + 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; } + + $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, + $throwable, + $parentWf->connection(), + $parentWf->queue() + ); }); } 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/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/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/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/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/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..a9561ae 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,72 @@ public function testAll(): void $this->assertSame(['test'], $result); } + + public function testThrowsExceptionWhenLogContainsException(): void + { + $workflow = WorkflowStub::load(WorkflowStub::make(TestParentWorkflow::class)->id()); + $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); + $storedWorkflow->update([ + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowPendingStatus::$name, + ]); + + $storedWorkflow->logs() + ->create([ + 'index' => 0, + 'now' => WorkflowStub::now(), + 'class' => \Workflow\Exception::class, + 'result' => Serializer::serialize([ + 'class' => \Exception::class, + 'message' => 'child failed', + 'code' => 0, + ]), + ]); + + WorkflowStub::setContext([ + 'storedWorkflow' => $storedWorkflow, + 'index' => 0, + 'now' => now(), + 'replaying' => false, + ]); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('child failed'); + + ChildWorkflowStub::make(TestChildWorkflow::class); + } + + public function testThrowsRuntimeExceptionWhenExceptionClassCannotBeInstantiated(): void + { + $workflow = WorkflowStub::load(WorkflowStub::make(TestParentWorkflow::class)->id()); + $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); + $storedWorkflow->update([ + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowPendingStatus::$name, + ]); + + $storedWorkflow->logs() + ->create([ + 'index' => 0, + 'now' => WorkflowStub::now(), + 'class' => \Workflow\Exception::class, + 'result' => Serializer::serialize([ + 'class' => \Tests\Fixtures\TestRequiredArgException::class, + 'message' => 'bad', + 'code' => 0, + ]), + ]); + + WorkflowStub::setContext([ + 'storedWorkflow' => $storedWorkflow, + 'index' => 0, + 'now' => now(), + 'replaying' => false, + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('[Tests\Fixtures\TestRequiredArgException] bad'); + + ChildWorkflowStub::make(TestChildWorkflow::class); + } } 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()); + } } diff --git a/tests/Unit/WorkflowStubTest.php b/tests/Unit/WorkflowStubTest.php index ef91ec0..cf6b7e6 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,96 @@ 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()); + } + + 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'; + }); + } }