Skip to content

Commit 0ad14d9

Browse files
authored
Merge pull request #751 from patchlevel/instant-retry
add instant retry command bus
2 parents 3245727 + 72052f8 commit 0ad14d9

File tree

6 files changed

+312
-16
lines changed

6 files changed

+312
-16
lines changed

docs/pages/command_bus.md

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -231,30 +231,30 @@ $commandBus = new SyncCommandBus($handlerProvider);
231231
$commandBus->dispatch(new CreateProfile($profileId, 'name'));
232232
$commandBus->dispatch(new ChangeProfileName($profileId, 'new name'));
233233
```
234-
### Retry Outdated Aggregate
234+
### Instant Retry
235235

236-
If you want to retry the command when an `AggregateOutdated` exception occurs,
237-
you can use the `RetryOutdatedAggregateCommandBus` decorator.
236+
If you want to retry the command when defined exceptions occur,
237+
you can use the `InstantRetryCommandBus` command bus decorator.
238238

239239
```php
240-
use Patchlevel\EventSourcing\CommandBus;
241-
use Patchlevel\EventSourcing\CommandBus\RetryOutdatedAggregateCommandBus;
240+
use Patchlevel\EventSourcing\CommandBus\CommandBus;
241+
use Patchlevel\EventSourcing\CommandBus\InstantRetryCommandBus;
242+
use Patchlevel\EventSourcing\Repository\AggregateOutdated;
242243

