feat: retry and redrive dead effects and broadcasts - #56
Merged
Merged
Conversation
`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>
|
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>
Owner
Author
|
@greptileai review |
`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>
Owner
Author
|
@greptileai review |
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>
Owner
Author
|
@greptileai review |
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>
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>
Owner
Author
|
@greptileai review |
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.
Ports cardmagic/solid-objects-ruby#78, which closes #73 there.
Why
DeadLetterManagercovered message dead letters only. A dead effect orbroadcast reached
status = 'dead'and nothing brought it back, so the recoverypath was an operator writing an
UPDATEagainst a runtime table. Retry was alsoone row at a time, and an incident produces dead rows in the hundreds.
The kind rides on the receiver
retryreturns a dead row to pending with a zero attempt count, no claim, andimmediate 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
Idempotency is a database constraint: the scope and filter digest go in a unique
active_scopecolumn that holds the digest while the task runs andNULLonceit 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 aRedriveSchedulercomponent, 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_msto effects andbroadcasts, 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 authorizationcontext through a new
administrationIdentityoption, defaulting to itsStringform and bounded to 255 bytes. A refused caller writes nothing; a readwrites 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
deadandpendingalready 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 runsagain, it keeps its id, retrying a pending effect changes nothing, a dead
broadcast returns and delivers,
runtime.deadLettersis unchanged, each scopereads 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, aseparate 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
vitest runtscprojectsBIGINTrather thanINTEGERforfailed_at_msoutside SQLite: the firstversion overflowed a 32-bit column with a millisecond epoch, which PostgreSQL
and MySQL both caught.
Parity
docs/parity.mdrecords the two differences. This runtime filters on the newfailed_at_msstamp, while Ruby filters on theupdated_atcolumn ActiveRecord 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
deadLetterscall 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