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

If you want to retry the command when an `AggregateOutdated` exception occurs,
you can use the `RetryOutdatedAggregateCommandBus` decorator.

```php
use Patchlevel\EventSourcing\CommandBus;
use Patchlevel\EventSourcing\CommandBus\RetryOutdatedAggregateCommandBus;

/**
* @var HandlerProvider $handlerProvider
* @var CommandBus $store
*/
$commandBus = new RetryOutdatedAggregateCommandBus(
$commandBus,
);
```
And you need to mark the command class with the `#[RetryAggregateOutdated]` attribute,
if you want to retry the command when an `AggregateOutdated` exception occurs.

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

#[RetryAggregateOutdated]
final class CreateProfile
{
public function __construct(
public readonly ProfileId $id,
public readonly string $name,
) {
}
}
```
!!! tip

You can specify the maximum number of retries in the `#[RetryAggregateOutdated]` attribute.
The default value is 3.

## Provider

There are different types of providers that you can use to register handlers.
Expand Down
8 changes: 7 additions & 1 deletion docs/pages/repository.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ $profile = Profile::create($id, 'david.badura@patchlevel.de');
/** @var Repository $repository */
$repository->save($profile);
```
!!! Warning
!!! warning

All events are written to the database with one transaction in order to ensure data consistency.
If an exception occurs during the save process,
Expand All @@ -174,6 +174,12 @@ $repository->save($profile);

Due to the nature of the aggregate having a playhead,
we have a unique constraint that ensures that no race condition happens here.
An `AggregateOutdated` exception is thrown if a conflict occurs.

!!! tip

If you use the Command Bus, you can use the [RetryOutdatedAggregateCommandBus](command_bus.md#retry-outdated-aggregate-command-bus)
to retry the command when an `AggregateOutdated` exception occurs automatically.

### Load an aggregate

Expand Down
18 changes: 18 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
parameters:
ignoreErrors:
-
message: '#^Dead catch \- Patchlevel\\EventSourcing\\Repository\\AggregateOutdated is never thrown in the try block\.$#'
identifier: catch.neverThrown
count: 1
path: src/CommandBus/RetryOutdatedAggregateCommandBus.php

-
message: '#^Method Patchlevel\\EventSourcing\\CommandBus\\RetryOutdatedAggregateCommandBus\:\:maxRetries\(\) is unused\.$#'
identifier: method.unused
count: 1
path: src/CommandBus/RetryOutdatedAggregateCommandBus.php

-
message: '#^Method Patchlevel\\EventSourcing\\CommandBus\\RetryOutdatedAggregateCommandBus\:\:maxRetries\(\) never returns null so it can be removed from the return type\.$#'
identifier: return.unusedType
count: 1
path: src/CommandBus/RetryOutdatedAggregateCommandBus.php

-
message: '#^Cannot unset offset ''url'' on array\{application_name\?\: string, charset\?\: string, dbname\?\: string, defaultTableOptions\?\: array\<string, mixed\>, driver\?\: ''ibm_db2''\|''mysqli''\|''oci8''\|''pdo_mysql''\|''pdo_oci''\|''pdo_pgsql''\|''pdo_sqlite''\|''pdo_sqlsrv''\|''pgsql''\|''sqlite3''\|''sqlsrv'', driverClass\?\: class\-string\<Doctrine\\DBAL\\Driver\>, driverOptions\?\: array\<mixed\>, host\?\: string, \.\.\.\}\.$#'
identifier: unset.offset
Expand Down
16 changes: 16 additions & 0 deletions src/Attribute/RetryAggregateOutdated.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Attribute;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS)]
final class RetryAggregateOutdated
{
public function __construct(
public readonly int $maxRetries = 3,

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

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (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) { } }

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

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (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 13 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 13 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) { } }
) {
}
}
49 changes: 49 additions & 0 deletions src/CommandBus/RetryOutdatedAggregateCommandBus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\CommandBus;

use Patchlevel\EventSourcing\Attribute\RetryAggregateOutdated;
use Patchlevel\EventSourcing\Repository\AggregateOutdated;
use ReflectionClass;

final class RetryOutdatedAggregateCommandBus implements CommandBus
{
public function __construct(
private readonly CommandBus $commandBus,
) {
}

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

private function doDispatch(object $command, int $retry, int|null $maxRetries = null): void
{
try {
$this->commandBus->dispatch($command);
} catch (AggregateOutdated $exception) {
$maxRetries ??= $this->maxRetries($command);

if ($retry >= $maxRetries) {
throw $exception;
}

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

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

Check failure on line 38 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:38: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);

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

return $attributes[0]->newInstance()->maxRetries;
}
}
143 changes: 143 additions & 0 deletions tests/Unit/CommandBus/RetryOutdatedAggregateCommandBusTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Tests\Unit\CommandBus;

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

final class RetryOutdatedAggregateCommandBusTest extends TestCase
{
public function testSuccess(): void
{
$command = new #[RetryAggregateOutdated(maxRetries: 3)]
class {
public function __construct()
{
}
};

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

$retryCommandBus = new RetryOutdatedAggregateCommandBus($innerCommandBus);

$retryCommandBus->dispatch($command);

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

public function testDispatchRetriesUntilSuccess(): void
{
$command = new #[RetryAggregateOutdated(maxRetries: 3)]
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 RetryOutdatedAggregateCommandBus($innerCommandBus);

$retryCommandBus->dispatch($command);

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

public function testDispatchThrowsAfterMaxRetries(): void
{
$command = new #[RetryAggregateOutdated(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 RetryOutdatedAggregateCommandBus($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 RetryOutdatedAggregateCommandBus($innerCommandBus);

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

$retryCommandBus->dispatch($command);
}

public function testSkipOtherExceptions(): void
{
$command = new #[RetryAggregateOutdated(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 RetryOutdatedAggregateCommandBus($innerCommandBus);

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

$retryCommandBus->dispatch($command);
}
}
Loading