feat(core): retry a failed operation in place - #364
Merged
Merged
Conversation
An operation carrying a `RetryPolicy` gets another attempt whenever it raises an error it calls retryable and its budget allows one: the failure is recorded as retried, the backoff is slept, and the same node executes again. An operation carrying none is attempted once, so nothing changes until something declares a policy. The policy is declared on the component whose unit it governs, which is what keeps one budget from meaning three things: an operation's retries its own execution, a source's is the default its assets inherit through `_resolve` alongside `dataset` and `normalizer`, and a job's is for its runs, which the platform will read in phase 2. Numbers live in the policy; whether an error is worth another attempt is behaviour, and lives on `Operation.retryable`. Only an exhausted or declined failure marks a node failed, so a node still working through its attempts cancels no dependents and does not trip `fail_fast`. `OPERATION_RETRIED` records the attempts that were retried, and `OPERATION_FAILED` keeps meaning exhausted, which is the same rule the hooks will follow one level up. Attempts are their own event rows: the deterministic id gains the attempt, left out when it is 1 so every id written before retries existed is unchanged. The multi-process worker owns its loop because only it sees the failures, and none of the reporting: the errors it retried travel back with the outcome and the parent replays them, so events and attempt counters match what happened in the child. By Digitl
The end-to-end pool test read this module's attempt counter as an earlier test had left it whenever the pool forks, which is the default on Linux: the child inherited a populated list, the asset succeeded on its first attempt, and no retry was recorded. macOS spawns, re-imports the module fresh and hid it. Clearing the counter in the test makes it read the same under both. By Digitl
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
`_handle_flushed` replayed the worker's retried attempts but nothing exercised it with a non-empty list, so a retry landing while the walk drains its in-flight work could have stopped emitting events unnoticed. The existing parametrized case covers both paths back into the parent at once. By Digitl
This was referenced Sep 17, 2026
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 1 of the retry system: an operation that fails is re-executed in place, within a declared attempt budget, without the run ever seeing the intermediate failure.
Implements the retry spec sections 3 and 4, following the phase 1 plan. Core only: nothing in the platform reads a policy yet.
Why
Production runs fail daily on transient vendor faults, and the only retry that exists is manual and whole-run. The cheapest level that can heal such a failure is the operation itself, seconds later, in the same run.
The shape
RetryPolicy(newinterloper/retry/package, exported asil.RetryPolicy) carries numbers only:max_attempts,delay,backoff,max_delay,jitter. Whether a given error is worth another attempt is behaviour, not configuration, so it lives onOperation.retryable(error)next toOperation.failure(), and exception types never enter serialized config.The policy is declared on the component whose unit it governs, which is what keeps one budget from meaning three things:
Operation.retrySource.retryJob.retryA source's policy reaches its assets through
Source._resolve, the existing one-line-per-default mechanism alongsidedataset,normalizerandmaterialization_strategy. Both decorators acceptretry=with no change, since they split**overridesagainst the class's fields.For the reviewer
Nothing retries yet. There is no instance-wide default, by design, so behaviour is unchanged until a component declares a policy. That is deliberate and recorded as a follow-up in the spec, with the two questions it needs answered first.
A retried attempt is not a verdict. Only an exhausted or declined failure calls
mark_failed, so a node still working through its attempts cancels no dependents and does not tripfail_fast.OPERATION_RETRIEDrecords the intermediate attempts andOPERATION_FAILEDkeeps meaning exhausted. That is the same rule the hooks will follow one level up in phase 2.Attempts are their own event rows.
RunState._operation_event_idis a uuid5 of(run_id, component_id, event_type), so without a change attempt 2's events would collide with attempt 1's and dedup away. The attempt now joins the key, and is left out when it is 1, so every id written before retries existed is unchanged and the host/child dedup still works.The multi-process worker owns its loop and none of the reporting. Only the child sees the failures, but the parent holds the
RunStateand emits every event, so the errors of retried attempts travel back in the result tuple (now six elements) and the parent replays them throughmark_retried. Counters and events therefore match what actually happened in the child.Two test-surface notes: two existing job tests assert the exact
config_schemaproperty set, which legitimately grows byretry; and I added an end-to-end test through a real process pool beyond the plan, because without it the parent's replay path was only ever exercised with an empty list.Verification
uv run ruff check,uv run ty checkanduv run pytestall pass: 2883 passed, 3 skipped, up 28 tests.Unrelated and pre-existing:
test_worker_adopts_and_releases_the_parent_span_contextfails when its file is run in isolation, tripping onassert "traceparent" in metadatabefore_workeris called. It is the known core global-state issue where telemetry is only initialised under the broader suite, and it passes in the runner directory and the full suite.Next
Phase 2 (platform) makes a failed run queue its own next attempt, moves backfill accounting onto stacks, and gates hooks on a stack's verdict.
By Digitl