diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7b8bffd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + +jobs: + tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + extensions: json, pdo, pdo_sqlite + coverage: none + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: php-cs-fixer + run: composer test:cs + + - name: PHPStan + run: composer test:stan + + - name: Unit tests + run: composer test:unit + + - name: Feature tests + run: composer test:feature diff --git a/.gitignore b/.gitignore index f0cd6b8..3800c26 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ vendor .idea .phpunit.result.cache +.php-cs-fixer.cache +.php-cs-fixer.dist.php \ No newline at end of file diff --git a/README.md b/README.md index f67d6a1..ae1f501 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,14 @@ Events are first written to a durable store (in-memory or SQL), then dispatched - **Subscribe** to event types with any callable - **Process** queued events sequentially — each event is routed to every registered subscriber by type - **Two stores out of the box** — in-memory for tests/dev, SQL (MySQL / SQLite) for production -- **Two processors out of the box** — standard (fail-fast for local/CI) and silent (log-and-continue for production) -- **Outbox pattern** — events are marked `processed` only after they're successfully dequeued +- **Composable dispatch** — a `ListenerDispatcher` interface with a default void+throw implementation and a redelivering decorator that catches failures and writes them to a `RedeliveryStore` +- **Outbox pattern** — events transition `pending → processing → processed`, with the intermediate state surviving worker crashes +- **Per-listener retries** — failures persist to a `RedeliveryStore` and the `SequentialRedeliveryProcessor` consults a configurable `RetryPolicy` to decide whether and when to retry; a single failing listener of an event is retried independently while the others continue +- **Ignored exceptions** — pass a list of `Throwable` classes to the dispatcher to silently swallow expected domain failures (no retry, no log, no DB row) +- **Status audit trail** — every event status transition is recorded in `event_outbox_status` for ops visibility - **Scheduled delivery** — set `publishAt` in the future; the processor only picks up events whose time has come - **Worker-safe** — MySQL store uses `FOR UPDATE SKIP LOCKED` to allow multiple workers without double-processing -- **Auto schema** — `SqlEventStore` creates its own tables on first boot, no migrations needed +- **Auto schema** — `SqlEventStore` and `SqlRedeliveryStore` each create their own tables on first boot, no migrations needed - **Clean architecture boundaries** — `EventSerializer` and `EventHydrator` keep `RawEvent` out of your application layer --- @@ -48,16 +51,27 @@ composer require hivesper/php-events | Class | Role | |---|---| | `RawEvent` | Immutable value object representing a single stored event | -| `EventStore` | Interface — a durable FIFO queue of `RawEvent` | +| `EventStore` | Interface — a durable queue of `RawEvent`, with `add()` / `next()` / `markProcessed()` | | `EventSerializer` | Interface — converts a domain event object into a `SerializedEvent` | | `EventHydrator` | Interface — reconstructs a domain event object from a stored name + payload | | `SerializedEvent` | Value object holding the event `name` and `payload` array | | `EventPublisher` | Serializes a domain event and pushes it into the store, returning its ID | | `EventSubscriberMap` | Registry of `name → callable[]` mappings | -| `EventProcessor` | Interface — drains the store and dispatches to subscribers | -| `SequentialEventProcessor` | Built-in processor — dispatches events one by one; exceptions propagate (fail-fast) | -| `SilentSequentialEventProcessor` | Built-in processor — same as above but catches per-listener failures and logs them via PSR-3 | +| `EventProcessor` | Interface — drains an `EventStore` and dispatches each event to its subscribers | +| `SequentialEventProcessor` | Built-in event processor — runs each subscriber via the supplied `ListenerDispatcher` and calls `markProcessed`. Anything the dispatcher throws propagates out (fail-fast for local/CI); use `RedeliveringListenerDispatcher` to route failures into a `RedeliveryStore` instead. | +| `SilentEventProcessor` | Decorator — wraps any `EventProcessor` and logs (via PSR-3) any `Throwable` that escapes `process()` instead of letting it propagate. Use in production around the whole batch. | +| `ListenerDispatcher` | Interface — invokes one (subscriber, event) pair. Returns `void`; lets the listener exception propagate. | +| `DefaultListenerDispatcher` | Built-in — resolves the handler, hydrates the payload, calls the listener. Swallows exceptions listed in `ignoredExceptions`; otherwise rethrows. Stateless and side-effect-free. | +| `RedeliveringListenerDispatcher` | Decorator — wraps any `ListenerDispatcher` and on a throw schedules a fresh row in the `RedeliveryStore` (attempt 1, retry-now). Used by the event-processing flow only — the redelivery processor must run a plain dispatcher to avoid double-scheduling. | +| `RedeliveryProcessor` | Interface — drains a `RedeliveryStore`, mirroring `EventProcessor::process()` | +| `SequentialRedeliveryProcessor` | Built-in redelivery processor — owns the `RetryPolicy` and decides per due row whether to reschedule, mark succeeded, or mark failed permanently | +| `SilentRedeliveryProcessor` | Decorator — same shape as `SilentEventProcessor` but for `RedeliveryProcessor`. | | `EventSubscriberBuilder` | Fluent builder that produces a ready-to-use `EventSubscriberMap` | +| `RetryPolicy` | Interface — decides whether and when to retry a failed listener (consulted only by the redelivery processor) | +| `NoRetryPolicy` | Default — never retries | +| `ExponentialBackoffRetryPolicy` | Built-in — five attempts with `100ms / 500ms / 1min / 5min` backoff | +| `RedeliveryStore` | Interface — persists per-(event, listener) retry state and exposes `retryNow()` for admin tooling | +| `InMemoryRedeliveryStore` / `SqlRedeliveryStore` | `RedeliveryStore` implementations | --- @@ -108,6 +122,36 @@ $processor->process($store); --- +## Scheduled jobs + +A production deployment runs four scheduled jobs against this library. They touch +mostly-disjoint tables and rows, so they can run in parallel without contending. + +| Job | Recommended cadence | What it does | +|---|---|---| +| `$eventProcessor->process($eventStore)` | Every minute, or on demand after a business write | Drains all currently-pending events from `event_outbox` and dispatches each to its listeners. Loops internally until empty. See [Running your own processor](#running-your-own-processor). | +| `$redeliveryProcessor->process($redeliveryStore)` | Every minute | Drains all currently-due retries from `event_outbox_redelivery` (transitions each to `dispatching` and back). Loops internally until empty. See [Automatic retry & failure tracking](#automatic-retry--failure-tracking). | +| `$eventStore->recoverStuckEvents(CarbonInterval::minutes(30))` | Every 5–15 minutes | Resets `event_outbox` rows wedged in `processing` (worker crash victims) back to `pending`. Threshold should be comfortably longer than your longest healthy dispatch — see [Recovering stuck events](#recovering-stuck-events). | +| `$redeliveryStore->recoverStuckRedeliveries(CarbonInterval::minutes(30))` | Every 5–15 minutes | Resets `event_outbox_redelivery` rows wedged in `dispatching` (worker crash victims) back to `pending_retry`. Same threshold guidance as above. | + +`recoverStuckEvents` and `recoverStuckRedeliveries` are SQL-specific (live on `SqlEventStore` / +`SqlRedeliveryStore`). The other two work against any `EventStore` / `RedeliveryStore` implementation. + +The four jobs do not compete for the same rows: + +- `EventProcessor::process()` reads `event_outbox` rows in `pending`; `recoverStuckEvents()` reads rows in `processing` — disjoint by status filter. +- `RedeliveryProcessor::process()` reads `event_outbox_redelivery` rows in `pending_retry` and claims by transitioning to `dispatching`; `recoverStuckRedeliveries()` reads rows in `dispatching` — disjoint by status filter. +- The redelivery and event tables are separate. +- The lock from `SELECT ... FOR UPDATE SKIP LOCKED` is held through each claim, so concurrent workers running the same job never claim the same row either. + +The one cross-job interaction to think about: if a `recoverStuck*` sweeper resets a row while a +slow worker is still actively dispatching it, another worker can pick it up on the next tick +and re-fire its listener. Choose thresholds comfortably longer than your longest healthy +dispatch, and make sure listeners are idempotent (the at-least-once outbox contract requires +that anyway). + +--- + ## RawEvent `RawEvent` is an internal value object used by the store layer. Application code does not construct or receive `RawEvent` directly — use `EventSerializer` when publishing and `EventHydrator` when processing (see below). @@ -207,7 +251,7 @@ final class AppEventSerializer implements EventSerializer ## EventHydrator -`EventHydrator` reconstructs a domain event object from the stored `name` and `payload`. `SequentialEventProcessor` calls it once per subscriber, passing the subscriber as the third argument so the hydrator can resolve a different type for each listener. +`EventHydrator` reconstructs a domain event object from the stored `name` and `payload`. `DefaultListenerDispatcher` calls it once per subscriber, passing the subscriber as the third argument so the hydrator can resolve a different type for each listener. ```php interface EventHydrator @@ -271,7 +315,10 @@ $subscribers->subscribe('order.placed', function (OrderPlaced $event): void { echo "Order placed: " . $event->orderId . PHP_EOL; }); -$processor = new SequentialEventProcessor($subscribers, hydrator: new AppEventHydrator()); +$processor = new SequentialEventProcessor( + $subscribers, + new DefaultListenerDispatcher(hydrator: new AppEventHydrator()), +); ``` --- @@ -303,15 +350,83 @@ $store = new SqlEventStore($pdo); // schema created here if not present CREATE TABLE event_outbox ( id VARCHAR(36) NOT NULL PRIMARY KEY, name VARCHAR(255) NOT NULL, - status VARCHAR(255) NOT NULL, -- 'pending' | 'processed' | 'failed' + status VARCHAR(255) NOT NULL, -- 'pending' | 'processing' | 'processed' payload JSON NOT NULL, - created_at DATETIME NOT NULL, - publish_at DATETIME NOT NULL, - INDEX idx_event_outbox_status_publish (status, publish_at) + created_at DATETIME(6) NOT NULL, + publish_at DATETIME(6) NOT NULL, + INDEX idx_event_outbox_status_publish (status, publish_at), + INDEX idx_event_outbox_created_at (created_at) ); + +-- Audit trail: one row per event status transition (pending → processing → processed) +CREATE TABLE event_outbox_status ( + event_id VARCHAR(36) NOT NULL, + status VARCHAR(255) NOT NULL, + error_message TEXT, + created_at DATETIME(6) NOT NULL, + INDEX idx_event_outbox_status_event_created (event_id, created_at DESC) +); +``` + +> MySQL workers use `SELECT … FOR UPDATE SKIP LOCKED` on the `event_outbox` table for safe +> concurrent processing. + +If you wire up a `RedeliveryStore` (see [Automatic retry & failure tracking](#automatic-retry--failure-tracking)), +a third table `event_outbox_redelivery` is created on demand for per-listener retry state. + +### Event lifecycle + +The processor advances each event through three states: + +1. `pending` — written by `add()` inside the caller's transaction, alongside a matching audit row. +2. `processing` — set by `next()` when the worker claims the event. The row is *claimed* but not + yet declared finished. The audit table gets a second row. +3. `processed` — set by `markProcessed()` once **every** listener for the event has settled + (succeeded, been persisted to the redelivery queue, been swallowed by the ignored-exceptions + list, or been marked permanently failed). The audit table gets a third row. + +If a worker dies between `next()` and `markProcessed()`, the row stays in `processing` — +intentionally. Any redelivery rows that *did* get persisted before the crash remain durable, so +listener-level retries still fire when their time comes. To recover the wedged `processing` row +itself, call `SqlEventStore::recoverStuckEvents()` from a separate scheduled job (see +[Recovering stuck events](#recovering-stuck-events) below). + +### Recovering stuck events + +`SqlEventStore::recoverStuckEvents(CarbonInterval $olderThan): int` resets events that are stuck +in `processing` back to `pending` so the next worker can claim them again. An event is "stuck" +when its most recent `processing` audit row is older than `$olderThan`. The recovery writes a +`pending` audit row tagged `Recovered from stuck processing state` so dashboards can distinguish +organic vs. recovered transitions. The method returns the number of events it recovered and is +safe to run alongside the main worker. + +```php +use Carbon\CarbonInterval; + +// In a separate cron task — e.g. every minute: +$recovered = $store->recoverStuckEvents(CarbonInterval::minutes(30)); +``` + +The threshold should be comfortably longer than the longest dispatch you expect under healthy +conditions. Too tight a threshold will pull events that are simply taking a while back into +`pending` and double-dispatch them; listeners are expected to be idempotent regardless, but +unnecessary work is unnecessary work. + +> Re-dispatch can re-fire listeners that already succeeded on the previous run. Listeners must +> be idempotent for the recovery path to be safe. + +### Recovering stuck redeliveries + +The same shape applies to the redelivery table: +`SqlRedeliveryStore::recoverStuckRedeliveries(CarbonInterval $olderThan): int` resets rows +wedged in `dispatching` back to `pending_retry` so a worker can claim them again on the next +`processNextRedelivery()` tick. A row is "stuck" when its `updated_at` is older than `$olderThan`. + +```php +$recovered = $redeliveryStore->recoverStuckRedeliveries(CarbonInterval::minutes(30)); ``` -> MySQL workers use `SELECT … FOR UPDATE SKIP LOCKED` for safe concurrent processing. +Same threshold guidance and same idempotency requirement as `recoverStuckEvents`. --- @@ -386,39 +501,85 @@ class MyProcessor implements EventProcessor { while ($event = $store->next()) { // your dispatch logic + $store->markProcessed($event->id); } } } ``` +> Custom processors must call `$store->markProcessed($event->id)` once dispatch for an event is +> complete. `next()` only moves the row from `pending` to `processing`; the final advance to +> `processed` is the processor's responsibility, so it can hold the row in `processing` while it +> drives any per-listener retries. + +### Custom EventStore + +If you implement your own `EventStore`, you need to satisfy the new `markProcessed()` method +alongside `add()` and `next()`. For an in-memory or queue-style store with no persisted status, +this is a one-liner: + +```php +class MyEventStore implements EventStore +{ + public function add(RawEvent $event): void { /* ... */ } + public function next(): ?RawEvent { /* ... */ } + public function markProcessed(string $eventId): void + { + // No-op when there's no persisted status to flip. + } +} +``` + --- -## Silent event processing +## Fail-fast in dev, durable in prod + +In local development and CI, letting a failing listener throw immediately is exactly what you want — fast, noisy feedback. In production the calculus is different: a single listener failure should not prevent the remaining listeners from running, nor should an infrastructure blip (a DB hiccup, a connection drop) leave the worker dead. + +The library separates those concerns into two composable layers: + +- **`RedeliveringListenerDispatcher`** — a decorator on the dispatcher. Catches a listener throw and writes a fresh row to the `RedeliveryStore`, so the failure is durable but the rest of the event's listeners still run. +- **`SilentEventProcessor`** / **`SilentRedeliveryProcessor`** — decorators on the processor. Catch anything that escapes `process()` (infra-level failures, the redelivery store's own writes failing, etc.) and log via PSR-3. -In local development and CI, letting a failing listener throw immediately is exactly what you want — fast, noisy feedback. In production the calculus is different: a single listener failure should not prevent the remaining listeners for that event from running, nor should it block every subsequent event in the queue. `SilentSequentialEventProcessor` wraps each listener dispatch in a try-catch, logs the failure, and carries on. +In dev, you skip both decorators — anything that goes wrong propagates to the call site. In prod, you compose them. ```php -use Tcds\Io\Raw\Infrastructure\SilentSequentialEventProcessor; +// Dev / CI — listener throws propagate out of process() +$processor = new SequentialEventProcessor($subscribers); // DefaultListenerDispatcher by default +$processor->process($store); +``` + +```php +// Production — listener throws become redelivery rows; infra throws get logged +use Vesper\Tool\Event\Infrastructure\Dispatch\DefaultListenerDispatcher; +use Vesper\Tool\Event\Infrastructure\Dispatch\RedeliveringListenerDispatcher; +use Vesper\Tool\Event\Infrastructure\SequentialEventProcessor; +use Vesper\Tool\Event\Infrastructure\SilentEventProcessor; + +$dispatcher = new RedeliveringListenerDispatcher( + new DefaultListenerDispatcher(), + $redeliveryStore, +); -$processor = new SilentSequentialEventProcessor($subscribers, $logger); +$processor = new SilentEventProcessor( + new SequentialEventProcessor($subscribers, $dispatcher), + $logger, +); $processor->process($store); ``` -Each failure produces exactly one log entry per event–listener pair, so nothing is silently swallowed. Every exception is recorded with enough context to diagnose and replay the specific listener later: +The silent decorator logs each escape with the throwable attached so you can diagnose what aborted the batch: ``` -Failed to dispatch event to listener. -{ - event: "order.placed" - event_id: "019612a3-..." - listener: "App\Listeners\SendConfirmationEmail" // or "Closure" - exception: RuntimeException: ... -} +Event processor aborted. +{ exception: RuntimeException: ... } ``` +(`SilentRedeliveryProcessor` logs `Redelivery processor aborted.` in the same shape.) Per-listener failures are caught one layer down by `RedeliveringListenerDispatcher` and don't pass through the silent decorator at all. + ### PSR-3 logger -`SilentSequentialEventProcessor` accepts any [PSR-3](https://www.php-fig.org/psr/psr-3/) `LoggerInterface`. vesper php-events ships no concrete logger — supply whichever one your application already uses: +The silent decorators accept any [PSR-3](https://www.php-fig.org/psr/psr-3/) `LoggerInterface`. vesper php-events ships no concrete logger — supply whichever one your application already uses: ```php // Monolog @@ -428,35 +589,244 @@ use Monolog\Handler\StreamHandler; $logger = new Logger('events'); $logger->pushHandler(new StreamHandler('php://stderr')); -$processor = new SilentSequentialEventProcessor($subscribers, $logger); +$processor = new SilentEventProcessor( + new SequentialEventProcessor($subscribers, $dispatcher), + $logger, +); ``` ```php // Laravel (already PSR-3 compatible) -$processor = new SilentSequentialEventProcessor($subscribers, app('log')); +$processor = new SilentEventProcessor( + new SequentialEventProcessor($subscribers, $dispatcher), + app('log'), +); ``` -### Choosing a processor per environment +### Choosing a wiring per environment -| Environment | Processor | Behaviour | +| Environment | Wiring | Behaviour | |---|---|---| -| Local / CI | `SequentialEventProcessor` | Any listener exception propagates immediately — nothing is hidden | -| Production | `SilentSequentialEventProcessor` | A failing listener is logged and skipped; all other listeners and subsequent events continue processing | +| Local / CI | `SequentialEventProcessor` + `DefaultListenerDispatcher` | A listener throw propagates out of `process()`. Nothing is hidden. | +| Production | `SilentEventProcessor` wrapping `SequentialEventProcessor`, dispatcher = `RedeliveringListenerDispatcher(DefaultListenerDispatcher, $redeliveryStore)` | A listener throw is written to `event_outbox_redelivery` and the next listener runs. Anything else (DB blip, etc.) is logged and the batch aborts; the next scheduled tick picks it up. | + +--- + +## Automatic retry & failure tracking + +A failed listener should not become a silent loss. This library supports automatic retry and +durable failure tracking on a **per-(event, listener)** basis: if a single listener of an event +fails, only that listener is retried — the others continue to run and successful deliveries are +not re-fired. -> **Coming soon:** Automatic retry and failure tracking will be added to the base processor. `SilentSequentialEventProcessor` is designed to remain the right choice for production once that lands — it already captures everything needed to identify and reprocess a specific failed listener. +The model is **fully async**: when a listener throws inside `SequentialEventProcessor`, the +`RedeliveringListenerDispatcher` catches the exception and persists it to the redelivery table +immediately (with `attempt_number = 1` and `next_retry_at = now`); the main outbox worker then +moves on to the next listener / next event. A separate scheduled job runs +`SequentialRedeliveryProcessor::process()`, which drains the redelivery table and consults the +`RetryPolicy` per failed attempt to decide whether to reschedule or mark the row permanently +failed. + +The split keeps the event processor simple — it knows nothing about the redelivery store, +retry policies, attempt counts, or permanent failure. The dispatcher decorator owns +"failure → row"; the redelivery processor owns "row → retry decision". + +### Retry policy + +A `RetryPolicy` decides whether a failed dispatch should be retried, and at what time. It is +consulted only by `SequentialRedeliveryProcessor`, not by the event processor: + +```php +use Vesper\Tool\Event\Retry\RetryPolicy; + +interface RetryPolicy +{ + /** @return CarbonImmutable|null null when no further retries should be made */ + public function nextRetryAt(int $previousAttempt): ?CarbonImmutable; +} +``` + +Two implementations ship out of the box: + +- **`NoRetryPolicy`** *(default)* — never retries. The first redelivery attempt that fails is + marked permanently failed. +- **`ExponentialBackoffRetryPolicy`** — five total attempts (one initial + four retries) with + delays of `100ms, 500ms, 1min, 5min` by default. Every retry is persisted back to the + redelivery table and picked up on a future `RedeliveryProcessor::process()` run. + +```php +use Vesper\Tool\Event\Infrastructure\Retry\ExponentialBackoffRetryPolicy; + +// Default delays — 100ms, 500ms, 1min, 5min. +$retryPolicy = new ExponentialBackoffRetryPolicy(); + +// Or roll your own delays: +$retryPolicy = new ExponentialBackoffRetryPolicy(delaysMs: [50, 250, 1_000, 30_000]); +``` + +Without a `RedeliveringListenerDispatcher` wrapping the dispatcher, any listener throw +propagates out of `process()` (fail-fast) — there is nowhere to persist the failed attempt. +Wrap the dispatcher to enable durable retry. + +### Redelivery store + +The `RedeliveryStore` interface owns per-listener retry state — when an attempt failed, how +many attempts have been made, when the next one should run, what the last error was. Two +implementations: + +- **`InMemoryRedeliveryStore`** — array-backed, for tests and dev. +- **`SqlRedeliveryStore`** — durable, MySQL/SQLite-compatible. Auto-creates its + `event_outbox_redelivery` table on first construction. Worker-safe via `FOR UPDATE SKIP LOCKED` + on MySQL. + +```php +use Vesper\Tool\Event\Infrastructure\Redelivery\SqlRedeliveryStore; + +$store = new SqlRedeliveryStore($pdo); // schema created here if not present +``` + +### Wiring it together + +```php +use Vesper\Tool\Event\Infrastructure\Dispatch\DefaultListenerDispatcher; +use Vesper\Tool\Event\Infrastructure\Dispatch\RedeliveringListenerDispatcher; +use Vesper\Tool\Event\Infrastructure\Redelivery\SequentialRedeliveryProcessor; +use Vesper\Tool\Event\Infrastructure\Redelivery\SilentRedeliveryProcessor; +use Vesper\Tool\Event\Infrastructure\Redelivery\SqlRedeliveryStore; +use Vesper\Tool\Event\Infrastructure\Retry\ExponentialBackoffRetryPolicy; +use Vesper\Tool\Event\Infrastructure\SequentialEventProcessor; +use Vesper\Tool\Event\Infrastructure\SilentEventProcessor; + +$default = new DefaultListenerDispatcher( + ignoredExceptions: [ + UserNotFoundException::class, + InvalidPayloadException::class, + ], +); + +$redeliveryStore = new SqlRedeliveryStore($pdo); + +// Event flow: failures route into $redeliveryStore via the dispatcher decorator; +// anything else that escapes the batch is logged by SilentEventProcessor. +$eventProcessor = new SilentEventProcessor( + new SequentialEventProcessor( + $subscribers, + new RedeliveringListenerDispatcher($default, $redeliveryStore), + ), + $logger, +); + +// Redelivery flow: plain dispatcher (no Redelivering wrap — that would double-schedule). +// The retry policy decides whether each failed attempt reschedules or marks failed. +$redeliveryProcessor = new SilentRedeliveryProcessor( + new SequentialRedeliveryProcessor( + $subscribers, + $default, + new ExponentialBackoffRetryPolicy(), + ), + $logger, +); +``` + +Run the two processors as separate scheduled jobs: + +```php +// In one cron task / queue worker: +$eventProcessor->process($eventStore); + +// In another cron task / queue worker: +$redeliveryProcessor->process($redeliveryStore); +``` + +Long backoffs (e.g. the default 1min / 5min steps) won't be picked up until a future +`RedeliveryProcessor::process()` call after their `next_retry_at` passes — which is exactly how +outbox workers already poll on a schedule. + +### Ignored exceptions (skip-list) + +Some listener failures are not bugs — they're expected domain outcomes that the application +already handles upstream (e.g. `UserNotFoundException`, `OrderAlreadyShipped`). Retrying them +wastes time and reporting them spams the error tracker. + +The `ignoredExceptions` constructor parameter on `DefaultListenerDispatcher` takes a list of +`Throwable` class-strings. Matching is `instanceof`-based, so subclasses are also matched. When +a listener throws an ignored exception the dispatcher swallows it and returns normally — +indistinguishable to callers from a clean run: + +- **No retry attempt** — `RedeliveringListenerDispatcher` only catches throws that propagate + past `DefaultListenerDispatcher`, so ignored exceptions never reach the redelivery store. +- **No PSR-3 log line** — nothing escaped the processor for `SilentEventProcessor` to log. +- **No row written to `event_outbox_redelivery`** by the event flow. +- **No exception propagation** — the next listener for the same event runs as normal. +- **Marked succeeded** if encountered during redelivery — the listener has "handled" the event. + +The recommended pattern is to share the same list with whatever already configures your +application's error reporter (Sentry/Bugsnag/etc.) so behaviour stays consistent across the +boundary: anything your app considers "expected and not worth a page" is also considered +expected here. + +### Permanently failed dispatches + +When `SequentialRedeliveryProcessor` runs a due retry and the `RetryPolicy` returns `null` (no +more retries), the row is marked `status = 'failed'` in `event_outbox_redelivery`. The row +stays in the table so operators can inspect it. The redelivery processor does **not** throw — +the loop continues to the next due row. + +The event processor itself never marks anything `failed`. The only path to a `failed` row is +through the redelivery processor's retry-policy exhaustion. If you want fail-fast semantics in +local/CI, simply omit the `RedeliveringListenerDispatcher` wrapper — listener throws will then +propagate out of `process()` on the first attempt instead. + +### Re-triggering a failed dispatch + +`RedeliveryStore::retryNow($eventId, $listener)` re-queues a dispatch for immediate retry, +regardless of its current status (including `failed`). The attempt count is preserved — the +retry policy's max-attempts ceiling still applies on subsequent automatic failures. The library +ships no CLI; wire `retryNow()` into whatever admin surface you prefer (admin UI, Slack +command, console script, etc.). + +For listing failures, query `event_outbox_redelivery` directly. + +### Listener identity and closures + +The redelivery row's `listener` column is the class name for class-string subscribers, the +class name for invokable objects (`get_class($obj)`), or the literal string `'Closure'` for +anonymous closures. Class-string and invokable-object listeners can be reliably retried across +processes; closures cannot (their identity is not stable across process boundaries). If your +listener registrations and your retry policy together require closure tracking, use a class +that implements `__invoke()` instead. --- ## RawEventStatus ```php -RawEventStatus::pending // event is waiting to be processed -RawEventStatus::processed // event was successfully dequeued -RawEventStatus::failed // reserved for failed-delivery tracking +RawEventStatus::pending // event is waiting to be processed +RawEventStatus::processing // event has been claimed by a worker; dispatch in flight +RawEventStatus::processed // event was dispatched to every listener (per-listener outcomes live in event_outbox_redelivery) ``` --- +## Operational queries + +Useful queries against the existing tables: + +- Listeners with permanent failures — `SELECT * FROM event_outbox_redelivery WHERE status = 'failed'`. +- Events recovered by the stuck-events sweeper — `SELECT * FROM event_outbox_status WHERE error_message = 'Recovered from stuck processing state'`. +- Dispatch latency — average time between consecutive rows in `event_outbox_status` for the same `event_id`. + +`recoverStuckEvents()` re-claims wedged rows (`processing → pending`) for re-dispatch. The other +plausible recovery mode — **force-complete** (`processing → processed`) — is intentionally not +exposed: it's only safe when the dispatch is believed to have finished but the bookkeeping +commit was lost, and that's a judgement call that belongs in operator tooling rather than a +library API. If you need it, the operation is two statements: `UPDATE event_outbox SET status = +'processed' WHERE id = ?` and an audit insert with a recovery marker. + +See [ROADMAP.md](ROADMAP.md) for known limitations and planned work. + +--- + ## Testing ```bash diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..327d3cb --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,134 @@ +# Roadmap + +Known limitations and planned work. The transactional outbox, per-listener +redelivery, ignored-exceptions skip-list and stuck-events recovery are shipped +and stable. Everything below is intentionally deferred until real usage shapes +the design — items are listed in roughly the order we expect to need them. + +## Operational concerns + +These matter once the library is running in production for a while. + +### Audit table pruning + +`event_outbox_status` appends a row per status transition and never shrinks. +`event_outbox_redelivery` keeps `succeeded` and `failed` rows forever. At +modest volume this is fine — at scale the audit table is the first thing to +get heavy. + +**Likely shape:** `SqlEventStore::pruneAuditRowsOlderThan(CarbonInterval $age, +int $batchSize = 1000): int` and a matching method on the redelivery tracker. +Called from a daily cron. Deletes in bounded batches to keep the lock window +short. + +**Trigger:** when the audit table crosses some millions of rows or shows up +in slow-query logs. + +### Metric / observability hooks + +There's no first-class way to observe queue depth, dispatch latency, retry +rates, or recovered-stuck-event counts. Users have to write their own SQL or +wrap the processor. + +**Likely shape:** a metering decorator on `ListenerDispatcher` (records +dispatch counts and latency per listener) and one on `EventProcessor` / +`RedeliveryProcessor` (records batch size and duration). Same composition +pattern as `SilentEventProcessor` and `RedeliveringListenerDispatcher` — no +new interface needed. Users plug StatsD / OpenTelemetry / Prometheus +backends in from there. + +**Trigger:** first time an oncall needs to answer "is the outbox keeping up?" +and there's no dashboard. + +### Stack trace in `last_error` + +`SqlRedeliveryStore` records the failed dispatch's error as +`ExceptionClass: message`. Useful for inspection but loses the throw site. +For permanent failures discovered weeks later, the missing context bites. + +**Likely shape:** widen `last_error` to include either the first stack frame +(`Class: message at File:Line`) or a serialised trace (TEXT → MEDIUMTEXT, JSON +column on MySQL). + +**Trigger:** first post-mortem where a permanently-failed row is unreadable. + +### Schema migrations + +Auto-create-on-construct is great for first boot but provides no path for +schema evolution. Adding a column (e.g. `claimed_until` for a lease-based +redelivery claim) won't backfill existing installations. + +**Likely shape:** a `schema_version` column on each table + a +`migrate()` method that runs idempotent ALTERs ordered by version. + +**Trigger:** the first time we need to change an existing column. + +## Scale concerns + +These matter once volume grows past a single moderate-sized service. + +### Batch claim and update + +Both `SqlEventStore::next()` and `SqlRedeliveryStore::next()` claim one row +per call inside their own transaction; `markProcessed()` and +`update()` each write back in their own transaction too. The processor loops +internally until empty, so 10k due rows means ~20k tiny transactions per +worker pass. + +**Likely shape:** `next(int $batchSize): list` / `list` +that claim N rows in one transaction, and a matching batched write-back. The +processor loop becomes "claim batch, dispatch each, write batch back." + +**Trigger:** first time a worker can't drain the queue at its current +cadence, or first time the per-transaction overhead shows up in DB load +graphs. + +### Per-aggregate ordering + +The processor dispatches events ordered by `publish_at`. Multiple events for +the same aggregate (same order, same user) can interleave across workers. For +many domains this is fine — for some (state transitions on the same entity) +it isn't. + +**Likely shape:** optional `partition_key` column on `event_outbox`. `next()` +prefers rows whose partition isn't already being processed. Either via a +distributed lock per partition or by including the partition in the claim +predicate. + +**Trigger:** first incident caused by two events for the same entity firing +out of order. + +### Idempotency tokens + +The library expects listeners to be idempotent (especially after a stuck-event +recovery re-dispatch) but doesn't help them be idempotent. A typical +production pattern: hand each dispatch a unique key that listeners can persist +in a dedup table to short-circuit duplicates. + +**Likely shape:** pass an `idempotency_key` to listeners alongside the +hydrated event (e.g. `event.{event_id}.listener.{listener_class}.attempt.{n}` +). Optional table + helper for storing/checking it. + +**Trigger:** first listener that can't safely re-execute (payment capture, +external API call without its own dedup). + +## Ergonomics + +### Per-listener retry policies + +The processor uses a single global `RetryPolicy`. Some listeners deserve +aggressive retries (billing, critical workflows); others should fail fast +(best-effort notifications). Spring Modulith supports per-handler policies +via annotations. + +**Likely shape:** `EventSubscriberMap` already maps name → callable[]. Extend +to map (name, listener) → RetryPolicy with a default fallback. + +**Trigger:** first time a fail-fast listener pollutes redelivery with rows +that will never succeed. + +## Reference reading + +- [Spring Modulith — Event Publication Registry](https://docs.spring.io/spring-modulith/reference/events.html) — closest reference shape (per-listener rows, status enum including `PROCESSING` / `RESUBMITTED`, completion attempts). +- [gruelbox/transaction-outbox](https://github.com/gruelbox/transaction-outbox) — alternative model with no `processing` state (uses optimistic-lock `version` column + `nextAttemptTime` lease). Worth understanding for context on why our model needs `processing` and theirs doesn't (their event = single dispatch; ours = multiple listeners). +- [AWS Prescriptive Guidance: Transactional Outbox](https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html) — high-level pattern doc. diff --git a/composer.lock b/composer.lock index 6c38dfb..ef744d7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5af8257fa5d172018110c809270e9083", + "content-hash": "2a7bb8226f0e70ed970c8636d32fdc11", "packages": [ { "name": "brick/math", @@ -137,16 +137,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.3", + "version": "3.11.4", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "6a7e652845bb018c668220c2a545aded8594fbbf" + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/6a7e652845bb018c668220c2a545aded8594fbbf", - "reference": "6a7e652845bb018c668220c2a545aded8594fbbf", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", "shasum": "" }, "require": { @@ -238,7 +238,54 @@ "type": "tidelift" } ], - "time": "2026-03-11T17:23:39+00:00" + "time": "2026-04-07T09:57:54+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.2", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + }, + "time": "2026-01-25T14:56:51+00:00" }, { "name": "psr/clock", @@ -494,16 +541,16 @@ }, { "name": "symfony/clock", - "version": "v8.0.0", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f" + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/832119f9b8dbc6c8e6f65f30c5969eca1e88764f", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f", + "url": "https://api.github.com/repos/symfony/clock/zipball/b55a638b189a6faa875e0ccdb00908fb87af95b3", + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3", "shasum": "" }, "require": { @@ -547,7 +594,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v8.0.0" + "source": "https://github.com/symfony/clock/tree/v8.0.8" }, "funding": [ { @@ -567,20 +614,20 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:46:48+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", "shasum": "" }, "require": { @@ -632,7 +679,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" }, "funding": [ { @@ -652,20 +699,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/translation", - "version": "v8.0.6", + "version": "v8.0.10", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b" + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", - "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", + "url": "https://api.github.com/repos/symfony/translation/zipball/f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67", "shasum": "" }, "require": { @@ -725,7 +772,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.6" + "source": "https://github.com/symfony/translation/tree/v8.0.10" }, "funding": [ { @@ -745,20 +792,20 @@ "type": "tidelift" } ], - "time": "2026-02-17T13:07:04+00:00" + "time": "2026-05-06T11:30:54+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", "shasum": "" }, "require": { @@ -771,7 +818,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -807,7 +854,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" }, "funding": [ { @@ -827,29 +874,30 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2026-01-05T13:30:16+00:00" }, { "name": "tcds-io/php-better-generics", - "version": "0.1.1", + "version": "1.0.0", "source": { "type": "git", "url": "https://github.com/tcds-io/php-better-generics.git", - "reference": "2e87e584898f4bc4393f96f1e980d7a612121268" + "reference": "20dfb6284e14718ff6fe68147a5676751e0c5123" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tcds-io/php-better-generics/zipball/2e87e584898f4bc4393f96f1e980d7a612121268", - "reference": "2e87e584898f4bc4393f96f1e980d7a612121268", + "url": "https://api.github.com/repos/tcds-io/php-better-generics/zipball/20dfb6284e14718ff6fe68147a5676751e0c5123", + "reference": "20dfb6284e14718ff6fe68147a5676751e0c5123", "shasum": "" }, "require": { - "php": "^8.4" + "php": "^8.4", + "phpstan/phpdoc-parser": "^2.0" }, "require-dev": { "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^10.5", - "slevomat/coding-standard": "^8.15", + "slevomat/coding-standard": "^8.16", "symfony/var-dumper": "^7.2" }, "type": "library", @@ -874,28 +922,28 @@ "description": "PHP library to better work with generics", "support": { "issues": "https://github.com/tcds-io/php-better-generics/issues", - "source": "https://github.com/tcds-io/php-better-generics/tree/0.1.1" + "source": "https://github.com/tcds-io/php-better-generics/tree/1.0.0" }, - "time": "2026-01-15T19:13:14+00:00" + "time": "2026-05-03T15:24:21+00:00" }, { "name": "tcds-io/php-jackson", - "version": "1.0.1", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/tcds-io/php-jackson.git", - "reference": "ab1c5bc134900f1cdb17aa8074b6bb3f93dcd0b8" + "reference": "ee84851810743a9f39852bed025ad0041a4fbd96" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tcds-io/php-jackson/zipball/ab1c5bc134900f1cdb17aa8074b6bb3f93dcd0b8", - "reference": "ab1c5bc134900f1cdb17aa8074b6bb3f93dcd0b8", + "url": "https://api.github.com/repos/tcds-io/php-jackson/zipball/ee84851810743a9f39852bed025ad0041a4fbd96", + "reference": "ee84851810743a9f39852bed025ad0041a4fbd96", "shasum": "" }, "require": { "ext-json": "*", "php": ">=8.4", - "tcds-io/php-better-generics": "^0.1.0" + "tcds-io/php-better-generics": "^1.0" }, "require-dev": { "nesbot/carbon": "^3.10", @@ -920,9 +968,9 @@ "description": "A lightweight, flexible object serializer for PHP, inspired by FasterXML/jackson", "support": { "issues": "https://github.com/tcds-io/php-jackson/issues", - "source": "https://github.com/tcds-io/php-jackson/tree/1.0.1" + "source": "https://github.com/tcds-io/php-jackson/tree/1.1.0" }, - "time": "2026-01-14T12:49:57+00:00" + "time": "2026-05-03T19:20:53+00:00" } ], "packages-dev": [ @@ -1164,16 +1212,16 @@ }, { "name": "php-cs-fixer/shim", - "version": "v3.94.2", + "version": "v3.95.2", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/shim.git", - "reference": "80fd29f44a736136a2f05bae5464816a444b91d1" + "reference": "319bd80c8db64ab5f7b79a19178299045bdb9957" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/80fd29f44a736136a2f05bae5464816a444b91d1", - "reference": "80fd29f44a736136a2f05bae5464816a444b91d1", + "url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/319bd80c8db64ab5f7b79a19178299045bdb9957", + "reference": "319bd80c8db64ab5f7b79a19178299045bdb9957", "shasum": "" }, "require": { @@ -1210,17 +1258,17 @@ "description": "A tool to automatically fix PHP code style", "support": { "issues": "https://github.com/PHP-CS-Fixer/shim/issues", - "source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.94.2" + "source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.95.2" }, - "time": "2026-02-20T16:14:17+00:00" + "time": "2026-05-15T09:21:09+00:00" }, { "name": "phpstan/phpstan", - "version": "2.1.40", + "version": "2.1.54", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", - "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", "shasum": "" }, "require": { @@ -1265,20 +1313,20 @@ "type": "github" } ], - "time": "2026-02-23T15:04:35+00:00" + "time": "2026-04-29T13:31:09+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.3", + "version": "12.5.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" + "reference": "876099a072646c7745f673d7aeab5382c4439691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691", + "reference": "876099a072646c7745f673d7aeab5382c4439691", "shasum": "" }, "require": { @@ -1287,7 +1335,6 @@ "ext-xmlwriter": "*", "nikic/php-parser": "^5.7.0", "php": ">=8.3", - "phpunit/php-file-iterator": "^6.0", "phpunit/php-text-template": "^5.0", "sebastian/complexity": "^5.0", "sebastian/environment": "^8.0.3", @@ -1334,7 +1381,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6" }, "funding": [ { @@ -1354,7 +1401,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T06:01:44+00:00" + "time": "2026-04-15T08:23:17+00:00" }, { "name": "phpunit/php-file-iterator", @@ -1615,16 +1662,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.14", + "version": "12.5.25", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0" + "reference": "792c2980442dfce319226b88fa845b8b6de3b333" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/792c2980442dfce319226b88fa845b8b6de3b333", + "reference": "792c2980442dfce319226b88fa845b8b6de3b333", "shasum": "" }, "require": { @@ -1638,15 +1685,15 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-code-coverage": "^12.5.6", "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.4", + "sebastian/comparator": "^7.1.6", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.3", + "sebastian/environment": "^8.1.0", "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", @@ -1693,31 +1740,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.25" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-18T12:38:40+00:00" + "time": "2026-05-13T03:56:57+00:00" }, { "name": "sebastian/cli-parser", @@ -1790,16 +1821,16 @@ }, { "name": "sebastian/comparator", - "version": "7.1.4", + "version": "7.1.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" + "reference": "c769009dee98f494e0edc3fd4f4087501688f11e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c769009dee98f494e0edc3fd4f4087501688f11e", + "reference": "c769009dee98f494e0edc3fd4f4087501688f11e", "shasum": "" }, "require": { @@ -1858,7 +1889,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.6" }, "funding": [ { @@ -1878,7 +1909,7 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:28:48+00:00" + "time": "2026-04-14T08:23:15+00:00" }, { "name": "sebastian/complexity", @@ -2007,16 +2038,16 @@ }, { "name": "sebastian/environment", - "version": "8.0.3", + "version": "8.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68" + "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6", + "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6", "shasum": "" }, "require": { @@ -2031,7 +2062,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -2059,7 +2090,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.3" + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.0" }, "funding": [ { @@ -2079,7 +2110,7 @@ "type": "tidelift" } ], - "time": "2025-08-12T14:11:56+00:00" + "time": "2026-04-15T12:13:01+00:00" }, { "name": "sebastian/exporter", @@ -2670,16 +2701,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -2692,7 +2723,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -2717,7 +2748,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -2728,25 +2759,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", "shasum": "" }, "require": { @@ -2800,7 +2835,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" }, "funding": [ { @@ -2820,7 +2855,7 @@ "type": "tidelift" } ], - "time": "2026-02-15T10:53:20+00:00" + "time": "2026-03-30T13:44:50+00:00" }, { "name": "theseer/tokenizer", diff --git a/src/Dispatch/ListenerDispatcher.php b/src/Dispatch/ListenerDispatcher.php new file mode 100644 index 0000000..2420bfc --- /dev/null +++ b/src/Dispatch/ListenerDispatcher.php @@ -0,0 +1,12 @@ + $listeners List of listeners to register for the event. + * @param list $listeners * * @return self */ @@ -40,8 +39,8 @@ public function eventName(string $name, array $listeners): self } /** - * @param Subscriber $listener Listener class to register for the events. - * @param list $names Plain, non-class event names to register the listener for. + * @param Subscriber $listener + * @param list $names * * @return self */ diff --git a/src/EventSubscriberMap.php b/src/EventSubscriberMap.php index 27b3f53..122b6e6 100644 --- a/src/EventSubscriberMap.php +++ b/src/EventSubscriberMap.php @@ -11,13 +11,10 @@ class EventSubscriberMap /** * @param array> $subscribers */ - public function __construct(private array $subscribers = []) - { - } + public function __construct(private array $subscribers = []) {} /** - * @param string $name The event name to subscribe to. - * @param Subscriber $subscriber A subscriber to be called when an event with the given name is dispatched. + * @param Subscriber $subscriber */ public function subscribe(string $name, callable|string $subscriber): void { diff --git a/src/Infrastructure/Dispatch/DefaultListenerDispatcher.php b/src/Infrastructure/Dispatch/DefaultListenerDispatcher.php new file mode 100644 index 0000000..7516374 --- /dev/null +++ b/src/Infrastructure/Dispatch/DefaultListenerDispatcher.php @@ -0,0 +1,49 @@ +> $ignoredExceptions */ + public function __construct( + private HandlerResolver $resolver = new DefaultHandlerResolver(), + private EventHydrator $hydrator = new JacksonHydrator(), + private array $ignoredExceptions = [], + ) {} + + #[Override] public function dispatch(RawEvent $event, callable|string $subscriber): void + { + $callable = $this->resolver->resolve($subscriber); + $domainEvent = $this->hydrator->hydrate($event->name, $event->payload, $callable); + + try { + $callable($domainEvent); + } catch (Throwable $e) { + if ($this->isIgnored($e)) { + return; + } + + throw $e; + } + } + + private function isIgnored(Throwable $error): bool + { + foreach ($this->ignoredExceptions as $class) { + if ($error instanceof $class) { + return true; + } + } + + return false; + } +} diff --git a/src/Infrastructure/Dispatch/RedeliveringListenerDispatcher.php b/src/Infrastructure/Dispatch/RedeliveringListenerDispatcher.php new file mode 100644 index 0000000..c8ea419 --- /dev/null +++ b/src/Infrastructure/Dispatch/RedeliveringListenerDispatcher.php @@ -0,0 +1,35 @@ +inner->dispatch($event, $subscriber); + } catch (Throwable $e) { + $this->store->schedule(Redelivery::schedule( + event: $event, + listener: ListenerKey::of($subscriber), + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now(), + lastError: $e, + )); + } + } +} diff --git a/src/Infrastructure/InMemoryEventStore.php b/src/Infrastructure/InMemoryEventStore.php index 6c8c952..0cf9cdc 100644 --- a/src/Infrastructure/InMemoryEventStore.php +++ b/src/Infrastructure/InMemoryEventStore.php @@ -18,6 +18,13 @@ class InMemoryEventStore implements EventStore #[Override] public function next(): ?RawEvent { - return array_shift($this->queue) ?? null; + $event = array_shift($this->queue); + + return $event?->claim(); + } + + #[Override] public function markProcessed(RawEvent $event): void + { + // No-op: the in-memory queue discards events on next(); there is no persisted status to flip. } } diff --git a/src/Infrastructure/Redelivery/InMemoryRedeliveryStore.php b/src/Infrastructure/Redelivery/InMemoryRedeliveryStore.php new file mode 100644 index 0000000..739e0e6 --- /dev/null +++ b/src/Infrastructure/Redelivery/InMemoryRedeliveryStore.php @@ -0,0 +1,82 @@ + */ + private array $rows = []; + + #[Override] + public function schedule(Redelivery $redelivery): void + { + $key = self::key($redelivery->eventId(), $redelivery->listener); + + if (isset($this->rows[$key])) { + return; + } + + $this->rows[$key] = $redelivery; + } + + #[Override] + public function next(): ?Redelivery + { + $now = CarbonImmutable::now(); + $candidate = null; + $candidateKey = null; + + foreach ($this->rows as $key => $row) { + if (!$row->isDue($now)) { + continue; + } + if ($candidate === null || $row->nextRetryAt->lessThan($candidate->nextRetryAt)) { + $candidate = $row; + $candidateKey = $key; + } + } + + if ($candidate === null || $candidateKey === null) { + return null; + } + + $claimed = $candidate->claim(); + $this->rows[$candidateKey] = $claimed; + + return $claimed; + } + + #[Override] + public function update(Redelivery $redelivery): void + { + $key = self::key($redelivery->eventId(), $redelivery->listener); + + if (!isset($this->rows[$key]) || !$this->rows[$key]->isDispatching()) { + return; + } + + $this->rows[$key] = $redelivery; + } + + #[Override] + public function retryNow(string $eventId, string $listener): void + { + $key = self::key($eventId, $listener); + + if (!isset($this->rows[$key])) { + return; + } + + $this->rows[$key] = $this->rows[$key]->queueForImmediateRetry(); + } + + private static function key(string $eventId, string $listener): string + { + return $eventId . '|' . $listener; + } +} diff --git a/src/Infrastructure/Redelivery/SequentialRedeliveryProcessor.php b/src/Infrastructure/Redelivery/SequentialRedeliveryProcessor.php new file mode 100644 index 0000000..6cee82c --- /dev/null +++ b/src/Infrastructure/Redelivery/SequentialRedeliveryProcessor.php @@ -0,0 +1,76 @@ + $subscribers */ + public function __construct( + private EventSubscriberMap $subscribers, + private ListenerDispatcher $dispatcher, + private RetryPolicy $retryPolicy = new NoRetryPolicy(), + ) {} + + #[Override] public function process(RedeliveryStore $store): void + { + while ($redelivery = $store->next()) { + if ($this->retryPolicy instanceof NoRetryPolicy) { + $store->update($redelivery->markFailedPermanently( + new RuntimeException("No retry policy configured; redelivery for listener '$redelivery->listener' on event '{$redelivery->eventName()}' marked failed without dispatch."), + )); + + continue; + } + + $subscriber = $this->findRegisteredSubscriber($redelivery); + + if ($subscriber === null) { + $store->update($redelivery->markFailedPermanently( + new RuntimeException("Listener '$redelivery->listener' is no longer registered for event '{$redelivery->eventName()}'."), + )); + + continue; + } + + try { + $this->dispatcher->dispatch($redelivery->event, $subscriber); + + $store->update($redelivery->markSucceeded()); + } catch (Throwable $error) { + $attemptsMade = $redelivery->attemptNumber + 1; + $nextRetryAt = $this->retryPolicy->nextRetryAt($attemptsMade); + + if ($nextRetryAt === null) { + $store->update($redelivery->markFailedPermanently($error)); + + continue; + } + + $store->update($redelivery->rescheduleTo($attemptsMade, $nextRetryAt, $error)); + } + } + } + + private function findRegisteredSubscriber(Redelivery $redelivery): callable|string|null + { + foreach ($this->subscribers->of($redelivery->eventName()) as $subscriber) { + if ($redelivery->isFor(ListenerKey::of($subscriber))) { + return $subscriber; + } + } + + return null; + } +} diff --git a/src/Infrastructure/Redelivery/SilentRedeliveryProcessor.php b/src/Infrastructure/Redelivery/SilentRedeliveryProcessor.php new file mode 100644 index 0000000..a0a7bcd --- /dev/null +++ b/src/Infrastructure/Redelivery/SilentRedeliveryProcessor.php @@ -0,0 +1,26 @@ +inner->process($store); + } catch (Throwable $e) { + $this->logger->error('Redelivery processor aborted.', ['exception' => $e]); + } + } +} diff --git a/src/Infrastructure/Redelivery/SqlRedeliveryStore.php b/src/Infrastructure/Redelivery/SqlRedeliveryStore.php new file mode 100644 index 0000000..b75f043 --- /dev/null +++ b/src/Infrastructure/Redelivery/SqlRedeliveryStore.php @@ -0,0 +1,275 @@ +ensureRedeliverySchema(); + } + + #[Override] + public function schedule(Redelivery $redelivery): void + { + $sql = match ($this->driverName()) { + 'mysql' => << << throw new RuntimeException('Unsupported database driver: ' . $this->driverName()), + }; + + $this->connection->prepare($sql)->execute([ + 'event_id' => $redelivery->eventId(), + 'listener' => $redelivery->listener, + 'status' => $redelivery->status->value, + 'attempt_number' => $redelivery->attemptNumber, + 'next_retry_at' => $redelivery->nextRetryAt->format('Y-m-d H:i:s.u'), + 'last_error' => $redelivery->lastError, + 'created_at' => $redelivery->createdAt->format('Y-m-d H:i:s.u'), + 'updated_at' => $redelivery->updatedAt->format('Y-m-d H:i:s.u'), + ]); + } + + #[Override] + public function next(): ?Redelivery + { + $this->connection->beginTransaction(); + + try { + $row = $this->fetchNextDueRow(); + + if ($row === null) { + $this->connection->commit(); + + return null; + } + + $claimed = $row->claim(); + $this->writeClaim($claimed); + $this->connection->commit(); + + return $claimed; + } catch (Throwable $e) { + $this->rollBackIfActive(); + + throw $e; + } + } + + /** UPDATE is guarded by status='dispatching' so a sweeper that has already reset the row is not overwritten. */ + #[Override] + public function update(Redelivery $redelivery): void + { + $this->connection->prepare( + <<execute([ + 'new_status' => $redelivery->status->value, + 'attempt_number' => $redelivery->attemptNumber, + 'next_retry_at' => $redelivery->nextRetryAt->format('Y-m-d H:i:s.u'), + 'last_error' => $redelivery->lastError, + 'updated_at' => $redelivery->updatedAt->format('Y-m-d H:i:s.u'), + 'event_id' => $redelivery->eventId(), + 'listener' => $redelivery->listener, + 'current_status' => RedeliveryStatus::Dispatching->value, + ]); + } + + #[Override] + public function retryNow(string $eventId, string $listener): void + { + $now = CarbonImmutable::now()->format('Y-m-d H:i:s.u'); + + $this->connection->prepare( + <<execute([ + 'status' => RedeliveryStatus::PendingRetry->value, + 'next_retry_at' => $now, + 'updated_at' => $now, + 'event_id' => $eventId, + 'listener' => $listener, + ]); + } + + /** + * Reset redelivery rows wedged in `dispatching` back to `pending_retry`. A row is "stuck" + * when its updated_at is older than $olderThan. Returns the number of rows recovered. + * Call from a separate scheduled job; safe to run alongside the redelivery worker. + */ + public function recoverStuckRedeliveries(CarbonInterval $olderThan): int + { + $now = CarbonImmutable::now()->format('Y-m-d H:i:s.u'); + $thresholdAt = CarbonImmutable::now()->sub($olderThan)->format('Y-m-d H:i:s.u'); + + $stmt = $this->connection->prepare( + <<execute([ + 'pending' => RedeliveryStatus::PendingRetry->value, + 'dispatching' => RedeliveryStatus::Dispatching->value, + 'now' => $now, + 'threshold' => $thresholdAt, + ]); + + return $stmt->rowCount(); + } + + private function writeClaim(Redelivery $claimed): void + { + $this->connection->prepare( + <<execute([ + 'status' => $claimed->status->value, + 'updated_at' => $claimed->updatedAt->format('Y-m-d H:i:s.u'), + 'event_id' => $claimed->eventId(), + 'listener' => $claimed->listener, + ]); + } + + private function fetchNextDueRow(): ?Redelivery + { + $lockClause = $this->lockingClause(); + + $stmt = $this->connection->prepare( + <<execute([ + 'status' => RedeliveryStatus::PendingRetry->value, + 'now' => CarbonImmutable::now()->format('Y-m-d H:i:s.u'), + ]); + + /** @var array{event_id: string, listener: string, r_status: string, attempt_number: int, next_retry_at: string, last_error: ?string, r_created_at: string, r_updated_at: string, event_name: string, event_status: string, event_payload: string, event_created_at: string, event_publish_at: string}|false $row */ + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (false === $row) { + return null; + } + + /** @var array $payload */ + $payload = json_decode($row['event_payload'], true, flags: JSON_THROW_ON_ERROR); + + $event = RawEvent::retrieve( + id: $row['event_id'], + name: $row['event_name'], + status: RawEventStatus::from($row['event_status']), + payload: $payload, + createdAt: new CarbonImmutable($row['event_created_at']), + publishAt: new CarbonImmutable($row['event_publish_at']), + ); + + return Redelivery::retrieve( + event: $event, + listener: $row['listener'], + status: RedeliveryStatus::from($row['r_status']), + attemptNumber: (int) $row['attempt_number'], + nextRetryAt: new CarbonImmutable($row['next_retry_at']), + lastError: $row['last_error'] ?? '', + createdAt: new CarbonImmutable($row['r_created_at']), + updatedAt: new CarbonImmutable($row['r_updated_at']), + ); + } + + /** Guards against the implicit-commit case where a DDL statement closed the transaction before the failure. */ + private function rollBackIfActive(): void + { + if ($this->connection->inTransaction()) { + $this->connection->rollBack(); + } + } + + private function ensureRedeliverySchema(): void + { + $driver = $this->driverName(); + + match ($driver) { + 'mysql' => MysqlRedeliverySchema::create($this->connection), + 'sqlite' => SqliteRedeliverySchema::create($this->connection), + default => throw new RuntimeException('Unsupported database driver: ' . $driver), + }; + } + + private function lockingClause(): string + { + return match ($this->driverName()) { + 'mysql' => 'FOR UPDATE SKIP LOCKED', + default => '', + }; + } + + private function driverName(): string + { + /** @var string */ + return $this->connection->getAttribute(PDO::ATTR_DRIVER_NAME); + } +} diff --git a/src/Infrastructure/Retry/ExponentialBackoffRetryPolicy.php b/src/Infrastructure/Retry/ExponentialBackoffRetryPolicy.php new file mode 100644 index 0000000..71522a5 --- /dev/null +++ b/src/Infrastructure/Retry/ExponentialBackoffRetryPolicy.php @@ -0,0 +1,43 @@ + */ + private array $delaysMs; + + /** + * @param list $delaysMs delay before each retry in milliseconds; the i-th element is the + * delay before attempt (i + 2). Default: 100ms, 500ms, 1min, 5min, + * yielding 5 total attempts (one initial + four retries). + */ + public function __construct( + array $delaysMs = [100, 500, 60_000, 300_000], + ) { + foreach ($delaysMs as $ms) { + if ($ms < 0) { + throw new InvalidArgumentException("Retry delays must be non-negative, got {$ms}."); + } + } + + $this->delaysMs = $delaysMs; + } + + #[Override] + public function nextRetryAt(int $previousAttempt): ?CarbonImmutable + { + $index = $previousAttempt - 1; + + if ($index < 0 || $index >= count($this->delaysMs)) { + return null; + } + + return CarbonImmutable::now()->addMilliseconds($this->delaysMs[$index]); + } +} diff --git a/src/Infrastructure/Retry/NoRetryPolicy.php b/src/Infrastructure/Retry/NoRetryPolicy.php new file mode 100644 index 0000000..eb82acb --- /dev/null +++ b/src/Infrastructure/Retry/NoRetryPolicy.php @@ -0,0 +1,16 @@ +prepare( <<execute(['table' => $table]); diff --git a/src/Infrastructure/Schema/MysqlRedeliverySchema.php b/src/Infrastructure/Schema/MysqlRedeliverySchema.php new file mode 100644 index 0000000..d241daa --- /dev/null +++ b/src/Infrastructure/Schema/MysqlRedeliverySchema.php @@ -0,0 +1,50 @@ +prepare( + <<execute(['table' => $table]); + + if ($stmt->fetchColumn()) { + return; + } + + $connection->exec($creationQuery); + } +} diff --git a/src/Infrastructure/Schema/SqliteEventStoreSchema.php b/src/Infrastructure/Schema/SqliteEventStoreSchema.php index c9fddd8..5446e23 100644 --- a/src/Infrastructure/Schema/SqliteEventStoreSchema.php +++ b/src/Infrastructure/Schema/SqliteEventStoreSchema.php @@ -7,24 +7,20 @@ class SqliteEventStoreSchema { - /** - * Ensure the outbox tables and indexes exist - * - * @throws PDOException - */ + /** @throws PDOException */ public static function create(PDO $connection): void { $connection->exec( <<exec( @@ -39,13 +35,13 @@ public static function create(PDO $connection): void $connection->exec( <<exec( diff --git a/src/Infrastructure/Schema/SqliteRedeliverySchema.php b/src/Infrastructure/Schema/SqliteRedeliverySchema.php new file mode 100644 index 0000000..78e73b4 --- /dev/null +++ b/src/Infrastructure/Schema/SqliteRedeliverySchema.php @@ -0,0 +1,37 @@ +exec( + <<exec( + 'CREATE INDEX IF NOT EXISTS idx_redelivery_due + ON event_outbox_redelivery (status, next_retry_at)', + ); + + $connection->exec( + 'CREATE INDEX IF NOT EXISTS idx_redelivery_event_id + ON event_outbox_redelivery (event_id)', + ); + } +} diff --git a/src/Infrastructure/SequentialEventProcessor.php b/src/Infrastructure/SequentialEventProcessor.php index 3807919..bd65a72 100644 --- a/src/Infrastructure/SequentialEventProcessor.php +++ b/src/Infrastructure/SequentialEventProcessor.php @@ -3,40 +3,29 @@ namespace Vesper\Tool\Event\Infrastructure; use Override; -use Vesper\Tool\Event\EventHydrator; +use Vesper\Tool\Event\Dispatch\ListenerDispatcher; use Vesper\Tool\Event\EventProcessor; use Vesper\Tool\Event\EventStore; use Vesper\Tool\Event\EventSubscriberMap; -use Vesper\Tool\Event\HandlerResolver; -use Vesper\Tool\Event\RawEvent; +use Vesper\Tool\Event\Infrastructure\Dispatch\DefaultListenerDispatcher; readonly class SequentialEventProcessor implements EventProcessor { - /** - * @param EventSubscriberMap $subscribers - */ + /** @param EventSubscriberMap $subscribers */ public function __construct( private EventSubscriberMap $subscribers, - private HandlerResolver $resolver = new DefaultHandlerResolver(), - private EventHydrator $hydrator = new JacksonHydrator(), - ) { - } + private ListenerDispatcher $dispatcher = new DefaultListenerDispatcher(), + ) {} #[Override] public function process(EventStore $store): void { while ($event = $store->next()) { foreach ($this->subscribers->of($event->name) as $subscriber) { - $this->dispatch($event, $subscriber); + $this->dispatcher->dispatch($event, $subscriber); } - } - } - protected function dispatch(RawEvent $event, callable|string $subscriber): void - { - $callable = $this->resolver->resolve($subscriber); - - $domainEvent = $this->hydrator->hydrate($event->name, $event->payload, $callable); - - $callable($domainEvent); + $event->markProcessed(); + $store->markProcessed($event); + } } } diff --git a/src/Infrastructure/SilentEventProcessor.php b/src/Infrastructure/SilentEventProcessor.php new file mode 100644 index 0000000..bcfae4a --- /dev/null +++ b/src/Infrastructure/SilentEventProcessor.php @@ -0,0 +1,26 @@ +inner->process($store); + } catch (Throwable $e) { + $this->logger->error('Event processor aborted.', ['exception' => $e]); + } + } +} diff --git a/src/Infrastructure/SilentSequentialEventProcessor.php b/src/Infrastructure/SilentSequentialEventProcessor.php deleted file mode 100644 index 8e878b5..0000000 --- a/src/Infrastructure/SilentSequentialEventProcessor.php +++ /dev/null @@ -1,41 +0,0 @@ - $subscribers - */ - public function __construct( - EventSubscriberMap $subscribers, - private LoggerInterface $logger, - HandlerResolver $resolver = new DefaultHandlerResolver(), - EventHydrator $hydrator = new JacksonHydrator(), - ) { - parent::__construct($subscribers, $resolver, $hydrator); - } - - #[Override] - protected function dispatch(RawEvent $event, callable|string $subscriber): void - { - try { - parent::dispatch($event, $subscriber); - } catch (Throwable $exception) { - $this->logger->error('Failed to dispatch event to listener.', [ - 'event' => $event->name, - 'event_id' => $event->id, - 'listener' => is_string($subscriber) ? $subscriber : 'Closure', - 'exception' => $exception, - ]); - } - } -} diff --git a/src/Infrastructure/SqlEventStore.php b/src/Infrastructure/SqlEventStore.php index 254513b..2a10143 100644 --- a/src/Infrastructure/SqlEventStore.php +++ b/src/Infrastructure/SqlEventStore.php @@ -3,11 +3,11 @@ namespace Vesper\Tool\Event\Infrastructure; use Carbon\CarbonImmutable; -use JsonException; +use Carbon\CarbonInterval; use Override; use PDO; -use PDOException; use RuntimeException; +use Throwable; use Vesper\Tool\Event\EventStore; use Vesper\Tool\Event\Infrastructure\Schema\MysqlEventStoreSchema; use Vesper\Tool\Event\Infrastructure\Schema\SqliteEventStoreSchema; @@ -21,20 +21,14 @@ public function __construct(private PDO $connection) $this->ensureOutboxSchema(); } - /** - * Add a new event to the outbox - * - * @throws JsonException - * @throws PDOException - */ #[Override] public function add(RawEvent $event): void { $stmt = $this->connection->prepare( <<execute([ @@ -45,28 +39,153 @@ public function add(RawEvent $event): void 'created_at' => $event->createdAt->format('Y-m-d H:i:s.u'), 'publish_at' => $event->publishAt->format('Y-m-d H:i:s.u'), ]); + + $this->insertStatusAudit($event->id, $event->status->value); } - /** - * Retrieve the next pending event (worker-safe) - * - * @throws JsonException - * @throws PDOException - */ #[Override] public function next(): ?RawEvent + { + $this->connection->beginTransaction(); + + try { + $row = $this->fetchNextPendingRow(); + + if ($row === null) { + $this->connection->commit(); + return null; + } + + /** @var array $payload */ + $payload = json_decode($row['payload'], true, flags: JSON_THROW_ON_ERROR); + + $event = RawEvent::retrieve( + id: $row['id'], + name: $row['name'], + status: RawEventStatus::pending, + payload: $payload, + createdAt: new CarbonImmutable($row['created_at']), + publishAt: new CarbonImmutable($row['publish_at']), + )->claim(); + + $this->connection->prepare( + "UPDATE event_outbox SET status = :status WHERE id = :id", + )->execute(['status' => $event->status->value, 'id' => $event->id]); + + $this->insertStatusAudit($event->id, $event->status->value); + + $this->connection->commit(); + + return $event; + } catch (Throwable $e) { + $this->rollBackIfActive(); + + throw $e; + } + } + + /** UPDATE is guarded by status='processing' so a row a sweep already reclaimed is not overwritten. */ + #[Override] + public function markProcessed(RawEvent $event): void + { + $this->connection->beginTransaction(); + + try { + $this->connection->prepare( + <<execute(['status' => $event->status->value, 'id' => $event->id]); + + $this->insertStatusAudit($event->id, $event->status->value); + + $this->connection->commit(); + } catch (Throwable $e) { + $this->rollBackIfActive(); + + throw $e; + } + } + + /** + * Reset events wedged in `processing` back to `pending`. A row is "stuck" when its most + * recent `processing` audit entry is older than $olderThan. Writes a `pending` audit row + * tagged "Recovered from stuck processing state" so dashboards can tell organic vs. recovered + * transitions apart. Returns the number of events recovered. Call from a separate scheduled + * job; safe to run alongside the main worker. + */ + public function recoverStuckEvents(CarbonInterval $olderThan): int + { + $thresholdAt = CarbonImmutable::now()->sub($olderThan)->format('Y-m-d H:i:s.u'); + + $stmt = $this->connection->prepare( + <<= :threshold + ) + SQL, + ); + $stmt->execute(['threshold' => $thresholdAt]); + + /** @var list $ids */ + $ids = $stmt->fetchAll(PDO::FETCH_COLUMN); + + if ($ids === []) { + return 0; + } + + $this->connection->beginTransaction(); + + try { + $updateStmt = $this->connection->prepare( + "UPDATE event_outbox SET status = 'pending' WHERE id = :id AND status = 'processing'", + ); + + $recovered = 0; + foreach ($ids as $id) { + $updateStmt->execute(['id' => $id]); + + if ($updateStmt->rowCount() === 0) { + continue; + } + + $this->insertStatusAudit($id, RawEventStatus::pending->value, 'Recovered from stuck processing state'); + $recovered++; + } + + $this->connection->commit(); + + return $recovered; + } catch (Throwable $e) { + $this->rollBackIfActive(); + + throw $e; + } + } + + /** + * @return array{id: string, name: string, status: string, payload: string, created_at: string, publish_at: string}|null + */ + private function fetchNextPendingRow(): ?array { $lockClause = $this->lockingClause(); $stmt = $this->connection->prepare( <<execute([ @@ -76,25 +195,32 @@ public function next(): ?RawEvent /** @var array{id: string, name: string, status: string, payload: string, created_at: string, publish_at: string}|false $row */ $row = $stmt->fetch(PDO::FETCH_ASSOC); - if ($row === false) { - return null; - } + return $row === false ? null : $row; + } - $this->connection->prepare( - "UPDATE event_outbox SET status = 'processed' WHERE id = :id", - )->execute(['id' => $row['id']]); + private function insertStatusAudit(string $eventId, string $status, ?string $errorMessage = null): void + { + $stmt = $this->connection->prepare( + << $payload */ - $payload = json_decode($row['payload'], true, flags: JSON_THROW_ON_ERROR); + $stmt->execute([ + 'event_id' => $eventId, + 'status' => $status, + 'error_message' => $errorMessage, + 'created_at' => CarbonImmutable::now()->format('Y-m-d H:i:s.u'), + ]); + } - return RawEvent::retrieve( - id: $row['id'], - name: $row['name'], - status: RawEventStatus::from($row['status']), - payload: $payload, - createdAt: new CarbonImmutable($row['created_at']), - publishAt: new CarbonImmutable($row['publish_at']), - ); + /** Guards against the implicit-commit case where a DDL statement closed the transaction before the failure. */ + private function rollBackIfActive(): void + { + if ($this->connection->inTransaction()) { + $this->connection->rollBack(); + } } private function ensureOutboxSchema(): void @@ -118,9 +244,7 @@ private function lockingClause(): string private function driverName(): string { - $driver = $this->connection->getAttribute(PDO::ATTR_DRIVER_NAME); - assert(is_string($driver)); - - return $driver; + /** @var string */ + return $this->connection->getAttribute(PDO::ATTR_DRIVER_NAME); } } diff --git a/src/ListenerKey.php b/src/ListenerKey.php new file mode 100644 index 0000000..1dcb2b2 --- /dev/null +++ b/src/ListenerKey.php @@ -0,0 +1,21 @@ + $payload */ private function __construct( - public string $id, - public string $name, - public RawEventStatus $status, - public array $payload, - public CarbonImmutable $createdAt, - public CarbonImmutable $publishAt, - ) { - } + public readonly string $id, + public readonly string $name, + private(set) RawEventStatus $status, + public readonly array $payload, + public readonly CarbonImmutable $createdAt, + public readonly CarbonImmutable $publishAt, + ) {} /** * @param array $payload @@ -58,4 +58,31 @@ public static function retrieve( publishAt: $publishAt, ); } + + public function claim(): self + { + $this->requireStatus(RawEventStatus::pending, 'claim'); + + $this->status = RawEventStatus::processing; + + return $this; + } + + public function markProcessed(): self + { + $this->requireStatus(RawEventStatus::processing, 'markProcessed'); + + $this->status = RawEventStatus::processed; + + return $this; + } + + private function requireStatus(RawEventStatus $expected, string $operation): void + { + if ($this->status !== $expected) { + throw new DomainException( + "Cannot $operation an event in status {$this->status->value} (expected $expected->value).", + ); + } + } } diff --git a/src/RawEventStatus.php b/src/RawEventStatus.php index 0ab40a8..e501cc4 100644 --- a/src/RawEventStatus.php +++ b/src/RawEventStatus.php @@ -5,6 +5,6 @@ enum RawEventStatus: string { case pending = 'pending'; + case processing = 'processing'; case processed = 'processed'; - case failed = 'failed'; } diff --git a/src/Redelivery/Redelivery.php b/src/Redelivery/Redelivery.php new file mode 100644 index 0000000..a647599 --- /dev/null +++ b/src/Redelivery/Redelivery.php @@ -0,0 +1,187 @@ +event->id; + } + + public function eventName(): string + { + return $this->event->name; + } + + public function isFor(string $listenerKey): bool + { + return $this->listener === $listenerKey; + } + + public function isDue(CarbonImmutable $now): bool + { + return $this->status === RedeliveryStatus::PendingRetry + && $this->nextRetryAt->lessThanOrEqualTo($now); + } + + public function isPending(): bool + { + return $this->status === RedeliveryStatus::PendingRetry; + } + + public function isDispatching(): bool + { + return $this->status === RedeliveryStatus::Dispatching; + } + + public function isFailed(): bool + { + return $this->status === RedeliveryStatus::Failed; + } + + public function isSucceeded(): bool + { + return $this->status === RedeliveryStatus::Succeeded; + } + + public function claim(): self + { + $this->requireStatus(RedeliveryStatus::PendingRetry, 'claim'); + + $this->status = RedeliveryStatus::Dispatching; + $this->updatedAt = CarbonImmutable::now(); + + return $this; + } + + public function rescheduleTo(int $attemptNumber, CarbonImmutable $nextRetryAt, Throwable $lastError): self + { + $this->requireStatus(RedeliveryStatus::Dispatching, 'rescheduleTo'); + + $this->status = RedeliveryStatus::PendingRetry; + $this->attemptNumber = $attemptNumber; + $this->nextRetryAt = $nextRetryAt; + $this->lastError = self::formatError($lastError); + $this->updatedAt = CarbonImmutable::now(); + + return $this; + } + + public function markSucceeded(): self + { + $this->requireStatus(RedeliveryStatus::Dispatching, 'markSucceeded'); + + $this->status = RedeliveryStatus::Succeeded; + $this->updatedAt = CarbonImmutable::now(); + + return $this; + } + + public function markFailedPermanently(Throwable $lastError): self + { + $this->requireStatus(RedeliveryStatus::Dispatching, 'markFailedPermanently'); + + $this->status = RedeliveryStatus::Failed; + $this->lastError = self::formatError($lastError); + $this->updatedAt = CarbonImmutable::now(); + + return $this; + } + + /** Admin override: any status → PendingRetry, retry now. Preserves attemptNumber so the retry-policy ceiling still applies. */ + public function queueForImmediateRetry(): self + { + $now = CarbonImmutable::now(); + + $this->status = RedeliveryStatus::PendingRetry; + $this->nextRetryAt = $now; + $this->updatedAt = $now; + + return $this; + } + + private function requireStatus(RedeliveryStatus $expected, string $operation): void + { + if ($this->status !== $expected) { + throw new DomainException( + "Cannot {$operation} a redelivery in status {$this->status->value} (expected {$expected->value}).", + ); + } + } + + private static function formatError(Throwable $error): string + { + return $error::class . ': ' . $error->getMessage(); + } +} diff --git a/src/Redelivery/RedeliveryProcessor.php b/src/Redelivery/RedeliveryProcessor.php new file mode 100644 index 0000000..732ca24 --- /dev/null +++ b/src/Redelivery/RedeliveryProcessor.php @@ -0,0 +1,8 @@ +pdo = new PDO('sqlite::memory:'); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $this->eventStore = new SqlEventStore($this->pdo); + $this->store = new SqlRedeliveryStore($this->pdo); + } + + public function test_schema_is_idempotent_across_multiple_instantiations(): void + { + new SqlRedeliveryStore($this->pdo); + new SqlRedeliveryStore($this->pdo); + + self::assertTrue(true); + } + + public function test_next_returns_null_when_table_is_empty(): void + { + self::assertNull($this->store->next()); + } + + public function test_schedule_then_next_round_trip(): void + { + $now = CarbonImmutable::parse('2026-05-16 12:00:00'); + CarbonImmutable::setTestNow($now); + + try { + $event = $this->insertEvent(); + $nextRetryAt = $now->subSecond(); + $this->store->schedule(Redelivery::schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: $nextRetryAt, + lastError: new RuntimeException('boom'), + )); + + $due = $this->store->next(); + + $expected = Redelivery::retrieve( + event: $event, + listener: 'App\\Listener', + status: RedeliveryStatus::Dispatching, + attemptNumber: 1, + nextRetryAt: $nextRetryAt, + lastError: 'RuntimeException: boom', + createdAt: $now, + updatedAt: $now, + ); + self::assertEquals($expected, $due); + } finally { + CarbonImmutable::setTestNow(); + } + } + + public function test_next_claims_row_so_a_second_call_skips_it(): void + { + $event = $this->insertEvent(); + $this->store->schedule(Redelivery::schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + )); + + $first = $this->store->next(); + $second = $this->store->next(); + + self::assertNotNull($first); + self::assertNull($second); + self::assertSame('dispatching', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); + } + + public function test_next_excludes_rows_whose_retry_time_is_in_the_future(): void + { + $event = $this->insertEvent(); + + $this->store->schedule(Redelivery::schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->addMinute(), + lastError: new RuntimeException('boom'), + )); + + self::assertNull($this->store->next()); + } + + public function test_schedule_is_a_noop_when_a_row_already_exists(): void + { + $event = $this->insertEvent(); + + $this->store->schedule(Redelivery::schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 2, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('first'), + )); + $this->store->schedule(Redelivery::schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->addMinute(), + lastError: new RuntimeException('replay-from-recovered-event'), + )); + + $due = $this->store->next(); + + self::assertNotNull($due); + self::assertSame(2, $due->attemptNumber); + self::assertStringContainsString('first', $due->lastError); + self::assertCount(1, $this->fetchAllRedeliveryRows($event->id)); + } + + public function test_update_with_succeeded_finalises_a_claimed_row(): void + { + $event = $this->insertEvent(); + $this->store->schedule(Redelivery::schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + )); + $claimed = $this->store->next(); + self::assertNotNull($claimed); + + $this->store->update($claimed->markSucceeded()); + + self::assertSame('succeeded', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); + self::assertNull($this->store->next()); + } + + public function test_update_is_a_noop_when_row_is_not_in_dispatching(): void + { + $event = $this->insertEvent(); + $redelivery = Redelivery::schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + ); + $this->store->schedule($redelivery); + + $this->store->update($redelivery->claim()->markSucceeded()); + + self::assertSame('pending_retry', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); + } + + public function test_update_with_failed_permanently_finalises_a_claimed_row(): void + { + $event = $this->insertEvent(); + $this->store->schedule(Redelivery::schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 5, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + )); + $claimed = $this->store->next(); + self::assertNotNull($claimed); + + $this->store->update($claimed->markFailedPermanently(new RuntimeException('final'))); + + self::assertSame('failed', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); + self::assertNull($this->store->next()); + } + + public function test_retry_now_re_queues_a_permanently_failed_row_preserving_attempt_count(): void + { + $event = $this->insertEvent(); + + $this->store->schedule(Redelivery::schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 5, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + )); + $claimed = $this->store->next(); + self::assertNotNull($claimed); + $this->store->update($claimed->markFailedPermanently(new RuntimeException('final'))); + + $this->store->retryNow($event->id, 'App\\Listener'); + + $due = $this->store->next(); + self::assertNotNull($due); + self::assertSame(5, $due->attemptNumber); + } + + public function test_recover_stuck_redeliveries_resets_dispatching_rows_older_than_threshold(): void + { + $event = $this->insertEvent(); + $this->store->schedule(Redelivery::schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + )); + $this->store->next(); + $this->backdateUpdatedAt($event->id, 'App\\Listener', CarbonImmutable::now()->subHour()); + + $recovered = $this->store->recoverStuckRedeliveries(CarbonInterval::minutes(30)); + + self::assertSame(1, $recovered); + self::assertSame('pending_retry', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); + } + + public function test_recover_stuck_redeliveries_skips_rows_within_threshold(): void + { + $event = $this->insertEvent(); + $this->store->schedule(Redelivery::schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + )); + $this->store->next(); + + $recovered = $this->store->recoverStuckRedeliveries(CarbonInterval::minutes(30)); + + self::assertSame(0, $recovered); + self::assertSame('dispatching', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); + } + + public function test_recover_stuck_redeliveries_does_not_touch_pending_or_terminal_rows(): void + { + $event = $this->insertEvent(); + $this->store->schedule(Redelivery::schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + )); + $this->backdateUpdatedAt($event->id, 'App\\Listener', CarbonImmutable::now()->subHour()); + + $recovered = $this->store->recoverStuckRedeliveries(CarbonInterval::minutes(30)); + + self::assertSame(0, $recovered); + self::assertSame('pending_retry', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); + } + + private function insertEvent(): RawEvent + { + $event = TestEventFactory::retrieveOrderPlaced(['order_id' => 1]); + $this->eventStore->add($event); + return $event; + } + + private function backdateUpdatedAt(string $eventId, string $listener, CarbonImmutable $newUpdatedAt): void + { + $this->pdo->prepare( + <<execute([ + 'updated_at' => $newUpdatedAt->format('Y-m-d H:i:s.u'), + 'event_id' => $eventId, + 'listener' => $listener, + ]); + } + + /** + * @return array> + */ + private function fetchAllRedeliveryRows(string $eventId): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM event_outbox_redelivery WHERE event_id = :id', + ); + $stmt->execute(['id' => $eventId]); + + /** @var array> */ + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + private function fetchRedeliveryStatus(string $eventId, string $listener): string + { + $stmt = $this->pdo->prepare( + 'SELECT status FROM event_outbox_redelivery WHERE event_id = :id AND listener = :listener', + ); + $stmt->execute(['id' => $eventId, 'listener' => $listener]); + $value = $stmt->fetchColumn(); + self::assertIsString($value); + return $value; + } +} diff --git a/tests/Feature/SqlEventStoreAuditTest.php b/tests/Feature/SqlEventStoreAuditTest.php new file mode 100644 index 0000000..15250a1 --- /dev/null +++ b/tests/Feature/SqlEventStoreAuditTest.php @@ -0,0 +1,140 @@ +pdo = new PDO('sqlite::memory:'); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $this->store = new SqlEventStore($this->pdo); + } + + public function test_add_inserts_pending_audit_row(): void + { + $event = self::createEvent('order.placed'); + + $this->store->add($event); + + $rows = $this->fetchAuditRows($event->id); + + self::assertSame([['status' => 'pending', 'error_message' => null]], $rows); + } + + public function test_next_transitions_event_to_processing_and_inserts_audit_row(): void + { + $event = self::createEvent('order.placed'); + $this->store->add($event); + + $retrieved = $this->store->next(); + + self::assertNotNull($retrieved); + self::assertSame(RawEventStatus::processing, $retrieved->status); + self::assertSame('processing', $this->fetchEventStatus($event->id)); + self::assertSame( + [ + ['status' => 'pending', 'error_message' => null], + ['status' => 'processing', 'error_message' => null], + ], + $this->fetchAuditRows($event->id), + ); + } + + public function test_mark_processed_advances_event_and_inserts_audit_row(): void + { + $event = self::createEvent('order.placed'); + $this->store->add($event); + $claimed = $this->store->next(); + self::assertNotNull($claimed); + + $claimed->markProcessed(); + $this->store->markProcessed($claimed); + + self::assertSame('processed', $this->fetchEventStatus($event->id)); + self::assertSame( + [ + ['status' => 'pending', 'error_message' => null], + ['status' => 'processing', 'error_message' => null], + ['status' => 'processed', 'error_message' => null], + ], + $this->fetchAuditRows($event->id), + ); + } + + public function test_next_does_not_pick_up_a_row_already_in_processing(): void + { + $event = self::createEvent('order.placed'); + $this->store->add($event); + $this->store->next(); + + self::assertNull($this->store->next()); + } + + public function test_mark_processed_is_a_noop_when_db_row_is_not_in_processing(): void + { + $event = self::createEvent('order.placed'); + $this->store->add($event); + + $stale = RawEvent::retrieve( + id: $event->id, + name: $event->name, + status: RawEventStatus::processed, + payload: [], + createdAt: $event->createdAt, + publishAt: $event->publishAt, + ); + $this->store->markProcessed($stale); + + self::assertSame('pending', $this->fetchEventStatus($event->id)); + } + + /** + * @return array + */ + private function fetchAuditRows(string $eventId): array + { + $stmt = $this->pdo->prepare( + 'SELECT status, error_message FROM event_outbox_status WHERE event_id = :id ORDER BY created_at', + ); + $stmt->execute(['id' => $eventId]); + + /** @var array */ + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + private function fetchEventStatus(string $eventId): string + { + $stmt = $this->pdo->prepare('SELECT status FROM event_outbox WHERE id = :id'); + $stmt->execute(['id' => $eventId]); + $value = $stmt->fetchColumn(); + self::assertIsString($value); + return $value; + } + + /** + * @param array $payload + */ + private static function createEvent( + string $name, + array $payload = [], + ?CarbonImmutable $publishAt = null, + ): RawEvent { + return RawEvent::create( + name: $name, + payload: $payload, + publishAt: $publishAt ?? CarbonImmutable::now()->subSecond(), + ); + } +} diff --git a/tests/Feature/SqlEventStoreRecoverStuckTest.php b/tests/Feature/SqlEventStoreRecoverStuckTest.php new file mode 100644 index 0000000..e8d5539 --- /dev/null +++ b/tests/Feature/SqlEventStoreRecoverStuckTest.php @@ -0,0 +1,182 @@ +pdo = new PDO('sqlite::memory:'); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $this->store = new SqlEventStore($this->pdo); + } + + public function test_recovers_processing_event_whose_audit_row_is_older_than_threshold(): void + { + $event = self::createEvent(); + $this->store->add($event); + + $this->store->next(); + $this->backdateLatestProcessingAuditRow($event->id, CarbonImmutable::now()->subHour()); + + $recovered = $this->store->recoverStuckEvents(CarbonInterval::minutes(30)); + + self::assertSame(1, $recovered); + self::assertSame('pending', $this->fetchEventStatus($event->id)); + } + + public function test_does_not_recover_processing_event_whose_audit_row_is_within_threshold(): void + { + $event = self::createEvent(); + $this->store->add($event); + $this->store->next(); + + $recovered = $this->store->recoverStuckEvents(CarbonInterval::minutes(30)); + + self::assertSame(0, $recovered); + self::assertSame('processing', $this->fetchEventStatus($event->id)); + } + + public function test_does_not_touch_pending_events(): void + { + $event = self::createEvent(); + $this->store->add($event); + + $recovered = $this->store->recoverStuckEvents(CarbonInterval::seconds(0)); + + self::assertSame(0, $recovered); + self::assertSame('pending', $this->fetchEventStatus($event->id)); + } + + public function test_does_not_touch_processed_events(): void + { + $event = self::createEvent(); + $this->store->add($event); + $claimed = $this->store->next(); + self::assertNotNull($claimed); + $claimed->markProcessed(); + $this->store->markProcessed($claimed); + $this->backdateLatestProcessingAuditRow($event->id, CarbonImmutable::now()->subHour()); + + $recovered = $this->store->recoverStuckEvents(CarbonInterval::minutes(30)); + + self::assertSame(0, $recovered); + self::assertSame('processed', $this->fetchEventStatus($event->id)); + } + + public function test_writes_a_recovery_audit_row(): void + { + $event = self::createEvent(); + $this->store->add($event); + $this->store->next(); + $this->backdateLatestProcessingAuditRow($event->id, CarbonImmutable::now()->subHour()); + + $this->store->recoverStuckEvents(CarbonInterval::minutes(30)); + + $recoveryRows = array_values(array_filter( + $this->fetchAuditRows($event->id), + fn(array $row): bool => $row['error_message'] === 'Recovered from stuck processing state', + )); + + self::assertSame( + [['status' => 'pending', 'error_message' => 'Recovered from stuck processing state']], + $recoveryRows, + ); + } + + public function test_recovered_event_can_be_processed_again(): void + { + $event = self::createEvent(); + $this->store->add($event); + $this->store->next(); + $this->backdateLatestProcessingAuditRow($event->id, CarbonImmutable::now()->subHour()); + + $this->store->recoverStuckEvents(CarbonInterval::minutes(30)); + + $reclaimed = $this->store->next(); + + self::assertNotNull($reclaimed); + self::assertSame($event->id, $reclaimed->id); + } + + public function test_recovers_multiple_stuck_events_in_one_call(): void + { + $eventA = self::createEvent(); + $eventB = self::createEvent(); + $this->store->add($eventA); + $this->store->add($eventB); + + $this->store->next(); + $this->store->next(); + $this->backdateLatestProcessingAuditRow($eventA->id, CarbonImmutable::now()->subHour()); + $this->backdateLatestProcessingAuditRow($eventB->id, CarbonImmutable::now()->subHour()); + + $recovered = $this->store->recoverStuckEvents(CarbonInterval::minutes(30)); + + self::assertSame(2, $recovered); + self::assertSame('pending', $this->fetchEventStatus($eventA->id)); + self::assertSame('pending', $this->fetchEventStatus($eventB->id)); + } + + public function test_returns_zero_when_no_stuck_events_exist(): void + { + self::assertSame(0, $this->store->recoverStuckEvents(CarbonInterval::minutes(30))); + } + + private function backdateLatestProcessingAuditRow(string $eventId, CarbonImmutable $newCreatedAt): void + { + $this->pdo->prepare( + <<execute([ + 'event_id' => $eventId, + 'created_at' => $newCreatedAt->format('Y-m-d H:i:s.u'), + ]); + } + + /** + * @return array + */ + private function fetchAuditRows(string $eventId): array + { + $stmt = $this->pdo->prepare( + 'SELECT status, error_message FROM event_outbox_status WHERE event_id = :id ORDER BY created_at', + ); + $stmt->execute(['id' => $eventId]); + + /** @var array */ + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + private function fetchEventStatus(string $eventId): string + { + $stmt = $this->pdo->prepare('SELECT status FROM event_outbox WHERE id = :id'); + $stmt->execute(['id' => $eventId]); + $value = $stmt->fetchColumn(); + self::assertIsString($value); + return $value; + } + + private static function createEvent(): RawEvent + { + return RawEvent::create( + name: 'order.placed', + payload: [], + publishAt: CarbonImmutable::now()->subSecond(), + ); + } +} diff --git a/tests/Feature/SqlEventStoreTest.php b/tests/Feature/SqlEventStoreTest.php index d17d560..caa810e 100644 --- a/tests/Feature/SqlEventStoreTest.php +++ b/tests/Feature/SqlEventStoreTest.php @@ -18,12 +18,9 @@ protected function setUp(): void $this->pdo = new PDO('sqlite::memory:'); $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - // SqlEventStore calls ensureOutboxSchema() in the constructor $this->store = new SqlEventStore($this->pdo); } - // ── next() ──────────────────────────────────────────────────────────────── - public function test_next_returns_null_when_store_is_empty(): void { self::assertNull($this->store->next()); @@ -33,13 +30,11 @@ public function test_next_returns_null_when_no_pending_events(): void { $event = self::createEvent('order.placed'); $this->store->add($event); - $this->store->next(); // consumes it + $this->store->next(); self::assertNull($this->store->next()); } - // ── add() + next() ──────────────────────────────────────────────────────── - public function test_add_persists_an_event_that_next_can_retrieve(): void { $event = self::createEvent('order.placed', ['order_id' => 99]); @@ -75,7 +70,7 @@ public function test_next_returns_correct_payload(): void public function test_next_marks_event_as_processed_so_it_is_not_returned_again(): void { $this->store->add(self::createEvent('order.placed')); - $this->store->next(); // marks as processed + $this->store->next(); self::assertNull($this->store->next()); } @@ -128,29 +123,21 @@ public function test_next_returns_event_with_pending_status_from_db(): void { $this->store->add(self::createEvent('order.placed')); - // A second store instance over the same DB sees the event as processed $anotherStore = new SqlEventStore($this->pdo); $first = $anotherStore->next(); self::assertNotNull($first); - - // Consuming it means a third retrieval should see nothing self::assertNull($anotherStore->next()); } - // ── schema ──────────────────────────────────────────────────────────────── - public function test_schema_is_idempotent_across_multiple_instantiations(): void { - // Creating a second store on the same DB should not throw or recreate tables new SqlEventStore($this->pdo); new SqlEventStore($this->pdo); self::assertTrue(true); } - // ── helpers ─────────────────────────────────────────────────────────────── - /** * @param array $payload */ diff --git a/tests/Unit/DefaultHandlerResolverTest.php b/tests/Unit/DefaultHandlerResolverTest.php index 9302f8a..6719709 100644 --- a/tests/Unit/DefaultHandlerResolverTest.php +++ b/tests/Unit/DefaultHandlerResolverTest.php @@ -19,7 +19,7 @@ protected function setUp(): void public function test_returns_callable_directly(): void { - $callable = fn () => null; + $callable = fn() => null; $resolved = $this->resolver->resolve($callable); diff --git a/tests/Unit/Dispatch/DefaultListenerDispatcherTest.php b/tests/Unit/Dispatch/DefaultListenerDispatcherTest.php new file mode 100644 index 0000000..c3df81a --- /dev/null +++ b/tests/Unit/Dispatch/DefaultListenerDispatcherTest.php @@ -0,0 +1,136 @@ + 1]); + $dispatcher = new DefaultListenerDispatcher(); + + $received = null; + $dispatcher->dispatch($event, function (object $e) use (&$received) { + $received = $e; + }); + + self::assertEquals((object) ['order_id' => 1], $received); + } + + public function test_rethrows_exception_from_listener(): void + { + $event = TestEventFactory::retrieveOrderPlaced(); + $exception = new RuntimeException('boom'); + $dispatcher = new DefaultListenerDispatcher(); + + $this->expectExceptionObject($exception); + + $dispatcher->dispatch($event, function () use ($exception) { + throw $exception; + }); + } + + public function test_swallows_exception_listed_in_ignored_exceptions(): void + { + $event = TestEventFactory::retrieveOrderPlaced(); + $dispatcher = new DefaultListenerDispatcher( + ignoredExceptions: [IgnorableExceptionStub::class], + ); + + $dispatcher->dispatch($event, function () { + throw new IgnorableExceptionStub('expected'); + }); + + self::assertTrue(true); + } + + public function test_rethrows_exception_not_listed_in_ignored_exceptions(): void + { + $event = TestEventFactory::retrieveOrderPlaced(); + $dispatcher = new DefaultListenerDispatcher( + ignoredExceptions: [IgnorableExceptionStub::class], + ); + + $this->expectException(RuntimeException::class); + + $dispatcher->dispatch($event, function () { + throw new RuntimeException('not ignorable'); + }); + } + + public function test_resolves_class_string_subscriber_via_handler_resolver(): void + { + $event = TestEventFactory::retrieveOrderPlaced(['order_id' => 42]); + $listener = new TrackingListener(); + + $dispatcher = new DefaultListenerDispatcher( + resolver: $this->resolverReturning($listener), + ); + + $dispatcher->dispatch($event, TrackingListener::class); + + self::assertEquals((object) ['order_id' => 42], $listener->received()); + } + + public function test_hydrates_payload_before_invoking_listener(): void + { + $event = TestEventFactory::retrieveOrderPlaced(['order_id' => 7]); + + $typedEvent = new readonly class (7) { + public function __construct(public int $orderId) {} + }; + + $received = null; + $subscriber = function (object $e) use (&$received) { + $received = $e; + }; + + $dispatcher = new DefaultListenerDispatcher( + hydrator: $this->mockHydratorReturning( + name: 'order.placed', + payload: ['order_id' => 7], + subscriber: $subscriber, + event: $typedEvent, + ), + ); + + $dispatcher->dispatch($event, $subscriber); + + self::assertSame($typedEvent, $received); + } + + private function resolverReturning(callable|object $listener): HandlerResolver + { + $resolver = $this->createStub(HandlerResolver::class); + $resolver->method('resolve')->willReturn($listener); + + return $resolver; + } + + /** + * @param array $payload + */ + private function mockHydratorReturning( + string $name, + array $payload, + callable|string $subscriber, + object $event, + ): EventHydrator { + $hydrator = $this->createMock(EventHydrator::class); + $hydrator->expects($this->once()) + ->method('hydrate') + ->with($name, $payload, $subscriber) + ->willReturn($event); + + return $hydrator; + } +} diff --git a/tests/Unit/Dispatch/RedeliveringListenerDispatcherTest.php b/tests/Unit/Dispatch/RedeliveringListenerDispatcherTest.php new file mode 100644 index 0000000..5d7dd9f --- /dev/null +++ b/tests/Unit/Dispatch/RedeliveringListenerDispatcherTest.php @@ -0,0 +1,128 @@ +createMock(RedeliveryStore::class); + $store->expects($this->never())->method('schedule'); + + $dispatcher = new RedeliveringListenerDispatcher(self::passthroughDispatcher(), $store); + + $dispatcher->dispatch($event, function () {}); + } + + public function test_schedules_redelivery_with_attempt_one_when_inner_throws(): void + { + $now = CarbonImmutable::parse('2026-05-16 12:00:00'); + CarbonImmutable::setTestNow($now); + + try { + $event = TestEventFactory::retrieveOrderPlaced(); + $exception = new RuntimeException('boom'); + + $expected = Redelivery::schedule( + event: $event, + listener: 'Closure', + attemptNumber: 1, + nextRetryAt: $now, + lastError: $exception, + ); + + $dispatcher = new RedeliveringListenerDispatcher( + self::throwingDispatcher($exception), + $this->mockStoreExpectingSchedule($expected), + ); + + $dispatcher->dispatch($event, function () {}); + } finally { + CarbonImmutable::setTestNow(); + } + } + + public function test_records_class_string_subscriber_under_its_class_name(): void + { + $now = CarbonImmutable::parse('2026-05-16 12:00:00'); + CarbonImmutable::setTestNow($now); + + try { + $event = TestEventFactory::retrieveOrderPlaced(); + $exception = new RuntimeException('boom'); + + $expected = Redelivery::schedule( + event: $event, + listener: ThrowingListener::class, + attemptNumber: 1, + nextRetryAt: $now, + lastError: $exception, + ); + + $dispatcher = new RedeliveringListenerDispatcher( + self::throwingDispatcher($exception), + $this->mockStoreExpectingSchedule($expected), + ); + + $dispatcher->dispatch($event, ThrowingListener::class); + } finally { + CarbonImmutable::setTestNow(); + } + } + + public function test_swallows_inner_exception_after_scheduling(): void + { + $event = TestEventFactory::retrieveOrderPlaced(); + + $dispatcher = new RedeliveringListenerDispatcher( + self::throwingDispatcher(new RuntimeException('boom')), + new InMemoryRedeliveryStore(), + ); + + $dispatcher->dispatch($event, function () {}); + + self::assertTrue(true); + } + + private function mockStoreExpectingSchedule(Redelivery $expected): RedeliveryStore + { + $store = $this->createMock(RedeliveryStore::class); + $store->expects($this->once()) + ->method('schedule') + ->with($expected); + + return $store; + } + + private static function passthroughDispatcher(): ListenerDispatcher + { + return new readonly class implements ListenerDispatcher { + public function dispatch(RawEvent $event, callable|string $subscriber): void {} + }; + } + + private static function throwingDispatcher(Throwable $error): ListenerDispatcher + { + return new readonly class ($error) implements ListenerDispatcher { + public function __construct(private Throwable $error) {} + public function dispatch(RawEvent $event, callable|string $subscriber): void + { + throw $this->error; + } + }; + } +} diff --git a/tests/Unit/EventPublisherTest.php b/tests/Unit/EventPublisherTest.php index 956a9fa..08f58a0 100644 --- a/tests/Unit/EventPublisherTest.php +++ b/tests/Unit/EventPublisherTest.php @@ -2,42 +2,63 @@ namespace Test\Vesper\Tool\Event\Unit; +use Carbon\CarbonImmutable; use PHPUnit\Framework\TestCase; use stdClass; +use Test\Vesper\Tool\Event\_Fixtures\CapturingEventStore; use Vesper\Tool\Event\EventPublisher; use Vesper\Tool\Event\EventSerializer; -use Vesper\Tool\Event\EventStore; use Vesper\Tool\Event\RawEvent; +use Vesper\Tool\Event\RawEventStatus; use Vesper\Tool\Event\SerializedEvent; class EventPublisherTest extends TestCase { public function test_publish_serializes_event_and_stores_it(): void { - $domainEvent = new stdClass(); + $now = CarbonImmutable::parse('2026-05-16 12:00:00'); + CarbonImmutable::setTestNow($now); - $serializer = $this->createStub(EventSerializer::class); - $serializer->method('serialize')->willReturn(new SerializedEvent('order.placed', ['order_id' => 1])); + try { + $store = new CapturingEventStore(); + $publisher = new EventPublisher( + $store, + $this->serializerReturning(new SerializedEvent('order.placed', ['order_id' => 1])), + ); - $store = $this->createMock(EventStore::class); - $store->expects(self::once())->method('add')->with( - $this->callback( - fn(RawEvent $e) => $e->name === 'order.placed' && $e->payload === ['order_id' => 1], - ), - ); + $id = $publisher->publish(new stdClass()); - new EventPublisher($store, $serializer)->publish($domainEvent); + $expected = RawEvent::retrieve( + id: $id, + name: 'order.placed', + status: RawEventStatus::pending, + payload: ['order_id' => 1], + createdAt: $now, + publishAt: $now, + ); + self::assertEquals([$expected], $store->added); + } finally { + CarbonImmutable::setTestNow(); + } } public function test_publish_returns_non_empty_string_id(): void { - $serializer = $this->createStub(EventSerializer::class); - $serializer->method('serialize')->willReturn(new SerializedEvent('order.placed', [])); - - $store = $this->createStub(EventStore::class); + $publisher = new EventPublisher( + new CapturingEventStore(), + $this->serializerReturning(new SerializedEvent('order.placed', [])), + ); - $id = new EventPublisher($store, $serializer)->publish(new stdClass()); + $id = $publisher->publish(new stdClass()); self::assertNotEmpty($id); } + + private function serializerReturning(SerializedEvent $serialized): EventSerializer + { + $serializer = $this->createStub(EventSerializer::class); + $serializer->method('serialize')->willReturn($serialized); + + return $serializer; + } } diff --git a/tests/Unit/EventSubscriberBuilderTest.php b/tests/Unit/EventSubscriberBuilderTest.php index 26866f6..ae6e75f 100644 --- a/tests/Unit/EventSubscriberBuilderTest.php +++ b/tests/Unit/EventSubscriberBuilderTest.php @@ -18,7 +18,7 @@ public function test_build_returns_empty_array_with_no_registrations(): void public function test_eventName_registers_callable_as_listener(): void { - $listener = fn () => null; + $listener = fn() => null; $result = EventSubscriberBuilder::create() ->eventName('order.placed', [$listener]) @@ -82,7 +82,7 @@ public function test_duplicate_of_multiple_function_calls_are_deduplicated(): vo public function test_duplicate_callable_listeners_are_deduplicated(): void { - $listener = fn () => null; + $listener = fn() => null; $result = EventSubscriberBuilder::create() ->eventName('order.placed', [$listener]) @@ -94,8 +94,8 @@ public function test_duplicate_callable_listeners_are_deduplicated(): void public function test_distinct_callable_listeners_are_not_deduplicated(): void { - $a = fn () => null; - $b = fn () => null; + $a = fn() => null; + $b = fn() => null; $result = EventSubscriberBuilder::create() ->eventName('order.placed', [$a]) diff --git a/tests/Unit/InMemoryEventStoreTest.php b/tests/Unit/InMemoryEventStoreTest.php index 6d77825..9576243 100644 --- a/tests/Unit/InMemoryEventStoreTest.php +++ b/tests/Unit/InMemoryEventStoreTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use Vesper\Tool\Event\Infrastructure\InMemoryEventStore; +use Vesper\Tool\Event\RawEventStatus; use Test\Vesper\Tool\Event\_Fixtures\TestEventFactory; class InMemoryEventStoreTest extends TestCase @@ -48,6 +49,19 @@ public function test_next_returns_null_after_all_events_are_consumed(): void self::assertNull($this->store->next()); } + public function test_next_transitions_returned_event_from_pending_to_processing(): void + { + $event = TestEventFactory::retrieveOrderPlaced(); + $this->store->add($event); + + self::assertSame(RawEventStatus::pending, $event->status); + + $claimed = $this->store->next(); + + self::assertNotNull($claimed); + self::assertSame(RawEventStatus::processing, $claimed->status); + } + public function test_multiple_events_can_be_enqueued_and_dequeued(): void { $events = [ diff --git a/tests/Unit/ListenerKeyTest.php b/tests/Unit/ListenerKeyTest.php new file mode 100644 index 0000000..08702b5 --- /dev/null +++ b/tests/Unit/ListenerKeyTest.php @@ -0,0 +1,27 @@ + null; + + self::assertSame('Closure', ListenerKey::of($closure)); + } +} diff --git a/tests/Unit/RayEventStatusTest.php b/tests/Unit/RawEventStatusTest.php similarity index 69% rename from tests/Unit/RayEventStatusTest.php rename to tests/Unit/RawEventStatusTest.php index f939d4e..42affdd 100644 --- a/tests/Unit/RayEventStatusTest.php +++ b/tests/Unit/RawEventStatusTest.php @@ -5,21 +5,21 @@ use PHPUnit\Framework\TestCase; use Vesper\Tool\Event\RawEventStatus; -class RayEventStatusTest extends TestCase +class RawEventStatusTest extends TestCase { public function test_has_pending_case(): void { self::assertSame('pending', RawEventStatus::pending->value); } - public function test_has_processed_case(): void + public function test_has_processing_case(): void { - self::assertSame('processed', RawEventStatus::processed->value); + self::assertSame('processing', RawEventStatus::processing->value); } - public function test_has_failed_case(): void + public function test_has_processed_case(): void { - self::assertSame('failed', RawEventStatus::failed->value); + self::assertSame('processed', RawEventStatus::processed->value); } public function test_from_resolves_pending(): void @@ -27,13 +27,13 @@ public function test_from_resolves_pending(): void self::assertSame(RawEventStatus::pending, RawEventStatus::from('pending')); } - public function test_from_resolves_processed(): void + public function test_from_resolves_processing(): void { - self::assertSame(RawEventStatus::processed, RawEventStatus::from('processed')); + self::assertSame(RawEventStatus::processing, RawEventStatus::from('processing')); } - public function test_from_resolves_failed(): void + public function test_from_resolves_processed(): void { - self::assertSame(RawEventStatus::failed, RawEventStatus::from('failed')); + self::assertSame(RawEventStatus::processed, RawEventStatus::from('processed')); } } diff --git a/tests/Unit/RawEventTest.php b/tests/Unit/RawEventTest.php new file mode 100644 index 0000000..3502fa7 --- /dev/null +++ b/tests/Unit/RawEventTest.php @@ -0,0 +1,131 @@ + 42], $publishAt); + + $expected = RawEvent::retrieve( + id: $event->id, + name: 'order.placed', + status: RawEventStatus::pending, + payload: ['order_id' => 42], + createdAt: $now, + publishAt: $publishAt, + ); + + self::assertEquals($expected, $event); + self::assertNotEmpty($event->id); + } finally { + CarbonImmutable::setTestNow(); + } + } + + public function test_create_generates_unique_ids(): void + { + $a = RawEvent::create('order.placed', [], CarbonImmutable::now()); + $b = RawEvent::create('order.placed', [], CarbonImmutable::now()); + + self::assertNotSame($a->id, $b->id); + } + + public function test_retrieve_reconstructs_an_event_from_supplied_values(): void + { + $createdAt = CarbonImmutable::parse('2025-01-01 08:00:00'); + $publishAt = CarbonImmutable::parse('2025-12-31 23:59:59'); + $payload = ['amount' => 50, 'currency' => 'USD']; + + $event = RawEvent::retrieve( + id: 'custom-id-123', + name: 'payment.failed', + status: RawEventStatus::processed, + payload: $payload, + createdAt: $createdAt, + publishAt: $publishAt, + ); + + $expected = RawEvent::retrieve( + id: 'custom-id-123', + name: 'payment.failed', + status: RawEventStatus::processed, + payload: $payload, + createdAt: $createdAt, + publishAt: $publishAt, + ); + + self::assertEquals($expected, $event); + } + + public function test_claim_transitions_pending_to_processing(): void + { + $event = RawEvent::create('order.placed', [], CarbonImmutable::now()); + + $claimed = $event->claim(); + + self::assertSame($event, $claimed); + self::assertSame(RawEventStatus::processing, $event->status); + } + + public function test_claim_rejects_non_pending_event(): void + { + $event = self::retrievedEvent(status: RawEventStatus::processing); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Cannot claim an event in status processing'); + + $event->claim(); + } + + public function test_mark_processed_transitions_processing_to_processed(): void + { + $event = RawEvent::create('order.placed', [], CarbonImmutable::now()); + $event->claim(); + + $processed = $event->markProcessed(); + + self::assertSame($event, $processed); + self::assertSame(RawEventStatus::processed, $event->status); + } + + public function test_mark_processed_rejects_pending_event(): void + { + $event = RawEvent::create('order.placed', [], CarbonImmutable::now()); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Cannot markProcessed an event in status pending'); + + $event->markProcessed(); + } + + private static function retrievedEvent( + string $id = 'test-id', + string $name = 'order.placed', + RawEventStatus $status = RawEventStatus::pending, + array $payload = [], + ?CarbonImmutable $createdAt = null, + ?CarbonImmutable $publishAt = null, + ): RawEvent { + return RawEvent::retrieve( + id: $id, + name: $name, + status: $status, + payload: $payload, + createdAt: $createdAt ?? CarbonImmutable::now(), + publishAt: $publishAt ?? CarbonImmutable::now(), + ); + } +} diff --git a/tests/Unit/RayEventTest.php b/tests/Unit/RayEventTest.php deleted file mode 100644 index 183fbfd..0000000 --- a/tests/Unit/RayEventTest.php +++ /dev/null @@ -1,132 +0,0 @@ -name); - } - - public function test_create_sets_payload(): void - { - $payload = ['order_id' => 42, 'total' => 99.99]; - $event = RawEvent::create('order.placed', $payload, CarbonImmutable::now()); - - self::assertSame($payload, $event->payload); - } - - public function test_create_defaults_status_to_pending(): void - { - $event = RawEvent::create('order.placed', [], CarbonImmutable::now()); - - self::assertSame(RawEventStatus::pending, $event->status); - } - - public function test_create_sets_publish_at(): void - { - $publishAt = CarbonImmutable::parse('2030-06-15 12:00:00'); - $event = RawEvent::create('order.placed', [], $publishAt); - - self::assertSame($publishAt->toIso8601String(), $event->publishAt->toIso8601String()); - } - - public function test_create_generates_non_empty_id(): void - { - $event = RawEvent::create('order.placed', [], CarbonImmutable::now()); - - self::assertNotEmpty($event->id); - } - - public function test_create_generates_unique_ids(): void - { - $a = RawEvent::create('order.placed', [], CarbonImmutable::now()); - $b = RawEvent::create('order.placed', [], CarbonImmutable::now()); - - self::assertNotSame($a->id, $b->id); - } - - // ── retrieve() ─────────────────────────────────────────────────────────── - - public function test_retrieve_preserves_id(): void - { - $event = $this->makeRetrievedEvent(id: 'custom-id-123'); - - self::assertSame('custom-id-123', $event->id); - } - - public function test_retrieve_preserves_name(): void - { - $event = $this->makeRetrievedEvent(name: 'payment.failed'); - - self::assertSame('payment.failed', $event->name); - } - - public function test_retrieve_preserves_status(): void - { - $event = $this->makeRetrievedEvent(status: RawEventStatus::processed); - - self::assertSame(RawEventStatus::processed, $event->status); - } - - public function test_retrieve_preserves_payload(): void - { - $payload = ['amount' => 50, 'currency' => 'USD']; - $event = $this->makeRetrievedEvent(payload: $payload); - - self::assertSame($payload, $event->payload); - } - - public function test_retrieve_preserves_created_at(): void - { - $createdAt = CarbonImmutable::parse('2025-01-01 08:00:00'); - $event = $this->makeRetrievedEvent(createdAt: $createdAt); - - self::assertSame($createdAt->toIso8601String(), $event->createdAt->toIso8601String()); - } - - public function test_retrieve_preserves_publish_at(): void - { - $publishAt = CarbonImmutable::parse('2025-12-31 23:59:59'); - $event = $this->makeRetrievedEvent(publishAt: $publishAt); - - self::assertSame($publishAt->toIso8601String(), $event->publishAt->toIso8601String()); - } - - // ── helpers ────────────────────────────────────────────────────────────── - - private function makeRetrievedEvent( - string $id = 'test-id', - string $name = 'order.placed', - RawEventStatus $status = RawEventStatus::pending, - array $payload = [], - ?CarbonImmutable $createdAt = null, - ?CarbonImmutable $publishAt = null, - ): RawEvent { - return RawEvent::retrieve( - id: $id, - name: $name, - status: $status, - payload: $payload, - createdAt: $createdAt ?? CarbonImmutable::now(), - publishAt: $publishAt ?? CarbonImmutable::now(), - ); - } -} diff --git a/tests/Unit/Redelivery/InMemoryRedeliveryStoreTest.php b/tests/Unit/Redelivery/InMemoryRedeliveryStoreTest.php new file mode 100644 index 0000000..6eb41b3 --- /dev/null +++ b/tests/Unit/Redelivery/InMemoryRedeliveryStoreTest.php @@ -0,0 +1,245 @@ +store = new InMemoryRedeliveryStore(); + $this->event = TestEventFactory::retrieveOrderPlaced(); + } + + public function test_next_returns_null_when_empty(): void + { + self::assertNull($this->store->next()); + } + + public function test_schedule_makes_redelivery_pickable_when_time_passes(): void + { + $now = CarbonImmutable::parse('2026-05-16 12:00:00'); + CarbonImmutable::setTestNow($now); + + try { + $nextRetryAt = $now->subSecond(); + $this->store->schedule(Redelivery::schedule( + event: $this->event, + listener: 'App\\SomeListener', + attemptNumber: 1, + nextRetryAt: $nextRetryAt, + lastError: new RuntimeException('boom'), + )); + + $due = $this->store->next(); + + $expected = Redelivery::retrieve( + event: $this->event, + listener: 'App\\SomeListener', + status: RedeliveryStatus::Dispatching, + attemptNumber: 1, + nextRetryAt: $nextRetryAt, + lastError: 'RuntimeException: boom', + createdAt: $now, + updatedAt: $now, + ); + self::assertEquals($expected, $due); + } finally { + CarbonImmutable::setTestNow(); + } + } + + public function test_next_excludes_rows_whose_retry_time_is_in_the_future(): void + { + $this->store->schedule(Redelivery::schedule( + event: $this->event, + listener: 'App\\SomeListener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->addMinute(), + lastError: new RuntimeException('boom'), + )); + + self::assertNull($this->store->next()); + } + + public function test_next_returns_earliest_scheduled_first(): void + { + $eventEarly = TestEventFactory::retrieveOrderPlaced(['n' => 1]); + $eventLate = TestEventFactory::retrieveOrderPlaced(['n' => 2]); + + $this->store->schedule(Redelivery::schedule( + event: $eventLate, + listener: 'App\\Late', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + )); + $this->store->schedule(Redelivery::schedule( + event: $eventEarly, + listener: 'App\\Early', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subMinute(), + lastError: new RuntimeException('boom'), + )); + + $due = $this->store->next(); + + self::assertNotNull($due); + self::assertSame('App\\Early', $due->listener); + } + + public function test_next_transitions_claimed_row_so_a_second_call_skips_it(): void + { + $this->store->schedule(Redelivery::schedule( + event: $this->event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + )); + + $first = $this->store->next(); + $second = $this->store->next(); + + self::assertNotNull($first); + self::assertNull($second); + } + + public function test_update_with_succeeded_finalises_a_claimed_row(): void + { + $this->store->schedule(Redelivery::schedule( + event: $this->event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + )); + $claimed = $this->store->next(); + self::assertNotNull($claimed); + + $this->store->update($claimed->markSucceeded()); + + self::assertNull($this->store->next()); + } + + public function test_update_is_a_noop_when_row_is_not_in_dispatching(): void + { + $this->store->schedule(Redelivery::schedule( + event: $this->event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + )); + + $stale = Redelivery::retrieve( + event: $this->event, + listener: 'App\\Listener', + status: RedeliveryStatus::Succeeded, + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: 'RuntimeException: boom', + createdAt: CarbonImmutable::now(), + updatedAt: CarbonImmutable::now(), + ); + $this->store->update($stale); + + self::assertNotNull($this->store->next()); + } + + public function test_update_with_failed_permanently_finalises_a_claimed_row(): void + { + $this->store->schedule(Redelivery::schedule( + event: $this->event, + listener: 'App\\Listener', + attemptNumber: 5, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + )); + $claimed = $this->store->next(); + self::assertNotNull($claimed); + + $this->store->update($claimed->markFailedPermanently(new RuntimeException('final'))); + + self::assertNull($this->store->next()); + } + + public function test_retry_now_re_queues_a_permanently_failed_row(): void + { + $this->store->schedule(Redelivery::schedule( + event: $this->event, + listener: 'App\\Listener', + attemptNumber: 5, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + )); + $claimed = $this->store->next(); + self::assertNotNull($claimed); + $this->store->update($claimed->markFailedPermanently(new RuntimeException('final'))); + + $this->store->retryNow($this->event->id, 'App\\Listener'); + + $due = $this->store->next(); + + self::assertNotNull($due); + self::assertSame('App\\Listener', $due->listener); + self::assertSame(5, $due->attemptNumber); + } + + public function test_schedule_is_a_noop_when_a_row_already_exists(): void + { + $this->store->schedule(Redelivery::schedule( + event: $this->event, + listener: 'App\\Listener', + attemptNumber: 2, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('first'), + )); + $this->store->schedule(Redelivery::schedule( + event: $this->event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->addMinute(), + lastError: new RuntimeException('replay-from-recovered-event'), + )); + + $due = $this->store->next(); + + self::assertNotNull($due); + self::assertSame(2, $due->attemptNumber); + self::assertStringContainsString('first', $due->lastError); + } + + public function test_update_is_a_noop_for_unknown_row(): void + { + $orphan = Redelivery::schedule( + event: $this->event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + )->claim()->markSucceeded(); + + $this->store->update($orphan); + + self::assertNull($this->store->next()); + } + + public function test_retry_now_is_a_noop_for_unknown_row(): void + { + $this->store->retryNow('unknown-id', 'App\\Listener'); + + self::assertNull($this->store->next()); + } +} diff --git a/tests/Unit/Redelivery/RedeliveryTest.php b/tests/Unit/Redelivery/RedeliveryTest.php new file mode 100644 index 0000000..8e350be --- /dev/null +++ b/tests/Unit/Redelivery/RedeliveryTest.php @@ -0,0 +1,309 @@ +addMinute(); + $now = CarbonImmutable::now(); + CarbonImmutable::setTestNow($now); + + try { + $redelivery = Redelivery::schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: $nextRetryAt, + lastError: new RuntimeException('boom'), + ); + + $expected = Redelivery::retrieve( + event: $event, + listener: 'App\\Listener', + status: RedeliveryStatus::PendingRetry, + attemptNumber: 1, + nextRetryAt: $nextRetryAt, + lastError: 'RuntimeException: boom', + createdAt: $now, + updatedAt: $now, + ); + + self::assertEquals($expected, $redelivery); + } finally { + CarbonImmutable::setTestNow(); + } + } + + public function test_retrieve_reconstructs_a_row_with_supplied_values(): void + { + $event = TestEventFactory::retrieveOrderPlaced(); + $createdAt = CarbonImmutable::parse('2026-01-01 10:00:00'); + $updatedAt = CarbonImmutable::parse('2026-01-01 10:05:00'); + $nextRetryAt = CarbonImmutable::parse('2026-01-01 10:10:00'); + + $redelivery = Redelivery::retrieve( + event: $event, + listener: 'App\\Listener', + status: RedeliveryStatus::Dispatching, + attemptNumber: 3, + nextRetryAt: $nextRetryAt, + lastError: 'RuntimeException: prior', + createdAt: $createdAt, + updatedAt: $updatedAt, + ); + + $expected = Redelivery::retrieve( + event: $event, + listener: 'App\\Listener', + status: RedeliveryStatus::Dispatching, + attemptNumber: 3, + nextRetryAt: $nextRetryAt, + lastError: 'RuntimeException: prior', + createdAt: $createdAt, + updatedAt: $updatedAt, + ); + + self::assertEquals($expected, $redelivery); + } + + public function test_retrieve_rejects_attempt_number_below_one(): void + { + $this->expectException(InvalidArgumentException::class); + + Redelivery::retrieve( + event: TestEventFactory::retrieveOrderPlaced(), + listener: 'App\\Listener', + status: RedeliveryStatus::PendingRetry, + attemptNumber: 0, + nextRetryAt: CarbonImmutable::now(), + lastError: '', + createdAt: CarbonImmutable::now(), + updatedAt: CarbonImmutable::now(), + ); + } + + public function test_schedule_rejects_attempt_number_below_one(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('attemptNumber must be at least 1'); + + Redelivery::schedule( + event: TestEventFactory::retrieveOrderPlaced(), + listener: 'App\\Listener', + attemptNumber: 0, + nextRetryAt: CarbonImmutable::now(), + lastError: new RuntimeException('boom'), + ); + } + + public function test_schedule_rejects_negative_attempt_number(): void + { + $this->expectException(InvalidArgumentException::class); + + Redelivery::schedule( + event: TestEventFactory::retrieveOrderPlaced(), + listener: 'App\\Listener', + attemptNumber: -3, + nextRetryAt: CarbonImmutable::now(), + lastError: new RuntimeException('boom'), + ); + } + + public function test_schedule_rejects_empty_listener(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('listener must not be empty'); + + Redelivery::schedule( + event: TestEventFactory::retrieveOrderPlaced(), + listener: '', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now(), + lastError: new RuntimeException('boom'), + ); + } + + public function test_schedule_rejects_whitespace_only_listener(): void + { + $this->expectException(InvalidArgumentException::class); + + Redelivery::schedule( + event: TestEventFactory::retrieveOrderPlaced(), + listener: " \t\n", + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now(), + lastError: new RuntimeException('boom'), + ); + } + + public function test_event_id_and_event_name_proxy_to_underlying_event(): void + { + $event = TestEventFactory::retrieveOrderPlaced(); + $redelivery = self::fresh($event, listener: 'App\\Listener'); + + self::assertSame($event->id, $redelivery->eventId()); + self::assertSame($event->name, $redelivery->eventName()); + } + + public function test_is_for_compares_listener_strings(): void + { + $redelivery = self::fresh(listener: 'App\\Listener'); + + self::assertTrue($redelivery->isFor('App\\Listener')); + self::assertFalse($redelivery->isFor('App\\Other')); + } + + public function test_is_due_only_when_pending_and_retry_time_passed(): void + { + $now = CarbonImmutable::now(); + $duePending = self::fresh(nextRetryAt: $now->subSecond()); + $futurePending = self::fresh(nextRetryAt: $now->addMinute()); + + self::assertTrue($duePending->isDue($now)); + self::assertFalse($futurePending->isDue($now)); + } + + public function test_claim_transitions_pending_to_dispatching(): void + { + $claimed = self::fresh()->claim(); + + self::assertTrue($claimed->isDispatching()); + self::assertFalse($claimed->isPending()); + } + + public function test_mark_succeeded_transitions_to_succeeded(): void + { + $finalised = self::fresh()->claim()->markSucceeded(); + + self::assertSame(RedeliveryStatus::Succeeded, $finalised->status); + self::assertTrue($finalised->isSucceeded()); + } + + public function test_mark_failed_permanently_transitions_to_failed_and_stores_error(): void + { + $error = new RuntimeException('final'); + $finalised = self::fresh()->claim()->markFailedPermanently($error); + + self::assertSame(RedeliveryStatus::Failed, $finalised->status); + self::assertTrue($finalised->isFailed()); + self::assertStringContainsString('final', $finalised->lastError); + } + + public function test_is_due_is_false_for_non_pending_statuses(): void + { + $now = CarbonImmutable::now(); + + $dispatching = self::fresh(nextRetryAt: $now->subSecond())->claim(); + $succeeded = self::fresh(nextRetryAt: $now->subSecond())->claim()->markSucceeded(); + $failed = self::fresh(nextRetryAt: $now->subSecond())->claim()->markFailedPermanently(new RuntimeException('x')); + + self::assertFalse($dispatching->isDue($now)); + self::assertFalse($succeeded->isDue($now)); + self::assertFalse($failed->isDue($now)); + } + + public function test_reschedule_to_bumps_attempt_count_and_returns_to_pending(): void + { + $error = new RuntimeException('again'); + $next = CarbonImmutable::now()->addMinutes(5); + + $rescheduled = self::fresh(attemptNumber: 1) + ->claim() + ->rescheduleTo(attemptNumber: 2, nextRetryAt: $next, lastError: $error); + + self::assertTrue($rescheduled->isPending()); + self::assertSame(2, $rescheduled->attemptNumber); + self::assertSame($next, $rescheduled->nextRetryAt); + self::assertStringContainsString('again', $rescheduled->lastError); + } + + public function test_queue_for_immediate_retry_preserves_attempt_count(): void + { + $original = self::fresh(attemptNumber: 4); + $requeued = $original->claim()->markFailedPermanently(new RuntimeException('x'))->queueForImmediateRetry(); + + self::assertTrue($requeued->isPending()); + self::assertSame(4, $requeued->attemptNumber); + } + + public function test_claim_mutates_and_returns_the_same_instance(): void + { + $redelivery = self::fresh(); + + $claimed = $redelivery->claim(); + + self::assertSame($redelivery, $claimed); + self::assertTrue($redelivery->isDispatching()); + } + + public function test_claim_rejects_a_non_pending_redelivery(): void + { + $redelivery = self::fresh()->claim(); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Cannot claim a redelivery in status dispatching'); + + $redelivery->claim(); + } + + public function test_mark_succeeded_rejects_a_non_dispatching_redelivery(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Cannot markSucceeded a redelivery in status pending_retry'); + + self::fresh()->markSucceeded(); + } + + public function test_mark_failed_permanently_rejects_a_non_dispatching_redelivery(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Cannot markFailedPermanently a redelivery in status pending_retry'); + + self::fresh()->markFailedPermanently(new RuntimeException('x')); + } + + public function test_reschedule_to_rejects_a_non_dispatching_redelivery(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Cannot rescheduleTo a redelivery in status pending_retry'); + + self::fresh()->rescheduleTo(2, CarbonImmutable::now(), new RuntimeException('x')); + } + + public function test_queue_for_immediate_retry_accepts_any_status(): void + { + $succeeded = self::fresh()->claim()->markSucceeded(); + $failed = self::fresh()->claim()->markFailedPermanently(new RuntimeException('x')); + + self::assertTrue($succeeded->queueForImmediateRetry()->isPending()); + self::assertTrue($failed->queueForImmediateRetry()->isPending()); + } + + private static function fresh( + ?RawEvent $event = null, + string $listener = 'App\\Listener', + int $attemptNumber = 1, + ?CarbonImmutable $nextRetryAt = null, + ): Redelivery { + return Redelivery::schedule( + event: $event ?? TestEventFactory::retrieveOrderPlaced(), + listener: $listener, + attemptNumber: $attemptNumber, + nextRetryAt: $nextRetryAt ?? CarbonImmutable::now(), + lastError: new RuntimeException('initial'), + ); + } +} diff --git a/tests/Unit/Redelivery/SequentialRedeliveryProcessorTest.php b/tests/Unit/Redelivery/SequentialRedeliveryProcessorTest.php new file mode 100644 index 0000000..28ff5dc --- /dev/null +++ b/tests/Unit/Redelivery/SequentialRedeliveryProcessorTest.php @@ -0,0 +1,318 @@ +subscribers = new EventSubscriberMap(); + } + + public function test_does_nothing_when_store_is_empty(): void + { + $store = new InMemoryRedeliveryStore(); + $called = false; + $this->subscribers->subscribe('order.placed', function () use (&$called) { + $called = true; + }); + + $processor = new SequentialRedeliveryProcessor( + $this->subscribers, + new DefaultListenerDispatcher(), + self::policyReturning(CarbonImmutable::now()->addMinute()), + ); + + $processor->process($store); + + self::assertFalse($called); + } + + public function test_dispatches_due_redelivery_and_marks_succeeded(): void + { + $event = TestEventFactory::retrieveOrderPlaced(); + $store = new InMemoryRedeliveryStore(); + $store->schedule(Redelivery::schedule( + event: $event, + listener: 'Closure', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('earlier failure'), + )); + + $received = null; + $this->subscribers->subscribe('order.placed', function (object $e) use (&$received) { + $received = $e; + }); + + $processor = new SequentialRedeliveryProcessor( + $this->subscribers, + new DefaultListenerDispatcher(), + self::policyReturning(CarbonImmutable::now()->addMinute()), + ); + + $processor->process($store); + + self::assertNotNull($received); + self::assertNull($store->next()); + } + + public function test_marks_succeeded_when_dispatcher_returns_without_throwing(): void + { + $now = CarbonImmutable::parse('2026-05-16 12:00:00'); + CarbonImmutable::setTestNow($now); + + try { + $event = TestEventFactory::retrieveOrderPlaced(); + $claimed = Redelivery::schedule( + event: $event, + listener: 'Closure', + attemptNumber: 1, + nextRetryAt: $now->subSecond(), + lastError: new RuntimeException('initial'), + )->claim(); + + $expected = Redelivery::retrieve( + event: $event, + listener: 'Closure', + status: RedeliveryStatus::Succeeded, + attemptNumber: 1, + nextRetryAt: $now->subSecond(), + lastError: 'RuntimeException: initial', + createdAt: $now, + updatedAt: $now, + ); + + $this->subscribers->subscribe('order.placed', function () {}); + + $processor = new SequentialRedeliveryProcessor( + $this->subscribers, + self::dispatcherThatDoesNotThrow(), + self::policyReturning($now->addMinute()), + ); + + $processor->process($this->mockStoreExpectingUpdate($claimed, $expected)); + } finally { + CarbonImmutable::setTestNow(); + } + } + + public function test_reschedules_with_attempt_plus_one_when_retry_policy_allows(): void + { + $now = CarbonImmutable::parse('2026-05-16 12:00:00'); + CarbonImmutable::setTestNow($now); + + try { + $event = TestEventFactory::retrieveOrderPlaced(); + $nextRetryAt = $now->addMinute(); + $exception = new RuntimeException('still failing'); + + $claimed = Redelivery::schedule( + event: $event, + listener: 'Closure', + attemptNumber: 1, + nextRetryAt: $now->subSecond(), + lastError: new RuntimeException('previous failure'), + )->claim(); + + $expected = Redelivery::retrieve( + event: $event, + listener: 'Closure', + status: RedeliveryStatus::PendingRetry, + attemptNumber: 2, + nextRetryAt: $nextRetryAt, + lastError: 'RuntimeException: still failing', + createdAt: $now, + updatedAt: $now, + ); + + $this->subscribers->subscribe('order.placed', function () {}); + + $processor = new SequentialRedeliveryProcessor( + $this->subscribers, + self::dispatcherThrowing($exception), + self::policyReturning($nextRetryAt), + ); + + $processor->process($this->mockStoreExpectingUpdate($claimed, $expected)); + } finally { + CarbonImmutable::setTestNow(); + } + } + + public function test_marks_failed_permanently_when_retry_policy_returns_null(): void + { + $now = CarbonImmutable::parse('2026-05-16 12:00:00'); + CarbonImmutable::setTestNow($now); + + try { + $event = TestEventFactory::retrieveOrderPlaced(); + $exception = new RuntimeException('still failing'); + + $claimed = Redelivery::schedule( + event: $event, + listener: 'Closure', + attemptNumber: 3, + nextRetryAt: $now->subSecond(), + lastError: new RuntimeException('previous failure'), + )->claim(); + + $expected = Redelivery::retrieve( + event: $event, + listener: 'Closure', + status: RedeliveryStatus::Failed, + attemptNumber: 3, + nextRetryAt: $now->subSecond(), + lastError: 'RuntimeException: still failing', + createdAt: $now, + updatedAt: $now, + ); + + $this->subscribers->subscribe('order.placed', function () {}); + + $processor = new SequentialRedeliveryProcessor( + $this->subscribers, + self::dispatcherThrowing($exception), + self::policyReturning(null), + ); + + $processor->process($this->mockStoreExpectingUpdate($claimed, $expected)); + } finally { + CarbonImmutable::setTestNow(); + } + } + + public function test_marks_failed_permanently_when_listener_is_no_longer_registered(): void + { + $now = CarbonImmutable::parse('2026-05-16 12:00:00'); + CarbonImmutable::setTestNow($now); + + try { + $event = TestEventFactory::retrieveOrderPlaced(); + + $claimed = Redelivery::schedule( + event: $event, + listener: 'App\\Removed', + attemptNumber: 1, + nextRetryAt: $now->subSecond(), + lastError: new RuntimeException('previous failure'), + )->claim(); + + $expected = Redelivery::retrieve( + event: $event, + listener: 'App\\Removed', + status: RedeliveryStatus::Failed, + attemptNumber: 1, + nextRetryAt: $now->subSecond(), + lastError: "RuntimeException: Listener 'App\\Removed' is no longer registered for event 'order.placed'.", + createdAt: $now, + updatedAt: $now, + ); + + $processor = new SequentialRedeliveryProcessor( + $this->subscribers, + self::dispatcherThatDoesNotThrow(), + self::policyReturning($now->addMinute()), + ); + + $processor->process($this->mockStoreExpectingUpdate($claimed, $expected)); + } finally { + CarbonImmutable::setTestNow(); + } + } + + public function test_drains_multiple_due_rows_in_one_call(): void + { + $eventA = TestEventFactory::retrieveOrderPlaced(['n' => 1]); + $eventB = TestEventFactory::retrieveOrderPlaced(['n' => 2]); + + $store = new InMemoryRedeliveryStore(); + $store->schedule(Redelivery::schedule( + event: $eventA, + listener: 'Closure', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('a failed'), + )); + $store->schedule(Redelivery::schedule( + event: $eventB, + listener: 'Closure', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('b failed'), + )); + + $received = []; + $this->subscribers->subscribe('order.placed', function (object $e) use (&$received) { + $received[] = $e->n; + }); + + $processor = new SequentialRedeliveryProcessor( + $this->subscribers, + new DefaultListenerDispatcher(), + self::policyReturning(CarbonImmutable::now()->addMinute()), + ); + + $processor->process($store); + + self::assertSame([1, 2], $received); + self::assertNull($store->next()); + } + + private function mockStoreExpectingUpdate(Redelivery $claimed, Redelivery $expected): RedeliveryStore + { + $store = $this->createMock(RedeliveryStore::class); + $store->method('next')->willReturnOnConsecutiveCalls($claimed, null); + $store->expects($this->once())->method('update')->with($expected); + $store->expects($this->never())->method('schedule'); + + return $store; + } + + private static function dispatcherThatDoesNotThrow(): ListenerDispatcher + { + return new readonly class implements ListenerDispatcher { + public function dispatch(RawEvent $event, callable|string $subscriber): void {} + }; + } + + private static function dispatcherThrowing(Throwable $error): ListenerDispatcher + { + return new readonly class ($error) implements ListenerDispatcher { + public function __construct(private Throwable $error) {} + public function dispatch(RawEvent $event, callable|string $subscriber): void + { + throw $this->error; + } + }; + } + + private static function policyReturning(?CarbonImmutable $nextRetryAt): RetryPolicy + { + return new readonly class ($nextRetryAt) implements RetryPolicy { + public function __construct(private ?CarbonImmutable $nextRetryAt) {} + public function nextRetryAt(int $previousAttempt): ?CarbonImmutable + { + return $this->nextRetryAt; + } + }; + } +} diff --git a/tests/Unit/Redelivery/SilentRedeliveryProcessorTest.php b/tests/Unit/Redelivery/SilentRedeliveryProcessorTest.php new file mode 100644 index 0000000..1e196d7 --- /dev/null +++ b/tests/Unit/Redelivery/SilentRedeliveryProcessorTest.php @@ -0,0 +1,54 @@ +called = true; + } + }; + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('error'); + + new SilentRedeliveryProcessor($inner, $logger)->process(new InMemoryRedeliveryStore()); + + self::assertTrue($inner->called); + } + + public function test_catches_and_logs_exception_from_inner(): void + { + $exception = new RuntimeException('redelivery processor exploded'); + $inner = new class ($exception) implements RedeliveryProcessor { + public function __construct(private RuntimeException $error) {} + #[Override] public function process(RedeliveryStore $store): void + { + throw $this->error; + } + }; + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('error') + ->with( + 'Redelivery processor aborted.', + ['exception' => $exception], + ); + + new SilentRedeliveryProcessor($inner, $logger)->process(new InMemoryRedeliveryStore()); + } +} diff --git a/tests/Unit/Retry/ExponentialBackoffRetryPolicyTest.php b/tests/Unit/Retry/ExponentialBackoffRetryPolicyTest.php new file mode 100644 index 0000000..c43e647 --- /dev/null +++ b/tests/Unit/Retry/ExponentialBackoffRetryPolicyTest.php @@ -0,0 +1,78 @@ +nextRetryAt(1); + + self::assertNotNull($next); + self::assertSame(100, (int) round(CarbonImmutable::now()->diffInMilliseconds($next))); + } + + public function test_returns_now_plus_second_delay_for_second_attempt(): void + { + $policy = new ExponentialBackoffRetryPolicy(delaysMs: [100, 500]); + + $next = $policy->nextRetryAt(2); + + self::assertNotNull($next); + self::assertSame(500, (int) round(CarbonImmutable::now()->diffInMilliseconds($next))); + } + + public function test_returns_null_when_attempts_exhausted(): void + { + $policy = new ExponentialBackoffRetryPolicy(delaysMs: [100, 500]); + + self::assertNull($policy->nextRetryAt(3)); + self::assertNull($policy->nextRetryAt(99)); + } + + public function test_default_delays_yield_four_retries_with_in_process_then_persisted_durations(): void + { + $policy = new ExponentialBackoffRetryPolicy(); + $now = CarbonImmutable::now(); + + self::assertSame(100, (int) round($now->diffInMilliseconds($policy->nextRetryAt(1)))); + self::assertSame(500, (int) round($now->diffInMilliseconds($policy->nextRetryAt(2)))); + self::assertSame(60_000, (int) round($now->diffInMilliseconds($policy->nextRetryAt(3)))); + self::assertSame(300_000, (int) round($now->diffInMilliseconds($policy->nextRetryAt(4)))); + self::assertNull($policy->nextRetryAt(5)); + } + + public function test_rejects_negative_delays(): void + { + $this->expectException(InvalidArgumentException::class); + + new ExponentialBackoffRetryPolicy(delaysMs: [100, -1]); + } + + public function test_returns_null_for_zero_or_negative_attempt_number(): void + { + $policy = new ExponentialBackoffRetryPolicy(delaysMs: [100]); + + self::assertNull($policy->nextRetryAt(0)); + self::assertNull($policy->nextRetryAt(-1)); + } +} diff --git a/tests/Unit/Retry/NoRetryPolicyTest.php b/tests/Unit/Retry/NoRetryPolicyTest.php new file mode 100644 index 0000000..29e28e9 --- /dev/null +++ b/tests/Unit/Retry/NoRetryPolicyTest.php @@ -0,0 +1,23 @@ +nextRetryAt(1)); + } + + public function test_returns_null_for_arbitrary_attempt(): void + { + $policy = new NoRetryPolicy(); + + self::assertNull($policy->nextRetryAt(42)); + } +} diff --git a/tests/Unit/SequentialEventProcessorTest.php b/tests/Unit/SequentialEventProcessorTest.php index 59cc98e..2afe572 100644 --- a/tests/Unit/SequentialEventProcessorTest.php +++ b/tests/Unit/SequentialEventProcessorTest.php @@ -3,13 +3,16 @@ namespace Test\Vesper\Tool\Event\Unit; use PHPUnit\Framework\TestCase; +use RuntimeException; +use Test\Vesper\Tool\Event\_Fixtures\TestEventFactory; +use Test\Vesper\Tool\Event\_Fixtures\TrackingEventStore; +use Test\Vesper\Tool\Event\_Fixtures\TrackingListener; use Vesper\Tool\Event\EventHydrator; use Vesper\Tool\Event\EventSubscriberMap; use Vesper\Tool\Event\HandlerResolver; +use Vesper\Tool\Event\Infrastructure\Dispatch\DefaultListenerDispatcher; use Vesper\Tool\Event\Infrastructure\InMemoryEventStore; use Vesper\Tool\Event\Infrastructure\SequentialEventProcessor; -use Test\Vesper\Tool\Event\_Fixtures\TestEventFactory; -use Test\Vesper\Tool\Event\_Fixtures\TrackingListener; class SequentialEventProcessorTest extends TestCase { @@ -47,7 +50,7 @@ public function test_dispatches_deserialized_payload_to_subscriber(): void $this->processor->process($this->store); - self::assertSame(1, $received->order_id); + self::assertEquals((object) ['order_id' => 1], $received); } public function test_calls_all_subscribers_for_the_same_event_type(): void @@ -72,20 +75,20 @@ public function test_routes_different_event_types_to_their_own_subscribers(): vo $this->store->add(TestEventFactory::retrieveOrderPlaced(['order_id' => 1])); $this->store->add(TestEventFactory::retrievePaymentReceived(['amount' => 50])); - $orderId = null; - $amount = null; + $orderEvent = null; + $paymentEvent = null; - $this->subscribers->subscribe('order.placed', function (object $e) use (&$orderId) { - $orderId = $e->order_id; + $this->subscribers->subscribe('order.placed', function (object $e) use (&$orderEvent) { + $orderEvent = $e; }); - $this->subscribers->subscribe('payment.received', function (object $e) use (&$amount) { - $amount = $e->amount; + $this->subscribers->subscribe('payment.received', function (object $e) use (&$paymentEvent) { + $paymentEvent = $e; }); $this->processor->process($this->store); - self::assertSame(1, $orderId); - self::assertSame(50, $amount); + self::assertEquals((object) ['order_id' => 1], $orderEvent); + self::assertEquals((object) ['amount' => 50], $paymentEvent); } public function test_processes_all_queued_events(): void @@ -119,15 +122,15 @@ public function test_invokes_class_string_subscriber_via_invoke(): void $listener = new TrackingListener(); - $resolver = $this->createStub(HandlerResolver::class); - $resolver->method('resolve')->willReturn($listener); - $subscribers = new EventSubscriberMap(['order.placed' => [TrackingListener::class]]); - $processor = new SequentialEventProcessor($subscribers, $resolver); + $processor = new SequentialEventProcessor( + $subscribers, + new DefaultListenerDispatcher(resolver: $this->resolverReturning($listener)), + ); $processor->process($this->store); - self::assertSame(42, $listener->received()->order_id); + self::assertEquals((object) ['order_id' => 42], $listener->received()); } public function test_does_not_dispatch_to_subscriber_for_a_different_type(): void @@ -157,46 +160,72 @@ public function __construct(public int $orderId) {} $received = $e; }; - $hydrator = $this->createMock(EventHydrator::class); - $hydrator->method('hydrate') - ->with('order.placed', ['order_id' => 7], $subscriber) - ->willReturn($typedEvent); - $subscribers = new EventSubscriberMap(); $subscribers->subscribe('order.placed', $subscriber); - $processor = new SequentialEventProcessor($subscribers, hydrator: $hydrator); + $processor = new SequentialEventProcessor( + $subscribers, + new DefaultListenerDispatcher(hydrator: $this->mockHydratorReturning( + name: 'order.placed', + payload: ['order_id' => 7], + subscriber: $subscriber, + event: $typedEvent, + )), + ); $processor->process($this->store); self::assertSame($typedEvent, $received); } - public function test_hydrates_once_per_subscriber_passing_subscriber_as_context(): void + public function test_calls_mark_processed_after_each_event_succeeds(): void { - $this->store->add(TestEventFactory::retrieveOrderPlaced(['order_id' => 1])); + $store = new TrackingEventStore(); + $event = TestEventFactory::retrieveOrderPlaced(); + $store->add($event); - $subscriberA = function () {}; - $subscriberB = function () {}; + $this->subscribers->subscribe('order.placed', function () {}); - $hydrateCalls = []; - $hydrator = $this->createMock(EventHydrator::class); - $hydrator->expects($this->exactly(2)) - ->method('hydrate') - ->willReturnCallback(function (string $name, array $payload, callable $subscriber) use (&$hydrateCalls): object { - $hydrateCalls[] = $subscriber; - return (object) $payload; - }); + $this->processor->process($store); - $subscribers = new EventSubscriberMap(); - $subscribers->subscribe('order.placed', $subscriberA); - $subscribers->subscribe('order.placed', $subscriberB); + self::assertSame([$event->id], $store->markProcessedCalls); + } - $processor = new SequentialEventProcessor($subscribers, hydrator: $hydrator); - $processor->process($this->store); + public function test_lets_listener_exceptions_propagate(): void + { + $this->store->add(TestEventFactory::retrieveOrderPlaced()); + + $this->subscribers->subscribe('order.placed', function () { + throw new RuntimeException('boom'); + }); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('boom'); - self::assertCount(2, $hydrateCalls); - self::assertSame($subscriberA, $hydrateCalls[0]); - self::assertSame($subscriberB, $hydrateCalls[1]); + $this->processor->process($this->store); } + private function resolverReturning(callable|object $listener): HandlerResolver + { + $resolver = $this->createStub(HandlerResolver::class); + $resolver->method('resolve')->willReturn($listener); + + return $resolver; + } + + /** + * @param array $payload + */ + private function mockHydratorReturning( + string $name, + array $payload, + callable|string $subscriber, + object $event, + ): EventHydrator { + $hydrator = $this->createMock(EventHydrator::class); + $hydrator->method('hydrate') + ->with($name, $payload, $subscriber) + ->willReturn($event); + + return $hydrator; + } } diff --git a/tests/Unit/SilentEventProcessorTest.php b/tests/Unit/SilentEventProcessorTest.php new file mode 100644 index 0000000..4da07f2 --- /dev/null +++ b/tests/Unit/SilentEventProcessorTest.php @@ -0,0 +1,54 @@ +called = true; + } + }; + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('error'); + + new SilentEventProcessor($inner, $logger)->process(new InMemoryEventStore()); + + self::assertTrue($inner->called); + } + + public function test_catches_and_logs_exception_from_inner(): void + { + $exception = new RuntimeException('processor exploded'); + $inner = new class ($exception) implements EventProcessor { + public function __construct(private RuntimeException $error) {} + #[Override] public function process(EventStore $store): void + { + throw $this->error; + } + }; + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('error') + ->with( + 'Event processor aborted.', + ['exception' => $exception], + ); + + new SilentEventProcessor($inner, $logger)->process(new InMemoryEventStore()); + } +} diff --git a/tests/Unit/SilentSequentialEventProcessorTest.php b/tests/Unit/SilentSequentialEventProcessorTest.php deleted file mode 100644 index 67356f2..0000000 --- a/tests/Unit/SilentSequentialEventProcessorTest.php +++ /dev/null @@ -1,174 +0,0 @@ -store = new InMemoryEventStore(); - $this->subscribers = new EventSubscriberMap(); - $this->logger = $this->createMock(LoggerInterface::class); - - $hydrator = $this->createStub(EventHydrator::class); - $hydrator->method('hydrate')->willReturnCallback(fn(string $name, array $payload) => (object) $payload); - - $this->processor = new SilentSequentialEventProcessor( - $this->subscribers, - $this->logger, - hydrator: $hydrator, - ); - } - - public function test_continues_processing_remaining_subscribers_after_one_throws(): void - { - $event = TestEventFactory::retrieveOrderPlaced(); - $this->store->add($event); - - $exception = new RuntimeException('Listener error'); - $secondCalled = false; - $this->subscribers->subscribe('order.placed', function () use ($exception) { - throw $exception; - }); - $this->subscribers->subscribe('order.placed', function () use (&$secondCalled) { - $secondCalled = true; - }); - - $this->logger->expects($this->once()) - ->method('error') - ->with( - 'Failed to dispatch event to listener.', - [ - 'event' => 'order.placed', - 'event_id' => $event->id, - 'listener' => 'Closure', - 'exception' => $exception, - ], - ); - - $this->processor->process($this->store); - - self::assertTrue($secondCalled); - } - - public function test_logs_error_with_exception_context_on_failure(): void - { - $event = TestEventFactory::retrieveOrderPlaced(); - $this->store->add($event); - - $exception = new RuntimeException('Boom'); - $this->subscribers->subscribe('order.placed', function () use ($exception) { - throw $exception; - }); - - $this->logger->expects($this->once()) - ->method('error') - ->with( - 'Failed to dispatch event to listener.', - [ - 'event' => 'order.placed', - 'event_id' => $event->id, - 'listener' => 'Closure', - 'exception' => $exception, - ], - ); - - $this->processor->process($this->store); - } - - public function test_logs_once_per_failing_listener(): void - { - $this->store->add(TestEventFactory::retrieveOrderPlaced()); - - $this->subscribers->subscribe('order.placed', function () { - throw new RuntimeException('First'); - }); - $this->subscribers->subscribe('order.placed', function () { - throw new RuntimeException('Second'); - }); - - $this->logger->expects($this->exactly(2))->method('error'); - - $this->processor->process($this->store); - } - - public function test_does_not_log_when_no_listener_throws(): void - { - $this->store->add(TestEventFactory::retrieveOrderPlaced()); - - $this->subscribers->subscribe('order.placed', function () {}); - - $this->logger->expects($this->never())->method('error'); - - $this->processor->process($this->store); - } - - public function test_logs_class_name_when_subscriber_is_a_class_string(): void - { - $event = TestEventFactory::retrieveOrderPlaced(); - $this->store->add($event); - - $this->subscribers->subscribe('order.placed', ThrowingListener::class); - - $this->logger->expects($this->once()) - ->method('error') - ->with( - 'Failed to dispatch event to listener.', - [ - 'event' => 'order.placed', - 'event_id' => $event->id, - 'listener' => ThrowingListener::class, - 'exception' => new RuntimeException('ThrowingListener always fails.'), - ] - ); - - $this->processor->process($this->store); - } - - public function test_continues_processing_subsequent_events_after_failure(): void - { - $firstEvent = TestEventFactory::retrieveOrderPlaced(); - $this->store->add($firstEvent); - $this->store->add(TestEventFactory::retrieveOrderPlaced(['n' => 2])); - - $exception = new RuntimeException('Missing n'); - $received = []; - $this->subscribers->subscribe('order.placed', function (object $e) use (&$received, $exception) { - if (!isset($e->n)) { - throw $exception; - } - $received[] = $e->n; - }); - - $this->logger->expects($this->once()) - ->method('error') - ->with( - 'Failed to dispatch event to listener.', - [ - 'event' => 'order.placed', - 'event_id' => $firstEvent->id, - 'listener' => 'Closure', - 'exception' => $exception, - ], - ); - - $this->processor->process($this->store); - - self::assertSame([2], $received); - } -} diff --git a/tests/_Fixtures/CapturingEventStore.php b/tests/_Fixtures/CapturingEventStore.php new file mode 100644 index 0000000..9594985 --- /dev/null +++ b/tests/_Fixtures/CapturingEventStore.php @@ -0,0 +1,25 @@ + */ + public array $added = []; + + #[Override] public function add(RawEvent $event): void + { + $this->added[] = $event; + } + + #[Override] public function next(): ?RawEvent + { + return null; + } + + #[Override] public function markProcessed(RawEvent $event): void {} +} diff --git a/tests/_Fixtures/EmptyEventStub.php b/tests/_Fixtures/EmptyEventStub.php index dd17dd5..0600175 100644 --- a/tests/_Fixtures/EmptyEventStub.php +++ b/tests/_Fixtures/EmptyEventStub.php @@ -2,6 +2,4 @@ namespace Test\Vesper\Tool\Event\_Fixtures; -readonly class EmptyEventStub -{ -} +readonly class EmptyEventStub {} diff --git a/tests/_Fixtures/IgnorableExceptionStub.php b/tests/_Fixtures/IgnorableExceptionStub.php new file mode 100644 index 0000000..d0bdcc4 --- /dev/null +++ b/tests/_Fixtures/IgnorableExceptionStub.php @@ -0,0 +1,7 @@ + */ + public array $markProcessedCalls = []; + + public function markProcessed(RawEvent $event): void + { + $this->markProcessedCalls[] = $event->id; + } +} diff --git a/tests/_Fixtures/TrackingListener.php b/tests/_Fixtures/TrackingListener.php index 292e44c..03254c2 100644 --- a/tests/_Fixtures/TrackingListener.php +++ b/tests/_Fixtures/TrackingListener.php @@ -1,6 +1,6 @@