Skip to content

Commit fad469b

Browse files
authored
Child workflow signaling (#290)
1 parent 57b9c08 commit fad469b

9 files changed

Lines changed: 637 additions & 0 deletions

src/ChildWorkflowHandle.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Workflow;
6+
7+
use Workflow\Models\StoredWorkflow;
8+
9+
final class ChildWorkflowHandle
10+
{
11+
public function __construct(
12+
public readonly StoredWorkflow $storedWorkflow
13+
) {
14+
}
15+
16+
public function __call($method, $arguments)
17+
{
18+
$context = WorkflowStub::getContext();
19+
20+
if ($context->replaying) {
21+
return;
22+
}
23+
24+
$savedContext = [
25+
'storedWorkflow' => $context->storedWorkflow,
26+
'index' => $context->index,
27+
'now' => $context->now,
28+
'replaying' => $context->replaying,
29+
];
30+
31+
try {
32+
WorkflowStub::fromStoredWorkflow($this->storedWorkflow)->{$method}(...$arguments);
33+
} finally {
34+
WorkflowStub::setContext($savedContext);
35+
}
36+
}
37+
38+
public function id(): int
39+
{
40+
return $this->storedWorkflow->id;
41+
}
42+
}

src/Workflow.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,26 @@ public function query($method)
8686
return $this->{$method}();
8787
}
8888

