Milestone 5: Resiliency & Advanced State

This commit is contained in:
Funky Waddle 2026-02-25 00:33:17 -06:00
parent 26db488032
commit a898a814ec
4 changed files with 194 additions and 0 deletions

53
src/DispatchFailed.php Normal file
View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Phred\Beacon;
use Phred\BeaconContracts\DispatchFailedInterface;
use Throwable;
/**
* DispatchFailed
*
* Internal event triggered when a listener throws an exception during dispatch.
* Used only when ResiliencyMode::CONTINUE is enabled.
*/
class DispatchFailed implements DispatchFailedInterface
{
/**
* @param Throwable $exception The exception thrown by the listener.
* @param callable $listener The listener that failed.
* @param object $event The original event being dispatched.
*/
public function __construct(
private readonly Throwable $exception,
private readonly mixed $listener, // Mixed because it could be an object, closure, or array
private readonly object $event
) {
}
/**
* @return Throwable
*/
public function getException(): Throwable
{
return $this->exception;
}
/**
* @return callable
*/
public function getListener(): callable
{
return is_callable($this->listener) ? $this->listener : fn() => null;
}
/**
* @return object
*/
public function getEvent(): object
{
return $this->event;
}
}

46
src/EventEnvelope.php Normal file
View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Phred\Beacon;
use Phred\BeaconContracts\EventEnvelopeInterface;
/**
* EventEnvelope
*
* A wrapper to carry immutable metadata (context) along with an event.
* Useful for traceability and cross-cutting concerns like request IDs.
*/
class EventEnvelope implements EventEnvelopeInterface
{
/**
* @param object $event The original event.
* @param array<string, mixed> $context Immutable metadata associated with the event.
*/
public function __construct(
private readonly object $event,
private readonly array $context = []
) {
}
/**
* Returns the underlying event.
*
* @return object
*/
public function getEvent(): object
{
return $this->event;
}
/**
* Returns the context map.
*
* @return array<string, mixed>
*/
public function getContext(): array
{
return $this->context;
}
}

23
src/ResiliencyMode.php Normal file
View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Phred\Beacon;
/**
* ResiliencyMode
*
* Defines how the dispatcher handles exceptions thrown by listeners.
*/
enum ResiliencyMode: string
{
/**
* Stop dispatching immediately when a listener throws an exception.
*/
case FAIL_FAST = 'fail_fast';
/**
* Log/Report the error and continue to the next listener.
*/
case CONTINUE = 'continue';
}

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Phred\Beacon\Tests\Unit;
use PHPUnit\Framework\TestCase;
use Phred\Beacon\EventDispatcher;
use Phred\Beacon\ListenerProvider;
use Phred\Beacon\ResiliencyMode;
use Phred\Beacon\DispatchFailed;
use Phred\Beacon\EventEnvelope;
class ResiliencyAndStateTest extends TestCase
{
public function test_it_throws_exception_in_fail_fast_mode(): void
{
$provider = new ListenerProvider();
$dispatcher = new EventDispatcher($provider);
$dispatcher->setResiliencyMode(ResiliencyMode::FAIL_FAST);
$provider->addListener('stdClass', function() {
throw new \RuntimeException('Test Error');
});
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Test Error');
$dispatcher->dispatch(new \stdClass());
}
public function test_it_continues_and_reports_in_continue_mode(): void
{
$provider = new ListenerProvider();
$dispatcher = new EventDispatcher($provider);
$dispatcher->setResiliencyMode(ResiliencyMode::CONTINUE);
$failedCount = 0;
$listenersRun = 0;
// Listener that fails
$provider->addListener('stdClass', function() {
throw new \RuntimeException('Test Error');
});
// Next listener that should still run
$provider->addListener('stdClass', function() use (&$listenersRun) {
$listenersRun++;
});
// Error reporter listener
$provider->addListener(DispatchFailed::class, function(DispatchFailed $event) use (&$failedCount) {
$failedCount++;
$this->assertEquals('Test Error', $event->getException()->getMessage());
});
$dispatcher->dispatch(new \stdClass());
$this->assertEquals(1, $listenersRun);
$this->assertEquals(1, $failedCount);
}
public function test_event_envelope_carries_context(): void
{
$event = new \stdClass();
$context = ['request_id' => '123'];
$envelope = new EventEnvelope($event, $context);
$this->assertSame($event, $envelope->getEvent());
$this->assertEquals($context, $envelope->getContext());
}
}