Skip to content

Python: carry fan-in edge buffers through checkpoint restore - #7948

Open
Yashvant Mahadev Hange (YashvantHange) wants to merge 5 commits into
microsoft:mainfrom
YashvantHange:python-restore-fan-in-buffers
Open

Python: carry fan-in edge buffers through checkpoint restore#7948
Yashvant Mahadev Hange (YashvantHange) wants to merge 5 commits into
microsoft:mainfrom
YashvantHange:python-restore-fan-in-buffers

Conversation

@YashvantHange

Copy link
Copy Markdown
Contributor

Motivation & Context

A fan-in holds messages in FanInEdgeRunner._buffer until every source has produced one. That buffer is neither
checkpointed nor reset when a workflow is restored, and both halves of that cause a bug:

  • Buffered messages are lost. The runner context drains a message on delivery, so once the fan-in buffers it the
    buffer is the only place it exists. A fan-in whose sources arrive in different supersteps holds messages across a
    superstep boundary, so a checkpoint taken there omits them. Resuming on a rebuilt workflow leaves a fan-in that is
    permanently short and never fires.
  • Stale messages survive. A run that fails mid-superstep can leave messages from a subset of sources in the
    buffer. Restoring an earlier checkpoint re-executes those sources, so the same message is delivered twice, and the
    duplicate can complete the fan-in before the restored superstep has produced all of its messages.

Description & Review Guide

Edge runner delivery state now travels with the checkpoint, under the reserved shared-state key _edge_state,
alongside the existing _executor_state.

  • What are the major changes?

    • EdgeRunner gains state_key, snapshot_state(), and restore_state(). The base implementations hold no
      state, so every runner other than the fan-in is unaffected.
    • FanInEdgeRunner implements them over its buffer.
    • RunnerImpl captures edge state in _prepare_checkpoint_state (the single path behind both
      create_checkpoint_if_enabled and build_checkpoint) and, in both restore_from_checkpoint and
      restore_checkpoint, resets every runner before reapplying what the checkpoint held. The reset runs even for
      runners the checkpoint has no entry for, which is what discards a buffer left by an interrupted run.
    • Tests cover the runner round trip, the discarded stale buffer, a workflow resumed from the boundary where the
      fan-in holds only one branch, and the same case inside a sub-workflow resumed through its parent's checkpoint.
  • What is the impact of these changes?

    • state_key is derived from the group's topology rather than EdgeGroup.id, which defaults to a random UUID and
      so differs between two instances of the same workflow definition - restoring onto a rebuilt instance is the
      normal case. Builder validation rejects two edges sharing a source -> target pair anywhere in the workflow, so
      no two groups can produce the same key.
    • A checkpoint written before this change has no _edge_state entry: every runner is reset and nothing is
      reapplied, which is the old behavior minus the stale buffer.
    • Buffered messages now pass through State, which deep-copies on write and again on export, where in-flight
      messages in WorkflowCheckpoint.messages do not. This matches how executor state is already handled and costs a
      copy per checkpointed superstep for a fan-in that is mid-aggregation.
    • Calling run() again on a workflow instance whose fan-in still holds messages is out of scope here. That is not
      a restore, and a pause-and-continue on the same instance (run(responses=...) after an idle-with-pending-
      requests stop) legitimately depends on the buffer surviving, so telling the two apart is a separate change.

Related Issue

Fixes #7371

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change.

A fan-in buffers messages until every source has produced one. That buffer
was neither checkpointed nor reset on restore, so buffered messages were
lost when resuming a rebuilt workflow, and a buffer left behind by a run
that failed mid-superstep survived the restore and duplicated deliveries.

Edge runner delivery state now travels with the checkpoint under the
reserved shared-state key _edge_state. Both restore paths reset every edge
runner before reapplying what the checkpoint held, so a checkpoint written
before this change resets the buffer instead of leaking it.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds checkpoint persistence and restoration for partially filled fan-in edge buffers.

Changes:

  • Adds topology-keyed edge-runner checkpoint state.
  • Restores or clears fan-in buffers during checkpoint recovery.
  • Adds runner, workflow, and sub-workflow regression tests.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
python/packages/core/agent_framework/_workflows/_const.py Defines the reserved edge-state key.
python/packages/core/agent_framework/_workflows/_checkpoint.py Documents edge state in checkpoints.
python/packages/core/agent_framework/_workflows/_edge_runner.py Snapshots and restores fan-in buffers.
python/packages/core/agent_framework/_workflows/_runner.py Integrates edge state into checkpoint lifecycle.
python/packages/core/tests/workflow/test_runner.py Tests buffer restoration and stale-state clearing.
python/packages/core/tests/workflow/test_checkpoint.py Tests workflow restoration from a partial fan-in.
python/packages/core/tests/workflow/test_sub_workflow.py Tests fan-in restoration within sub-workflows.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread python/packages/core/agent_framework/_workflows/_edge_runner.py Outdated
Executor ids are only required to be non-empty, so joining source and
target ids with "->" and "," let two different fan-in groups produce the
same key: sources ['a', 'b->t,c'] and ['a->t,b', 'c'] into the same target
collide. One group's buffer snapshot would then overwrite the other's, and
the restore would hand both runners the same messages. JSON-encode the
sorted pairs instead.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

