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
41 changes: 25 additions & 16 deletions docs/pages/command_bus.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,30 +231,30 @@ $commandBus = new SyncCommandBus($handlerProvider);
$commandBus->dispatch(new CreateProfile($profileId, 'name'));
$commandBus->dispatch(new ChangeProfileName($profileId, 'new name'));
```
### Retry Outdated Aggregate
### Instant Retry

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

```php
use Patchlevel\EventSourcing\CommandBus;
use Patchlevel\EventSourcing\CommandBus\RetryOutdatedAggregateCommandBus;
use Patchlevel\EventSourcing\CommandBus\CommandBus;
use Patchlevel\EventSourcing\CommandBus\InstantRetryCommandBus;
use Patchlevel\EventSourcing\Repository\AggregateOutdated;

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

```php
use Patchlevel\EventSourcing\Attribute\RetryAggregateOutdated;
use Patchlevel\EventSourcing\Attribute\InstantRetry;

#[RetryAggregateOutdated]
#[InstantRetry]
final class CreateProfile
{
public function __construct(
Expand All @@ -266,8 +266,17 @@ final class CreateProfile
```
!!! tip

You can specify the maximum number of retries in the `#[RetryAggregateOutdated]` attribute.
The default value is 3.
You can override the default values for the maximum number of retries and the conditions
by passing them to the `InstantRetry` attribute.

```php
use Patchlevel\EventSourcing\Attribute\InstantRetry;

#[InstantRetry(3, [AggregateOutdated::class])]
final class CreateProfile
{
}
```

## Provider

Expand Down
22 changes: 22 additions & 0 deletions src/Attribute/InstantRetry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Attribute;

use Attribute;
use Throwable;

#[Attribute(Attribute::TARGET_CLASS)]
final class InstantRetry
{
/**
* @param positive-int|null $maxRetries
* @param list<class-string<Throwable>>|null $exceptions
*/
public function __construct(
public readonly int|null $maxRetries = null,
public readonly array|null $exceptions = null,
) {
}
}
1 change: 1 addition & 0 deletions src/Attribute/RetryAggregateOutdated.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@

use Attribute;

/** @deprecated use InstantRetry instead. */
#[Attribute(Attribute::TARGET_CLASS)]
final class RetryAggregateOutdated
{
public function __construct(
public readonly int $maxRetries = 3,

Check warning on line 14 in src/Attribute/RetryAggregateOutdated.php

View workflow job for this annotation

GitHub Actions / Mutation tests (locked, 8.3, ubuntu-latest)

Escaped Mutant for Mutator "IncrementInteger": @@ @@ #[Attribute(Attribute::TARGET_CLASS)] final class RetryAggregateOutdated { - public function __construct(public readonly int $maxRetries = 3) + public function __construct(public readonly int $maxRetries = 4) { } }

Check warning on line 14 in src/Attribute/RetryAggregateOutdated.php

View workflow job for this annotation

GitHub Actions / Mutation tests (locked, 8.3, ubuntu-latest)

Escaped Mutant for Mutator "DecrementInteger": @@ @@ #[Attribute(Attribute::TARGET_CLASS)] final class RetryAggregateOutdated { - public function __construct(public readonly int $maxRetries = 3) + public function __construct(public readonly int $maxRetries = 2) { } }
) {
}
}
65 changes: 65 additions & 0 deletions src/CommandBus/InstantRetryCommandBus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\CommandBus;

use Patchlevel\EventSourcing\Attribute\InstantRetry;
use Patchlevel\EventSourcing\Repository\AggregateOutdated;
use ReflectionClass;
use Throwable;

use function in_array;

final class InstantRetryCommandBus implements CommandBus
{
/**
* @param positive-int $defaultMaxRetries
* @param list<class-string<Throwable>> $defaultExceptions
*/
public function __construct(
private readonly CommandBus $commandBus,
private readonly int $defaultMaxRetries = 3,
private readonly array $defaultExceptions = [AggregateOutdated::class],
) {
}

public function dispatch(object $command): void
{
$this->doDispatch($command, 0);
}

private function doDispatch(object $command, int $retry): void
{
try {
$this->commandBus->dispatch($command);
} catch (Throwable $exception) {
$configuration = $this->configuration($command);

if ($configuration === null) {
throw $exception;
}

$exceptions = $configuration->exceptions ?? $this->defaultExceptions;
$maxRetries = $configuration->maxRetries ?? $this->defaultMaxRetries;

if ($retry >= $maxRetries || !in_array($exception::class, $exceptions, true)) {
throw $exception;
}

$this->doDispatch($command, $retry + 1);
}
}

private function configuration(object $command): InstantRetry|null
{
$reflectionClass = new ReflectionClass($command);
$attributes = $reflectionClass->getAttributes(InstantRetry::class);

if ($attributes === []) {
return null;
}

return $attributes[0]->newInstance();
}
}
1 change: 1 addition & 0 deletions src/CommandBus/RetryOutdatedAggregateCommandBus.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Patchlevel\EventSourcing\Repository\AggregateOutdated;
use ReflectionClass;

/** @deprecated use RetryCommandBus instead */
final class RetryOutdatedAggregateCommandBus implements CommandBus
{
public function __construct(
Expand Down Expand Up @@ -35,10 +36,10 @@
}
}

private function maxRetries(object $command): int|null

Check failure on line 39 in src/CommandBus/RetryOutdatedAggregateCommandBus.php

View workflow job for this annotation

GitHub Actions / Static Analysis by Psalm (locked, 8.3, ubuntu-latest)

LessSpecificReturnType