89+
public function child(): ?ChildWorkflowHandle
90+
{
91+
$storedChild = $this->storedWorkflow->children()
92+
->wherePivot('parent_index', '<', WorkflowStub::getContext()->index)
93+
->orderByDesc('child_workflow_id')
94+
->first();
95+
96+
return $storedChild ? new ChildWorkflowHandle($storedChild) : null;
97+
}
98+
99+
public function children(): array
100+
{
101+
return $this->storedWorkflow->children()
102+
->wherePivot('parent_index', '<', WorkflowStub::getContext()->index)
103+
->orderByDesc('child_workflow_id')
104+
->get()
105+
->map(static fn ($child) => new ChildWorkflowHandle($child))
106+
->toArray();
107+
}
108+
89109
public function middleware()
90110
{
91111
$parentWorkflow = $this->storedWorkflow->parents()
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Feature;
6+
7+
use Tests\Fixtures\TestParentSignalingChildViaSignal;
8+
use Tests\Fixtures\TestParentWorkflowSignalingChildDirectly;
9+
use Tests\Fixtures\TestParentWorkflowWithContextCheck;
10+
use Tests\Fixtures\TestParentWorkflowWithMultipleChildren;
11+
use Tests\TestCase;
12+
use Workflow\States\WorkflowCompletedStatus;
13+
use Workflow\WorkflowStub;
14+
15+
final class ChildWorkflowSignalingTest extends TestCase
16+
{
17+
public function testParentCanSignalChildDirectly(): void
18+
{
19+
$parentWorkflow = WorkflowStub::make(TestParentWorkflowSignalingChildDirectly::class);
20+
$parentWorkflow->start();
21+
22+
while ($parentWorkflow->running());
23+
24+
$this->assertSame(WorkflowCompletedStatus::class, $parentWorkflow->status());
25+
$this->assertSame('direct_signaling_approved', $parentWorkflow->output());
26+
}
27+
28+
public function testParentContextNotCorruptedByChildSignaling(): void
29+
{
30+
$parentWorkflow = WorkflowStub::make(TestParentWorkflowWithContextCheck::class);
31+
$parentWorkflow->start();
32+
33+
while ($parentWorkflow->running());
34+
35+
$this->assertSame(WorkflowCompletedStatus::class, $parentWorkflow->status());
36+
$this->assertSame('success', $parentWorkflow->output());
37+
}
38+
39+
public function testParentSignalMethodForwardsToChild(): void
40+
{
41+
$parentWorkflow = WorkflowStub::make(TestParentSignalingChildViaSignal::class);
42+
$parentWorkflow->start();
43+
44+
sleep(3);
45+
46+
$parentWorkflow->forwardApproval('approved');
47+
48+
$timeout = 10;
49+
while ($parentWorkflow->running() && $timeout-- > 0) {
50+
sleep(1);
51+
}
52+
53+
$this->assertSame(WorkflowCompletedStatus::class, $parentWorkflow->status());
54+
$this->assertSame('forwarded_approved', $parentWorkflow->output());
55+
}
56+
57+
public function testChildrenReturnsMultipleHandlesInOrder(): void
58+
{
59+
$parentWorkflow = WorkflowStub::make(TestParentWorkflowWithMultipleChildren::class);
60+
$parentWorkflow->start();
61+
62+
while ($parentWorkflow->running());
63+
64+
$this->assertSame(WorkflowCompletedStatus::class, $parentWorkflow->status());
65+
$this->assertSame('child1_first|child2_second|child3_third', $parentWorkflow->output());
66+
}
67+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Fixtures;
6+
7+
use Workflow\ChildWorkflowStub;
8+
use Workflow\SignalMethod;
9+
use Workflow\Workflow;
10+
use Workflow\WorkflowStub;
11+
12+
class TestParentSignalingChildViaSignal extends Workflow
13+
{
14+
private bool $receivedSignal = false;
15+
16+
private ?string $signalStatus = null;
17+
18+
#[SignalMethod]
19+
public function forwardApproval(string $status): void
20+
{
21+
$this->receivedSignal = true;
22+
$this->signalStatus = $status;
23+
}
24+
25+
public function execute()
26+
{
27+
$childPromise = ChildWorkflowStub::make(TestSimpleChildWorkflowWithSignal::class, 'forwarded');
28+
29+
$childHandle = $this->child();
30+
31+
yield WorkflowStub::await(fn () => $this->receivedSignal);
32+
33+
if ($childHandle && $this->signalStatus) {
34+
$childHandle->approve($this->signalStatus);
35+
}
36+
37+
$result = yield $childPromise;
38+
39+
return $result;
40+
}
41+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Fixtures;
6+
7+
use Workflow\ChildWorkflowStub;
8+
use Workflow\Workflow;
9+
10+
final class TestParentWorkflowSignalingChildDirectly extends Workflow
11+
{
12+
public function execute()
13+
{
14+
$childPromise = ChildWorkflowStub::make(TestSimpleChildWorkflowWithSignal::class, 'direct_signaling');
15+
16+
$childHandle = $this->child();
17+
18+
$childHandle->approve('approved');
19+
20+
$result = yield $childPromise;
21+
22+
return $result;
23+
}
24+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Fixtures;
6+
7+
use Workflow\ActivityStub;
8+
use Workflow\ChildWorkflowStub;
9+
use Workflow\Workflow;
10+
use Workflow\WorkflowStub;
11+
12+
final class TestParentWorkflowWithContextCheck extends Workflow
13+
{
14+
public function execute()
15+
{
16+
$childPromise = ChildWorkflowStub::make(TestSimpleChildWorkflowWithSignal::class, 'context_check');
17+
18+
$contextBefore = WorkflowStub::getContext();
19+
$indexBefore = $contextBefore->index;
20+
$nowBefore = $contextBefore->now;
21+
$storedWorkflowBefore = $contextBefore->storedWorkflow->id;
22+
23+
$childHandle = $this->child();
24+
$childHandle->approve('approved');
25+
26+
$contextAfter = WorkflowStub::getContext();
27+
$indexAfter = $contextAfter->index;
28+
$nowAfter = $contextAfter->now;
29+
$storedWorkflowAfter = $contextAfter->storedWorkflow->id;
30+
31+
if ($indexBefore !== $indexAfter) {
32+
return 'context_corrupted:index:' . $indexBefore . ':' . $indexAfter;
33+
}
34+
35+
if ($nowBefore->timestamp !== $nowAfter->timestamp) {
36+
return 'context_corrupted:now:' . $nowBefore->timestamp . ':' . $nowAfter->timestamp;
37+
}
38+
39+
if ($storedWorkflowBefore !== $storedWorkflowAfter) {
40+
return 'context_corrupted:workflow:' . $storedWorkflowBefore . ':' . $storedWorkflowAfter;
41+
}
42+
43+
yield ActivityStub::make(TestActivity::class);
44+
45+
$result = yield $childPromise;
46+
47+
return 'success';
48+
}
49+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Fixtures;
6+
7+
use Workflow\ChildWorkflowStub;
8+
use Workflow\Workflow;
9+
10+
final class TestParentWorkflowWithMultipleChildren extends Workflow
11+
{
12+
public function execute()
13+
{
14+
$child1Promise = ChildWorkflowStub::make(TestSimpleChildWorkflowWithSignal::class, 'child1');
15+
$child1Handle = $this->child();
16+
$child1Handle->approve('first');
17+
18+
$child2Promise = ChildWorkflowStub::make(TestSimpleChildWorkflowWithSignal::class, 'child2');
19+
$child2Handle = $this->child();
20+
$child2Handle->approve('second');
21+
22+
$child3Promise = ChildWorkflowStub::make(TestSimpleChildWorkflowWithSignal::class, 'child3');
23+
$child3Handle = $this->child();
24+
$child3Handle->approve('third');
25+
26+
$allChildHandles = $this->children();
27+
28+
if (count($allChildHandles) !== 3) {
29+
return 'wrong_child_count:' . count($allChildHandles);
30+
}
31+
32+
$child3Id = $allChildHandles[0]->id();
33+
$child2Id = $allChildHandles[1]->id();
34+
$child1Id = $allChildHandles[2]->id();
35+
36+
if (! ($child3Id > $child2Id && $child2Id > $child1Id)) {
37+
return "wrong_order:{$child1Id},{$child2Id},{$child3Id}";
38+
}
39+
40+
$results = yield ChildWorkflowStub::all([$child1Promise, $child2Promise, $child3Promise]);
41+
42+
return implode('|', $results);
43+
}
44+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Fixtures;
6+
7+
use Workflow\SignalMethod;
8+
use Workflow\Workflow;
9+
use Workflow\WorkflowStub;
10+
11+
class TestSimpleChildWorkflowWithSignal extends Workflow
12+
{
13+
private ?string $approved = null;
14+
15+
#[SignalMethod]
16+
public function approve(string $status): void
17+
{
18+
$this->approved = $status;
19+
}
20+
21+
public function execute(string $prefix)
22+
{
23+
yield WorkflowStub::await(fn () => $this->approved !== null);
24+
25+
return $prefix . '_' . $this->approved;
26+
}
27+
}

0 commit comments

Comments
 (0)