Skip to content

fix: re-run StepMutators on late-attached platform decorators (#3025)#3238

Merged
talsperre merged 6 commits into
Netflix:masterfrom
alexzhu0:fix/late-attached-stepmutator-3025
Jun 7, 2026
Merged

fix: re-run StepMutators on late-attached platform decorators (#3025)#3238
talsperre merged 6 commits into
Netflix:masterfrom
alexzhu0:fix/late-attached-stepmutator-3025

Conversation

@alexzhu0

@alexzhu0 alexzhu0 commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

PR Type

  • Bug fix
  • Core Runtime change (higher bar -- metaflow/decorators.py)

Summary

StepMutator.mutate() can again see and override late-attached platform decorators (e.g. @kubernetes). Before this fix, on deploy paths that attach the compute decorator late (Argo Workflows, --with conda/pypi, etc.) a StepMutator override of @kubernetes resources was silently dropped.

Issue

Fixes #3025

Reproduction

Runtime: argo-workflows (any deploy path that late-attaches @kubernetes)

A StepMutator that inspects decorator_specs for "kubernetes" and calls add_decorator(..., duplicates=OVERRIDE) to bump cpu/memory:

from metaflow import FlowSpec, StepMutator, step

class kubernetes_override(StepMutator):
    def init(self, *args, **kwargs):
        self.deco_kwargs = kwargs
    def mutate(self, mutable_step):
        for deco_name, _, _, attributes in mutable_step.decorator_specs:
            if deco_name == "kubernetes":
                attributes.update(self.deco_kwargs)
                mutable_step.add_decorator(
                    deco_type=deco_name, deco_kwargs=attributes,
                    duplicates=mutable_step.OVERRIDE,
                )

class MyFlow(FlowSpec):
    @kubernetes_override(cpu=2, memory=8192)
    @step
    def start(self):
        self.next(self.end)
    @step
    def end(self):
        pass

Commands to run:

python my_flow.py argo-workflows create --only-json

Where evidence shows up: the generated Argo workflow / the @kubernetes attributes on the start step.

Before (override silently ignored)
start step @kubernetes: cpu=1, memory=4096   # defaults — the mutator never saw @kubernetes
After (override applied)
start step @kubernetes: cpu=2, memory=8192   # mutator override is honored

The included unit test test/unit/test_late_attached_mutator.py encodes exactly this: it fails before the fix (AssertionError: assert '1' == '4') and passes after, with no cloud backend required — it drives the internal _init_step_decorators_attach_decorators_process_late_attached_decorator sequence directly using a MagicMock datastore.

Root Cause

The single caller of StepMutator.mutate() is _init_step_decorators (metaflow/decorators.py). PR #2719 moved _init_step_decorators() out of Argo's make_flow() and into cli.py start(), so the mutator pass now runs before platform decorators are late-attached:

  1. _init_step_decorators() runs StepMutator.mutate()@kubernetes is not attached yet
  2. _attach_decorators() late-attaches @kubernetes to the steps
  3. _process_late_attached_decorator() calls external_init() + step_init() on the new decorators but never re-runs mutate()

So a StepMutator iterating mutable_step.decorator_specs for "kubernetes" in step 1 finds nothing, and its add_decorator(..., OVERRIDE) is a no-op. The invariant violated: a StepMutator is supposed to observe every decorator on its step, including platform decorators attached at deploy time — true in 2.19.13, broken in 2.19.14.

Why This Fix Is Correct

_process_late_attached_decorator now re-runs step mutators on exactly the steps that received a late-attached decorator, then rebuilds the graph — mirroring the existing StepMutator handling in _init_step_decorators (same MutableStep(...) construction, same cls._init_graph() refresh). This restores the 2.19.13 ordering guarantee while keeping the 2.19.14 architecture. The fix is minimal (one function, +48 lines, no signature changes) and touches only the late-attach path.

Key correctness points:

  • Scoped re-run: mutators are re-run only for late_attached_step_names (the steps that actually received a late-attached decorator), so mutators on unaffected steps are not invoked a second time.
  • Idempotent external_init(): after add_decorator(OVERRIDE) may create replacement decorator instances, external_init() is called again; it is guarded by _ran_init, so already-initialized decorators are untouched.
  • Graph rebuild only when needed: cls._init_graph() runs only if a mutator actually ran (mutators_ran), avoiding a needless rebuild on the common no-mutator path.

Failure Modes Considered

  1. Non-idempotent / cross-step mutators. Re-running every mutator on every step could double-apply side effects or apply step A's override to step B. Mitigated by gating the re-run on late_attached_step_names and matching _init_step_decorators's per-step iteration; the test test_step_without_mutator_keeps_kubernetes_defaults asserts a mutator-less step keeps defaults (cpu=1, memory=4096) and that exactly one @kubernetes exists per step (no duplication from OVERRIDE).
  2. No late attachment at all (the common case). If no decorator in deco_names is present, late_attached_step_names is empty, mutators_ran stays False, no mutator runs and no graph rebuild happens — behavior is identical to before. Covered by test_no_late_attachment_is_a_noop.
  3. Backward compatibility / spin path. No public signature changed; the is_spin/skip_decorators handling in the subsequent step_init loop is untouched. The full test/unit suite passes unchanged.

Tests

  • Unit tests added/updated — test/unit/test_late_attached_mutator.py (3 cases: override-applies, no-mutator-keeps-defaults, no-attach-noop)
  • Reproduction script provided (the test is a runnable repro; fails before, passes after — no cloud backend needed)
  • CI passes (tox -e unit locally; targeted decorator/graph/mutator suite: 207 passed, 0 regressions)

Non-Goals

FlowMutator.mutate() is not re-run here. A FlowMutator that inspects step-level decorators (via MutableFlow.steps) for a late-attached @kubernetes would have the symmetric blindness. I scoped this PR to the StepMutator case in the issue to keep the change minimal and reviewable. Happy to follow up with the FlowMutator re-run in a separate PR if you'd like it closed too — flagging it explicitly so it isn't silently missed.

AI Tool Usage

  • AI tools were used (describe below)

  • Tool: Claude Code.

  • Used for: locating the regression (bisecting the ordering change to PR Remove a case where duplicate init functions were called (when deploy… #2719), drafting the fix and the unit test, and running the local test suite.

  • Review: I reviewed and understand every line. The fix deliberately mirrors the existing StepMutator pass in _init_step_decorators. The test fails before the change (assert '1' == '4') and passes after, and the full test/unit suite passes with no regressions.

StepMutator.mutate() can no longer see or override late-attached platform
decorators (e.g. @kubernetes) on deploy paths that attach the compute
decorator after the mutator pass — Argo Workflows, conda/pypi --with, etc.

PR Netflix#2719 moved _init_step_decorators() (the only caller of mutate()) into
cli.py start(), so it now runs *before* _attach_decorators(). The new
_process_late_attached_decorator() only calls external_init()/step_init()
on the late-attached decorators and never re-runs mutate(), so a
StepMutator that inspects decorator_specs for "kubernetes" finds nothing
and its add_decorator(..., OVERRIDE) silently no-ops (regression vs 2.19.13).

This re-runs step mutators on exactly the steps that received a late-attached
decorator, then rebuilds the graph — mirroring the StepMutator handling in
_init_step_decorators. external_init() is idempotent (guarded by _ran_init).

Adds test/unit/test_late_attached_mutator.py: drives the
_init_step_decorators -> _attach_decorators -> _process_late_attached_decorator
sequence with a MagicMock datastore (no cloud backend), failing before the
fix and passing after.

Fixes Netflix#3025
@greptile-apps

greptile-apps Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR re-runs StepMutator.mutate() for steps that receive freshly late-attached platform decorators (e.g., @kubernetes on Argo Workflows), restoring the pre-2.19.14 guarantee that a StepMutator sees every decorator on its step including those attached at deploy time.

  • metaflow/decorators.py: _process_late_attached_decorator now collects steps with uninitialized (_ran_init = False) decorators, re-runs StepMutator.mutate() on those steps, then conditionally rebuilds the graph before proceeding with external_init and step_init.
  • test/unit/test_late_attached_mutator.py: Adds a parametrized unit test covering the fresh-attach and pre-initialized cases, verifying mutator re-run enrollment.
  • test/ux/core/test_argo_compilation.py + flow file: Adds an end-to-end Argo compilation test asserting that mutator-overridden cpu/memory attributes appear in the generated Argo workflow template.

Confidence Score: 5/5

The change is safe to merge — it is scoped entirely to the late-attach code path and does not affect the normal (no-late-attach) execution flow.

The fix correctly uses the _ran_init flag to distinguish freshly-attached decorators from statically-defined ones before running the mutator re-pass, mirrors the existing MutableStep construction pattern from _init_step_decorators, and gates the graph rebuild only on steps that received a genuinely new decorator. The common path (no late attachment, no StepMutator) is a documented no-op. All call sites that use graph after the function returns already refresh their reference from flow._graph.

No files require special attention, though the unit test coverage of the actual mutation outcome (not just the re-invocation) would strengthen confidence in test/unit/test_late_attached_mutator.py.

Important Files Changed

Filename Overview
metaflow/decorators.py Core fix: re-runs StepMutator.mutate() for steps with freshly late-attached (uninitialized) decorators before external_init/step_init; uses _ran_init flag to avoid false-positive re-runs on statically-defined decorators.
test/unit/test_late_attached_mutator.py Unit test verifying mutator re-run enrollment guard (fresh vs pre-initialized decorator); only checks whether mutate() was called, not whether the mutation was applied.
test/ux/core/flows/decorators/late_attached_kubernetes_mutator_flow.py New fixture flow used by the Argo UX test to validate cpu/memory overrides applied by a StepMutator on a late-attached @kubernetes decorator.
test/ux/core/test_argo_compilation.py New Argo compilation test asserts that cpu=2/memory=8192M appears on the start step's container template and defaults remain on the end step.

Reviews (6): Last reviewed commit: "Apply Black formatting to Argo compilati..." | Re-trigger Greptile

Comment thread metaflow/decorators.py
@codecov

codecov Bot commented Jun 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.73684% with 1 line in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (master@6cc3431). Learn more about missing BASE report.

Files with missing lines Patch % Lines
metaflow/decorators.py 94.73% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff            @@
##             master    #3238   +/-   ##
=========================================
  Coverage          ?   28.82%           
=========================================
  Files             ?      381           
  Lines             ?    52467           
  Branches          ?     9260           
=========================================
  Hits              ?    15126           
  Misses            ?    36329           
  Partials          ?     1012           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Address review feedback: build late_attached_step_names from decorators
whose _ran_init is still False (genuinely just-attached), not from any
step carrying a matching decorator name. A statically-defined decorator
already initialized by _init_step_decorators has _ran_init=True and must
not trigger a second mutator pass, which could double-run a non-idempotent
StepMutator. The _ran_init check is done before external_init() flips the
flag, in a separate statement from the (idempotent) external_init() call,
so initialization is unchanged and only the enrollment set is narrowed.

Adds test_already_initialized_decorator_does_not_retrigger_mutator: an
already-external_init'd @kubernetes does not re-invoke the mutator, while
a freshly attached one triggers exactly one re-run.
@alexzhu0

alexzhu0 commented Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Addressed the double-mutation concern in be00ee1.

Change: late_attached_step_names is now built only from decorators whose _ran_init is still False — i.e. genuinely just-attached ones — rather than from any step carrying a matching decorator name. A statically-defined @kubernetes that _init_step_decorators already initialized has _ran_init=True, so it no longer enrolls its step for a second mutator pass. The _ran_init check is read before external_init() flips the flag, in a separate statement from the (idempotent) external_init() call, so initialization behavior is unchanged and only the re-run set is narrowed.

Added test_already_initialized_decorator_does_not_retrigger_mutator: an already-external_init'd @kubernetes does not re-invoke the mutator, while a freshly attached one triggers exactly one re-run. (Verified it fails without the narrowing — the already-initialized step would otherwise be re-run — and passes with it.)

Scope note for honesty: this _ran_init distinction is meaningful for the platform deploy callers (Argo/Airflow/Step Functions), which run after _init_step_decorators has initialized static step decorators. On the conda/pypi self-attach path (called from flow_init, before _init_step_decorators) a static @conda/@pypi is still _ran_init=False, so the narrowing is a no-op there — same behavior as before, no regression, but it does not additionally suppress that case. Happy to adjust if you'd prefer a different signal than _ran_init.

Full test/unit + test/cmd suite green locally (531 passed).

@alexzhu0

alexzhu0 commented Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

Note on the red ❌ on the test check (Test Metaflow with complete Kubernetes stack): it's a CI-infra flake, not a code failure. The job died in the Bring up the environment step before any test ran:

📥 Installing gum...
curl: (22) The requested URL returned error: 504
make: *** [install-gum] Error 22
Waiting for infra timed out

i.e. the gum download returned a transient HTTP 504, so the K8s stack never came up. The same job passed on the previous commit (fd62326), and the only change since is the pure-Python narrowing in decorators.py.

Everything else is green on be00ee1, including the cloud-backend integration suite that actually exercises this fix: argo-kubernetes, airflow-kubernetes, and sfn-batch all pass (plus local / Unit / Spin / Coverage). I don't have rerun permissions on this repo — a maintainer re-run of that one job should clear it.

talsperre and others added 4 commits June 7, 2026 17:56
Move external_init() on late-attached decorators to run after the step
mutator re-run instead of before, so a decorator replaced via
add_decorator(OVERRIDE) is never initialized and only the replacement is
initialized before step_init(). This mirrors the mutate-then-init ordering
in _init_step_decorators.

Bring test_late_attached_mutator.py in line with CONTRIBUTING test
conventions: pytest-mock fixtures instead of unittest.mock.MagicMock, mutator
call tracking moved into a fixture, shared cases parametrized, and a
module-level skip when the @kubernetes plugin is not enabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@talsperre talsperre self-assigned this Jun 7, 2026
@talsperre
talsperre merged commit 732ca76 into Netflix:master Jun 7, 2026
42 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: StepMutator.mutate() never sees late-attached platform decorators (regression in 2.19.14)

2 participants