Skip to content

fix(workflow): don't persist context from skipped steps - #1573

Closed
key4ng wants to merge 1 commit into
mainfrom
fix/workflow-skip-context-clobber
Closed

key4ng wants to merge 1 commit into
mainfrom
fix/workflow-skip-context-clobber

Conversation

@key4ng

@key4ng key4ng commented May 29, 2026 •

Copy link
Copy Markdown
Member

Description

Problem

Cloud-gateway startup intermittently fails because the worker is never registered. The AddWorker workflow dies at register_workers with:

WARN  register_workers error="Context value not found: workers" will_retry=false
ERROR Workflow failed ... failed_step=register_workers
WARN  Failed job: type=AddWorker, worker=https://api.openai.com,
      error=Workflow failed at step register_workers: Context value not found: workers

/workers then stays at total=0 and the gateway times out. This surfaces as a flaky e2e failure (TestImageGenerationRegularMcpServer::test_response_is_mcp_call_shape) that reproduces on main (e.g. run 26653116197, a CPU runner with no GPU workers) — so it is independent of GPUs/runners and is masked, not caused, by retries.

Root cause — last-writer-wins clobber on shared workflow context. In execute_step_with_retry, each step does snapshot → execute → write-back, and the write-back replaced the entire shared context (s.context = context.clone()), unconditionally — even when the step returned StepResult::Skip:

let mut context = self.state_store.get_context(instance_id).await?; // full snapshot
let result = timeout(step_timeout, step.executor.execute(&mut context)).await;
self.state_store.update(instance_id, |s| { s.context = context.clone(); }).await?; // whole-context overwrite

The worker-registration DAG fans out from classify_worker_type into a local branch and an external branch that run in parallel and reconverge at register_workers. For a cloud (external) worker, every local-branch step is a guard that returns Skip immediately (detect_connection_mode, detect_backend, discover_metadata, discover_dp_info, create_local_worker). But because the engine persisted their stale snapshots regardless, a local Skip step whose snapshot predated create_external_workers' write — and whose write-back landed after it — wiped actual_workers from the context. register_workers then found nothing.

This contradicts the documented design intent in data.rs: "branch-specific steps check worker_kind and return StepResult::Skip" to opt out — which only works if a skipped step's context isn't persisted.

Solution

Skip the context write-back when a step returns StepResult::Skip. A skipped step did no work, so there is nothing to persist, and its stale snapshot must not overwrite a concurrently-running sibling's mutations. Success/Failure behavior is unchanged.

This is a minimal, general engine fix (one conditional) that makes the existing "return Skip to opt out" pattern behave as designed, for all workflows — no changes to the worker-registration definition or the step guards.

Changes

  • crates/workflow/src/engine.rs — execute_step_with_retry only persists the step's context when the result is not Skip.
  • crates/workflow/src/engine.rs — new skip_clobber_tests module: a deterministic regression test where a Skip sibling, scheduled (via notifies) to write back after a Success writer, must not clobber the writer's context mutation. Fails before the fix (left: ""), passes after.

Test Plan

Before/after, in crates/workflow:

# regression test fails on the pre-fix engine (unconditional write-back):
#   assertion `left == right` failed: skipped sibling clobbered the writer's context mutation
#     left: ""   right: "written"
cargo test -p wfaas skip_clobber          # passes with the fix
cargo test -p wfaas                        # 21 existing tests + new test, all green
cargo +nightly fmt -p wfaas -- --check     # clean
cargo clippy -p wfaas --all-targets -- -D warnings  # clean (only a pre-existing clippy.toml config note)
  • Regression test reproduces the bug without the fix and passes with it (verified by temporarily reverting the one-line guard).
  • Full wfaas suite + doctests pass.
  • cargo +nightly fmt / clippy clean on wfaas.
  • model_gateway build / full pre-commit not run locally (sandbox could not fetch some deps / disk-constrained). Engine public API is unchanged, so the consumer compiles unchanged — relying on CI to confirm. The real end-to-end signal is e2e_test/responses no longer flaking on register_workers: Context value not found: workers.
Checklist
  • cargo +nightly fmt passes
  • cargo clippy --all-targets --all-features -- -D warnings passes (verified on the changed crate; full-workspace run deferred to CI — see Test Plan)
  • (Optional) Documentation updated
  • (Optional) Please join us on Slack #sig-smg to discuss, review, and merge PRs

Summary by CodeRabbit

  • Bug Fixes
    • Fixed a critical data loss issue in parallel workflow execution. Previously, when multiple independent steps ran concurrently, skipped steps would incorrectly overwrite context data modifications made by their sibling steps. Workflow data is now correctly preserved, ensuring all step modifications persist properly even when some steps are skipped during parallel execution.

Review Change Stack

Parallel sibling steps each take their own `get_context` snapshot, and the
per-step write-back replaced the *entire* shared context (last-writer-wins).
A step returning `StepResult::Skip` did no work, but its stale snapshot was
still persisted, silently clobbering a concurrently-running sibling's
mutations.

This is the worker-registration race: for a cloud/external worker the local
branch's no-op `Skip` steps (`detect_connection_mode`, `create_local_worker`,
etc.) ran in parallel with the external branch and overwrote the context
that `create_external_workers` had populated, wiping `actual_workers`. The
`register_workers` step then failed with "Context value not found: workers",
the worker never registered, and cloud-gateway startup timed out — observed
as a flaky `TestImageGenerationRegularMcpServer` failure (reproduces on main,
independent of GPUs).

Skip the context write-back when the step result is `Skip`, matching the
documented design intent that branch steps "return `StepResult::Skip`" to
opt out. Add a deterministic engine regression test that reproduces the
clobber (fails before the fix, passes after).

Signed-off-by: key4ng <rukeyang@gmail.com>

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request prevents skipped workflow steps from persisting their stale context snapshots, which previously clobbered concurrent sibling step mutations. It also introduces a regression test to verify this behavior. The review feedback points out that skipped steps currently remain in a running state indefinitely because their status is never updated to StepStatus::Skipped in the state store. The reviewer suggests updating the step status upon skipping and adding a corresponding assertion in the test suite.

Comment on lines +969 to +975
if !matches!(result, Ok(Ok(StepResult::Skip))) {
self.state_store
.update(instance_id, |s| {
s.context = context.clone();
})
.await?;
}

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.

high

When a step returns StepResult::Skip, its status in the state store is never updated to StepStatus::Skipped and remains StepStatus::Running (or StepStatus::Retrying) indefinitely.

At the start of execute_step_with_retry (line 931), the step status is set to StepStatus::Running or StepStatus::Retrying. When the step returns StepResult::Skip, execute_step_with_retry does not update the step's status in the state store (unlike the Success case). Furthermore, in the parallel execution loop (lines 755-758), needs_update is set to false for Ok(StepResult::Skip), meaning the caller also skips updating the state store.

To fix this, we should update the step status to StepStatus::Skipped and set completed_at when StepResult::Skip is returned.

            if !matches!(result, Ok(Ok(StepResult::Skip))) {
                self.state_store
                    .update(instance_id, |s| {
                        s.context = context.clone();
                    })
                    .await?;
            } else {
                self.state_store
                    .update(instance_id, |s| {
                        if let Some(step_state) = s.step_states.get_mut(&step.id) {
                            step_state.status = StepStatus::Skipped;
                            step_state.completed_at = Some(Utc::now());
                        }
                    })
                    .await?;
            }

Comment on lines +1388 to +1391
assert_eq!(
state.context.data.value, "written",
"skipped sibling clobbered the writer's context mutation"
);

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.

medium

