fix: re-run StepMutators on late-attached platform decorators (#3025)#3238
Conversation
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 SummaryThis PR re-runs
Confidence Score: 5/5The 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 No files require special attention, though the unit test coverage of the actual mutation outcome (not just the re-invocation) would strengthen confidence in Important Files Changed
Reviews (6): Last reviewed commit: "Apply Black formatting to Argo compilati..." | Re-trigger Greptile |
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
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.
|
Thanks for the review. Addressed the double-mutation concern in Change: Added Scope note for honesty: this Full |
|
Note on the red ❌ on the i.e. the Everything else is green on |
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]>
PR Type
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,--withconda/pypi, etc.) aStepMutatoroverride of@kubernetesresources was silently dropped.Issue
Fixes #3025
Reproduction
Runtime: argo-workflows (any deploy path that late-attaches
@kubernetes)A
StepMutatorthat inspectsdecorator_specsfor"kubernetes"and callsadd_decorator(..., duplicates=OVERRIDE)to bump cpu/memory:Commands to run:
Where evidence shows up: the generated Argo workflow / the
@kubernetesattributes on thestartstep.Before (override silently ignored)
After (override applied)
The included unit test
test/unit/test_late_attached_mutator.pyencodes 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_decoratorsequence directly using aMagicMockdatastore.Root Cause
The single caller of
StepMutator.mutate()is_init_step_decorators(metaflow/decorators.py). PR #2719 moved_init_step_decorators()out of Argo'smake_flow()and intocli.py start(), so the mutator pass now runs before platform decorators are late-attached:_init_step_decorators()runsStepMutator.mutate()—@kubernetesis not attached yet_attach_decorators()late-attaches@kubernetesto the steps_process_late_attached_decorator()callsexternal_init()+step_init()on the new decorators but never re-runsmutate()So a
StepMutatoriteratingmutable_step.decorator_specsfor"kubernetes"in step 1 finds nothing, and itsadd_decorator(..., OVERRIDE)is a no-op. The invariant violated: aStepMutatoris 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_decoratornow re-runs step mutators on exactly the steps that received a late-attached decorator, then rebuilds the graph — mirroring the existingStepMutatorhandling in_init_step_decorators(sameMutableStep(...)construction, samecls._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:
late_attached_step_names(the steps that actually received a late-attached decorator), so mutators on unaffected steps are not invoked a second time.external_init(): afteradd_decorator(OVERRIDE)may create replacement decorator instances,external_init()is called again; it is guarded by_ran_init, so already-initialized decorators are untouched.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
late_attached_step_namesand matching_init_step_decorators's per-step iteration; the testtest_step_without_mutator_keeps_kubernetes_defaultsasserts a mutator-less step keeps defaults (cpu=1, memory=4096) and that exactly one@kubernetesexists per step (no duplication fromOVERRIDE).deco_namesis present,late_attached_step_namesis empty,mutators_ranstaysFalse, no mutator runs and no graph rebuild happens — behavior is identical to before. Covered bytest_no_late_attachment_is_a_noop.is_spin/skip_decoratorshandling in the subsequentstep_initloop is untouched. The fulltest/unitsuite passes unchanged.Tests
test/unit/test_late_attached_mutator.py(3 cases: override-applies, no-mutator-keeps-defaults, no-attach-noop)tox -e unitlocally; targeted decorator/graph/mutator suite: 207 passed, 0 regressions)Non-Goals
FlowMutator.mutate()is not re-run here. AFlowMutatorthat inspects step-level decorators (viaMutableFlow.steps) for a late-attached@kuberneteswould have the symmetric blindness. I scoped this PR to theStepMutatorcase in the issue to keep the change minimal and reviewable. Happy to follow up with theFlowMutatorre-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
StepMutatorpass in_init_step_decorators. The test fails before the change (assert '1' == '4') and passes after, and the fulltest/unitsuite passes with no regressions.