|
Real-world pipelines rarely fit a clean "all-or-nothing" dependency model. A few recurring shapes that Airflow users encounter every day:
None of these are exotic. They show up in analytics platforms, ML infrastructure, observability pipelines, and data platform teams across industries. They share the same shape: the decision to run a downstream task depends on counts and ratios of upstream outcomes, not on every upstream reaching one specific state.
Airflow's current TriggerRule is a flat enum of 13 hand-picked presets. When one of those presets happens to match a real-world shape, authoring is easy. When it does not, Dag authors fall back to one of three workarounds, each with real business cost:
ALL_SUCCESS and let a tolerable failure fail the whole branch, or use ALL_DONE and let a bad upstream silently poison the downstream. Teams catch this in production, not in code review.The core problem is that the set of useful shapes is combinatorial. Covering it with a preset enum means the enum grows every time a new shape is needed. The pattern does not scale: each new preset needs an enum value, an evaluator branch, tests, documentation, and a release cycle before authors can use it. Meanwhile the authoring story for Dag authors stays the same: wait for a preset or work around the gap.
This AIP proposes an additive authoring surface that lets Dag authors describe the condition they actually want, in one line, without waiting for a new preset. The 13 existing enum values remain valid and unchanged; they simply become shortcuts for specific expressions.
trigger_rule="all_success" keep working. No DB migration, no Dag rewrites required.Add a structured expression form for trigger rules that lives alongside the existing enum.
from airflow.sdk import TriggerRule as TR
# Today's form, still valid
task_a = EmptyOperator(task_id="a", trigger_rule=TR.ALL_DONE_MIN_ONE_SUCCESS)
# New equivalent
task_b = EmptyOperator(
task_id="b",
trigger_rule=TR.expr(done="all", success=">=1"),
)
# Combinations that previously required a new enum value become one-liners
task_c = EmptyOperator(
task_id="c",
trigger_rule=TR.expr(done="all", success=">=35"), # ingestion fan-out, 35 of 40 partitions
)
task_d = EmptyOperator(
task_id="d",
trigger_rule=TR.expr(failed=0, upstream_failed=0, skipped="<=1"),
) |
The expression accepts keyword thresholds against the upstream-state counts Airflow already computes (success ,failed, skipped , upstream_failed, removed, done, plus the setup-scoped variants used for setup and teardown). Values accept "all" , "none" , "any" or ">=1" , plain integers, or comparison strings (">=N", "<=N", "==N", "<N", ">N"). Conditions are implicitly ANDed.
The following are intentionally excluded from this AIP. They are viable extensions that could be proposed as separate AIPs or follow-up PRs once the expression form is in users' hands and real feedback is available:
a >> Require("success") >> downstream). This introduces composition semantics with the task-level rule that deserve their own discussion.OR-combined conditions (for example, "at least one success OR all skipped") and conditional or implication logic (for example, "if A succeeded, then B must too"). The aggregate form here is AND-only by design.Authors today express dependency conditions by picking from a hard-coded list of 13 presets. When the one they need is not there, the options are:
All three carry real cost: slower iteration, more brittle Dags, harder post-mortems when a pipeline misbehaves because "the branch operator was doing something clever". The expression form replaces the preset-hunting step with a direct statement of what the author actually wants, in the Dag file, with no release cycle involved.
There is a secondary internal benefit. The evaluator today is a large if-elif chain, one branch per enum value. Once the expression form is in place, each enum value's branch can collapse to "look up the preset expression and evaluate it". That is a smaller surface area to maintain and a safer place to add future extensions.
The problem is not a specific missing preset. It is the trajectory. Every team that needs a combination not currently in the enum repeats the same loop: file an issue, write a PR, wait for the release, then adopt the rule. The set of useful combinations is large enough (and grows with the ecosystem) that chasing it through enum additions is structurally the wrong approach.
Concretely, requirements along the lines of the following have come up over the past two years, and each would have needed its own enum value under the current model:
Each is one line of expression and zero enum churn under this proposal.
- No DB migration - Trigger rules live in serialized Dag JSON, which is regenerated every parse cycle. No schema changes, no upgrade scripts.
- No Dag changes required - Nothing breaks. Authors choose when, or whether, to adopt the expression form.
No migration is required - This is a strictly additive change; no existing behavior is altered, deprecated, or removed. Dag authors do not need to rewrite anything.
If an author chooses to adopt the expression form for an existing Dag, the change is a one-line substitution per task. The mapping table above functions as a drop-in translation. An automated rewrite (via a `ruff`-style codemod or a small script) would be straightforward, but is not required: the enum form remains fully supported and is often more readable when it fits.
There are no breaking changes in this AIP. The existing enum values are not being deprecated or removed. Whether to deprecate any of them (for example, `ALL_DONE_MIN_ONE_SUCCESS` once its expression form is canonical) is explicitly a decision for a future AIP, not this one.
example_dags/.TriggerRule enum value has a corresponding expression, verified by a parametrised test matrix: for every upstream-state vector in the existing test suite, the enum form and the expression form produce the same downstream outcome.TR.expr(...) serializes, deserializes, and evaluates to the same behavior on subsequent scheduler cycles.