Add an assertion to verify that the skipped step's status is correctly updated to StepStatus::Skipped in the state store, ensuring that skipped steps do not remain in the Running state.

        assert_eq!(
            state.context.data.value, "written",
            "skipped sibling clobbered the writer's context mutation"
        );
        assert_eq!(
            state.step_states.get("skipper").unwrap().status,
            StepStatus::Skipped,
            "skipped step should have Skipped status in the state store"
        );

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Clean fix. The conditional guard at execute_step_with_retry correctly prevents a Skip step's stale context snapshot from clobbering concurrent siblings' writes. The matches! pattern precisely targets only Skip (errors/timeouts/failures continue to persist as before), and the regression test uses deterministic Notify-based synchronization to reproduce the race reliably. LGTM.

@coderabbitai

coderabbitai Bot commented May 29, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The engine now guards against context overwrites when independent steps run in parallel. execute_step_with_retry conditionally persists context mutations only for steps that do not return StepResult::Skip, preventing stale snapshots from clobbering concurrent sibling changes. A new regression test validates the fix.

Changes

Concurrent step context clobbering fix

Layer / File(s) Summary
Conditional context persistence in execute_step_with_retry
crates/workflow/src/engine.rs
The step execution engine checks whether a step returned StepResult::Skip before writing context back to the state store, preventing stale snapshots from overwriting concurrent sibling context mutations.
Regression test for concurrent skip-induced clobbering
crates/workflow/src/engine.rs
A test module with concurrent executor implementations verifies that a sibling step returning Skip does not clobber another sibling's context mutation when both run in parallel.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

A rabbit hops through parallel threads,
Where skip steps once left stale beds—
Now context flows in sync, serene,
No clobber storms betweens! 🐰✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(workflow): don't persist context from skipped steps' directly and concisely describes the main change: preventing context persistence from skipped workflow steps, which aligns with the core fix addressing race conditions in parallel step execution.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/workflow-skip-context-clobber

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/workflow/src/engine.rs`:
- Around line 1324-1327: The timing cushion after awaiting self.writer_done is
fragile because writer_done is signaled inside Writer::execute() before the
actual persistence step runs; increase the sleep from 50ms to a larger margin
(e.g., 150–200ms) and add a clarifying comment that this is a best-effort timing
cushion (not a deterministic sync), referencing the writer_done notification and
Writer::execute() persistence sequence so future readers understand why the
extra delay is required.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c32bcc55-e74d-44b2-8458-b7ce259af930

📥 Commits

Reviewing files that changed from the base of the PR and between f4597b3 and dbacb0f.

📒 Files selected for processing (1)
  • crates/workflow/src/engine.rs

Comment on lines +1324 to +1327
self.writer_done.notified().await;
// Cushion so the writer's async write-back is fully applied first.
tokio::time::sleep(Duration::from_millis(50)).await;
Ok(StepResult::Skip)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Minor: timing-based synchronization could be fragile under load.

The 50ms sleep ensures Writer's context persistence completes before SlowSkipper returns, but writer_done is notified inside Writer's execute(), before the persistence at lines 969-975 actually runs. Under heavy CI load, 50ms might occasionally be insufficient.

Consider bumping to 100-200ms for extra margin, or adding a comment acknowledging this is a best-effort timing cushion rather than a deterministic guarantee.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/workflow/src/engine.rs` around lines 1324 - 1327, The timing cushion
after awaiting self.writer_done is fragile because writer_done is signaled
inside Writer::execute() before the actual persistence step runs; increase the
sleep from 50ms to a larger margin (e.g., 150–200ms) and add a clarifying
comment that this is a best-effort timing cushion (not a deterministic sync),
referencing the writer_done notification and Writer::execute() persistence
sequence so future readers understand why the extra delay is required.

@key4ng key4ng closed this May 29, 2026
@lightseek-bot
lightseek-bot deleted the fix/workflow-skip-context-clobber branch May 30, 2026 01:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

workflow Workflow crate changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant