Skip to content

Commit bfa1706

Browse files
authored
feat(commandbus): async commands (#685)
1 parent 779973e commit bfa1706

20 files changed

+562
-3
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"psr/http-message": "^1.0|^2.0",
3131
"psr/log": "^3.0.0",
3232
"symfony/cache": "^7.2",
33+
"symfony/process": "^7.1",
3334
"symfony/uid": "^7.1",
3435
"symfony/var-dumper": "^7.1",
3536
"symfony/var-exporter": "^7.1",
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\CommandBus;
6+
7+
use Attribute;
8+
9+
#[Attribute]
10+
final readonly class AsyncCommand
11+
{
12+
}
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 Tempest\CommandBus;
6+
7+
use Symfony\Component\Uid\Uuid;
8+
use Tempest\Core\KernelEvent;
9+
use Tempest\EventBus\EventHandler;
10+
use Tempest\Reflection\ClassReflector;
11+
12+
final readonly class AsyncCommandMiddleware implements CommandBusMiddleware
13+
{
14+
public function __construct(
15+
private CommandBusConfig $commandBusConfig,
16+
private CommandRepository $repository,
17+
) {
18+
}
19+
20+
#[EventHandler(KernelEvent::BOOTED)]
21+
public function onBooted(): void
22+
{
23+
$this->commandBusConfig->addMiddleware(self::class);
24+
}
25+
26+
public function __invoke(object $command, CommandBusMiddlewareCallable $next): void
27+
{
28+
$reflector = new ClassReflector($command);
29+
30+
if ($reflector->hasAttribute(AsyncCommand::class)) {
31+
$this->repository->store(Uuid::v7()->toString(), $command);
32+
33+
return;
34+
}
35+
36+
$next($command);
37+
}
38+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\CommandBus\AsyncCommandRepositories;
6+
7+
use Tempest\CommandBus\CommandRepository;
8+
use Tempest\CommandBus\Exceptions\CouldNotResolveCommand;
9+
use function Tempest\Support\arr;
10+
11+
final readonly class FileCommandRepository implements CommandRepository
12+
{
13+
public function store(string $uuid, object $command): void
14+
{
15+
$payload = serialize($command);
16+
17+
file_put_contents(__DIR__ . "/../stored-commands/{$uuid}.pending.txt", $payload);
18+
}
19+
20+
public function findPendingCommand(string $uuid): object
21+
{
22+
$path = __DIR__ . "/../stored-commands/{$uuid}.pending.txt";
23+
24+
if (! file_exists($path)) {
25+
throw new CouldNotResolveCommand($uuid);
26+
}
27+
28+
$payload = file_get_contents($path);
29+
30+
return unserialize($payload);
31+
}
32+
33+
public function markAsDone(string $uuid): void
34+
{
35+
$path = __DIR__ . "/../stored-commands/{$uuid}.pending.txt";
36+
37+
unlink($path);
38+
}
39+
40+
public function markAsFailed(string $uuid): void
41+
{
42+
rename(
43+
from: __DIR__ . "/../stored-commands/{$uuid}.pending.txt",
44+
to: __DIR__ . "/../stored-commands/{$uuid}.failed.txt",
45+
);
46+
}
47+
48+
public function getPendingCommands(): array
49+
{
50+
return arr(glob(__DIR__ . "/../stored-commands/*.pending.txt"))
51+
->mapWithKeys(function (string $path) {
52+
$uuid = str_replace('.pending.txt', '', pathinfo($path, PATHINFO_BASENAME));
53+
54+
$payload = file_get_contents($path);
55+
56+
yield $uuid => unserialize($payload);
57+
})
58+
->toArray();
59+
}
60+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\CommandBus\AsyncCommandRepositories;
6+
7+
use Tempest\CommandBus\CommandRepository;
8+
9+
final class MemoryRepository implements CommandRepository
10+
{
11+
private array $commands = [];
12+
13+
public function store(string $uuid, object $command): void
14+
{
15+
$this->commands[$uuid] = $command;
16+
}
17+
18+
public function getPendingCommands(): array
19+
{
20+
return $this->commands;
21+
}
22+
23+
public function findPendingCommand(string $uuid): object
24+
{
25+
return $this->commands[$uuid];
26+
}
27+
28+
public function markAsDone(string $uuid): void
29+
{
30+
unset($this->commands[$uuid]);
31+
}
32+
33+
public function markAsFailed(string $uuid): void
34+
{
35+
unset($this->commands[$uuid]);
36+
}
37+
}

src/Tempest/CommandBus/src/CommandBusConfig.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Tempest\CommandBus;
66

7+
use Tempest\CommandBus\AsyncCommandRepositories\FileCommandRepository;
78
use Tempest\Reflection\MethodReflector;
89

910
final class CommandBusConfig
@@ -14,6 +15,9 @@ public function __construct(
1415

1516
/** @var array<array-key, class-string<\Tempest\CommandBus\CommandBusMiddleware>> */
1617
public array $middleware = [],
18+
19+
/** @var class-string<\Tempest\CommandBus\CommandRepository> $commandRepositoryClass */
20+
public string $commandRepositoryClass = FileCommandRepository::class,
1721
) {
1822
}
1923

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\CommandBus;
6+
7+
interface CommandRepository
8+
{
9+
public function store(string $uuid, object $command): void;
10+
11+
/** @return array<string, object> */
12+
public function getPendingCommands(): array;
13+
14+
public function findPendingCommand(string $uuid): object;
15+
16+
public function markAsDone(string $uuid): void;
17+
18+
public function markAsFailed(string $uuid): void;
19+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\CommandBus;
6+
7+
use Tempest\Container\Container;
8+
use Tempest\Container\Initializer;
9+
use Tempest\Container\Singleton;
10+
11+
final readonly class CommandRepositoryInitializer implements Initializer
12+
{
13+
#[Singleton]
14+
public function initialize(Container $container): CommandRepository
15+
{
16+
$commandRepositoryClass = $container->get(CommandBusConfig::class)->commandRepositoryClass;
17+
18+
return $container->get($commandRepositoryClass);
19+
}
20+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\CommandBus\Exceptions;
6+
7+
use Exception;
8+
9+
final class CouldNotResolveCommand extends Exception
10+
{
11+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\CommandBus;
6+
7+
use Tempest\Console\Console;
8+
use Tempest\Console\ConsoleCommand;
9+
use Tempest\Console\ExitCode;
10+
use Tempest\Console\HasConsole;
11+
use Tempest\Container\Container;
12+
use function Tempest\Support\arr;
13+
use Throwable;
14+
15+
final readonly class HandleAsyncCommand
16+
{
17+
use HasConsole;
18+
19+
public function __construct(
20+
private CommandBusConfig $commandBusConfig,
21+
private Container $container,
22+
private Console $console,
23+
private CommandRepository $repository,
24+
) {
25+
}
26+
27+
#[ConsoleCommand(name: 'command:handle')]
28+
public function __invoke(?string $uuid = null): ExitCode
29+
{
30+
try {
31+
if ($uuid) {
32+
$command = $this->repository->findPendingCommand($uuid);
33+
} else {
34+
$command = arr($this->repository->getPendingCommands())->first();
35+
}
36+
37+
if (! $command) {
38+
$this->error('No pending command found');
39+
40+
return ExitCode::ERROR;
41+
}
42+
43+
$commandHandler = $this->commandBusConfig->handlers[$command::class] ?? null;
44+
45+
if (! $commandHandler) {
46+
$commandClass = $command::class;
47+
$this->error("No handler found for command {$commandClass}");
48+
49+
return ExitCode::ERROR;
50+
}
51+
52+
$commandHandler->handler->invokeArgs(
53+
$this->container->get($commandHandler->handler->getDeclaringClass()->getName()),
54+
[$command],
55+
);
56+
57+
$this->repository->markAsDone($uuid);
58+
$this->success('Done');
59+
60+
return ExitCode::SUCCESS;
61+
} catch (Throwable $throwable) {
62+
$this->repository->markAsFailed($uuid);
63+
$this->error($throwable->getMessage());
64+
65+
return ExitCode::ERROR;
66+
}
67+
}
68+
}

0 commit comments

Comments
 (0)