Skip to content

feat: retry and redrive dead effects and broadcasts - #56

Merged
cardmagic merged 11 commits into
mainfrom
feat/dead-letter-redrive
Sep 22, 2026
Merged

cardmagic merged 11 commits into
mainfrom
feat/dead-letter-redrive

Conversation

@cardmagic

Copy link
Copy Markdown
Owner

Ports cardmagic/solid-objects-ruby#78, which closes #73 there.

Why

DeadLetterManager covered message dead letters only. A dead effect or
broadcast reached status = 'dead' and nothing brought it back, so the recovery
path was an operator writing an UPDATE against a runtime table. Retry was also
one row at a time, and an incident produces dead rows in the hundreds.

The kind rides on the receiver

runtime.deadLetters              // messages, unchanged
runtime.deadLetters.effects
runtime.deadLetters.broadcasts

retry returns a dead row to pending with a zero attempt count, no claim, and
immediate availability, and keeps its id so a handler that deduplicates on the
effect id still sees the same key. It acts only on a dead row, so a second press
cannot double-enqueue and cannot take a row away from a worker that holds it.

Each scope authorizes under its own resource name: effect_dead_letters,
broadcast_dead_letters, redrives.

Redrive

const task = await runtime.deadLetters.effects.redrive({
  actorType: "payments",
  failedAfter: new Date(Date.now() - 6 * 60 * 60 * 1000),
  limit: 5_000,
  authorizationContext,
})
await task.cancel({ authorizationContext })

await runtime.redrives.find(task.id, { authorizationContext })
await runtime.redrives.all({ status: "running", authorizationContext })

Idempotency is a database constraint: the scope and filter digest go in a unique
active_scope column that holds the digest while the task runs and NULL once
it finishes, so the index is total rather than partial and two processes that
start the same redrive share one task.

runtime.run() advances one bounded batch per pass through a RedriveScheduler
component, each batch its own short transaction. A pass that moved rows pauses
redriveBatchPauseMilliseconds; an idle pass waits the idle polling interval,
so an empty table is not polled every few milliseconds.

A defect this port found, fixed in both runtimes

A redrive read its scope fresh on every pass, so a row it moved that failed
again landed straight back in it. With a handler that is still broken and no
limit, the task would move the same rows forever and never finish.

Ruby did not show it, because that test drives the redrive with no effect worker
running. Here the workers run beside it, so the churn was immediate: the
supervised test hung until it timed out.

A pass now takes only rows that were already dead when the task started. That
needs a failure stamp, so schema version 11 adds failed_at_ms to effects and
broadcasts, stamps it on the dead transition, and backfills existing dead rows
from their availability stamp. The same bound is now in
the Ruby branch,
keyed on updated_at.

Audit

Every retry and every redrive transition writes one row to
solid_objects_administration_events. The identity comes from the authorization
context through a new administrationIdentity option, defaulting to its
String form and bounded to 255 bytes. A refused caller writes nothing; a read
writes nothing.

The event id carries the timestamp and a counter, so a reader orders by it and
sees insertion order within a process. Two events can share a millisecond, and
across processes the stamp is all any log can offer.

Schema

Version 11: two tables and one column on each of effects and broadcasts. No
change to statuses, because dead and pending already exist.

Tests

27 new tests across two files, each watched failing first.

test/dead-letter-scopes.test.ts: a dead effect returns to pending and runs
again, it keeps its id, retrying a pending effect changes nothing, a dead
broadcast returns and delivers, runtime.deadLetters is unchanged, each scope
reads only its own kind, an unauthorized caller is refused, and each scope names
its own resource.

test/redrive.test.ts: bounded batches, the limit, running idempotency, a
separate task per scope and per filter set, a new task after the first finishes,
cancel leaving moved rows moved, both filters, reading tasks back, remaining
counted at read time, a row that died after the task started is not moved, a
running runtime draining a redrive with no caller driving it, refusal, and the
audit rows for retry, finish, cancel, identity, refusal, and reads.

Validation

Gate Result
vitest run 477 passed, 32 skipped
PostgreSQL 18 52 passed
MySQL 8.4 39 passed, 7 skipped
Cloudflare Workers 51 passed
Browser, Playwright Chromium 9 passed
Prettier, 3 check scripts, 4 tsc projects clean

BIGINT rather than INTEGER for failed_at_ms outside SQLite: the first
version overflowed a 32-bit column with a millisecond epoch, which PostgreSQL
and MySQL both caught.

Parity

docs/parity.md records the two differences. This runtime filters on the new
failed_at_ms stamp, while Ruby filters on the updated_at column Active
Record already maintains. The Durable Objects engine keeps its own message and
outbox tables inside each object, so these scopes cover the SQL backends here
and its deadLetters call is unchanged.

What this does not do

Automatic redrive on a schedule, which the issue puts out of scope, and the
dashboard UI for the new scopes.

🤖 Generated with Claude Code

cardmagic and others added 2 commits September 22, 2026 13:18
`DeadLetterManager` covered message dead letters only. A dead effect or
broadcast reached `status = 'dead'` and nothing brought it back, so the
recovery path was an operator writing an UPDATE against a runtime table.
Retry was also one row at a time, and an incident produces dead rows in
the hundreds.

`runtime.deadLetters` keeps its message meaning and answers `effects`
and `broadcasts`, so the kind rides on the receiver. `retry` returns a
dead row to pending, keeps its id, and acts only on a dead row, so a
second press cannot double-enqueue and cannot take a row from a worker.

`redrive` opens a durable task and returns at once. A unique index on
the active scope makes it idempotent in the database rather than in a
read followed by a write. `runtime.run()` advances one bounded batch per
pass, so a redrive never holds a transaction longer than one batch.

A redrive moves what was already dead when it started. Without that
bound, a row that fails again lands back in the same scope and a task
whose handler is still broken would move it forever. The bound needs a
failure stamp, so schema version 11 adds `failed_at_ms` to effects and
broadcasts and backfills it from the availability stamp.

Every retry and every task transition writes one row to
`solid_objects_administration_events` under the identity that asked for
it, through a new `administrationIdentity` option.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`RedriveManager.start` is public and ran no check of its own, so the
only guard was the scope that normally calls it. A caller that reached
the manager directly started a task unauthorized.

The check moves into `start`, under the scope's own resource name, so
there is one check and no way around it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; no new actionable issue was introduced since the previous review, and all previous findings are resolved.

Summary

Adds durable retry and redrive support for dead effects and broadcasts.

  • Introduces kind-specific dead-letter scopes with authorization, retry, filtering, and audit records.
  • Adds durable, idempotent redrive tasks processed in bounded transactional batches.
  • Adds schema version 11, including redrive and administration-event tables plus failure timestamps.
  • Integrates redrive scheduling into the runtime and documents configuration, operations, dashboard scope, and runtime parity.
  • The latest revision adds concise dashboard documentation for the new APIs and audit records.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Operator[Operator] -->|retry or redrive| Auth[Administration authorization]
  Auth -->|retry| Scope[Effect or broadcast dead-letter scope]
  Scope -->|dead to pending| Worker[Effect or broadcast worker]
  Auth -->|start| Task[Durable redrive task]
  Task --> Scheduler[Redrive scheduler]
  Scheduler -->|bounded transaction| Scope
  Auth --> Audit[Administration event]
  Task --> Audit
Loading

Reviews (6) · Last reviewed commit: "docs: point the dashboard at the new sco..."

Comment thread src/redrive.ts
Comment thread src/redrive.ts Outdated
Comment thread src/schema.ts Outdated
Comment thread src/dead-letter-scopes.ts Outdated
Comment thread src/dead-letter-scopes.ts
Comment thread src/configuration.ts Outdated
cardmagic and others added 2 commits September 22, 2026 13:49
Eight comments explained decisions the code below them already shows, or
carried a why that belongs in a commit message. They are gone, and the
reasoning each one held is in the commit that introduced it and in the
pull request body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`claim()` was an unlocked SELECT outside the batch transaction, so two
processes could take the same task. One revived the candidates, the
other saw zero rows changed and marked the whole task completed while
matching rows were still dead. The same gap let a batch revive rows
after a cancel had committed.

A pass now claims, moves, and closes inside one transaction, with
`FOR UPDATE SKIP LOCKED` where the database has it, and every write
guards on `status = 'running'`.

Each administration event moves into the transaction that causes it, so
a retry that names a row which does not exist writes nothing, and a
transition that commits cannot lose its event.

`failedAfter` and `limit` are validated. `new Date("nonsense").getTime()`
is NaN, which JSON turns into null, so an unusable date silently became
an unfiltered redrive over every dead row.

The migration no longer swallows every DDL error. It asks the schema
whether `failed_at_ms` exists and adds it only when missing, so a real
failure surfaces instead of being recorded as a completed migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

Comment thread src/schema.ts
Comment thread src/redrive.ts
`information_schema.columns` spans every schema on PostgreSQL and every
database on MySQL, so a same-named table elsewhere could answer for this
one. The column check would then skip the `ALTER`, record migration 11,
and leave every later query referencing a column that is not there.

The lookup narrows to `current_schema()` or `DATABASE()`, as the other
metadata queries in this repository already do.

`RedriveManager.start` and the filter parameters take the types the rest
of the code uses, rather than restating `unknown`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

cardmagic and others added 3 commits September 22, 2026 14:25
A retry of an effect or a broadcast wrote an administration event and a
retry of a message did not, so the one path that existed before this
branch was the one the audit missed. The Ruby port records all three,
and comparing the two suites is what surfaced it.

The event goes in the same transaction as the retry, so a retry that
commits cannot lose its event and a retry that raises cannot leave one.

The suite gains the six cases the Ruby side already covered: a dead
transmit effect replaying, a scope reading only dead rows, a cancel that
cannot overwrite a finished task, a task reported as a frozen value, and
audit rows for a broadcast and a message retry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Ruby suite covers a retry that raises after the lookup succeeds, in
both the scope and the message path. The TypeScript suite covered only
the lookup failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

Comment thread src/repository.ts
Comment thread docs/parity.md Outdated
Comment thread test/dead-letter-scopes.test.ts Outdated
cardmagic and others added 2 commits September 22, 2026 14:45
The parity round added a second "Dead letters, retry, and redrive"
section beside the one already there. Only the newer, complete one
remains.

The new tests annotated observables, effect arguments, and result arrays
as `unknown`. They name the shapes they actually carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An event records an authorized press, not a state transition, and the
redrive transitions are the opposite. The rule was implicit in the
tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

The Ruby dashboard guide says the dashboard does not yet surface dead
effects and broadcasts, and where the API for them lives. This one did
not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

@cardmagic
cardmagic merged commit 96c66e6 into main Sep 22, 2026
19 checks passed
@cardmagic
cardmagic deleted the feat/dead-letter-redrive branch September 22, 2026 22:45
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.

Dead-letter retry for effects and broadcasts, and bulk redrive

1 participant