This is an automated email from the ASF dual-hosted git repository.
jason810496 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 498724fc13a Go SDK: enumerate every user-facing enum in the airflow
package (#74064)
498724fc13a is described below
commit 498724fc13a41835f5cf68b352799e920eb47cd0
Author: PoAn Yang <[email protected]>
AuthorDate: Fri Oct 2 16:29:50 2026 +0900
Go SDK: enumerate every user-facing enum in the airflow package (#74064)
Signed-off-by: PoAn Yang <[email protected]>
---
go-sdk/README.md | 12 +-
go-sdk/adr/0008-native-dag-interface.md | 5 +
go-sdk/airflow/dag.go | 5 +
go-sdk/airflow/enums.go | 156 ++++++++++++++++++++++
go-sdk/airflow/enums_test.go | 221 ++++++++++++++++++++++++++++++++
go-sdk/airflow/spec.go | 31 -----
go-sdk/airflow/spec_test.go | 71 ----------
go-sdk/airflow/trigger_dag_run.go | 36 ++----
go-sdk/airflow/trigger_dag_run_test.go | 24 ++--
go-sdk/internal/genspec/authoring.go | 6 +-
10 files changed, 423 insertions(+), 144 deletions(-)
diff --git a/go-sdk/README.md b/go-sdk/README.md
index a6dedde0804..3bd9f527502 100644
--- a/go-sdk/README.md
+++ b/go-sdk/README.md
@@ -396,6 +396,11 @@ drift, so a missed bump is caught by `go test`. A snapshot
can also grow a field
struct does not declare — so the `check-go-sdk-generated-drift` prek hook
regenerates them and fails
when the committed files differ.
+[`airflow/enums.go`](./airflow/enums.go) declares the `genmodels.DagRunState`
constants again, so that
+a Dag author does not import `genmodels`. `TestDagRunStateMatchesGenmodels`
fails when regenerating
+the models adds, renames or removes a `DagRunState` constant. The test keeps
failing until
+`airflow/enums.go` declares the same constants as `genmodels`.
+
## Regenerating the Dag and task specs
`airflow.DagSpec` and `airflow.TaskSpec` in
[`airflow/spec.gen.go`](./airflow/spec.gen.go) are
@@ -416,9 +421,10 @@ it into the authoring shape, each entry carrying the
reason it exists:
schema rather than hand-writing the field keeps every field in one struct
declaration, which is
what lets `TaskSpec` implement the sealed `TaskOption`.
-`trigger_rule` is typed as a plain string with no values named, so
`TriggerRule` and its constants
-are hand-written in [`airflow/spec.go`](./airflow/spec.go);
-`TestTriggerRuleConstantsMatchPython` is their tripwire against Airflow's own
enum.
+The schema types `trigger_rule` and `weight_rule` as plain strings and does
not list their values.
+`TriggerRule`, `WeightRule` and their constants are therefore hand-written in
+[`airflow/enums.go`](./airflow/enums.go). `TestRuleConstantsMatchPython`
checks those constants
+against the Python enums in airflow-core.
The `check-go-sdk-generated-drift` prek hook regenerates the file and fails
when the committed one
differs, so a schema change that never reached Go cannot merge.
diff --git a/go-sdk/adr/0008-native-dag-interface.md
b/go-sdk/adr/0008-native-dag-interface.md
index aebb92f5514..974218268a5 100644
--- a/go-sdk/adr/0008-native-dag-interface.md
+++ b/go-sdk/adr/0008-native-dag-interface.md
@@ -175,6 +175,11 @@ const (
Either the schema gains titles or the generate step keeps the map.
- **The schema is the serialized shape, not the authoring shape.** It requires
`fileloc` and `tasks` on a Dag, and `task_type`, `_task_module`, `ui_color`,
`ui_fgcolor`, and `template_fields` on an operator, all of which the SDK fills
in, and it carries a serialized `timetable` object where an author writes a
schedule.
Generation needs an exclusion list and a hand-written field or two, the same
kind of rule
[ADR-0009](../../airflow-core/adr/lang-sdk/0009-provider-operators-as-generated-dsl.md)
states for provider operators.
+- **A state enum generated into `genmodels` is re-exported from `airflow` once
a field that an author can reach holds its values.** Today the only re-exported
enum is `DagRunState`, the type of the values in
`TriggerDagRunSpec.AllowedStates` and `TriggerDagRunSpec.FailedStates`.
`DagRunType` is re-exported once a field such as `allowed_run_types` on
`DagSpec` holds its values. `TaskInstanceState` is re-exported once
`airflow.TaskInstance` gets a state field.
+ `airflow/enums.go` lists every user-facing enum with the Python enum that it
mirrors.
+ `airflow.DagRunState` is a type alias of `genmodels.DagRunState`, not a new
type. `airflow.DagRun` is an alias of `sdk.DagRun`, and package `sdk` cannot
import `airflow`. So a state field that `sdk.DagRun` gains later has a type
declared outside `airflow`. `actx.DagRun().State == airflow.DagRunStateFailed`
compiles only if both sides have the same type.
+ The cost is that `%T` and package reflect report `airflow.DagRunState` as
`genmodels.DagRunState`, and package `airflow` cannot declare a method on that
type.
+- **A value outside an enum is rejected when the task is added.** Go converts
a string literal to an enum type without a cast, so the enum type alone does
not stop `TaskSpec{TriggerRule: "all_sucess"}`. `dag.Task` panics with a
message that names the field and lists the valid values. Python rejects an
unknown trigger rule when the operator is constructed. It rejects an unknown
weight rule only when the Dag is serialized.
- **A data edge is labelled by redeclaring it.** Declaring an edge that
already exists is idempotent, so `extracted.Before(airflow.Label(transformed,
"rows"))` labels the edge `Inputs` created.
- **Renaming a Go function renames the task.** The id is derived, and history,
clears, and the UI all key on task_id, so renaming a function whose task
carries no `TaskSpec` id is a Dag change.
`airflow.TaskSpec{TaskID: ...}` pins an id that has to outlive the
function's name, and it is also how a Dag gets snake_case ids, since nothing
transforms a Go name.
diff --git a/go-sdk/airflow/dag.go b/go-sdk/airflow/dag.go
index 757ee52f817..5f301676d15 100644
--- a/go-sdk/airflow/dag.go
+++ b/go-sdk/airflow/dag.go
@@ -121,6 +121,8 @@ type TaskRef struct {
// - fn comes from TriggerDagRun and opts holds an Inputs
// - an option is nil or is not one that package airflow defines
// - opts holds more than one TaskSpec or more than one Inputs
+// - the TaskSpec sets TriggerRule to a value that is not a TriggerRule
constant
+// - the TaskSpec sets WeightRule to a value that is not a WeightRule
constant
// - the tasks passed to Inputs do not match the parameters of fn after the
Context
// - the Dag already has a task with the same task_id
// - the Dag is already registered
@@ -209,6 +211,9 @@ func (d *DagRef) Task(fn any, opts ...TaskOption) *TaskRef {
"airflow.DagRef.Task: task %q of Dag %q: %v", taskID,
d.dagID, triggerErr,
))
}
+ if err := checkTaskSpec(cfg.spec); err != nil {
+ panic(fmt.Sprintf("airflow.DagRef.Task: task %q of Dag %q: %v",
taskID, d.dagID, err))
+ }
if _, exists := d.tasksByID[taskID]; exists {
panic(fmt.Sprintf(
"airflow.DagRef.Task: Dag %q already has a task %q; "+
diff --git a/go-sdk/airflow/enums.go b/go-sdk/airflow/enums.go
new file mode 100644
index 00000000000..4aba07c91c3
--- /dev/null
+++ b/go-sdk/airflow/enums.go
@@ -0,0 +1,156 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package airflow
+
+import (
+ "fmt"
+ "slices"
+
+ "github.com/apache/airflow/go-sdk/pkg/execution/genmodels"
+)
+
+// This file declares every enum type whose values a Dag author sets. Each
item below gives
+// the Go enum, the Python file that declares the same enum, and the fields
that hold its values:
+//
+// - TriggerRule: airflow-core/src/airflow/task/trigger_rule.py, for
TaskSpec.TriggerRule
+// - WeightRule: airflow-core/src/airflow/task/weight_rule.py, for
TaskSpec.WeightRule
+// - DagRunState: airflow-core/src/airflow/utils/state.py, for
TriggerDagRunSpec.AllowedStates
+// and TriggerDagRunSpec.FailedStates
+//
+// Two more Python enums are not declared here yet, because no field that an
author can reach
+// holds their values:
+//
+// - DagRunType: airflow-core/src/airflow/utils/types.py. DagSpec leaves out
+// allowed_run_types, and DagRun has no run type field.
+// - TaskInstanceState: airflow-core/src/airflow/utils/state.py.
TaskInstance has no state field.
+//
+// Adding such a field also means declaring its enum here, the way DagRunState
is declared.
+//
+// An enum is a named string type, and each constant has the type name as its
prefix, as in
+// TriggerRuleAllDone. Go does not scope a constant to its type, so without
the prefix, a name
+// such as airflow.AllDone would not say which enum the constant belongs to. A
field with a fixed
+// set of values gets its enum type before the field is released. Changing a
released field from
+// string to an enum type breaks every Dag that sets the field from a value of
type string, such
+// as a string variable.
+//
+// DagRef.Task panics on a TriggerRule, WeightRule or DagRunState value that
is not one of the
+// constants of that type. The one exception is an empty TriggerRule or
WeightRule, which leaves
+// that rule unset.
+//
+// The Dag serialization schema types trigger_rule and weight_rule as plain
strings and does not
+// list their values. TriggerRule, WeightRule and their constants are
therefore written by hand in
+// this file, and internal/genspec/authoring.go gives these types to the
generated fields
+// TaskSpec.TriggerRule and TaskSpec.WeightRule.
+
+// TriggerRule decides when a task runs, based on the states of its upstream
tasks. An empty
+// TriggerRule leaves the trigger rule of the task unset.
+type TriggerRule string
+
+const (
+ TriggerRuleAllSuccess TriggerRule = "all_success"
+ TriggerRuleAllFailed TriggerRule = "all_failed"
+ TriggerRuleAllDone TriggerRule = "all_done"
+ TriggerRuleAllDoneMinOneSuccess TriggerRule =
"all_done_min_one_success"
+ TriggerRuleAllDoneSetupSuccess TriggerRule =
"all_done_setup_success"
+ TriggerRuleOneSuccess TriggerRule = "one_success"
+ TriggerRuleOneFailed TriggerRule = "one_failed"
+ TriggerRuleOneDone TriggerRule = "one_done"
+ TriggerRuleNoneFailed TriggerRule = "none_failed"
+ TriggerRuleNoneFailedMinOneSuccess TriggerRule =
"none_failed_min_one_success"
+ TriggerRuleNoneSkipped TriggerRule = "none_skipped"
+ TriggerRuleAllSkipped TriggerRule = "all_skipped"
+ TriggerRuleAlways TriggerRule = "always"
+)
+
+// WeightRule decides how Airflow computes the effective priority weight of a
task.
+// WeightRuleAbsolute uses the TaskSpec.PriorityWeight of the task alone.
WeightRuleDownstream
+// sums the PriorityWeight of the task and of every task downstream of it.
WeightRuleUpstream sums
+// the PriorityWeight of the task and of every task upstream of it. An empty
WeightRule leaves the
+// weight rule of the task unset.
+//
+// Python also accepts the import path of a PriorityWeightStrategy class for
weight_rule. The
+// class can be built into Airflow or registered by a plugin. A Go task can
only name one of the
+// three built-in rules.
+type WeightRule string
+
+const (
+ WeightRuleDownstream WeightRule = "downstream"
+ WeightRuleUpstream WeightRule = "upstream"
+ WeightRuleAbsolute WeightRule = "absolute"
+)
+
+// DagRunState is the state of a Dag run.
+//
+// DagRunState is an alias of genmodels.DagRunState so that an author does not
import genmodels.
+// The types in pkg/execution/genmodels are generated from the supervisor
schema. Because
+// DagRunState is an alias, %T and package reflect report its name as
genmodels.DagRunState.
+type DagRunState = genmodels.DagRunState
+
+const (
+ DagRunStateQueued DagRunState = "queued"
+ DagRunStateRunning DagRunState = "running"
+ DagRunStateSuccess DagRunState = "success"
+ DagRunStateFailed DagRunState = "failed"
+)
+
+// triggerRules, weightRules and dagRunStates list the valid values of
TriggerRule, WeightRule and
+// DagRunState. Tests in this package check these lists and the constants
above against the
+// Python enums for TriggerRule and WeightRule, and against genmodels for
DagRunState.
+var (
+ triggerRules = []TriggerRule{
+ TriggerRuleAllSuccess,
+ TriggerRuleAllFailed,
+ TriggerRuleAllDone,
+ TriggerRuleAllDoneMinOneSuccess,
+ TriggerRuleAllDoneSetupSuccess,
+ TriggerRuleOneSuccess,
+ TriggerRuleOneFailed,
+ TriggerRuleOneDone,
+ TriggerRuleNoneFailed,
+ TriggerRuleNoneFailedMinOneSuccess,
+ TriggerRuleNoneSkipped,
+ TriggerRuleAllSkipped,
+ TriggerRuleAlways,
+ }
+ weightRules = []WeightRule{WeightRuleDownstream, WeightRuleUpstream,
WeightRuleAbsolute}
+ dagRunStates = []DagRunState{
+ DagRunStateQueued,
+ DagRunStateRunning,
+ DagRunStateSuccess,
+ DagRunStateFailed,
+ }
+)
+
+// checkTaskSpec rejects a TaskSpec whose TriggerRule is not a TriggerRule
constant or whose
+// WeightRule is not a WeightRule constant. An empty TriggerRule or WeightRule
is valid and leaves
+// that rule unset.
+func checkTaskSpec(spec TaskSpec) error {
+ if err := checkRule("TriggerRule", "trigger rule", spec.TriggerRule,
triggerRules); err != nil {
+ return err
+ }
+ return checkRule("WeightRule", "weight rule", spec.WeightRule,
weightRules)
+}
+
+func checkRule[T ~string](field, kind string, value T, valid []T) error {
+ if value == "" || slices.Contains(valid, value) {
+ return nil
+ }
+ return fmt.Errorf(
+ "airflow.TaskSpec.%s is %q, which is not a %s; use one of %q",
field, value, kind, valid,
+ )
+}
diff --git a/go-sdk/airflow/enums_test.go b/go-sdk/airflow/enums_test.go
new file mode 100644
index 00000000000..32c8cd7fb09
--- /dev/null
+++ b/go-sdk/airflow/enums_test.go
@@ -0,0 +1,221 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package airflow
+
+import (
+ "fmt"
+ "maps"
+ "os"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// enumsPath is the path, relative to this package, of the file that declares
each enum.
+const enumsPath = "enums.go"
+
+func readMatches(t *testing.T, path, pattern string) [][]string {
+ t.Helper()
+
+ body, err := os.ReadFile(filepath.FromSlash(path))
+ require.NoError(t, err)
+ matches :=
regexp.MustCompile(pattern).FindAllStringSubmatch(string(body), -1)
+ require.NotEmpty(t, matches, "%s matched nothing, so the file's shape
has changed", path)
+ return matches
+}
+
+// readPairs maps the first submatch of each match of pattern in the file at
path to the second
+// submatch, such as a constant name to its value.
+func readPairs(t *testing.T, path, pattern string) map[string]string {
+ t.Helper()
+
+ pairs := make(map[string]string)
+ for _, match := range readMatches(t, path, pattern) {
+ pairs[match[1]] = match[2]
+ }
+ return pairs
+}
+
+// readPythonEnum maps the name of each member of the Python enum in the file
at path to the
+// value of the member. It fails the test if a line that assigns to an
upper-case name at the
+// indentation of a member is not written as NAME = "value". Without that
check, a member written
+// another way would go unread instead of failing the test.
+func readPythonEnum(t *testing.T, path string) map[string]string {
+ t.Helper()
+
+ members := readPairs(t, path, `(?m)^ ([A-Z][A-Z0-9_]*) = "([^"]*)"`)
+ var assigned []string
+ for _, match := range readMatches(t, path, `(?m)^
([A-Z][A-Z0-9_]*)\s*[:=]`) {
+ assigned = append(assigned, match[1])
+ }
+ require.ElementsMatch(t, assigned, slices.Collect(maps.Keys(members)),
+ "%s has a member that is not written as NAME = \"value\"", path)
+ return members
+}
+
+// goConstantSuffix turns the name of a Python enum member, such as
ALL_DONE_MIN_ONE_SUCCESS,
+// into the part of a Go constant name after the type name, such as
AllDoneMinOneSuccess.
+func goConstantSuffix(pythonName string) string {
+ var suffix strings.Builder
+ for _, word := range strings.Split(strings.ToLower(pythonName), "_") {
+ if word != "" {
+ suffix.WriteString(strings.ToUpper(word[:1]) + word[1:])
+ }
+ }
+ return suffix.String()
+}
+
+func toStrings[T ~string](values []T) []string {
+ out := make([]string, 0, len(values))
+ for _, value := range values {
+ out = append(out, string(value))
+ }
+ return out
+}
+
+// TestRuleConstantsMatchPython fails when the Python side adds or renames a
trigger rule or a
+// weight rule. The Dag serialization schema types trigger_rule and
weight_rule as plain strings
+// and does not list their values. The TriggerRule and WeightRule constants
are therefore
+// hand-written, outside the generated files that the
check-go-sdk-generated-drift prek hook
+// checks. The test also compares triggerRules and weightRules with the Python
enums.
+// checkTaskSpec accepts only the values in those lists, so an author cannot
set a rule that the
+// lists leave out.
+func TestRuleConstantsMatchPython(t *testing.T) {
+ for _, tt := range []struct {
+ goType string
+ pythonPath string
+ valid []string
+ }{
+ {
+ goType: "TriggerRule",
+ pythonPath:
"../../airflow-core/src/airflow/task/trigger_rule.py",
+ valid: toStrings(triggerRules),
+ },
+ {
+ goType: "WeightRule",
+ pythonPath:
"../../airflow-core/src/airflow/task/weight_rule.py",
+ valid: toStrings(weightRules),
+ },
+ } {
+ t.Run(tt.goType, func(t *testing.T) {
+ python := readPythonEnum(t, tt.pythonPath)
+ want := make(map[string]string, len(python))
+ for name, value := range python {
+ want[goConstantSuffix(name)] = value
+ }
+ spelled := readPairs(
+ t,
+ enumsPath,
+ `(?m)^\t`+tt.goType+`(\w+) +`+tt.goType+` =
"([^"]*)"$`,
+ )
+
+ assert.Equal(t, want, spelled)
+ assert.ElementsMatch(t,
slices.Collect(maps.Values(python)), tt.valid)
+ })
+ }
+}
+
+// TestDagRunStateMatchesGenmodels fails when regenerating genmodels from the
supervisor schema
+// adds, renames or removes a DagRunState constant. The DagRunState alias
brings the type into
+// this package but not the genmodels constants, so enums.go declares each of
those constants
+// again.
+func TestDagRunStateMatchesGenmodels(t *testing.T) {
+ generated := readPairs(
+ t,
+ "../pkg/execution/genmodels/models.gen.go",
+ `(?m)^const DagRunState(\w+) DagRunState = "([^"]*)"$`,
+ )
+ declared := readPairs(t, enumsPath, `(?m)^\tDagRunState(\w+)
+DagRunState = "([^"]*)"$`)
+
+ assert.Equal(t, generated, declared)
+ assert.ElementsMatch(t, slices.Collect(maps.Values(generated)),
toStrings(dagRunStates))
+}
+
+func TestTaskRejectsAValueOutsideAnEnum(t *testing.T) {
+ const (
+ triggerRuleValues = `["all_success" "all_failed" "all_done"
"all_done_min_one_success" ` +
+ `"all_done_setup_success" "one_success" "one_failed"
"one_done" "none_failed" ` +
+ `"none_failed_min_one_success" "none_skipped"
"all_skipped" "always"]`
+ weightRuleValues = `["downstream" "upstream" "absolute"]`
+ )
+ tests := []struct {
+ name string
+ fn any
+ spec TaskSpec
+ taskID string
+ want string
+ }{
+ {
+ name: "TriggerRule of a task named after its
function",
+ fn: extract,
+ spec: TaskSpec{TriggerRule: "all_sucess"},
+ taskID: "extract",
+ want: `airflow.TaskSpec.TriggerRule is "all_sucess",
which is not a trigger rule; ` +
+ `use one of ` + triggerRuleValues,
+ },
+ {
+ name: "WeightRule next to a valid TriggerRule",
+ fn: extract,
+ spec: TaskSpec{
+ TaskID: "extract_rows", TriggerRule:
TriggerRuleAllDone, WeightRule: "Absolute",
+ },
+ taskID: "extract_rows",
+ want: `airflow.TaskSpec.WeightRule is "Absolute", which
is not a weight rule; ` +
+ `use one of ` + weightRuleValues,
+ },
+ {
+ name: "WeightRule of a task from TriggerDagRun",
+ fn: TriggerDagRun(TriggerDagRunSpec{DagID:
"downstream_etl"}),
+ spec: TaskSpec{TaskID: "trigger_downstream",
WeightRule: "Absolute"},
+ taskID: "trigger_downstream",
+ want: `airflow.TaskSpec.WeightRule is "Absolute", which
is not a weight rule; ` +
+ `use one of ` + weightRuleValues,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dag := Dag("etl")
+ assert.PanicsWithValue(t,
+ fmt.Sprintf(`airflow.DagRef.Task: task %q of
Dag "etl": %s`, tt.taskID, tt.want),
+ func() { dag.Task(tt.fn, tt.spec) },
+ )
+ assert.NotPanics(t,
+ func() { dag.Task(extract, TaskSpec{TaskID:
tt.taskID}) },
+ "a rejected task does not take its task_id",
+ )
+ })
+ }
+}
+
+func TestTaskAcceptsEveryValueOfAnEnum(t *testing.T) {
+ dag := Dag("etl")
+ dag.Task(extract, TaskSpec{TaskID: "no_rules"})
+ for _, rule := range triggerRules {
+ dag.Task(extract, TaskSpec{TaskID: "trigger_" + string(rule),
TriggerRule: rule})
+ }
+ for _, rule := range weightRules {
+ dag.Task(extract, TaskSpec{TaskID: "weight_" + string(rule),
WeightRule: rule})
+ }
+
+ assert.Len(t, dag.tasks, 1+len(triggerRules)+len(weightRules))
+}
diff --git a/go-sdk/airflow/spec.go b/go-sdk/airflow/spec.go
index 4b928999d4e..af477b795fc 100644
--- a/go-sdk/airflow/spec.go
+++ b/go-sdk/airflow/spec.go
@@ -39,37 +39,6 @@ import "reflect"
//go:generate go run github.com/atombender/[email protected] --only-models
--struct-name-from-title --tags "" --capitalization ID --capitalization JSON
--capitalization MD --capitalization XCom -p airflow -o spec.gen.go
../../.build/go-sdk/spec.schema.json
//go:generate go run ../internal/genspec -license spec.gen.go
-// TriggerRule is when a task runs, given the state of the tasks upstream of
it, and
-// WeightRule is how a task's priority weight is worked out from the graph
around it.
-// The serialization schema types both as a plain string and names none of the
values
-// either takes, so the constants are written here and the generated fields
are given
-// these types by internal/genspec/authoring.go.
-type TriggerRule string
-
-const (
- TriggerRuleAllSuccess TriggerRule = "all_success"
- TriggerRuleAllFailed TriggerRule = "all_failed"
- TriggerRuleAllDone TriggerRule = "all_done"
- TriggerRuleAllDoneMinOneSuccess TriggerRule =
"all_done_min_one_success"
- TriggerRuleAllDoneSetupSuccess TriggerRule =
"all_done_setup_success"
- TriggerRuleOneSuccess TriggerRule = "one_success"
- TriggerRuleOneFailed TriggerRule = "one_failed"
- TriggerRuleOneDone TriggerRule = "one_done"
- TriggerRuleNoneFailed TriggerRule = "none_failed"
- TriggerRuleNoneFailedMinOneSuccess TriggerRule =
"none_failed_min_one_success"
- TriggerRuleNoneSkipped TriggerRule = "none_skipped"
- TriggerRuleAllSkipped TriggerRule = "all_skipped"
- TriggerRuleAlways TriggerRule = "always"
-)
-
-type WeightRule string
-
-const (
- WeightRuleDownstream WeightRule = "downstream"
- WeightRuleUpstream WeightRule = "upstream"
- WeightRuleAbsolute WeightRule = "absolute"
-)
-
// copySpec returns a copy of spec that shares no slice, map or pointer with
it, so that a
// caller changing what it still holds cannot change a registered Dag.
Assigning a spec
// copies a slice header or a pointer and not what it points at, which is the
sharing this
diff --git a/go-sdk/airflow/spec_test.go b/go-sdk/airflow/spec_test.go
deleted file mode 100644
index 2221bd56e91..00000000000
--- a/go-sdk/airflow/spec_test.go
+++ /dev/null
@@ -1,71 +0,0 @@
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements. See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership. The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License. You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied. See the License for the
-// specific language governing permissions and limitations
-// under the License.
-
-package airflow
-
-import (
- "os"
- "path/filepath"
- "regexp"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-// specPath is where the Go half of each value set is declared, reached from
this package.
-const specPath = "spec.go"
-
-func readLines(t *testing.T, path, pattern string) []string {
- t.Helper()
-
- body, err := os.ReadFile(filepath.FromSlash(path))
- require.NoError(t, err)
- matches :=
regexp.MustCompile(pattern).FindAllStringSubmatch(string(body), -1)
- require.NotEmpty(t, matches, "%s matched nothing, so the file's shape
has changed", path)
- values := make([]string, 0, len(matches))
- for _, match := range matches {
- values = append(values, match[1])
- }
- return values
-}
-
-// TestRuleConstantsMatchPython is the tripwire for a trigger rule or a weight
rule
-// added or renamed on the Python side. The serialization schema types both
fields as a
-// plain string and names none of their values, so the constants here are
hand-written
-// and the spec drift check cannot see them; Airflow rejects a rule it does
not know, so
-// a missing constant is a Dag that fails to register.
-func TestRuleConstantsMatchPython(t *testing.T) {
- for _, tt := range []struct {
- goType string
- pythonPath string
- }{
- {goType: "TriggerRule", pythonPath:
"../../airflow-core/src/airflow/task/trigger_rule.py"},
- {goType: "WeightRule", pythonPath:
"../../airflow-core/src/airflow/task/weight_rule.py"},
- } {
- t.Run(tt.goType, func(t *testing.T) {
- python := readLines(t, tt.pythonPath, `(?m)^ [A-Z_]+
= "([a-z_]+)"$`)
- spelled := readLines(
- t,
- specPath,
- `(?m)^\t`+tt.goType+`\w+ +`+tt.goType+` =
"([a-z_]+)"$`,
- )
-
- assert.ElementsMatch(t, python, spelled)
- })
- }
-}
diff --git a/go-sdk/airflow/trigger_dag_run.go
b/go-sdk/airflow/trigger_dag_run.go
index dcec75a9cca..d6ede6f724c 100644
--- a/go-sdk/airflow/trigger_dag_run.go
+++ b/go-sdk/airflow/trigger_dag_run.go
@@ -24,8 +24,6 @@ import (
"fmt"
"slices"
"time"
-
- "github.com/apache/airflow/go-sdk/pkg/execution/genmodels"
)
// TriggerDagRunSpec holds the options of a task that triggers a Dag run.
[TriggerDagRun] takes a
@@ -61,16 +59,15 @@ type TriggerDagRunSpec struct {
// PokeInterval is how often a task that waits checks the state of the
new Dag run. It must be
// a whole number of seconds. When PokeInterval is nil, the task checks
every 60 seconds.
PokeInterval *time.Duration
- // AllowedStates are the states of the new Dag run in which a task that
waits succeeds. Each
- // state is one of queued, running, success and failed. When
AllowedStates is empty, the task
- // succeeds in the success state.
- AllowedStates []string
- // FailedStates are the states of the new Dag run in which a task that
waits fails. Each state
- // is one of queued, running, success and failed. A nil FailedStates
fails the task in the
- // failed state. A FailedStates that is empty but not nil means that no
state fails the task.
- // The task then succeeds once the new Dag run is in a state that
AllowedStates lists. In any
- // other state, including failed, the task keeps waiting.
- FailedStates []string
+ // AllowedStates are the states of the new Dag run in which a task that
waits succeeds. When
+ // AllowedStates is empty, the task succeeds in the success state of
the new Dag run.
+ AllowedStates []DagRunState
+ // FailedStates are the states of the new Dag run in which a task that
waits fails. When
+ // FailedStates is nil, the task fails in the failed state of the new
Dag run. When
+ // FailedStates is empty but not nil, no Dag run state fails the task.
The task then succeeds
+ // once the new Dag run is in a state that AllowedStates lists. In any
other state, including
+ // failed, the task keeps waiting.
+ FailedStates []DagRunState
// SkipWhenAlreadyExists marks the task skipped if the Dag run already
exists.
SkipWhenAlreadyExists bool
// FailWhenDagIsPaused fails the task when the Dag to trigger is paused.
@@ -108,15 +105,6 @@ func TriggerDagRun(spec TriggerDagRunSpec)
TriggerDagRunTask {
return TriggerDagRunTask{spec: spec}
}
-// validDagRunStates are the values that TriggerDagRunOperator accepts in
allowed_states and
-// failed_states.
-var validDagRunStates = []string{
- string(genmodels.DagRunStateQueued),
- string(genmodels.DagRunStateRunning),
- string(genmodels.DagRunStateSuccess),
- string(genmodels.DagRunStateFailed),
-}
-
// copyTriggerDagRunSpec checks spec and returns a deep copy of it, so that
nothing the caller
// still holds, such as Conf, a state slice or a pointer field, can change the
task that
// DagRef.Task added.
@@ -133,14 +121,14 @@ func copyTriggerDagRunSpec(spec TriggerDagRunSpec)
(TriggerDagRunSpec, error) {
}
for _, field := range []struct {
name string
- states []string
+ states []DagRunState
}{{"AllowedStates", spec.AllowedStates}, {"FailedStates",
spec.FailedStates}} {
for _, state := range field.states {
- if !slices.Contains(validDagRunStates, state) {
+ if !slices.Contains(dagRunStates, state) {
return TriggerDagRunSpec{}, fmt.Errorf(
"airflow.TriggerDagRunSpec.%s has %q,
which is not a Dag run state; "+
"use one of %q",
- field.name, state, validDagRunStates,
+ field.name, state, dagRunStates,
)
}
}
diff --git a/go-sdk/airflow/trigger_dag_run_test.go
b/go-sdk/airflow/trigger_dag_run_test.go
index b812f146cec..d7cf45b92ec 100644
--- a/go-sdk/airflow/trigger_dag_run_test.go
+++ b/go-sdk/airflow/trigger_dag_run_test.go
@@ -40,8 +40,8 @@ func TestTriggerDagRunIsATask(t *testing.T) {
ResetDagRun: true,
WaitForCompletion: true,
PokeInterval: ptr(30 * time.Second),
- AllowedStates: []string{"success", "failed"},
- FailedStates: []string{"queued", "running"},
+ AllowedStates: []DagRunState{DagRunStateSuccess,
DagRunStateFailed},
+ FailedStates: []DagRunState{DagRunStateQueued,
DagRunStateRunning},
SkipWhenAlreadyExists: true,
FailWhenDagIsPaused: true,
Note: "triggered by etl",
@@ -71,8 +71,8 @@ func TestTriggerDagRunKeepsNilApartFromZero(t *testing.T) {
DagID: "downstream_etl",
Conf: map[string]any{},
PokeInterval: ptr(time.Duration(0)),
- AllowedStates: []string{},
- FailedStates: []string{},
+ AllowedStates: []DagRunState{},
+ FailedStates: []DagRunState{},
Deferrable: ptr(false),
}
task = dag.Task(TriggerDagRun(zero), TaskSpec{TaskID: "zero"})
@@ -130,7 +130,7 @@ func TestTriggerDagRunRejectsAnInvalidSpec(t *testing.T) {
{
name: "unknown state in AllowedStates",
trigger: TriggerDagRun(TriggerDagRunSpec{
- DagID: "downstream_etl", AllowedStates:
[]string{"success", "SUCCESS"},
+ DagID: "downstream_etl", AllowedStates:
[]DagRunState{DagRunStateSuccess, "SUCCESS"},
}),
want: `airflow.TriggerDagRunSpec.AllowedStates has
"SUCCESS", which is not a Dag ` +
`run state; use one of ["queued" "running"
"success" "failed"]`,
@@ -138,7 +138,7 @@ func TestTriggerDagRunRejectsAnInvalidSpec(t *testing.T) {
{
name: "unknown state in FailedStates",
trigger: TriggerDagRun(TriggerDagRunSpec{
- DagID: "downstream_etl", FailedStates:
[]string{"skipped"},
+ DagID: "downstream_etl", FailedStates:
[]DagRunState{"skipped"},
}),
want: `airflow.TriggerDagRunSpec.FailedStates has
"skipped", which is not a Dag ` +
`run state; use one of ["queued" "running"
"success" "failed"]`,
@@ -175,8 +175,8 @@ func TestTriggerDagRunRejectsAnInvalidSpec(t *testing.T) {
func TestTriggerDagRunCopiesTheSpec(t *testing.T) {
nested := map[string]any{"table": "rows"}
- allowed := []string{"success"}
- failed := []string{"failed"}
+ allowed := []DagRunState{DagRunStateSuccess}
+ failed := []DagRunState{DagRunStateFailed}
poke := 30 * time.Second
deferrable := true
spec := TriggerDagRunSpec{
@@ -192,8 +192,8 @@ func TestTriggerDagRunCopiesTheSpec(t *testing.T) {
nested["table"] = "changed"
spec.Conf["added"] = true
- allowed[0] = "running"
- failed[0] = "queued"
+ allowed[0] = DagRunStateRunning
+ failed[0] = DagRunStateQueued
poke = time.Minute
deferrable = false
@@ -205,8 +205,8 @@ func TestTriggerDagRunCopiesTheSpec(t *testing.T) {
},
stored.Conf,
)
- assert.Equal(t, []string{"success"}, stored.AllowedStates)
- assert.Equal(t, []string{"failed"}, stored.FailedStates)
+ assert.Equal(t, []DagRunState{DagRunStateSuccess}, stored.AllowedStates)
+ assert.Equal(t, []DagRunState{DagRunStateFailed}, stored.FailedStates)
assert.Equal(t, 30*time.Second, *stored.PokeInterval)
assert.True(t, *stored.Deferrable)
}
diff --git a/go-sdk/internal/genspec/authoring.go
b/go-sdk/internal/genspec/authoring.go
index 4eac1c5ccc1..d515065a5d5 100644
--- a/go-sdk/internal/genspec/authoring.go
+++ b/go-sdk/internal/genspec/authoring.go
@@ -81,7 +81,7 @@ var dagShape = authoringShape{
"dag_dependencies": "derived from the edges and the
assets a Dag declares",
"timezone": "carried by the time.Time an
author sets on StartDate",
"timetable": "the serialized form of Schedule,
which is injected instead",
- "allowed_run_types": "a union the author expresses by
setting Schedule",
+ "allowed_run_types": "no Go authoring type yet: a list
of DagRunType values",
"_concurrency": "the pre-2.2 spelling of
MaxActiveTasks",
"has_on_success_callback": "derived from whether a callback
is registered",
"has_on_failure_callback": "derived from whether a callback
is registered",
@@ -171,8 +171,8 @@ var taskShape = authoringShape{
"max_active_tis_per_dag": {goType: "int"},
"max_active_tis_per_dagrun": {goType: "int"},
// TriggerRule, WeightRule and their constants are hand-written
in the airflow
- // package: the schema types both fields as a plain string and
names none of
- // the values either one takes.
+ // package, because the schema types trigger_rule and
weight_rule as plain
+ // strings and does not list their values.
"trigger_rule": {goType: "TriggerRule"},
"weight_rule": {goType: "WeightRule"},
// A multiplier, not a switch: 0 keeps the delay constant, 2.0
doubles it each