243-
/**
244-
* @var HandlerProvider $handlerProvider
245-
* @var CommandBus $store
246-
*/
247-
$commandBus = new RetryOutdatedAggregateCommandBus(
244+
/** @var CommandBus $store */
245+
$commandBus = new InstantRetryCommandBus(
248246
$commandBus,
247+
3, // maximum number of retries, default is 3
248+
[AggregateOutdated::class], // exceptions to retry, default is [AggregateOutdated::class]
249249
);
250250
```
251-
And you need to mark the command class with the `#[RetryAggregateOutdated]` attribute,
252-
if you want to retry the command when an `AggregateOutdated` exception occurs.
251+
After that, you need to mark the command class with the `#[InstantRetry]` attribute,
252+
to indicate that the command should be retried when the condition is met.
253253

254254
```php
255-
use Patchlevel\EventSourcing\Attribute\RetryAggregateOutdated;
255+
use Patchlevel\EventSourcing\Attribute\InstantRetry;
256256

257-
#[RetryAggregateOutdated]
257+
#[InstantRetry]
258258
final class CreateProfile
259259
{
260260
public function __construct(
@@ -266,8 +266,17 @@ final class CreateProfile
266266
```
267267
!!! tip
268268

269-
You can specify the maximum number of retries in the `#[RetryAggregateOutdated]` attribute.
270-
The default value is 3.
269+
You can override the default values for the maximum number of retries and the conditions
270+
by passing them to the `InstantRetry` attribute.
271+
272+
```php
273+
use Patchlevel\EventSourcing\Attribute\InstantRetry;
274+
275+
#[InstantRetry(3, [AggregateOutdated::class])]
276+
final class CreateProfile
277+
{
278+
}
279+
```
271280

272281
## Provider
273282

src/Attribute/InstantRetry.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\Attribute;
6+
7+
use Attribute;
8+
use Throwable;
9+
10+
#[Attribute(Attribute::TARGET_CLASS)]
11+
final class InstantRetry
12+
{
13+
/**
14+
* @param positive-int|null $maxRetries
15+
* @param list<class-string<Throwable>>|null $exceptions
16+
*/
17+
public function __construct(
18+
public readonly int|null $maxRetries = null,
19+
public readonly array|null $exceptions = null,
20+
) {
21+
}
22+
}

src/Attribute/RetryAggregateOutdated.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Attribute;
88

9+
/** @deprecated use InstantRetry instead. */
910
#[Attribute(Attribute::TARGET_CLASS)]
1011
final class RetryAggregateOutdated
1112
{
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\CommandBus;
6+
7+
use Patchlevel\EventSourcing\Attribute\InstantRetry;
8+
use Patchlevel\EventSourcing\Repository\AggregateOutdated;
9+
use ReflectionClass;
10+
use Throwable;
11+
12+
use function in_array;
13+
14+
final class InstantRetryCommandBus implements CommandBus
15+
{
16+
/**
17+
* @param positive-int $defaultMaxRetries
18+
* @param list<class-string<Throwable>> $defaultExceptions
19+
*/
20+
public function __construct(
21+
private readonly CommandBus $commandBus,
22+
private readonly int $defaultMaxRetries = 3,
23+
private readonly array $defaultExceptions = [AggregateOutdated::class],
24+
) {
25+
}
26+
27+
public function dispatch(object $command): void
28+
{
29+
$this->doDispatch($command, 0);
30+
}
31+
32+
private function doDispatch(object $command, int $retry): void
33+
{
34+
try {
35+
$this->commandBus->dispatch($command);
36+
} catch (Throwable $exception) {
37+
$configuration = $this->configuration($command);
38+
39+
if ($configuration === null) {
40+
throw $exception;
41+
}
42+
43+
$exceptions = $configuration->exceptions ?? $this->defaultExceptions;
44+
$maxRetries = $configuration->maxRetries ?? $this->defaultMaxRetries;
45+
46+
if ($retry >= $maxRetries || !in_array($exception::class, $exceptions, true)) {
47+
throw $exception;
48+
}
49+
50+
$this->doDispatch($command, $retry + 1);
51+
}
52+
}
53+
54+
private function configuration(object $command): InstantRetry|null
55+
{
56+
$reflectionClass = new ReflectionClass($command);
57+
$attributes = $reflectionClass->getAttributes(InstantRetry::class);
58+
59+
if ($attributes === []) {
60+
return null;
61+
}
62+
63+
return $attributes[0]->newInstance();
64+
}
65+
}

src/CommandBus/RetryOutdatedAggregateCommandBus.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Patchlevel\EventSourcing\Repository\AggregateOutdated;
99
use ReflectionClass;
1010

11+
/** @deprecated use RetryCommandBus instead */
1112
final class RetryOutdatedAggregateCommandBus implements CommandBus
1213
{
1314
public function __construct(
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\Tests\Unit\CommandBus;
6+
7+
use Patchlevel\EventSourcing\Attribute\InstantRetry;
8+
use Patchlevel\EventSourcing\CommandBus\CommandBus;
9+
use Patchlevel\EventSourcing\CommandBus\InstantRetryCommandBus;
10+
use Patchlevel\EventSourcing\Repository\AggregateOutdated;
11+
use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId;
12+
use PHPUnit\Framework\TestCase;
13+
use RuntimeException;
14+
15+
final class InstantRetryCommandBusTest extends TestCase
16+
{
17+
public function testSuccess(): void
18+
{
19+
$command = new #[InstantRetry]
20+
class {
21+
public function __construct()
22+
{
23+
}
24+
};
25+
26+
$innerCommandBus = $this->createMock(CommandBus::class);
27+
$innerCommandBus
28+
->expects($this->once())
29+
->method('dispatch')
30+
->with($command);
31+
32+
$retryCommandBus = new InstantRetryCommandBus($innerCommandBus);
33+
34+
$retryCommandBus->dispatch($command);
35+
36+
$this->assertTrue(true); // If no exception is thrown, the test passes
37+
}
38+
39+
public function testDispatchRetriesUntilSuccess(): void
40+
{
41+
$command = new #[InstantRetry]
42+
class {
43+
public function __construct()
44+
{
45+
}
46+
};
47+
48+
$innerCommandBus = $this->createMock(CommandBus::class);
49+
$innerCommandBus
50+
->expects($this->exactly(3))
51+
->method('dispatch')
52+
->with($command)
53+
->willReturnOnConsecutiveCalls(
54+
$this->throwException(new AggregateOutdated('profile', ProfileId::fromString('profile'))),
55+
$this->throwException(new AggregateOutdated('profile', ProfileId::fromString('profile'))),
56+
null, // Success on the third attempt
57+
);
58+
59+
$retryCommandBus = new InstantRetryCommandBus($innerCommandBus);
60+
61+
$retryCommandBus->dispatch($command);
62+
63+
$this->assertTrue(true); // If no exception is thrown, the test passes
64+
}
65+
66+
public function testDispatchThrowsAfterMaxRetries(): void
67+
{
68+
$command = new #[InstantRetry]
69+
class {
70+
public function __construct()
71+
{
72+
}
73+
};
74+
75+
$innerCommandBus = $this->createMock(CommandBus::class);
76+
$innerCommandBus
77+
->expects($this->exactly(4))
78+
->method('dispatch')
79+
->with($command)
80+
->willThrowException(
81+
new AggregateOutdated(
82+
'profile',
83+
ProfileId::fromString('profile'),
84+
),
85+
);
86+
87+
$retryCommandBus = new InstantRetryCommandBus($innerCommandBus);
88+
89+
$this->expectException(AggregateOutdated::class);
90+
91+
$retryCommandBus->dispatch($command);
92+
}
93+
94+
public function testDispatchThrowsAfterMaxRetriesWithOverride(): void
95+
{
96+
$command = new #[InstantRetry(maxRetries: 2)]
97+
class {
98+
public function __construct()
99+
{
100+
}
101+
};
102+
103+
$innerCommandBus = $this->createMock(CommandBus::class);
104+
$innerCommandBus
105+
->expects($this->exactly(3))
106+
->method('dispatch')
107+
->with($command)
108+
->willThrowException(
109+
new AggregateOutdated(
110+
'profile',
111+
ProfileId::fromString('profile'),
112+
),
113+
);
114+
115+
$retryCommandBus = new InstantRetryCommandBus($innerCommandBus);
116+
117+
$this->expectException(AggregateOutdated::class);
118+
119+
$retryCommandBus->dispatch($command);
120+
}
121+
122+
public function testDispatchNotRetry(): void
123+
{
124+
$command = new class {
125+
public function __construct()
126+
{
127+
}
128+
};
129+
130+
$innerCommandBus = $this->createMock(CommandBus::class);
131+
$innerCommandBus
132+
->expects($this->once())
133+
->method('dispatch')
134+
->with($command)
135+
->willThrowException(
136+
new AggregateOutdated(
137+
'profile',
138+
ProfileId::fromString('profile'),
139+
),
140+
);
141+
142+
$retryCommandBus = new InstantRetryCommandBus($innerCommandBus);
143+
144+
$this->expectException(AggregateOutdated::class);
145+
146+
$retryCommandBus->dispatch($command);
147+
}
148+
149+
public function testSkipOtherExceptions(): void
150+
{
151+
$command = new #[InstantRetry(maxRetries: 2)]
152+
class {
153+
public function __construct()
154+
{
155+
}
156+
};
157+
158+
$innerCommandBus = $this->createMock(CommandBus::class);
159+
$innerCommandBus
160+
->expects($this->once())
161+
->method('dispatch')
162+
->with($command)
163+
->willThrowException(new RuntimeException('Some other exception'));
164+
165+
$retryCommandBus = new InstantRetryCommandBus($innerCommandBus);
166+
167+
$this->expectException(RuntimeException::class);
168+
169+
$retryCommandBus->dispatch($command);
170+
}
171+
172+
public function testOverrideException(): void
173+
{
174+
$command = new #[InstantRetry(exceptions: [RuntimeException::class])]
175+
class {
176+
public function __construct()
177+
{
178+
}
179+
};
180+
181+
$innerCommandBus = $this->createMock(CommandBus::class);
182+
$innerCommandBus
183+
->expects($this->exactly(3))
184+
->method('dispatch')
185+
->with($command)
186+
->willReturnOnConsecutiveCalls(
187+
$this->throwException(new RuntimeException()),
188+
$this->throwException(new RuntimeException()),
189+
null, // Success on the third attempt
190+
);
191+
192+
$retryCommandBus = new InstantRetryCommandBus($innerCommandBus);
193+
194+
$retryCommandBus->dispatch($command);
195+
196+
$this->assertTrue(true); // If no exception is thrown, the test passes
197+
}
198+
}

0 commit comments

Comments
 (0)