Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ coverage.xml
.phpunit.result.cache
.php_cs.cache
.php-cs-fixer.cache
.vs
10 changes: 9 additions & 1 deletion src/WorkflowStub.php
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,15 @@ private function dispatch(): void
);
}

$this->storedWorkflow->status->transitionTo(WorkflowPendingStatus::class);
try {
$this->storedWorkflow->status->transitionTo(WorkflowPendingStatus::class);
} catch (\Spatie\ModelStates\Exceptions\TransitionNotFound $exception) {
$this->storedWorkflow->refresh();

if ($this->status() !== WorkflowPendingStatus::class) {
throw $exception;
}
}

$dispatch = static::faked() ? 'dispatchSync' : 'dispatch';

Expand Down
3 changes: 3 additions & 0 deletions tests/.env.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
76 changes: 76 additions & 0 deletions tests/Feature/NestedSignalRaceConditionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

namespace Tests\Feature;

use RuntimeException;
use Tests\Fixtures\TestNestedSignalLeafWorkflow;
use Tests\Fixtures\TestNestedSignalParentWorkflow;
use Tests\TestCase;
use Workflow\Models\StoredWorkflow;
use Workflow\States\WorkflowCompletedStatus;
use Workflow\WorkflowStub;

final class NestedSignalRaceConditionTest extends TestCase
{
public function testNestedChildWorkflowsWithDuplicateSignalsDoNotGetStuckPending(): void
{
$runId = (int) now()
->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());
}
}
33 changes: 33 additions & 0 deletions tests/Fixtures/TestNestedSignalLeafWorkflow.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Tests\Fixtures;

use Generator;
use function Workflow\awaitWithTimeout;
use Workflow\SignalMethod;
use Workflow\Workflow;

final class TestNestedSignalLeafWorkflow extends Workflow
{
private bool $responded = false;

#[SignalMethod]
public function respond(): void
{
$this->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,
];
}
}
29 changes: 29 additions & 0 deletions tests/Fixtures/TestNestedSignalMiddleWorkflow.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Tests\Fixtures;

use Generator;
use function Workflow\all;
use function Workflow\child;
use Workflow\Workflow;

final class TestNestedSignalMiddleWorkflow extends Workflow
{
public function execute(int $runId, int $middleIndex, int $leafCount = 3): Generator
{
$promises = [];

for ($leafIndex = 0; $leafIndex < $leafCount; $leafIndex++) {
$promises[] = child(TestNestedSignalLeafWorkflow::class, $runId, $middleIndex, $leafIndex);
}

$results = yield all($promises);

return count(array_filter(
$results,
static fn (array $result): bool => (bool) ($result['resolved'] ?? false)
));
}
}
31 changes: 31 additions & 0 deletions tests/Fixtures/TestNestedSignalParentWorkflow.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Tests\Fixtures;

use Generator;
use function Workflow\all;
use function Workflow\child;
use Workflow\Workflow;

final class TestNestedSignalParentWorkflow extends Workflow
{
public function execute(int $runId, int $middleCount = 10, int $leafCount = 3): Generator
{
$promises = [];

for ($middleIndex = 0; $middleIndex < $middleCount; $middleIndex++) {
$promises[] = child(TestNestedSignalMiddleWorkflow::class, $runId, $middleIndex, $leafCount);
}

$resolvedPerMiddle = yield all($promises);

return [
'run_id' => $runId,
'middle_count' => $middleCount,
'leaf_count' => $leafCount,
'resolved_leaf_count' => array_sum($resolvedPerMiddle),
];
}
}
73 changes: 73 additions & 0 deletions tests/Unit/ChildWorkflowStubTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -89,6 +91,77 @@ public function testLoadsChildWorkflow(): void
$this->assertNull($result);
}

public function testIgnoresTransitionNotFoundWhenChildResumeThrows(): void
{
$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,
]);

ChildWorkflowStub::make(TestChildWorkflow::class);

$this->assertSame(1, WorkflowStub::getContext()->index);
}

public function testAll(): void
{
$workflow = WorkflowStub::load(WorkflowStub::make(TestParentWorkflow::class)->id());
Expand Down
39 changes: 39 additions & 0 deletions tests/Unit/ChildWorkflowTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Tests\Unit;

use Tests\Fixtures\TestChildWorkflow;
use Tests\Fixtures\TestWorkflow;
use Tests\TestCase;
use Workflow\ChildWorkflow;
use Workflow\Models\StoredWorkflow;
use Workflow\Serializers\Serializer;
use Workflow\States\WorkflowRunningStatus;
use Workflow\WorkflowStub;

final class ChildWorkflowTest extends TestCase
{
public function testHandleReleasesWhenParentWorkflowIsRunning(): void
{
$parent = WorkflowStub::make(TestWorkflow::class);
$storedParent = StoredWorkflow::findOrFail($parent->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);
}
}
17 changes: 17 additions & 0 deletions tests/Unit/WorkflowStubTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down