|
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", skipped=0), # equivalent to ALL_DONE_MIN_ONE_SUCCESS
)
# 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 (e.g., success ,failed, skipped etc.). Values accept "all" , "none" , "any" or ">=1" , plain integers, or comparison strings (">=N", "<=N", "==N", "<N", ">N"). Conditions are implicitly ANDed.
All convertable enum values were mapped into expression - see Appendix A below (some cannot be converted - which is written explicitly).
Trigger-rule expressions apply to mapped and dynamically-expanded upstreams with no additional syntax.
Literal counters from _UpstreamTIStates : success , skipped , failed , upstream_failed , done .
failed in expressions means the union FAILED ∪ UPSTREAM_FAILED, matching the existing failure-cascade mental model. The literal upstream_failed counter remains available as a separate slot.evaluation_mode : "eager" | "complete" = "eager". "eager" fires as soon as a matching upstream exists (the semantics of the current ONE_SUCCESS and ONE_FAILED rules); "complete" waits for every relevant upstream to finish.When an expression fails, the downstream TaskInstance state is determined by the upstream states that caused the failure: if any relevant upstream is in FAILED or UPSTREAM_FAILED, downstream is set to UPSTREAM_FAILED; otherwise if any is SKIPPED, downstream is set to SKIPPED; otherwise the task remains pending. Enum-form rules continue to follow their existing per-rule routing unchanged.
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.ALL_DONE_SETUP_SUCCESS, ONE_DONE, and ALWAYS remain enum-only. Their semantics are not expressible as AND-only count conditions (see Appendix A).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 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.
ALL_DONE_SETUP_SUCCESS on teardowns, implicit ALL_SUCCESS on work tasks downstream of setups). This AIP does not touch those semantics. The expression form is a drop-in replacement for ordinary trigger rules and does not unlock or alter setup or teardown behavior.example_dags/.TR.expr(...) serializes, deserializes, and evaluates to the same behavior on subsequent scheduler cycles.| # | Enum | Expression | Notes |
|---|---|---|---|
| 1 | ALL_SUCCESS | TR.expr(success="all") | |
| 2 | ALL_FAILED | TR.expr(done="all", success=0, skipped=0) | |
| 3 | ALL_DONE | TR.expr(done="all") | |
| 4 | ALL_DONE_MIN_ONE_SUCCESS | TR.expr(done="all", success=">=1", skipped=0) | |
| 5 | ALL_DONE_SETUP_SUCCESS | enum-only | Conditional on graph structure |
| 6 | ONE_SUCCESS | TR.expr(success=">=1", evaluation_mode="eager") | |
| 7 | ONE_FAILED | TR.expr(failed=">=1",evaluation_mode="eager") | |
| 8 | ONE_DONE | enum-only | Requires OR between success and failed, which the AND-only grammar does not support |
| 9 | NONE_FAILED | TR.expr(done="all", failed=0) | |
| 10 | NONE_SKIPPED | TR.expr(done="all", skipped=0) | |
| 11 | NONE_FAILED_MIN_ONE_SUCCESS | TR.expr(done="all", failed=0, success=">=1") | |
| 12 | ALL_SKIPPED | TR.expr(skipped="all") | |
| 13 | ALWAYS | enum-only | Short-circuits evaluation; not a count condition |
Enum-only exceptions.
ALL_DONE_SETUP_SUCCESS: shape-dependent (requires ≥1 setup success if any setup upstream exists, otherwise falls through to ALL_DONE). Not a pure count condition.ONE_DONE: requires success>=1 OR failed>=1 with eager evaluation. The AND-only expression grammar cannot express this without a derived counter; keeping it as a named preset is cleaner than introducing a single-use slot.ALWAYS: bypasses the trigger-rule dependency entirely. No count form is meaningful.