feat(db)!: retry a failed run, and report the stack's verdict - #365
Merged
Merged
Conversation
A failed run queues its own next attempt when its target job declares a policy. That happens in `RunStore.complete`, the single terminal path every failure takes (executor verdict, executor exception, launch failure, reaper timeout), inside the transaction that marks the run failed. Doing it there rather than in a sweeping controller is what makes "has a successor" true the instant a run fails, so a doomed attempt never looks final to anything reading the table. A run is one attempt; `root_run_id` groups the attempts of one unit of work into a stack, and `scheduled_for` holds the backoff, honoured by the queue's claim so a retry waits without needing a status of its own and without holding the head of the line. The root cannot be a column default, since it references the row's own id, and a `table=True` model skips validation, so a `before_insert` listener owns the invariant and no creation site has to know about it. Backfills finalize on stacks: the verdict reads each stack's latest attempt, so an attempt a later one healed no longer condemns the batch, while a queued successor still counts as in flight and keeps it open. The `executions` view ranks by attempt before severity for the same reason, and gains an `attempts` column; `operation_retried` counts toward it without ever winning the verdict. Hooks observe a verdict, never an attempt: the sweep skips a failed run whose successor already exists, which needs no knowledge of budgets or backoff. The context gains the stack's position so a message can say the work succeeded on the second attempt or failed after three. Nothing retries until a job declares a policy: there is no instance-wide default. By Digitl
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
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.
What
Phase 2 of the retry system: a failed run queues its own next attempt, backfills and hooks report the stack's outcome rather than any one attempt's.
Implements the retry spec sections 5 to 8, following the phase 2 plan. Builds on phase 1 (#364), which added the policy and operation-level retry.
The shape
A run is one attempt; a stack is the attempts of one unit of work, keyed by a new
root_run_id.scheduled_forholds the backoff.The successor is created in
RunStore.complete, the single terminal path every failure takes: executor verdict, executor exception, queue launch failure, reaper timeout. Doing it there rather than in a sweeping controller is the load-bearing choice, because it makes "has a successor" true in the same transaction that marks the run failed. That is what lets the hook evaluator gate on the successor's existence without knowing anything about budgets or backoff, with no window in which a doomed attempt looks final.Everything else follows from the stack:
scheduled_forhas not passed, so a retry waits without a status of its own and without holding the head of the queue.lookbackwindow is a backfill.executionsview: ranks by attempt before severity, so a healed operation reads as the success it ended as instead of being outranked forever by its own first attempt. Gains anattemptscolumn.Nothing retries until a job declares a policy. There is no instance-wide default, by design.
For the reviewer
Three constraints found during implementation, each of which would have shipped a bug:
The migration must be idempotent.
create_allbuilds tables from the models and then runs the chain, so on a fresh database the columns already exist by the time 004 runs. I confirmed the non-idempotent form fails (column "root_run_id" of relation "runs" already exists) before fixing it. WithoutIF NOT EXISTSthis breaksmake dev-resetand every new deployment.attemptsis the view's last column.CREATE OR REPLACE VIEWmay only append; inserting a column mid-list reads to Postgres as renaming the one that was there, and it refuses. Only a live server catches this, which is why there is now an integration test for the view rather than relying on the SQLite stand-in that exercises the mapping, not the query.root_run_idis stamped by abefore_insertlistener, not at each creation site. It cannot be a column default because it references the row's own id, and atable=Truemodel skips pydantic validation so a validator never fires. The plan called for assigning it at every creation site; there are 37Run(...)constructions, and every future one would have been a trap. All 37 are untouched.One existing test changed legitimately: the hook metadata assertion, which now carries
attemptandattempts.Migration
Revision
004: two columns, a recursive backfill folding existingretry_ofchains into stacks, two indexes, and the view replacement. A run whose predecessor was deleted becomes its own root, which is correct: its lineage is gone.Verified against a populated database as well as a fresh one. On a populated one all existing runs came out correctly self-rooted with no nulls.
Verification
uv run ruff check,uv run ty checkanduv run pytest: 2905 passed, 9 skipped. The 9 skips are the Postgres integration tests, which pass against a live server:INTERLOPER_TEST_POSTGRES_DSN=... uv run pytest packages/interloper-db/tests/migrations -m integrationgives 9 passed.Next
Phase 3 (surfaces) makes the runs listing stack-native and teaches the app to read a stack as one piece of work.
By Digitl