Deterministic durable ids under concurrency via task lineage - #438
Draft
Tomperez98 wants to merge 1 commit into
Draft
Deterministic durable ids under concurrency via task lineage#438Tomperez98 wants to merge 1 commit into
Tomperez98 wants to merge 1 commit into
Conversation
A workflow running durable chains as plain asyncio tasks recovered the
wrong values after replay: child ids were minted from one shared
per-execution seq counter advanced at call time, and under concurrency
the global call order flips between the live pass (wall-clock decides
which sibling settles first) and the replay pass (instant recovery
decides). Same logical op, different id, another op's stored value.
Fix: key ids on task structure instead of call order. A loop task
factory (resonate/lineage.py) stamps every task with a lineage path --
its parent's path plus a spawn index -- assigned synchronously inside
create_task, so paths reflect program order, which is replay-stable.
Context._next_id then counts per path relative to the state's anchor
task, minting {id}.t{i}...t{j}.{seq}. Sequential code has an empty path
and keeps the flat {id}.{seq} ids unchanged.
The spawn counter is re-armed at the user-code boundary
(invoke_with_retry) so tasks the SDK or the network stack spawns
beforehand cannot skew user task indices. Untracked or foreign tasks
fall back to the empty path (the old behavior).
Bare create_task / gather / TaskGroup now replay deterministically with
no new API and no user-supplied ids.
See docs/concurrent-id-determinism.md for the full diagnosis.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tomperez98
marked this pull request as draft
July 2, 2026 20:58
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.
The bug
A workflow that runs durable chains concurrently returns the wrong values after replay — deterministically wrong, every run:
Child ids were minted from one shared per-execution counter (
Context._next_id) advanced atctx.runcall time. That maps a logical op to the same id on every replay only if the global call order is identical on every replay — true for sequential code, false under concurrency: which chain reaches its secondctx.runfirst is gated on which first-stage future settled first, and that is decided by wall-clock on the live pass but by instant promise recovery on the replay pass. The orders disagree, the ids swap, and each chain recovers the other chain's durably stored value.Not a
gatherbug (create_task,TaskGroup,ensure_futureall reproduce it), and not fixable by the creationChain— ids already follow call order; the defect is that call order itself is not replay-stable. Full diagnosis indocs/concurrent-id-determinism.md.The fix
Key ids on task structure instead of call order. Two facts are replay-stable, inductively, under the same determinism assumption sequential code already relies on:
asyncio.create_taskis called synchronously from the parent task, so within one task, spawn order is program order;New module
resonate/lineage.py: a loop task factory (installed idempotently fromCore.execute_until_blocked_inner, wrapping any pre-installed factory) stamps every task with a lineage path — its parent's path plus a spawn index — assigned synchronously insidecreate_task, before the event loop has any say in scheduling. Carried in contextvars, so it survives eager task factories and inline-awaited coroutines share their caller's path.Context._next_idthen keys its seq counter per lineage path relative to the state's anchor (the task its body runs in):The id is a pure function of (task path, per-path call index) — both replay-stable — so the swap is structurally impossible. The spawn counter is re-armed at the user-code boundary (
invoke_with_retry, per attempt) so tasks the SDK or the network stack (aiohttp/nats internals) spawns from the same task beforehand cannot skew user task indices.Bare
create_task/gather/TaskGroup/wait_fornow replay deterministically — no new API, no combinator, no user-supplied ids. Sequential code has an empty path and keeps the exact flat{id}.{seq}ids it always had.Trade-offs
loop.set_task_factoryis installed on whatever loop runs workflows — usually the application's. It wraps any existing factory and is a no-op for untracked tasks, but it is a process-visible mutation, and if something later replaces the factory, tracking silently degrades (see next point).create_taskcall site — including SDK-internal spawns likectx.sleep's background task — shifts spawn indices and therefore ids. This is the same class of caveat the flat seq already had for statement order (in-flight workflows across a redeploy that changes workflow structure can mis-recover), but the sensitive surface now includes task-spawn sites, and an SDK upgrade that changes its own internal spawn pattern inside user tasks would shift indices too.create_taskonly for whichever future finished first) makes spawn order itself nondeterministic — out of scope, same contract as sequential code's determinism assumption.t{n}segment per level. Thetmarker keeps path segments from ever colliding with child-context promise ids (a promise id always ends in a numeric seq segment).Compared to the alternatives from the design doc: a
ctx.gathercombinator (option B) forces an API and leaves raw asyncio silently unsafe; deterministic replay via recorded settlement order (option C) needs server support and a scheduler-gate in the runtime core. This sits in between: option C's ergonomics (bare asyncio works) at roughly option B's implementation cost, paid for with the structural-id caveats above.Testing
tests/test_lineage.py— unit tests for the lineage module (creation-order indices, nesting, gather argument order, inline awaits, boundary reset, untracked/foreign fallback, factory wrap + idempotence).test_run_concurrent_task_chains_replay_deterministicallyintests/test_resonate.py— end-to-end suspend/replay race against the in-process LocalNetwork; fails with the old id scheme, passes now.wfabove, printing minted ids per pass) run 10/10 consecutive passes against a realresonate devserver; ids identical across live and replay passes. Fulljust examplessuite passes against the same server.🤖 Generated with Claude Code