From 909c9c481a363b699e9207b04c92f7c2a3c4d5b5 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Thu, 12 Feb 2026 16:36:11 -0600 Subject: [PATCH 1/8] Fix retry --- .gitignore | 1 + src/WorkflowStub.php | 11 ++- .../Feature/NestedSignalRaceConditionTest.php | 76 +++++++++++++++++++ .../Fixtures/TestNestedSignalLeafWorkflow.php | 33 ++++++++ .../TestNestedSignalMiddleWorkflow.php | 29 +++++++ .../TestNestedSignalParentWorkflow.php | 31 ++++++++ tests/Unit/WorkflowStubTest.php | 17 +++++ 7 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/NestedSignalRaceConditionTest.php create mode 100644 tests/Fixtures/TestNestedSignalLeafWorkflow.php create mode 100644 tests/Fixtures/TestNestedSignalMiddleWorkflow.php create mode 100644 tests/Fixtures/TestNestedSignalParentWorkflow.php diff --git a/.gitignore b/.gitignore index 4d84bc35..c36cea39 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ coverage.xml .phpunit.result.cache .php_cs.cache .php-cs-fixer.cache +.vs \ No newline at end of file diff --git a/src/WorkflowStub.php b/src/WorkflowStub.php index 76b828bd..7850124c 100644 --- a/src/WorkflowStub.php +++ b/src/WorkflowStub.php @@ -9,6 +9,7 @@ use Illuminate\Support\Traits\Macroable; use LimitIterator; use ReflectionClass; +use Spatie\ModelStates\Exceptions\TransitionNotFound; use SplFileObject; use Workflow\Events\WorkflowFailed; use Workflow\Events\WorkflowStarted; @@ -381,7 +382,15 @@ private function dispatch(): void ); } - $this->storedWorkflow->status->transitionTo(WorkflowPendingStatus::class); + try { + $this->storedWorkflow->status->transitionTo(WorkflowPendingStatus::class); + } catch (TransitionNotFound) { + $this->storedWorkflow->refresh(); + + if (! $this->running()) { + return; + } + } $dispatch = static::faked() ? 'dispatchSync' : 'dispatch'; diff --git a/tests/Feature/NestedSignalRaceConditionTest.php b/tests/Feature/NestedSignalRaceConditionTest.php new file mode 100644 index 00000000..c6612bde --- /dev/null +++ b/tests/Feature/NestedSignalRaceConditionTest.php @@ -0,0 +1,76 @@ +format('Uu'); + $middleCount = 12; + $leafCount = 3; + $duplicateSignals = 4; + $expectedLeafCount = $middleCount * $leafCount; + + $workflow = WorkflowStub::make(TestNestedSignalParentWorkflow::class); + $workflow->start($runId, $middleCount, $leafCount); + + $creationDeadline = now() + ->addSeconds(30); + $leafIds = []; + while (now()->lt($creationDeadline)) { + $leafIds = StoredWorkflow::query() + ->where('class', TestNestedSignalLeafWorkflow::class) + ->pluck('id') + ->all(); + + if (count($leafIds) === $expectedLeafCount) { + break; + } + + usleep(50000); + } + + $this->assertCount($expectedLeafCount, $leafIds, 'Timed out waiting for all nested leaf workflows'); + + for ($round = 0; $round < $duplicateSignals; $round++) { + foreach ($leafIds as $leafId) { + WorkflowStub::load((int) $leafId)->respond(); + } + } + + $completionDeadline = now() + ->addSeconds(120); + while ($workflow->running() && now()->lt($completionDeadline)) { + usleep(50000); + $workflow->fresh(); + } + + if ($workflow->running()) { + throw new RuntimeException(sprintf( + 'Nested signal run %d did not complete before timeout. Current status: %s', + $runId, + (string) $workflow->status() + )); + } + + $this->assertSame(WorkflowCompletedStatus::class, $workflow->status()); + $this->assertSame([ + 'run_id' => $runId, + 'middle_count' => $middleCount, + 'leaf_count' => $leafCount, + 'resolved_leaf_count' => $expectedLeafCount, + ], $workflow->output()); + } +} diff --git a/tests/Fixtures/TestNestedSignalLeafWorkflow.php b/tests/Fixtures/TestNestedSignalLeafWorkflow.php new file mode 100644 index 00000000..8897680f --- /dev/null +++ b/tests/Fixtures/TestNestedSignalLeafWorkflow.php @@ -0,0 +1,33 @@ +responded = true; + } + + public function execute(int $runId, int $middleIndex, int $leafIndex): Generator + { + $resolved = yield awaitWithTimeout(30, fn (): bool => $this->responded); + + return [ + 'run_id' => $runId, + 'middle_index' => $middleIndex, + 'leaf_index' => $leafIndex, + 'resolved' => $resolved, + ]; + } +} diff --git a/tests/Fixtures/TestNestedSignalMiddleWorkflow.php b/tests/Fixtures/TestNestedSignalMiddleWorkflow.php new file mode 100644 index 00000000..4714e567 --- /dev/null +++ b/tests/Fixtures/TestNestedSignalMiddleWorkflow.php @@ -0,0 +1,29 @@ + (bool) ($result['resolved'] ?? false) + )); + } +} diff --git a/tests/Fixtures/TestNestedSignalParentWorkflow.php b/tests/Fixtures/TestNestedSignalParentWorkflow.php new file mode 100644 index 00000000..6a63cf2a --- /dev/null +++ b/tests/Fixtures/TestNestedSignalParentWorkflow.php @@ -0,0 +1,31 @@ + $runId, + 'middle_count' => $middleCount, + 'leaf_count' => $leafCount, + 'resolved_leaf_count' => array_sum($resolvedPerMiddle), + ]; + } +} diff --git a/tests/Unit/WorkflowStubTest.php b/tests/Unit/WorkflowStubTest.php index fbc22182..fde6f766 100644 --- a/tests/Unit/WorkflowStubTest.php +++ b/tests/Unit/WorkflowStubTest.php @@ -258,6 +258,23 @@ public function testHandlesDuplicateLogInsertionProperly(): void Queue::assertPushed(TestWorkflow::class, 1); } + public function testResumeWhilePendingDoesNotThrowAndStillDispatches(): void + { + Queue::fake(); + + $workflow = WorkflowStub::make(TestWorkflow::class); + $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); + $storedWorkflow->update([ + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowPendingStatus::$name, + ]); + + $workflow->resume(); + + $this->assertSame(WorkflowPendingStatus::class, $workflow->status()); + Queue::assertPushed(TestWorkflow::class, 1); + } + public function testIsUpdateMethodReturnsTrueForUpdateMethods(): void { $this->assertTrue(WorkflowStub::isUpdateMethod(TestChatBotWorkflow::class, 'receive')); From e0640e8d072623d26d5d10db24257ff47ad9bc96 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Thu, 12 Feb 2026 16:37:56 -0600 Subject: [PATCH 2/8] Cleanup --- src/WorkflowStub.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/WorkflowStub.php b/src/WorkflowStub.php index 7850124c..746990d3 100644 --- a/src/WorkflowStub.php +++ b/src/WorkflowStub.php @@ -9,7 +9,6 @@ use Illuminate\Support\Traits\Macroable; use LimitIterator; use ReflectionClass; -use Spatie\ModelStates\Exceptions\TransitionNotFound; use SplFileObject; use Workflow\Events\WorkflowFailed; use Workflow\Events\WorkflowStarted; @@ -384,7 +383,7 @@ private function dispatch(): void try { $this->storedWorkflow->status->transitionTo(WorkflowPendingStatus::class); - } catch (TransitionNotFound) { + } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) { $this->storedWorkflow->refresh(); if (! $this->running()) { From 0b2c9b71e203f9e121bf50ceb741ce5b115308f8 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Thu, 12 Feb 2026 17:20:07 -0600 Subject: [PATCH 3/8] More specific --- src/WorkflowStub.php | 6 ++--- tests/.env.feature | 3 +++ tests/Unit/ChildWorkflowTest.php | 45 ++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/ChildWorkflowTest.php diff --git a/src/WorkflowStub.php b/src/WorkflowStub.php index 746990d3..84e33ac2 100644 --- a/src/WorkflowStub.php +++ b/src/WorkflowStub.php @@ -383,11 +383,11 @@ private function dispatch(): void try { $this->storedWorkflow->status->transitionTo(WorkflowPendingStatus::class); - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) { + } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound $exception) { $this->storedWorkflow->refresh(); - if (! $this->running()) { - return; + if ($this->status() !== WorkflowPendingStatus::class) { + throw $exception; } } diff --git a/tests/.env.feature b/tests/.env.feature index 406cbc87..e6a598ce 100644 --- a/tests/.env.feature +++ b/tests/.env.feature @@ -7,6 +7,9 @@ DB_PORT=5432 DB_USERNAME=laravel DB_PASSWORD=laravel +CACHE_DRIVER=redis +CACHE_STORE=redis + QUEUE_CONNECTION=redis QUEUE_FAILED_DRIVER=null diff --git a/tests/Unit/ChildWorkflowTest.php b/tests/Unit/ChildWorkflowTest.php new file mode 100644 index 00000000..a040aa04 --- /dev/null +++ b/tests/Unit/ChildWorkflowTest.php @@ -0,0 +1,45 @@ +id()); + $storedParent->update([ + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowRunningStatus::class, + ]); + + $storedChild = StoredWorkflow::create([ + 'class' => TestChildWorkflow::class, + 'arguments' => Serializer::serialize([]), + ]); + + $job = new ChildWorkflow( + 0, + now()->toDateTimeString(), + $storedChild, + true, + $storedParent + ); + + $job->handle(); + + $this->assertSame(1, $storedParent->logs()->count()); + $this->assertSame(WorkflowRunningStatus::class, $storedParent->refresh()->status::class); + } +} From a1a85f5cf380dd46f05c396b58c895790c956ec3 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Thu, 12 Feb 2026 17:22:20 -0600 Subject: [PATCH 4/8] Cleanup --- tests/Unit/ChildWorkflowTest.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/Unit/ChildWorkflowTest.php b/tests/Unit/ChildWorkflowTest.php index a040aa04..a83dede9 100644 --- a/tests/Unit/ChildWorkflowTest.php +++ b/tests/Unit/ChildWorkflowTest.php @@ -29,13 +29,7 @@ public function testHandleReleasesWhenParentWorkflowIsRunning(): void 'arguments' => Serializer::serialize([]), ]); - $job = new ChildWorkflow( - 0, - now()->toDateTimeString(), - $storedChild, - true, - $storedParent - ); + $job = new ChildWorkflow(0, now() ->toDateTimeString(), $storedChild, true, $storedParent); $job->handle(); From e667ffca26417ac0ae870eaeacc362cf675db29d Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Thu, 12 Feb 2026 17:35:16 -0600 Subject: [PATCH 5/8] Cleanup --- tests/Unit/ChildWorkflowTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/ChildWorkflowTest.php b/tests/Unit/ChildWorkflowTest.php index a83dede9..49da05bf 100644 --- a/tests/Unit/ChildWorkflowTest.php +++ b/tests/Unit/ChildWorkflowTest.php @@ -29,7 +29,7 @@ public function testHandleReleasesWhenParentWorkflowIsRunning(): void 'arguments' => Serializer::serialize([]), ]); - $job = new ChildWorkflow(0, now() ->toDateTimeString(), $storedChild, true, $storedParent); + $job = new ChildWorkflow(0, now()->toDateTimeString(), $storedChild, true, $storedParent); $job->handle(); From 9fe4b9c20bc847e0f18149b36cbe892aae0f47d9 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Thu, 12 Feb 2026 17:56:59 -0600 Subject: [PATCH 6/8] Coverage --- tests/Unit/ChildWorkflowStubTest.php | 32 ++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/Unit/ChildWorkflowStubTest.php b/tests/Unit/ChildWorkflowStubTest.php index 61d51fdd..6634391c 100644 --- a/tests/Unit/ChildWorkflowStubTest.php +++ b/tests/Unit/ChildWorkflowStubTest.php @@ -11,6 +11,7 @@ use Workflow\Models\StoredWorkflow; use Workflow\Serializers\Serializer; use Workflow\States\WorkflowPendingStatus; +use Workflow\States\WorkflowRunningStatus; use Workflow\WorkflowStub; final class ChildWorkflowStubTest extends TestCase @@ -89,6 +90,37 @@ public function testLoadsChildWorkflow(): void $this->assertNull($result); } + public function testIgnoresTransitionNotFoundWhenExistingChildIsAlreadyRunning(): void + { + $workflow = WorkflowStub::load(WorkflowStub::make(TestParentWorkflow::class)->id()); + $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); + $storedWorkflow->update([ + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowPendingStatus::$name, + ]); + + $childWorkflow = WorkflowStub::load(WorkflowStub::make(TestChildWorkflow::class)->id()); + $storedChildWorkflow = StoredWorkflow::findOrFail($childWorkflow->id()); + $storedChildWorkflow->update([ + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowRunningStatus::class, + ]); + $storedChildWorkflow->parents() + ->attach($storedWorkflow, [ + 'parent_index' => 0, + 'parent_now' => now(), + ]); + + ChildWorkflowStub::make(TestChildWorkflow::class) + ->then(static function ($value) use (&$result) { + $result = $value; + }); + + $this->assertNull($result); + $this->assertSame(WorkflowRunningStatus::class, $storedChildWorkflow->refresh()->status::class); + $this->assertSame(1, WorkflowStub::getContext()->index); + } + public function testAll(): void { $workflow = WorkflowStub::load(WorkflowStub::make(TestParentWorkflow::class)->id()); From f1420e750e23b13919041395b283157288bfecee Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Thu, 12 Feb 2026 18:29:32 -0600 Subject: [PATCH 7/8] Coverage --- tests/Unit/ChildWorkflowStubTest.php | 92 ++++++++++++++++++++-------- 1 file changed, 67 insertions(+), 25 deletions(-) diff --git a/tests/Unit/ChildWorkflowStubTest.php b/tests/Unit/ChildWorkflowStubTest.php index 6634391c..de37cefb 100644 --- a/tests/Unit/ChildWorkflowStubTest.php +++ b/tests/Unit/ChildWorkflowStubTest.php @@ -4,6 +4,8 @@ namespace Tests\Unit; +use Mockery; +use Spatie\ModelStates\Exceptions\TransitionNotFound; use Tests\Fixtures\TestChildWorkflow; use Tests\Fixtures\TestParentWorkflow; use Tests\TestCase; @@ -11,7 +13,6 @@ use Workflow\Models\StoredWorkflow; use Workflow\Serializers\Serializer; use Workflow\States\WorkflowPendingStatus; -use Workflow\States\WorkflowRunningStatus; use Workflow\WorkflowStub; final class ChildWorkflowStubTest extends TestCase @@ -90,34 +91,75 @@ public function testLoadsChildWorkflow(): void $this->assertNull($result); } - public function testIgnoresTransitionNotFoundWhenExistingChildIsAlreadyRunning(): void + public function testIgnoresTransitionNotFoundWhenChildResumeThrows(): void { - $workflow = WorkflowStub::load(WorkflowStub::make(TestParentWorkflow::class)->id()); - $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); - $storedWorkflow->update([ - 'arguments' => Serializer::serialize([]), - 'status' => WorkflowPendingStatus::$name, + $logs = Mockery::mock(); + $logs->shouldReceive('whereIndex') + ->once() + ->with(0) + ->andReturnSelf(); + $logs->shouldReceive('first') + ->once() + ->andReturn(null); + + $childWorkflow = new class + { + public function running(): bool + { + return true; + } + + public function created(): bool + { + return false; + } + + public function resume(): void + { + throw TransitionNotFound::make('running', 'pending', StoredWorkflow::class); + } + + public function completed(): bool + { + return false; + } + + public function startAsChild(...$arguments): void + { + } + }; + + $storedChildWorkflow = Mockery::mock(); + $storedChildWorkflow->shouldReceive('toWorkflow') + ->once() + ->andReturn($childWorkflow); + + $children = Mockery::mock(); + $children->shouldReceive('wherePivot') + ->once() + ->with('parent_index', 0) + ->andReturnSelf(); + $children->shouldReceive('first') + ->once() + ->andReturn($storedChildWorkflow); + + $storedWorkflow = Mockery::mock(); + $storedWorkflow->shouldReceive('logs') + ->once() + ->andReturn($logs); + $storedWorkflow->shouldReceive('children') + ->once() + ->andReturn($children); + + WorkflowStub::setContext([ + 'storedWorkflow' => $storedWorkflow, + 'index' => 0, + 'now' => now(), + 'replaying' => false, ]); - $childWorkflow = WorkflowStub::load(WorkflowStub::make(TestChildWorkflow::class)->id()); - $storedChildWorkflow = StoredWorkflow::findOrFail($childWorkflow->id()); - $storedChildWorkflow->update([ - 'arguments' => Serializer::serialize([]), - 'status' => WorkflowRunningStatus::class, - ]); - $storedChildWorkflow->parents() - ->attach($storedWorkflow, [ - 'parent_index' => 0, - 'parent_now' => now(), - ]); - - ChildWorkflowStub::make(TestChildWorkflow::class) - ->then(static function ($value) use (&$result) { - $result = $value; - }); + ChildWorkflowStub::make(TestChildWorkflow::class); - $this->assertNull($result); - $this->assertSame(WorkflowRunningStatus::class, $storedChildWorkflow->refresh()->status::class); $this->assertSame(1, WorkflowStub::getContext()->index); } From 1aed41ba61e56bfa76eb61246cb7ce4bee8d144b Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Thu, 12 Feb 2026 18:32:34 -0600 Subject: [PATCH 8/8] Cleanup --- tests/Unit/ChildWorkflowStubTest.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Unit/ChildWorkflowStubTest.php b/tests/Unit/ChildWorkflowStubTest.php index de37cefb..f3d90dc3 100644 --- a/tests/Unit/ChildWorkflowStubTest.php +++ b/tests/Unit/ChildWorkflowStubTest.php @@ -102,8 +102,7 @@ public function testIgnoresTransitionNotFoundWhenChildResumeThrows(): void ->once() ->andReturn(null); - $childWorkflow = new class - { + $childWorkflow = new class() { public function running(): bool { return true;