From 311f0014f0377879e02cd9e99d063aaa4942c64e Mon Sep 17 00:00:00 2001 From: Jason Viljoen Date: Tue, 28 Apr 2026 00:09:41 +0200 Subject: [PATCH 01/16] feat: add automatic retry and per-subscriber failure tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hybrid in-process + persisted retries for listener failures, tracked durably per (event, listener) so a single failing listener of an event is retried independently while the others continue. Adds an intermediate `processing` event status so events claimed by a worker survive crashes mid-dispatch. Opt-in via new processor constructor params (RetryPolicy, RedeliveryTracker, ignoredExceptions) defaulting to no-retry + no-tracker — existing behaviour unchanged. EventStore gains markProcessed(); the previously-unused event_outbox_status table is now active as an audit trail. Also fixes four test fixtures left in the old Test\Tcds\Io\Ray\_Fixtures namespace by the earlier "Migrate to vesper" commit so the test suite runs. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 319 +++++++++++++++++- src/DueRedelivery.php | 17 + src/EventStore.php | 22 ++ src/Infrastructure/InMemoryEventStore.php | 5 + .../InMemoryRedeliveryTracker.php | 125 +++++++ .../Retry/ExponentialBackoffRetryPolicy.php | 43 +++ src/Infrastructure/Retry/NoRetryPolicy.php | 16 + .../Schema/MysqlRedeliverySchema.php | 56 +++ .../Schema/SqliteRedeliverySchema.php | 38 +++ .../SequentialEventProcessor.php | 150 +++++++- .../SilentSequentialEventProcessor.php | 32 +- src/Infrastructure/SqlEventStore.php | 134 +++++++- src/Infrastructure/SqlRedeliveryTracker.php | 246 ++++++++++++++ src/RawEventStatus.php | 1 + src/RedeliveryTracker.php | 54 +++ src/Retry/RetryPolicy.php | 19 ++ tests/Feature/SqlEventStoreAuditTest.php | 129 +++++++ tests/Feature/SqlRedeliveryTrackerTest.php | 193 +++++++++++ tests/Unit/InMemoryRedeliveryTrackerTest.php | 171 ++++++++++ .../ExponentialBackoffRetryPolicyTest.php | 78 +++++ tests/Unit/Retry/NoRetryPolicyTest.php | 23 ++ tests/Unit/SequentialEventProcessorTest.php | 251 +++++++++++++- .../SilentSequentialEventProcessorTest.php | 79 ++++- tests/_Fixtures/IgnorableExceptionStub.php | 9 + tests/_Fixtures/ListenerA.php | 2 +- tests/_Fixtures/ListenerB.php | 2 +- tests/_Fixtures/OrderPlacedStub.php | 2 +- .../RecordingSequentialEventProcessor.php | 21 ++ tests/_Fixtures/TrackingEventStore.php | 21 ++ tests/_Fixtures/TrackingListener.php | 2 +- 30 files changed, 2205 insertions(+), 55 deletions(-) create mode 100644 src/DueRedelivery.php create mode 100644 src/Infrastructure/InMemoryRedeliveryTracker.php create mode 100644 src/Infrastructure/Retry/ExponentialBackoffRetryPolicy.php create mode 100644 src/Infrastructure/Retry/NoRetryPolicy.php create mode 100644 src/Infrastructure/Schema/MysqlRedeliverySchema.php create mode 100644 src/Infrastructure/Schema/SqliteRedeliverySchema.php create mode 100644 src/Infrastructure/SqlRedeliveryTracker.php create mode 100644 src/RedeliveryTracker.php create mode 100644 src/Retry/RetryPolicy.php create mode 100644 tests/Feature/SqlEventStoreAuditTest.php create mode 100644 tests/Feature/SqlRedeliveryTrackerTest.php create mode 100644 tests/Unit/InMemoryRedeliveryTrackerTest.php create mode 100644 tests/Unit/Retry/ExponentialBackoffRetryPolicyTest.php create mode 100644 tests/Unit/Retry/NoRetryPolicyTest.php create mode 100644 tests/_Fixtures/IgnorableExceptionStub.php create mode 100644 tests/_Fixtures/RecordingSequentialEventProcessor.php create mode 100644 tests/_Fixtures/TrackingEventStore.php diff --git a/README.md b/README.md index f67d6a1..33c8ba4 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,13 @@ Events are first written to a durable store (in-memory or SQL), then dispatched - **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 +- **Outbox pattern** — events transition `pending → processing → processed`, with the intermediate state surviving worker crashes +- **Per-listener retries** — configurable `RetryPolicy` plus a hybrid in-process + persisted `RedeliveryTracker`; a single failing listener of an event is retried independently while the others continue +- **Ignored exceptions** — pass a list of `Throwable` classes 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 `SqlRedeliveryTracker` 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,21 @@ 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 | +| `SequentialEventProcessor` | Built-in processor — dispatches events one by one with optional retry policy; exceptions propagate after retries are exhausted (fail-fast) | +| `SilentSequentialEventProcessor` | Same as above but catches per-listener failures after retries are exhausted, logs them via PSR-3, and continues | | `EventSubscriberBuilder` | Fluent builder that produces a ready-to-use `EventSubscriberMap` | +| `RetryPolicy` | Interface — decides whether and when to retry a failed listener | +| `NoRetryPolicy` | Default — never retries | +| `ExponentialBackoffRetryPolicy` | Built-in — five attempts with `100ms / 500ms / 1min / 5min` backoff | +| `RedeliveryTracker` | Interface — persists per-(event, listener) retry state and exposes `retryNow()` for admin tooling | +| `InMemoryRedeliveryTracker` / `SqlRedeliveryTracker` | Tracker implementations | --- @@ -303,15 +311,45 @@ $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' | 'failed' 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` for safe concurrent processing. +> MySQL workers use `SELECT … FOR UPDATE SKIP LOCKED` on the `event_outbox` table for safe +> concurrent processing. + +If you wire up a `RedeliveryTracker` (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. The stuck `processing` row is detectable +by querying `event_outbox_status` (see [Future work](#future-work)). --- @@ -386,11 +424,35 @@ 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 @@ -443,20 +505,249 @@ $processor = new SilentSequentialEventProcessor($subscribers, app('log')); | 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 | -> **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. +Both processors support [Automatic retry & failure tracking](#automatic-retry--failure-tracking) +through the same constructor parameters. The retry policy and the redelivery tracker are opt-in: +default behaviour for both processors is unchanged from earlier versions. + +--- + +## 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. + +The model is **hybrid**: + +1. A small in-process retry burst (sleep + retry) for fast-recovery transient failures. +2. A persisted, scheduled redelivery for slower-recovery failures and for surviving worker crashes. + +Once configured, the same retry behaviour applies to both `SequentialEventProcessor` (which +fails fast after retries are exhausted) and `SilentSequentialEventProcessor` (which logs and +continues). + +### Retry policy + +A `RetryPolicy` decides whether a failed dispatch should be retried, and at what time: + +```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. Same behaviour as before this feature existed, + so wiring up the new processor parameters without choosing a policy is a no-op. +- **`ExponentialBackoffRetryPolicy`** — five total attempts (one initial + four retries) with + delays of `100ms, 500ms, 1min, 5min` by default. The 100ms / 500ms retries happen in-process; + the 1min / 5min retries are persisted and picked up on a future processor 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]); +``` + +The processor classifies each delay as **in-process** (sleep + retry on the same worker) when it +is less than `inProcessRetryThresholdMs` (default `1000`), and **persisted** otherwise. Persisted +retries require a `RedeliveryTracker` to be configured — without one, a long-delay retry is +treated as exhausted and reported. + +### Redelivery tracker + +The `RedeliveryTracker` 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: + +- **`InMemoryRedeliveryTracker`** — array-backed, for tests and dev. +- **`SqlRedeliveryTracker`** — 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\SqlRedeliveryTracker; + +$tracker = new SqlRedeliveryTracker($pdo); // schema created here if not present +``` + +### Wiring a processor with retries + +```php +use Vesper\Tool\Event\Infrastructure\Retry\ExponentialBackoffRetryPolicy; +use Vesper\Tool\Event\Infrastructure\SilentSequentialEventProcessor; +use Vesper\Tool\Event\Infrastructure\SqlRedeliveryTracker; + +$processor = new SilentSequentialEventProcessor( + subscribers: $subscribers, + logger: $logger, + retryPolicy: new ExponentialBackoffRetryPolicy(), + redeliveryTracker: new SqlRedeliveryTracker($pdo), + ignoredExceptions: [ + UserNotFoundException::class, + InvalidPayloadException::class, + ], +); + +$processor->process($store); +``` + +A single call to `process()` will: + +1. Drain new pending events from the store and dispatch them to every registered listener. +2. After the main queue is empty, drain any **due** redeliveries — listener failures from earlier + runs whose `next_retry_at` has now passed. + +Long backoffs (e.g. the default 1min / 5min steps) won't be drained until a future `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 takes a list of `Throwable` class-strings. +Matching is `instanceof`-based, so subclasses are also matched. When a listener throws an +ignored exception: + +- **No retry attempt** — the policy is skipped entirely. +- **No PSR-3 log line** (in `SilentSequentialEventProcessor`). +- **No row written to `event_outbox_redelivery`.** +- **No exception propagation** in `SequentialEventProcessor` either — this is treated as + silent success, the next listener for the same event runs as normal. + +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 a listener has exhausted its retries (or `nextRetryAt` returns `null` immediately because +no retry policy is configured), it is recorded in `event_outbox_redelivery` with +`status = 'failed'`. The row stays in the table so operators can inspect it. The behaviour +differs slightly between processors: + +| Processor | On exhaustion | +|---|---| +| `SequentialEventProcessor` | Marks the row `failed`, then **rethrows** the original exception (fail-fast). The event row stays in `processing` — no `markProcessed()` is called. | +| `SilentSequentialEventProcessor` | Marks the row `failed`, **logs** via PSR-3 (same shape as today), and **swallows** the exception so the next listener runs. The event eventually advances to `processed`. | + +### Re-triggering a failed dispatch + +`RedeliveryTracker::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 successfully dispatched to all listeners +RawEventStatus::failed // reserved for future event-level fatal use (see "Future work") ``` --- +## Future work + +This section captures known gaps and what we'd want to add. The redelivery layer is the headline +feature shipped now; what follows is intentionally deferred so we can let real usage shape it. + +### Stuck-events monitor + +If a worker dies between `next()` and `markProcessed()`, the row stays in +`event_outbox.status = 'processing'` indefinitely. Redelivery rows that *did* get persisted are +durable and will still fire — but the parent event row is wedged. + +Detection query (works today against existing tables): + +```sql +SELECT id, name, created_at, publish_at +FROM event_outbox +WHERE status = 'processing' + AND id IN ( + SELECT event_id FROM event_outbox_status + WHERE status = 'processing' + AND created_at < NOW() - INTERVAL 30 MINUTE -- pick a threshold that matches your workload + ); +``` + +A future sweeper should support three recovery modes: + +- **Re-claim** (`processing → pending`) — safe **only** if listeners are idempotent. Re-dispatch + may re-fire listeners that already succeeded. +- **Force-complete** (`processing → processed`) — safe only when the dispatch is believed to + have finished but the bookkeeping commit was lost. Should write a `processed` audit row marked + as recovered so dashboards can distinguish organic vs. recovered transitions. +- **Mark dead** (`processing → failed`) — finally puts `RawEventStatus::failed` to use; operator + decides next steps. + +### Schema columns to consider when the monitor lands + +Based on Spring Modulith's `EVENT_PUBLICATION` table (the closest production-grade analogue): + +| Column | Why we'd want it | +|---|---| +| `completion_attempts` (INT) on `event_outbox` | How many times this event has been claimed for processing. Increments on every `next()`. Distinguishes "stuck once" from "repeatedly poisoning workers". | +| `last_resubmission_date` (TIMESTAMP) on `event_outbox` | Distinguishes organic stuck rows from rows an operator has already touched. | +| A `RESUBMITTED` enum case (or a `recovered_at` / `recovered_by` column on `event_outbox_status`) | So dashboards can show "rescued by hand" separately from happy path. | + +The existing `event_outbox_redelivery` table already tracks `attempt_number` and `last_error` at +the *listener* level; the columns above are for *event-level* tracking, which we don't need yet +but will once the monitor lands. + +### Operational signals you can build today + +Without waiting for the in-library monitor, these are queryable from the existing tables: + +- "Events stuck in `processing` for more than T" — the SQL above. +- "Listeners with permanent failures" — `SELECT * FROM event_outbox_redelivery WHERE status = 'failed'`. +- "Average time-between rows in `event_outbox_status` by status pair" — reveals dispatch latency. + +### What `RawEventStatus::failed` is reserved for + +A natural future use: a sweep over `event_outbox_redelivery` that, when **every** listener for +an event has permanently failed and there's nothing left to retry, marks the event row itself +`failed`. Computable from the existing tables; out of scope for this PR. The enum case is +documented as reserved so future readers don't think it's dead code. + +### 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. + +--- + ## Testing ```bash diff --git a/src/DueRedelivery.php b/src/DueRedelivery.php new file mode 100644 index 0000000..8c898c8 --- /dev/null +++ b/src/DueRedelivery.php @@ -0,0 +1,17 @@ +queue) ?? null; } + + #[Override] public function markProcessed(string $eventId): void + { + // No-op: the in-memory queue discards events on next(); there is no persisted status to flip. + } } diff --git a/src/Infrastructure/InMemoryRedeliveryTracker.php b/src/Infrastructure/InMemoryRedeliveryTracker.php new file mode 100644 index 0000000..448395c --- /dev/null +++ b/src/Infrastructure/InMemoryRedeliveryTracker.php @@ -0,0 +1,125 @@ + */ + private array $rows = []; + + #[Override] + public function schedule( + RawEvent $event, + string $listener, + int $attemptNumber, + CarbonImmutable $nextRetryAt, + Throwable $lastError, + ): void { + $key = self::key($event->id, $listener); + $now = CarbonImmutable::now(); + + $this->rows[$key] = [ + 'event' => $event, + 'listener' => $listener, + 'status' => self::STATUS_PENDING, + 'attempt_number' => $attemptNumber, + 'next_retry_at' => $nextRetryAt, + 'last_error' => self::formatError($lastError), + 'created_at' => $this->rows[$key]['created_at'] ?? $now, + 'updated_at' => $now, + ]; + } + + #[Override] + public function nextDue(): ?DueRedelivery + { + $now = CarbonImmutable::now(); + $candidate = null; + + foreach ($this->rows as $row) { + if ($row['status'] !== self::STATUS_PENDING) { + continue; + } + if ($row['next_retry_at'] === null || $row['next_retry_at']->greaterThan($now)) { + continue; + } + if ($candidate === null || $row['next_retry_at']->lessThan($candidate['next_retry_at'])) { + $candidate = $row; + } + } + + if ($candidate === null) { + return null; + } + + return new DueRedelivery( + event: $candidate['event'], + listener: $candidate['listener'], + attemptNumber: $candidate['attempt_number'], + ); + } + + #[Override] + public function markFailedPermanently(string $eventId, string $listener, Throwable $lastError): void + { + $key = self::key($eventId, $listener); + + if (!isset($this->rows[$key])) { + return; + } + + $this->rows[$key]['status'] = self::STATUS_FAILED; + $this->rows[$key]['next_retry_at'] = null; + $this->rows[$key]['last_error'] = self::formatError($lastError); + $this->rows[$key]['updated_at'] = CarbonImmutable::now(); + } + + #[Override] + public function markSucceeded(string $eventId, string $listener): void + { + $key = self::key($eventId, $listener); + + if (!isset($this->rows[$key])) { + return; + } + + $this->rows[$key]['status'] = self::STATUS_SUCCEEDED; + $this->rows[$key]['next_retry_at'] = null; + $this->rows[$key]['updated_at'] = CarbonImmutable::now(); + } + + #[Override] + public function retryNow(string $eventId, string $listener): void + { + $key = self::key($eventId, $listener); + + if (!isset($this->rows[$key])) { + return; + } + + $this->rows[$key]['status'] = self::STATUS_PENDING; + $this->rows[$key]['next_retry_at'] = CarbonImmutable::now(); + $this->rows[$key]['updated_at'] = CarbonImmutable::now(); + } + + private static function key(string $eventId, string $listener): string + { + return $eventId . '|' . $listener; + } + + private static function formatError(Throwable $error): string + { + return $error::class . ': ' . $error->getMessage(); + } +} 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]); + + if ($stmt->fetchColumn()) { + return; + } + + $connection->exec($creationQuery); + } +} diff --git a/src/Infrastructure/Schema/SqliteRedeliverySchema.php b/src/Infrastructure/Schema/SqliteRedeliverySchema.php new file mode 100644 index 0000000..d4e8725 --- /dev/null +++ b/src/Infrastructure/Schema/SqliteRedeliverySchema.php @@ -0,0 +1,38 @@ +exec( + <<exec( + 'CREATE INDEX IF NOT EXISTS idx_redelivery_due + ON event_outbox_redelivery (status, next_retry_at)', + ); + } +} diff --git a/src/Infrastructure/SequentialEventProcessor.php b/src/Infrastructure/SequentialEventProcessor.php index 3807919..80c19d7 100644 --- a/src/Infrastructure/SequentialEventProcessor.php +++ b/src/Infrastructure/SequentialEventProcessor.php @@ -2,23 +2,45 @@ namespace Vesper\Tool\Event\Infrastructure; +use Carbon\CarbonImmutable; +use Closure; use Override; +use RuntimeException; +use Throwable; use Vesper\Tool\Event\EventHydrator; use Vesper\Tool\Event\EventProcessor; use Vesper\Tool\Event\EventStore; use Vesper\Tool\Event\EventSubscriberMap; use Vesper\Tool\Event\HandlerResolver; +use Vesper\Tool\Event\Infrastructure\Retry\NoRetryPolicy; use Vesper\Tool\Event\RawEvent; +use Vesper\Tool\Event\RedeliveryTracker; +use Vesper\Tool\Event\Retry\RetryPolicy; -readonly class SequentialEventProcessor implements EventProcessor +class SequentialEventProcessor implements EventProcessor { /** - * @param EventSubscriberMap $subscribers + * @param EventSubscriberMap $subscribers + * @param list> $ignoredExceptions exceptions thrown by a + * listener that should be + * silently swallowed: no + * retry, no log, no redelivery + * row + * @param int $inProcessRetryThresholdMs the policy's next-retry + * delay is performed in-process + * (sleep + retry) when ≤ this + * value; otherwise the failure + * is persisted for later + * redelivery */ public function __construct( - private EventSubscriberMap $subscribers, - private HandlerResolver $resolver = new DefaultHandlerResolver(), - private EventHydrator $hydrator = new JacksonHydrator(), + private readonly EventSubscriberMap $subscribers, + private readonly HandlerResolver $resolver = new DefaultHandlerResolver(), + private readonly EventHydrator $hydrator = new JacksonHydrator(), + private readonly RetryPolicy $retryPolicy = new NoRetryPolicy(), + private readonly ?RedeliveryTracker $redeliveryTracker = null, + private readonly array $ignoredExceptions = [], + private readonly int $inProcessRetryThresholdMs = 1000, ) { } @@ -28,15 +50,127 @@ public function __construct( foreach ($this->subscribers->of($event->name) as $subscriber) { $this->dispatch($event, $subscriber); } + $store->markProcessed($event->id); + } + + if ($this->redeliveryTracker !== null) { + while ($due = $this->redeliveryTracker->nextDue()) { + $subscriber = $this->findRegisteredSubscriber($due->event->name, $due->listener); + + if ($subscriber === null) { + $this->redeliveryTracker->markFailedPermanently( + $due->event->id, + $due->listener, + new RuntimeException("Listener '{$due->listener}' is no longer registered for event '{$due->event->name}'."), + ); + continue; + } + + $this->dispatch($due->event, $subscriber, $due->attemptNumber); + } } } - protected function dispatch(RawEvent $event, callable|string $subscriber): void + private function findRegisteredSubscriber(string $eventName, string $listenerKey): callable|string|null { - $callable = $this->resolver->resolve($subscriber); + foreach ($this->subscribers->of($eventName) as $subscriber) { + if ($this->listenerKey($subscriber) === $listenerKey) { + return $subscriber; + } + } + return null; + } + /** + * @param int $attemptsMade attempts already made before this dispatch call (0 for a fresh + * event, ≥1 when called from the redelivery drain) + */ + protected function dispatch(RawEvent $event, callable|string $subscriber, int $attemptsMade = 0): void + { + $callable = $this->resolver->resolve($subscriber); $domainEvent = $this->hydrator->hydrate($event->name, $event->payload, $callable); + $listener = $this->listenerKey($subscriber); + + while (true) { + try { + $callable($domainEvent); + $this->redeliveryTracker?->markSucceeded($event->id, $listener); + return; + } catch (Throwable $e) { + if ($this->isIgnored($e)) { + return; + } + + $attemptsMade++; + $nextRetryAt = $this->retryPolicy->nextRetryAt($attemptsMade); + + if ($nextRetryAt === null) { + $this->onPermanentFailure($event, $subscriber, $e); + throw $e; + } + + $delayMs = $this->msUntil($nextRetryAt); + + if ($delayMs <= $this->inProcessRetryThresholdMs) { + $this->sleep($delayMs); + continue; + } + + if ($this->redeliveryTracker !== null) { + $this->redeliveryTracker->schedule($event, $listener, $attemptsMade, $nextRetryAt, $e); + return; + } + + $this->onPermanentFailure($event, $subscriber, $e); + throw $e; + } + } + } - $callable($domainEvent); + /** + * Hook called once a listener's failure can no longer be retried (policy exhausted, or no + * tracker is configured to persist a long-delay retry). Default: persist the permanent-failure + * marker on the redelivery tracker if one is configured. Subclasses can extend (e.g. log). + */ + protected function onPermanentFailure(RawEvent $event, callable|string $subscriber, Throwable $error): void + { + $this->redeliveryTracker?->markFailedPermanently($event->id, $this->listenerKey($subscriber), $error); + } + + protected function listenerKey(callable|string $subscriber): string + { + if (is_string($subscriber)) { + return $subscriber; + } + + if (is_object($subscriber) && !($subscriber instanceof Closure)) { + return $subscriber::class; + } + + return 'Closure'; + } + + protected function sleep(int $milliseconds): void + { + if ($milliseconds <= 0) { + return; + } + usleep($milliseconds * 1000); + } + + private function isIgnored(Throwable $error): bool + { + foreach ($this->ignoredExceptions as $class) { + if ($error instanceof $class) { + return true; + } + } + return false; + } + + private function msUntil(CarbonImmutable $when): int + { + $diffMs = (int) round(CarbonImmutable::now()->diffInMilliseconds($when, absolute: false)); + return max(0, $diffMs); } } diff --git a/src/Infrastructure/SilentSequentialEventProcessor.php b/src/Infrastructure/SilentSequentialEventProcessor.php index 8e878b5..b20ec77 100644 --- a/src/Infrastructure/SilentSequentialEventProcessor.php +++ b/src/Infrastructure/SilentSequentialEventProcessor.php @@ -4,36 +4,52 @@ use Override; use Psr\Log\LoggerInterface; +use Throwable; use Vesper\Tool\Event\EventHydrator; use Vesper\Tool\Event\EventSubscriberMap; use Vesper\Tool\Event\HandlerResolver; +use Vesper\Tool\Event\Infrastructure\Retry\NoRetryPolicy; use Vesper\Tool\Event\RawEvent; -use Throwable; +use Vesper\Tool\Event\RedeliveryTracker; +use Vesper\Tool\Event\Retry\RetryPolicy; -readonly class SilentSequentialEventProcessor extends SequentialEventProcessor +class SilentSequentialEventProcessor extends SequentialEventProcessor { /** - * @param EventSubscriberMap $subscribers + * @param EventSubscriberMap $subscribers + * @param list> $ignoredExceptions */ public function __construct( EventSubscriberMap $subscribers, - private LoggerInterface $logger, + private readonly LoggerInterface $logger, HandlerResolver $resolver = new DefaultHandlerResolver(), EventHydrator $hydrator = new JacksonHydrator(), + RetryPolicy $retryPolicy = new NoRetryPolicy(), + ?RedeliveryTracker $redeliveryTracker = null, + array $ignoredExceptions = [], + int $inProcessRetryThresholdMs = 1000, ) { - parent::__construct($subscribers, $resolver, $hydrator); + parent::__construct( + subscribers: $subscribers, + resolver: $resolver, + hydrator: $hydrator, + retryPolicy: $retryPolicy, + redeliveryTracker: $redeliveryTracker, + ignoredExceptions: $ignoredExceptions, + inProcessRetryThresholdMs: $inProcessRetryThresholdMs, + ); } #[Override] - protected function dispatch(RawEvent $event, callable|string $subscriber): void + protected function dispatch(RawEvent $event, callable|string $subscriber, int $attemptsMade = 0): void { try { - parent::dispatch($event, $subscriber); + parent::dispatch($event, $subscriber, $attemptsMade); } 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', + 'listener' => $this->listenerKey($subscriber), 'exception' => $exception, ]); } diff --git a/src/Infrastructure/SqlEventStore.php b/src/Infrastructure/SqlEventStore.php index 254513b..2e05a41 100644 --- a/src/Infrastructure/SqlEventStore.php +++ b/src/Infrastructure/SqlEventStore.php @@ -8,6 +8,7 @@ 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; @@ -22,7 +23,8 @@ public function __construct(private PDO $connection) } /** - * Add a new event to the outbox + * Add a new event to the outbox. Inserts both the event row and a + * 'pending' audit row in the caller's transaction. * * @throws JsonException * @throws PDOException @@ -45,16 +47,91 @@ 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) + * Claim the next pending event for this worker (worker-safe via FOR UPDATE + * SKIP LOCKED on MySQL). Transitions the row from `pending` to `processing` + * inside a single transaction together with the audit row insert. * * @throws JsonException * @throws PDOException */ #[Override] public function next(): ?RawEvent + { + $startedTransaction = $this->beginTransactionIfNeeded(); + + try { + $row = $this->fetchNextPendingRow(); + + if ($row === null) { + $this->commitIfStarted($startedTransaction); + return null; + } + + $this->connection->prepare( + "UPDATE event_outbox SET status = 'processing' WHERE id = :id", + )->execute(['id' => $row['id']]); + + $this->insertStatusAudit($row['id'], RawEventStatus::processing->value); + + $this->commitIfStarted($startedTransaction); + + /** @var array $payload */ + $payload = json_decode($row['payload'], true, flags: JSON_THROW_ON_ERROR); + + return RawEvent::retrieve( + id: $row['id'], + name: $row['name'], + status: RawEventStatus::processing, + payload: $payload, + createdAt: new CarbonImmutable($row['created_at']), + publishAt: new CarbonImmutable($row['publish_at']), + ); + } catch (Throwable $e) { + $this->rollBackIfStarted($startedTransaction); + throw $e; + } + } + + /** + * Advance an event from `processing` to `processed`. Inserts the matching + * audit row in the same transaction. The UPDATE is guarded with + * `status = 'processing'` so a stuck row that's already been recovered by + * a sweep won't be silently overwritten. + * + * @throws PDOException + */ + #[Override] + public function markProcessed(string $eventId): void + { + $startedTransaction = $this->beginTransactionIfNeeded(); + + try { + $this->connection->prepare( + <<execute(['id' => $eventId]); + + $this->insertStatusAudit($eventId, RawEventStatus::processed->value); + + $this->commitIfStarted($startedTransaction); + } catch (Throwable $e) { + $this->rollBackIfStarted($startedTransaction); + 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(); @@ -76,25 +153,48 @@ 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; + } + + private function insertStatusAudit(string $eventId, string $status, ?string $errorMessage = null): void + { + $stmt = $this->connection->prepare( + <<execute([ + 'event_id' => $eventId, + 'status' => $status, + 'error_message' => $errorMessage, + 'created_at' => CarbonImmutable::now()->format('Y-m-d H:i:s.u'), + ]); + } + + private function beginTransactionIfNeeded(): bool + { + if ($this->connection->inTransaction()) { + return false; } - $this->connection->prepare( - "UPDATE event_outbox SET status = 'processed' WHERE id = :id", - )->execute(['id' => $row['id']]); + $this->connection->beginTransaction(); + return true; + } - /** @var array $payload */ - $payload = json_decode($row['payload'], true, flags: JSON_THROW_ON_ERROR); + private function commitIfStarted(bool $started): void + { + if ($started) { + $this->connection->commit(); + } + } - 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']), - ); + private function rollBackIfStarted(bool $started): void + { + if ($started && $this->connection->inTransaction()) { + $this->connection->rollBack(); + } } private function ensureOutboxSchema(): void diff --git a/src/Infrastructure/SqlRedeliveryTracker.php b/src/Infrastructure/SqlRedeliveryTracker.php new file mode 100644 index 0000000..143f599 --- /dev/null +++ b/src/Infrastructure/SqlRedeliveryTracker.php @@ -0,0 +1,246 @@ +ensureRedeliverySchema(); + } + + /** + * @throws PDOException + */ + #[Override] + public function schedule( + RawEvent $event, + string $listener, + int $attemptNumber, + CarbonImmutable $nextRetryAt, + Throwable $lastError, + ): void { + $now = CarbonImmutable::now()->format('Y-m-d H:i:s.u'); + $errorMessage = self::formatError($lastError); + $nextRetryAtSql = $nextRetryAt->format('Y-m-d H:i:s.u'); + + $sql = match ($this->driverName()) { + 'mysql' => << << throw new RuntimeException('Unsupported database driver: ' . $this->driverName()), + }; + + $this->connection->prepare($sql)->execute([ + 'event_id' => $event->id, + 'listener' => $listener, + 'status' => self::STATUS_PENDING, + 'attempt_number' => $attemptNumber, + 'next_retry_at' => $nextRetryAtSql, + 'last_error' => $errorMessage, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + /** + * @throws JsonException + * @throws PDOException + */ + #[Override] + public function nextDue(): ?DueRedelivery + { + $lockClause = $this->lockingClause(); + + $stmt = $this->connection->prepare( + <<execute([ + 'status' => self::STATUS_PENDING, + 'now' => CarbonImmutable::now()->format('Y-m-d H:i:s.u'), + ]); + + /** @var array{event_id: string, listener: string, attempt_number: int, 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 ($row === false) { + 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 new DueRedelivery( + event: $event, + listener: $row['listener'], + attemptNumber: (int) $row['attempt_number'], + ); + } + + /** + * @throws PDOException + */ + #[Override] + public function markFailedPermanently(string $eventId, string $listener, Throwable $lastError): void + { + $this->connection->prepare( + <<execute([ + 'status' => self::STATUS_FAILED, + 'last_error' => self::formatError($lastError), + 'updated_at' => CarbonImmutable::now()->format('Y-m-d H:i:s.u'), + 'event_id' => $eventId, + 'listener' => $listener, + ]); + } + + /** + * @throws PDOException + */ + #[Override] + public function markSucceeded(string $eventId, string $listener): void + { + $this->connection->prepare( + <<execute([ + 'status' => self::STATUS_SUCCEEDED, + 'updated_at' => CarbonImmutable::now()->format('Y-m-d H:i:s.u'), + 'event_id' => $eventId, + 'listener' => $listener, + ]); + } + + /** + * @throws PDOException + */ + #[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' => self::STATUS_PENDING, + 'next_retry_at' => $now, + 'updated_at' => $now, + 'event_id' => $eventId, + 'listener' => $listener, + ]); + } + + private static function formatError(Throwable $error): string + { + return $error::class . ': ' . $error->getMessage(); + } + + 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 + { + $driver = $this->connection->getAttribute(PDO::ATTR_DRIVER_NAME); + assert(is_string($driver)); + + return $driver; + } +} diff --git a/src/RawEventStatus.php b/src/RawEventStatus.php index 0ab40a8..d5eed18 100644 --- a/src/RawEventStatus.php +++ b/src/RawEventStatus.php @@ -5,6 +5,7 @@ enum RawEventStatus: string { case pending = 'pending'; + case processing = 'processing'; case processed = 'processed'; case failed = 'failed'; } diff --git a/src/RedeliveryTracker.php b/src/RedeliveryTracker.php new file mode 100644 index 0000000..325d21e --- /dev/null +++ b/src/RedeliveryTracker.php @@ -0,0 +1,54 @@ +id and + * join event_outbox to look the rest up. + */ + public function schedule( + RawEvent $event, + string $listener, + int $attemptNumber, + CarbonImmutable $nextRetryAt, + Throwable $lastError, + ): void; + + /** + * Pick up the next due redelivery (worker-safe; locks the row on MySQL). + * Returns null when none are due. + */ + public function nextDue(): ?DueRedelivery; + + /** + * Mark a redelivery as permanently failed — no further automatic attempts. + * The row stays in the table so operators can inspect it and call retryNow() + * if appropriate. + */ + public function markFailedPermanently(string $eventId, string $listener, Throwable $lastError): void; + + /** + * Mark a redelivery as succeeded. The row stays in the table for audit; + * future nextDue() calls will not return it. + */ + public function markSucceeded(string $eventId, string $listener): void; + + /** + * Application-side admin API: re-queue a redelivery for immediate retry. + * Sets status to pending_retry and next_retry_at to now(), regardless of the + * row's current status (including 'failed'). Attempt count is preserved so + * the retry policy's max-attempts ceiling still applies on subsequent + * automatic failures. + */ + public function retryNow(string $eventId, string $listener): void; +} diff --git a/src/Retry/RetryPolicy.php b/src/Retry/RetryPolicy.php new file mode 100644 index 0000000..983cca7 --- /dev/null +++ b/src/Retry/RetryPolicy.php @@ -0,0 +1,19 @@ +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, 'returned RawEvent reflects new status'); + self::assertSame('processing', $this->fetchEventStatus($event->id), 'event_outbox row was flipped to processing'); + + $rows = $this->fetchAuditRows($event->id); + self::assertCount(2, $rows); + self::assertSame('pending', $rows[0]['status']); + self::assertSame('processing', $rows[1]['status']); + } + + public function test_mark_processed_advances_event_and_inserts_audit_row(): void + { + $event = self::createEvent('order.placed'); + $this->store->add($event); + $this->store->next(); + + $this->store->markProcessed($event->id); + + self::assertSame('processed', $this->fetchEventStatus($event->id)); + + $rows = $this->fetchAuditRows($event->id); + self::assertCount(3, $rows); + self::assertSame('pending', $rows[0]['status']); + self::assertSame('processing', $rows[1]['status']); + self::assertSame('processed', $rows[2]['status']); + } + + 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(); // pending → processing + + // A second call must not pick up the processing row (it's not pending anymore). + self::assertNull($this->store->next()); + } + + public function test_mark_processed_is_a_noop_when_row_is_not_in_processing(): void + { + $event = self::createEvent('order.placed'); + $this->store->add($event); + + // Skip next(); call markProcessed directly. The guard `WHERE status='processing'` should + // make the UPDATE a no-op for a row still in 'pending'. + $this->store->markProcessed($event->id); + + self::assertSame('pending', $this->fetchEventStatus($event->id), 'pending row is left untouched'); + } + + /** + * @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/SqlRedeliveryTrackerTest.php b/tests/Feature/SqlRedeliveryTrackerTest.php new file mode 100644 index 0000000..1058659 --- /dev/null +++ b/tests/Feature/SqlRedeliveryTrackerTest.php @@ -0,0 +1,193 @@ +pdo = new PDO('sqlite::memory:'); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $this->store = new SqlEventStore($this->pdo); + $this->tracker = new SqlRedeliveryTracker($this->pdo); + } + + public function test_schema_is_idempotent_across_multiple_instantiations(): void + { + new SqlRedeliveryTracker($this->pdo); + new SqlRedeliveryTracker($this->pdo); + + self::assertTrue(true); + } + + public function test_next_due_returns_null_when_table_is_empty(): void + { + self::assertNull($this->tracker->nextDue()); + } + + public function test_schedule_then_next_due_round_trip(): void + { + $event = $this->insertEvent(); + + $this->tracker->schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + ); + + $due = $this->tracker->nextDue(); + + self::assertNotNull($due); + self::assertSame($event->id, $due->event->id); + self::assertSame('App\\Listener', $due->listener); + self::assertSame(1, $due->attemptNumber); + self::assertSame($event->name, $due->event->name); + } + + public function test_next_due_excludes_rows_whose_retry_time_is_in_the_future(): void + { + $event = $this->insertEvent(); + + $this->tracker->schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->addMinute(), + lastError: new RuntimeException('boom'), + ); + + self::assertNull($this->tracker->nextDue()); + } + + public function test_schedule_is_idempotent_and_updates_on_repeat(): void + { + $event = $this->insertEvent(); + + $this->tracker->schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->addMinute(), + lastError: new RuntimeException('first'), + ); + $this->tracker->schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 2, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('second'), + ); + + $due = $this->tracker->nextDue(); + + self::assertNotNull($due); + self::assertSame(2, $due->attemptNumber, 'second schedule overwrote the first row'); + + $rows = $this->fetchAllRedeliveryRows($event->id); + self::assertCount(1, $rows, 'no duplicate rows for (event_id, listener)'); + } + + public function test_mark_succeeded_removes_row_from_due_queue(): void + { + $event = $this->insertEvent(); + + $this->tracker->schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + ); + + $this->tracker->markSucceeded($event->id, 'App\\Listener'); + + self::assertNull($this->tracker->nextDue()); + self::assertSame('succeeded', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); + } + + public function test_mark_failed_permanently_removes_row_from_due_queue(): void + { + $event = $this->insertEvent(); + + $this->tracker->schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 5, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + ); + + $this->tracker->markFailedPermanently($event->id, 'App\\Listener', new RuntimeException('final')); + + self::assertNull($this->tracker->nextDue()); + self::assertSame('failed', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); + } + + public function test_retry_now_re_queues_a_permanently_failed_row_preserving_attempt_count(): void + { + $event = $this->insertEvent(); + + $this->tracker->schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 5, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + ); + $this->tracker->markFailedPermanently($event->id, 'App\\Listener', new RuntimeException('final')); + + $this->tracker->retryNow($event->id, 'App\\Listener'); + + $due = $this->tracker->nextDue(); + self::assertNotNull($due); + self::assertSame(5, $due->attemptNumber, 'attempt count is preserved across retryNow()'); + } + + private function insertEvent(): RawEvent + { + $event = TestEventFactory::retrieveOrderPlaced(['order_id' => 1]); + $this->store->add($event); + return $event; + } + + /** + * @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/Unit/InMemoryRedeliveryTrackerTest.php b/tests/Unit/InMemoryRedeliveryTrackerTest.php new file mode 100644 index 0000000..39061ba --- /dev/null +++ b/tests/Unit/InMemoryRedeliveryTrackerTest.php @@ -0,0 +1,171 @@ +tracker = new InMemoryRedeliveryTracker(); + $this->event = TestEventFactory::retrieveOrderPlaced(); + } + + public function test_next_due_returns_null_when_empty(): void + { + self::assertNull($this->tracker->nextDue()); + } + + public function test_schedule_makes_redelivery_pickable_when_time_passes(): void + { + $this->tracker->schedule( + event: $this->event, + listener: 'App\\SomeListener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + ); + + $due = $this->tracker->nextDue(); + + self::assertNotNull($due); + self::assertSame($this->event->id, $due->event->id); + self::assertSame('App\\SomeListener', $due->listener); + self::assertSame(1, $due->attemptNumber); + } + + public function test_next_due_excludes_rows_whose_retry_time_is_in_the_future(): void + { + $this->tracker->schedule( + event: $this->event, + listener: 'App\\SomeListener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->addMinute(), + lastError: new RuntimeException('boom'), + ); + + self::assertNull($this->tracker->nextDue()); + } + + public function test_next_due_returns_earliest_scheduled_first(): void + { + $eventEarly = TestEventFactory::retrieveOrderPlaced(['n' => 1]); + $eventLate = TestEventFactory::retrieveOrderPlaced(['n' => 2]); + + $this->tracker->schedule( + event: $eventLate, + listener: 'App\\Late', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + ); + $this->tracker->schedule( + event: $eventEarly, + listener: 'App\\Early', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subMinute(), + lastError: new RuntimeException('boom'), + ); + + $due = $this->tracker->nextDue(); + + self::assertNotNull($due); + self::assertSame('App\\Early', $due->listener); + } + + public function test_mark_succeeded_removes_row_from_due_queue(): void + { + $this->tracker->schedule( + event: $this->event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + ); + + $this->tracker->markSucceeded($this->event->id, 'App\\Listener'); + + self::assertNull($this->tracker->nextDue()); + } + + public function test_mark_failed_permanently_removes_row_from_due_queue(): void + { + $this->tracker->schedule( + event: $this->event, + listener: 'App\\Listener', + attemptNumber: 5, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + ); + + $this->tracker->markFailedPermanently($this->event->id, 'App\\Listener', new RuntimeException('final')); + + self::assertNull($this->tracker->nextDue()); + } + + public function test_retry_now_re_queues_a_permanently_failed_row(): void + { + $this->tracker->schedule( + event: $this->event, + listener: 'App\\Listener', + attemptNumber: 5, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + ); + $this->tracker->markFailedPermanently($this->event->id, 'App\\Listener', new RuntimeException('final')); + + $this->tracker->retryNow($this->event->id, 'App\\Listener'); + + $due = $this->tracker->nextDue(); + + self::assertNotNull($due); + self::assertSame('App\\Listener', $due->listener); + self::assertSame(5, $due->attemptNumber, 'attempt count is preserved across retryNow()'); + } + + public function test_schedule_is_idempotent_on_event_id_listener(): void + { + $this->tracker->schedule( + event: $this->event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->addMinute(), + lastError: new RuntimeException('boom'), + ); + $this->tracker->schedule( + event: $this->event, + listener: 'App\\Listener', + attemptNumber: 2, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('again'), + ); + + $due = $this->tracker->nextDue(); + + self::assertNotNull($due); + self::assertSame(2, $due->attemptNumber, 'rescheduling updates attempt count, does not insert a duplicate'); + } + + public function test_mark_succeeded_is_a_noop_for_unknown_row(): void + { + $this->tracker->markSucceeded('unknown-id', 'App\\Listener'); + + self::assertNull($this->tracker->nextDue()); + } + + public function test_retry_now_is_a_noop_for_unknown_row(): void + { + $this->tracker->retryNow('unknown-id', 'App\\Listener'); + + self::assertNull($this->tracker->nextDue()); + } +} 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..01bd90a 100644 --- a/tests/Unit/SequentialEventProcessorTest.php +++ b/tests/Unit/SequentialEventProcessorTest.php @@ -2,14 +2,22 @@ namespace Test\Vesper\Tool\Event\Unit; +use Carbon\CarbonImmutable; use PHPUnit\Framework\TestCase; +use RuntimeException; +use Test\Vesper\Tool\Event\_Fixtures\IgnorableExceptionStub; +use Test\Vesper\Tool\Event\_Fixtures\RecordingSequentialEventProcessor; +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\InMemoryEventStore; +use Vesper\Tool\Event\Infrastructure\InMemoryRedeliveryTracker; use Vesper\Tool\Event\Infrastructure\SequentialEventProcessor; -use Test\Vesper\Tool\Event\_Fixtures\TestEventFactory; -use Test\Vesper\Tool\Event\_Fixtures\TrackingListener; +use Vesper\Tool\Event\RedeliveryTracker; +use Vesper\Tool\Event\Retry\RetryPolicy; class SequentialEventProcessorTest extends TestCase { @@ -199,4 +207,243 @@ public function test_hydrates_once_per_subscriber_passing_subscriber_as_context( self::assertSame($subscriberB, $hydrateCalls[1]); } + // ── retry policy / redelivery ────────────────────────────────────────────── + + public function test_calls_mark_processed_after_each_event_succeeds(): void + { + $store = new TrackingEventStore(); + $event = TestEventFactory::retrieveOrderPlaced(); + $store->add($event); + + $this->subscribers->subscribe('order.placed', function () {}); + + $this->processor->process($store); + + self::assertSame([$event->id], $store->markProcessedCalls); + } + + public function test_does_not_call_mark_processed_when_a_listener_throws_in_fail_fast_mode(): void + { + $store = new TrackingEventStore(); + $event = TestEventFactory::retrieveOrderPlaced(); + $store->add($event); + + $this->subscribers->subscribe('order.placed', function () { + throw new RuntimeException('boom'); + }); + + try { + $this->processor->process($store); + self::fail('Expected exception was not thrown'); + } catch (RuntimeException) { + // expected + } + + self::assertSame([], $store->markProcessedCalls, 'event must remain in processing when dispatch propagates'); + } + + public function test_in_process_retry_succeeds_on_second_attempt(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromFormat('Y-m-d H:i:s.u', '2026-04-27 12:00:00.000000')); + try { + $event = TestEventFactory::retrieveOrderPlaced(); + $this->store->add($event); + + $calls = 0; + $this->subscribers->subscribe('order.placed', function () use (&$calls) { + $calls++; + if ($calls === 1) { + throw new RuntimeException('transient'); + } + }); + + $policy = self::policyReturning(retryDelayMs: 50); + $processor = new RecordingSequentialEventProcessor( + $this->subscribers, + retryPolicy: $policy, + inProcessRetryThresholdMs: 100, + ); + + $processor->process($this->store); + + self::assertSame(2, $calls); + self::assertSame([50], $processor->sleeps, 'one in-process sleep happened between the two attempts'); + } finally { + CarbonImmutable::setTestNow(); + } + } + + public function test_in_process_retry_exhausted_propagates_in_fail_fast(): void + { + $this->store->add(TestEventFactory::retrieveOrderPlaced()); + + $exception = new RuntimeException('always fails'); + $this->subscribers->subscribe('order.placed', function () use ($exception) { + throw $exception; + }); + + $policy = self::policyReturning(retryDelayMs: 10, exhaustAfter: 1); + $processor = new RecordingSequentialEventProcessor( + $this->subscribers, + retryPolicy: $policy, + inProcessRetryThresholdMs: 100, + ); + + $this->expectExceptionObject($exception); + $processor->process($this->store); + } + + public function test_persists_to_tracker_when_next_retry_delay_exceeds_threshold(): void + { + $event = TestEventFactory::retrieveOrderPlaced(); + $this->store->add($event); + + $exception = new RuntimeException('boom'); + $this->subscribers->subscribe('order.placed', function () use ($exception) { + throw $exception; + }); + + $tracker = $this->createMock(RedeliveryTracker::class); + $tracker->expects($this->once()) + ->method('schedule') + ->with( + $this->callback(fn($e) => $e->id === $event->id), + 'Closure', + 1, + $this->isInstanceOf(CarbonImmutable::class), + $exception, + ); + $tracker->method('nextDue')->willReturn(null); + + $policy = self::policyReturning(retryDelayMs: 5_000); // way above threshold + $processor = new RecordingSequentialEventProcessor( + $this->subscribers, + retryPolicy: $policy, + redeliveryTracker: $tracker, + inProcessRetryThresholdMs: 100, + ); + + $processor->process($this->store); + + self::assertSame([], $processor->sleeps, 'no in-process sleep when delay exceeds threshold'); + } + + public function test_calls_mark_succeeded_when_listener_succeeds_with_tracker_configured(): void + { + $event = TestEventFactory::retrieveOrderPlaced(); + $this->store->add($event); + + $this->subscribers->subscribe('order.placed', function () {}); + + $tracker = $this->createMock(RedeliveryTracker::class); + $tracker->expects($this->once()) + ->method('markSucceeded') + ->with($event->id, 'Closure'); + $tracker->method('nextDue')->willReturn(null); + + $processor = new SequentialEventProcessor($this->subscribers, redeliveryTracker: $tracker); + $processor->process($this->store); + } + + public function test_ignored_exception_short_circuits_with_no_retry_no_propagation_no_tracker_calls(): void + { + $this->store->add(TestEventFactory::retrieveOrderPlaced()); + + $calls = 0; + $this->subscribers->subscribe('order.placed', function () use (&$calls) { + $calls++; + throw new IgnorableExceptionStub('expected domain failure'); + }); + + $tracker = $this->createMock(RedeliveryTracker::class); + $tracker->expects($this->never())->method('schedule'); + $tracker->expects($this->never())->method('markFailedPermanently'); + $tracker->expects($this->never())->method('markSucceeded'); + $tracker->method('nextDue')->willReturn(null); + + $processor = new SequentialEventProcessor( + $this->subscribers, + retryPolicy: self::policyReturning(retryDelayMs: 10), + redeliveryTracker: $tracker, + ignoredExceptions: [IgnorableExceptionStub::class], + ); + + $processor->process($this->store); + + self::assertSame(1, $calls, 'listener ran exactly once and was not retried'); + } + + public function test_drains_due_redeliveries_after_the_main_queue(): void + { + $event = TestEventFactory::retrieveOrderPlaced(); + + $tracker = new InMemoryRedeliveryTracker(); + $tracker->schedule( + event: $event, + listener: 'Closure', + attemptNumber: 2, + 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 SequentialEventProcessor($this->subscribers, redeliveryTracker: $tracker); + $processor->process($this->store); + + self::assertNotNull($received, 'redelivery dispatch invoked the listener'); + self::assertNull($tracker->nextDue(), 'redelivery row marked succeeded after dispatch'); + } + + public function test_calls_mark_failed_permanently_when_no_more_retries_with_tracker(): void + { + $this->store->add(TestEventFactory::retrieveOrderPlaced()); + + $exception = new RuntimeException('boom'); + $this->subscribers->subscribe('order.placed', function () use ($exception) { + throw $exception; + }); + + $tracker = $this->createMock(RedeliveryTracker::class); + $tracker->expects($this->once())->method('markFailedPermanently'); + $tracker->expects($this->never())->method('schedule'); + $tracker->method('nextDue')->willReturn(null); + + $processor = new SequentialEventProcessor( + $this->subscribers, + redeliveryTracker: $tracker, + ); + + try { + $processor->process($this->store); + self::fail('expected exception'); + } catch (RuntimeException) { + // expected — base class is fail-fast + } + } + + /** + * Build a RetryPolicy stub that returns now()+$retryDelayMs for the first $exhaustAfter + * attempts, then null. + */ + private static function policyReturning(int $retryDelayMs, int $exhaustAfter = 5): RetryPolicy + { + return new readonly class ($retryDelayMs, $exhaustAfter) implements RetryPolicy { + public function __construct( + private int $retryDelayMs, + private int $exhaustAfter, + ) {} + + public function nextRetryAt(int $previousAttempt): ?CarbonImmutable + { + if ($previousAttempt > $this->exhaustAfter) { + return null; + } + return CarbonImmutable::now()->addMilliseconds($this->retryDelayMs); + } + }; + } } diff --git a/tests/Unit/SilentSequentialEventProcessorTest.php b/tests/Unit/SilentSequentialEventProcessorTest.php index 67356f2..1e4f1a1 100644 --- a/tests/Unit/SilentSequentialEventProcessorTest.php +++ b/tests/Unit/SilentSequentialEventProcessorTest.php @@ -5,12 +5,14 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use RuntimeException; +use Test\Vesper\Tool\Event\_Fixtures\IgnorableExceptionStub; +use Test\Vesper\Tool\Event\_Fixtures\TestEventFactory; +use Test\Vesper\Tool\Event\_Fixtures\ThrowingListener; use Vesper\Tool\Event\EventHydrator; use Vesper\Tool\Event\EventSubscriberMap; use Vesper\Tool\Event\Infrastructure\InMemoryEventStore; use Vesper\Tool\Event\Infrastructure\SilentSequentialEventProcessor; -use Test\Vesper\Tool\Event\_Fixtures\TestEventFactory; -use Test\Vesper\Tool\Event\_Fixtures\ThrowingListener; +use Vesper\Tool\Event\RedeliveryTracker; class SilentSequentialEventProcessorTest extends TestCase { @@ -171,4 +173,77 @@ public function test_continues_processing_subsequent_events_after_failure(): voi self::assertSame([2], $received); } + + // ── retry / redelivery integration ───────────────────────────────────────── + + public function test_logs_and_marks_failed_permanently_when_retries_are_exhausted(): void + { + $event = TestEventFactory::retrieveOrderPlaced(); + $this->store->add($event); + + $exception = new RuntimeException('always'); + $this->subscribers->subscribe('order.placed', function () use ($exception) { + throw $exception; + }); + + $tracker = $this->createMock(RedeliveryTracker::class); + $tracker->expects($this->once()) + ->method('markFailedPermanently') + ->with($event->id, 'Closure', $exception); + $tracker->method('nextDue')->willReturn(null); + + $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, + ], + ); + + $hydrator = $this->createStub(EventHydrator::class); + $hydrator->method('hydrate')->willReturnCallback(fn(string $name, array $payload) => (object) $payload); + + $processor = new SilentSequentialEventProcessor( + subscribers: $this->subscribers, + logger: $this->logger, + hydrator: $hydrator, + redeliveryTracker: $tracker, + ); + + $processor->process($this->store); + } + + public function test_ignored_exception_is_suppressed_with_no_log_or_tracker_call(): void + { + $this->store->add(TestEventFactory::retrieveOrderPlaced()); + + $this->subscribers->subscribe('order.placed', function () { + throw new IgnorableExceptionStub('expected domain failure'); + }); + + $tracker = $this->createMock(RedeliveryTracker::class); + $tracker->expects($this->never())->method('schedule'); + $tracker->expects($this->never())->method('markFailedPermanently'); + $tracker->expects($this->never())->method('markSucceeded'); + $tracker->method('nextDue')->willReturn(null); + + $this->logger->expects($this->never())->method('error'); + + $hydrator = $this->createStub(EventHydrator::class); + $hydrator->method('hydrate')->willReturnCallback(fn(string $name, array $payload) => (object) $payload); + + $processor = new SilentSequentialEventProcessor( + subscribers: $this->subscribers, + logger: $this->logger, + hydrator: $hydrator, + redeliveryTracker: $tracker, + ignoredExceptions: [IgnorableExceptionStub::class], + ); + + $processor->process($this->store); + } } diff --git a/tests/_Fixtures/IgnorableExceptionStub.php b/tests/_Fixtures/IgnorableExceptionStub.php new file mode 100644 index 0000000..b626b52 --- /dev/null +++ b/tests/_Fixtures/IgnorableExceptionStub.php @@ -0,0 +1,9 @@ + */ + public array $sleeps = []; + + protected function sleep(int $milliseconds): void + { + $this->sleeps[] = $milliseconds; + } +} diff --git a/tests/_Fixtures/TrackingEventStore.php b/tests/_Fixtures/TrackingEventStore.php new file mode 100644 index 0000000..422d881 --- /dev/null +++ b/tests/_Fixtures/TrackingEventStore.php @@ -0,0 +1,21 @@ + */ + public array $markProcessedCalls = []; + + public function markProcessed(string $eventId): void + { + $this->markProcessedCalls[] = $eventId; + } +} 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 @@ Date: Tue, 28 Apr 2026 22:54:46 +0200 Subject: [PATCH 02/16] Upgrade composer dependencies --- composer.lock | 157 ++++++++++++++++++++++---------------------------- 1 file changed, 70 insertions(+), 87 deletions(-) diff --git a/composer.lock b/composer.lock index 6c38dfb..114551c 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,7 @@ "type": "tidelift" } ], - "time": "2026-03-11T17:23:39+00:00" + "time": "2026-04-07T09:57:54+00:00" }, { "name": "psr/clock", @@ -494,16 +494,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 +547,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 +567,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 +632,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 +652,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.8", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b" + "reference": "27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", - "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", + "url": "https://api.github.com/repos/symfony/translation/zipball/27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f", + "reference": "27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f", "shasum": "" }, "require": { @@ -725,7 +725,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.8" }, "funding": [ { @@ -745,7 +745,7 @@ "type": "tidelift" } ], - "time": "2026-02-17T13:07:04+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/translation-contracts", @@ -1164,16 +1164,16 @@ }, { "name": "php-cs-fixer/shim", - "version": "v3.94.2", + "version": "v3.95.1", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/shim.git", - "reference": "80fd29f44a736136a2f05bae5464816a444b91d1" + "reference": "f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a" }, "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/f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a", + "reference": "f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a", "shasum": "" }, "require": { @@ -1210,17 +1210,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.1" }, - "time": "2026-02-20T16:14:17+00:00" + "time": "2026-04-12T17:00:34+00:00" }, { "name": "phpstan/phpstan", - "version": "2.1.40", + "version": "2.1.53", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", - "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/ef67586798c003274797b288a68b221e4270dca7", + "reference": "ef67586798c003274797b288a68b221e4270dca7", "shasum": "" }, "require": { @@ -1265,20 +1265,20 @@ "type": "github" } ], - "time": "2026-02-23T15:04:35+00:00" + "time": "2026-04-28T16:09:00+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 +1287,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 +1333,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 +1353,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 +1614,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.14", + "version": "12.5.23", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0" + "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969", + "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969", "shasum": "" }, "require": { @@ -1638,15 +1637,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 +1692,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.23" }, "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-04-18T06:12:49+00:00" }, { "name": "sebastian/cli-parser", @@ -1790,16 +1773,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 +1841,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 +1861,7 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:28:48+00:00" + "time": "2026-04-14T08:23:15+00:00" }, { "name": "sebastian/complexity", @@ -2007,16 +1990,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 +2014,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -2059,7 +2042,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 +2062,7 @@ "type": "tidelift" } ], - "time": "2025-08-12T14:11:56+00:00" + "time": "2026-04-15T12:13:01+00:00" }, { "name": "sebastian/exporter", @@ -2737,16 +2720,16 @@ }, { "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 +2783,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 +2803,7 @@ "type": "tidelift" } ], - "time": "2026-02-15T10:53:20+00:00" + "time": "2026-03-30T13:44:50+00:00" }, { "name": "theseer/tokenizer", From aa33e9acceab95471daf68821c70850cbc3db210 Mon Sep 17 00:00:00 2001 From: Jason Viljoen Date: Fri, 15 May 2026 21:37:45 +0200 Subject: [PATCH 03/16] refactor: async-only retry, simplify outbox lifecycle Pull all retry logic out of the main dispatch loop. Listener failures are persisted to event_outbox_redelivery immediately; a separate processNextRedelivery() entrypoint, invoked by its own scheduled job, applies the retry policy on each attempt. - SequentialEventProcessor.dispatch(): one attempt; on failure either schedule for later or rethrow (no more in-process sleep+retry, no inProcessRetryThresholdMs param) - Add SequentialEventProcessor.processNextRedelivery() for the cron flow - New Infrastructure\RedeliveryStatus enum (replaces string constants in both InMemory and SQL trackers) - Schema cleanup: next_retry_at NOT NULL, listener VARCHAR(500), FK to event_outbox dropped (project convention), plain idx_redelivery_event_id added for the JOIN - Drop unused RawEventStatus::failed (events don't fail, listeners do) - Remove obsolete RecordingSequentialEventProcessor test fixture - README updated to reflect the new flow Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 61 ++++----- .../InMemoryRedeliveryTracker.php | 20 +-- src/Infrastructure/RedeliveryStatus.php | 10 ++ .../Schema/MysqlRedeliverySchema.php | 12 +- .../Schema/SqliteRedeliverySchema.php | 13 +- .../SequentialEventProcessor.php | 121 +++++++----------- .../SilentSequentialEventProcessor.php | 4 +- src/Infrastructure/SqlEventStore.php | 3 + src/Infrastructure/SqlRedeliveryTracker.php | 35 +---- src/RawEventStatus.php | 1 - src/Retry/RetryPolicy.php | 6 +- tests/Unit/RayEventStatusTest.php | 16 +-- tests/Unit/SequentialEventProcessorTest.php | 93 ++++++-------- .../RecordingSequentialEventProcessor.php | 21 --- 14 files changed, 158 insertions(+), 258 deletions(-) create mode 100644 src/Infrastructure/RedeliveryStatus.php delete mode 100644 tests/_Fixtures/RecordingSequentialEventProcessor.php diff --git a/README.md b/README.md index 33c8ba4..88bfe8a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Events are first written to a durable store (in-memory or SQL), then dispatched - **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 transition `pending → processing → processed`, with the intermediate state surviving worker crashes -- **Per-listener retries** — configurable `RetryPolicy` plus a hybrid in-process + persisted `RedeliveryTracker`; a single failing listener of an event is retried independently while the others continue +- **Per-listener retries** — configurable `RetryPolicy` plus a persisted `RedeliveryTracker`; a single failing listener of an event is retried independently while the others continue - **Ignored exceptions** — pass a list of `Throwable` classes 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 @@ -58,7 +58,7 @@ composer require hivesper/php-events | `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 with optional retry policy; exceptions propagate after retries are exhausted (fail-fast) | +| `SequentialEventProcessor` | Built-in processor — dispatches events one by one with optional retry policy; exceptions propagate after retries are exhausted (fail-fast). Call `processNextRedelivery()` in a separate scheduled job to pick up persisted retries. | | `SilentSequentialEventProcessor` | Same as above but catches per-listener failures after retries are exhausted, logs them via PSR-3, and continues | | `EventSubscriberBuilder` | Fluent builder that produces a ready-to-use `EventSubscriberMap` | | `RetryPolicy` | Interface — decides whether and when to retry a failed listener | @@ -311,7 +311,7 @@ $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' | 'processing' | 'processed' | 'failed' + status VARCHAR(255) NOT NULL, -- 'pending' | 'processing' | 'processed' payload JSON NOT NULL, created_at DATETIME(6) NOT NULL, publish_at DATETIME(6) NOT NULL, @@ -518,14 +518,14 @@ durable failure tracking on a **per-(event, listener)** basis: if a single liste fails, only that listener is retried — the others continue to run and successful deliveries are not re-fired. -The model is **hybrid**: - -1. A small in-process retry burst (sleep + retry) for fast-recovery transient failures. -2. A persisted, scheduled redelivery for slower-recovery failures and for surviving worker crashes. +The model is **fully async**: when a listener fails, the failure is persisted to the redelivery +table immediately and the main outbox worker moves on to the next event. A separate scheduled +job calls `processNextRedelivery()` to pick up due retries — the retry policy is consulted at +that point to decide whether to re-schedule or mark permanently failed. Once configured, the same retry behaviour applies to both `SequentialEventProcessor` (which -fails fast after retries are exhausted) and `SilentSequentialEventProcessor` (which logs and -continues). +fails fast on the first attempt when no tracker is configured, or after `nextRetryAt` returns +null on a retry) and `SilentSequentialEventProcessor` (which logs and continues). ### Retry policy @@ -546,8 +546,8 @@ Two implementations ship out of the box: - **`NoRetryPolicy`** *(default)* — never retries. Same behaviour as before this feature existed, so wiring up the new processor parameters without choosing a policy is a no-op. - **`ExponentialBackoffRetryPolicy`** — five total attempts (one initial + four retries) with - delays of `100ms, 500ms, 1min, 5min` by default. The 100ms / 500ms retries happen in-process; - the 1min / 5min retries are persisted and picked up on a future processor run. + delays of `100ms, 500ms, 1min, 5min` by default. Every retry is persisted to the redelivery + table and picked up on a future `processNextRedelivery()` run. ```php use Vesper\Tool\Event\Infrastructure\Retry\ExponentialBackoffRetryPolicy; @@ -559,10 +559,9 @@ $retryPolicy = new ExponentialBackoffRetryPolicy(); $retryPolicy = new ExponentialBackoffRetryPolicy(delaysMs: [50, 250, 1_000, 30_000]); ``` -The processor classifies each delay as **in-process** (sleep + retry on the same worker) when it -is less than `inProcessRetryThresholdMs` (default `1000`), and **persisted** otherwise. Persisted -retries require a `RedeliveryTracker` to be configured — without one, a long-delay retry is -treated as exhausted and reported. +Retries require a `RedeliveryTracker` to be configured — without one, any failure is treated +as permanent and rethrown by `SequentialEventProcessor` (or logged and swallowed by +`SilentSequentialEventProcessor`). ### Redelivery tracker @@ -602,15 +601,19 @@ $processor = new SilentSequentialEventProcessor( $processor->process($store); ``` -A single call to `process()` will: +`process()` drains the pending outbox and dispatches each event to its listeners. + +Redeliveries are processed separately — call `processNextRedelivery()` in its own scheduled +job. It picks up one due retry using the same `FOR UPDATE SKIP LOCKED` strategy as `next()`: -1. Drain new pending events from the store and dispatch them to every registered listener. -2. After the main queue is empty, drain any **due** redeliveries — listener failures from earlier - runs whose `next_retry_at` has now passed. +```php +// In a separate cron task / queue worker: +$processor->processNextRedelivery(); // picks up the next due retry, if any +``` -Long backoffs (e.g. the default 1min / 5min steps) won't be drained until a future `process()` -call after their `next_retry_at` passes — which is exactly how outbox workers already poll on a -schedule. +Long backoffs (e.g. the default 1min / 5min steps) won't be picked up until a future +`processNextRedelivery()` call after their `next_retry_at` passes — which is exactly how +outbox workers already poll on a schedule. ### Ignored exceptions (skip-list) @@ -671,8 +674,7 @@ that implements `__invoke()` instead. ```php RawEventStatus::pending // event is waiting to be processed RawEventStatus::processing // event has been claimed by a worker; dispatch in flight -RawEventStatus::processed // event was successfully dispatched to all listeners -RawEventStatus::failed // reserved for future event-level fatal use (see "Future work") +RawEventStatus::processed // event was dispatched to every listener (per-listener outcomes live in event_outbox_redelivery) ``` --- @@ -701,15 +703,13 @@ WHERE status = 'processing' ); ``` -A future sweeper should support three recovery modes: +A future sweeper should support two recovery modes: - **Re-claim** (`processing → pending`) — safe **only** if listeners are idempotent. Re-dispatch may re-fire listeners that already succeeded. - **Force-complete** (`processing → processed`) — safe only when the dispatch is believed to have finished but the bookkeeping commit was lost. Should write a `processed` audit row marked as recovered so dashboards can distinguish organic vs. recovered transitions. -- **Mark dead** (`processing → failed`) — finally puts `RawEventStatus::failed` to use; operator - decides next steps. ### Schema columns to consider when the monitor lands @@ -733,13 +733,6 @@ Without waiting for the in-library monitor, these are queryable from the existin - "Listeners with permanent failures" — `SELECT * FROM event_outbox_redelivery WHERE status = 'failed'`. - "Average time-between rows in `event_outbox_status` by status pair" — reveals dispatch latency. -### What `RawEventStatus::failed` is reserved for - -A natural future use: a sweep over `event_outbox_redelivery` that, when **every** listener for -an event has permanently failed and there's nothing left to retry, marks the event row itself -`failed`. Computable from the existing tables; out of scope for this PR. The enum case is -documented as reserved so future readers don't think it's dead code. - ### 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). diff --git a/src/Infrastructure/InMemoryRedeliveryTracker.php b/src/Infrastructure/InMemoryRedeliveryTracker.php index 448395c..a91423d 100644 --- a/src/Infrastructure/InMemoryRedeliveryTracker.php +++ b/src/Infrastructure/InMemoryRedeliveryTracker.php @@ -11,11 +11,7 @@ class InMemoryRedeliveryTracker implements RedeliveryTracker { - public const string STATUS_PENDING = 'pending_retry'; - public const string STATUS_FAILED = 'failed'; - public const string STATUS_SUCCEEDED = 'succeeded'; - - /** @var array */ + /** @var array */ private array $rows = []; #[Override] @@ -32,7 +28,7 @@ public function schedule( $this->rows[$key] = [ 'event' => $event, 'listener' => $listener, - 'status' => self::STATUS_PENDING, + 'status' => RedeliveryStatus::PendingRetry, 'attempt_number' => $attemptNumber, 'next_retry_at' => $nextRetryAt, 'last_error' => self::formatError($lastError), @@ -48,10 +44,10 @@ public function nextDue(): ?DueRedelivery $candidate = null; foreach ($this->rows as $row) { - if ($row['status'] !== self::STATUS_PENDING) { + if ($row['status'] !== RedeliveryStatus::PendingRetry) { continue; } - if ($row['next_retry_at'] === null || $row['next_retry_at']->greaterThan($now)) { + if ($row['next_retry_at']->greaterThan($now)) { continue; } if ($candidate === null || $row['next_retry_at']->lessThan($candidate['next_retry_at'])) { @@ -79,8 +75,7 @@ public function markFailedPermanently(string $eventId, string $listener, Throwab return; } - $this->rows[$key]['status'] = self::STATUS_FAILED; - $this->rows[$key]['next_retry_at'] = null; + $this->rows[$key]['status'] = RedeliveryStatus::Failed; $this->rows[$key]['last_error'] = self::formatError($lastError); $this->rows[$key]['updated_at'] = CarbonImmutable::now(); } @@ -94,8 +89,7 @@ public function markSucceeded(string $eventId, string $listener): void return; } - $this->rows[$key]['status'] = self::STATUS_SUCCEEDED; - $this->rows[$key]['next_retry_at'] = null; + $this->rows[$key]['status'] = RedeliveryStatus::Succeeded; $this->rows[$key]['updated_at'] = CarbonImmutable::now(); } @@ -108,7 +102,7 @@ public function retryNow(string $eventId, string $listener): void return; } - $this->rows[$key]['status'] = self::STATUS_PENDING; + $this->rows[$key]['status'] = RedeliveryStatus::PendingRetry; $this->rows[$key]['next_retry_at'] = CarbonImmutable::now(); $this->rows[$key]['updated_at'] = CarbonImmutable::now(); } diff --git a/src/Infrastructure/RedeliveryStatus.php b/src/Infrastructure/RedeliveryStatus.php new file mode 100644 index 0000000..04405ff --- /dev/null +++ b/src/Infrastructure/RedeliveryStatus.php @@ -0,0 +1,10 @@ +exec( @@ -21,7 +15,7 @@ public static function create(PDO $connection): void listener TEXT NOT NULL, status TEXT NOT NULL, attempt_number INTEGER NOT NULL, - next_retry_at TEXT, + next_retry_at TEXT NOT NULL, last_error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, @@ -34,5 +28,10 @@ public static function create(PDO $connection): void '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 80c19d7..82617bf 100644 --- a/src/Infrastructure/SequentialEventProcessor.php +++ b/src/Infrastructure/SequentialEventProcessor.php @@ -2,7 +2,6 @@ namespace Vesper\Tool\Event\Infrastructure; -use Carbon\CarbonImmutable; use Closure; use Override; use RuntimeException; @@ -21,17 +20,7 @@ class SequentialEventProcessor implements EventProcessor { /** * @param EventSubscriberMap $subscribers - * @param list> $ignoredExceptions exceptions thrown by a - * listener that should be - * silently swallowed: no - * retry, no log, no redelivery - * row - * @param int $inProcessRetryThresholdMs the policy's next-retry - * delay is performed in-process - * (sleep + retry) when ≤ this - * value; otherwise the failure - * is persisted for later - * redelivery + * @param list> $ignoredExceptions */ public function __construct( private readonly EventSubscriberMap $subscribers, @@ -40,7 +29,6 @@ public function __construct( private readonly RetryPolicy $retryPolicy = new NoRetryPolicy(), private readonly ?RedeliveryTracker $redeliveryTracker = null, private readonly array $ignoredExceptions = [], - private readonly int $inProcessRetryThresholdMs = 1000, ) { } @@ -52,23 +40,37 @@ public function __construct( } $store->markProcessed($event->id); } + } - if ($this->redeliveryTracker !== null) { - while ($due = $this->redeliveryTracker->nextDue()) { - $subscriber = $this->findRegisteredSubscriber($due->event->name, $due->listener); + /** + * Dispatch the next due redelivery, if any. Call this from a separate + * scheduled job so persisted retries are picked up independently of + * the main outbox worker. + */ + public function processNextRedelivery(): void + { + if ($this->redeliveryTracker === null) { + return; + } - if ($subscriber === null) { - $this->redeliveryTracker->markFailedPermanently( - $due->event->id, - $due->listener, - new RuntimeException("Listener '{$due->listener}' is no longer registered for event '{$due->event->name}'."), - ); - continue; - } + $due = $this->redeliveryTracker->nextDue(); - $this->dispatch($due->event, $subscriber, $due->attemptNumber); - } + if ($due === null) { + return; } + + $subscriber = $this->findRegisteredSubscriber($due->event->name, $due->listener); + + if ($subscriber === null) { + $this->redeliveryTracker->markFailedPermanently( + $due->event->id, + $due->listener, + new RuntimeException("Listener '{$due->listener}' is no longer registered for event '{$due->event->name}'."), + ); + return; + } + + $this->dispatch($due->event, $subscriber, $due->attemptNumber); } private function findRegisteredSubscriber(string $eventName, string $listenerKey): callable|string|null @@ -81,57 +83,38 @@ private function findRegisteredSubscriber(string $eventName, string $listenerKey return null; } - /** - * @param int $attemptsMade attempts already made before this dispatch call (0 for a fresh - * event, ≥1 when called from the redelivery drain) - */ + /** @param int $attemptsMade attempts already made before this call (0 for fresh, ≥1 from redelivery) */ protected function dispatch(RawEvent $event, callable|string $subscriber, int $attemptsMade = 0): void { $callable = $this->resolver->resolve($subscriber); $domainEvent = $this->hydrator->hydrate($event->name, $event->payload, $callable); $listener = $this->listenerKey($subscriber); - while (true) { - try { - $callable($domainEvent); - $this->redeliveryTracker?->markSucceeded($event->id, $listener); + try { + $callable($domainEvent); + $this->redeliveryTracker?->markSucceeded($event->id, $listener); + } catch (Throwable $e) { + if ($this->isIgnored($e)) { return; - } catch (Throwable $e) { - if ($this->isIgnored($e)) { - return; - } - - $attemptsMade++; - $nextRetryAt = $this->retryPolicy->nextRetryAt($attemptsMade); - - if ($nextRetryAt === null) { - $this->onPermanentFailure($event, $subscriber, $e); - throw $e; - } - - $delayMs = $this->msUntil($nextRetryAt); - - if ($delayMs <= $this->inProcessRetryThresholdMs) { - $this->sleep($delayMs); - continue; - } + } - if ($this->redeliveryTracker !== null) { - $this->redeliveryTracker->schedule($event, $listener, $attemptsMade, $nextRetryAt, $e); - return; - } + $attemptsMade++; + $nextRetryAt = $this->retryPolicy->nextRetryAt($attemptsMade); + if ($nextRetryAt === null) { $this->onPermanentFailure($event, $subscriber, $e); throw $e; } + + if ($this->redeliveryTracker === null) { + throw $e; + } + + $this->redeliveryTracker->schedule($event, $listener, $attemptsMade, $nextRetryAt, $e); } } - /** - * Hook called once a listener's failure can no longer be retried (policy exhausted, or no - * tracker is configured to persist a long-delay retry). Default: persist the permanent-failure - * marker on the redelivery tracker if one is configured. Subclasses can extend (e.g. log). - */ + /** Called when a listener's failure can no longer be retried. Subclasses can extend (e.g. log). */ protected function onPermanentFailure(RawEvent $event, callable|string $subscriber, Throwable $error): void { $this->redeliveryTracker?->markFailedPermanently($event->id, $this->listenerKey($subscriber), $error); @@ -150,14 +133,6 @@ protected function listenerKey(callable|string $subscriber): string return 'Closure'; } - protected function sleep(int $milliseconds): void - { - if ($milliseconds <= 0) { - return; - } - usleep($milliseconds * 1000); - } - private function isIgnored(Throwable $error): bool { foreach ($this->ignoredExceptions as $class) { @@ -167,10 +142,4 @@ private function isIgnored(Throwable $error): bool } return false; } - - private function msUntil(CarbonImmutable $when): int - { - $diffMs = (int) round(CarbonImmutable::now()->diffInMilliseconds($when, absolute: false)); - return max(0, $diffMs); - } } diff --git a/src/Infrastructure/SilentSequentialEventProcessor.php b/src/Infrastructure/SilentSequentialEventProcessor.php index b20ec77..fe34dab 100644 --- a/src/Infrastructure/SilentSequentialEventProcessor.php +++ b/src/Infrastructure/SilentSequentialEventProcessor.php @@ -21,13 +21,12 @@ class SilentSequentialEventProcessor extends SequentialEventProcessor */ public function __construct( EventSubscriberMap $subscribers, - private readonly LoggerInterface $logger, + private LoggerInterface $logger, HandlerResolver $resolver = new DefaultHandlerResolver(), EventHydrator $hydrator = new JacksonHydrator(), RetryPolicy $retryPolicy = new NoRetryPolicy(), ?RedeliveryTracker $redeliveryTracker = null, array $ignoredExceptions = [], - int $inProcessRetryThresholdMs = 1000, ) { parent::__construct( subscribers: $subscribers, @@ -36,7 +35,6 @@ public function __construct( retryPolicy: $retryPolicy, redeliveryTracker: $redeliveryTracker, ignoredExceptions: $ignoredExceptions, - inProcessRetryThresholdMs: $inProcessRetryThresholdMs, ); } diff --git a/src/Infrastructure/SqlEventStore.php b/src/Infrastructure/SqlEventStore.php index 2e05a41..5583b3a 100644 --- a/src/Infrastructure/SqlEventStore.php +++ b/src/Infrastructure/SqlEventStore.php @@ -93,6 +93,7 @@ public function next(): ?RawEvent ); } catch (Throwable $e) { $this->rollBackIfStarted($startedTransaction); + throw $e; } } @@ -124,6 +125,7 @@ public function markProcessed(string $eventId): void $this->commitIfStarted($startedTransaction); } catch (Throwable $e) { $this->rollBackIfStarted($startedTransaction); + throw $e; } } @@ -180,6 +182,7 @@ private function beginTransactionIfNeeded(): bool } $this->connection->beginTransaction(); + return true; } diff --git a/src/Infrastructure/SqlRedeliveryTracker.php b/src/Infrastructure/SqlRedeliveryTracker.php index 143f599..b99b84f 100644 --- a/src/Infrastructure/SqlRedeliveryTracker.php +++ b/src/Infrastructure/SqlRedeliveryTracker.php @@ -6,7 +6,6 @@ use JsonException; use Override; use PDO; -use PDOException; use RuntimeException; use Throwable; use Vesper\Tool\Event\DueRedelivery; @@ -18,18 +17,11 @@ readonly class SqlRedeliveryTracker implements RedeliveryTracker { - public const string STATUS_PENDING = 'pending_retry'; - public const string STATUS_FAILED = 'failed'; - public const string STATUS_SUCCEEDED = 'succeeded'; - public function __construct(private PDO $connection) { $this->ensureRedeliverySchema(); } - /** - * @throws PDOException - */ #[Override] public function schedule( RawEvent $event, @@ -71,7 +63,7 @@ public function schedule( $this->connection->prepare($sql)->execute([ 'event_id' => $event->id, 'listener' => $listener, - 'status' => self::STATUS_PENDING, + 'status' => RedeliveryStatus::PendingRetry->value, 'attempt_number' => $attemptNumber, 'next_retry_at' => $nextRetryAtSql, 'last_error' => $errorMessage, @@ -80,10 +72,7 @@ public function schedule( ]); } - /** - * @throws JsonException - * @throws PDOException - */ + /** @throws JsonException */ #[Override] public function nextDue(): ?DueRedelivery { @@ -103,7 +92,6 @@ public function nextDue(): ?DueRedelivery FROM event_outbox_redelivery r INNER JOIN event_outbox e ON e.id = r.event_id WHERE r.status = :status - AND r.next_retry_at IS NOT NULL AND r.next_retry_at <= :now ORDER BY r.next_retry_at LIMIT 1 {$lockClause} @@ -111,7 +99,7 @@ public function nextDue(): ?DueRedelivery ); $stmt->execute([ - 'status' => self::STATUS_PENDING, + 'status' => RedeliveryStatus::PendingRetry->value, 'now' => CarbonImmutable::now()->format('Y-m-d H:i:s.u'), ]); @@ -141,9 +129,6 @@ public function nextDue(): ?DueRedelivery ); } - /** - * @throws PDOException - */ #[Override] public function markFailedPermanently(string $eventId, string $listener, Throwable $lastError): void { @@ -151,13 +136,12 @@ public function markFailedPermanently(string $eventId, string $listener, Throwab <<execute([ - 'status' => self::STATUS_FAILED, + 'status' => RedeliveryStatus::Failed->value, 'last_error' => self::formatError($lastError), 'updated_at' => CarbonImmutable::now()->format('Y-m-d H:i:s.u'), 'event_id' => $eventId, @@ -165,9 +149,6 @@ public function markFailedPermanently(string $eventId, string $listener, Throwab ]); } - /** - * @throws PDOException - */ #[Override] public function markSucceeded(string $eventId, string $listener): void { @@ -175,21 +156,17 @@ public function markSucceeded(string $eventId, string $listener): void <<execute([ - 'status' => self::STATUS_SUCCEEDED, + 'status' => RedeliveryStatus::Succeeded->value, 'updated_at' => CarbonImmutable::now()->format('Y-m-d H:i:s.u'), 'event_id' => $eventId, 'listener' => $listener, ]); } - /** - * @throws PDOException - */ #[Override] public function retryNow(string $eventId, string $listener): void { @@ -204,7 +181,7 @@ public function retryNow(string $eventId, string $listener): void WHERE event_id = :event_id AND listener = :listener SQL, )->execute([ - 'status' => self::STATUS_PENDING, + 'status' => RedeliveryStatus::PendingRetry->value, 'next_retry_at' => $now, 'updated_at' => $now, 'event_id' => $eventId, diff --git a/src/RawEventStatus.php b/src/RawEventStatus.php index d5eed18..e501cc4 100644 --- a/src/RawEventStatus.php +++ b/src/RawEventStatus.php @@ -7,5 +7,4 @@ enum RawEventStatus: string case pending = 'pending'; case processing = 'processing'; case processed = 'processed'; - case failed = 'failed'; } diff --git a/src/Retry/RetryPolicy.php b/src/Retry/RetryPolicy.php index 983cca7..904ddce 100644 --- a/src/Retry/RetryPolicy.php +++ b/src/Retry/RetryPolicy.php @@ -11,9 +11,9 @@ interface RetryPolicy * further retries should be made. * * $previousAttempt = 1 means "the first attempt just failed; when should - * attempt 2 run?" The processor classifies the returned timestamp as an - * in-process retry (sleep then retry) or a persisted retry (insert into - * the redelivery table) based on its own threshold. + * attempt 2 run?" The processor persists the returned timestamp in the + * redelivery table; the cron-driven `processNextRedelivery()` flow picks + * it up when it comes due. */ public function nextRetryAt(int $previousAttempt): ?CarbonImmutable; } diff --git a/tests/Unit/RayEventStatusTest.php b/tests/Unit/RayEventStatusTest.php index f939d4e..68582ed 100644 --- a/tests/Unit/RayEventStatusTest.php +++ b/tests/Unit/RayEventStatusTest.php @@ -12,14 +12,14 @@ 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/SequentialEventProcessorTest.php b/tests/Unit/SequentialEventProcessorTest.php index 01bd90a..9688d93 100644 --- a/tests/Unit/SequentialEventProcessorTest.php +++ b/tests/Unit/SequentialEventProcessorTest.php @@ -6,7 +6,6 @@ use PHPUnit\Framework\TestCase; use RuntimeException; use Test\Vesper\Tool\Event\_Fixtures\IgnorableExceptionStub; -use Test\Vesper\Tool\Event\_Fixtures\RecordingSequentialEventProcessor; use Test\Vesper\Tool\Event\_Fixtures\TestEventFactory; use Test\Vesper\Tool\Event\_Fixtures\TrackingEventStore; use Test\Vesper\Tool\Event\_Fixtures\TrackingListener; @@ -242,38 +241,38 @@ public function test_does_not_call_mark_processed_when_a_listener_throws_in_fail self::assertSame([], $store->markProcessedCalls, 'event must remain in processing when dispatch propagates'); } - public function test_in_process_retry_succeeds_on_second_attempt(): void + public function test_persists_failure_to_tracker_for_later_retry(): void { - CarbonImmutable::setTestNow(CarbonImmutable::createFromFormat('Y-m-d H:i:s.u', '2026-04-27 12:00:00.000000')); - try { - $event = TestEventFactory::retrieveOrderPlaced(); - $this->store->add($event); - - $calls = 0; - $this->subscribers->subscribe('order.placed', function () use (&$calls) { - $calls++; - if ($calls === 1) { - throw new RuntimeException('transient'); - } - }); + $event = TestEventFactory::retrieveOrderPlaced(); + $this->store->add($event); + + $exception = new RuntimeException('boom'); + $this->subscribers->subscribe('order.placed', function () use ($exception) { + throw $exception; + }); - $policy = self::policyReturning(retryDelayMs: 50); - $processor = new RecordingSequentialEventProcessor( - $this->subscribers, - retryPolicy: $policy, - inProcessRetryThresholdMs: 100, + $tracker = $this->createMock(RedeliveryTracker::class); + $tracker->expects($this->once()) + ->method('schedule') + ->with( + $this->callback(fn($e) => $e->id === $event->id), + 'Closure', + 1, + $this->isInstanceOf(CarbonImmutable::class), + $exception, ); + $tracker->method('nextDue')->willReturn(null); - $processor->process($this->store); + $processor = new SequentialEventProcessor( + $this->subscribers, + retryPolicy: self::policyReturning(retryDelayMs: 50), + redeliveryTracker: $tracker, + ); - self::assertSame(2, $calls); - self::assertSame([50], $processor->sleeps, 'one in-process sleep happened between the two attempts'); - } finally { - CarbonImmutable::setTestNow(); - } + $processor->process($this->store); } - public function test_in_process_retry_exhausted_propagates_in_fail_fast(): void + public function test_propagates_failure_when_retry_policy_returns_null_and_tracker_configured(): void { $this->store->add(TestEventFactory::retrieveOrderPlaced()); @@ -282,50 +281,36 @@ public function test_in_process_retry_exhausted_propagates_in_fail_fast(): void throw $exception; }); - $policy = self::policyReturning(retryDelayMs: 10, exhaustAfter: 1); - $processor = new RecordingSequentialEventProcessor( + $tracker = $this->createMock(RedeliveryTracker::class); + $tracker->expects($this->never())->method('schedule'); + $tracker->method('nextDue')->willReturn(null); + + $processor = new SequentialEventProcessor( $this->subscribers, - retryPolicy: $policy, - inProcessRetryThresholdMs: 100, + retryPolicy: self::policyReturning(retryDelayMs: 50, exhaustAfter: 0), + redeliveryTracker: $tracker, ); $this->expectExceptionObject($exception); $processor->process($this->store); } - public function test_persists_to_tracker_when_next_retry_delay_exceeds_threshold(): void + public function test_propagates_failure_when_no_tracker_configured(): void { - $event = TestEventFactory::retrieveOrderPlaced(); - $this->store->add($event); + $this->store->add(TestEventFactory::retrieveOrderPlaced()); $exception = new RuntimeException('boom'); $this->subscribers->subscribe('order.placed', function () use ($exception) { throw $exception; }); - $tracker = $this->createMock(RedeliveryTracker::class); - $tracker->expects($this->once()) - ->method('schedule') - ->with( - $this->callback(fn($e) => $e->id === $event->id), - 'Closure', - 1, - $this->isInstanceOf(CarbonImmutable::class), - $exception, - ); - $tracker->method('nextDue')->willReturn(null); - - $policy = self::policyReturning(retryDelayMs: 5_000); // way above threshold - $processor = new RecordingSequentialEventProcessor( + $processor = new SequentialEventProcessor( $this->subscribers, - retryPolicy: $policy, - redeliveryTracker: $tracker, - inProcessRetryThresholdMs: 100, + retryPolicy: self::policyReturning(retryDelayMs: 50), ); + $this->expectExceptionObject($exception); $processor->process($this->store); - - self::assertSame([], $processor->sleeps, 'no in-process sleep when delay exceeds threshold'); } public function test_calls_mark_succeeded_when_listener_succeeds_with_tracker_configured(): void @@ -373,7 +358,7 @@ public function test_ignored_exception_short_circuits_with_no_retry_no_propagati self::assertSame(1, $calls, 'listener ran exactly once and was not retried'); } - public function test_drains_due_redeliveries_after_the_main_queue(): void + public function test_process_next_redelivery_dispatches_due_redelivery(): void { $event = TestEventFactory::retrieveOrderPlaced(); @@ -392,7 +377,7 @@ public function test_drains_due_redeliveries_after_the_main_queue(): void }); $processor = new SequentialEventProcessor($this->subscribers, redeliveryTracker: $tracker); - $processor->process($this->store); + $processor->processNextRedelivery(); self::assertNotNull($received, 'redelivery dispatch invoked the listener'); self::assertNull($tracker->nextDue(), 'redelivery row marked succeeded after dispatch'); diff --git a/tests/_Fixtures/RecordingSequentialEventProcessor.php b/tests/_Fixtures/RecordingSequentialEventProcessor.php deleted file mode 100644 index 9238807..0000000 --- a/tests/_Fixtures/RecordingSequentialEventProcessor.php +++ /dev/null @@ -1,21 +0,0 @@ - */ - public array $sleeps = []; - - protected function sleep(int $milliseconds): void - { - $this->sleeps[] = $milliseconds; - } -} From ce9077fdbe017056383d836f57b527eef31f8c40 Mon Sep 17 00:00:00 2001 From: Jason Viljoen Date: Fri, 15 May 2026 21:46:23 +0200 Subject: [PATCH 04/16] fix: hold redelivery row lock through dispatch SqlRedeliveryTracker::nextDue() runs SELECT ... FOR UPDATE SKIP LOCKED but without an explicit transaction the lock released immediately on autocommit, letting two cron workers pick up the same row and dispatch the same listener concurrently. Add RedeliveryTracker::processNextDue(callable) that wraps nextDue() and the handler in a single transaction so the row lock is held through dispatch. SequentialEventProcessor::processNextRedelivery() now drives through it, catching dispatch's fail-fast throw inside the handler so the resulting schedule / markFailedPermanently side effects commit, then re-throwing after the transaction closes. In-memory tracker passes through (single-process, no concurrency to guard). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../InMemoryRedeliveryTracker.php | 12 +++ .../SequentialEventProcessor.php | 40 ++++++--- src/Infrastructure/SqlRedeliveryTracker.php | 45 ++++++++++ src/RedeliveryTracker.php | 21 +++++ tests/Feature/SqlRedeliveryTrackerTest.php | 85 +++++++++++++++++++ 5 files changed, 189 insertions(+), 14 deletions(-) diff --git a/src/Infrastructure/InMemoryRedeliveryTracker.php b/src/Infrastructure/InMemoryRedeliveryTracker.php index a91423d..d1d0454 100644 --- a/src/Infrastructure/InMemoryRedeliveryTracker.php +++ b/src/Infrastructure/InMemoryRedeliveryTracker.php @@ -93,6 +93,18 @@ public function markSucceeded(string $eventId, string $listener): void $this->rows[$key]['updated_at'] = CarbonImmutable::now(); } + #[Override] + public function processNextDue(callable $handler): void + { + $due = $this->nextDue(); + + if ($due === null) { + return; + } + + $handler($due); + } + #[Override] public function retryNow(string $eventId, string $listener): void { diff --git a/src/Infrastructure/SequentialEventProcessor.php b/src/Infrastructure/SequentialEventProcessor.php index 82617bf..bb6496a 100644 --- a/src/Infrastructure/SequentialEventProcessor.php +++ b/src/Infrastructure/SequentialEventProcessor.php @@ -46,6 +46,12 @@ public function __construct( * Dispatch the next due redelivery, if any. Call this from a separate * scheduled job so persisted retries are picked up independently of * the main outbox worker. + * + * The tracker holds the SELECT ... FOR UPDATE row lock through dispatch + * via processNextDue(), so concurrent cron workers cannot pick up the + * same row. Dispatch's fail-fast throw is deferred until after the + * transaction commits so the schedule / markFailedPermanently side + * effects persist. */ public function processNextRedelivery(): void { @@ -53,24 +59,30 @@ public function processNextRedelivery(): void return; } - $due = $this->redeliveryTracker->nextDue(); + $deferredException = null; - if ($due === null) { - return; - } + $this->redeliveryTracker->processNextDue(function ($due) use (&$deferredException): void { + $subscriber = $this->findRegisteredSubscriber($due->event->name, $due->listener); + + if ($subscriber === null) { + $this->redeliveryTracker->markFailedPermanently( + $due->event->id, + $due->listener, + new RuntimeException("Listener '{$due->listener}' is no longer registered for event '{$due->event->name}'."), + ); + return; + } - $subscriber = $this->findRegisteredSubscriber($due->event->name, $due->listener); + try { + $this->dispatch($due->event, $subscriber, $due->attemptNumber); + } catch (Throwable $e) { + $deferredException = $e; + } + }); - if ($subscriber === null) { - $this->redeliveryTracker->markFailedPermanently( - $due->event->id, - $due->listener, - new RuntimeException("Listener '{$due->listener}' is no longer registered for event '{$due->event->name}'."), - ); - return; + if ($deferredException !== null) { + throw $deferredException; } - - $this->dispatch($due->event, $subscriber, $due->attemptNumber); } private function findRegisteredSubscriber(string $eventName, string $listenerKey): callable|string|null diff --git a/src/Infrastructure/SqlRedeliveryTracker.php b/src/Infrastructure/SqlRedeliveryTracker.php index b99b84f..7e5d798 100644 --- a/src/Infrastructure/SqlRedeliveryTracker.php +++ b/src/Infrastructure/SqlRedeliveryTracker.php @@ -167,6 +167,26 @@ public function markSucceeded(string $eventId, string $listener): void ]); } + #[Override] + public function processNextDue(callable $handler): void + { + $startedTransaction = $this->beginTransactionIfNeeded(); + + try { + $due = $this->nextDue(); + + if ($due !== null) { + $handler($due); + } + + $this->commitIfStarted($startedTransaction); + } catch (Throwable $e) { + $this->rollBackIfStarted($startedTransaction); + + throw $e; + } + } + #[Override] public function retryNow(string $eventId, string $listener): void { @@ -194,6 +214,31 @@ private static function formatError(Throwable $error): string return $error::class . ': ' . $error->getMessage(); } + private function beginTransactionIfNeeded(): bool + { + if ($this->connection->inTransaction()) { + return false; + } + + $this->connection->beginTransaction(); + + return true; + } + + private function commitIfStarted(bool $started): void + { + if ($started) { + $this->connection->commit(); + } + } + + private function rollBackIfStarted(bool $started): void + { + if ($started && $this->connection->inTransaction()) { + $this->connection->rollBack(); + } + } + private function ensureRedeliverySchema(): void { $driver = $this->driverName(); diff --git a/src/RedeliveryTracker.php b/src/RedeliveryTracker.php index 325d21e..a074a13 100644 --- a/src/RedeliveryTracker.php +++ b/src/RedeliveryTracker.php @@ -27,9 +27,30 @@ public function schedule( /** * Pick up the next due redelivery (worker-safe; locks the row on MySQL). * Returns null when none are due. + * + * Note: callers that intend to dispatch the returned redelivery should use + * processNextDue() instead — that wraps the read and the subsequent state + * transition in a single transaction, so the row lock is held through the + * dispatch and concurrent workers cannot pick up the same row. */ public function nextDue(): ?DueRedelivery; + /** + * Atomically claim the next due redelivery and pass it to $handler. SQL + * implementations open a transaction so the row lock acquired by nextDue() + * is held through the handler call, preventing concurrent workers from + * picking up the same row. + * + * The handler is expected to drive dispatch and the resulting state + * transition (schedule / markSucceeded / markFailedPermanently). It must + * not throw — wrap dispatch in your own try/catch if you need to swallow + * or defer the exception, otherwise the transaction will roll back and + * those state transitions will be lost. + * + * @param callable(DueRedelivery): void $handler + */ + public function processNextDue(callable $handler): void; + /** * Mark a redelivery as permanently failed — no further automatic attempts. * The row stays in the table so operators can inspect it and call retryNow() diff --git a/tests/Feature/SqlRedeliveryTrackerTest.php b/tests/Feature/SqlRedeliveryTrackerTest.php index 1058659..d3ba7bc 100644 --- a/tests/Feature/SqlRedeliveryTrackerTest.php +++ b/tests/Feature/SqlRedeliveryTrackerTest.php @@ -139,6 +139,91 @@ public function test_mark_failed_permanently_removes_row_from_due_queue(): void self::assertSame('failed', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); } + public function test_process_next_due_runs_handler_inside_a_transaction(): void + { + $event = $this->insertEvent(); + $this->tracker->schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + ); + + $observedInTransaction = null; + + $this->tracker->processNextDue(function () use (&$observedInTransaction): void { + $observedInTransaction = $this->pdo->inTransaction(); + }); + + self::assertTrue($observedInTransaction, 'handler ran inside an active transaction'); + self::assertFalse($this->pdo->inTransaction(), 'transaction committed after processNextDue returned'); + } + + public function test_process_next_due_commits_handler_side_effects(): void + { + $event = $this->insertEvent(); + $this->tracker->schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 5, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + ); + + $this->tracker->processNextDue(function () use ($event): void { + $this->tracker->markFailedPermanently( + $event->id, + 'App\\Listener', + new RuntimeException('final'), + ); + }); + + self::assertSame('failed', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); + self::assertNull($this->tracker->nextDue()); + } + + public function test_process_next_due_rolls_back_when_handler_throws(): void + { + $event = $this->insertEvent(); + $this->tracker->schedule( + event: $event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + ); + + try { + $this->tracker->processNextDue(function () use ($event): void { + $this->tracker->markSucceeded($event->id, 'App\\Listener'); + throw new RuntimeException('handler exploded'); + }); + self::fail('expected exception'); + } catch (RuntimeException $e) { + self::assertSame('handler exploded', $e->getMessage()); + } + + self::assertSame( + 'pending_retry', + $this->fetchRedeliveryStatus($event->id, 'App\\Listener'), + 'markSucceeded rolled back because the handler threw — caller must catch and defer to keep side effects', + ); + self::assertFalse($this->pdo->inTransaction(), 'transaction rolled back, not left dangling'); + } + + public function test_process_next_due_is_a_noop_when_nothing_is_due(): void + { + $invoked = false; + + $this->tracker->processNextDue(function () use (&$invoked): void { + $invoked = true; + }); + + self::assertFalse($invoked, 'handler not invoked when no row is due'); + self::assertFalse($this->pdo->inTransaction(), 'transaction committed even when no row was found'); + } + public function test_retry_now_re_queues_a_permanently_failed_row_preserving_attempt_count(): void { $event = $this->insertEvent(); From 92c8611344f43a77a25d70b9e30043eb2e0b4a13 Mon Sep 17 00:00:00 2001 From: Jason Viljoen Date: Fri, 15 May 2026 21:52:48 +0200 Subject: [PATCH 05/16] feat: recoverStuckEvents() to unwedge processing rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a worker dies between next() and markProcessed(), the event row stays in 'processing' forever. Redelivery rows are durable so listener-level retries still fire, but the parent event row was unrecoverable without manual intervention. SqlEventStore::recoverStuckEvents(CarbonInterval $olderThan): int now transitions stuck rows back to 'pending' and writes a recovery audit row tagged "Recovered from stuck processing state" so dashboards can tell organic vs. recovered transitions apart. A row is "stuck" when its most recent 'processing' audit entry is older than the threshold. Call from a separate scheduled job — listeners must be idempotent because re-dispatch can re-fire listeners that already succeeded on the previous run, but that property is already required by the at-least-once outbox contract. The README's "Stuck-events monitor" Future Work subsection becomes "Force-complete recovery for processing rows" — that mode is intentionally left out of the library since it's a judgement call. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 81 ++++---- src/Infrastructure/SqlEventStore.php | 66 +++++++ .../Feature/SqlEventStoreRecoverStuckTest.php | 181 ++++++++++++++++++ 3 files changed, 285 insertions(+), 43 deletions(-) create mode 100644 tests/Feature/SqlEventStoreRecoverStuckTest.php diff --git a/README.md b/README.md index 88bfe8a..481646d 100644 --- a/README.md +++ b/README.md @@ -348,8 +348,33 @@ The processor advances each event through three states: 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. The stuck `processing` row is detectable -by querying `event_outbox_status` (see [Future work](#future-work)). +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. --- @@ -684,54 +709,24 @@ RawEventStatus::processed // event was dispatched to every listener (per-liste This section captures known gaps and what we'd want to add. The redelivery layer is the headline feature shipped now; what follows is intentionally deferred so we can let real usage shape it. -### Stuck-events monitor - -If a worker dies between `next()` and `markProcessed()`, the row stays in -`event_outbox.status = 'processing'` indefinitely. Redelivery rows that *did* get persisted are -durable and will still fire — but the parent event row is wedged. - -Detection query (works today against existing tables): +### Force-complete recovery for processing rows -```sql -SELECT id, name, created_at, publish_at -FROM event_outbox -WHERE status = 'processing' - AND id IN ( - SELECT event_id FROM event_outbox_status - WHERE status = 'processing' - AND created_at < NOW() - INTERVAL 30 MINUTE -- pick a threshold that matches your workload - ); -``` - -A future sweeper should support two recovery modes: - -- **Re-claim** (`processing → pending`) — safe **only** if listeners are idempotent. Re-dispatch - may re-fire listeners that already succeeded. -- **Force-complete** (`processing → processed`) — safe only when the dispatch is believed to - have finished but the bookkeeping commit was lost. Should write a `processed` audit row marked - as recovered so dashboards can distinguish organic vs. recovered transitions. - -### Schema columns to consider when the monitor lands - -Based on Spring Modulith's `EVENT_PUBLICATION` table (the closest production-grade analogue): - -| Column | Why we'd want it | -|---|---| -| `completion_attempts` (INT) on `event_outbox` | How many times this event has been claimed for processing. Increments on every `next()`. Distinguishes "stuck once" from "repeatedly poisoning workers". | -| `last_resubmission_date` (TIMESTAMP) on `event_outbox` | Distinguishes organic stuck rows from rows an operator has already touched. | -| A `RESUBMITTED` enum case (or a `recovered_at` / `recovered_by` column on `event_outbox_status`) | So dashboards can show "rescued by hand" separately from happy path. | - -The existing `event_outbox_redelivery` table already tracks `attempt_number` and `last_error` at -the *listener* level; the columns above are for *event-level* tracking, which we don't need yet -but will once the monitor lands. +`recoverStuckEvents()` re-claims 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. ### Operational signals you can build today -Without waiting for the in-library monitor, these are queryable from the existing tables: +These are queryable from the existing tables: -- "Events stuck in `processing` for more than T" — the SQL above. - "Listeners with permanent failures" — `SELECT * FROM event_outbox_redelivery WHERE status = 'failed'`. - "Average time-between rows in `event_outbox_status` by status pair" — reveals dispatch latency. +- "Events recovered by the stuck-events sweeper" — `SELECT * FROM event_outbox_status WHERE error_message = 'Recovered from stuck processing state'`. + +See [ROADMAP.md](ROADMAP.md) for known limitations and planned work. ### Reference reading diff --git a/src/Infrastructure/SqlEventStore.php b/src/Infrastructure/SqlEventStore.php index 5583b3a..4463aba 100644 --- a/src/Infrastructure/SqlEventStore.php +++ b/src/Infrastructure/SqlEventStore.php @@ -3,6 +3,7 @@ namespace Vesper\Tool\Event\Infrastructure; use Carbon\CarbonImmutable; +use Carbon\CarbonInterval; use JsonException; use Override; use PDO; @@ -130,6 +131,71 @@ public function markProcessed(string $eventId): void } } + /** + * Reset events wedged in `processing` back to `pending` so a worker can claim them again. + * A row counts as stuck when its most recent `processing` audit entry is older than + * $olderThan. Writes a `pending` audit row tagged "Recovered from stuck processing state" + * for each recovery so dashboards can distinguish organic vs. recovered transitions. + * Returns the number of events recovered. + * + * Call from a separate scheduled job; idempotent, safe to run alongside the main worker. + * + * @throws PDOException + */ + 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; + } + + $startedTransaction = $this->beginTransactionIfNeeded(); + + 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->commitIfStarted($startedTransaction); + + return $recovered; + } catch (Throwable $e) { + $this->rollBackIfStarted($startedTransaction); + + throw $e; + } + } + /** * @return array{id: string, name: string, status: string, payload: string, created_at: string, publish_at: string}|null */ diff --git a/tests/Feature/SqlEventStoreRecoverStuckTest.php b/tests/Feature/SqlEventStoreRecoverStuckTest.php new file mode 100644 index 0000000..7a3a413 --- /dev/null +++ b/tests/Feature/SqlEventStoreRecoverStuckTest.php @@ -0,0 +1,181 @@ +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(); // pending → processing + $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(); + // Don't backdate — audit row is fresh. + + $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); + // Skip next() — row stays in pending. + + $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); + $this->store->next(); + $this->store->markProcessed($event->id); + $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::assertCount(1, $recoveryRows, 'exactly one recovery audit row was written'); + self::assertSame('pending', $recoveryRows[0]['status']); + } + + 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(), + ); + } +} From a09fa9a8739a1cbc9f1e87850d33b79c5fa00da1 Mon Sep 17 00:00:00 2001 From: Jason Viljoen Date: Fri, 15 May 2026 21:55:21 +0200 Subject: [PATCH 06/16] docs: extract roadmap from README into ROADMAP.md The README's "Future work" section had grown beyond what a usage doc should carry. Move it into a top-level ROADMAP.md and add the deferred items surfaced in the recent design review: audit pruning, metric hooks, stack traces in last_error, schema migrations, batch redelivery, per-aggregate ordering, idempotency tokens, per-listener retry policies. Each entry has a likely shape and a trigger (the signal that says "now's the time"), so future-us can pull items in as real usage demands them rather than guessing. README retains the operational queries block (useful day one) and a link to ROADMAP for everything else. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 25 +++-------- ROADMAP.md | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 19 deletions(-) create mode 100644 ROADMAP.md diff --git a/README.md b/README.md index 481646d..17c3a73 100644 --- a/README.md +++ b/README.md @@ -704,36 +704,23 @@ RawEventStatus::processed // event was dispatched to every listener (per-liste --- -## Future work +## Operational queries -This section captures known gaps and what we'd want to add. The redelivery layer is the headline -feature shipped now; what follows is intentionally deferred so we can let real usage shape it. +Useful queries against the existing tables: -### Force-complete recovery for processing rows +- 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 rows (`processing → pending`) for re-dispatch. The other +`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. -### Operational signals you can build today - -These are queryable from the existing tables: - -- "Listeners with permanent failures" — `SELECT * FROM event_outbox_redelivery WHERE status = 'failed'`. -- "Average time-between rows in `event_outbox_status` by status pair" — reveals dispatch latency. -- "Events recovered by the stuck-events sweeper" — `SELECT * FROM event_outbox_status WHERE error_message = 'Recovered from stuck processing state'`. - See [ROADMAP.md](ROADMAP.md) for known limitations and planned work. -### 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. - --- ## Testing diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..c57ec30 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,127 @@ +# 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 small `EventProcessorObserver` interface with hooks like +`onDispatched`, `onScheduledForRetry`, `onPermanentlyFailed`, `onRecovered`. +Default no-op implementation; users plug in StatsD / OpenTelemetry / +Prometheus / whatever 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` + +`SqlRedeliveryTracker` 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 redelivery + +`processNextRedelivery()` drains one row per call. For a backlog of 10k +retries, that's 10k separate transactions and lock acquisitions. + +**Likely shape:** `processDueRedeliveries(int $batchSize): int` that wraps a +configurable number of dispatches in one transaction (or one transaction per +row but in a tight loop, sharing the connection). + +**Trigger:** first time the cron task can't keep up at its current cadence. + +### 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. From 24dc657fc47f7f24de1c245ef61b80ec90eb778b Mon Sep 17 00:00:00 2001 From: Jason Viljoen Date: Fri, 15 May 2026 22:12:23 +0200 Subject: [PATCH 07/16] test: lock in silent processor behaviour for redelivery flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SilentSequentialEventProcessor inherits processNextRedelivery() from its parent and provides its silent behaviour through the dispatch() override alone — so retries exhausting in a cron tick log+mark-failed quietly, and transient failures reschedule without noise. The flow was correct but untested; if dispatch is ever refactored, this nails the contract down. Two new tests: - exhaustion: listener throws, retry policy returns null, expect one log entry and the row no longer due (markFailedPermanently committed inside the processNextDue transaction) - transient: listener throws, retry policy returns a future time, expect no log entry and the row not currently due (rescheduled forward) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SilentSequentialEventProcessorTest.php | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/tests/Unit/SilentSequentialEventProcessorTest.php b/tests/Unit/SilentSequentialEventProcessorTest.php index 1e4f1a1..293beda 100644 --- a/tests/Unit/SilentSequentialEventProcessorTest.php +++ b/tests/Unit/SilentSequentialEventProcessorTest.php @@ -2,6 +2,7 @@ namespace Test\Vesper\Tool\Event\Unit; +use Carbon\CarbonImmutable; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use RuntimeException; @@ -11,8 +12,10 @@ use Vesper\Tool\Event\EventHydrator; use Vesper\Tool\Event\EventSubscriberMap; use Vesper\Tool\Event\Infrastructure\InMemoryEventStore; +use Vesper\Tool\Event\Infrastructure\InMemoryRedeliveryTracker; use Vesper\Tool\Event\Infrastructure\SilentSequentialEventProcessor; use Vesper\Tool\Event\RedeliveryTracker; +use Vesper\Tool\Event\Retry\RetryPolicy; class SilentSequentialEventProcessorTest extends TestCase { @@ -246,4 +249,105 @@ public function test_ignored_exception_is_suppressed_with_no_log_or_tracker_call $processor->process($this->store); } + + // ── processNextRedelivery silent behaviour ───────────────────────────────── + + public function test_process_next_redelivery_logs_and_marks_failed_permanently_on_exhaustion(): void + { + $event = TestEventFactory::retrieveOrderPlaced(); + $tracker = new InMemoryRedeliveryTracker(); + $tracker->schedule( + event: $event, + listener: 'Closure', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('previous failure'), + ); + + $exception = new RuntimeException('still broken'); + $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, + ], + ); + + $hydrator = $this->createStub(EventHydrator::class); + $hydrator->method('hydrate')->willReturnCallback(fn(string $name, array $payload) => (object) $payload); + + $processor = new SilentSequentialEventProcessor( + subscribers: $this->subscribers, + logger: $this->logger, + hydrator: $hydrator, + retryPolicy: self::policyExhaustedImmediately(), + redeliveryTracker: $tracker, + ); + + $processor->processNextRedelivery(); + + self::assertNull($tracker->nextDue(), 'row marked failed permanently — no longer due'); + } + + public function test_process_next_redelivery_silently_reschedules_when_retries_remain(): void + { + $event = TestEventFactory::retrieveOrderPlaced(); + $tracker = new InMemoryRedeliveryTracker(); + $tracker->schedule( + event: $event, + listener: 'Closure', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('previous failure'), + ); + + $this->subscribers->subscribe('order.placed', function () { + throw new RuntimeException('still broken'); + }); + + $this->logger->expects($this->never())->method('error'); + + $hydrator = $this->createStub(EventHydrator::class); + $hydrator->method('hydrate')->willReturnCallback(fn(string $name, array $payload) => (object) $payload); + + $processor = new SilentSequentialEventProcessor( + subscribers: $this->subscribers, + logger: $this->logger, + hydrator: $hydrator, + retryPolicy: self::policyReturningFutureRetry(), + redeliveryTracker: $tracker, + ); + + $processor->processNextRedelivery(); + + self::assertNull($tracker->nextDue(), 'row rescheduled for the future — not due now'); + } + + private static function policyExhaustedImmediately(): RetryPolicy + { + return new readonly class implements RetryPolicy { + public function nextRetryAt(int $previousAttempt): ?CarbonImmutable + { + return null; + } + }; + } + + private static function policyReturningFutureRetry(): RetryPolicy + { + return new readonly class implements RetryPolicy { + public function nextRetryAt(int $previousAttempt): ?CarbonImmutable + { + return CarbonImmutable::now()->addMinute(); + } + }; + } } From 00532aece816ef2c40a3dc594c162dd6b81e4b0b Mon Sep 17 00:00:00 2001 From: Jason Viljoen Date: Fri, 15 May 2026 22:14:41 +0200 Subject: [PATCH 08/16] feat: include attempt number in silent processor log context For an oncall reading a log line, knowing "this was attempt 5 of 5" vs "this was attempt 1 of many" changes the urgency. Add 'attempt' to the PSR-3 context with the value of the failed attempt (1 for a fresh dispatch that exhausted on the first try, N+1 when called from processNextRedelivery with N prior attempts already recorded). Parent dispatch only throws after incrementing its local attempt counter, so the silent override can compute the failed attempt as $attemptsMade + 1 (the value entering this call plus the attempt that just completed). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 1 + src/Infrastructure/SilentSequentialEventProcessor.php | 5 +++++ tests/Unit/SilentSequentialEventProcessorTest.php | 6 ++++++ 3 files changed, 12 insertions(+) diff --git a/README.md b/README.md index 17c3a73..dee5810 100644 --- a/README.md +++ b/README.md @@ -499,6 +499,7 @@ Failed to dispatch event to listener. event: "order.placed" event_id: "019612a3-..." listener: "App\Listeners\SendConfirmationEmail" // or "Closure" + attempt: 3 // the attempt that just failed; 1 if no retry policy exception: RuntimeException: ... } ``` diff --git a/src/Infrastructure/SilentSequentialEventProcessor.php b/src/Infrastructure/SilentSequentialEventProcessor.php index fe34dab..b18311c 100644 --- a/src/Infrastructure/SilentSequentialEventProcessor.php +++ b/src/Infrastructure/SilentSequentialEventProcessor.php @@ -44,10 +44,15 @@ protected function dispatch(RawEvent $event, callable|string $subscriber, int $a try { parent::dispatch($event, $subscriber, $attemptsMade); } catch (Throwable $exception) { + // Parent dispatch only throws after incrementing its local attempt counter and + // either persisting markFailedPermanently or determining no tracker is configured — + // so the attempt that just failed is $attemptsMade + 1 (the value entering this call + // plus the attempt parent dispatch just completed). $this->logger->error('Failed to dispatch event to listener.', [ 'event' => $event->name, 'event_id' => $event->id, 'listener' => $this->listenerKey($subscriber), + 'attempt' => $attemptsMade + 1, 'exception' => $exception, ]); } diff --git a/tests/Unit/SilentSequentialEventProcessorTest.php b/tests/Unit/SilentSequentialEventProcessorTest.php index 293beda..459d946 100644 --- a/tests/Unit/SilentSequentialEventProcessorTest.php +++ b/tests/Unit/SilentSequentialEventProcessorTest.php @@ -62,6 +62,7 @@ public function test_continues_processing_remaining_subscribers_after_one_throws 'event' => 'order.placed', 'event_id' => $event->id, 'listener' => 'Closure', + 'attempt' => 1, 'exception' => $exception, ], ); @@ -89,6 +90,7 @@ public function test_logs_error_with_exception_context_on_failure(): void 'event' => 'order.placed', 'event_id' => $event->id, 'listener' => 'Closure', + 'attempt' => 1, 'exception' => $exception, ], ); @@ -138,6 +140,7 @@ public function test_logs_class_name_when_subscriber_is_a_class_string(): void 'event' => 'order.placed', 'event_id' => $event->id, 'listener' => ThrowingListener::class, + 'attempt' => 1, 'exception' => new RuntimeException('ThrowingListener always fails.'), ] ); @@ -168,6 +171,7 @@ public function test_continues_processing_subsequent_events_after_failure(): voi 'event' => 'order.placed', 'event_id' => $firstEvent->id, 'listener' => 'Closure', + 'attempt' => 1, 'exception' => $exception, ], ); @@ -203,6 +207,7 @@ public function test_logs_and_marks_failed_permanently_when_retries_are_exhauste 'event' => 'order.placed', 'event_id' => $event->id, 'listener' => 'Closure', + 'attempt' => 1, 'exception' => $exception, ], ); @@ -277,6 +282,7 @@ public function test_process_next_redelivery_logs_and_marks_failed_permanently_o 'event' => 'order.placed', 'event_id' => $event->id, 'listener' => 'Closure', + 'attempt' => 2, 'exception' => $exception, ], ); From 6bd84d3f1fdb6d7ca6daab8aa068424c57204791 Mon Sep 17 00:00:00 2001 From: Jason Viljoen Date: Fri, 15 May 2026 22:19:27 +0200 Subject: [PATCH 09/16] docs: consolidate the three scheduled jobs into one overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each entry point — process(), processNextRedelivery(), recoverStuckEvents() — was already documented in its own section, but an operator setting up cron for the first time had to read the whole README to discover all three. Add a single Scheduled jobs section right after Quick start with the recommended cadence, a one-line summary, and a link to the detailed section for each. Also documents that the three jobs touch mostly-disjoint tables and don't compete for the same rows, with the one cross-job interaction (recoverStuckEvents flipping a row a slow worker still holds) called out explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index dee5810..0e8d26a 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,36 @@ $processor->process($store); --- +## Scheduled jobs + +A production deployment runs three 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 | +|---|---|---| +| `$processor->process($store)` | 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). | +| `$processor->processNextRedelivery()` | Every minute, looped per tick to drain a batch | Picks up one due retry from `event_outbox_redelivery` and dispatches it. Loop the call within a single tick if you want to drain multiple. See [Automatic retry & failure tracking](#automatic-retry--failure-tracking). | +| `$store->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). | + +Only `recoverStuckEvents` is SQL-specific (lives on `SqlEventStore`). The other two +work against any `EventStore` / `RedeliveryTracker` implementation. + +The three jobs do not compete for the same rows: + +- `process()` reads `event_outbox` rows in `pending` status; `recoverStuckEvents()` reads + rows in `processing` status — disjoint by the status filter. +- `processNextRedelivery()` reads the redelivery table, not the outbox. +- The lock from `SELECT ... FOR UPDATE SKIP LOCKED` is held through each job's transaction, + so concurrent workers running the same job never claim the same row either. + +The one cross-job interaction to think about: if `recoverStuckEvents` flips a row from +`processing` back to `pending` while a slow worker is still actively dispatching it, +another worker can pick it up via `process()` and re-fire its listeners. Choose a +threshold 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). From bfd7bbd11bd5282875f5a826e2daec042a350190 Mon Sep 17 00:00:00 2001 From: Jason Viljoen Date: Fri, 15 May 2026 22:50:13 +0200 Subject: [PATCH 10/16] refactor: RedeliveryTracker::schedule() takes a RedeliveryRequest Five parameters on the schedule() call (event, listener, attemptNumber, nextRetryAt, lastError) is at the edge of what's readable, and the constructor is the right place to enforce invariants the call site shouldn't have to remember. Introduce a RedeliveryRequest value object that bundles the five fields and validates two invariants in its constructor: - attemptNumber >= 1 (you can only schedule a retry for an attempt that has already happened) - listener must not be empty or whitespace-only Symmetric with DueRedelivery on the read side: DueRedelivery is the projection nextDue() returns for dispatch; RedeliveryRequest is the input schedule() persists. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../InMemoryRedeliveryTracker.php | 22 ++--- .../SequentialEventProcessor.php | 9 +- src/Infrastructure/SqlRedeliveryTracker.php | 20 ++--- src/RedeliveryRequest.php | 34 +++++++ src/RedeliveryTracker.php | 17 ++-- tests/Feature/SqlRedeliveryTrackerTest.php | 41 ++++----- tests/Unit/InMemoryRedeliveryTrackerTest.php | 37 ++++---- tests/Unit/RedeliveryRequestTest.php | 88 +++++++++++++++++++ tests/Unit/SequentialEventProcessorTest.php | 19 ++-- .../SilentSequentialEventProcessorTest.php | 9 +- 10 files changed, 207 insertions(+), 89 deletions(-) create mode 100644 src/RedeliveryRequest.php create mode 100644 tests/Unit/RedeliveryRequestTest.php diff --git a/src/Infrastructure/InMemoryRedeliveryTracker.php b/src/Infrastructure/InMemoryRedeliveryTracker.php index d1d0454..81d0ff8 100644 --- a/src/Infrastructure/InMemoryRedeliveryTracker.php +++ b/src/Infrastructure/InMemoryRedeliveryTracker.php @@ -7,6 +7,7 @@ use Throwable; use Vesper\Tool\Event\DueRedelivery; use Vesper\Tool\Event\RawEvent; +use Vesper\Tool\Event\RedeliveryRequest; use Vesper\Tool\Event\RedeliveryTracker; class InMemoryRedeliveryTracker implements RedeliveryTracker @@ -15,23 +16,18 @@ class InMemoryRedeliveryTracker implements RedeliveryTracker private array $rows = []; #[Override] - public function schedule( - RawEvent $event, - string $listener, - int $attemptNumber, - CarbonImmutable $nextRetryAt, - Throwable $lastError, - ): void { - $key = self::key($event->id, $listener); + public function schedule(RedeliveryRequest $request): void + { + $key = self::key($request->event->id, $request->listener); $now = CarbonImmutable::now(); $this->rows[$key] = [ - 'event' => $event, - 'listener' => $listener, + 'event' => $request->event, + 'listener' => $request->listener, 'status' => RedeliveryStatus::PendingRetry, - 'attempt_number' => $attemptNumber, - 'next_retry_at' => $nextRetryAt, - 'last_error' => self::formatError($lastError), + 'attempt_number' => $request->attemptNumber, + 'next_retry_at' => $request->nextRetryAt, + 'last_error' => self::formatError($request->lastError), 'created_at' => $this->rows[$key]['created_at'] ?? $now, 'updated_at' => $now, ]; diff --git a/src/Infrastructure/SequentialEventProcessor.php b/src/Infrastructure/SequentialEventProcessor.php index bb6496a..9917080 100644 --- a/src/Infrastructure/SequentialEventProcessor.php +++ b/src/Infrastructure/SequentialEventProcessor.php @@ -13,6 +13,7 @@ use Vesper\Tool\Event\HandlerResolver; use Vesper\Tool\Event\Infrastructure\Retry\NoRetryPolicy; use Vesper\Tool\Event\RawEvent; +use Vesper\Tool\Event\RedeliveryRequest; use Vesper\Tool\Event\RedeliveryTracker; use Vesper\Tool\Event\Retry\RetryPolicy; @@ -122,7 +123,13 @@ protected function dispatch(RawEvent $event, callable|string $subscriber, int $a throw $e; } - $this->redeliveryTracker->schedule($event, $listener, $attemptsMade, $nextRetryAt, $e); + $this->redeliveryTracker->schedule(new RedeliveryRequest( + event: $event, + listener: $listener, + attemptNumber: $attemptsMade, + nextRetryAt: $nextRetryAt, + lastError: $e, + )); } } diff --git a/src/Infrastructure/SqlRedeliveryTracker.php b/src/Infrastructure/SqlRedeliveryTracker.php index 7e5d798..c9b0105 100644 --- a/src/Infrastructure/SqlRedeliveryTracker.php +++ b/src/Infrastructure/SqlRedeliveryTracker.php @@ -13,6 +13,7 @@ use Vesper\Tool\Event\Infrastructure\Schema\SqliteRedeliverySchema; use Vesper\Tool\Event\RawEvent; use Vesper\Tool\Event\RawEventStatus; +use Vesper\Tool\Event\RedeliveryRequest; use Vesper\Tool\Event\RedeliveryTracker; readonly class SqlRedeliveryTracker implements RedeliveryTracker @@ -23,16 +24,11 @@ public function __construct(private PDO $connection) } #[Override] - public function schedule( - RawEvent $event, - string $listener, - int $attemptNumber, - CarbonImmutable $nextRetryAt, - Throwable $lastError, - ): void { + public function schedule(RedeliveryRequest $request): void + { $now = CarbonImmutable::now()->format('Y-m-d H:i:s.u'); - $errorMessage = self::formatError($lastError); - $nextRetryAtSql = $nextRetryAt->format('Y-m-d H:i:s.u'); + $errorMessage = self::formatError($request->lastError); + $nextRetryAtSql = $request->nextRetryAt->format('Y-m-d H:i:s.u'); $sql = match ($this->driverName()) { 'mysql' => <<connection->prepare($sql)->execute([ - 'event_id' => $event->id, - 'listener' => $listener, + 'event_id' => $request->event->id, + 'listener' => $request->listener, 'status' => RedeliveryStatus::PendingRetry->value, - 'attempt_number' => $attemptNumber, + 'attempt_number' => $request->attemptNumber, 'next_retry_at' => $nextRetryAtSql, 'last_error' => $errorMessage, 'created_at' => $now, diff --git a/src/RedeliveryRequest.php b/src/RedeliveryRequest.php new file mode 100644 index 0000000..61971a2 --- /dev/null +++ b/src/RedeliveryRequest.php @@ -0,0 +1,34 @@ +id and - * join event_outbox to look the rest up. + * row's attempt_number, next_retry_at, and last_error. The request carries + * the full RawEvent (not just the id) so in-memory implementations can keep + * their own copy for later rehydration; SQL implementations only need the + * event id and join event_outbox to look the rest up. */ - public function schedule( - RawEvent $event, - string $listener, - int $attemptNumber, - CarbonImmutable $nextRetryAt, - Throwable $lastError, - ): void; + public function schedule(RedeliveryRequest $request): void; /** * Pick up the next due redelivery (worker-safe; locks the row on MySQL). diff --git a/tests/Feature/SqlRedeliveryTrackerTest.php b/tests/Feature/SqlRedeliveryTrackerTest.php index d3ba7bc..b0d9751 100644 --- a/tests/Feature/SqlRedeliveryTrackerTest.php +++ b/tests/Feature/SqlRedeliveryTrackerTest.php @@ -10,6 +10,7 @@ use Vesper\Tool\Event\Infrastructure\SqlEventStore; use Vesper\Tool\Event\Infrastructure\SqlRedeliveryTracker; use Vesper\Tool\Event\RawEvent; +use Vesper\Tool\Event\RedeliveryRequest; class SqlRedeliveryTrackerTest extends TestCase { @@ -43,13 +44,13 @@ public function test_schedule_then_next_due_round_trip(): void { $event = $this->insertEvent(); - $this->tracker->schedule( + $this->tracker->schedule(new RedeliveryRequest( event: $event, listener: 'App\\Listener', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), - ); + )); $due = $this->tracker->nextDue(); @@ -64,13 +65,13 @@ public function test_next_due_excludes_rows_whose_retry_time_is_in_the_future(): { $event = $this->insertEvent(); - $this->tracker->schedule( + $this->tracker->schedule(new RedeliveryRequest( event: $event, listener: 'App\\Listener', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->addMinute(), lastError: new RuntimeException('boom'), - ); + )); self::assertNull($this->tracker->nextDue()); } @@ -79,20 +80,20 @@ public function test_schedule_is_idempotent_and_updates_on_repeat(): void { $event = $this->insertEvent(); - $this->tracker->schedule( + $this->tracker->schedule(new RedeliveryRequest( event: $event, listener: 'App\\Listener', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->addMinute(), lastError: new RuntimeException('first'), - ); - $this->tracker->schedule( + )); + $this->tracker->schedule(new RedeliveryRequest( event: $event, listener: 'App\\Listener', attemptNumber: 2, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('second'), - ); + )); $due = $this->tracker->nextDue(); @@ -107,13 +108,13 @@ public function test_mark_succeeded_removes_row_from_due_queue(): void { $event = $this->insertEvent(); - $this->tracker->schedule( + $this->tracker->schedule(new RedeliveryRequest( event: $event, listener: 'App\\Listener', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), - ); + )); $this->tracker->markSucceeded($event->id, 'App\\Listener'); @@ -125,13 +126,13 @@ public function test_mark_failed_permanently_removes_row_from_due_queue(): void { $event = $this->insertEvent(); - $this->tracker->schedule( + $this->tracker->schedule(new RedeliveryRequest( event: $event, listener: 'App\\Listener', attemptNumber: 5, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), - ); + )); $this->tracker->markFailedPermanently($event->id, 'App\\Listener', new RuntimeException('final')); @@ -142,13 +143,13 @@ public function test_mark_failed_permanently_removes_row_from_due_queue(): void public function test_process_next_due_runs_handler_inside_a_transaction(): void { $event = $this->insertEvent(); - $this->tracker->schedule( + $this->tracker->schedule(new RedeliveryRequest( event: $event, listener: 'App\\Listener', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), - ); + )); $observedInTransaction = null; @@ -163,13 +164,13 @@ public function test_process_next_due_runs_handler_inside_a_transaction(): void public function test_process_next_due_commits_handler_side_effects(): void { $event = $this->insertEvent(); - $this->tracker->schedule( + $this->tracker->schedule(new RedeliveryRequest( event: $event, listener: 'App\\Listener', attemptNumber: 5, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), - ); + )); $this->tracker->processNextDue(function () use ($event): void { $this->tracker->markFailedPermanently( @@ -186,13 +187,13 @@ public function test_process_next_due_commits_handler_side_effects(): void public function test_process_next_due_rolls_back_when_handler_throws(): void { $event = $this->insertEvent(); - $this->tracker->schedule( + $this->tracker->schedule(new RedeliveryRequest( event: $event, listener: 'App\\Listener', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), - ); + )); try { $this->tracker->processNextDue(function () use ($event): void { @@ -228,13 +229,13 @@ public function test_retry_now_re_queues_a_permanently_failed_row_preserving_att { $event = $this->insertEvent(); - $this->tracker->schedule( + $this->tracker->schedule(new RedeliveryRequest( event: $event, listener: 'App\\Listener', attemptNumber: 5, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), - ); + )); $this->tracker->markFailedPermanently($event->id, 'App\\Listener', new RuntimeException('final')); $this->tracker->retryNow($event->id, 'App\\Listener'); diff --git a/tests/Unit/InMemoryRedeliveryTrackerTest.php b/tests/Unit/InMemoryRedeliveryTrackerTest.php index 39061ba..7b50ad7 100644 --- a/tests/Unit/InMemoryRedeliveryTrackerTest.php +++ b/tests/Unit/InMemoryRedeliveryTrackerTest.php @@ -8,6 +8,7 @@ use Test\Vesper\Tool\Event\_Fixtures\TestEventFactory; use Vesper\Tool\Event\Infrastructure\InMemoryRedeliveryTracker; use Vesper\Tool\Event\RawEvent; +use Vesper\Tool\Event\RedeliveryRequest; class InMemoryRedeliveryTrackerTest extends TestCase { @@ -27,13 +28,13 @@ public function test_next_due_returns_null_when_empty(): void public function test_schedule_makes_redelivery_pickable_when_time_passes(): void { - $this->tracker->schedule( + $this->tracker->schedule(new RedeliveryRequest( event: $this->event, listener: 'App\\SomeListener', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), - ); + )); $due = $this->tracker->nextDue(); @@ -45,13 +46,13 @@ public function test_schedule_makes_redelivery_pickable_when_time_passes(): void public function test_next_due_excludes_rows_whose_retry_time_is_in_the_future(): void { - $this->tracker->schedule( + $this->tracker->schedule(new RedeliveryRequest( event: $this->event, listener: 'App\\SomeListener', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->addMinute(), lastError: new RuntimeException('boom'), - ); + )); self::assertNull($this->tracker->nextDue()); } @@ -61,20 +62,20 @@ public function test_next_due_returns_earliest_scheduled_first(): void $eventEarly = TestEventFactory::retrieveOrderPlaced(['n' => 1]); $eventLate = TestEventFactory::retrieveOrderPlaced(['n' => 2]); - $this->tracker->schedule( + $this->tracker->schedule(new RedeliveryRequest( event: $eventLate, listener: 'App\\Late', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), - ); - $this->tracker->schedule( + )); + $this->tracker->schedule(new RedeliveryRequest( event: $eventEarly, listener: 'App\\Early', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subMinute(), lastError: new RuntimeException('boom'), - ); + )); $due = $this->tracker->nextDue(); @@ -84,13 +85,13 @@ public function test_next_due_returns_earliest_scheduled_first(): void public function test_mark_succeeded_removes_row_from_due_queue(): void { - $this->tracker->schedule( + $this->tracker->schedule(new RedeliveryRequest( event: $this->event, listener: 'App\\Listener', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), - ); + )); $this->tracker->markSucceeded($this->event->id, 'App\\Listener'); @@ -99,13 +100,13 @@ public function test_mark_succeeded_removes_row_from_due_queue(): void public function test_mark_failed_permanently_removes_row_from_due_queue(): void { - $this->tracker->schedule( + $this->tracker->schedule(new RedeliveryRequest( event: $this->event, listener: 'App\\Listener', attemptNumber: 5, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), - ); + )); $this->tracker->markFailedPermanently($this->event->id, 'App\\Listener', new RuntimeException('final')); @@ -114,13 +115,13 @@ public function test_mark_failed_permanently_removes_row_from_due_queue(): void public function test_retry_now_re_queues_a_permanently_failed_row(): void { - $this->tracker->schedule( + $this->tracker->schedule(new RedeliveryRequest( event: $this->event, listener: 'App\\Listener', attemptNumber: 5, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), - ); + )); $this->tracker->markFailedPermanently($this->event->id, 'App\\Listener', new RuntimeException('final')); $this->tracker->retryNow($this->event->id, 'App\\Listener'); @@ -134,20 +135,20 @@ public function test_retry_now_re_queues_a_permanently_failed_row(): void public function test_schedule_is_idempotent_on_event_id_listener(): void { - $this->tracker->schedule( + $this->tracker->schedule(new RedeliveryRequest( event: $this->event, listener: 'App\\Listener', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->addMinute(), lastError: new RuntimeException('boom'), - ); - $this->tracker->schedule( + )); + $this->tracker->schedule(new RedeliveryRequest( event: $this->event, listener: 'App\\Listener', attemptNumber: 2, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('again'), - ); + )); $due = $this->tracker->nextDue(); diff --git a/tests/Unit/RedeliveryRequestTest.php b/tests/Unit/RedeliveryRequestTest.php new file mode 100644 index 0000000..2664b3f --- /dev/null +++ b/tests/Unit/RedeliveryRequestTest.php @@ -0,0 +1,88 @@ +addMinute(); + + $request = new RedeliveryRequest( + event: $event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: $nextRetryAt, + lastError: $error, + ); + + self::assertSame($event, $request->event); + self::assertSame('App\\Listener', $request->listener); + self::assertSame(1, $request->attemptNumber); + self::assertSame($nextRetryAt, $request->nextRetryAt); + self::assertSame($error, $request->lastError); + } + + public function test_rejects_attempt_number_below_one(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('attemptNumber must be at least 1'); + + new RedeliveryRequest( + event: TestEventFactory::retrieveOrderPlaced(), + listener: 'App\\Listener', + attemptNumber: 0, + nextRetryAt: CarbonImmutable::now(), + lastError: new RuntimeException('boom'), + ); + } + + public function test_rejects_negative_attempt_number(): void + { + $this->expectException(InvalidArgumentException::class); + + new RedeliveryRequest( + event: TestEventFactory::retrieveOrderPlaced(), + listener: 'App\\Listener', + attemptNumber: -3, + nextRetryAt: CarbonImmutable::now(), + lastError: new RuntimeException('boom'), + ); + } + + public function test_rejects_empty_listener(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('listener must not be empty'); + + new RedeliveryRequest( + event: TestEventFactory::retrieveOrderPlaced(), + listener: '', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now(), + lastError: new RuntimeException('boom'), + ); + } + + public function test_rejects_whitespace_only_listener(): void + { + $this->expectException(InvalidArgumentException::class); + + new RedeliveryRequest( + event: TestEventFactory::retrieveOrderPlaced(), + listener: " \t\n", + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now(), + lastError: new RuntimeException('boom'), + ); + } +} diff --git a/tests/Unit/SequentialEventProcessorTest.php b/tests/Unit/SequentialEventProcessorTest.php index 9688d93..f4fffc9 100644 --- a/tests/Unit/SequentialEventProcessorTest.php +++ b/tests/Unit/SequentialEventProcessorTest.php @@ -15,6 +15,7 @@ use Vesper\Tool\Event\Infrastructure\InMemoryEventStore; use Vesper\Tool\Event\Infrastructure\InMemoryRedeliveryTracker; use Vesper\Tool\Event\Infrastructure\SequentialEventProcessor; +use Vesper\Tool\Event\RedeliveryRequest; use Vesper\Tool\Event\RedeliveryTracker; use Vesper\Tool\Event\Retry\RetryPolicy; @@ -254,13 +255,13 @@ public function test_persists_failure_to_tracker_for_later_retry(): void $tracker = $this->createMock(RedeliveryTracker::class); $tracker->expects($this->once()) ->method('schedule') - ->with( - $this->callback(fn($e) => $e->id === $event->id), - 'Closure', - 1, - $this->isInstanceOf(CarbonImmutable::class), - $exception, - ); + ->with($this->callback( + fn(RedeliveryRequest $r): bool => + $r->event->id === $event->id + && $r->listener === 'Closure' + && $r->attemptNumber === 1 + && $r->lastError === $exception, + )); $tracker->method('nextDue')->willReturn(null); $processor = new SequentialEventProcessor( @@ -363,13 +364,13 @@ public function test_process_next_redelivery_dispatches_due_redelivery(): void $event = TestEventFactory::retrieveOrderPlaced(); $tracker = new InMemoryRedeliveryTracker(); - $tracker->schedule( + $tracker->schedule(new RedeliveryRequest( event: $event, listener: 'Closure', attemptNumber: 2, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('earlier failure'), - ); + )); $received = null; $this->subscribers->subscribe('order.placed', function (object $e) use (&$received) { diff --git a/tests/Unit/SilentSequentialEventProcessorTest.php b/tests/Unit/SilentSequentialEventProcessorTest.php index 459d946..b142b31 100644 --- a/tests/Unit/SilentSequentialEventProcessorTest.php +++ b/tests/Unit/SilentSequentialEventProcessorTest.php @@ -14,6 +14,7 @@ use Vesper\Tool\Event\Infrastructure\InMemoryEventStore; use Vesper\Tool\Event\Infrastructure\InMemoryRedeliveryTracker; use Vesper\Tool\Event\Infrastructure\SilentSequentialEventProcessor; +use Vesper\Tool\Event\RedeliveryRequest; use Vesper\Tool\Event\RedeliveryTracker; use Vesper\Tool\Event\Retry\RetryPolicy; @@ -261,13 +262,13 @@ public function test_process_next_redelivery_logs_and_marks_failed_permanently_o { $event = TestEventFactory::retrieveOrderPlaced(); $tracker = new InMemoryRedeliveryTracker(); - $tracker->schedule( + $tracker->schedule(new RedeliveryRequest( event: $event, listener: 'Closure', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('previous failure'), - ); + )); $exception = new RuntimeException('still broken'); $this->subscribers->subscribe('order.placed', function () use ($exception) { @@ -307,13 +308,13 @@ public function test_process_next_redelivery_silently_reschedules_when_retries_r { $event = TestEventFactory::retrieveOrderPlaced(); $tracker = new InMemoryRedeliveryTracker(); - $tracker->schedule( + $tracker->schedule(new RedeliveryRequest( event: $event, listener: 'Closure', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('previous failure'), - ); + )); $this->subscribers->subscribe('order.placed', function () { throw new RuntimeException('still broken'); From b4b50e8aeeec69d38871288d63c9262430c6a0b4 Mon Sep 17 00:00:00 2001 From: Jason Viljoen Date: Fri, 15 May 2026 23:05:01 +0200 Subject: [PATCH 11/16] refactor: typed Redelivery entity for in-memory tracker storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The InMemoryRedeliveryTracker stored its rows as an array-of-shape with an 8-field PHPDoc annotation that nobody enjoyed reading. Replace it with a Redelivery domain entity that mirrors RawEvent's pattern: private constructor, static factories (fromRequest / retrieve), named transition methods for each lifecycle step (rescheduled, markedFailedPermanently, markedSucceeded, queuedForImmediateRetry). Each tracker mutation is now a one-liner — `$this->rows[$key] = $this->rows[$key]->markedFailedPermanently($e)` instead of three positional array writes — and the storage type is `array` rather than the eight-field shape annotation. RedeliveryStatus moves alongside from Infrastructure to the root namespace, matching RawEventStatus's location: Redelivery (root) now carries it, so having the enum sit in Infrastructure would invert the usual dependency direction. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../InMemoryRedeliveryTracker.php | 63 +++------ src/Infrastructure/SqlRedeliveryTracker.php | 1 + src/Redelivery.php | 133 ++++++++++++++++++ src/{Infrastructure => }/RedeliveryStatus.php | 2 +- 4 files changed, 158 insertions(+), 41 deletions(-) create mode 100644 src/Redelivery.php rename src/{Infrastructure => }/RedeliveryStatus.php (76%) diff --git a/src/Infrastructure/InMemoryRedeliveryTracker.php b/src/Infrastructure/InMemoryRedeliveryTracker.php index 81d0ff8..72b9210 100644 --- a/src/Infrastructure/InMemoryRedeliveryTracker.php +++ b/src/Infrastructure/InMemoryRedeliveryTracker.php @@ -6,31 +6,24 @@ use Override; use Throwable; use Vesper\Tool\Event\DueRedelivery; -use Vesper\Tool\Event\RawEvent; +use Vesper\Tool\Event\Redelivery; use Vesper\Tool\Event\RedeliveryRequest; +use Vesper\Tool\Event\RedeliveryStatus; use Vesper\Tool\Event\RedeliveryTracker; class InMemoryRedeliveryTracker implements RedeliveryTracker { - /** @var array */ + /** @var array */ private array $rows = []; #[Override] public function schedule(RedeliveryRequest $request): void { $key = self::key($request->event->id, $request->listener); - $now = CarbonImmutable::now(); - $this->rows[$key] = [ - 'event' => $request->event, - 'listener' => $request->listener, - 'status' => RedeliveryStatus::PendingRetry, - 'attempt_number' => $request->attemptNumber, - 'next_retry_at' => $request->nextRetryAt, - 'last_error' => self::formatError($request->lastError), - 'created_at' => $this->rows[$key]['created_at'] ?? $now, - 'updated_at' => $now, - ]; + $this->rows[$key] = isset($this->rows[$key]) + ? $this->rows[$key]->rescheduled($request) + : Redelivery::fromRequest($request); } #[Override] @@ -40,13 +33,13 @@ public function nextDue(): ?DueRedelivery $candidate = null; foreach ($this->rows as $row) { - if ($row['status'] !== RedeliveryStatus::PendingRetry) { + if ($row->status !== RedeliveryStatus::PendingRetry) { continue; } - if ($row['next_retry_at']->greaterThan($now)) { + if ($row->nextRetryAt->greaterThan($now)) { continue; } - if ($candidate === null || $row['next_retry_at']->lessThan($candidate['next_retry_at'])) { + if ($candidate === null || $row->nextRetryAt->lessThan($candidate->nextRetryAt)) { $candidate = $row; } } @@ -56,28 +49,26 @@ public function nextDue(): ?DueRedelivery } return new DueRedelivery( - event: $candidate['event'], - listener: $candidate['listener'], - attemptNumber: $candidate['attempt_number'], + event: $candidate->event, + listener: $candidate->listener, + attemptNumber: $candidate->attemptNumber, ); } #[Override] - public function markFailedPermanently(string $eventId, string $listener, Throwable $lastError): void + public function processNextDue(callable $handler): void { - $key = self::key($eventId, $listener); + $due = $this->nextDue(); - if (!isset($this->rows[$key])) { + if ($due === null) { return; } - $this->rows[$key]['status'] = RedeliveryStatus::Failed; - $this->rows[$key]['last_error'] = self::formatError($lastError); - $this->rows[$key]['updated_at'] = CarbonImmutable::now(); + $handler($due); } #[Override] - public function markSucceeded(string $eventId, string $listener): void + public function markFailedPermanently(string $eventId, string $listener, Throwable $lastError): void { $key = self::key($eventId, $listener); @@ -85,20 +76,19 @@ public function markSucceeded(string $eventId, string $listener): void return; } - $this->rows[$key]['status'] = RedeliveryStatus::Succeeded; - $this->rows[$key]['updated_at'] = CarbonImmutable::now(); + $this->rows[$key] = $this->rows[$key]->markedFailedPermanently($lastError); } #[Override] - public function processNextDue(callable $handler): void + public function markSucceeded(string $eventId, string $listener): void { - $due = $this->nextDue(); + $key = self::key($eventId, $listener); - if ($due === null) { + if (!isset($this->rows[$key])) { return; } - $handler($due); + $this->rows[$key] = $this->rows[$key]->markedSucceeded(); } #[Override] @@ -110,18 +100,11 @@ public function retryNow(string $eventId, string $listener): void return; } - $this->rows[$key]['status'] = RedeliveryStatus::PendingRetry; - $this->rows[$key]['next_retry_at'] = CarbonImmutable::now(); - $this->rows[$key]['updated_at'] = CarbonImmutable::now(); + $this->rows[$key] = $this->rows[$key]->queuedForImmediateRetry(); } private static function key(string $eventId, string $listener): string { return $eventId . '|' . $listener; } - - private static function formatError(Throwable $error): string - { - return $error::class . ': ' . $error->getMessage(); - } } diff --git a/src/Infrastructure/SqlRedeliveryTracker.php b/src/Infrastructure/SqlRedeliveryTracker.php index c9b0105..870333f 100644 --- a/src/Infrastructure/SqlRedeliveryTracker.php +++ b/src/Infrastructure/SqlRedeliveryTracker.php @@ -14,6 +14,7 @@ use Vesper\Tool\Event\RawEvent; use Vesper\Tool\Event\RawEventStatus; use Vesper\Tool\Event\RedeliveryRequest; +use Vesper\Tool\Event\RedeliveryStatus; use Vesper\Tool\Event\RedeliveryTracker; readonly class SqlRedeliveryTracker implements RedeliveryTracker diff --git a/src/Redelivery.php b/src/Redelivery.php new file mode 100644 index 0000000..e13a00d --- /dev/null +++ b/src/Redelivery.php @@ -0,0 +1,133 @@ +event, + listener: $request->listener, + status: RedeliveryStatus::PendingRetry, + attemptNumber: $request->attemptNumber, + nextRetryAt: $request->nextRetryAt, + lastError: self::formatError($request->lastError), + createdAt: $now, + updatedAt: $now, + ); + } + + /** Reconstruct from persisted storage. */ + public static function retrieve( + RawEvent $event, + string $listener, + RedeliveryStatus $status, + int $attemptNumber, + CarbonImmutable $nextRetryAt, + string $lastError, + CarbonImmutable $createdAt, + CarbonImmutable $updatedAt, + ): self { + return new self( + event: $event, + listener: $listener, + status: $status, + attemptNumber: $attemptNumber, + nextRetryAt: $nextRetryAt, + lastError: $lastError, + createdAt: $createdAt, + updatedAt: $updatedAt, + ); + } + + /** Re-schedule an existing row: bumps attempt count, resets status to pending_retry, preserves createdAt. */ + public function rescheduled(RedeliveryRequest $request): self + { + return new self( + event: $this->event, + listener: $this->listener, + status: RedeliveryStatus::PendingRetry, + attemptNumber: $request->attemptNumber, + nextRetryAt: $request->nextRetryAt, + lastError: self::formatError($request->lastError), + createdAt: $this->createdAt, + updatedAt: CarbonImmutable::now(), + ); + } + + public function markedFailedPermanently(Throwable $lastError): self + { + return new self( + event: $this->event, + listener: $this->listener, + status: RedeliveryStatus::Failed, + attemptNumber: $this->attemptNumber, + nextRetryAt: $this->nextRetryAt, + lastError: self::formatError($lastError), + createdAt: $this->createdAt, + updatedAt: CarbonImmutable::now(), + ); + } + + public function markedSucceeded(): self + { + return new self( + event: $this->event, + listener: $this->listener, + status: RedeliveryStatus::Succeeded, + attemptNumber: $this->attemptNumber, + nextRetryAt: $this->nextRetryAt, + lastError: $this->lastError, + createdAt: $this->createdAt, + updatedAt: CarbonImmutable::now(), + ); + } + + /** Reset to pending_retry with next_retry_at = now(), preserving attempt count. */ + public function queuedForImmediateRetry(): self + { + $now = CarbonImmutable::now(); + + return new self( + event: $this->event, + listener: $this->listener, + status: RedeliveryStatus::PendingRetry, + attemptNumber: $this->attemptNumber, + nextRetryAt: $now, + lastError: $this->lastError, + createdAt: $this->createdAt, + updatedAt: $now, + ); + } + + private static function formatError(Throwable $error): string + { + return $error::class . ': ' . $error->getMessage(); + } +} diff --git a/src/Infrastructure/RedeliveryStatus.php b/src/RedeliveryStatus.php similarity index 76% rename from src/Infrastructure/RedeliveryStatus.php rename to src/RedeliveryStatus.php index 04405ff..fc31b7a 100644 --- a/src/Infrastructure/RedeliveryStatus.php +++ b/src/RedeliveryStatus.php @@ -1,6 +1,6 @@ Date: Fri, 15 May 2026 23:12:27 +0200 Subject: [PATCH 12/16] docs: strip WHAT-style comments from src/ Cull comments that just restate the method name, the obvious @throws annotations from any PDO/json method, and per-method docs on the new Redelivery transition methods where the names already say it. Kept: - WHY-style commentary that explains a non-obvious decision (the markProcessed guard, the deferred-throw rationale in processNextRedelivery, the handler-must-not-throw contract on processNextDue, recoverStuckEvents' role as a separate cron job) - @param / @var generics and shape annotations PHPStan relies on - InMemoryEventStore::markProcessed's "no-op because" comment, which explains an unusual implementation Net: ~110 lines of comment fluff removed across 10 files, 183 tests still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/DueRedelivery.php | 5 +- src/EventStore.php | 22 ++------ .../SequentialEventProcessor.php | 15 ++---- .../SilentSequentialEventProcessor.php | 4 -- src/Infrastructure/SqlEventStore.php | 40 +++------------ src/Infrastructure/SqlRedeliveryTracker.php | 2 - src/Redelivery.php | 11 ---- src/RedeliveryRequest.php | 6 --- src/RedeliveryTracker.php | 51 ++++--------------- src/Retry/RetryPolicy.php | 9 +--- 10 files changed, 28 insertions(+), 137 deletions(-) diff --git a/src/DueRedelivery.php b/src/DueRedelivery.php index 8c898c8..1632cdb 100644 --- a/src/DueRedelivery.php +++ b/src/DueRedelivery.php @@ -4,10 +4,7 @@ readonly class DueRedelivery { - /** - * @param string $listener class-string of the listener, or "Closure" for anonymous - * @param int $attemptNumber attempts already made; the upcoming retry will be (this + 1) - */ + /** @param string $listener class-string of the listener, or "Closure" for anonymous */ public function __construct( public RawEvent $event, public string $listener, diff --git a/src/EventStore.php b/src/EventStore.php index 9ecb1f0..6e14d8b 100644 --- a/src/EventStore.php +++ b/src/EventStore.php @@ -4,29 +4,15 @@ interface EventStore { - /** - * Persist a new event in the outbox. Should be called inside the caller's - * transaction so the event commits or rolls back together with the - * business operation that produced it. - */ + /** Call inside the caller's business transaction so event + business change commit together. */ public function add(RawEvent $event): void; /** - * Claim the next pending event for this worker, transitioning it from - * `pending` to `processing`. Returns null when nothing is due. - * - * The returned event remains in `processing` state until the processor - * calls markProcessed() once every listener has settled. A worker that - * dies between next() and markProcessed() leaves the row in `processing`, - * which is intentional — it lets a future stuck-events monitor recover it. + * Claim the next pending event, transitioning it from `pending` to `processing`. Returns null + * when nothing is due. A worker that dies before calling markProcessed() leaves the row in + * `processing` — call SqlEventStore::recoverStuckEvents() from a separate cron to recover it. */ public function next(): ?RawEvent; - /** - * Advance the given event from `processing` to `processed`, signalling that - * every listener has either succeeded, been persisted to the redelivery - * queue, been swallowed by the ignored-exceptions list, or been marked - * permanently failed. - */ public function markProcessed(string $eventId): void; } diff --git a/src/Infrastructure/SequentialEventProcessor.php b/src/Infrastructure/SequentialEventProcessor.php index 9917080..262ee08 100644 --- a/src/Infrastructure/SequentialEventProcessor.php +++ b/src/Infrastructure/SequentialEventProcessor.php @@ -44,15 +44,8 @@ public function __construct( } /** - * Dispatch the next due redelivery, if any. Call this from a separate - * scheduled job so persisted retries are picked up independently of - * the main outbox worker. - * - * The tracker holds the SELECT ... FOR UPDATE row lock through dispatch - * via processNextDue(), so concurrent cron workers cannot pick up the - * same row. Dispatch's fail-fast throw is deferred until after the - * transaction commits so the schedule / markFailedPermanently side - * effects persist. + * Dispatch's fail-fast throw is deferred until processNextDue() has committed, so the + * schedule / markFailedPermanently side effects persist instead of getting rolled back. */ public function processNextRedelivery(): void { @@ -96,7 +89,7 @@ private function findRegisteredSubscriber(string $eventName, string $listenerKey return null; } - /** @param int $attemptsMade attempts already made before this call (0 for fresh, ≥1 from redelivery) */ + /** @param int $attemptsMade attempts already made (0 from process(), ≥1 from processNextRedelivery()) */ protected function dispatch(RawEvent $event, callable|string $subscriber, int $attemptsMade = 0): void { $callable = $this->resolver->resolve($subscriber); @@ -133,7 +126,7 @@ protected function dispatch(RawEvent $event, callable|string $subscriber, int $a } } - /** Called when a listener's failure can no longer be retried. Subclasses can extend (e.g. log). */ + /** Hook for subclasses; called once a listener's failure can no longer be retried. */ protected function onPermanentFailure(RawEvent $event, callable|string $subscriber, Throwable $error): void { $this->redeliveryTracker?->markFailedPermanently($event->id, $this->listenerKey($subscriber), $error); diff --git a/src/Infrastructure/SilentSequentialEventProcessor.php b/src/Infrastructure/SilentSequentialEventProcessor.php index b18311c..dd6c23e 100644 --- a/src/Infrastructure/SilentSequentialEventProcessor.php +++ b/src/Infrastructure/SilentSequentialEventProcessor.php @@ -44,10 +44,6 @@ protected function dispatch(RawEvent $event, callable|string $subscriber, int $a try { parent::dispatch($event, $subscriber, $attemptsMade); } catch (Throwable $exception) { - // Parent dispatch only throws after incrementing its local attempt counter and - // either persisting markFailedPermanently or determining no tracker is configured — - // so the attempt that just failed is $attemptsMade + 1 (the value entering this call - // plus the attempt parent dispatch just completed). $this->logger->error('Failed to dispatch event to listener.', [ 'event' => $event->name, 'event_id' => $event->id, diff --git a/src/Infrastructure/SqlEventStore.php b/src/Infrastructure/SqlEventStore.php index 4463aba..efac156 100644 --- a/src/Infrastructure/SqlEventStore.php +++ b/src/Infrastructure/SqlEventStore.php @@ -4,10 +4,8 @@ use Carbon\CarbonImmutable; use Carbon\CarbonInterval; -use JsonException; use Override; use PDO; -use PDOException; use RuntimeException; use Throwable; use Vesper\Tool\Event\EventStore; @@ -23,13 +21,6 @@ public function __construct(private PDO $connection) $this->ensureOutboxSchema(); } - /** - * Add a new event to the outbox. Inserts both the event row and a - * 'pending' audit row in the caller's transaction. - * - * @throws JsonException - * @throws PDOException - */ #[Override] public function add(RawEvent $event): void { @@ -52,14 +43,6 @@ public function add(RawEvent $event): void $this->insertStatusAudit($event->id, $event->status->value); } - /** - * Claim the next pending event for this worker (worker-safe via FOR UPDATE - * SKIP LOCKED on MySQL). Transitions the row from `pending` to `processing` - * inside a single transaction together with the audit row insert. - * - * @throws JsonException - * @throws PDOException - */ #[Override] public function next(): ?RawEvent { @@ -99,14 +82,7 @@ public function next(): ?RawEvent } } - /** - * Advance an event from `processing` to `processed`. Inserts the matching - * audit row in the same transaction. The UPDATE is guarded with - * `status = 'processing'` so a stuck row that's already been recovered by - * a sweep won't be silently overwritten. - * - * @throws PDOException - */ + /** UPDATE is guarded by status='processing' so a row a sweep already reclaimed is not overwritten. */ #[Override] public function markProcessed(string $eventId): void { @@ -132,15 +108,11 @@ public function markProcessed(string $eventId): void } /** - * Reset events wedged in `processing` back to `pending` so a worker can claim them again. - * A row counts as stuck when its most recent `processing` audit entry is older than - * $olderThan. Writes a `pending` audit row tagged "Recovered from stuck processing state" - * for each recovery so dashboards can distinguish organic vs. recovered transitions. - * Returns the number of events recovered. - * - * Call from a separate scheduled job; idempotent, safe to run alongside the main worker. - * - * @throws PDOException + * 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 { diff --git a/src/Infrastructure/SqlRedeliveryTracker.php b/src/Infrastructure/SqlRedeliveryTracker.php index 870333f..3cfe80f 100644 --- a/src/Infrastructure/SqlRedeliveryTracker.php +++ b/src/Infrastructure/SqlRedeliveryTracker.php @@ -3,7 +3,6 @@ namespace Vesper\Tool\Event\Infrastructure; use Carbon\CarbonImmutable; -use JsonException; use Override; use PDO; use RuntimeException; @@ -69,7 +68,6 @@ public function schedule(RedeliveryRequest $request): void ]); } - /** @throws JsonException */ #[Override] public function nextDue(): ?DueRedelivery { diff --git a/src/Redelivery.php b/src/Redelivery.php index e13a00d..159bb6d 100644 --- a/src/Redelivery.php +++ b/src/Redelivery.php @@ -5,13 +5,6 @@ use Carbon\CarbonImmutable; use Throwable; -/** - * The canonical domain entity for a per-(event, listener) redelivery row — - * the in-memory analogue of an event_outbox_redelivery SQL row. - * - * Fully immutable: lifecycle transitions return a new instance with the - * relevant fields updated, mirroring the RawEvent / RawEventStatus pattern. - */ readonly class Redelivery { private function __construct( @@ -26,7 +19,6 @@ private function __construct( ) { } - /** Build a fresh pending_retry row from a schedule request. */ public static function fromRequest(RedeliveryRequest $request): self { $now = CarbonImmutable::now(); @@ -43,7 +35,6 @@ public static function fromRequest(RedeliveryRequest $request): self ); } - /** Reconstruct from persisted storage. */ public static function retrieve( RawEvent $event, string $listener, @@ -66,7 +57,6 @@ public static function retrieve( ); } - /** Re-schedule an existing row: bumps attempt count, resets status to pending_retry, preserves createdAt. */ public function rescheduled(RedeliveryRequest $request): self { return new self( @@ -109,7 +99,6 @@ public function markedSucceeded(): self ); } - /** Reset to pending_retry with next_retry_at = now(), preserving attempt count. */ public function queuedForImmediateRetry(): self { $now = CarbonImmutable::now(); diff --git a/src/RedeliveryRequest.php b/src/RedeliveryRequest.php index 61971a2..8077283 100644 --- a/src/RedeliveryRequest.php +++ b/src/RedeliveryRequest.php @@ -6,12 +6,6 @@ use InvalidArgumentException; use Throwable; -/** - * Input to RedeliveryTracker::schedule() — the per-(event, listener) state captured - * at the moment a dispatch failed and the retry policy chose to schedule another - * attempt. Invariants are enforced in the constructor so callers can't quietly - * persist a nonsensical row (attempt zero, empty listener identifier, etc.). - */ readonly class RedeliveryRequest { public function __construct( diff --git a/src/RedeliveryTracker.php b/src/RedeliveryTracker.php index 5e58152..082572a 100644 --- a/src/RedeliveryTracker.php +++ b/src/RedeliveryTracker.php @@ -6,63 +6,34 @@ interface RedeliveryTracker { - /** - * Persist a failed dispatch so it can be retried later. - * - * Idempotent on (event id, listener): scheduling again updates the existing - * row's attempt_number, next_retry_at, and last_error. The request carries - * the full RawEvent (not just the id) so in-memory implementations can keep - * their own copy for later rehydration; SQL implementations only need the - * event id and join event_outbox to look the rest up. - */ + /** Idempotent on (event id, listener): re-scheduling updates the existing row. */ public function schedule(RedeliveryRequest $request): void; /** - * Pick up the next due redelivery (worker-safe; locks the row on MySQL). - * Returns null when none are due. - * - * Note: callers that intend to dispatch the returned redelivery should use - * processNextDue() instead — that wraps the read and the subsequent state - * transition in a single transaction, so the row lock is held through the - * dispatch and concurrent workers cannot pick up the same row. + * Returns the next due redelivery, or null when none is due. Callers that intend to + * dispatch the returned row should use processNextDue() instead — that holds the + * row lock through the dispatch so concurrent workers cannot claim the same row. */ public function nextDue(): ?DueRedelivery; /** - * Atomically claim the next due redelivery and pass it to $handler. SQL - * implementations open a transaction so the row lock acquired by nextDue() - * is held through the handler call, preventing concurrent workers from - * picking up the same row. - * - * The handler is expected to drive dispatch and the resulting state - * transition (schedule / markSucceeded / markFailedPermanently). It must - * not throw — wrap dispatch in your own try/catch if you need to swallow - * or defer the exception, otherwise the transaction will roll back and - * those state transitions will be lost. + * Atomically claim the next due redelivery and pass it to $handler. The handler must + * not throw — if it does, the surrounding transaction rolls back and any + * schedule / markSucceeded / markFailedPermanently side effects are lost. Wrap + * dispatch in your own try/catch and defer the throw past this call if you need + * fail-fast semantics. * * @param callable(DueRedelivery): void $handler */ public function processNextDue(callable $handler): void; - /** - * Mark a redelivery as permanently failed — no further automatic attempts. - * The row stays in the table so operators can inspect it and call retryNow() - * if appropriate. - */ public function markFailedPermanently(string $eventId, string $listener, Throwable $lastError): void; - /** - * Mark a redelivery as succeeded. The row stays in the table for audit; - * future nextDue() calls will not return it. - */ public function markSucceeded(string $eventId, string $listener): void; /** - * Application-side admin API: re-queue a redelivery for immediate retry. - * Sets status to pending_retry and next_retry_at to now(), regardless of the - * row's current status (including 'failed'). Attempt count is preserved so - * the retry policy's max-attempts ceiling still applies on subsequent - * automatic failures. + * Admin API: re-queue for immediate retry regardless of current status. Attempt count + * is preserved, so the retry policy's max-attempts ceiling still applies. */ public function retryNow(string $eventId, string $listener): void; } diff --git a/src/Retry/RetryPolicy.php b/src/Retry/RetryPolicy.php index 904ddce..c9d0bd7 100644 --- a/src/Retry/RetryPolicy.php +++ b/src/Retry/RetryPolicy.php @@ -7,13 +7,8 @@ interface RetryPolicy { /** - * Return the absolute timestamp of the next retry attempt, or null when no - * further retries should be made. - * - * $previousAttempt = 1 means "the first attempt just failed; when should - * attempt 2 run?" The processor persists the returned timestamp in the - * redelivery table; the cron-driven `processNextRedelivery()` flow picks - * it up when it comes due. + * Return the absolute timestamp of the next retry, or null when no further retries should run. + * $previousAttempt = 1 means "the first attempt just failed; when should attempt 2 run?" */ public function nextRetryAt(int $previousAttempt): ?CarbonImmutable; } From 31a1731f5d2658c28e9cda11604b764179da766b Mon Sep 17 00:00:00 2001 From: Jason Viljoen Date: Fri, 15 May 2026 23:33:28 +0200 Subject: [PATCH 13/16] refactor: redelivery follows the event-store/processor split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RedeliveryTracker interface mixed storage (schedule, nextDue, mark*) with processing-flow concerns (processNextDue(callable) — a transactional callback that wrapped dispatch). Refactor to mirror the event-store/event-processor separation exactly: - Rename RedeliveryTracker → RedeliveryStore (with both implementations and the constructor param following: $redeliveryTracker → $redeliveryStore) - Rename nextDue() → next() — now also claims the row atomically, mirroring EventStore::next()'s pending → processing transition - New RedeliveryStatus::Dispatching for the claimed-by-a-worker state - Drop RedeliveryStore::processNextDue(callable) entirely — the claim-by- status pattern handles concurrency, so the callback wrapper is unneeded - markSucceeded / markFailedPermanently now guarded by status = 'dispatching' so a stale worker can't overwrite a row a sweep already reclaimed - Add SqlRedeliveryStore::recoverStuckRedeliveries(CarbonInterval): int — sibling to recoverStuckEvents, sweeps wedged 'dispatching' rows back to 'pending_retry'. Adds a fourth cron task to the README's Scheduled jobs overview. SequentialEventProcessor::processNextRedelivery() is now a straightforward claim → dispatch → finalise flow with no deferred-exception dance, because each storage call is its own short transaction (just like the event side). The new state machine is symmetric: event_outbox: pending → processing → processed event_outbox_redelivery: pending_retry → dispatching → succeeded | failed Tests updated to reflect the new mark/markFailedPermanently semantics (must claim via next() before they apply) and the new sweeper. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 75 ++++--- ROADMAP.md | 2 +- ...racker.php => InMemoryRedeliveryStore.php} | 30 +-- .../SequentialEventProcessor.php | 52 ++--- .../SilentSequentialEventProcessor.php | 6 +- ...veryTracker.php => SqlRedeliveryStore.php} | 201 +++++++++++------- src/Redelivery.php | 14 ++ src/RedeliveryStatus.php | 1 + src/RedeliveryStore.php | 32 +++ src/RedeliveryTracker.php | 39 ---- ...kerTest.php => SqlRedeliveryStoreTest.php} | 196 +++++++++-------- ...st.php => InMemoryRedeliveryStoreTest.php} | 104 ++++++--- tests/Unit/SequentialEventProcessorTest.php | 40 ++-- .../SilentSequentialEventProcessorTest.php | 28 +-- 14 files changed, 467 insertions(+), 353 deletions(-) rename src/Infrastructure/{InMemoryRedeliveryTracker.php => InMemoryRedeliveryStore.php} (79%) rename src/Infrastructure/{SqlRedeliveryTracker.php => SqlRedeliveryStore.php} (66%) create mode 100644 src/RedeliveryStore.php delete mode 100644 src/RedeliveryTracker.php rename tests/Feature/{SqlRedeliveryTrackerTest.php => SqlRedeliveryStoreTest.php} (50%) rename tests/Unit/{InMemoryRedeliveryTrackerTest.php => InMemoryRedeliveryStoreTest.php} (53%) diff --git a/README.md b/README.md index 0e8d26a..e5d6f09 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,12 @@ Events are first written to a durable store (in-memory or SQL), then dispatched - **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 transition `pending → processing → processed`, with the intermediate state surviving worker crashes -- **Per-listener retries** — configurable `RetryPolicy` plus a persisted `RedeliveryTracker`; a single failing listener of an event is retried independently while the others continue +- **Per-listener retries** — configurable `RetryPolicy` plus a persisted `RedeliveryStore`; a single failing listener of an event is retried independently while the others continue - **Ignored exceptions** — pass a list of `Throwable` classes 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` and `SqlRedeliveryTracker` each create their 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 --- @@ -64,8 +64,8 @@ composer require hivesper/php-events | `RetryPolicy` | Interface — decides whether and when to retry a failed listener | | `NoRetryPolicy` | Default — never retries | | `ExponentialBackoffRetryPolicy` | Built-in — five attempts with `100ms / 500ms / 1min / 5min` backoff | -| `RedeliveryTracker` | Interface — persists per-(event, listener) retry state and exposes `retryNow()` for admin tooling | -| `InMemoryRedeliveryTracker` / `SqlRedeliveryTracker` | Tracker implementations | +| `RedeliveryStore` | Interface — persists per-(event, listener) retry state and exposes `retryNow()` for admin tooling | +| `InMemoryRedeliveryStore` / `SqlRedeliveryStore` | Tracker implementations | --- @@ -118,31 +118,31 @@ $processor->process($store); ## Scheduled jobs -A production deployment runs three scheduled jobs against this library. They touch +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 | |---|---|---| -| `$processor->process($store)` | 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). | -| `$processor->processNextRedelivery()` | Every minute, looped per tick to drain a batch | Picks up one due retry from `event_outbox_redelivery` and dispatches it. Loop the call within a single tick if you want to drain multiple. See [Automatic retry & failure tracking](#automatic-retry--failure-tracking). | -| `$store->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). | +| `$processor->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). | +| `$processor->processNextRedelivery()` | Every minute, looped per tick to drain a batch | Claims one due retry from `event_outbox_redelivery` (transitions it to `dispatching`) and dispatches it. Loop the call within a single tick if you want to drain multiple. 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. | -Only `recoverStuckEvents` is SQL-specific (lives on `SqlEventStore`). The other two -work against any `EventStore` / `RedeliveryTracker` implementation. +`recoverStuckEvents` and `recoverStuckRedeliveries` are SQL-specific (live on `SqlEventStore` / +`SqlRedeliveryStore`). The other two work against any `EventStore` / `RedeliveryStore` implementation. -The three jobs do not compete for the same rows: +The four jobs do not compete for the same rows: -- `process()` reads `event_outbox` rows in `pending` status; `recoverStuckEvents()` reads - rows in `processing` status — disjoint by the status filter. -- `processNextRedelivery()` reads the redelivery table, not the outbox. -- The lock from `SELECT ... FOR UPDATE SKIP LOCKED` is held through each job's transaction, - so concurrent workers running the same job never claim the same row either. +- `process()` reads `event_outbox` rows in `pending`; `recoverStuckEvents()` reads rows in `processing` — disjoint by status filter. +- `processNextRedelivery()` 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 `recoverStuckEvents` flips a row from -`processing` back to `pending` while a slow worker is still actively dispatching it, -another worker can pick it up via `process()` and re-fire its listeners. Choose a -threshold comfortably longer than your longest healthy dispatch, and make sure -listeners are idempotent (the at-least-once outbox contract requires that anyway). +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). --- @@ -362,7 +362,7 @@ CREATE TABLE event_outbox_status ( > MySQL workers use `SELECT … FOR UPDATE SKIP LOCKED` on the `event_outbox` table for safe > concurrent processing. -If you wire up a `RedeliveryTracker` (see [Automatic retry & failure tracking](#automatic-retry--failure-tracking)), +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 @@ -406,6 +406,19 @@ 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)); +``` + +Same threshold guidance and same idempotency requirement as `recoverStuckEvents`. + --- ## EventSubscriberMap @@ -615,25 +628,25 @@ $retryPolicy = new ExponentialBackoffRetryPolicy(); $retryPolicy = new ExponentialBackoffRetryPolicy(delaysMs: [50, 250, 1_000, 30_000]); ``` -Retries require a `RedeliveryTracker` to be configured — without one, any failure is treated +Retries require a `RedeliveryStore` to be configured — without one, any failure is treated as permanent and rethrown by `SequentialEventProcessor` (or logged and swallowed by `SilentSequentialEventProcessor`). ### Redelivery tracker -The `RedeliveryTracker` interface owns per-listener retry state — when an attempt failed, how +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: -- **`InMemoryRedeliveryTracker`** — array-backed, for tests and dev. -- **`SqlRedeliveryTracker`** — durable, MySQL/SQLite-compatible. Auto-creates its +- **`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\SqlRedeliveryTracker; +use Vesper\Tool\Event\Infrastructure\SqlRedeliveryStore; -$tracker = new SqlRedeliveryTracker($pdo); // schema created here if not present +$tracker = new SqlRedeliveryStore($pdo); // schema created here if not present ``` ### Wiring a processor with retries @@ -641,13 +654,13 @@ $tracker = new SqlRedeliveryTracker($pdo); // schema created here if not present ```php use Vesper\Tool\Event\Infrastructure\Retry\ExponentialBackoffRetryPolicy; use Vesper\Tool\Event\Infrastructure\SilentSequentialEventProcessor; -use Vesper\Tool\Event\Infrastructure\SqlRedeliveryTracker; +use Vesper\Tool\Event\Infrastructure\SqlRedeliveryStore; $processor = new SilentSequentialEventProcessor( subscribers: $subscribers, logger: $logger, retryPolicy: new ExponentialBackoffRetryPolicy(), - redeliveryTracker: new SqlRedeliveryTracker($pdo), + redeliveryStore: new SqlRedeliveryStore($pdo), ignoredExceptions: [ UserNotFoundException::class, InvalidPayloadException::class, @@ -706,7 +719,7 @@ differs slightly between processors: ### Re-triggering a failed dispatch -`RedeliveryTracker::retryNow($eventId, $listener)` re-queues a dispatch for immediate retry, +`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 diff --git a/ROADMAP.md b/ROADMAP.md index c57ec30..93852fd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -40,7 +40,7 @@ and there's no dashboard. ### Stack trace in `last_error` -`SqlRedeliveryTracker` records the failed dispatch's error as +`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. diff --git a/src/Infrastructure/InMemoryRedeliveryTracker.php b/src/Infrastructure/InMemoryRedeliveryStore.php similarity index 79% rename from src/Infrastructure/InMemoryRedeliveryTracker.php rename to src/Infrastructure/InMemoryRedeliveryStore.php index 72b9210..fd33211 100644 --- a/src/Infrastructure/InMemoryRedeliveryTracker.php +++ b/src/Infrastructure/InMemoryRedeliveryStore.php @@ -9,9 +9,9 @@ use Vesper\Tool\Event\Redelivery; use Vesper\Tool\Event\RedeliveryRequest; use Vesper\Tool\Event\RedeliveryStatus; -use Vesper\Tool\Event\RedeliveryTracker; +use Vesper\Tool\Event\RedeliveryStore; -class InMemoryRedeliveryTracker implements RedeliveryTracker +class InMemoryRedeliveryStore implements RedeliveryStore { /** @var array */ private array $rows = []; @@ -27,12 +27,13 @@ public function schedule(RedeliveryRequest $request): void } #[Override] - public function nextDue(): ?DueRedelivery + public function next(): ?DueRedelivery { $now = CarbonImmutable::now(); $candidate = null; + $candidateKey = null; - foreach ($this->rows as $row) { + foreach ($this->rows as $key => $row) { if ($row->status !== RedeliveryStatus::PendingRetry) { continue; } @@ -41,13 +42,16 @@ public function nextDue(): ?DueRedelivery } if ($candidate === null || $row->nextRetryAt->lessThan($candidate->nextRetryAt)) { $candidate = $row; + $candidateKey = $key; } } - if ($candidate === null) { + if ($candidate === null || $candidateKey === null) { return null; } + $this->rows[$candidateKey] = $candidate->claimed(); + return new DueRedelivery( event: $candidate->event, listener: $candidate->listener, @@ -55,24 +59,12 @@ public function nextDue(): ?DueRedelivery ); } - #[Override] - public function processNextDue(callable $handler): void - { - $due = $this->nextDue(); - - if ($due === null) { - return; - } - - $handler($due); - } - #[Override] public function markFailedPermanently(string $eventId, string $listener, Throwable $lastError): void { $key = self::key($eventId, $listener); - if (!isset($this->rows[$key])) { + if (!isset($this->rows[$key]) || $this->rows[$key]->status !== RedeliveryStatus::Dispatching) { return; } @@ -84,7 +76,7 @@ public function markSucceeded(string $eventId, string $listener): void { $key = self::key($eventId, $listener); - if (!isset($this->rows[$key])) { + if (!isset($this->rows[$key]) || $this->rows[$key]->status !== RedeliveryStatus::Dispatching) { return; } diff --git a/src/Infrastructure/SequentialEventProcessor.php b/src/Infrastructure/SequentialEventProcessor.php index 262ee08..bdb915d 100644 --- a/src/Infrastructure/SequentialEventProcessor.php +++ b/src/Infrastructure/SequentialEventProcessor.php @@ -14,7 +14,7 @@ use Vesper\Tool\Event\Infrastructure\Retry\NoRetryPolicy; use Vesper\Tool\Event\RawEvent; use Vesper\Tool\Event\RedeliveryRequest; -use Vesper\Tool\Event\RedeliveryTracker; +use Vesper\Tool\Event\RedeliveryStore; use Vesper\Tool\Event\Retry\RetryPolicy; class SequentialEventProcessor implements EventProcessor @@ -28,7 +28,7 @@ public function __construct( private readonly HandlerResolver $resolver = new DefaultHandlerResolver(), private readonly EventHydrator $hydrator = new JacksonHydrator(), private readonly RetryPolicy $retryPolicy = new NoRetryPolicy(), - private readonly ?RedeliveryTracker $redeliveryTracker = null, + private readonly ?RedeliveryStore $redeliveryStore = null, private readonly array $ignoredExceptions = [], ) { } @@ -43,40 +43,30 @@ public function __construct( } } - /** - * Dispatch's fail-fast throw is deferred until processNextDue() has committed, so the - * schedule / markFailedPermanently side effects persist instead of getting rolled back. - */ public function processNextRedelivery(): void { - if ($this->redeliveryTracker === null) { + if ($this->redeliveryStore === null) { return; } - $deferredException = null; - - $this->redeliveryTracker->processNextDue(function ($due) use (&$deferredException): void { - $subscriber = $this->findRegisteredSubscriber($due->event->name, $due->listener); + $due = $this->redeliveryStore->next(); - if ($subscriber === null) { - $this->redeliveryTracker->markFailedPermanently( - $due->event->id, - $due->listener, - new RuntimeException("Listener '{$due->listener}' is no longer registered for event '{$due->event->name}'."), - ); - return; - } + if ($due === null) { + return; + } - try { - $this->dispatch($due->event, $subscriber, $due->attemptNumber); - } catch (Throwable $e) { - $deferredException = $e; - } - }); + $subscriber = $this->findRegisteredSubscriber($due->event->name, $due->listener); - if ($deferredException !== null) { - throw $deferredException; + if ($subscriber === null) { + $this->redeliveryStore->markFailedPermanently( + $due->event->id, + $due->listener, + new RuntimeException("Listener '{$due->listener}' is no longer registered for event '{$due->event->name}'."), + ); + return; } + + $this->dispatch($due->event, $subscriber, $due->attemptNumber); } private function findRegisteredSubscriber(string $eventName, string $listenerKey): callable|string|null @@ -98,7 +88,7 @@ protected function dispatch(RawEvent $event, callable|string $subscriber, int $a try { $callable($domainEvent); - $this->redeliveryTracker?->markSucceeded($event->id, $listener); + $this->redeliveryStore?->markSucceeded($event->id, $listener); } catch (Throwable $e) { if ($this->isIgnored($e)) { return; @@ -112,11 +102,11 @@ protected function dispatch(RawEvent $event, callable|string $subscriber, int $a throw $e; } - if ($this->redeliveryTracker === null) { + if ($this->redeliveryStore === null) { throw $e; } - $this->redeliveryTracker->schedule(new RedeliveryRequest( + $this->redeliveryStore->schedule(new RedeliveryRequest( event: $event, listener: $listener, attemptNumber: $attemptsMade, @@ -129,7 +119,7 @@ protected function dispatch(RawEvent $event, callable|string $subscriber, int $a /** Hook for subclasses; called once a listener's failure can no longer be retried. */ protected function onPermanentFailure(RawEvent $event, callable|string $subscriber, Throwable $error): void { - $this->redeliveryTracker?->markFailedPermanently($event->id, $this->listenerKey($subscriber), $error); + $this->redeliveryStore?->markFailedPermanently($event->id, $this->listenerKey($subscriber), $error); } protected function listenerKey(callable|string $subscriber): string diff --git a/src/Infrastructure/SilentSequentialEventProcessor.php b/src/Infrastructure/SilentSequentialEventProcessor.php index dd6c23e..a721a85 100644 --- a/src/Infrastructure/SilentSequentialEventProcessor.php +++ b/src/Infrastructure/SilentSequentialEventProcessor.php @@ -10,7 +10,7 @@ use Vesper\Tool\Event\HandlerResolver; use Vesper\Tool\Event\Infrastructure\Retry\NoRetryPolicy; use Vesper\Tool\Event\RawEvent; -use Vesper\Tool\Event\RedeliveryTracker; +use Vesper\Tool\Event\RedeliveryStore; use Vesper\Tool\Event\Retry\RetryPolicy; class SilentSequentialEventProcessor extends SequentialEventProcessor @@ -25,7 +25,7 @@ public function __construct( HandlerResolver $resolver = new DefaultHandlerResolver(), EventHydrator $hydrator = new JacksonHydrator(), RetryPolicy $retryPolicy = new NoRetryPolicy(), - ?RedeliveryTracker $redeliveryTracker = null, + ?RedeliveryStore $redeliveryStore = null, array $ignoredExceptions = [], ) { parent::__construct( @@ -33,7 +33,7 @@ public function __construct( resolver: $resolver, hydrator: $hydrator, retryPolicy: $retryPolicy, - redeliveryTracker: $redeliveryTracker, + redeliveryStore: $redeliveryStore, ignoredExceptions: $ignoredExceptions, ); } diff --git a/src/Infrastructure/SqlRedeliveryTracker.php b/src/Infrastructure/SqlRedeliveryStore.php similarity index 66% rename from src/Infrastructure/SqlRedeliveryTracker.php rename to src/Infrastructure/SqlRedeliveryStore.php index 3cfe80f..dc88908 100644 --- a/src/Infrastructure/SqlRedeliveryTracker.php +++ b/src/Infrastructure/SqlRedeliveryStore.php @@ -3,6 +3,7 @@ namespace Vesper\Tool\Event\Infrastructure; use Carbon\CarbonImmutable; +use Carbon\CarbonInterval; use Override; use PDO; use RuntimeException; @@ -14,9 +15,9 @@ use Vesper\Tool\Event\RawEventStatus; use Vesper\Tool\Event\RedeliveryRequest; use Vesper\Tool\Event\RedeliveryStatus; -use Vesper\Tool\Event\RedeliveryTracker; +use Vesper\Tool\Event\RedeliveryStore; -readonly class SqlRedeliveryTracker implements RedeliveryTracker +readonly class SqlRedeliveryStore implements RedeliveryStore { public function __construct(private PDO $connection) { @@ -69,74 +70,75 @@ public function schedule(RedeliveryRequest $request): void } #[Override] - public function nextDue(): ?DueRedelivery + public function next(): ?DueRedelivery { - $lockClause = $this->lockingClause(); - - $stmt = $this->connection->prepare( - <<beginTransactionIfNeeded(); - $stmt->execute([ - 'status' => RedeliveryStatus::PendingRetry->value, - 'now' => CarbonImmutable::now()->format('Y-m-d H:i:s.u'), - ]); + try { + $row = $this->fetchNextDueRow(); - /** @var array{event_id: string, listener: string, attempt_number: int, 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 ($row === null) { + $this->commitIfStarted($startedTransaction); + return null; + } - if ($row === false) { - return null; - } + $this->connection->prepare( + <<execute([ + 'status' => RedeliveryStatus::Dispatching->value, + 'updated_at' => CarbonImmutable::now()->format('Y-m-d H:i:s.u'), + 'event_id' => $row['event_id'], + 'listener' => $row['listener'], + ]); - /** @var array $payload */ - $payload = json_decode($row['event_payload'], true, flags: JSON_THROW_ON_ERROR); + $this->commitIfStarted($startedTransaction); - $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']), - ); + /** @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 new DueRedelivery( + event: $event, + listener: $row['listener'], + attemptNumber: (int) $row['attempt_number'], + ); + } catch (Throwable $e) { + $this->rollBackIfStarted($startedTransaction); - return new DueRedelivery( - event: $event, - listener: $row['listener'], - attemptNumber: (int) $row['attempt_number'], - ); + throw $e; + } } + /** UPDATE is guarded by status='dispatching' so a row a sweep already reclaimed is not overwritten. */ #[Override] public function markFailedPermanently(string $eventId, string $listener, Throwable $lastError): void { $this->connection->prepare( <<execute([ - 'status' => RedeliveryStatus::Failed->value, + 'new_status' => RedeliveryStatus::Failed->value, + 'current_status' => RedeliveryStatus::Dispatching->value, 'last_error' => self::formatError($lastError), 'updated_at' => CarbonImmutable::now()->format('Y-m-d H:i:s.u'), 'event_id' => $eventId, @@ -144,44 +146,28 @@ public function markFailedPermanently(string $eventId, string $listener, Throwab ]); } + /** UPDATE is guarded by status='dispatching' so a row a sweep already reclaimed is not overwritten. */ #[Override] public function markSucceeded(string $eventId, string $listener): void { $this->connection->prepare( <<execute([ - 'status' => RedeliveryStatus::Succeeded->value, + 'new_status' => RedeliveryStatus::Succeeded->value, + 'current_status' => RedeliveryStatus::Dispatching->value, 'updated_at' => CarbonImmutable::now()->format('Y-m-d H:i:s.u'), 'event_id' => $eventId, 'listener' => $listener, ]); } - #[Override] - public function processNextDue(callable $handler): void - { - $startedTransaction = $this->beginTransactionIfNeeded(); - - try { - $due = $this->nextDue(); - - if ($due !== null) { - $handler($due); - } - - $this->commitIfStarted($startedTransaction); - } catch (Throwable $e) { - $this->rollBackIfStarted($startedTransaction); - - throw $e; - } - } - #[Override] public function retryNow(string $eventId, string $listener): void { @@ -204,6 +190,73 @@ public function retryNow(string $eventId, string $listener): void ]); } + /** + * 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(); + } + + /** + * @return array{event_id: string, listener: string, attempt_number: int, event_name: string, event_status: string, event_payload: string, event_created_at: string, event_publish_at: string}|null + */ + private function fetchNextDueRow(): ?array + { + $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, attempt_number: int, 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); + + return $row === false ? null : $row; + } + private static function formatError(Throwable $error): string { return $error::class . ': ' . $error->getMessage(); diff --git a/src/Redelivery.php b/src/Redelivery.php index 159bb6d..70de12b 100644 --- a/src/Redelivery.php +++ b/src/Redelivery.php @@ -57,6 +57,20 @@ public static function retrieve( ); } + public function claimed(): self + { + return new self( + event: $this->event, + listener: $this->listener, + status: RedeliveryStatus::Dispatching, + attemptNumber: $this->attemptNumber, + nextRetryAt: $this->nextRetryAt, + lastError: $this->lastError, + createdAt: $this->createdAt, + updatedAt: CarbonImmutable::now(), + ); + } + public function rescheduled(RedeliveryRequest $request): self { return new self( diff --git a/src/RedeliveryStatus.php b/src/RedeliveryStatus.php index fc31b7a..476d3af 100644 --- a/src/RedeliveryStatus.php +++ b/src/RedeliveryStatus.php @@ -5,6 +5,7 @@ enum RedeliveryStatus: string { case PendingRetry = 'pending_retry'; + case Dispatching = 'dispatching'; case Failed = 'failed'; case Succeeded = 'succeeded'; } diff --git a/src/RedeliveryStore.php b/src/RedeliveryStore.php new file mode 100644 index 0000000..261bb7e --- /dev/null +++ b/src/RedeliveryStore.php @@ -0,0 +1,32 @@ +pdo = new PDO('sqlite::memory:'); $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - $this->store = new SqlEventStore($this->pdo); - $this->tracker = new SqlRedeliveryTracker($this->pdo); + $this->eventStore = new SqlEventStore($this->pdo); + $this->store = new SqlRedeliveryStore($this->pdo); } public function test_schema_is_idempotent_across_multiple_instantiations(): void { - new SqlRedeliveryTracker($this->pdo); - new SqlRedeliveryTracker($this->pdo); + new SqlRedeliveryStore($this->pdo); + new SqlRedeliveryStore($this->pdo); self::assertTrue(true); } - public function test_next_due_returns_null_when_table_is_empty(): void + public function test_next_returns_null_when_table_is_empty(): void { - self::assertNull($this->tracker->nextDue()); + self::assertNull($this->store->next()); } - public function test_schedule_then_next_due_round_trip(): void + public function test_schedule_then_next_round_trip(): void { $event = $this->insertEvent(); - $this->tracker->schedule(new RedeliveryRequest( + $this->store->schedule(new RedeliveryRequest( event: $event, listener: 'App\\Listener', attemptNumber: 1, @@ -52,7 +53,7 @@ public function test_schedule_then_next_due_round_trip(): void lastError: new RuntimeException('boom'), )); - $due = $this->tracker->nextDue(); + $due = $this->store->next(); self::assertNotNull($due); self::assertSame($event->id, $due->event->id); @@ -61,11 +62,30 @@ public function test_schedule_then_next_due_round_trip(): void self::assertSame($event->name, $due->event->name); } - public function test_next_due_excludes_rows_whose_retry_time_is_in_the_future(): void + public function test_next_claims_row_so_a_second_call_skips_it(): void + { + $event = $this->insertEvent(); + $this->store->schedule(new RedeliveryRequest( + 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, 'second call must skip the row already in dispatching'); + 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->tracker->schedule(new RedeliveryRequest( + $this->store->schedule(new RedeliveryRequest( event: $event, listener: 'App\\Listener', attemptNumber: 1, @@ -73,21 +93,21 @@ public function test_next_due_excludes_rows_whose_retry_time_is_in_the_future(): lastError: new RuntimeException('boom'), )); - self::assertNull($this->tracker->nextDue()); + self::assertNull($this->store->next()); } public function test_schedule_is_idempotent_and_updates_on_repeat(): void { $event = $this->insertEvent(); - $this->tracker->schedule(new RedeliveryRequest( + $this->store->schedule(new RedeliveryRequest( event: $event, listener: 'App\\Listener', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->addMinute(), lastError: new RuntimeException('first'), )); - $this->tracker->schedule(new RedeliveryRequest( + $this->store->schedule(new RedeliveryRequest( event: $event, listener: 'App\\Listener', attemptNumber: 2, @@ -95,7 +115,7 @@ public function test_schedule_is_idempotent_and_updates_on_repeat(): void lastError: new RuntimeException('second'), )); - $due = $this->tracker->nextDue(); + $due = $this->store->next(); self::assertNotNull($due); self::assertSame(2, $due->attemptNumber, 'second schedule overwrote the first row'); @@ -104,154 +124,156 @@ public function test_schedule_is_idempotent_and_updates_on_repeat(): void self::assertCount(1, $rows, 'no duplicate rows for (event_id, listener)'); } - public function test_mark_succeeded_removes_row_from_due_queue(): void + public function test_mark_succeeded_finalises_a_claimed_row(): void { $event = $this->insertEvent(); - - $this->tracker->schedule(new RedeliveryRequest( + $this->store->schedule(new RedeliveryRequest( event: $event, listener: 'App\\Listener', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), )); + $this->store->next(); // claim - $this->tracker->markSucceeded($event->id, 'App\\Listener'); + $this->store->markSucceeded($event->id, 'App\\Listener'); - self::assertNull($this->tracker->nextDue()); self::assertSame('succeeded', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); + self::assertNull($this->store->next()); } - public function test_mark_failed_permanently_removes_row_from_due_queue(): void + public function test_mark_succeeded_is_a_noop_when_row_is_not_in_dispatching(): void { $event = $this->insertEvent(); - - $this->tracker->schedule(new RedeliveryRequest( + $this->store->schedule(new RedeliveryRequest( event: $event, listener: 'App\\Listener', - attemptNumber: 5, + attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), )); - $this->tracker->markFailedPermanently($event->id, 'App\\Listener', new RuntimeException('final')); + $this->store->markSucceeded($event->id, 'App\\Listener'); - self::assertNull($this->tracker->nextDue()); - self::assertSame('failed', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); + self::assertSame('pending_retry', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); } - public function test_process_next_due_runs_handler_inside_a_transaction(): void + public function test_mark_failed_permanently_finalises_a_claimed_row(): void { $event = $this->insertEvent(); - $this->tracker->schedule(new RedeliveryRequest( + $this->store->schedule(new RedeliveryRequest( event: $event, listener: 'App\\Listener', - attemptNumber: 1, + attemptNumber: 5, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), )); + $this->store->next(); // claim - $observedInTransaction = null; - - $this->tracker->processNextDue(function () use (&$observedInTransaction): void { - $observedInTransaction = $this->pdo->inTransaction(); - }); + $this->store->markFailedPermanently($event->id, 'App\\Listener', new RuntimeException('final')); - self::assertTrue($observedInTransaction, 'handler ran inside an active transaction'); - self::assertFalse($this->pdo->inTransaction(), 'transaction committed after processNextDue returned'); + self::assertSame('failed', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); + self::assertNull($this->store->next()); } - public function test_process_next_due_commits_handler_side_effects(): void + public function test_retry_now_re_queues_a_permanently_failed_row_preserving_attempt_count(): void { $event = $this->insertEvent(); - $this->tracker->schedule(new RedeliveryRequest( + + $this->store->schedule(new RedeliveryRequest( event: $event, listener: 'App\\Listener', attemptNumber: 5, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), )); + $this->store->next(); // claim + $this->store->markFailedPermanently($event->id, 'App\\Listener', new RuntimeException('final')); - $this->tracker->processNextDue(function () use ($event): void { - $this->tracker->markFailedPermanently( - $event->id, - 'App\\Listener', - new RuntimeException('final'), - ); - }); + $this->store->retryNow($event->id, 'App\\Listener'); - self::assertSame('failed', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); - self::assertNull($this->tracker->nextDue()); + $due = $this->store->next(); + self::assertNotNull($due); + self::assertSame(5, $due->attemptNumber, 'attempt count is preserved across retryNow()'); } - public function test_process_next_due_rolls_back_when_handler_throws(): void + public function test_recover_stuck_redeliveries_resets_dispatching_rows_older_than_threshold(): void { $event = $this->insertEvent(); - $this->tracker->schedule(new RedeliveryRequest( + $this->store->schedule(new RedeliveryRequest( event: $event, listener: 'App\\Listener', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), )); + $this->store->next(); // claim → status=dispatching + $this->backdateUpdatedAt($event->id, 'App\\Listener', CarbonImmutable::now()->subHour()); - try { - $this->tracker->processNextDue(function () use ($event): void { - $this->tracker->markSucceeded($event->id, 'App\\Listener'); - throw new RuntimeException('handler exploded'); - }); - self::fail('expected exception'); - } catch (RuntimeException $e) { - self::assertSame('handler exploded', $e->getMessage()); - } - - self::assertSame( - 'pending_retry', - $this->fetchRedeliveryStatus($event->id, 'App\\Listener'), - 'markSucceeded rolled back because the handler threw — caller must catch and defer to keep side effects', - ); - self::assertFalse($this->pdo->inTransaction(), 'transaction rolled back, not left dangling'); + $recovered = $this->store->recoverStuckRedeliveries(CarbonInterval::minutes(30)); + + self::assertSame(1, $recovered); + self::assertSame('pending_retry', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); } - public function test_process_next_due_is_a_noop_when_nothing_is_due(): void + public function test_recover_stuck_redeliveries_skips_rows_within_threshold(): void { - $invoked = false; + $event = $this->insertEvent(); + $this->store->schedule(new RedeliveryRequest( + event: $event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + )); + $this->store->next(); // claim — updated_at is fresh - $this->tracker->processNextDue(function () use (&$invoked): void { - $invoked = true; - }); + $recovered = $this->store->recoverStuckRedeliveries(CarbonInterval::minutes(30)); - self::assertFalse($invoked, 'handler not invoked when no row is due'); - self::assertFalse($this->pdo->inTransaction(), 'transaction committed even when no row was found'); + self::assertSame(0, $recovered); + self::assertSame('dispatching', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); } - public function test_retry_now_re_queues_a_permanently_failed_row_preserving_attempt_count(): void + public function test_recover_stuck_redeliveries_does_not_touch_pending_or_terminal_rows(): void { $event = $this->insertEvent(); - - $this->tracker->schedule(new RedeliveryRequest( + $this->store->schedule(new RedeliveryRequest( event: $event, listener: 'App\\Listener', - attemptNumber: 5, + attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), )); - $this->tracker->markFailedPermanently($event->id, 'App\\Listener', new RuntimeException('final')); + $this->backdateUpdatedAt($event->id, 'App\\Listener', CarbonImmutable::now()->subHour()); - $this->tracker->retryNow($event->id, 'App\\Listener'); + $recovered = $this->store->recoverStuckRedeliveries(CarbonInterval::minutes(30)); - $due = $this->tracker->nextDue(); - self::assertNotNull($due); - self::assertSame(5, $due->attemptNumber, 'attempt count is preserved across retryNow()'); + self::assertSame(0, $recovered, 'pending_retry rows are not the sweeper\'s concern'); + self::assertSame('pending_retry', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); } private function insertEvent(): RawEvent { $event = TestEventFactory::retrieveOrderPlaced(['order_id' => 1]); - $this->store->add($event); + $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> */ diff --git a/tests/Unit/InMemoryRedeliveryTrackerTest.php b/tests/Unit/InMemoryRedeliveryStoreTest.php similarity index 53% rename from tests/Unit/InMemoryRedeliveryTrackerTest.php rename to tests/Unit/InMemoryRedeliveryStoreTest.php index 7b50ad7..858709b 100644 --- a/tests/Unit/InMemoryRedeliveryTrackerTest.php +++ b/tests/Unit/InMemoryRedeliveryStoreTest.php @@ -6,29 +6,29 @@ use PHPUnit\Framework\TestCase; use RuntimeException; use Test\Vesper\Tool\Event\_Fixtures\TestEventFactory; -use Vesper\Tool\Event\Infrastructure\InMemoryRedeliveryTracker; +use Vesper\Tool\Event\Infrastructure\InMemoryRedeliveryStore; use Vesper\Tool\Event\RawEvent; use Vesper\Tool\Event\RedeliveryRequest; -class InMemoryRedeliveryTrackerTest extends TestCase +class InMemoryRedeliveryStoreTest extends TestCase { - private InMemoryRedeliveryTracker $tracker; + private InMemoryRedeliveryStore $store; private RawEvent $event; protected function setUp(): void { - $this->tracker = new InMemoryRedeliveryTracker(); + $this->store = new InMemoryRedeliveryStore(); $this->event = TestEventFactory::retrieveOrderPlaced(); } - public function test_next_due_returns_null_when_empty(): void + public function test_next_returns_null_when_empty(): void { - self::assertNull($this->tracker->nextDue()); + self::assertNull($this->store->next()); } public function test_schedule_makes_redelivery_pickable_when_time_passes(): void { - $this->tracker->schedule(new RedeliveryRequest( + $this->store->schedule(new RedeliveryRequest( event: $this->event, listener: 'App\\SomeListener', attemptNumber: 1, @@ -36,7 +36,7 @@ public function test_schedule_makes_redelivery_pickable_when_time_passes(): void lastError: new RuntimeException('boom'), )); - $due = $this->tracker->nextDue(); + $due = $this->store->next(); self::assertNotNull($due); self::assertSame($this->event->id, $due->event->id); @@ -44,9 +44,9 @@ public function test_schedule_makes_redelivery_pickable_when_time_passes(): void self::assertSame(1, $due->attemptNumber); } - public function test_next_due_excludes_rows_whose_retry_time_is_in_the_future(): void + public function test_next_excludes_rows_whose_retry_time_is_in_the_future(): void { - $this->tracker->schedule(new RedeliveryRequest( + $this->store->schedule(new RedeliveryRequest( event: $this->event, listener: 'App\\SomeListener', attemptNumber: 1, @@ -54,22 +54,22 @@ public function test_next_due_excludes_rows_whose_retry_time_is_in_the_future(): lastError: new RuntimeException('boom'), )); - self::assertNull($this->tracker->nextDue()); + self::assertNull($this->store->next()); } - public function test_next_due_returns_earliest_scheduled_first(): void + public function test_next_returns_earliest_scheduled_first(): void { $eventEarly = TestEventFactory::retrieveOrderPlaced(['n' => 1]); $eventLate = TestEventFactory::retrieveOrderPlaced(['n' => 2]); - $this->tracker->schedule(new RedeliveryRequest( + $this->store->schedule(new RedeliveryRequest( event: $eventLate, listener: 'App\\Late', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), )); - $this->tracker->schedule(new RedeliveryRequest( + $this->store->schedule(new RedeliveryRequest( event: $eventEarly, listener: 'App\\Early', attemptNumber: 1, @@ -77,15 +77,15 @@ public function test_next_due_returns_earliest_scheduled_first(): void lastError: new RuntimeException('boom'), )); - $due = $this->tracker->nextDue(); + $due = $this->store->next(); self::assertNotNull($due); self::assertSame('App\\Early', $due->listener); } - public function test_mark_succeeded_removes_row_from_due_queue(): void + public function test_next_transitions_claimed_row_so_a_second_call_skips_it(): void { - $this->tracker->schedule(new RedeliveryRequest( + $this->store->schedule(new RedeliveryRequest( event: $this->event, listener: 'App\\Listener', attemptNumber: 1, @@ -93,40 +93,76 @@ public function test_mark_succeeded_removes_row_from_due_queue(): void lastError: new RuntimeException('boom'), )); - $this->tracker->markSucceeded($this->event->id, 'App\\Listener'); + $first = $this->store->next(); + $second = $this->store->next(); - self::assertNull($this->tracker->nextDue()); + self::assertNotNull($first); + self::assertNull($second, 'a second call must skip the just-claimed row'); } - public function test_mark_failed_permanently_removes_row_from_due_queue(): void + public function test_mark_succeeded_finalises_a_claimed_row(): void { - $this->tracker->schedule(new RedeliveryRequest( + $this->store->schedule(new RedeliveryRequest( + event: $this->event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + )); + $this->store->next(); // claim + + $this->store->markSucceeded($this->event->id, 'App\\Listener'); + + self::assertNull($this->store->next()); + } + + public function test_mark_succeeded_is_a_noop_when_row_is_not_in_dispatching(): void + { + $this->store->schedule(new RedeliveryRequest( + event: $this->event, + listener: 'App\\Listener', + attemptNumber: 1, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('boom'), + )); + + $this->store->markSucceeded($this->event->id, 'App\\Listener'); + + $due = $this->store->next(); + self::assertNotNull($due, 'row remains claimable because markSucceeded did not apply'); + } + + public function test_mark_failed_permanently_finalises_a_claimed_row(): void + { + $this->store->schedule(new RedeliveryRequest( event: $this->event, listener: 'App\\Listener', attemptNumber: 5, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), )); + $this->store->next(); // claim - $this->tracker->markFailedPermanently($this->event->id, 'App\\Listener', new RuntimeException('final')); + $this->store->markFailedPermanently($this->event->id, 'App\\Listener', new RuntimeException('final')); - self::assertNull($this->tracker->nextDue()); + self::assertNull($this->store->next()); } public function test_retry_now_re_queues_a_permanently_failed_row(): void { - $this->tracker->schedule(new RedeliveryRequest( + $this->store->schedule(new RedeliveryRequest( event: $this->event, listener: 'App\\Listener', attemptNumber: 5, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), )); - $this->tracker->markFailedPermanently($this->event->id, 'App\\Listener', new RuntimeException('final')); + $this->store->next(); // claim + $this->store->markFailedPermanently($this->event->id, 'App\\Listener', new RuntimeException('final')); - $this->tracker->retryNow($this->event->id, 'App\\Listener'); + $this->store->retryNow($this->event->id, 'App\\Listener'); - $due = $this->tracker->nextDue(); + $due = $this->store->next(); self::assertNotNull($due); self::assertSame('App\\Listener', $due->listener); @@ -135,14 +171,14 @@ public function test_retry_now_re_queues_a_permanently_failed_row(): void public function test_schedule_is_idempotent_on_event_id_listener(): void { - $this->tracker->schedule(new RedeliveryRequest( + $this->store->schedule(new RedeliveryRequest( event: $this->event, listener: 'App\\Listener', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->addMinute(), lastError: new RuntimeException('boom'), )); - $this->tracker->schedule(new RedeliveryRequest( + $this->store->schedule(new RedeliveryRequest( event: $this->event, listener: 'App\\Listener', attemptNumber: 2, @@ -150,7 +186,7 @@ public function test_schedule_is_idempotent_on_event_id_listener(): void lastError: new RuntimeException('again'), )); - $due = $this->tracker->nextDue(); + $due = $this->store->next(); self::assertNotNull($due); self::assertSame(2, $due->attemptNumber, 'rescheduling updates attempt count, does not insert a duplicate'); @@ -158,15 +194,15 @@ public function test_schedule_is_idempotent_on_event_id_listener(): void public function test_mark_succeeded_is_a_noop_for_unknown_row(): void { - $this->tracker->markSucceeded('unknown-id', 'App\\Listener'); + $this->store->markSucceeded('unknown-id', 'App\\Listener'); - self::assertNull($this->tracker->nextDue()); + self::assertNull($this->store->next()); } public function test_retry_now_is_a_noop_for_unknown_row(): void { - $this->tracker->retryNow('unknown-id', 'App\\Listener'); + $this->store->retryNow('unknown-id', 'App\\Listener'); - self::assertNull($this->tracker->nextDue()); + self::assertNull($this->store->next()); } } diff --git a/tests/Unit/SequentialEventProcessorTest.php b/tests/Unit/SequentialEventProcessorTest.php index f4fffc9..0168e87 100644 --- a/tests/Unit/SequentialEventProcessorTest.php +++ b/tests/Unit/SequentialEventProcessorTest.php @@ -13,10 +13,10 @@ use Vesper\Tool\Event\EventSubscriberMap; use Vesper\Tool\Event\HandlerResolver; use Vesper\Tool\Event\Infrastructure\InMemoryEventStore; -use Vesper\Tool\Event\Infrastructure\InMemoryRedeliveryTracker; +use Vesper\Tool\Event\Infrastructure\InMemoryRedeliveryStore; use Vesper\Tool\Event\Infrastructure\SequentialEventProcessor; use Vesper\Tool\Event\RedeliveryRequest; -use Vesper\Tool\Event\RedeliveryTracker; +use Vesper\Tool\Event\RedeliveryStore; use Vesper\Tool\Event\Retry\RetryPolicy; class SequentialEventProcessorTest extends TestCase @@ -252,7 +252,7 @@ public function test_persists_failure_to_tracker_for_later_retry(): void throw $exception; }); - $tracker = $this->createMock(RedeliveryTracker::class); + $tracker = $this->createMock(RedeliveryStore::class); $tracker->expects($this->once()) ->method('schedule') ->with($this->callback( @@ -262,12 +262,12 @@ public function test_persists_failure_to_tracker_for_later_retry(): void && $r->attemptNumber === 1 && $r->lastError === $exception, )); - $tracker->method('nextDue')->willReturn(null); + $tracker->method('next')->willReturn(null); $processor = new SequentialEventProcessor( $this->subscribers, retryPolicy: self::policyReturning(retryDelayMs: 50), - redeliveryTracker: $tracker, + redeliveryStore: $tracker, ); $processor->process($this->store); @@ -282,14 +282,14 @@ public function test_propagates_failure_when_retry_policy_returns_null_and_track throw $exception; }); - $tracker = $this->createMock(RedeliveryTracker::class); + $tracker = $this->createMock(RedeliveryStore::class); $tracker->expects($this->never())->method('schedule'); - $tracker->method('nextDue')->willReturn(null); + $tracker->method('next')->willReturn(null); $processor = new SequentialEventProcessor( $this->subscribers, retryPolicy: self::policyReturning(retryDelayMs: 50, exhaustAfter: 0), - redeliveryTracker: $tracker, + redeliveryStore: $tracker, ); $this->expectExceptionObject($exception); @@ -321,13 +321,13 @@ public function test_calls_mark_succeeded_when_listener_succeeds_with_tracker_co $this->subscribers->subscribe('order.placed', function () {}); - $tracker = $this->createMock(RedeliveryTracker::class); + $tracker = $this->createMock(RedeliveryStore::class); $tracker->expects($this->once()) ->method('markSucceeded') ->with($event->id, 'Closure'); - $tracker->method('nextDue')->willReturn(null); + $tracker->method('next')->willReturn(null); - $processor = new SequentialEventProcessor($this->subscribers, redeliveryTracker: $tracker); + $processor = new SequentialEventProcessor($this->subscribers, redeliveryStore: $tracker); $processor->process($this->store); } @@ -341,16 +341,16 @@ public function test_ignored_exception_short_circuits_with_no_retry_no_propagati throw new IgnorableExceptionStub('expected domain failure'); }); - $tracker = $this->createMock(RedeliveryTracker::class); + $tracker = $this->createMock(RedeliveryStore::class); $tracker->expects($this->never())->method('schedule'); $tracker->expects($this->never())->method('markFailedPermanently'); $tracker->expects($this->never())->method('markSucceeded'); - $tracker->method('nextDue')->willReturn(null); + $tracker->method('next')->willReturn(null); $processor = new SequentialEventProcessor( $this->subscribers, retryPolicy: self::policyReturning(retryDelayMs: 10), - redeliveryTracker: $tracker, + redeliveryStore: $tracker, ignoredExceptions: [IgnorableExceptionStub::class], ); @@ -363,7 +363,7 @@ public function test_process_next_redelivery_dispatches_due_redelivery(): void { $event = TestEventFactory::retrieveOrderPlaced(); - $tracker = new InMemoryRedeliveryTracker(); + $tracker = new InMemoryRedeliveryStore(); $tracker->schedule(new RedeliveryRequest( event: $event, listener: 'Closure', @@ -377,11 +377,11 @@ public function test_process_next_redelivery_dispatches_due_redelivery(): void $received = $e; }); - $processor = new SequentialEventProcessor($this->subscribers, redeliveryTracker: $tracker); + $processor = new SequentialEventProcessor($this->subscribers, redeliveryStore: $tracker); $processor->processNextRedelivery(); self::assertNotNull($received, 'redelivery dispatch invoked the listener'); - self::assertNull($tracker->nextDue(), 'redelivery row marked succeeded after dispatch'); + self::assertNull($tracker->next(), 'redelivery row marked succeeded after dispatch'); } public function test_calls_mark_failed_permanently_when_no_more_retries_with_tracker(): void @@ -393,14 +393,14 @@ public function test_calls_mark_failed_permanently_when_no_more_retries_with_tra throw $exception; }); - $tracker = $this->createMock(RedeliveryTracker::class); + $tracker = $this->createMock(RedeliveryStore::class); $tracker->expects($this->once())->method('markFailedPermanently'); $tracker->expects($this->never())->method('schedule'); - $tracker->method('nextDue')->willReturn(null); + $tracker->method('next')->willReturn(null); $processor = new SequentialEventProcessor( $this->subscribers, - redeliveryTracker: $tracker, + redeliveryStore: $tracker, ); try { diff --git a/tests/Unit/SilentSequentialEventProcessorTest.php b/tests/Unit/SilentSequentialEventProcessorTest.php index b142b31..537053f 100644 --- a/tests/Unit/SilentSequentialEventProcessorTest.php +++ b/tests/Unit/SilentSequentialEventProcessorTest.php @@ -12,10 +12,10 @@ use Vesper\Tool\Event\EventHydrator; use Vesper\Tool\Event\EventSubscriberMap; use Vesper\Tool\Event\Infrastructure\InMemoryEventStore; -use Vesper\Tool\Event\Infrastructure\InMemoryRedeliveryTracker; +use Vesper\Tool\Event\Infrastructure\InMemoryRedeliveryStore; use Vesper\Tool\Event\Infrastructure\SilentSequentialEventProcessor; use Vesper\Tool\Event\RedeliveryRequest; -use Vesper\Tool\Event\RedeliveryTracker; +use Vesper\Tool\Event\RedeliveryStore; use Vesper\Tool\Event\Retry\RetryPolicy; class SilentSequentialEventProcessorTest extends TestCase @@ -194,11 +194,11 @@ public function test_logs_and_marks_failed_permanently_when_retries_are_exhauste throw $exception; }); - $tracker = $this->createMock(RedeliveryTracker::class); + $tracker = $this->createMock(RedeliveryStore::class); $tracker->expects($this->once()) ->method('markFailedPermanently') ->with($event->id, 'Closure', $exception); - $tracker->method('nextDue')->willReturn(null); + $tracker->method('next')->willReturn(null); $this->logger->expects($this->once()) ->method('error') @@ -220,7 +220,7 @@ public function test_logs_and_marks_failed_permanently_when_retries_are_exhauste subscribers: $this->subscribers, logger: $this->logger, hydrator: $hydrator, - redeliveryTracker: $tracker, + redeliveryStore: $tracker, ); $processor->process($this->store); @@ -234,11 +234,11 @@ public function test_ignored_exception_is_suppressed_with_no_log_or_tracker_call throw new IgnorableExceptionStub('expected domain failure'); }); - $tracker = $this->createMock(RedeliveryTracker::class); + $tracker = $this->createMock(RedeliveryStore::class); $tracker->expects($this->never())->method('schedule'); $tracker->expects($this->never())->method('markFailedPermanently'); $tracker->expects($this->never())->method('markSucceeded'); - $tracker->method('nextDue')->willReturn(null); + $tracker->method('next')->willReturn(null); $this->logger->expects($this->never())->method('error'); @@ -249,7 +249,7 @@ public function test_ignored_exception_is_suppressed_with_no_log_or_tracker_call subscribers: $this->subscribers, logger: $this->logger, hydrator: $hydrator, - redeliveryTracker: $tracker, + redeliveryStore: $tracker, ignoredExceptions: [IgnorableExceptionStub::class], ); @@ -261,7 +261,7 @@ public function test_ignored_exception_is_suppressed_with_no_log_or_tracker_call public function test_process_next_redelivery_logs_and_marks_failed_permanently_on_exhaustion(): void { $event = TestEventFactory::retrieveOrderPlaced(); - $tracker = new InMemoryRedeliveryTracker(); + $tracker = new InMemoryRedeliveryStore(); $tracker->schedule(new RedeliveryRequest( event: $event, listener: 'Closure', @@ -296,18 +296,18 @@ public function test_process_next_redelivery_logs_and_marks_failed_permanently_o logger: $this->logger, hydrator: $hydrator, retryPolicy: self::policyExhaustedImmediately(), - redeliveryTracker: $tracker, + redeliveryStore: $tracker, ); $processor->processNextRedelivery(); - self::assertNull($tracker->nextDue(), 'row marked failed permanently — no longer due'); + self::assertNull($tracker->next(), 'row marked failed permanently — no longer due'); } public function test_process_next_redelivery_silently_reschedules_when_retries_remain(): void { $event = TestEventFactory::retrieveOrderPlaced(); - $tracker = new InMemoryRedeliveryTracker(); + $tracker = new InMemoryRedeliveryStore(); $tracker->schedule(new RedeliveryRequest( event: $event, listener: 'Closure', @@ -330,12 +330,12 @@ public function test_process_next_redelivery_silently_reschedules_when_retries_r logger: $this->logger, hydrator: $hydrator, retryPolicy: self::policyReturningFutureRetry(), - redeliveryTracker: $tracker, + redeliveryStore: $tracker, ); $processor->processNextRedelivery(); - self::assertNull($tracker->nextDue(), 'row rescheduled for the future — not due now'); + self::assertNull($tracker->next(), 'row rescheduled for the future — not due now'); } private static function policyExhaustedImmediately(): RetryPolicy From 64215da1735d6ca804981355cac7aa06505cf6f3 Mon Sep 17 00:00:00 2001 From: Jason Viljoen Date: Sat, 16 May 2026 19:57:28 +0200 Subject: [PATCH 14/16] refactor: composable dispatch via ListenerDispatcher and RedeliveryProcessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SequentialEventProcessor used to own handler resolution, payload hydration, ignored-exception handling, the retry-policy ceiling, and the redelivery write — all in one class. Split those concerns into composable layers. New ListenerDispatcher abstraction (src/Dispatch/): - DefaultListenerDispatcher: resolves handler, hydrates, dispatches; swallows ignoredExceptions, rethrows everything else. Stateless. - RedeliveringListenerDispatcher: decorator that catches a listener throw and writes a fresh row to RedeliveryStore (attempt 1, retry-now). Used in the event-processing flow only — the redelivery processor runs a plain dispatcher to avoid double-scheduling. New RedeliveryProcessor interface (src/Redelivery/), mirroring EventProcessor: - SequentialRedeliveryProcessor owns the RetryPolicy and decides per due row whether to reschedule, mark succeeded, or mark failed permanently. - SilentRedeliveryProcessor: PSR-3 log-and-continue decorator, same shape as the silent event-processor decorator. Renames + namespace reorg: - SilentSequentialEventProcessor -> SilentEventProcessor, now a generic decorator wrapping any EventProcessor. - Redelivery types move to Vesper\Tool\Event\Redelivery\* (Redelivery, RedeliveryStatus, RedeliveryStore, RedeliveryProcessor). - Redelivery infra moves to src/Infrastructure/Redelivery/*. - Dispatch infra at src/Infrastructure/Dispatch/*. - New ListenerKey utility normalises callable|string subscribers to a single string identifier (class name or 'Closure'), replacing the duplicated inline convention. SequentialEventProcessor is now ~20 lines: drain the store, hand each (event, subscriber) pair to the ListenerDispatcher, mark processed. All retry, hydration, and ignored-exception logic lives in composable decorators. EventStore::markProcessed() now takes a RawEvent (was string $eventId) so the processor and the store share the same claimed entity end-to-end. README + ROADMAP rewritten to document the new composition pattern: dev wires the bare SequentialEventProcessor + DefaultListenerDispatcher, prod wraps with SilentEventProcessor and a RedeliveringListenerDispatcher. ROADMAP's metering and batch-claim entries updated to fit the new shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 2 + README.md | 240 +++++++----- ROADMAP.md | 29 +- src/Dispatch/ListenerDispatcher.php | 12 + src/DueRedelivery.php | 14 - src/EventPublisher.php | 3 +- src/EventStore.php | 2 +- src/EventSubscriberBuilder.php | 7 +- src/EventSubscriberMap.php | 7 +- .../Dispatch/DefaultListenerDispatcher.php | 49 +++ .../RedeliveringListenerDispatcher.php | 35 ++ src/Infrastructure/InMemoryEventStore.php | 6 +- .../InMemoryRedeliveryStore.php | 102 ----- .../Redelivery/InMemoryRedeliveryStore.php | 82 ++++ .../SequentialRedeliveryProcessor.php | 76 ++++ .../Redelivery/SilentRedeliveryProcessor.php | 26 ++ .../Redelivery/SqlRedeliveryStore.php | 275 +++++++++++++ .../Schema/MysqlEventStoreSchema.php | 52 ++- .../Schema/MysqlRedeliverySchema.php | 36 +- .../Schema/SqliteEventStoreSchema.php | 38 +- .../Schema/SqliteRedeliverySchema.php | 24 +- .../SequentialEventProcessor.php | 136 +------ src/Infrastructure/SilentEventProcessor.php | 26 ++ .../SilentSequentialEventProcessor.php | 56 --- src/Infrastructure/SqlEventStore.php | 127 +++--- src/Infrastructure/SqlRedeliveryStore.php | 316 --------------- src/ListenerKey.php | 21 + src/RawEvent.php | 45 ++- src/Redelivery.php | 136 ------- src/Redelivery/Redelivery.php | 187 +++++++++ src/Redelivery/RedeliveryProcessor.php | 8 + src/{ => Redelivery}/RedeliveryStatus.php | 2 +- src/Redelivery/RedeliveryStore.php | 36 ++ src/RedeliveryRequest.php | 28 -- src/RedeliveryStore.php | 32 -- src/SerializedEvent.php | 3 +- .../SqlRedeliveryStoreTest.php | 139 ++++--- tests/Feature/SqlEventStoreAuditTest.php | 55 +-- .../Feature/SqlEventStoreRecoverStuckTest.php | 25 +- tests/Feature/SqlEventStoreTest.php | 17 +- tests/Unit/DefaultHandlerResolverTest.php | 2 +- .../DefaultListenerDispatcherTest.php | 136 +++++++ .../RedeliveringListenerDispatcherTest.php | 128 +++++++ tests/Unit/EventPublisherTest.php | 53 ++- tests/Unit/EventSubscriberBuilderTest.php | 8 +- tests/Unit/InMemoryEventStoreTest.php | 14 + tests/Unit/ListenerKeyTest.php | 27 ++ ...tStatusTest.php => RawEventStatusTest.php} | 2 +- tests/Unit/RawEventTest.php | 131 +++++++ tests/Unit/RayEventTest.php | 132 ------- .../InMemoryRedeliveryStoreTest.php | 139 ++++--- tests/Unit/Redelivery/RedeliveryTest.php | 309 +++++++++++++++ .../SequentialRedeliveryProcessorTest.php | 318 ++++++++++++++++ .../SilentRedeliveryProcessorTest.php | 54 +++ tests/Unit/RedeliveryRequestTest.php | 88 ----- tests/Unit/SequentialEventProcessorTest.php | 296 +++----------- tests/Unit/SilentEventProcessorTest.php | 54 +++ .../SilentSequentialEventProcessorTest.php | 360 ------------------ tests/_Fixtures/CapturingEventStore.php | 25 ++ tests/_Fixtures/EmptyEventStub.php | 4 +- tests/_Fixtures/IgnorableExceptionStub.php | 4 +- tests/_Fixtures/ListenerA.php | 4 +- tests/_Fixtures/ListenerB.php | 4 +- tests/_Fixtures/OrderPlacedStub.php | 3 +- tests/_Fixtures/TrackingEventStore.php | 10 +- tests/prepend.php | 1 - 66 files changed, 2695 insertions(+), 2123 deletions(-) create mode 100644 src/Dispatch/ListenerDispatcher.php delete mode 100644 src/DueRedelivery.php create mode 100644 src/Infrastructure/Dispatch/DefaultListenerDispatcher.php create mode 100644 src/Infrastructure/Dispatch/RedeliveringListenerDispatcher.php delete mode 100644 src/Infrastructure/InMemoryRedeliveryStore.php create mode 100644 src/Infrastructure/Redelivery/InMemoryRedeliveryStore.php create mode 100644 src/Infrastructure/Redelivery/SequentialRedeliveryProcessor.php create mode 100644 src/Infrastructure/Redelivery/SilentRedeliveryProcessor.php create mode 100644 src/Infrastructure/Redelivery/SqlRedeliveryStore.php create mode 100644 src/Infrastructure/SilentEventProcessor.php delete mode 100644 src/Infrastructure/SilentSequentialEventProcessor.php delete mode 100644 src/Infrastructure/SqlRedeliveryStore.php create mode 100644 src/ListenerKey.php delete mode 100644 src/Redelivery.php create mode 100644 src/Redelivery/Redelivery.php create mode 100644 src/Redelivery/RedeliveryProcessor.php rename src/{ => Redelivery}/RedeliveryStatus.php (82%) create mode 100644 src/Redelivery/RedeliveryStore.php delete mode 100644 src/RedeliveryRequest.php delete mode 100644 src/RedeliveryStore.php rename tests/Feature/{ => Redelivery}/SqlRedeliveryStoreTest.php (69%) create mode 100644 tests/Unit/Dispatch/DefaultListenerDispatcherTest.php create mode 100644 tests/Unit/Dispatch/RedeliveringListenerDispatcherTest.php create mode 100644 tests/Unit/ListenerKeyTest.php rename tests/Unit/{RayEventStatusTest.php => RawEventStatusTest.php} (95%) create mode 100644 tests/Unit/RawEventTest.php delete mode 100644 tests/Unit/RayEventTest.php rename tests/Unit/{ => Redelivery}/InMemoryRedeliveryStoreTest.php (55%) create mode 100644 tests/Unit/Redelivery/RedeliveryTest.php create mode 100644 tests/Unit/Redelivery/SequentialRedeliveryProcessorTest.php create mode 100644 tests/Unit/Redelivery/SilentRedeliveryProcessorTest.php delete mode 100644 tests/Unit/RedeliveryRequestTest.php create mode 100644 tests/Unit/SilentEventProcessorTest.php delete mode 100644 tests/Unit/SilentSequentialEventProcessorTest.php create mode 100644 tests/_Fixtures/CapturingEventStore.php 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 e5d6f09..ae1f501 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,10 @@ 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) +- **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** — configurable `RetryPolicy` plus a persisted `RedeliveryStore`; a single failing listener of an event is retried independently while the others continue -- **Ignored exceptions** — pass a list of `Throwable` classes to silently swallow expected domain failures (no retry, no log, no DB row) +- **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 @@ -57,15 +57,21 @@ composer require hivesper/php-events | `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 with optional retry policy; exceptions propagate after retries are exhausted (fail-fast). Call `processNextRedelivery()` in a separate scheduled job to pick up persisted retries. | -| `SilentSequentialEventProcessor` | Same as above but catches per-listener failures after retries are exhausted, logs them via PSR-3, and continues | +| `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 | +| `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` | Tracker implementations | +| `InMemoryRedeliveryStore` / `SqlRedeliveryStore` | `RedeliveryStore` implementations | --- @@ -123,8 +129,8 @@ mostly-disjoint tables and rows, so they can run in parallel without contending. | Job | Recommended cadence | What it does | |---|---|---| -| `$processor->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). | -| `$processor->processNextRedelivery()` | Every minute, looped per tick to drain a batch | Claims one due retry from `event_outbox_redelivery` (transitions it to `dispatching`) and dispatches it. Loop the call within a single tick if you want to drain multiple. See [Automatic retry & failure tracking](#automatic-retry--failure-tracking). | +| `$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. | @@ -133,8 +139,8 @@ mostly-disjoint tables and rows, so they can run in parallel without contending. The four jobs do not compete for the same rows: -- `process()` reads `event_outbox` rows in `pending`; `recoverStuckEvents()` reads rows in `processing` — disjoint by status filter. -- `processNextRedelivery()` reads `event_outbox_redelivery` rows in `pending_retry` and claims by transitioning to `dispatching`; `recoverStuckRedeliveries()` reads rows in `dispatching` — disjoint by status filter. +- `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. @@ -245,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 @@ -309,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()), +); ``` --- @@ -523,33 +532,54 @@ class MyEventStore implements EventStore --- -## 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); +``` -$processor = new SilentSequentialEventProcessor($subscribers, $logger); +```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 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" - attempt: 3 // the attempt that just failed; 1 if no retry policy - 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 @@ -559,24 +589,26 @@ 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 | - -Both processors support [Automatic retry & failure tracking](#automatic-retry--failure-tracking) -through the same constructor parameters. The retry policy and the redelivery tracker are opt-in: -default behaviour for both processors is unchanged from earlier versions. +| 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. | --- @@ -587,18 +619,22 @@ durable failure tracking on a **per-(event, listener)** basis: if a single liste fails, only that listener is retried — the others continue to run and successful deliveries are not re-fired. -The model is **fully async**: when a listener fails, the failure is persisted to the redelivery -table immediately and the main outbox worker moves on to the next event. A separate scheduled -job calls `processNextRedelivery()` to pick up due retries — the retry policy is consulted at -that point to decide whether to re-schedule or mark permanently failed. +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. -Once configured, the same retry behaviour applies to both `SequentialEventProcessor` (which -fails fast on the first attempt when no tracker is configured, or after `nextRetryAt` returns -null on a retry) and `SilentSequentialEventProcessor` (which logs and continues). +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: +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; @@ -612,11 +648,11 @@ interface RetryPolicy Two implementations ship out of the box: -- **`NoRetryPolicy`** *(default)* — never retries. Same behaviour as before this feature existed, - so wiring up the new processor parameters without choosing a policy is a no-op. +- **`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 to the redelivery - table and picked up on a future `processNextRedelivery()` run. + 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; @@ -628,11 +664,11 @@ $retryPolicy = new ExponentialBackoffRetryPolicy(); $retryPolicy = new ExponentialBackoffRetryPolicy(delaysMs: [50, 250, 1_000, 30_000]); ``` -Retries require a `RedeliveryStore` to be configured — without one, any failure is treated -as permanent and rethrown by `SequentialEventProcessor` (or logged and swallowed by -`SilentSequentialEventProcessor`). +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 tracker +### 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 @@ -644,44 +680,66 @@ implementations: on MySQL. ```php -use Vesper\Tool\Event\Infrastructure\SqlRedeliveryStore; +use Vesper\Tool\Event\Infrastructure\Redelivery\SqlRedeliveryStore; -$tracker = new SqlRedeliveryStore($pdo); // schema created here if not present +$store = new SqlRedeliveryStore($pdo); // schema created here if not present ``` -### Wiring a processor with retries +### 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\SilentSequentialEventProcessor; -use Vesper\Tool\Event\Infrastructure\SqlRedeliveryStore; - -$processor = new SilentSequentialEventProcessor( - subscribers: $subscribers, - logger: $logger, - retryPolicy: new ExponentialBackoffRetryPolicy(), - redeliveryStore: new SqlRedeliveryStore($pdo), - ignoredExceptions: [ +use Vesper\Tool\Event\Infrastructure\SequentialEventProcessor; +use Vesper\Tool\Event\Infrastructure\SilentEventProcessor; + +$default = new DefaultListenerDispatcher( + ignoredExceptions: [ UserNotFoundException::class, InvalidPayloadException::class, ], ); -$processor->process($store); -``` +$redeliveryStore = new SqlRedeliveryStore($pdo); -`process()` drains the pending outbox and dispatches each event to its listeners. +// 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, +); +``` -Redeliveries are processed separately — call `processNextRedelivery()` in its own scheduled -job. It picks up one due retry using the same `FOR UPDATE SKIP LOCKED` strategy as `next()`: +Run the two processors as separate scheduled jobs: ```php -// In a separate cron task / queue worker: -$processor->processNextRedelivery(); // picks up the next due retry, if any +// 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 -`processNextRedelivery()` call after their `next_retry_at` passes — which is exactly how +`RedeliveryProcessor::process()` call after their `next_retry_at` passes — which is exactly how outbox workers already poll on a schedule. ### Ignored exceptions (skip-list) @@ -690,15 +748,17 @@ Some listener failures are not bugs — they're expected domain outcomes that th already handles upstream (e.g. `UserNotFoundException`, `OrderAlreadyShipped`). Retrying them wastes time and reporting them spams the error tracker. -The `ignoredExceptions` constructor parameter takes a list of `Throwable` class-strings. -Matching is `instanceof`-based, so subclasses are also matched. When a listener throws an -ignored exception: +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** — the policy is skipped entirely. -- **No PSR-3 log line** (in `SilentSequentialEventProcessor`). -- **No row written to `event_outbox_redelivery`.** -- **No exception propagation** in `SequentialEventProcessor` either — this is treated as - silent success, the next listener for the same event runs as normal. +- **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 @@ -707,15 +767,15 @@ expected here. ### Permanently failed dispatches -When a listener has exhausted its retries (or `nextRetryAt` returns `null` immediately because -no retry policy is configured), it is recorded in `event_outbox_redelivery` with -`status = 'failed'`. The row stays in the table so operators can inspect it. The behaviour -differs slightly between processors: +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. -| Processor | On exhaustion | -|---|---| -| `SequentialEventProcessor` | Marks the row `failed`, then **rethrows** the original exception (fail-fast). The event row stays in `processing` — no `markProcessed()` is called. | -| `SilentSequentialEventProcessor` | Marks the row `failed`, **logs** via PSR-3 (same shape as today), and **swallows** the exception so the next listener runs. The event eventually advances to `processed`. | +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 diff --git a/ROADMAP.md b/ROADMAP.md index 93852fd..327d3cb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,10 +30,12 @@ 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 small `EventProcessorObserver` interface with hooks like -`onDispatched`, `onScheduledForRetry`, `onPermanentlyFailed`, `onRecovered`. -Default no-op implementation; users plug in StatsD / OpenTelemetry / -Prometheus / whatever from there. +**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. @@ -65,16 +67,21 @@ redelivery claim) won't backfill existing installations. These matter once volume grows past a single moderate-sized service. -### Batch redelivery +### Batch claim and update -`processNextRedelivery()` drains one row per call. For a backlog of 10k -retries, that's 10k separate transactions and lock acquisitions. +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:** `processDueRedeliveries(int $batchSize): int` that wraps a -configurable number of dispatches in one transaction (or one transaction per -row but in a tight loop, sharing the connection). +**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 the cron task can't keep up at its current cadence. +**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 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 b92598e..0cf9cdc 100644 --- a/src/Infrastructure/InMemoryEventStore.php +++ b/src/Infrastructure/InMemoryEventStore.php @@ -18,10 +18,12 @@ 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(string $eventId): void + #[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/InMemoryRedeliveryStore.php b/src/Infrastructure/InMemoryRedeliveryStore.php deleted file mode 100644 index fd33211..0000000 --- a/src/Infrastructure/InMemoryRedeliveryStore.php +++ /dev/null @@ -1,102 +0,0 @@ - */ - private array $rows = []; - - #[Override] - public function schedule(RedeliveryRequest $request): void - { - $key = self::key($request->event->id, $request->listener); - - $this->rows[$key] = isset($this->rows[$key]) - ? $this->rows[$key]->rescheduled($request) - : Redelivery::fromRequest($request); - } - - #[Override] - public function next(): ?DueRedelivery - { - $now = CarbonImmutable::now(); - $candidate = null; - $candidateKey = null; - - foreach ($this->rows as $key => $row) { - if ($row->status !== RedeliveryStatus::PendingRetry) { - continue; - } - if ($row->nextRetryAt->greaterThan($now)) { - continue; - } - if ($candidate === null || $row->nextRetryAt->lessThan($candidate->nextRetryAt)) { - $candidate = $row; - $candidateKey = $key; - } - } - - if ($candidate === null || $candidateKey === null) { - return null; - } - - $this->rows[$candidateKey] = $candidate->claimed(); - - return new DueRedelivery( - event: $candidate->event, - listener: $candidate->listener, - attemptNumber: $candidate->attemptNumber, - ); - } - - #[Override] - public function markFailedPermanently(string $eventId, string $listener, Throwable $lastError): void - { - $key = self::key($eventId, $listener); - - if (!isset($this->rows[$key]) || $this->rows[$key]->status !== RedeliveryStatus::Dispatching) { - return; - } - - $this->rows[$key] = $this->rows[$key]->markedFailedPermanently($lastError); - } - - #[Override] - public function markSucceeded(string $eventId, string $listener): void - { - $key = self::key($eventId, $listener); - - if (!isset($this->rows[$key]) || $this->rows[$key]->status !== RedeliveryStatus::Dispatching) { - return; - } - - $this->rows[$key] = $this->rows[$key]->markedSucceeded(); - } - - #[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]->queuedForImmediateRetry(); - } - - private static function key(string $eventId, string $listener): string - { - return $eventId . '|' . $listener; - } -} 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/Schema/MysqlEventStoreSchema.php b/src/Infrastructure/Schema/MysqlEventStoreSchema.php index 2a264fa..603e04d 100644 --- a/src/Infrastructure/Schema/MysqlEventStoreSchema.php +++ b/src/Infrastructure/Schema/MysqlEventStoreSchema.php @@ -7,44 +7,40 @@ class MysqlEventStoreSchema { - /** - * Ensure the outbox tables and indexes exist - * - * @throws PDOException - */ + /** @throws PDOException */ public static function create(PDO $connection): void { self::createIfNeeded( connection: $connection, table: 'event_outbox', creationQuery: <<prepare( <<execute(['table' => $table]); diff --git a/src/Infrastructure/Schema/MysqlRedeliverySchema.php b/src/Infrastructure/Schema/MysqlRedeliverySchema.php index be96d18..d241daa 100644 --- a/src/Infrastructure/Schema/MysqlRedeliverySchema.php +++ b/src/Infrastructure/Schema/MysqlRedeliverySchema.php @@ -12,21 +12,21 @@ public static function create(PDO $connection): void connection: $connection, table: 'event_outbox_redelivery', creationQuery: <<prepare( <<execute(['table' => $table]); 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 index e55fed1..78e73b4 100644 --- a/src/Infrastructure/Schema/SqliteRedeliverySchema.php +++ b/src/Infrastructure/Schema/SqliteRedeliverySchema.php @@ -10,18 +10,18 @@ public static function create(PDO $connection): void { $connection->exec( <<exec( diff --git a/src/Infrastructure/SequentialEventProcessor.php b/src/Infrastructure/SequentialEventProcessor.php index bdb915d..bd65a72 100644 --- a/src/Infrastructure/SequentialEventProcessor.php +++ b/src/Infrastructure/SequentialEventProcessor.php @@ -2,146 +2,30 @@ namespace Vesper\Tool\Event\Infrastructure; -use Closure; use Override; -use RuntimeException; -use Throwable; -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\Infrastructure\Retry\NoRetryPolicy; -use Vesper\Tool\Event\RawEvent; -use Vesper\Tool\Event\RedeliveryRequest; -use Vesper\Tool\Event\RedeliveryStore; -use Vesper\Tool\Event\Retry\RetryPolicy; +use Vesper\Tool\Event\Infrastructure\Dispatch\DefaultListenerDispatcher; -class SequentialEventProcessor implements EventProcessor +readonly class SequentialEventProcessor implements EventProcessor { - /** - * @param EventSubscriberMap $subscribers - * @param list> $ignoredExceptions - */ + /** @param EventSubscriberMap $subscribers */ public function __construct( - private readonly EventSubscriberMap $subscribers, - private readonly HandlerResolver $resolver = new DefaultHandlerResolver(), - private readonly EventHydrator $hydrator = new JacksonHydrator(), - private readonly RetryPolicy $retryPolicy = new NoRetryPolicy(), - private readonly ?RedeliveryStore $redeliveryStore = null, - private readonly array $ignoredExceptions = [], - ) { - } + private EventSubscriberMap $subscribers, + 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); - } - $store->markProcessed($event->id); - } - } - - public function processNextRedelivery(): void - { - if ($this->redeliveryStore === null) { - return; - } - - $due = $this->redeliveryStore->next(); - - if ($due === null) { - return; - } - - $subscriber = $this->findRegisteredSubscriber($due->event->name, $due->listener); - - if ($subscriber === null) { - $this->redeliveryStore->markFailedPermanently( - $due->event->id, - $due->listener, - new RuntimeException("Listener '{$due->listener}' is no longer registered for event '{$due->event->name}'."), - ); - return; - } - - $this->dispatch($due->event, $subscriber, $due->attemptNumber); - } - - private function findRegisteredSubscriber(string $eventName, string $listenerKey): callable|string|null - { - foreach ($this->subscribers->of($eventName) as $subscriber) { - if ($this->listenerKey($subscriber) === $listenerKey) { - return $subscriber; + $this->dispatcher->dispatch($event, $subscriber); } - } - return null; - } - /** @param int $attemptsMade attempts already made (0 from process(), ≥1 from processNextRedelivery()) */ - protected function dispatch(RawEvent $event, callable|string $subscriber, int $attemptsMade = 0): void - { - $callable = $this->resolver->resolve($subscriber); - $domainEvent = $this->hydrator->hydrate($event->name, $event->payload, $callable); - $listener = $this->listenerKey($subscriber); - - try { - $callable($domainEvent); - $this->redeliveryStore?->markSucceeded($event->id, $listener); - } catch (Throwable $e) { - if ($this->isIgnored($e)) { - return; - } - - $attemptsMade++; - $nextRetryAt = $this->retryPolicy->nextRetryAt($attemptsMade); - - if ($nextRetryAt === null) { - $this->onPermanentFailure($event, $subscriber, $e); - throw $e; - } - - if ($this->redeliveryStore === null) { - throw $e; - } - - $this->redeliveryStore->schedule(new RedeliveryRequest( - event: $event, - listener: $listener, - attemptNumber: $attemptsMade, - nextRetryAt: $nextRetryAt, - lastError: $e, - )); - } - } - - /** Hook for subclasses; called once a listener's failure can no longer be retried. */ - protected function onPermanentFailure(RawEvent $event, callable|string $subscriber, Throwable $error): void - { - $this->redeliveryStore?->markFailedPermanently($event->id, $this->listenerKey($subscriber), $error); - } - - protected function listenerKey(callable|string $subscriber): string - { - if (is_string($subscriber)) { - return $subscriber; - } - - if (is_object($subscriber) && !($subscriber instanceof Closure)) { - return $subscriber::class; - } - - return 'Closure'; - } - - private function isIgnored(Throwable $error): bool - { - foreach ($this->ignoredExceptions as $class) { - if ($error instanceof $class) { - return true; - } + $event->markProcessed(); + $store->markProcessed($event); } - return false; } } 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 a721a85..0000000 --- a/src/Infrastructure/SilentSequentialEventProcessor.php +++ /dev/null @@ -1,56 +0,0 @@ - $subscribers - * @param list> $ignoredExceptions - */ - public function __construct( - EventSubscriberMap $subscribers, - private LoggerInterface $logger, - HandlerResolver $resolver = new DefaultHandlerResolver(), - EventHydrator $hydrator = new JacksonHydrator(), - RetryPolicy $retryPolicy = new NoRetryPolicy(), - ?RedeliveryStore $redeliveryStore = null, - array $ignoredExceptions = [], - ) { - parent::__construct( - subscribers: $subscribers, - resolver: $resolver, - hydrator: $hydrator, - retryPolicy: $retryPolicy, - redeliveryStore: $redeliveryStore, - ignoredExceptions: $ignoredExceptions, - ); - } - - #[Override] - protected function dispatch(RawEvent $event, callable|string $subscriber, int $attemptsMade = 0): void - { - try { - parent::dispatch($event, $subscriber, $attemptsMade); - } catch (Throwable $exception) { - $this->logger->error('Failed to dispatch event to listener.', [ - 'event' => $event->name, - 'event_id' => $event->id, - 'listener' => $this->listenerKey($subscriber), - 'attempt' => $attemptsMade + 1, - 'exception' => $exception, - ]); - } - } -} diff --git a/src/Infrastructure/SqlEventStore.php b/src/Infrastructure/SqlEventStore.php index efac156..2a10143 100644 --- a/src/Infrastructure/SqlEventStore.php +++ b/src/Infrastructure/SqlEventStore.php @@ -26,9 +26,9 @@ public function add(RawEvent $event): void { $stmt = $this->connection->prepare( <<execute([ @@ -46,37 +46,39 @@ public function add(RawEvent $event): void #[Override] public function next(): ?RawEvent { - $startedTransaction = $this->beginTransactionIfNeeded(); + $this->connection->beginTransaction(); try { $row = $this->fetchNextPendingRow(); if ($row === null) { - $this->commitIfStarted($startedTransaction); + $this->connection->commit(); return null; } - $this->connection->prepare( - "UPDATE event_outbox SET status = 'processing' WHERE id = :id", - )->execute(['id' => $row['id']]); - - $this->insertStatusAudit($row['id'], RawEventStatus::processing->value); - - $this->commitIfStarted($startedTransaction); - /** @var array $payload */ $payload = json_decode($row['payload'], true, flags: JSON_THROW_ON_ERROR); - return RawEvent::retrieve( + $event = RawEvent::retrieve( id: $row['id'], name: $row['name'], - status: RawEventStatus::processing, + 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->rollBackIfStarted($startedTransaction); + $this->rollBackIfActive(); throw $e; } @@ -84,24 +86,24 @@ public function next(): ?RawEvent /** UPDATE is guarded by status='processing' so a row a sweep already reclaimed is not overwritten. */ #[Override] - public function markProcessed(string $eventId): void + public function markProcessed(RawEvent $event): void { - $startedTransaction = $this->beginTransactionIfNeeded(); + $this->connection->beginTransaction(); try { $this->connection->prepare( <<execute(['id' => $eventId]); + UPDATE event_outbox + SET status = :status + WHERE id = :id AND status = 'processing' + SQL, + )->execute(['status' => $event->status->value, 'id' => $event->id]); - $this->insertStatusAudit($eventId, RawEventStatus::processed->value); + $this->insertStatusAudit($event->id, $event->status->value); - $this->commitIfStarted($startedTransaction); + $this->connection->commit(); } catch (Throwable $e) { - $this->rollBackIfStarted($startedTransaction); + $this->rollBackIfActive(); throw $e; } @@ -120,15 +122,15 @@ public function recoverStuckEvents(CarbonInterval $olderThan): int $stmt = $this->connection->prepare( <<= :threshold - ) - SQL, + SELECT e.id FROM event_outbox e + WHERE e.status = 'processing' + AND NOT EXISTS ( + SELECT 1 FROM event_outbox_status s + WHERE s.event_id = e.id + AND s.status = 'processing' + AND s.created_at >= :threshold + ) + SQL, ); $stmt->execute(['threshold' => $thresholdAt]); @@ -139,7 +141,7 @@ public function recoverStuckEvents(CarbonInterval $olderThan): int return 0; } - $startedTransaction = $this->beginTransactionIfNeeded(); + $this->connection->beginTransaction(); try { $updateStmt = $this->connection->prepare( @@ -158,11 +160,11 @@ public function recoverStuckEvents(CarbonInterval $olderThan): int $recovered++; } - $this->commitIfStarted($startedTransaction); + $this->connection->commit(); return $recovered; } catch (Throwable $e) { - $this->rollBackIfStarted($startedTransaction); + $this->rollBackIfActive(); throw $e; } @@ -177,13 +179,13 @@ private function fetchNextPendingRow(): ?array $stmt = $this->connection->prepare( <<execute([ @@ -200,9 +202,9 @@ private function insertStatusAudit(string $eventId, string $status, ?string $err { $stmt = $this->connection->prepare( <<execute([ @@ -213,27 +215,10 @@ private function insertStatusAudit(string $eventId, string $status, ?string $err ]); } - private function beginTransactionIfNeeded(): bool + /** Guards against the implicit-commit case where a DDL statement closed the transaction before the failure. */ + private function rollBackIfActive(): void { if ($this->connection->inTransaction()) { - return false; - } - - $this->connection->beginTransaction(); - - return true; - } - - private function commitIfStarted(bool $started): void - { - if ($started) { - $this->connection->commit(); - } - } - - private function rollBackIfStarted(bool $started): void - { - if ($started && $this->connection->inTransaction()) { $this->connection->rollBack(); } } @@ -259,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/Infrastructure/SqlRedeliveryStore.php b/src/Infrastructure/SqlRedeliveryStore.php deleted file mode 100644 index dc88908..0000000 --- a/src/Infrastructure/SqlRedeliveryStore.php +++ /dev/null @@ -1,316 +0,0 @@ -ensureRedeliverySchema(); - } - - #[Override] - public function schedule(RedeliveryRequest $request): void - { - $now = CarbonImmutable::now()->format('Y-m-d H:i:s.u'); - $errorMessage = self::formatError($request->lastError); - $nextRetryAtSql = $request->nextRetryAt->format('Y-m-d H:i:s.u'); - - $sql = match ($this->driverName()) { - 'mysql' => << << throw new RuntimeException('Unsupported database driver: ' . $this->driverName()), - }; - - $this->connection->prepare($sql)->execute([ - 'event_id' => $request->event->id, - 'listener' => $request->listener, - 'status' => RedeliveryStatus::PendingRetry->value, - 'attempt_number' => $request->attemptNumber, - 'next_retry_at' => $nextRetryAtSql, - 'last_error' => $errorMessage, - 'created_at' => $now, - 'updated_at' => $now, - ]); - } - - #[Override] - public function next(): ?DueRedelivery - { - $startedTransaction = $this->beginTransactionIfNeeded(); - - try { - $row = $this->fetchNextDueRow(); - - if ($row === null) { - $this->commitIfStarted($startedTransaction); - return null; - } - - $this->connection->prepare( - <<execute([ - 'status' => RedeliveryStatus::Dispatching->value, - 'updated_at' => CarbonImmutable::now()->format('Y-m-d H:i:s.u'), - 'event_id' => $row['event_id'], - 'listener' => $row['listener'], - ]); - - $this->commitIfStarted($startedTransaction); - - /** @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 new DueRedelivery( - event: $event, - listener: $row['listener'], - attemptNumber: (int) $row['attempt_number'], - ); - } catch (Throwable $e) { - $this->rollBackIfStarted($startedTransaction); - - throw $e; - } - } - - /** UPDATE is guarded by status='dispatching' so a row a sweep already reclaimed is not overwritten. */ - #[Override] - public function markFailedPermanently(string $eventId, string $listener, Throwable $lastError): void - { - $this->connection->prepare( - <<execute([ - 'new_status' => RedeliveryStatus::Failed->value, - 'current_status' => RedeliveryStatus::Dispatching->value, - 'last_error' => self::formatError($lastError), - 'updated_at' => CarbonImmutable::now()->format('Y-m-d H:i:s.u'), - 'event_id' => $eventId, - 'listener' => $listener, - ]); - } - - /** UPDATE is guarded by status='dispatching' so a row a sweep already reclaimed is not overwritten. */ - #[Override] - public function markSucceeded(string $eventId, string $listener): void - { - $this->connection->prepare( - <<execute([ - 'new_status' => RedeliveryStatus::Succeeded->value, - 'current_status' => RedeliveryStatus::Dispatching->value, - 'updated_at' => CarbonImmutable::now()->format('Y-m-d H:i:s.u'), - 'event_id' => $eventId, - 'listener' => $listener, - ]); - } - - #[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(); - } - - /** - * @return array{event_id: string, listener: string, attempt_number: int, event_name: string, event_status: string, event_payload: string, event_created_at: string, event_publish_at: string}|null - */ - private function fetchNextDueRow(): ?array - { - $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, attempt_number: int, 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); - - return $row === false ? null : $row; - } - - private static function formatError(Throwable $error): string - { - return $error::class . ': ' . $error->getMessage(); - } - - private function beginTransactionIfNeeded(): bool - { - if ($this->connection->inTransaction()) { - return false; - } - - $this->connection->beginTransaction(); - - return true; - } - - private function commitIfStarted(bool $started): void - { - if ($started) { - $this->connection->commit(); - } - } - - private function rollBackIfStarted(bool $started): void - { - if ($started && $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 - { - $driver = $this->connection->getAttribute(PDO::ATTR_DRIVER_NAME); - assert(is_string($driver)); - - return $driver; - } -} 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/Redelivery.php b/src/Redelivery.php deleted file mode 100644 index 70de12b..0000000 --- a/src/Redelivery.php +++ /dev/null @@ -1,136 +0,0 @@ -event, - listener: $request->listener, - status: RedeliveryStatus::PendingRetry, - attemptNumber: $request->attemptNumber, - nextRetryAt: $request->nextRetryAt, - lastError: self::formatError($request->lastError), - createdAt: $now, - updatedAt: $now, - ); - } - - public static function retrieve( - RawEvent $event, - string $listener, - RedeliveryStatus $status, - int $attemptNumber, - CarbonImmutable $nextRetryAt, - string $lastError, - CarbonImmutable $createdAt, - CarbonImmutable $updatedAt, - ): self { - return new self( - event: $event, - listener: $listener, - status: $status, - attemptNumber: $attemptNumber, - nextRetryAt: $nextRetryAt, - lastError: $lastError, - createdAt: $createdAt, - updatedAt: $updatedAt, - ); - } - - public function claimed(): self - { - return new self( - event: $this->event, - listener: $this->listener, - status: RedeliveryStatus::Dispatching, - attemptNumber: $this->attemptNumber, - nextRetryAt: $this->nextRetryAt, - lastError: $this->lastError, - createdAt: $this->createdAt, - updatedAt: CarbonImmutable::now(), - ); - } - - public function rescheduled(RedeliveryRequest $request): self - { - return new self( - event: $this->event, - listener: $this->listener, - status: RedeliveryStatus::PendingRetry, - attemptNumber: $request->attemptNumber, - nextRetryAt: $request->nextRetryAt, - lastError: self::formatError($request->lastError), - createdAt: $this->createdAt, - updatedAt: CarbonImmutable::now(), - ); - } - - public function markedFailedPermanently(Throwable $lastError): self - { - return new self( - event: $this->event, - listener: $this->listener, - status: RedeliveryStatus::Failed, - attemptNumber: $this->attemptNumber, - nextRetryAt: $this->nextRetryAt, - lastError: self::formatError($lastError), - createdAt: $this->createdAt, - updatedAt: CarbonImmutable::now(), - ); - } - - public function markedSucceeded(): self - { - return new self( - event: $this->event, - listener: $this->listener, - status: RedeliveryStatus::Succeeded, - attemptNumber: $this->attemptNumber, - nextRetryAt: $this->nextRetryAt, - lastError: $this->lastError, - createdAt: $this->createdAt, - updatedAt: CarbonImmutable::now(), - ); - } - - public function queuedForImmediateRetry(): self - { - $now = CarbonImmutable::now(); - - return new self( - event: $this->event, - listener: $this->listener, - status: RedeliveryStatus::PendingRetry, - attemptNumber: $this->attemptNumber, - nextRetryAt: $now, - lastError: $this->lastError, - createdAt: $this->createdAt, - updatedAt: $now, - ); - } - - private static function formatError(Throwable $error): string - { - return $error::class . ': ' . $error->getMessage(); - } -} 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 @@ +insertEvent(); - - $this->store->schedule(new RedeliveryRequest( - event: $event, - listener: 'App\\Listener', - attemptNumber: 1, - nextRetryAt: CarbonImmutable::now()->subSecond(), - lastError: new RuntimeException('boom'), - )); - - $due = $this->store->next(); - - self::assertNotNull($due); - self::assertSame($event->id, $due->event->id); - self::assertSame('App\\Listener', $due->listener); - self::assertSame(1, $due->attemptNumber); - self::assertSame($event->name, $due->event->name); + $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(new RedeliveryRequest( + $this->store->schedule(Redelivery::schedule( event: $event, listener: 'App\\Listener', attemptNumber: 1, @@ -77,7 +91,7 @@ public function test_next_claims_row_so_a_second_call_skips_it(): void $second = $this->store->next(); self::assertNotNull($first); - self::assertNull($second, 'second call must skip the row already in dispatching'); + self::assertNull($second); self::assertSame('dispatching', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); } @@ -85,7 +99,7 @@ public function test_next_excludes_rows_whose_retry_time_is_in_the_future(): voi { $event = $this->insertEvent(); - $this->store->schedule(new RedeliveryRequest( + $this->store->schedule(Redelivery::schedule( event: $event, listener: 'App\\Listener', attemptNumber: 1, @@ -96,81 +110,83 @@ public function test_next_excludes_rows_whose_retry_time_is_in_the_future(): voi self::assertNull($this->store->next()); } - public function test_schedule_is_idempotent_and_updates_on_repeat(): void + public function test_schedule_is_a_noop_when_a_row_already_exists(): void { $event = $this->insertEvent(); - $this->store->schedule(new RedeliveryRequest( + $this->store->schedule(Redelivery::schedule( event: $event, listener: 'App\\Listener', - attemptNumber: 1, - nextRetryAt: CarbonImmutable::now()->addMinute(), + attemptNumber: 2, + nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('first'), )); - $this->store->schedule(new RedeliveryRequest( + $this->store->schedule(Redelivery::schedule( event: $event, listener: 'App\\Listener', - attemptNumber: 2, - nextRetryAt: CarbonImmutable::now()->subSecond(), - lastError: new RuntimeException('second'), + 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, 'second schedule overwrote the first row'); - - $rows = $this->fetchAllRedeliveryRows($event->id); - self::assertCount(1, $rows, 'no duplicate rows for (event_id, listener)'); + self::assertSame(2, $due->attemptNumber); + self::assertStringContainsString('first', $due->lastError); + self::assertCount(1, $this->fetchAllRedeliveryRows($event->id)); } - public function test_mark_succeeded_finalises_a_claimed_row(): void + public function test_update_with_succeeded_finalises_a_claimed_row(): void { $event = $this->insertEvent(); - $this->store->schedule(new RedeliveryRequest( + $this->store->schedule(Redelivery::schedule( event: $event, listener: 'App\\Listener', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), )); - $this->store->next(); // claim + $claimed = $this->store->next(); + self::assertNotNull($claimed); - $this->store->markSucceeded($event->id, 'App\\Listener'); + $this->store->update($claimed->markSucceeded()); self::assertSame('succeeded', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); self::assertNull($this->store->next()); } - public function test_mark_succeeded_is_a_noop_when_row_is_not_in_dispatching(): void + public function test_update_is_a_noop_when_row_is_not_in_dispatching(): void { $event = $this->insertEvent(); - $this->store->schedule(new RedeliveryRequest( + $redelivery = Redelivery::schedule( event: $event, listener: 'App\\Listener', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), - )); + ); + $this->store->schedule($redelivery); - $this->store->markSucceeded($event->id, 'App\\Listener'); + $this->store->update($redelivery->claim()->markSucceeded()); self::assertSame('pending_retry', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); } - public function test_mark_failed_permanently_finalises_a_claimed_row(): void + public function test_update_with_failed_permanently_finalises_a_claimed_row(): void { $event = $this->insertEvent(); - $this->store->schedule(new RedeliveryRequest( + $this->store->schedule(Redelivery::schedule( event: $event, listener: 'App\\Listener', attemptNumber: 5, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), )); - $this->store->next(); // claim + $claimed = $this->store->next(); + self::assertNotNull($claimed); - $this->store->markFailedPermanently($event->id, 'App\\Listener', new RuntimeException('final')); + $this->store->update($claimed->markFailedPermanently(new RuntimeException('final'))); self::assertSame('failed', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); self::assertNull($this->store->next()); @@ -180,34 +196,35 @@ public function test_retry_now_re_queues_a_permanently_failed_row_preserving_att { $event = $this->insertEvent(); - $this->store->schedule(new RedeliveryRequest( + $this->store->schedule(Redelivery::schedule( event: $event, listener: 'App\\Listener', attemptNumber: 5, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), )); - $this->store->next(); // claim - $this->store->markFailedPermanently($event->id, 'App\\Listener', new RuntimeException('final')); + $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, 'attempt count is preserved across retryNow()'); + self::assertSame(5, $due->attemptNumber); } public function test_recover_stuck_redeliveries_resets_dispatching_rows_older_than_threshold(): void { $event = $this->insertEvent(); - $this->store->schedule(new RedeliveryRequest( + $this->store->schedule(Redelivery::schedule( event: $event, listener: 'App\\Listener', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), )); - $this->store->next(); // claim → status=dispatching + $this->store->next(); $this->backdateUpdatedAt($event->id, 'App\\Listener', CarbonImmutable::now()->subHour()); $recovered = $this->store->recoverStuckRedeliveries(CarbonInterval::minutes(30)); @@ -219,14 +236,14 @@ public function test_recover_stuck_redeliveries_resets_dispatching_rows_older_th public function test_recover_stuck_redeliveries_skips_rows_within_threshold(): void { $event = $this->insertEvent(); - $this->store->schedule(new RedeliveryRequest( + $this->store->schedule(Redelivery::schedule( event: $event, listener: 'App\\Listener', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), )); - $this->store->next(); // claim — updated_at is fresh + $this->store->next(); $recovered = $this->store->recoverStuckRedeliveries(CarbonInterval::minutes(30)); @@ -237,7 +254,7 @@ public function test_recover_stuck_redeliveries_skips_rows_within_threshold(): v public function test_recover_stuck_redeliveries_does_not_touch_pending_or_terminal_rows(): void { $event = $this->insertEvent(); - $this->store->schedule(new RedeliveryRequest( + $this->store->schedule(Redelivery::schedule( event: $event, listener: 'App\\Listener', attemptNumber: 1, @@ -248,7 +265,7 @@ public function test_recover_stuck_redeliveries_does_not_touch_pending_or_termin $recovered = $this->store->recoverStuckRedeliveries(CarbonInterval::minutes(30)); - self::assertSame(0, $recovered, 'pending_retry rows are not the sweeper\'s concern'); + self::assertSame(0, $recovered); self::assertSame('pending_retry', $this->fetchRedeliveryStatus($event->id, 'App\\Listener')); } @@ -263,10 +280,10 @@ private function backdateUpdatedAt(string $eventId, string $listener, CarbonImmu { $this->pdo->prepare( <<execute([ 'updated_at' => $newUpdatedAt->format('Y-m-d H:i:s.u'), 'event_id' => $eventId, diff --git a/tests/Feature/SqlEventStoreAuditTest.php b/tests/Feature/SqlEventStoreAuditTest.php index fa8901d..15250a1 100644 --- a/tests/Feature/SqlEventStoreAuditTest.php +++ b/tests/Feature/SqlEventStoreAuditTest.php @@ -41,52 +41,63 @@ public function test_next_transitions_event_to_processing_and_inserts_audit_row( $retrieved = $this->store->next(); self::assertNotNull($retrieved); - self::assertSame(RawEventStatus::processing, $retrieved->status, 'returned RawEvent reflects new status'); - self::assertSame('processing', $this->fetchEventStatus($event->id), 'event_outbox row was flipped to processing'); - - $rows = $this->fetchAuditRows($event->id); - self::assertCount(2, $rows); - self::assertSame('pending', $rows[0]['status']); - self::assertSame('processing', $rows[1]['status']); + 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); - $this->store->next(); + $claimed = $this->store->next(); + self::assertNotNull($claimed); - $this->store->markProcessed($event->id); + $claimed->markProcessed(); + $this->store->markProcessed($claimed); self::assertSame('processed', $this->fetchEventStatus($event->id)); - - $rows = $this->fetchAuditRows($event->id); - self::assertCount(3, $rows); - self::assertSame('pending', $rows[0]['status']); - self::assertSame('processing', $rows[1]['status']); - self::assertSame('processed', $rows[2]['status']); + 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(); // pending → processing + $this->store->next(); - // A second call must not pick up the processing row (it's not pending anymore). self::assertNull($this->store->next()); } - public function test_mark_processed_is_a_noop_when_row_is_not_in_processing(): void + 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); - // Skip next(); call markProcessed directly. The guard `WHERE status='processing'` should - // make the UPDATE a no-op for a row still in 'pending'. - $this->store->markProcessed($event->id); + $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), 'pending row is left untouched'); + self::assertSame('pending', $this->fetchEventStatus($event->id)); } /** diff --git a/tests/Feature/SqlEventStoreRecoverStuckTest.php b/tests/Feature/SqlEventStoreRecoverStuckTest.php index 7a3a413..e8d5539 100644 --- a/tests/Feature/SqlEventStoreRecoverStuckTest.php +++ b/tests/Feature/SqlEventStoreRecoverStuckTest.php @@ -27,7 +27,7 @@ public function test_recovers_processing_event_whose_audit_row_is_older_than_thr $event = self::createEvent(); $this->store->add($event); - $this->store->next(); // pending → processing + $this->store->next(); $this->backdateLatestProcessingAuditRow($event->id, CarbonImmutable::now()->subHour()); $recovered = $this->store->recoverStuckEvents(CarbonInterval::minutes(30)); @@ -40,9 +40,7 @@ public function test_does_not_recover_processing_event_whose_audit_row_is_within { $event = self::createEvent(); $this->store->add($event); - $this->store->next(); - // Don't backdate — audit row is fresh. $recovered = $this->store->recoverStuckEvents(CarbonInterval::minutes(30)); @@ -54,7 +52,6 @@ public function test_does_not_touch_pending_events(): void { $event = self::createEvent(); $this->store->add($event); - // Skip next() — row stays in pending. $recovered = $this->store->recoverStuckEvents(CarbonInterval::seconds(0)); @@ -66,8 +63,10 @@ public function test_does_not_touch_processed_events(): void { $event = self::createEvent(); $this->store->add($event); - $this->store->next(); - $this->store->markProcessed($event->id); + $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)); @@ -90,8 +89,10 @@ public function test_writes_a_recovery_audit_row(): void fn(array $row): bool => $row['error_message'] === 'Recovered from stuck processing state', )); - self::assertCount(1, $recoveryRows, 'exactly one recovery audit row was written'); - self::assertSame('pending', $recoveryRows[0]['status']); + self::assertSame( + [['status' => 'pending', 'error_message' => 'Recovered from stuck processing state']], + $recoveryRows, + ); } public function test_recovered_event_can_be_processed_again(): void @@ -137,10 +138,10 @@ private function backdateLatestProcessingAuditRow(string $eventId, CarbonImmutab { $this->pdo->prepare( <<execute([ 'event_id' => $eventId, 'created_at' => $newCreatedAt->format('Y-m-d H:i:s.u'), 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 95% rename from tests/Unit/RayEventStatusTest.php rename to tests/Unit/RawEventStatusTest.php index 68582ed..42affdd 100644 --- a/tests/Unit/RayEventStatusTest.php +++ b/tests/Unit/RawEventStatusTest.php @@ -5,7 +5,7 @@ use PHPUnit\Framework\TestCase; use Vesper\Tool\Event\RawEventStatus; -class RayEventStatusTest extends TestCase +class RawEventStatusTest extends TestCase { public function test_has_pending_case(): void { 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/InMemoryRedeliveryStoreTest.php b/tests/Unit/Redelivery/InMemoryRedeliveryStoreTest.php similarity index 55% rename from tests/Unit/InMemoryRedeliveryStoreTest.php rename to tests/Unit/Redelivery/InMemoryRedeliveryStoreTest.php index 858709b..6eb41b3 100644 --- a/tests/Unit/InMemoryRedeliveryStoreTest.php +++ b/tests/Unit/Redelivery/InMemoryRedeliveryStoreTest.php @@ -1,14 +1,15 @@ store->schedule(new RedeliveryRequest( - event: $this->event, - listener: 'App\\SomeListener', - attemptNumber: 1, - nextRetryAt: CarbonImmutable::now()->subSecond(), - lastError: new RuntimeException('boom'), - )); - - $due = $this->store->next(); - - self::assertNotNull($due); - self::assertSame($this->event->id, $due->event->id); - self::assertSame('App\\SomeListener', $due->listener); - self::assertSame(1, $due->attemptNumber); + $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(new RedeliveryRequest( + $this->store->schedule(Redelivery::schedule( event: $this->event, listener: 'App\\SomeListener', attemptNumber: 1, @@ -62,14 +78,14 @@ public function test_next_returns_earliest_scheduled_first(): void $eventEarly = TestEventFactory::retrieveOrderPlaced(['n' => 1]); $eventLate = TestEventFactory::retrieveOrderPlaced(['n' => 2]); - $this->store->schedule(new RedeliveryRequest( + $this->store->schedule(Redelivery::schedule( event: $eventLate, listener: 'App\\Late', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), )); - $this->store->schedule(new RedeliveryRequest( + $this->store->schedule(Redelivery::schedule( event: $eventEarly, listener: 'App\\Early', attemptNumber: 1, @@ -85,7 +101,7 @@ public function test_next_returns_earliest_scheduled_first(): void public function test_next_transitions_claimed_row_so_a_second_call_skips_it(): void { - $this->store->schedule(new RedeliveryRequest( + $this->store->schedule(Redelivery::schedule( event: $this->event, listener: 'App\\Listener', attemptNumber: 1, @@ -97,28 +113,29 @@ public function test_next_transitions_claimed_row_so_a_second_call_skips_it(): v $second = $this->store->next(); self::assertNotNull($first); - self::assertNull($second, 'a second call must skip the just-claimed row'); + self::assertNull($second); } - public function test_mark_succeeded_finalises_a_claimed_row(): void + public function test_update_with_succeeded_finalises_a_claimed_row(): void { - $this->store->schedule(new RedeliveryRequest( + $this->store->schedule(Redelivery::schedule( event: $this->event, listener: 'App\\Listener', attemptNumber: 1, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), )); - $this->store->next(); // claim + $claimed = $this->store->next(); + self::assertNotNull($claimed); - $this->store->markSucceeded($this->event->id, 'App\\Listener'); + $this->store->update($claimed->markSucceeded()); self::assertNull($this->store->next()); } - public function test_mark_succeeded_is_a_noop_when_row_is_not_in_dispatching(): void + public function test_update_is_a_noop_when_row_is_not_in_dispatching(): void { - $this->store->schedule(new RedeliveryRequest( + $this->store->schedule(Redelivery::schedule( event: $this->event, listener: 'App\\Listener', attemptNumber: 1, @@ -126,39 +143,50 @@ public function test_mark_succeeded_is_a_noop_when_row_is_not_in_dispatching(): lastError: new RuntimeException('boom'), )); - $this->store->markSucceeded($this->event->id, 'App\\Listener'); + $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); - $due = $this->store->next(); - self::assertNotNull($due, 'row remains claimable because markSucceeded did not apply'); + self::assertNotNull($this->store->next()); } - public function test_mark_failed_permanently_finalises_a_claimed_row(): void + public function test_update_with_failed_permanently_finalises_a_claimed_row(): void { - $this->store->schedule(new RedeliveryRequest( + $this->store->schedule(Redelivery::schedule( event: $this->event, listener: 'App\\Listener', attemptNumber: 5, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), )); - $this->store->next(); // claim + $claimed = $this->store->next(); + self::assertNotNull($claimed); - $this->store->markFailedPermanently($this->event->id, 'App\\Listener', new RuntimeException('final')); + $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(new RedeliveryRequest( + $this->store->schedule(Redelivery::schedule( event: $this->event, listener: 'App\\Listener', attemptNumber: 5, nextRetryAt: CarbonImmutable::now()->subSecond(), lastError: new RuntimeException('boom'), )); - $this->store->next(); // claim - $this->store->markFailedPermanently($this->event->id, 'App\\Listener', new RuntimeException('final')); + $claimed = $this->store->next(); + self::assertNotNull($claimed); + $this->store->update($claimed->markFailedPermanently(new RuntimeException('final'))); $this->store->retryNow($this->event->id, 'App\\Listener'); @@ -166,35 +194,44 @@ public function test_retry_now_re_queues_a_permanently_failed_row(): void self::assertNotNull($due); self::assertSame('App\\Listener', $due->listener); - self::assertSame(5, $due->attemptNumber, 'attempt count is preserved across retryNow()'); + self::assertSame(5, $due->attemptNumber); } - public function test_schedule_is_idempotent_on_event_id_listener(): void + public function test_schedule_is_a_noop_when_a_row_already_exists(): void { - $this->store->schedule(new RedeliveryRequest( + $this->store->schedule(Redelivery::schedule( event: $this->event, listener: 'App\\Listener', - attemptNumber: 1, - nextRetryAt: CarbonImmutable::now()->addMinute(), - lastError: new RuntimeException('boom'), + attemptNumber: 2, + nextRetryAt: CarbonImmutable::now()->subSecond(), + lastError: new RuntimeException('first'), )); - $this->store->schedule(new RedeliveryRequest( + $this->store->schedule(Redelivery::schedule( event: $this->event, listener: 'App\\Listener', - attemptNumber: 2, - nextRetryAt: CarbonImmutable::now()->subSecond(), - lastError: new RuntimeException('again'), + 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, 'rescheduling updates attempt count, does not insert a duplicate'); + self::assertSame(2, $due->attemptNumber); + self::assertStringContainsString('first', $due->lastError); } - public function test_mark_succeeded_is_a_noop_for_unknown_row(): void + public function test_update_is_a_noop_for_unknown_row(): void { - $this->store->markSucceeded('unknown-id', 'App\\Listener'); + $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()); } 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/RedeliveryRequestTest.php b/tests/Unit/RedeliveryRequestTest.php deleted file mode 100644 index 2664b3f..0000000 --- a/tests/Unit/RedeliveryRequestTest.php +++ /dev/null @@ -1,88 +0,0 @@ -addMinute(); - - $request = new RedeliveryRequest( - event: $event, - listener: 'App\\Listener', - attemptNumber: 1, - nextRetryAt: $nextRetryAt, - lastError: $error, - ); - - self::assertSame($event, $request->event); - self::assertSame('App\\Listener', $request->listener); - self::assertSame(1, $request->attemptNumber); - self::assertSame($nextRetryAt, $request->nextRetryAt); - self::assertSame($error, $request->lastError); - } - - public function test_rejects_attempt_number_below_one(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('attemptNumber must be at least 1'); - - new RedeliveryRequest( - event: TestEventFactory::retrieveOrderPlaced(), - listener: 'App\\Listener', - attemptNumber: 0, - nextRetryAt: CarbonImmutable::now(), - lastError: new RuntimeException('boom'), - ); - } - - public function test_rejects_negative_attempt_number(): void - { - $this->expectException(InvalidArgumentException::class); - - new RedeliveryRequest( - event: TestEventFactory::retrieveOrderPlaced(), - listener: 'App\\Listener', - attemptNumber: -3, - nextRetryAt: CarbonImmutable::now(), - lastError: new RuntimeException('boom'), - ); - } - - public function test_rejects_empty_listener(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('listener must not be empty'); - - new RedeliveryRequest( - event: TestEventFactory::retrieveOrderPlaced(), - listener: '', - attemptNumber: 1, - nextRetryAt: CarbonImmutable::now(), - lastError: new RuntimeException('boom'), - ); - } - - public function test_rejects_whitespace_only_listener(): void - { - $this->expectException(InvalidArgumentException::class); - - new RedeliveryRequest( - event: TestEventFactory::retrieveOrderPlaced(), - listener: " \t\n", - attemptNumber: 1, - nextRetryAt: CarbonImmutable::now(), - lastError: new RuntimeException('boom'), - ); - } -} diff --git a/tests/Unit/SequentialEventProcessorTest.php b/tests/Unit/SequentialEventProcessorTest.php index 0168e87..2afe572 100644 --- a/tests/Unit/SequentialEventProcessorTest.php +++ b/tests/Unit/SequentialEventProcessorTest.php @@ -2,22 +2,17 @@ namespace Test\Vesper\Tool\Event\Unit; -use Carbon\CarbonImmutable; use PHPUnit\Framework\TestCase; use RuntimeException; -use Test\Vesper\Tool\Event\_Fixtures\IgnorableExceptionStub; 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\InMemoryRedeliveryStore; use Vesper\Tool\Event\Infrastructure\SequentialEventProcessor; -use Vesper\Tool\Event\RedeliveryRequest; -use Vesper\Tool\Event\RedeliveryStore; -use Vesper\Tool\Event\Retry\RetryPolicy; class SequentialEventProcessorTest extends TestCase { @@ -55,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 @@ -80,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 @@ -127,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 @@ -165,50 +160,23 @@ 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 - { - $this->store->add(TestEventFactory::retrieveOrderPlaced(['order_id' => 1])); - - $subscriberA = function () {}; - $subscriberB = 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; - }); - - $subscribers = new EventSubscriberMap(); - $subscribers->subscribe('order.placed', $subscriberA); - $subscribers->subscribe('order.placed', $subscriberB); - - $processor = new SequentialEventProcessor($subscribers, hydrator: $hydrator); - $processor->process($this->store); - - self::assertCount(2, $hydrateCalls); - self::assertSame($subscriberA, $hydrateCalls[0]); - self::assertSame($subscriberB, $hydrateCalls[1]); - } - - // ── retry policy / redelivery ────────────────────────────────────────────── - public function test_calls_mark_processed_after_each_event_succeeds(): void { $store = new TrackingEventStore(); @@ -222,214 +190,42 @@ public function test_calls_mark_processed_after_each_event_succeeds(): void self::assertSame([$event->id], $store->markProcessedCalls); } - public function test_does_not_call_mark_processed_when_a_listener_throws_in_fail_fast_mode(): void + public function test_lets_listener_exceptions_propagate(): void { - $store = new TrackingEventStore(); - $event = TestEventFactory::retrieveOrderPlaced(); - $store->add($event); + $this->store->add(TestEventFactory::retrieveOrderPlaced()); $this->subscribers->subscribe('order.placed', function () { throw new RuntimeException('boom'); }); - try { - $this->processor->process($store); - self::fail('Expected exception was not thrown'); - } catch (RuntimeException) { - // expected - } - - self::assertSame([], $store->markProcessedCalls, 'event must remain in processing when dispatch propagates'); - } - - public function test_persists_failure_to_tracker_for_later_retry(): void - { - $event = TestEventFactory::retrieveOrderPlaced(); - $this->store->add($event); - - $exception = new RuntimeException('boom'); - $this->subscribers->subscribe('order.placed', function () use ($exception) { - throw $exception; - }); - - $tracker = $this->createMock(RedeliveryStore::class); - $tracker->expects($this->once()) - ->method('schedule') - ->with($this->callback( - fn(RedeliveryRequest $r): bool => - $r->event->id === $event->id - && $r->listener === 'Closure' - && $r->attemptNumber === 1 - && $r->lastError === $exception, - )); - $tracker->method('next')->willReturn(null); - - $processor = new SequentialEventProcessor( - $this->subscribers, - retryPolicy: self::policyReturning(retryDelayMs: 50), - redeliveryStore: $tracker, - ); - - $processor->process($this->store); - } - - public function test_propagates_failure_when_retry_policy_returns_null_and_tracker_configured(): void - { - $this->store->add(TestEventFactory::retrieveOrderPlaced()); - - $exception = new RuntimeException('always fails'); - $this->subscribers->subscribe('order.placed', function () use ($exception) { - throw $exception; - }); - - $tracker = $this->createMock(RedeliveryStore::class); - $tracker->expects($this->never())->method('schedule'); - $tracker->method('next')->willReturn(null); - - $processor = new SequentialEventProcessor( - $this->subscribers, - retryPolicy: self::policyReturning(retryDelayMs: 50, exhaustAfter: 0), - redeliveryStore: $tracker, - ); - - $this->expectExceptionObject($exception); - $processor->process($this->store); - } - - public function test_propagates_failure_when_no_tracker_configured(): void - { - $this->store->add(TestEventFactory::retrieveOrderPlaced()); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('boom'); - $exception = new RuntimeException('boom'); - $this->subscribers->subscribe('order.placed', function () use ($exception) { - throw $exception; - }); - - $processor = new SequentialEventProcessor( - $this->subscribers, - retryPolicy: self::policyReturning(retryDelayMs: 50), - ); - - $this->expectExceptionObject($exception); - $processor->process($this->store); - } - - public function test_calls_mark_succeeded_when_listener_succeeds_with_tracker_configured(): void - { - $event = TestEventFactory::retrieveOrderPlaced(); - $this->store->add($event); - - $this->subscribers->subscribe('order.placed', function () {}); - - $tracker = $this->createMock(RedeliveryStore::class); - $tracker->expects($this->once()) - ->method('markSucceeded') - ->with($event->id, 'Closure'); - $tracker->method('next')->willReturn(null); - - $processor = new SequentialEventProcessor($this->subscribers, redeliveryStore: $tracker); - $processor->process($this->store); - } - - public function test_ignored_exception_short_circuits_with_no_retry_no_propagation_no_tracker_calls(): void - { - $this->store->add(TestEventFactory::retrieveOrderPlaced()); - - $calls = 0; - $this->subscribers->subscribe('order.placed', function () use (&$calls) { - $calls++; - throw new IgnorableExceptionStub('expected domain failure'); - }); - - $tracker = $this->createMock(RedeliveryStore::class); - $tracker->expects($this->never())->method('schedule'); - $tracker->expects($this->never())->method('markFailedPermanently'); - $tracker->expects($this->never())->method('markSucceeded'); - $tracker->method('next')->willReturn(null); - - $processor = new SequentialEventProcessor( - $this->subscribers, - retryPolicy: self::policyReturning(retryDelayMs: 10), - redeliveryStore: $tracker, - ignoredExceptions: [IgnorableExceptionStub::class], - ); - - $processor->process($this->store); - - self::assertSame(1, $calls, 'listener ran exactly once and was not retried'); - } - - public function test_process_next_redelivery_dispatches_due_redelivery(): void - { - $event = TestEventFactory::retrieveOrderPlaced(); - - $tracker = new InMemoryRedeliveryStore(); - $tracker->schedule(new RedeliveryRequest( - event: $event, - listener: 'Closure', - attemptNumber: 2, - 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 SequentialEventProcessor($this->subscribers, redeliveryStore: $tracker); - $processor->processNextRedelivery(); - - self::assertNotNull($received, 'redelivery dispatch invoked the listener'); - self::assertNull($tracker->next(), 'redelivery row marked succeeded after dispatch'); + $this->processor->process($this->store); } - public function test_calls_mark_failed_permanently_when_no_more_retries_with_tracker(): void + private function resolverReturning(callable|object $listener): HandlerResolver { - $this->store->add(TestEventFactory::retrieveOrderPlaced()); - - $exception = new RuntimeException('boom'); - $this->subscribers->subscribe('order.placed', function () use ($exception) { - throw $exception; - }); - - $tracker = $this->createMock(RedeliveryStore::class); - $tracker->expects($this->once())->method('markFailedPermanently'); - $tracker->expects($this->never())->method('schedule'); - $tracker->method('next')->willReturn(null); - - $processor = new SequentialEventProcessor( - $this->subscribers, - redeliveryStore: $tracker, - ); + $resolver = $this->createStub(HandlerResolver::class); + $resolver->method('resolve')->willReturn($listener); - try { - $processor->process($this->store); - self::fail('expected exception'); - } catch (RuntimeException) { - // expected — base class is fail-fast - } + return $resolver; } /** - * Build a RetryPolicy stub that returns now()+$retryDelayMs for the first $exhaustAfter - * attempts, then null. + * @param array $payload */ - private static function policyReturning(int $retryDelayMs, int $exhaustAfter = 5): RetryPolicy - { - return new readonly class ($retryDelayMs, $exhaustAfter) implements RetryPolicy { - public function __construct( - private int $retryDelayMs, - private int $exhaustAfter, - ) {} - - public function nextRetryAt(int $previousAttempt): ?CarbonImmutable - { - if ($previousAttempt > $this->exhaustAfter) { - return null; - } - return CarbonImmutable::now()->addMilliseconds($this->retryDelayMs); - } - }; + 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 537053f..0000000 --- a/tests/Unit/SilentSequentialEventProcessorTest.php +++ /dev/null @@ -1,360 +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', - 'attempt' => 1, - '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', - 'attempt' => 1, - '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, - 'attempt' => 1, - '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', - 'attempt' => 1, - 'exception' => $exception, - ], - ); - - $this->processor->process($this->store); - - self::assertSame([2], $received); - } - - // ── retry / redelivery integration ───────────────────────────────────────── - - public function test_logs_and_marks_failed_permanently_when_retries_are_exhausted(): void - { - $event = TestEventFactory::retrieveOrderPlaced(); - $this->store->add($event); - - $exception = new RuntimeException('always'); - $this->subscribers->subscribe('order.placed', function () use ($exception) { - throw $exception; - }); - - $tracker = $this->createMock(RedeliveryStore::class); - $tracker->expects($this->once()) - ->method('markFailedPermanently') - ->with($event->id, 'Closure', $exception); - $tracker->method('next')->willReturn(null); - - $this->logger->expects($this->once()) - ->method('error') - ->with( - 'Failed to dispatch event to listener.', - [ - 'event' => 'order.placed', - 'event_id' => $event->id, - 'listener' => 'Closure', - 'attempt' => 1, - 'exception' => $exception, - ], - ); - - $hydrator = $this->createStub(EventHydrator::class); - $hydrator->method('hydrate')->willReturnCallback(fn(string $name, array $payload) => (object) $payload); - - $processor = new SilentSequentialEventProcessor( - subscribers: $this->subscribers, - logger: $this->logger, - hydrator: $hydrator, - redeliveryStore: $tracker, - ); - - $processor->process($this->store); - } - - public function test_ignored_exception_is_suppressed_with_no_log_or_tracker_call(): void - { - $this->store->add(TestEventFactory::retrieveOrderPlaced()); - - $this->subscribers->subscribe('order.placed', function () { - throw new IgnorableExceptionStub('expected domain failure'); - }); - - $tracker = $this->createMock(RedeliveryStore::class); - $tracker->expects($this->never())->method('schedule'); - $tracker->expects($this->never())->method('markFailedPermanently'); - $tracker->expects($this->never())->method('markSucceeded'); - $tracker->method('next')->willReturn(null); - - $this->logger->expects($this->never())->method('error'); - - $hydrator = $this->createStub(EventHydrator::class); - $hydrator->method('hydrate')->willReturnCallback(fn(string $name, array $payload) => (object) $payload); - - $processor = new SilentSequentialEventProcessor( - subscribers: $this->subscribers, - logger: $this->logger, - hydrator: $hydrator, - redeliveryStore: $tracker, - ignoredExceptions: [IgnorableExceptionStub::class], - ); - - $processor->process($this->store); - } - - // ── processNextRedelivery silent behaviour ───────────────────────────────── - - public function test_process_next_redelivery_logs_and_marks_failed_permanently_on_exhaustion(): void - { - $event = TestEventFactory::retrieveOrderPlaced(); - $tracker = new InMemoryRedeliveryStore(); - $tracker->schedule(new RedeliveryRequest( - event: $event, - listener: 'Closure', - attemptNumber: 1, - nextRetryAt: CarbonImmutable::now()->subSecond(), - lastError: new RuntimeException('previous failure'), - )); - - $exception = new RuntimeException('still broken'); - $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', - 'attempt' => 2, - 'exception' => $exception, - ], - ); - - $hydrator = $this->createStub(EventHydrator::class); - $hydrator->method('hydrate')->willReturnCallback(fn(string $name, array $payload) => (object) $payload); - - $processor = new SilentSequentialEventProcessor( - subscribers: $this->subscribers, - logger: $this->logger, - hydrator: $hydrator, - retryPolicy: self::policyExhaustedImmediately(), - redeliveryStore: $tracker, - ); - - $processor->processNextRedelivery(); - - self::assertNull($tracker->next(), 'row marked failed permanently — no longer due'); - } - - public function test_process_next_redelivery_silently_reschedules_when_retries_remain(): void - { - $event = TestEventFactory::retrieveOrderPlaced(); - $tracker = new InMemoryRedeliveryStore(); - $tracker->schedule(new RedeliveryRequest( - event: $event, - listener: 'Closure', - attemptNumber: 1, - nextRetryAt: CarbonImmutable::now()->subSecond(), - lastError: new RuntimeException('previous failure'), - )); - - $this->subscribers->subscribe('order.placed', function () { - throw new RuntimeException('still broken'); - }); - - $this->logger->expects($this->never())->method('error'); - - $hydrator = $this->createStub(EventHydrator::class); - $hydrator->method('hydrate')->willReturnCallback(fn(string $name, array $payload) => (object) $payload); - - $processor = new SilentSequentialEventProcessor( - subscribers: $this->subscribers, - logger: $this->logger, - hydrator: $hydrator, - retryPolicy: self::policyReturningFutureRetry(), - redeliveryStore: $tracker, - ); - - $processor->processNextRedelivery(); - - self::assertNull($tracker->next(), 'row rescheduled for the future — not due now'); - } - - private static function policyExhaustedImmediately(): RetryPolicy - { - return new readonly class implements RetryPolicy { - public function nextRetryAt(int $previousAttempt): ?CarbonImmutable - { - return null; - } - }; - } - - private static function policyReturningFutureRetry(): RetryPolicy - { - return new readonly class implements RetryPolicy { - public function nextRetryAt(int $previousAttempt): ?CarbonImmutable - { - return CarbonImmutable::now()->addMinute(); - } - }; - } -} 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 index b626b52..d0bdcc4 100644 --- a/tests/_Fixtures/IgnorableExceptionStub.php +++ b/tests/_Fixtures/IgnorableExceptionStub.php @@ -4,6 +4,4 @@ use RuntimeException; -class IgnorableExceptionStub extends RuntimeException -{ -} +class IgnorableExceptionStub extends RuntimeException {} diff --git a/tests/_Fixtures/ListenerA.php b/tests/_Fixtures/ListenerA.php index 3d1c8e0..b710ece 100644 --- a/tests/_Fixtures/ListenerA.php +++ b/tests/_Fixtures/ListenerA.php @@ -4,7 +4,5 @@ readonly class ListenerA { - public function __invoke(): void - { - } + public function __invoke(): void {} } diff --git a/tests/_Fixtures/ListenerB.php b/tests/_Fixtures/ListenerB.php index 3731156..8db9e90 100644 --- a/tests/_Fixtures/ListenerB.php +++ b/tests/_Fixtures/ListenerB.php @@ -4,7 +4,5 @@ readonly class ListenerB { - public function __invoke(): void - { - } + public function __invoke(): void {} } diff --git a/tests/_Fixtures/OrderPlacedStub.php b/tests/_Fixtures/OrderPlacedStub.php index c4a35ff..768eb4d 100644 --- a/tests/_Fixtures/OrderPlacedStub.php +++ b/tests/_Fixtures/OrderPlacedStub.php @@ -7,6 +7,5 @@ public function __construct( public int $orderId, public float $total, - ) { - } + ) {} } diff --git a/tests/_Fixtures/TrackingEventStore.php b/tests/_Fixtures/TrackingEventStore.php index 422d881..5590d6b 100644 --- a/tests/_Fixtures/TrackingEventStore.php +++ b/tests/_Fixtures/TrackingEventStore.php @@ -3,19 +3,15 @@ namespace Test\Vesper\Tool\Event\_Fixtures; use Vesper\Tool\Event\Infrastructure\InMemoryEventStore; +use Vesper\Tool\Event\RawEvent; -/** - * In-memory event store that records every markProcessed() call. Used by - * processor tests to verify the markProcessed lifecycle without standing up - * a SQL database. - */ class TrackingEventStore extends InMemoryEventStore { /** @var list */ public array $markProcessedCalls = []; - public function markProcessed(string $eventId): void + public function markProcessed(RawEvent $event): void { - $this->markProcessedCalls[] = $eventId; + $this->markProcessedCalls[] = $event->id; } } diff --git a/tests/prepend.php b/tests/prepend.php index 89b8d60..8bf99eb 100644 --- a/tests/prepend.php +++ b/tests/prepend.php @@ -1,4 +1,3 @@ Date: Sat, 16 May 2026 21:05:54 +0200 Subject: [PATCH 15/16] ci: run cs, stan, unit, and feature tests on pull requests GitHub Actions workflow that runs the four composer scripts (test:cs, test:stan, test:unit, test:feature) on every pull request. PHP 8.4 on ubuntu-latest with pdo_sqlite; feature tests use sqlite::memory so no service container is needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/ci.yml 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 From 8eece705cd56d00ca698b7481188245d0c6ff358 Mon Sep 17 00:00:00 2001 From: Jason Viljoen Date: Sat, 16 May 2026 21:07:24 +0200 Subject: [PATCH 16/16] Upgrade composer dependencies --- composer.lock | 154 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 103 insertions(+), 51 deletions(-) diff --git a/composer.lock b/composer.lock index 114551c..ef744d7 100644 --- a/composer.lock +++ b/composer.lock @@ -240,6 +240,53 @@ ], "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", "version": "1.0.0", @@ -656,16 +703,16 @@ }, { "name": "symfony/translation", - "version": "v8.0.8", + "version": "v8.0.10", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f" + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f", - "reference": "27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f", + "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.8" + "source": "https://github.com/symfony/translation/tree/v8.0.10" }, "funding": [ { @@ -745,20 +792,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+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.95.1", + "version": "v3.95.2", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/shim.git", - "reference": "f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a" + "reference": "319bd80c8db64ab5f7b79a19178299045bdb9957" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a", - "reference": "f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a", + "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.95.1" + "source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.95.2" }, - "time": "2026-04-12T17:00:34+00:00" + "time": "2026-05-15T09:21:09+00:00" }, { "name": "phpstan/phpstan", - "version": "2.1.53", + "version": "2.1.54", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/ef67586798c003274797b288a68b221e4270dca7", - "reference": "ef67586798c003274797b288a68b221e4270dca7", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", "shasum": "" }, "require": { @@ -1265,7 +1313,7 @@ "type": "github" } ], - "time": "2026-04-28T16:09:00+00:00" + "time": "2026-04-29T13:31:09+00:00" }, { "name": "phpunit/php-code-coverage", @@ -1614,16 +1662,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.23", + "version": "12.5.25", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969" + "reference": "792c2980442dfce319226b88fa845b8b6de3b333" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969", - "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/792c2980442dfce319226b88fa845b8b6de3b333", + "reference": "792c2980442dfce319226b88fa845b8b6de3b333", "shasum": "" }, "require": { @@ -1692,7 +1740,7 @@ "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.23" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.25" }, "funding": [ { @@ -1700,7 +1748,7 @@ "type": "other" } ], - "time": "2026-04-18T06:12:49+00:00" + "time": "2026-05-13T03:56:57+00:00" }, { "name": "sebastian/cli-parser", @@ -2653,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": { @@ -2675,7 +2723,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -2700,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": [ { @@ -2711,12 +2759,16 @@ "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",