From 92ea207588a0396c3d8c23bb0647f6dc26cf0f05 Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 13 Dec 2023 14:35:30 +0100 Subject: [PATCH 1/2] store projection error object --- .../Command/ProjectionStatusCommand.php | 62 +++++++++++++++++-- src/Projection/Projection/Projection.php | 15 ++++- .../Projection/Store/DoctrineStore.php | 9 ++- .../Projection/Store/ErrorSerializer.php | 37 +++++++++++ .../Projectionist/DefaultProjectionist.php | 4 +- .../Projection/Projection/ProjectionTest.php | 7 ++- .../Projection/Store/ErrorSerializerTest.php | 46 ++++++++++++++ .../{ => Store}/InMemoryStoreTest.php | 2 +- .../DefaultProjectionistTest.php | 50 +++++++++++---- 9 files changed, 209 insertions(+), 23 deletions(-) create mode 100644 src/Projection/Projection/Store/ErrorSerializer.php create mode 100644 tests/Unit/Projection/Projection/Store/ErrorSerializerTest.php rename tests/Unit/Projection/Projection/{ => Store}/InMemoryStoreTest.php (99%) diff --git a/src/Console/Command/ProjectionStatusCommand.php b/src/Console/Command/ProjectionStatusCommand.php index ea53b7322..b74e12d32 100644 --- a/src/Console/Command/ProjectionStatusCommand.php +++ b/src/Console/Command/ProjectionStatusCommand.php @@ -4,13 +4,17 @@ namespace Patchlevel\EventSourcing\Console\Command; +use Patchlevel\EventSourcing\Console\InputHelper; use Patchlevel\EventSourcing\Console\OutputStyle; use Patchlevel\EventSourcing\Projection\Projection\Projection; +use Patchlevel\EventSourcing\Projection\Projection\ProjectionId; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use function array_map; +use function dump; #[AsCommand( 'event-sourcing:projection:status', @@ -18,31 +22,79 @@ )] final class ProjectionStatusCommand extends ProjectionCommand { + protected function configure(): void + { + parent::configure(); + + $this->addArgument( + 'id', + InputArgument::OPTIONAL, + 'The projection to display more information about', + ); + } + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new OutputStyle($input, $output); + + $id = InputHelper::nullableString($input->getArgument('id')); $projections = $this->projectionist->projections(); - $io->table( + if ($id === null) { + $io->table( + [ + 'id', + 'name', + 'version', + 'position', + 'status', + 'error message', + ], + array_map( + static fn (Projection $projection) => [ + $projection->id()->toString(), + $projection->id()->name(), + $projection->id()->version(), + $projection->position(), + $projection->status()->value, + $projection->errorMessage(), + ], + [...$projections], + ), + ); + + return 0; + } + + $projection = $projections->get(ProjectionId::fromString($id)); + + $io->horizontalTable( [ + 'id', 'name', 'version', 'position', 'status', 'error message', ], - array_map( - static fn (Projection $projection) => [ + [ + [ + $projection->id()->toString(), $projection->id()->name(), $projection->id()->version(), $projection->position(), $projection->status()->value, $projection->errorMessage(), ], - [...$projections], - ), + ], ); + $errorObject = $projection->errorObject(); + + if ($errorObject) { + dump($errorObject); + } + return 0; } } diff --git a/src/Projection/Projection/Projection.php b/src/Projection/Projection/Projection.php index 01eecfbd6..6ac6e4b8a 100644 --- a/src/Projection/Projection/Projection.php +++ b/src/Projection/Projection/Projection.php @@ -4,6 +4,8 @@ namespace Patchlevel\EventSourcing\Projection\Projection; +use Throwable; + final class Projection { public function __construct( @@ -11,6 +13,7 @@ public function __construct( private ProjectionStatus $status = ProjectionStatus::New, private int $position = 0, private string|null $errorMessage = null, + private Throwable|null $errorObject = null, ) { } @@ -34,6 +37,11 @@ public function errorMessage(): string|null return $this->errorMessage; } + public function errorObject(): Throwable|null + { + return $this->errorObject; + } + public function incrementPosition(): void { $this->position++; @@ -48,6 +56,7 @@ public function booting(): void { $this->status = ProjectionStatus::Booting; $this->errorMessage = null; + $this->errorObject = null; } public function isBooting(): bool @@ -59,6 +68,7 @@ public function active(): void { $this->status = ProjectionStatus::Active; $this->errorMessage = null; + $this->errorObject = null; } public function isActive(): bool @@ -76,10 +86,11 @@ public function isOutdated(): bool return $this->status === ProjectionStatus::Outdated; } - public function error(string|null $message = null): void + public function error(Throwable|string|null $error = null): void { $this->status = ProjectionStatus::Error; - $this->errorMessage = $message; + $this->errorMessage = $error instanceof Throwable ? $error->getMessage() : $error; + $this->errorObject = $error instanceof Throwable ? $error : null; } public function isError(): bool diff --git a/src/Projection/Projection/Store/DoctrineStore.php b/src/Projection/Projection/Store/DoctrineStore.php index 6fd86506c..9a01ff879 100644 --- a/src/Projection/Projection/Store/DoctrineStore.php +++ b/src/Projection/Projection/Store/DoctrineStore.php @@ -57,7 +57,7 @@ public function all(): ProjectionCollection ->from($this->projectionTable) ->getSQL(); - /** @var list $result */ + /** @var list $result */ $result = $this->connection->fetchAllAssociative($sql); return new ProjectionCollection( @@ -68,6 +68,7 @@ static function (array $data) { ProjectionStatus::from($data['status']), $data['position'], $data['error_message'], + ErrorSerializer::unserialize($data['error_object']), ); }, $result, @@ -80,6 +81,8 @@ public function save(Projection ...$projections): void $this->connection->transactional( function (Connection $connection) use ($projections): void { foreach ($projections as $projection) { + $errorObject = ErrorSerializer::serialize($projection->errorObject()); + try { $effectedRows = (int)$connection->update( $this->projectionTable, @@ -87,6 +90,7 @@ function (Connection $connection) use ($projections): void { 'position' => $projection->position(), 'status' => $projection->status()->value, 'error_message' => $projection->errorMessage(), + 'error_object' => $errorObject, ], [ 'name' => $projection->id()->name(), @@ -106,6 +110,7 @@ function (Connection $connection) use ($projections): void { 'position' => $projection->position(), 'status' => $projection->status()->value, 'error_message' => $projection->errorMessage(), + 'error_object' => $errorObject, ], ); } @@ -142,6 +147,8 @@ public function configureSchema(Schema $schema, Connection $connection): void ->setNotnull(true); $table->addColumn('error_message', Types::STRING) ->setNotnull(false); + $table->addColumn('error_object', Types::BLOB) + ->setNotnull(false); $table->setPrimaryKey(['name', 'version']); } diff --git a/src/Projection/Projection/Store/ErrorSerializer.php b/src/Projection/Projection/Store/ErrorSerializer.php new file mode 100644 index 000000000..6be0749dd --- /dev/null +++ b/src/Projection/Projection/Store/ErrorSerializer.php @@ -0,0 +1,37 @@ + true]); + + if (!$result instanceof Throwable) { + return null; + } + + return $result; + } +} diff --git a/src/Projection/Projectionist/DefaultProjectionist.php b/src/Projection/Projectionist/DefaultProjectionist.php index 696219ef3..48456bdae 100644 --- a/src/Projection/Projectionist/DefaultProjectionist.php +++ b/src/Projection/Projectionist/DefaultProjectionist.php @@ -88,7 +88,7 @@ public function boot( $e->getMessage(), )); - $projection->error($e->getMessage()); + $projection->error($e); $this->projectionStore->save($projection); if ($throwByError) { @@ -400,7 +400,7 @@ private function handleMessage(Message $message, Projection $projection, bool $t ), ); - $projection->error($e->getMessage()); + $projection->error($e); $this->projectionStore->save($projection); if ($throwByError) { diff --git a/tests/Unit/Projection/Projection/ProjectionTest.php b/tests/Unit/Projection/Projection/ProjectionTest.php index d5d802c97..6aab00acf 100644 --- a/tests/Unit/Projection/Projection/ProjectionTest.php +++ b/tests/Unit/Projection/Projection/ProjectionTest.php @@ -8,6 +8,7 @@ use Patchlevel\EventSourcing\Projection\Projection\ProjectionId; use Patchlevel\EventSourcing\Projection\Projection\ProjectionStatus; use PHPUnit\Framework\TestCase; +use RuntimeException; /** @covers \Patchlevel\EventSourcing\Projection\Projection\Projection */ final class ProjectionTest extends TestCase @@ -65,7 +66,9 @@ public function testError(): void new ProjectionId('test', 1), ); - $projection->error(); + $exception = new RuntimeException('test'); + + $projection->error($exception); self::assertEquals(ProjectionStatus::Error, $projection->status()); self::assertFalse($projection->isNew()); @@ -73,6 +76,8 @@ public function testError(): void self::assertFalse($projection->isActive()); self::assertTrue($projection->isError()); self::assertFalse($projection->isOutdated()); + self::assertEquals('test', $projection->errorMessage()); + self::assertEquals($exception, $projection->errorObject()); } public function testOutdated(): void diff --git a/tests/Unit/Projection/Projection/Store/ErrorSerializerTest.php b/tests/Unit/Projection/Projection/Store/ErrorSerializerTest.php new file mode 100644 index 000000000..60d6e85c6 --- /dev/null +++ b/tests/Unit/Projection/Projection/Store/ErrorSerializerTest.php @@ -0,0 +1,46 @@ +exception; } }; @@ -243,10 +245,22 @@ public function create(): void $projectionist->boot(); - self::assertEquals([ - new Projection($projector->targetProjection(), ProjectionStatus::Booting), - new Projection($projector->targetProjection(), ProjectionStatus::Error, 0, 'ERROR'), - ], $projectionStore->savedProjections); + self::assertEquals( + [ + new Projection( + $projector->targetProjection(), + ProjectionStatus::Booting, + ), + new Projection( + $projector->targetProjection(), + ProjectionStatus::Error, + 0, + 'ERROR', + $projector->exception, + ), + ], + $projectionStore->savedProjections, + ); } public function testRunning(): void @@ -409,6 +423,11 @@ public function handle(Message $message): void public function testRunningWithError(): void { $projector = new class implements Projector { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + public function targetProjection(): ProjectionId { return new ProjectionId('test', 1); @@ -416,7 +435,7 @@ public function targetProjection(): ProjectionId public function handle(Message $message): void { - throw new RuntimeException('ERROR'); + throw $this->exception; } }; @@ -442,9 +461,18 @@ public function handle(Message $message): void $projectionist->run(); - self::assertEquals([ - new Projection($projector->targetProjection(), ProjectionStatus::Error, 0, 'ERROR'), - ], $projectionStore->savedProjections); + self::assertEquals( + [ + new Projection( + $projector->targetProjection(), + ProjectionStatus::Error, + 0, + 'ERROR', + $projector->exception, + ), + ], + $projectionStore->savedProjections, + ); } public function testRunningMarkOutdated(): void From d0d8f66ababf94617ca8e452cc9f2312708ca78c Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 14 Dec 2023 10:12:35 +0100 Subject: [PATCH 2/2] replace dump output with own implementation --- src/Console/Command/ProjectionStatusCommand.php | 6 +++--- src/Console/OutputStyle.php | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Console/Command/ProjectionStatusCommand.php b/src/Console/Command/ProjectionStatusCommand.php index b74e12d32..2746f85ff 100644 --- a/src/Console/Command/ProjectionStatusCommand.php +++ b/src/Console/Command/ProjectionStatusCommand.php @@ -12,9 +12,9 @@ use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Throwable; use function array_map; -use function dump; #[AsCommand( 'event-sourcing:projection:status', @@ -91,8 +91,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $errorObject = $projection->errorObject(); - if ($errorObject) { - dump($errorObject); + if ($errorObject instanceof Throwable) { + $io->throwable($errorObject); } return 0; diff --git a/src/Console/OutputStyle.php b/src/Console/OutputStyle.php index 6964f1fba..c2f908386 100644 --- a/src/Console/OutputStyle.php +++ b/src/Console/OutputStyle.php @@ -9,6 +9,9 @@ use Patchlevel\EventSourcing\Serializer\Encoder\Encoder; use Patchlevel\EventSourcing\Serializer\EventSerializer; use Symfony\Component\Console\Style\SymfonyStyle; +use Throwable; + +use function sprintf; final class OutputStyle extends SymfonyStyle { @@ -37,4 +40,17 @@ public function message(EventSerializer $serializer, Message $message): void $this->block($data->payload); } + + public function throwable(Throwable $error): void + { + $number = 1; + + do { + $this->error(sprintf('%d) %s', $number, $error->getMessage())); + $this->block($error->getTraceAsString()); + + $number++; + $error = $error->getPrevious(); + } while ($error !== null); + } }