src/CommandBus/RetryOutdatedAggregateCommandBus.php:39:51: LessSpecificReturnType: The inferred return type 'int' for Patchlevel\EventSourcing\CommandBus\RetryOutdatedAggregateCommandBus::maxRetries is more specific than the declared return type 'int|null' (see https://psalm.dev/088)
{
$reflectionClass = new ReflectionClass($command);
$attributes = $reflectionClass->getAttributes(RetryAggregateOutdated::class);

Check failure on line 42 in src/CommandBus/RetryOutdatedAggregateCommandBus.php

View workflow job for this annotation

GitHub Actions / Static Analysis by Psalm (locked, 8.3, ubuntu-latest)

DeprecatedClass

src/CommandBus/RetryOutdatedAggregateCommandBus.php:42:55: DeprecatedClass: Class Patchlevel\EventSourcing\Attribute\RetryAggregateOutdated is deprecated (see https://psalm.dev/098)

if ($attributes === []) {
return 0;
Expand Down
198 changes: 198 additions & 0 deletions tests/Unit/CommandBus/InstantRetryCommandBusTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Tests\Unit\CommandBus;

use Patchlevel\EventSourcing\Attribute\InstantRetry;
use Patchlevel\EventSourcing\CommandBus\CommandBus;
use Patchlevel\EventSourcing\CommandBus\InstantRetryCommandBus;
use Patchlevel\EventSourcing\Repository\AggregateOutdated;
use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId;
use PHPUnit\Framework\TestCase;
use RuntimeException;

final class InstantRetryCommandBusTest extends TestCase
{
public function testSuccess(): void
{
$command = new #[InstantRetry]
class {
public function __construct()
{
}
};

$innerCommandBus = $this->createMock(CommandBus::class);
$innerCommandBus
->expects($this->once())
->method('dispatch')
->with($command);

$retryCommandBus = new InstantRetryCommandBus($innerCommandBus);

$retryCommandBus->dispatch($command);

$this->assertTrue(true); // If no exception is thrown, the test passes
}

public function testDispatchRetriesUntilSuccess(): void
{
$command = new #[InstantRetry]
class {
public function __construct()
{
}
};

$innerCommandBus = $this->createMock(CommandBus::class);
$innerCommandBus
->expects($this->exactly(3))
->method('dispatch')
->with($command)
->willReturnOnConsecutiveCalls(
$this->throwException(new AggregateOutdated('profile', ProfileId::fromString('profile'))),
$this->throwException(new AggregateOutdated('profile', ProfileId::fromString('profile'))),
null, // Success on the third attempt
);

$retryCommandBus = new InstantRetryCommandBus($innerCommandBus);

$retryCommandBus->dispatch($command);

$this->assertTrue(true); // If no exception is thrown, the test passes
}

public function testDispatchThrowsAfterMaxRetries(): void
{
$command = new #[InstantRetry]
class {
public function __construct()
{
}
};

$innerCommandBus = $this->createMock(CommandBus::class);
$innerCommandBus
->expects($this->exactly(4))
->method('dispatch')
->with($command)
->willThrowException(
new AggregateOutdated(
'profile',
ProfileId::fromString('profile'),
),
);

$retryCommandBus = new InstantRetryCommandBus($innerCommandBus);

$this->expectException(AggregateOutdated::class);

$retryCommandBus->dispatch($command);
}

public function testDispatchThrowsAfterMaxRetriesWithOverride(): void
{
$command = new #[InstantRetry(maxRetries: 2)]
class {
public function __construct()
{
}
};

$innerCommandBus = $this->createMock(CommandBus::class);
$innerCommandBus
->expects($this->exactly(3))
->method('dispatch')
->with($command)
->willThrowException(
new AggregateOutdated(
'profile',
ProfileId::fromString('profile'),
),
);

$retryCommandBus = new InstantRetryCommandBus($innerCommandBus);

$this->expectException(AggregateOutdated::class);

$retryCommandBus->dispatch($command);
}

public function testDispatchNotRetry(): void
{
$command = new class {
public function __construct()
{
}
};

$innerCommandBus = $this->createMock(CommandBus::class);
$innerCommandBus
->expects($this->once())
->method('dispatch')
->with($command)
->willThrowException(
new AggregateOutdated(
'profile',
ProfileId::fromString('profile'),
),
);

$retryCommandBus = new InstantRetryCommandBus($innerCommandBus);

$this->expectException(AggregateOutdated::class);

$retryCommandBus->dispatch($command);
}

public function testSkipOtherExceptions(): void
{
$command = new #[InstantRetry(maxRetries: 2)]
class {
public function __construct()
{
}
};

$innerCommandBus = $this->createMock(CommandBus::class);
$innerCommandBus
->expects($this->once())
->method('dispatch')
->with($command)
->willThrowException(new RuntimeException('Some other exception'));

$retryCommandBus = new InstantRetryCommandBus($innerCommandBus);

$this->expectException(RuntimeException::class);

$retryCommandBus->dispatch($command);
}

public function testOverrideException(): void
{
$command = new #[InstantRetry(exceptions: [RuntimeException::class])]
class {
public function __construct()
{
}
};

$innerCommandBus = $this->createMock(CommandBus::class);
$innerCommandBus
->expects($this->exactly(3))
->method('dispatch')
->with($command)
->willReturnOnConsecutiveCalls(
$this->throwException(new RuntimeException()),
$this->throwException(new RuntimeException()),
null, // Success on the third attempt
);

$retryCommandBus = new InstantRetryCommandBus($innerCommandBus);

$retryCommandBus->dispatch($command);

$this->assertTrue(true); // If no exception is thrown, the test passes
}
}
Loading