Skip to content

Automatic retry & per-listener failure tracking, with composable dispatch - #9

Merged
jcviljoen merged 16 commits into
mainfrom
feature/automatic-retry-and-failure-tracking-per-subscriber
May 16, 2026
Merged

Automatic retry & per-listener failure tracking, with composable dispatch#9
jcviljoen merged 16 commits into
mainfrom
feature/automatic-retry-and-failure-tracking-per-subscriber

Conversation

@jcviljoen

@jcviljoen jcviljoen commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

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

  • RedeliveryStore interface (in-memory + SQL) persists per-(event, listener) retry state.
  • Fully async: a listener throw is written straight to the redelivery table; the main outbox worker moves on.
  • RetryPolicy (default NoRetryPolicy, plus a five-attempt ExponentialBackoffRetryPolicy) 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

  • New ListenerDispatcher interface in src/Dispatch/.
    • DefaultListenerDispatcher resolves the handler, hydrates the payload, dispatches; swallows configured ignoredExceptions, rethrows the rest.
    • RedeliveringListenerDispatcher is a decorator that catches a listener throw and schedules a fresh redelivery row.
  • New RedeliveryProcessor interface mirroring EventProcessor. SequentialRedeliveryProcessor owns the RetryPolicy and decides per row whether to reschedule, mark succeeded, or mark failed permanently.
  • SilentSequentialEventProcessor becomes SilentEventProcessor — a generic decorator wrapping any EventProcessor. A matching SilentRedeliveryProcessor exists for the redelivery side.
  • New ListenerKey utility normalises a callable|string subscriber to a single string identifier (class name or 'Closure').

The result: dev wires the bare SequentialEventProcessor + DefaultListenerDispatcher and gets fail-fast behaviour. Prod wraps with SilentEventProcessor and uses RedeliveringListenerDispatcher for durable per-listener failures.

Outbox lifecycle + crash recovery

  • Events transition pending → processing → processed; redeliveries transition pending_retry → dispatching → succeeded | failed. Two symmetric state machines; next() claims atomically.
  • Worker-safe: MySQL claim uses 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) and SqlRedeliveryStore::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.
  • README's Scheduled jobs section documents the four cron tasks (event drain, redelivery drain, two sweepers) and how they avoid contention.

Audit trail

  • Every event status transition writes a row to the previously-unused event_outbox_status table.
  • EventStore::markProcessed() now takes a RawEvent (was string $eventId) so the processor and the store share the same claimed entity end-to-end.

Namespace reorganisation

  • Redelivery types moved to Vesper\Tool\Event\Redelivery\* (Redelivery, RedeliveryStatus, RedeliveryStore, RedeliveryProcessor).
  • Redelivery infra moved to src/Infrastructure/Redelivery/*; dispatch infra at src/Infrastructure/Dispatch/*.

Docs

  • README rewritten around the composition pattern and the per-environment wiring tables.
  • ROADMAP entries for metering and batch claim updated to fit the new shape (metering becomes a ListenerDispatcher decorator, batch claim becomes next(int $batchSize): list<...> on both stores).

🤖 Generated with Claude Code

jcviljoen and others added 14 commits April 28, 2026 00:09
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>
@jcviljoen jcviljoen changed the title Feature: Add automatic retry and per-subscriber failure tracking Automatic retry & per-listener failure tracking, with composable dispatch May 16, 2026
jcviljoen and others added 2 commits May 16, 2026 21:05
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
jcviljoen merged commit df22641 into main May 16, 2026
1 check passed
@jcviljoen
jcviljoen deleted the feature/automatic-retry-and-failure-tracking-per-subscriber branch May 16, 2026 20:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant