From 72052f8f0c30520f2296091005f5970de1771845 Mon Sep 17 00:00:00 2001 From: David Badura Date: Tue, 17 Jun 2025 12:29:22 +0200 Subject: [PATCH] add instant retry command bus --- docs/pages/command_bus.md | 41 ++-- src/Attribute/InstantRetry.php | 22 ++ src/Attribute/RetryAggregateOutdated.php | 1 + src/CommandBus/InstantRetryCommandBus.php | 65 ++++++ .../RetryOutdatedAggregateCommandBus.php | 1 + .../CommandBus/InstantRetryCommandBusTest.php | 198 ++++++++++++++++++ 6 files changed, 312 insertions(+), 16 deletions(-) create mode 100644 src/Attribute/InstantRetry.php create mode 100644 src/CommandBus/InstantRetryCommandBus.php create mode 100644 tests/Unit/CommandBus/InstantRetryCommandBusTest.php diff --git a/docs/pages/command_bus.md b/docs/pages/command_bus.md index 7ac15e6c..75d23da1 100644 --- a/docs/pages/command_bus.md +++ b/docs/pages/command_bus.md @@ -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( @@ -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 diff --git a/src/Attribute/InstantRetry.php b/src/Attribute/InstantRetry.php new file mode 100644 index 00000000..596008c0 --- /dev/null +++ b/src/Attribute/InstantRetry.php @@ -0,0 +1,22 @@ +>|null $exceptions + */ + public function __construct( + public readonly int|null $maxRetries = null, + public readonly array|null $exceptions = null, + ) { + } +} diff --git a/src/Attribute/RetryAggregateOutdated.php b/src/Attribute/RetryAggregateOutdated.php index 0bd68749..3198490f 100644 --- a/src/Attribute/RetryAggregateOutdated.php +++ b/src/Attribute/RetryAggregateOutdated.php @@ -6,6 +6,7 @@ use Attribute; +/** @deprecated use InstantRetry instead. */ #[Attribute(Attribute::TARGET_CLASS)] final class RetryAggregateOutdated { diff --git a/src/CommandBus/InstantRetryCommandBus.php b/src/CommandBus/InstantRetryCommandBus.php new file mode 100644 index 00000000..41ff60b4 --- /dev/null +++ b/src/CommandBus/InstantRetryCommandBus.php @@ -0,0 +1,65 @@ +> $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(); + } +} diff --git a/src/CommandBus/RetryOutdatedAggregateCommandBus.php b/src/CommandBus/RetryOutdatedAggregateCommandBus.php index e102895f..37be4db7 100644 --- a/src/CommandBus/RetryOutdatedAggregateCommandBus.php +++ b/src/CommandBus/RetryOutdatedAggregateCommandBus.php @@ -8,6 +8,7 @@ use Patchlevel\EventSourcing\Repository\AggregateOutdated; use ReflectionClass; +/** @deprecated use RetryCommandBus instead */ final class RetryOutdatedAggregateCommandBus implements CommandBus { public function __construct( diff --git a/tests/Unit/CommandBus/InstantRetryCommandBusTest.php b/tests/Unit/CommandBus/InstantRetryCommandBusTest.php new file mode 100644 index 00000000..a63df907 --- /dev/null +++ b/tests/Unit/CommandBus/InstantRetryCommandBusTest.php @@ -0,0 +1,198 @@ +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 + } +}