Automatic retry & per-listener failure tracking, with composable dispatch - #9
Merged
jcviljoen merged 16 commits intoMay 16, 2026
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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<string, Redelivery>` 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…ocessor 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
jcviljoen
deleted the
feature/automatic-retry-and-failure-tracking-per-subscriber
branch
May 16, 2026 20:43
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds durable per-(event, listener) retries to the outbox, an audit trail and crash-recovery for in-flight rows, and refactors event processing into a small set of composable building blocks. Existing wiring keeps its old behaviour: retries are opt-in, the new dispatch composition has sensible defaults.
Per-listener retries
RedeliveryStoreinterface (in-memory + SQL) persists per-(event, listener) retry state.RetryPolicy(defaultNoRetryPolicy, plus a five-attemptExponentialBackoffRetryPolicy) is consulted only by the redelivery processor — the event processor never decides whether to retry.retryNow(eventId, listener)on the store for admin-driven immediate retries; attempt count is preserved so the policy's ceiling still applies.Composable dispatch
ListenerDispatcherinterface insrc/Dispatch/.DefaultListenerDispatcherresolves the handler, hydrates the payload, dispatches; swallows configuredignoredExceptions, rethrows the rest.RedeliveringListenerDispatcheris a decorator that catches a listener throw and schedules a fresh redelivery row.RedeliveryProcessorinterface mirroringEventProcessor.SequentialRedeliveryProcessorowns theRetryPolicyand decides per row whether to reschedule, mark succeeded, or mark failed permanently.SilentSequentialEventProcessorbecomesSilentEventProcessor— a generic decorator wrapping anyEventProcessor. A matchingSilentRedeliveryProcessorexists for the redelivery side.ListenerKeyutility normalises acallable|stringsubscriber to a single string identifier (class name or'Closure').The result: dev wires the bare
SequentialEventProcessor+DefaultListenerDispatcherand gets fail-fast behaviour. Prod wraps withSilentEventProcessorand usesRedeliveringListenerDispatcherfor durable per-listener failures.Outbox lifecycle + crash recovery
pending → processing → processed; redeliveries transitionpending_retry → dispatching → succeeded | failed. Two symmetric state machines;next()claims atomically.FOR UPDATE SKIP LOCKED; mark/update calls are guarded by the claimed status so a stale worker cannot overwrite a row a sweeper has already reset.EventStore::recoverStuckEvents(CarbonInterval)andSqlRedeliveryStore::recoverStuckRedeliveries(CarbonInterval)sweep rows wedged by crashed workers back to the pending state. Disjoint by status filter from the main drain loops, so the sweepers and processors do not contend.Audit trail
event_outbox_statustable.EventStore::markProcessed()now takes aRawEvent(wasstring $eventId) so the processor and the store share the same claimed entity end-to-end.Namespace reorganisation
Vesper\Tool\Event\Redelivery\*(Redelivery,RedeliveryStatus,RedeliveryStore,RedeliveryProcessor).src/Infrastructure/Redelivery/*; dispatch infra atsrc/Infrastructure/Dispatch/*.Docs
ListenerDispatcherdecorator, batch claim becomesnext(int $batchSize): list<...>on both stores).🤖 Generated with Claude Code