@moonbox3 on PR microsoft#7948: _run_iteration's asyncio.gather calls did not cancel
their other tasks when one raised, so a still-in-flight fan-in delivery
could resume after restore_checkpoint/restore_from_checkpoint had already
reset the FanInEdgeRunner's buffer on the same runner instance, appending
a message from the failed, never-checkpointed superstep into the freshly
restored buffer.

Confirmed the race is real before fixing it: reproduced it with a test
that fails a sibling source's delivery while another source's fan-in
delivery is parked mid-flight, restores from a checkpoint taken before
either message was sent, then releases the parked delivery. Without the
fix it appends the stale message into the just-restored buffer, and the
fan-in later fires using that stale value instead of the fresh resumed
delivery. Added _gather_cancelling_siblings_on_error to replace both
gather() call sites in _run_iteration, so a failing delivery cancels and
awaits every other one before the exception propagates.
Comment thread python/packages/core/agent_framework/_workflows/_runner.py Outdated
@moonbox3 follow-up on PR microsoft#7948: the previous fix wrapped _run_iteration's
two gather() sites (across sources, and across edge runners for one
source), but FanOutEdgeRunner.send_message has its own separate
asyncio.gather() across one message's fan-out targets. When a fan-out has
a single edge runner for its source - the common case - that inner gather
is the only level where one target's failure has a sibling to race
against, so the outer fix never reached it.

Confirmed the race is real before fixing it: reproduced a fan-out target
still executing when its sibling fails, calling ctx.send_message after
restore_checkpoint has already cleared RunnerContext._messages, landing
its stale output in the queue the restore reset for the resumed run. The
test fails on the pre-fix code and passes after.

Moved the cancel-and-await helper (renamed gather_cancelling_siblings_on_error,
no longer private) into _edge_runner.py, since _runner.py already imports
from there and the reverse would be circular, and used it at the fan-out
gather site too.
Retroactive check on our own claim, per mistake microsoft#12: this PR's review
reply said the other gather() sites in _workflow.py/_workflow_executor.py
"hold no cross-invocation buffered state a restore can race with." Tested
rather than re-argued it.

_workflow.py:1034 (send_request_info_response) survives: it pops a still-
pending event before writing anything, and a restore clears pending
events too, so an orphaned write fails closed (raises) instead of landing.
Reproduced directly against InProcRunnerContext to confirm.

_workflow_executor.py's four gather() sites do not survive. ctx.send_message
and ctx.yield_output/add_event write into RunnerContext state (_messages,
the event queue) with no equivalent precondition check. Reproduced the
same shape as the fan-in/fan-out bugs: a sub-workflow output still being
forwarded when a sibling output's send fails calls ctx.send_message after
a checkpoint restore has already cleared the parent's message queue,
landing stale output in it. Fixed all four sites with the same
gather_cancelling_siblings_on_error helper already used in _run_iteration
and FanOutEdgeRunner - moved to _edge_runner.py in the prior commit
specifically so _workflow_executor.py could import it without a cycle.
@YashvantHange

Copy link
Copy Markdown
Contributor Author

Evan Mattson (@moonbox3) — flagging this myself rather than waiting for you to find it: after fixing the fan-out gather you reported, I went back and re-examined my own reply from earlier in this thread — the one that excluded "a couple of other gather() sites in _workflow.py/_workflow_executor.py" with "none of them hold cross-invocation buffered state a restore can race with." I hadn't tested that, I'd just reasoned it the same way I got the fan-out exclusion wrong. So I went and tested each one instead of trusting the line I'd already written.

_workflow_executor.py's four gather() sites were wrong to exclude — same bug as the fan-out one, now fixed. _process_workflow_result's output-forwarding (ctx.send_message/ctx.yield_output for sub-workflow outputs, plus the intermediate-output forwarder) and on_checkpoint_restore's legacy request-event replay all write into RunnerContext state (_messages, the event queue) with no precondition gate. Reproduced it directly on the clearest one: a sub-workflow output still being forwarded when a sibling output's send fails calls ctx.send_message after a checkpoint restore has already cleared the parent's message queue, landing stale output in it — same shape as your fan-out repro, just one layer up. Fixed in c7453aff using the same gather_cancelling_siblings_on_error helper at all four sites (moved it into _edge_runner.py in the prior commit specifically so this file could import it without a cycle). New regression test (test_workflow_executor_orphaned_output_forward_cannot_repopulate_a_restored_message_queue) fails on the pre-fix code, passes after.

_workflow.py:1034 (send_request_info_response) is the one site that actually holds up — tested, not assumed. It pops the pending request event before writing anything and raises if it's missing; since a restore clears pending events too, an orphaned sibling's write fails closed instead of landing. Confirmed this directly against InProcRunnerContext rather than just reading the code, given how the other exclusion turned out.

Net: the "these other sites are fine" line I gave you earlier in this thread wasn't reliable — one of the two claims in it was wrong. Wanted you to have the accurate picture rather than assume silence meant it held up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: FanInEdgeRunner may contain states that are not reset by checkpoint restoration

3 participants