Skip to content

Commit 3825fbd

Browse files
authored
Child exceptions (#348)
1 parent 1542cda commit 3825fbd

15 files changed

Lines changed: 448 additions & 7 deletions

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Quality Cycle
2+
3+
Run these commands in order before considering any change complete:
4+
5+
1. `composer ecs` — Fix code style (auto-fixes)
6+
2. `composer stan` — Static analysis (must pass with no errors)
7+
3. `composer unit` — Unit tests (must all pass)
8+
4. `composer coverage` — Unit tests with coverage (must be 100%)
9+
5. `composer feature` — Feature tests (must all pass)

src/ChildWorkflowStub.php

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
use React\Promise\Deferred;
99
use React\Promise\PromiseInterface;
1010
use function React\Promise\resolve;
11+
use RuntimeException;
12+
use Throwable;
1113
use Workflow\Exceptions\TransitionNotFound;
1214
use Workflow\Serializers\Serializer;
1315

@@ -46,7 +48,24 @@ public static function make($workflow, ...$arguments): PromiseInterface
4648
if ($log) {
4749
++$context->index;
4850
WorkflowStub::setContext($context);
49-
return resolve(Serializer::unserialize($log->result));
51+
$result = Serializer::unserialize($log->result);
52+
if (
53+
is_array($result)
54+
&& array_key_exists('class', $result)
55+
&& is_subclass_of($result['class'], Throwable::class)
56+
) {
57+
try {
58+
$throwable = new $result['class']($result['message'] ?? '', (int) ($result['code'] ?? 0));
59+
} catch (Throwable $throwable) {
60+
throw new RuntimeException(
61+
sprintf('[%s] %s', $result['class'], (string) ($result['message'] ?? '')),
62+
(int) ($result['code'] ?? 0),
63+
$throwable
64+
);
65+
}
66+
throw $throwable;
67+
}
68+
return resolve($result);
5069
}
5170

5271
if (! $context->replaying) {

src/Exception.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ public function handle()
5353
try {
5454
if ($this->storedWorkflow->hasLogByIndex($this->index)) {
5555
$workflow->resume();
56-
} else {
56+
} elseif (! $this->storedWorkflow->logs()->where('class', self::class)->exists()) {
5757
$workflow->next($this->index, $this->now, self::class, $this->exception);
5858
}
5959
} catch (TransitionNotFound) {

src/WorkflowStub.php

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,12 +288,44 @@ public function fail($exception): void
288288

289289
$this->storedWorkflow->parents()
290290
->each(static function ($parentWorkflow) use ($exception) {
291-
try {
292-
$parentWorkflow->toWorkflow()
293-
->fail($exception);
294-
} catch (TransitionNotFound) {
291+
if (
292+
$parentWorkflow->pivot->parent_index === StoredWorkflow::CONTINUE_PARENT_INDEX
293+
|| $parentWorkflow->pivot->parent_index === StoredWorkflow::ACTIVE_WORKFLOW_INDEX
294+
) {
295+
try {
296+
$parentWorkflow->toWorkflow()
297+
->fail($exception);
298+
} catch (TransitionNotFound) {
299+
return;
300+
}
295301
return;
296302
}
303+
304+
$file = new SplFileObject($exception->getFile());
305+
$iterator = new LimitIterator($file, max(0, $exception->getLine() - 4), 7);
306+
307+
$throwable = [
308+
'class' => get_class($exception),
309+
'message' => $exception->getMessage(),
310+
'code' => $exception->getCode(),
311+
'line' => $exception->getLine(),
312+
'file' => $exception->getFile(),
313+
'trace' => collect($exception->getTrace())
314+
->filter(static fn ($trace) => Serializer::serializable($trace))
315+
->toArray(),
316+
'snippet' => array_slice(iterator_to_array($iterator), 0, 7),
317+
];
318+
319+
$parentWf = $parentWorkflow->toWorkflow();
320+
321+
Exception::dispatch(
322+
$parentWorkflow->pivot->parent_index,
323+
$parentWorkflow->pivot->parent_now,
324+
$parentWorkflow,
325+
$throwable,
326+
$parentWf->connection(),
327+
$parentWf->queue()
328+
);
297329
});
298330
}
299331

tests/Feature/ParentWorkflowTest.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,18 @@ public function testRetry(): void
5353
$storedWorkflow = StoredWorkflow::findOrFail($workflow->id());
5454
$storedWorkflow->status = WorkflowCreatedStatus::class;
5555
$storedWorkflow->save();
56+
$storedWorkflow->logs()
57+
->delete();
58+
$storedWorkflow->exceptions()
59+
->delete();
5660

5761
$storedChildWorkflow = StoredWorkflow::findOrFail($workflow->id() + 1);
5862
$storedChildWorkflow->status = WorkflowCreatedStatus::class;
5963
$storedChildWorkflow->save();
64+
$storedChildWorkflow->logs()
65+
->delete();
66+
$storedChildWorkflow->exceptions()
67+
->delete();
6068

6169
$workflow->fresh()
6270
->start(shouldThrow: false);
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Feature;
6+
7+
use Tests\Fixtures\TestSagaChildWorkflow;
8+
use Tests\Fixtures\TestSagaSingleChildWorkflow;
9+
use Tests\TestCase;
10+
use Workflow\States\WorkflowCompletedStatus;
11+
use Workflow\WorkflowStub;
12+
13+
final class SagaChildWorkflowTest extends TestCase
14+
{
15+
public function testSingleChildExceptionTriggersCompensation(): void
16+
{
17+
$workflow = WorkflowStub::make(TestSagaSingleChildWorkflow::class);
18+
19+
$workflow->start();
20+
21+
while ($workflow->running());
22+
23+
$this->assertSame(WorkflowCompletedStatus::class, $workflow->status());
24+
$this->assertSame('compensated', $workflow->output());
25+
}
26+
27+
public function testParallelChildExceptionsTriggersCompensation(): void
28+
{
29+
$workflow = WorkflowStub::make(TestSagaChildWorkflow::class);
30+
31+
$workflow->start();
32+
33+
while ($workflow->running());
34+
35+
$this->assertSame(WorkflowCompletedStatus::class, $workflow->status());
36+
$this->assertSame('compensated', $workflow->output());
37+
}
38+
}

tests/Feature/SagaWorkflowTest.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Tests\Feature;
66

77
use Tests\Fixtures\TestActivity;
8+
use Tests\Fixtures\TestSagaParallelActivityWorkflow;
89
use Tests\Fixtures\TestSagaWorkflow;
910
use Tests\Fixtures\TestUndoActivity;
1011
use Tests\TestCase;
@@ -48,4 +49,16 @@ public function testFailed(): void
4849
->values()
4950
->toArray());
5051
}
52+
53+
public function testParallelActivityExceptionsTriggersCompensation(): void
54+
{
55+
$workflow = WorkflowStub::make(TestSagaParallelActivityWorkflow::class);
56+
57+
$workflow->start();
58+
59+
while ($workflow->running());
60+
61+
$this->assertSame(WorkflowCompletedStatus::class, $workflow->status());
62+
$this->assertSame('compensated', $workflow->output());
63+
}
5164
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Fixtures;
6+
7+
use Exception;
8+
use Workflow\Workflow;
9+
10+
class TestChildExceptionThrowingWorkflow extends Workflow
11+
{
12+
public function execute()
13+
{
14+
throw new Exception('child failed');
15+
}
16+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Fixtures;
6+
7+
use Exception;
8+
9+
class TestRequiredArgException extends Exception
10+
{
11+
public function __construct(
12+
string $message,
13+
int $code,
14+
private readonly string $requiredContext,
15+
) {
16+
parent::__construct($message, $code);
17+
}
18+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Fixtures;
6+
7+
use Workflow\Workflow;
8+
use function Workflow\{activity, all, child};
9+
10+
class TestSagaChildWorkflow extends Workflow
11+
{
12+
public function execute()
13+
{
14+
try {
15+
yield activity(TestActivity::class);
16+
$this->addCompensation(static fn () => activity(TestUndoActivity::class));
17+
18+
$children = [
19+
child(TestChildExceptionThrowingWorkflow::class),
20+
child(TestChildExceptionThrowingWorkflow::class),
21+
child(TestChildExceptionThrowingWorkflow::class),
22+
];
23+
24+
yield all($children);
25+
26+
return 'success';
27+
} catch (\Throwable $th) {
28+
yield from $this->compensate();
29+
30+
return 'compensated';
31+
}
32+
}
33+
}

0 commit comments

Comments
 (0)