jason810496 commented on code in PR #74319:
URL: https://github.com/apache/airflow/pull/74319#discussion_r4191906019
##########
go-sdk/airflow/dag.go:
##########
@@ -82,11 +91,52 @@ func Dag(dagID string, spec ...DagSpec) *DagRef {
}
d := &DagRef{dagID: dagID}
if len(spec) == 1 {
+ if err := checkDagSpec(spec[0]); err != nil {
+ panic(fmt.Sprintf("airflow.Dag: Dag %q: %v", dagID,
err))
+ }
d.spec = copySpec(spec[0])
}
return d
}
+// tagMaxLength is the longest tag that Python's DAG accepts, counted in
characters. Airflow stores
+// a tag in a column of that length.
+const tagMaxLength = 100
+
+// checkDagSpec rejects a DagSpec with a value that Python rejects when it
builds or validates a
+// Dag. Depending on the value, Airflow would otherwise fail to load the
serialized Dag, fail to
+// store the Dag, or never schedule the Dag.
+func checkDagSpec(spec DagSpec) error {
Review Comment:
Let's add a TODO here saying that the validation should only be invoked at
the build-time eventually.
##########
go-sdk/airflow/recorded_payload_test.go:
##########
@@ -0,0 +1,155 @@
+// 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 (
+ "encoding/json"
+ "flag"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// recordedPayloadPath is the path of a fixture of the Airflow core tests. For
each serialized Dag
+// in the file, those tests run DagSerialization.validate_serialized_dag and
put the Dag in a Dag
+// bag. If a change to Airflow stops Airflow from loading what this SDK
writes, those tests fail.
+const recordedPayloadPath = "../../airflow-core/tests/unit/dag_processing/" +
+ "lang_sdk_fixtures/go_native.json"
+
+var updateRecordedPayload = flag.Bool(
+ "update-recorded-payload", false, "rewrite "+recordedPayloadPath,
+)
+
+// recordedDags builds the Dags of the fixture. They also use constructs that
the Dags of
+// scripts/ci/lang_sdk_serialization/test_dags.yaml leave out, such as dag.If,
TriggerDagRun and
+// Label.
+func recordedDags() []*DagRef {
+ dag := Dag("go_native", DagSpec{
Review Comment:
I agreed this is a nice robust test case.
Instead of storing the excepted JSON output in the go-sdk test dir, would it
be better to make it part of the YAML test case that we define for all the
languages so that the TS and Java serde can also be verified with this
sophisticated case.
##########
go-sdk/airflow/conformance_test.go:
##########
@@ -0,0 +1,214 @@
+// 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 (
+ "encoding/json"
+ "flag"
+ "fmt"
+ "os"
+ "reflect"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
+)
+
+// TestSerializeConformanceDags builds the Dags of
scripts/ci/lang_sdk_serialization/test_dags.yaml
+// with this SDK, serializes them, and writes them to a JSON file keyed by
dag_id.
+// serialize_python.py in the same directory does the same with Airflow's own
serializer. compare.py
+// there runs this test as the serializer of the Go SDK for the
+// check-go-sdk-serialization-conformance prek hook, and passes the two paths
after -args:
+//
+// go -C go-sdk test ./airflow -count=1 -run
'^TestSerializeConformanceDags$' -args <test_dags.yaml> <output.json>
+//
+// A plain go test run passes no paths, so the test skips.
+func TestSerializeConformanceDags(t *testing.T) {
+ args := flag.Args()
+ if len(args) != 2 {
+ t.Skip(
+ "compare.py runs this with the paths of test_dags.yaml
and of the output after -args",
+ )
+ }
+ raw, err := os.ReadFile(args[0])
+ require.NoError(t, err)
+ var doc yaml.Node
+ require.NoError(t, yaml.Unmarshal(raw, &doc))
+ var file struct {
+ Dags []conformanceDag `yaml:"dags"`
+ }
+ require.NoError(t, doc.Decode(&file))
+
+ bundle := Bundle()
+ dags := make([]*DagRef, len(file.Dags))
+ for i, dagCase := range file.Dags {
+ dags[i] = buildConformanceDag(t, dagCase)
+ bundle.Register(dags[i])
+ }
+ serialized := make(map[string]any, len(dags))
+ for _, dag := range dags {
+ // compare.py does not compare fileloc, which names the file
that declares a Dag, so any path
+ // works here. Airflow still needs a fileloc to load the Dag.
+ serialized[dag.dagID] = dag.serialize("/bundles/app/etl", "etl")
+ }
+ out, err := json.MarshalIndent(serialized, "", " ")
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(args[1], append(out, '\n'), 0o644))
+}
+
+// conformanceDag is one Dag of test_dags.yaml. A spec maps the snake_case
keys of the serialization
+// schema to values. The tag !datetime marks a value that is a moment, and
!timedelta marks a number
+// of seconds.
+type conformanceDag struct {
+ DagID string `yaml:"dag_id"`
+ Spec yaml.Node `yaml:"spec"`
+ Groups []string `yaml:"groups"`
+ Tasks []conformanceTask `yaml:"tasks"`
+ OrderEdges [][2]string `yaml:"order_edges"`
+}
+
+type conformanceTask struct {
+ TaskID string `yaml:"task_id"`
+ Group string `yaml:"group"`
+ Upstream []string `yaml:"upstream"`
+ Spec yaml.Node `yaml:"spec"`
+}
+
+func buildConformanceDag(t *testing.T, dagCase conformanceDag) *DagRef {
+ t.Helper()
+ var spec DagSpec
+ setConformanceSpec(t, &spec, dagSpecRules, dagCase.Spec, dagCase.DagID)
+ dag := Dag(dagCase.DagID, spec)
+
+ // test_dags.yaml gives the full group_id, so the parent of a group is
what comes before the last
+ // dot. A parent comes before the groups it holds.
+ groups := make(map[string]*TaskGroupRef)
+ for _, groupID := range dagCase.Groups {
+ cut := strings.LastIndex(groupID, ".")
+ if cut < 0 {
+ groups[groupID] = dag.TaskGroup(groupID)
+ continue
+ }
+ parent, ok := groups[groupID[:cut]]
+ require.True(t, ok, "%s: group %q comes before its parent",
dagCase.DagID, groupID)
+ groups[groupID] = parent.TaskGroup(groupID[cut+1:])
+ }
+
+ tasks := make(map[string]*TaskRef)
+ for _, task := range dagCase.Tasks {
+ var taskSpec TaskSpec
+ label := dagCase.DagID + "." + task.TaskID
+ setConformanceSpec(t, &taskSpec, taskSpecRules, task.Spec,
label)
+ taskSpec.TaskID = task.TaskID
+ // Each upstream task passes its result to a parameter of the
task, which gives the task the edge
+ // that test_dags.yaml asks for.
+ upstreams := make([]*TaskRef, len(task.Upstream))
+ for i, upstreamID := range task.Upstream {
+ upstream, ok := tasks[upstreamID]
+ require.True(t, ok, "%s: upstream %q comes after the
task", label, upstreamID)
+ upstreams[i] = upstream
+ }
+ fn := conformanceTaskFunction(len(upstreams))
+ opts := []TaskOption{taskSpec, Inputs(upstreams...)}
+ var added *TaskRef
+ if task.Group == "" {
+ added = dag.Task(fn, opts...)
+ } else {
+ group, ok := groups[task.Group]
+ require.True(t, ok, "%s: no group %q", label,
task.Group)
+ added = group.Task(fn, opts...)
+ }
+ tasks[added.taskID] = added
+ }
+
+ node := func(id string) Node {
+ if group, ok := groups[id]; ok {
+ return group
+ }
+ task, ok := tasks[id]
+ require.True(t, ok, "%s: no task or group %q", dagCase.DagID,
id)
+ return task
+ }
+ for _, edge := range dagCase.OrderEdges {
+ node(edge[0]).Before(node(edge[1]))
Review Comment:
Would it be possible to just invoke the public API to construct the whole
Dag?
##########
go-sdk/airflow/dag.go:
##########
@@ -82,11 +91,52 @@ func Dag(dagID string, spec ...DagSpec) *DagRef {
}
d := &DagRef{dagID: dagID}
if len(spec) == 1 {
+ if err := checkDagSpec(spec[0]); err != nil {
+ panic(fmt.Sprintf("airflow.Dag: Dag %q: %v", dagID,
err))
+ }
d.spec = copySpec(spec[0])
}
return d
}
+// tagMaxLength is the longest tag that Python's DAG accepts, counted in
characters. Airflow stores
+// a tag in a column of that length.
+const tagMaxLength = 100
+
+// checkDagSpec rejects a DagSpec with a value that Python rejects when it
builds or validates a
+// Dag. Depending on the value, Airflow would otherwise fail to load the
serialized Dag, fail to
+// store the Dag, or never schedule the Dag.
+func checkDagSpec(spec DagSpec) error {
+ if err := checkSchedule(spec.Schedule); err != nil {
+ return err
+ }
+ // An unset MaxActiveRuns takes [core] max_active_runs_per_dag, which
is 16 by default.
+ if spec.Schedule == "@continuous" && (spec.MaxActiveRuns == 0 ||
spec.MaxActiveRuns > 1) {
Review Comment:
Negative `MaxActiveRuns` isn't rejected: `DagSpec{Schedule: "@continuous",
MaxActiveRuns: -5}` builds and serializes `max_active_runs: -5`.
```suggestion
if spec.Schedule == "@continuous" && (spec.MaxActiveRuns <= 0 ||
spec.MaxActiveRuns > 1) {
```
##########
go-sdk/airflow/serialize.go:
##########
@@ -0,0 +1,779 @@
+// 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 (
+ "cmp"
+ "fmt"
+ "reflect"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// A serialized Dag is the Dag JSON that Airflow stores for a Dag and that its
scheduler reads.
+// Airflow core owns the format.
airflow-core/src/airflow/serialization/schema.json describes it,
+// and Python's DagSerialization writes it for a Dag authored in Python. The
serializer in this file
+// does not write the same bytes as Python. Python picks the fields to leave
out with a
+// client_defaults table that a bundle never gets, so Python writes some
fields at their schema
+// default, such as retry_delay, that this serializer leaves out. Airflow
reads a missing field as
+// its schema default, so it loads the same Dag from either serialization.
+// scripts/ci/lang_sdk_serialization/compare.py checks that for the Dags in
test_dags.yaml in the
+// same directory.
+
+// dagSerializationVersion is the __version of a serialized Dag. It is the
SERIALIZER_VERSION that
+// Airflow core's DagSerialization checks when it loads a serialized Dag. The
supervisor schema has
+// versions named by date, which Airflow negotiates with each runtime, but the
Dag JSON that a
+// bundle returns has only this number.
+const dagSerializationVersion = 3
+
+// A task that runs a Go function gets this task_type and _task_module, where
a task of a Python Dag
+// names the class and the module of its operator. Airflow never imports
_task_module, so the two
+// can name the coordinator that runs the task instead, as the TypeScript SDK
does for its tasks.
+// Nothing in Airflow reads language. It tells a reader of the serialized Dag
which SDK wrote the
+// task.
+const (
+ goTaskType = "GoOperator"
+ goTaskModule = "airflow.sdk.coordinators.executable"
+ goLanguage = "go"
+)
+
+// Python's serializer writes these values for a TriggerDagRunOperator. The
operator class is in
+//
providers/standard/src/airflow/providers/standard/operators/trigger_dagrun.py.
+const (
+ triggerDagRunTaskType = "TriggerDagRunOperator"
+ triggerDagRunTaskModule =
"airflow.providers.standard.operators.trigger_dagrun"
+ triggerDagRunUIColor = "#ffefeb"
+)
+
+var triggerDagRunTemplateFields = []string{
+ "trigger_dag_id",
+ "trigger_run_id",
+ "logical_date",
+ "conf",
+ "wait_for_completion",
+ "skip_when_already_exists",
+}
+
+// The ui_color and ui_fgcolor that Python's TaskGroup uses when the Dag
author sets neither.
+const (
+ defaultGroupUIColor = "CornflowerBlue"
+ defaultGroupUIFgColor = "#000"
+)
+
+// A Schedule maps to one of these timetables. Python's DAG picks the same
ones for a schedule
+// string while [scheduler] create_cron_data_intervals is false, which is its
default. When the
+// option is true, Python picks CronDataIntervalTimetable for a cron
expression instead. A bundle
+// cannot read the option, so a Go Dag always gets CronTriggerTimetable
+// (https://github.com/apache/airflow/issues/67938).
+const (
+ nullTimetable = "airflow.timetables.simple.NullTimetable"
+ onceTimetable = "airflow.timetables.simple.OnceTimetable"
+ continuousTimetable = "airflow.timetables.simple.ContinuousTimetable"
+ cronTriggerTimetable = "airflow.timetables.trigger.CronTriggerTimetable"
+)
+
+// cronPresets maps each cron preset to the expression that Python records for
it. It holds the same
+// entries as cron_presets in airflow-core/src/airflow/utils/dates.py.
+var cronPresets = map[string]string{
+ "@hourly": "0 * * * *",
+ "@daily": "0 0 * * *",
+ "@weekly": "0 0 * * 0",
+ "@monthly": "0 0 1 * *",
+ "@quarterly": "0 0 1 */3 *",
+ "@yearly": "0 0 1 1 *",
+}
+
+// croniterAliases are the presets that croniter, which reads a cron
expression for Airflow, accepts
+// in upper or lower case, such as "@Daily". Python passes such a preset to
croniter unchanged
+// unless the preset is a key of cronPresets, and a Go Dag does the same.
+var croniterAliases = map[string]bool{
+ "@midnight": true,
+ "@hourly": true,
+ "@daily": true,
+ "@weekly": true,
+ "@monthly": true,
+ "@yearly": true,
+ "@annually": true,
+}
+
+// cronItem matches one comma-separated item of a field of a cron expression.
An item is one value,
+// or values joined by -, / or #. A value is one of these:
+// - a number, with or without an L or a W before or after it, such as 15W
or L5
+// - one of *, ?, L, W and R
+// - R with a range, such as R(0-30)
+// - three letters, as in a month or weekday name such as JAN or MON
+//
+// cronItem checks only the shape of an item. It takes some items that
croniter rejects, such as a
+// minute of 61 or a weekday of 5L. croniter rejects those only when Airflow
tries to schedule the
+// Dag.
+var cronItem = regexp.MustCompile(`^(?i:` + cronValue + `(?:[-/#]` + cronValue
+ `)*)$`)
+
+const cronValue =
`(?:[0-9]+[LW]?|[LW][0-9]+|[*?LWR]|R\([0-9]+-[0-9]+\)|[A-Z]{3})`
+
+// checkSchedule returns an error unless the Schedule is empty, a preset, or
shaped like a cron
+// expression of five to seven fields. Airflow does not check a cron
expression when it loads a
+// serialized Dag. Without this check, a Schedule such as "every day" would
reach Airflow, and
+// Airflow would never schedule the Dag, with only a line in its log to say
why.
+func checkSchedule(schedule string) error {
+ switch schedule {
+ case "", "@once", "@continuous":
+ return nil
+ }
+ if _, ok := cronPresets[schedule]; ok ||
croniterAliases[strings.ToLower(schedule)] {
+ return nil
+ }
+ fields := strings.Fields(schedule)
+ shaped := len(fields) >= 5 && len(fields) <= 7
+ for _, field := range fields {
+ for item := range strings.SplitSeq(field, ",") {
+ shaped = shaped && cronItem.MatchString(item)
+ }
+ }
+ if !shaped {
+ return fmt.Errorf(
+ "airflow.DagSpec.Schedule is %q, which is not a cron
expression or a preset; set a "+
+ "cron expression such as \"0 3 * * *\", a
preset such as \"@daily\", \"@once\" or "+
+ "\"@continuous\", or no Schedule for a Dag that
runs only when something triggers it",
+ schedule,
+ )
+ }
+ return nil
+}
+
+// serializeTimetable returns the timetable that Python's DAG builds from the
same schedule string.
+// Dag has already checked the Schedule with checkSchedule.
+func serializeTimetable(schedule string) map[string]any {
+ switch schedule {
+ case "":
+ return map[string]any{"__type": nullTimetable, "__var":
map[string]any{}}
+ case "@once":
+ return map[string]any{"__type": onceTimetable, "__var":
map[string]any{}}
+ case "@continuous":
+ return map[string]any{"__type": continuousTimetable, "__var":
map[string]any{}}
+ }
+ expression := schedule
+ if preset, ok := cronPresets[schedule]; ok {
+ expression = preset
+ }
+ return map[string]any{
+ "__type": cronTriggerTimetable,
+ "__var": map[string]any{
+ "expression": expression,
+ "timezone": "UTC",
+ "interval": 0.0,
+ "run_immediately": false,
+ },
+ }
+}
+
+// fieldRule says how the serializer writes one field of DagSpec, TaskSpec or
TaskGroupSpec. Each of
+// the three structs has a table with an entry for every field. When the
generator adds a field to a
+// struct, TestSpecRulesCoverEveryField fails until the table gets an entry
for it.
+type fieldRule struct {
+ // key is the property of the serialization schema that the field sets.
A field with no key, such
+ // as DagSpec.Schedule, has code of its own that writes it, which the
comment on its entry names.
+ key string
+ // schemaDefault is the default that the serialization schema gives the
property, or nil when the
+ // schema has no default for it. writeSpecFields leaves out a value
equal to the default, because
+ // Airflow reads a missing property as its schema default.
+ schemaDefault any
+ // set marks a list that Python keeps in a set, so Python writes the
list sorted and without
+ // repeats. writeSpecFields writes it the same way.
+ set bool
+ // skip marks a field that has a key but that writeSpecFields never
writes. The comment on its
+ // entry says why.
+ skip bool
+}
+
+var dagSpecRules = map[string]fieldRule{
+ "Catchup": {key: "catchup"},
+ "DagDisplayName": {key: "dag_display_name"},
+ "DagrunTimeout": {key: "dagrun_timeout"},
+ "Description": {key: "description"},
+ "DisableBundleVersioning": {key: "disable_bundle_versioning"},
+ "DocMD": {key: "doc_md"},
+ "EndDate": {key: "end_date"},
+ "FailFast": {key: "fail_fast", schemaDefault: false},
+ "IsPausedUponCreation": {key: "is_paused_upon_creation"},
+ "MaxActiveRuns": {key: "max_active_runs"},
+ "MaxActiveTasks": {key: "max_active_tasks"},
+ "MaxConsecutiveFailedDagRuns": {key: "max_consecutive_failed_dag_runs"},
+ // serializeTaskLocked writes Queue as the queue of each Go task whose
TaskSpec sets no Queue.
+ "Queue": {},
+ "RenderTemplateAsNativeObj": {key: "render_template_as_native_obj",
schemaDefault: false},
+ // serializeTimetable writes Schedule as the timetable.
+ "Schedule": {},
+ "StartDate": {key: "start_date"},
+ "Tags": {key: "tags", set: true},
+}
+
+var taskSpecRules = map[string]fieldRule{
+ "TaskDisplayName": {key: "_task_display_name"},
+ "DependsOnPast": {key: "depends_on_past", schemaDefault: false},
+ "DoXComPush": {key: "do_xcom_push", schemaDefault: true},
+ "DocMD": {key: "doc_md"},
+ // Python writes email_on_failure and email_on_retry only for an
operator that has an email
Review Comment:
We shouldn't hand write the schema default in the SDK side serde.
It would be better to define the schema default at the Spec generator stage
to prevent the drift like the TS SDK side.
##########
go-sdk/airflow/serialize.go:
##########
@@ -0,0 +1,779 @@
+// 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 (
+ "cmp"
+ "fmt"
+ "reflect"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// A serialized Dag is the Dag JSON that Airflow stores for a Dag and that its
scheduler reads.
+// Airflow core owns the format.
airflow-core/src/airflow/serialization/schema.json describes it,
+// and Python's DagSerialization writes it for a Dag authored in Python. The
serializer in this file
+// does not write the same bytes as Python. Python picks the fields to leave
out with a
+// client_defaults table that a bundle never gets, so Python writes some
fields at their schema
+// default, such as retry_delay, that this serializer leaves out. Airflow
reads a missing field as
+// its schema default, so it loads the same Dag from either serialization.
+// scripts/ci/lang_sdk_serialization/compare.py checks that for the Dags in
test_dags.yaml in the
+// same directory.
+
+// dagSerializationVersion is the __version of a serialized Dag. It is the
SERIALIZER_VERSION that
+// Airflow core's DagSerialization checks when it loads a serialized Dag. The
supervisor schema has
+// versions named by date, which Airflow negotiates with each runtime, but the
Dag JSON that a
+// bundle returns has only this number.
+const dagSerializationVersion = 3
+
+// A task that runs a Go function gets this task_type and _task_module, where
a task of a Python Dag
+// names the class and the module of its operator. Airflow never imports
_task_module, so the two
+// can name the coordinator that runs the task instead, as the TypeScript SDK
does for its tasks.
+// Nothing in Airflow reads language. It tells a reader of the serialized Dag
which SDK wrote the
+// task.
+const (
+ goTaskType = "GoOperator"
+ goTaskModule = "airflow.sdk.coordinators.executable"
+ goLanguage = "go"
+)
+
+// Python's serializer writes these values for a TriggerDagRunOperator. The
operator class is in
+//
providers/standard/src/airflow/providers/standard/operators/trigger_dagrun.py.
+const (
+ triggerDagRunTaskType = "TriggerDagRunOperator"
+ triggerDagRunTaskModule =
"airflow.providers.standard.operators.trigger_dagrun"
+ triggerDagRunUIColor = "#ffefeb"
+)
+
+var triggerDagRunTemplateFields = []string{
+ "trigger_dag_id",
+ "trigger_run_id",
+ "logical_date",
+ "conf",
+ "wait_for_completion",
+ "skip_when_already_exists",
+}
+
+// The ui_color and ui_fgcolor that Python's TaskGroup uses when the Dag
author sets neither.
+const (
+ defaultGroupUIColor = "CornflowerBlue"
+ defaultGroupUIFgColor = "#000"
+)
+
+// A Schedule maps to one of these timetables. Python's DAG picks the same
ones for a schedule
+// string while [scheduler] create_cron_data_intervals is false, which is its
default. When the
+// option is true, Python picks CronDataIntervalTimetable for a cron
expression instead. A bundle
+// cannot read the option, so a Go Dag always gets CronTriggerTimetable
+// (https://github.com/apache/airflow/issues/67938).
+const (
+ nullTimetable = "airflow.timetables.simple.NullTimetable"
+ onceTimetable = "airflow.timetables.simple.OnceTimetable"
+ continuousTimetable = "airflow.timetables.simple.ContinuousTimetable"
+ cronTriggerTimetable = "airflow.timetables.trigger.CronTriggerTimetable"
+)
+
+// cronPresets maps each cron preset to the expression that Python records for
it. It holds the same
+// entries as cron_presets in airflow-core/src/airflow/utils/dates.py.
+var cronPresets = map[string]string{
+ "@hourly": "0 * * * *",
+ "@daily": "0 0 * * *",
+ "@weekly": "0 0 * * 0",
+ "@monthly": "0 0 1 * *",
+ "@quarterly": "0 0 1 */3 *",
+ "@yearly": "0 0 1 1 *",
+}
+
+// croniterAliases are the presets that croniter, which reads a cron
expression for Airflow, accepts
+// in upper or lower case, such as "@Daily". Python passes such a preset to
croniter unchanged
+// unless the preset is a key of cronPresets, and a Go Dag does the same.
+var croniterAliases = map[string]bool{
+ "@midnight": true,
+ "@hourly": true,
+ "@daily": true,
+ "@weekly": true,
+ "@monthly": true,
+ "@yearly": true,
+ "@annually": true,
+}
+
+// cronItem matches one comma-separated item of a field of a cron expression.
An item is one value,
+// or values joined by -, / or #. A value is one of these:
+// - a number, with or without an L or a W before or after it, such as 15W
or L5
+// - one of *, ?, L, W and R
+// - R with a range, such as R(0-30)
+// - three letters, as in a month or weekday name such as JAN or MON
+//
+// cronItem checks only the shape of an item. It takes some items that
croniter rejects, such as a
+// minute of 61 or a weekday of 5L. croniter rejects those only when Airflow
tries to schedule the
+// Dag.
+var cronItem = regexp.MustCompile(`^(?i:` + cronValue + `(?:[-/#]` + cronValue
+ `)*)$`)
+
+const cronValue =
`(?:[0-9]+[LW]?|[LW][0-9]+|[*?LWR]|R\([0-9]+-[0-9]+\)|[A-Z]{3})`
+
+// checkSchedule returns an error unless the Schedule is empty, a preset, or
shaped like a cron
+// expression of five to seven fields. Airflow does not check a cron
expression when it loads a
+// serialized Dag. Without this check, a Schedule such as "every day" would
reach Airflow, and
+// Airflow would never schedule the Dag, with only a line in its log to say
why.
+func checkSchedule(schedule string) error {
+ switch schedule {
+ case "", "@once", "@continuous":
+ return nil
+ }
+ if _, ok := cronPresets[schedule]; ok ||
croniterAliases[strings.ToLower(schedule)] {
+ return nil
+ }
+ fields := strings.Fields(schedule)
+ shaped := len(fields) >= 5 && len(fields) <= 7
+ for _, field := range fields {
+ for item := range strings.SplitSeq(field, ",") {
+ shaped = shaped && cronItem.MatchString(item)
+ }
+ }
+ if !shaped {
+ return fmt.Errorf(
+ "airflow.DagSpec.Schedule is %q, which is not a cron
expression or a preset; set a "+
+ "cron expression such as \"0 3 * * *\", a
preset such as \"@daily\", \"@once\" or "+
+ "\"@continuous\", or no Schedule for a Dag that
runs only when something triggers it",
+ schedule,
+ )
+ }
+ return nil
+}
+
+// serializeTimetable returns the timetable that Python's DAG builds from the
same schedule string.
+// Dag has already checked the Schedule with checkSchedule.
+func serializeTimetable(schedule string) map[string]any {
+ switch schedule {
+ case "":
+ return map[string]any{"__type": nullTimetable, "__var":
map[string]any{}}
+ case "@once":
+ return map[string]any{"__type": onceTimetable, "__var":
map[string]any{}}
+ case "@continuous":
+ return map[string]any{"__type": continuousTimetable, "__var":
map[string]any{}}
+ }
+ expression := schedule
+ if preset, ok := cronPresets[schedule]; ok {
+ expression = preset
+ }
+ return map[string]any{
+ "__type": cronTriggerTimetable,
+ "__var": map[string]any{
+ "expression": expression,
+ "timezone": "UTC",
+ "interval": 0.0,
+ "run_immediately": false,
+ },
+ }
+}
+
+// fieldRule says how the serializer writes one field of DagSpec, TaskSpec or
TaskGroupSpec. Each of
+// the three structs has a table with an entry for every field. When the
generator adds a field to a
+// struct, TestSpecRulesCoverEveryField fails until the table gets an entry
for it.
+type fieldRule struct {
+ // key is the property of the serialization schema that the field sets.
A field with no key, such
+ // as DagSpec.Schedule, has code of its own that writes it, which the
comment on its entry names.
+ key string
+ // schemaDefault is the default that the serialization schema gives the
property, or nil when the
+ // schema has no default for it. writeSpecFields leaves out a value
equal to the default, because
+ // Airflow reads a missing property as its schema default.
+ schemaDefault any
+ // set marks a list that Python keeps in a set, so Python writes the
list sorted and without
+ // repeats. writeSpecFields writes it the same way.
+ set bool
+ // skip marks a field that has a key but that writeSpecFields never
writes. The comment on its
+ // entry says why.
+ skip bool
+}
+
+var dagSpecRules = map[string]fieldRule{
+ "Catchup": {key: "catchup"},
+ "DagDisplayName": {key: "dag_display_name"},
+ "DagrunTimeout": {key: "dagrun_timeout"},
+ "Description": {key: "description"},
+ "DisableBundleVersioning": {key: "disable_bundle_versioning"},
+ "DocMD": {key: "doc_md"},
+ "EndDate": {key: "end_date"},
+ "FailFast": {key: "fail_fast", schemaDefault: false},
+ "IsPausedUponCreation": {key: "is_paused_upon_creation"},
+ "MaxActiveRuns": {key: "max_active_runs"},
+ "MaxActiveTasks": {key: "max_active_tasks"},
+ "MaxConsecutiveFailedDagRuns": {key: "max_consecutive_failed_dag_runs"},
+ // serializeTaskLocked writes Queue as the queue of each Go task whose
TaskSpec sets no Queue.
+ "Queue": {},
+ "RenderTemplateAsNativeObj": {key: "render_template_as_native_obj",
schemaDefault: false},
+ // serializeTimetable writes Schedule as the timetable.
+ "Schedule": {},
+ "StartDate": {key: "start_date"},
+ "Tags": {key: "tags", set: true},
+}
+
+var taskSpecRules = map[string]fieldRule{
+ "TaskDisplayName": {key: "_task_display_name"},
+ "DependsOnPast": {key: "depends_on_past", schemaDefault: false},
+ "DoXComPush": {key: "do_xcom_push", schemaDefault: true},
+ "DocMD": {key: "doc_md"},
+ // Python writes email_on_failure and email_on_retry only for an
operator that has an email
+ // recipient, and a TaskSpec has no field for a recipient.
+ "EmailOnFailure": {key: "email_on_failure", schemaDefault:
true, skip: true},
+ "EmailOnRetry": {key: "email_on_retry", schemaDefault:
true, skip: true},
+ "EndDate": {key: "end_date"},
+ "ExecutionTimeout": {key: "execution_timeout"},
+ "Executor": {key: "executor"},
+ "IgnoreFirstDependsOnPast": {key: "ignore_first_depends_on_past",
schemaDefault: false},
+ "MapIndexTemplate": {key: "map_index_template"},
+ "MaxActiveTisPerDag": {key: "max_active_tis_per_dag"},
+ "MaxActiveTisPerDagrun": {key: "max_active_tis_per_dagrun"},
+ "MaxRetryDelay": {key: "max_retry_delay"},
+ "Owner": {key: "owner", schemaDefault: "airflow"},
+ "Pool": {key: "pool", schemaDefault:
"default_pool"},
+ "PoolSlots": {key: "pool_slots", schemaDefault: 1},
+ "PriorityWeight": {key: "priority_weight", schemaDefault: 1},
+ "Queue": {key: "queue", schemaDefault: "default"},
+ "Retries": {key: "retries", schemaDefault: 0},
+ "RetryDelay": {key: "retry_delay", schemaDefault: 300.0},
+ "RetryExponentialBackoff": {key: "retry_exponential_backoff",
schemaDefault: 0},
+ "StartDate": {key: "start_date"},
+ // serializeTaskLocked writes the task_id of the TaskRef, where the
group_ids of the groups that
+ // hold the task prefix it.
+ "TaskID": {key: "task_id", skip: true},
+ "TriggerRule": {key: "trigger_rule", schemaDefault:
"all_success"},
+ "WaitForDownstream": {key: "wait_for_downstream", schemaDefault: false},
+ "WaitForPastDependsBeforeSkipping": {
+ key: "wait_for_past_depends_before_skipping",
+ schemaDefault: false,
+ },
+ "WeightRule": {key: "weight_rule", schemaDefault: "downstream"},
+}
+
+// The schema gives no property of a task group a default. serializeTaskGroup
starts each group
+// from the defaults of Python's TaskGroup instead.
+var taskGroupSpecRules = map[string]fieldRule{
+ "DocMD": {key: "doc_md"},
+ "GroupDisplayName": {key: "group_display_name"},
+ "PrefixGroupID": {key: "prefix_group_id"},
+ "Tooltip": {key: "tooltip"},
+ "UIColor": {key: "ui_color"},
+ "UIFgColor": {key: "ui_fgcolor"},
+}
+
+// writeSpecFields writes into data each field of spec that has a key in
rules. It leaves out a
+// field that is unset or that holds its schema default. A field is unset when
it holds its zero
+// value. A pointer field is unset only when it is nil, so a pointer field can
set the zero value of
+// the type it points to, such as false or 0.
+func writeSpecFields(data map[string]any, spec any, rules
map[string]fieldRule) {
+ value := reflect.ValueOf(spec)
+ for i := range value.NumField() {
+ name := value.Type().Field(i).Name
+ field, ok := rules[name]
+ if !ok {
+ panic(fmt.Sprintf(
+ "airflow: the serializer has no rule for
%s.%s", value.Type().Name(), name,
+ ))
+ }
+ if field.key == "" || field.skip {
+ continue
+ }
+ encoded, set := encodeSpecValue(value.Field(i), field.set)
+ if !set || (field.schemaDefault != nil && isSameJSON(encoded,
field.schemaDefault)) {
+ continue
+ }
+ data[field.key] = encoded
+ }
+}
+
+// encodeSpecValue returns the value of a spec field in the form that Python's
serializer writes for
+// the property, or false when the field is unset. set says that Python keeps
the list in a set.
+func encodeSpecValue(value reflect.Value, set bool) (any, bool) {
+ if value.Kind() == reflect.Pointer {
+ if value.IsNil() {
+ return nil, false
+ }
+ value = value.Elem()
+ } else if value.IsZero() {
+ return nil, false
+ }
+ switch v := value.Interface().(type) {
+ case time.Time:
+ // The zero Time moved to a location with In is not the zero
value of time.Time, so value.IsZero
+ // above misses it.
+ if v.IsZero() {
+ return nil, false
+ }
+ return encodeTime(v), true
+ case time.Duration:
+ return encodeDuration(v), true
+ }
+ switch value.Kind() {
+ case reflect.String:
+ return value.String(), true
+ case reflect.Bool:
+ return value.Bool(), true
+ case reflect.Int:
+ return int(value.Int()), true
+ case reflect.Float64:
+ return value.Float(), true
+ case reflect.Slice:
+ if value.Type().Elem().Kind() == reflect.String {
+ items := make([]string, value.Len())
+ for i := range items {
+ items[i] = value.Index(i).String()
+ }
+ if set {
+ slices.Sort(items)
+ items = slices.Compact(items)
+ }
+ return items, true
+ }
+ }
+ panic(fmt.Sprintf("airflow: the serializer cannot write a spec field of
type %s", value.Type()))
+}
+
+// isSameJSON reports whether two encoded values are the same JSON value. Like
Python, it takes 1
+// and 1.0 for the same number.
+func isSameJSON(a, b any) bool {
+ if x, ok := jsonNumber(a); ok {
+ y, ok := jsonNumber(b)
+ return ok && x == y
+ }
+ return a == b
+}
+
+func jsonNumber(value any) (float64, bool) {
+ switch v := value.(type) {
+ case int:
+ return float64(v), true
+ case float64:
+ return v, true
+ }
+ return 0, false
+}
+
+// encodeTime returns t as Python's serializer writes a datetime: the seconds
since the Unix epoch,
+// to the microsecond. A Python datetime holds nothing finer than a
microsecond.
+func encodeTime(t time.Time) float64 { return float64(t.UnixMicro()) / 1e6 }
+
+// checkTime returns an error for a time that a Python datetime cannot hold,
which is a time whose
+// year in UTC is not from 1 to 9999. Airflow would reject a serialized Dag
with such a time when it
+// loads the Dag. field names the field that holds t. The zero Time passes,
because it means that
+// the field is unset.
+func checkTime(field string, t time.Time) error {
+ if year := t.UTC().Year(); !t.IsZero() && (year < 1 || year > 9999) {
+ return fmt.Errorf(
+ "%s is %s in UTC; Airflow takes a time only from year 1
to year 9999",
+ field, t.UTC().Format(time.RFC3339Nano),
+ )
+ }
+ return nil
+}
+
+// encodeDuration returns d as Python's serializer writes a timedelta: a
number of seconds, to the
+// microsecond. A Python timedelta holds nothing finer than a microsecond.
+func encodeDuration(d time.Duration) float64 { return
float64(d.Microseconds()) / 1e6 }
+
+// serialize returns the serialized Dag that Airflow stores for d. A
DagFileParsingResult carries it
+// as the data of one entry of serialized_dags. Python's
DagSerialization.to_dict returns the same
+// shape for a Dag authored in Python. fileloc is the path of the file that
declares the Dag, and
+// relativeFileloc is that path relative to the root of its Dag bundle.
+//
+// serialize panics unless d is registered. Registration expands each edge to
or from a task group
+// into edges between tasks, which the downstream_task_ids of a serialized Dag
hold.
+func (d *DagRef) serialize(fileloc, relativeFileloc string) map[string]any {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+
+ if !d.registered {
+ panic(fmt.Sprintf(
+ "airflow: Dag %q is not registered, so its group edges
are not expanded yet", d.dagID,
+ ))
+ }
+ dag := map[string]any{
+ "dag_id": d.dagID,
+ "fileloc": fileloc,
+ "relative_fileloc": relativeFileloc,
+ // A Go Dag has no timezone of its own. Airflow reads its cron
expression in UTC, the timezone
+ // that serializeTimetable writes.
+ "timezone": "UTC",
+ "timetable": serializeTimetable(d.spec.Schedule),
+ "tasks": d.serializeTasksLocked(),
+ "dag_dependencies": d.serializeDagDependenciesLocked(),
+ "task_group": d.serializeTaskGroupsLocked(),
+ "edge_info": d.serializeEdgeInfoLocked(),
+ // Python's serializer writes params, deadline and
allowed_run_types for every Dag. A Go Dag
+ // cannot set any of them yet.
+ "params": []any{},
+ "deadline": nil,
+ "allowed_run_types": nil,
+ }
+ // writeSpecFields leaves out each field that the Dag does not set.
That includes max_active_tasks,
+ // max_active_runs, max_consecutive_failed_dag_runs, catchup and
disable_bundle_versioning, which a
+ // Python Dag takes from the Airflow config when it does not set them.
A bundle cannot read the
+ // config, so Airflow fills those five in from its own config when it
receives the Dag.
+ writeSpecFields(dag, d.spec, dagSpecRules)
+ return map[string]any{"__version": dagSerializationVersion, "dag": dag}
+}
+
+// serializeTasksLocked writes the tasks of d in the order they were added, as
Python writes the
+// tasks of a Dag. The caller holds d.mu.
+func (d *DagRef) serializeTasksLocked() []any {
+ tasks := make([]any, len(d.tasks))
+ for i, task := range d.tasks {
+ tasks[i] = d.serializeTaskLocked(task)
+ }
+ return tasks
+}
+
+// serializeTaskLocked writes one task of d. The caller holds d.mu.
+func (d *DagRef) serializeTaskLocked(task *TaskRef) map[string]any {
+ data := map[string]any{"task_id": task.taskID}
+ spec := task.spec
+ if task.triggerDagRun != nil {
+ writeTriggerDagRun(data, *task.triggerDagRun)
+ } else {
+ data["task_type"] = goTaskType
+ data["_task_module"] = goTaskModule
+ data["language"] = goLanguage
+ // Python writes template_fields for every operator. A Go task
has no template fields, so the list
+ // is empty.
+ data["template_fields"] = []string{}
+ // is_stub makes the API server send the task its arguments
from _arg_bindings when the
+ // task runs, as it does for a @task.stub task of a Python Dag.
+ data["is_stub"] = true
+ if len(task.inputs) > 0 {
+ data["_arg_bindings"] =
serializeArgBindings(task.inputs)
+ }
+ if task.ifRef != nil {
+ // Python writes _can_skip_downstream for a branch
operator. Airflow reads the skipmixin_key XCom
+ // of the condition only when the flag is set, before
it runs a task that comes after the
+ // condition.
+ data["_can_skip_downstream"] = true
+ }
+ if spec.Queue == "" {
+ spec.Queue = d.spec.Queue
+ }
+ }
+ writeSpecFields(data, spec, taskSpecRules)
+ if len(task.downstreams) > 0 {
+ downstreams := taskIDs(task.downstreams)
+ slices.Sort(downstreams)
+ data["downstream_task_ids"] = downstreams
+ }
+ return map[string]any{"__type": "operator", "__var": data}
+}
+
+// serializeArgBindings returns the arg bindings for the tasks that Inputs
passed. When the task
+// runs, the API server sends it these bindings, one for each parameter after
the Context, in order.
+// Package reflect cannot read the names of the parameters of a Go function,
and a Go task takes its
+// arguments by position. So each binding is named after the position of its
parameter: arg0 for the
+// first parameter after the Context, arg1 for the next, and so on.
+func serializeArgBindings(inputs []*TaskRef) []any {
Review Comment:
Though this feature gap is not caused by this PR, but it's a highly related
feature gap for Go SDK side.
Let's also support the `Literal(value)` to support injected the JSON
serializable literal at the `Input` in this PR.
TS SDK already supported it, and the Java SDK will support it soon as well
in https://github.com/apache/airflow/pull/71190.
##########
go-sdk/airflow/serialize.go:
##########
@@ -0,0 +1,779 @@
+// 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 (
+ "cmp"
+ "fmt"
+ "reflect"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// A serialized Dag is the Dag JSON that Airflow stores for a Dag and that its
scheduler reads.
+// Airflow core owns the format.
airflow-core/src/airflow/serialization/schema.json describes it,
+// and Python's DagSerialization writes it for a Dag authored in Python. The
serializer in this file
+// does not write the same bytes as Python. Python picks the fields to leave
out with a
+// client_defaults table that a bundle never gets, so Python writes some
fields at their schema
+// default, such as retry_delay, that this serializer leaves out. Airflow
reads a missing field as
+// its schema default, so it loads the same Dag from either serialization.
+// scripts/ci/lang_sdk_serialization/compare.py checks that for the Dags in
test_dags.yaml in the
+// same directory.
+
+// dagSerializationVersion is the __version of a serialized Dag. It is the
SERIALIZER_VERSION that
+// Airflow core's DagSerialization checks when it loads a serialized Dag. The
supervisor schema has
+// versions named by date, which Airflow negotiates with each runtime, but the
Dag JSON that a
+// bundle returns has only this number.
+const dagSerializationVersion = 3
+
+// A task that runs a Go function gets this task_type and _task_module, where
a task of a Python Dag
+// names the class and the module of its operator. Airflow never imports
_task_module, so the two
+// can name the coordinator that runs the task instead, as the TypeScript SDK
does for its tasks.
+// Nothing in Airflow reads language. It tells a reader of the serialized Dag
which SDK wrote the
+// task.
+const (
+ goTaskType = "GoOperator"
+ goTaskModule = "airflow.sdk.coordinators.executable"
+ goLanguage = "go"
+)
+
+// Python's serializer writes these values for a TriggerDagRunOperator. The
operator class is in
+//
providers/standard/src/airflow/providers/standard/operators/trigger_dagrun.py.
+const (
+ triggerDagRunTaskType = "TriggerDagRunOperator"
+ triggerDagRunTaskModule =
"airflow.providers.standard.operators.trigger_dagrun"
+ triggerDagRunUIColor = "#ffefeb"
+)
+
+var triggerDagRunTemplateFields = []string{
+ "trigger_dag_id",
+ "trigger_run_id",
+ "logical_date",
+ "conf",
+ "wait_for_completion",
+ "skip_when_already_exists",
+}
+
+// The ui_color and ui_fgcolor that Python's TaskGroup uses when the Dag
author sets neither.
+const (
+ defaultGroupUIColor = "CornflowerBlue"
+ defaultGroupUIFgColor = "#000"
+)
+
+// A Schedule maps to one of these timetables. Python's DAG picks the same
ones for a schedule
+// string while [scheduler] create_cron_data_intervals is false, which is its
default. When the
+// option is true, Python picks CronDataIntervalTimetable for a cron
expression instead. A bundle
+// cannot read the option, so a Go Dag always gets CronTriggerTimetable
+// (https://github.com/apache/airflow/issues/67938).
+const (
+ nullTimetable = "airflow.timetables.simple.NullTimetable"
+ onceTimetable = "airflow.timetables.simple.OnceTimetable"
+ continuousTimetable = "airflow.timetables.simple.ContinuousTimetable"
+ cronTriggerTimetable = "airflow.timetables.trigger.CronTriggerTimetable"
+)
+
+// cronPresets maps each cron preset to the expression that Python records for
it. It holds the same
+// entries as cron_presets in airflow-core/src/airflow/utils/dates.py.
+var cronPresets = map[string]string{
+ "@hourly": "0 * * * *",
+ "@daily": "0 0 * * *",
+ "@weekly": "0 0 * * 0",
+ "@monthly": "0 0 1 * *",
+ "@quarterly": "0 0 1 */3 *",
+ "@yearly": "0 0 1 1 *",
+}
+
+// croniterAliases are the presets that croniter, which reads a cron
expression for Airflow, accepts
+// in upper or lower case, such as "@Daily". Python passes such a preset to
croniter unchanged
+// unless the preset is a key of cronPresets, and a Go Dag does the same.
+var croniterAliases = map[string]bool{
+ "@midnight": true,
+ "@hourly": true,
+ "@daily": true,
+ "@weekly": true,
+ "@monthly": true,
+ "@yearly": true,
+ "@annually": true,
+}
+
+// cronItem matches one comma-separated item of a field of a cron expression.
An item is one value,
+// or values joined by -, / or #. A value is one of these:
+// - a number, with or without an L or a W before or after it, such as 15W
or L5
+// - one of *, ?, L, W and R
+// - R with a range, such as R(0-30)
+// - three letters, as in a month or weekday name such as JAN or MON
+//
+// cronItem checks only the shape of an item. It takes some items that
croniter rejects, such as a
+// minute of 61 or a weekday of 5L. croniter rejects those only when Airflow
tries to schedule the
+// Dag.
+var cronItem = regexp.MustCompile(`^(?i:` + cronValue + `(?:[-/#]` + cronValue
+ `)*)$`)
+
+const cronValue =
`(?:[0-9]+[LW]?|[LW][0-9]+|[*?LWR]|R\([0-9]+-[0-9]+\)|[A-Z]{3})`
+
+// checkSchedule returns an error unless the Schedule is empty, a preset, or
shaped like a cron
+// expression of five to seven fields. Airflow does not check a cron
expression when it loads a
+// serialized Dag. Without this check, a Schedule such as "every day" would
reach Airflow, and
+// Airflow would never schedule the Dag, with only a line in its log to say
why.
+func checkSchedule(schedule string) error {
+ switch schedule {
+ case "", "@once", "@continuous":
+ return nil
+ }
+ if _, ok := cronPresets[schedule]; ok ||
croniterAliases[strings.ToLower(schedule)] {
+ return nil
+ }
+ fields := strings.Fields(schedule)
+ shaped := len(fields) >= 5 && len(fields) <= 7
+ for _, field := range fields {
+ for item := range strings.SplitSeq(field, ",") {
+ shaped = shaped && cronItem.MatchString(item)
+ }
+ }
+ if !shaped {
+ return fmt.Errorf(
+ "airflow.DagSpec.Schedule is %q, which is not a cron
expression or a preset; set a "+
+ "cron expression such as \"0 3 * * *\", a
preset such as \"@daily\", \"@once\" or "+
+ "\"@continuous\", or no Schedule for a Dag that
runs only when something triggers it",
+ schedule,
+ )
+ }
+ return nil
+}
+
+// serializeTimetable returns the timetable that Python's DAG builds from the
same schedule string.
+// Dag has already checked the Schedule with checkSchedule.
+func serializeTimetable(schedule string) map[string]any {
+ switch schedule {
+ case "":
+ return map[string]any{"__type": nullTimetable, "__var":
map[string]any{}}
+ case "@once":
+ return map[string]any{"__type": onceTimetable, "__var":
map[string]any{}}
+ case "@continuous":
+ return map[string]any{"__type": continuousTimetable, "__var":
map[string]any{}}
+ }
+ expression := schedule
+ if preset, ok := cronPresets[schedule]; ok {
+ expression = preset
+ }
+ return map[string]any{
+ "__type": cronTriggerTimetable,
+ "__var": map[string]any{
+ "expression": expression,
+ "timezone": "UTC",
+ "interval": 0.0,
+ "run_immediately": false,
+ },
+ }
+}
+
+// fieldRule says how the serializer writes one field of DagSpec, TaskSpec or
TaskGroupSpec. Each of
+// the three structs has a table with an entry for every field. When the
generator adds a field to a
+// struct, TestSpecRulesCoverEveryField fails until the table gets an entry
for it.
+type fieldRule struct {
+ // key is the property of the serialization schema that the field sets.
A field with no key, such
+ // as DagSpec.Schedule, has code of its own that writes it, which the
comment on its entry names.
+ key string
+ // schemaDefault is the default that the serialization schema gives the
property, or nil when the
+ // schema has no default for it. writeSpecFields leaves out a value
equal to the default, because
+ // Airflow reads a missing property as its schema default.
+ schemaDefault any
+ // set marks a list that Python keeps in a set, so Python writes the
list sorted and without
+ // repeats. writeSpecFields writes it the same way.
+ set bool
+ // skip marks a field that has a key but that writeSpecFields never
writes. The comment on its
+ // entry says why.
+ skip bool
+}
+
+var dagSpecRules = map[string]fieldRule{
Review Comment:
These ~20 static JSON-schema defaults are hand-typed as Go literals instead
of generated from `go-sdk/schema/dag-schema.json`, unlike ts-sdk's generated
`dag-schema-fields.ts`. `TestSpecRulesMatchTheSchema` catches drift before
merge, but a generated table can't drift by construction. Worth considering for
consistency with ts-sdk.
##########
go-sdk/airflow/serialize.go:
##########
@@ -0,0 +1,779 @@
+// 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 (
+ "cmp"
+ "fmt"
+ "reflect"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// A serialized Dag is the Dag JSON that Airflow stores for a Dag and that its
scheduler reads.
+// Airflow core owns the format.
airflow-core/src/airflow/serialization/schema.json describes it,
+// and Python's DagSerialization writes it for a Dag authored in Python. The
serializer in this file
+// does not write the same bytes as Python. Python picks the fields to leave
out with a
+// client_defaults table that a bundle never gets, so Python writes some
fields at their schema
+// default, such as retry_delay, that this serializer leaves out. Airflow
reads a missing field as
+// its schema default, so it loads the same Dag from either serialization.
+// scripts/ci/lang_sdk_serialization/compare.py checks that for the Dags in
test_dags.yaml in the
+// same directory.
+
+// dagSerializationVersion is the __version of a serialized Dag. It is the
SERIALIZER_VERSION that
+// Airflow core's DagSerialization checks when it loads a serialized Dag. The
supervisor schema has
+// versions named by date, which Airflow negotiates with each runtime, but the
Dag JSON that a
+// bundle returns has only this number.
+const dagSerializationVersion = 3
+
+// A task that runs a Go function gets this task_type and _task_module, where
a task of a Python Dag
+// names the class and the module of its operator. Airflow never imports
_task_module, so the two
+// can name the coordinator that runs the task instead, as the TypeScript SDK
does for its tasks.
+// Nothing in Airflow reads language. It tells a reader of the serialized Dag
which SDK wrote the
+// task.
+const (
+ goTaskType = "GoOperator"
+ goTaskModule = "airflow.sdk.coordinators.executable"
+ goLanguage = "go"
+)
+
+// Python's serializer writes these values for a TriggerDagRunOperator. The
operator class is in
+//
providers/standard/src/airflow/providers/standard/operators/trigger_dagrun.py.
+const (
+ triggerDagRunTaskType = "TriggerDagRunOperator"
+ triggerDagRunTaskModule =
"airflow.providers.standard.operators.trigger_dagrun"
+ triggerDagRunUIColor = "#ffefeb"
+)
+
+var triggerDagRunTemplateFields = []string{
+ "trigger_dag_id",
+ "trigger_run_id",
+ "logical_date",
+ "conf",
+ "wait_for_completion",
+ "skip_when_already_exists",
+}
+
+// The ui_color and ui_fgcolor that Python's TaskGroup uses when the Dag
author sets neither.
+const (
+ defaultGroupUIColor = "CornflowerBlue"
+ defaultGroupUIFgColor = "#000"
+)
+
+// A Schedule maps to one of these timetables. Python's DAG picks the same
ones for a schedule
+// string while [scheduler] create_cron_data_intervals is false, which is its
default. When the
+// option is true, Python picks CronDataIntervalTimetable for a cron
expression instead. A bundle
+// cannot read the option, so a Go Dag always gets CronTriggerTimetable
+// (https://github.com/apache/airflow/issues/67938).
+const (
+ nullTimetable = "airflow.timetables.simple.NullTimetable"
+ onceTimetable = "airflow.timetables.simple.OnceTimetable"
+ continuousTimetable = "airflow.timetables.simple.ContinuousTimetable"
+ cronTriggerTimetable = "airflow.timetables.trigger.CronTriggerTimetable"
+)
+
+// cronPresets maps each cron preset to the expression that Python records for
it. It holds the same
+// entries as cron_presets in airflow-core/src/airflow/utils/dates.py.
+var cronPresets = map[string]string{
+ "@hourly": "0 * * * *",
+ "@daily": "0 0 * * *",
+ "@weekly": "0 0 * * 0",
+ "@monthly": "0 0 1 * *",
+ "@quarterly": "0 0 1 */3 *",
+ "@yearly": "0 0 1 1 *",
+}
+
+// croniterAliases are the presets that croniter, which reads a cron
expression for Airflow, accepts
+// in upper or lower case, such as "@Daily". Python passes such a preset to
croniter unchanged
+// unless the preset is a key of cronPresets, and a Go Dag does the same.
+var croniterAliases = map[string]bool{
+ "@midnight": true,
+ "@hourly": true,
+ "@daily": true,
+ "@weekly": true,
+ "@monthly": true,
+ "@yearly": true,
+ "@annually": true,
+}
+
+// cronItem matches one comma-separated item of a field of a cron expression.
An item is one value,
+// or values joined by -, / or #. A value is one of these:
+// - a number, with or without an L or a W before or after it, such as 15W
or L5
+// - one of *, ?, L, W and R
+// - R with a range, such as R(0-30)
+// - three letters, as in a month or weekday name such as JAN or MON
+//
+// cronItem checks only the shape of an item. It takes some items that
croniter rejects, such as a
+// minute of 61 or a weekday of 5L. croniter rejects those only when Airflow
tries to schedule the
+// Dag.
+var cronItem = regexp.MustCompile(`^(?i:` + cronValue + `(?:[-/#]` + cronValue
+ `)*)$`)
+
+const cronValue =
`(?:[0-9]+[LW]?|[LW][0-9]+|[*?LWR]|R\([0-9]+-[0-9]+\)|[A-Z]{3})`
+
+// checkSchedule returns an error unless the Schedule is empty, a preset, or
shaped like a cron
+// expression of five to seven fields. Airflow does not check a cron
expression when it loads a
+// serialized Dag. Without this check, a Schedule such as "every day" would
reach Airflow, and
+// Airflow would never schedule the Dag, with only a line in its log to say
why.
+func checkSchedule(schedule string) error {
+ switch schedule {
+ case "", "@once", "@continuous":
+ return nil
+ }
+ if _, ok := cronPresets[schedule]; ok ||
croniterAliases[strings.ToLower(schedule)] {
+ return nil
+ }
+ fields := strings.Fields(schedule)
+ shaped := len(fields) >= 5 && len(fields) <= 7
+ for _, field := range fields {
+ for item := range strings.SplitSeq(field, ",") {
+ shaped = shaped && cronItem.MatchString(item)
+ }
+ }
+ if !shaped {
+ return fmt.Errorf(
+ "airflow.DagSpec.Schedule is %q, which is not a cron
expression or a preset; set a "+
+ "cron expression such as \"0 3 * * *\", a
preset such as \"@daily\", \"@once\" or "+
+ "\"@continuous\", or no Schedule for a Dag that
runs only when something triggers it",
+ schedule,
+ )
+ }
+ return nil
+}
+
+// serializeTimetable returns the timetable that Python's DAG builds from the
same schedule string.
+// Dag has already checked the Schedule with checkSchedule.
+func serializeTimetable(schedule string) map[string]any {
+ switch schedule {
+ case "":
+ return map[string]any{"__type": nullTimetable, "__var":
map[string]any{}}
+ case "@once":
+ return map[string]any{"__type": onceTimetable, "__var":
map[string]any{}}
+ case "@continuous":
+ return map[string]any{"__type": continuousTimetable, "__var":
map[string]any{}}
+ }
+ expression := schedule
+ if preset, ok := cronPresets[schedule]; ok {
+ expression = preset
+ }
+ return map[string]any{
+ "__type": cronTriggerTimetable,
+ "__var": map[string]any{
+ "expression": expression,
+ "timezone": "UTC",
+ "interval": 0.0,
+ "run_immediately": false,
+ },
+ }
+}
+
+// fieldRule says how the serializer writes one field of DagSpec, TaskSpec or
TaskGroupSpec. Each of
+// the three structs has a table with an entry for every field. When the
generator adds a field to a
+// struct, TestSpecRulesCoverEveryField fails until the table gets an entry
for it.
+type fieldRule struct {
+ // key is the property of the serialization schema that the field sets.
A field with no key, such
+ // as DagSpec.Schedule, has code of its own that writes it, which the
comment on its entry names.
+ key string
+ // schemaDefault is the default that the serialization schema gives the
property, or nil when the
+ // schema has no default for it. writeSpecFields leaves out a value
equal to the default, because
+ // Airflow reads a missing property as its schema default.
+ schemaDefault any
+ // set marks a list that Python keeps in a set, so Python writes the
list sorted and without
+ // repeats. writeSpecFields writes it the same way.
+ set bool
+ // skip marks a field that has a key but that writeSpecFields never
writes. The comment on its
+ // entry says why.
+ skip bool
+}
+
+var dagSpecRules = map[string]fieldRule{
+ "Catchup": {key: "catchup"},
+ "DagDisplayName": {key: "dag_display_name"},
+ "DagrunTimeout": {key: "dagrun_timeout"},
+ "Description": {key: "description"},
+ "DisableBundleVersioning": {key: "disable_bundle_versioning"},
+ "DocMD": {key: "doc_md"},
+ "EndDate": {key: "end_date"},
+ "FailFast": {key: "fail_fast", schemaDefault: false},
+ "IsPausedUponCreation": {key: "is_paused_upon_creation"},
+ "MaxActiveRuns": {key: "max_active_runs"},
+ "MaxActiveTasks": {key: "max_active_tasks"},
+ "MaxConsecutiveFailedDagRuns": {key: "max_consecutive_failed_dag_runs"},
+ // serializeTaskLocked writes Queue as the queue of each Go task whose
TaskSpec sets no Queue.
+ "Queue": {},
+ "RenderTemplateAsNativeObj": {key: "render_template_as_native_obj",
schemaDefault: false},
+ // serializeTimetable writes Schedule as the timetable.
+ "Schedule": {},
+ "StartDate": {key: "start_date"},
+ "Tags": {key: "tags", set: true},
+}
+
+var taskSpecRules = map[string]fieldRule{
+ "TaskDisplayName": {key: "_task_display_name"},
+ "DependsOnPast": {key: "depends_on_past", schemaDefault: false},
+ "DoXComPush": {key: "do_xcom_push", schemaDefault: true},
+ "DocMD": {key: "doc_md"},
+ // Python writes email_on_failure and email_on_retry only for an
operator that has an email
+ // recipient, and a TaskSpec has no field for a recipient.
+ "EmailOnFailure": {key: "email_on_failure", schemaDefault:
true, skip: true},
+ "EmailOnRetry": {key: "email_on_retry", schemaDefault:
true, skip: true},
+ "EndDate": {key: "end_date"},
+ "ExecutionTimeout": {key: "execution_timeout"},
+ "Executor": {key: "executor"},
+ "IgnoreFirstDependsOnPast": {key: "ignore_first_depends_on_past",
schemaDefault: false},
+ "MapIndexTemplate": {key: "map_index_template"},
+ "MaxActiveTisPerDag": {key: "max_active_tis_per_dag"},
+ "MaxActiveTisPerDagrun": {key: "max_active_tis_per_dagrun"},
+ "MaxRetryDelay": {key: "max_retry_delay"},
+ "Owner": {key: "owner", schemaDefault: "airflow"},
+ "Pool": {key: "pool", schemaDefault:
"default_pool"},
+ "PoolSlots": {key: "pool_slots", schemaDefault: 1},
+ "PriorityWeight": {key: "priority_weight", schemaDefault: 1},
+ "Queue": {key: "queue", schemaDefault: "default"},
+ "Retries": {key: "retries", schemaDefault: 0},
+ "RetryDelay": {key: "retry_delay", schemaDefault: 300.0},
+ "RetryExponentialBackoff": {key: "retry_exponential_backoff",
schemaDefault: 0},
+ "StartDate": {key: "start_date"},
+ // serializeTaskLocked writes the task_id of the TaskRef, where the
group_ids of the groups that
+ // hold the task prefix it.
+ "TaskID": {key: "task_id", skip: true},
+ "TriggerRule": {key: "trigger_rule", schemaDefault:
"all_success"},
+ "WaitForDownstream": {key: "wait_for_downstream", schemaDefault: false},
+ "WaitForPastDependsBeforeSkipping": {
+ key: "wait_for_past_depends_before_skipping",
+ schemaDefault: false,
+ },
+ "WeightRule": {key: "weight_rule", schemaDefault: "downstream"},
+}
+
+// The schema gives no property of a task group a default. serializeTaskGroup
starts each group
+// from the defaults of Python's TaskGroup instead.
+var taskGroupSpecRules = map[string]fieldRule{
+ "DocMD": {key: "doc_md"},
+ "GroupDisplayName": {key: "group_display_name"},
+ "PrefixGroupID": {key: "prefix_group_id"},
+ "Tooltip": {key: "tooltip"},
+ "UIColor": {key: "ui_color"},
+ "UIFgColor": {key: "ui_fgcolor"},
+}
+
+// writeSpecFields writes into data each field of spec that has a key in
rules. It leaves out a
+// field that is unset or that holds its schema default. A field is unset when
it holds its zero
+// value. A pointer field is unset only when it is nil, so a pointer field can
set the zero value of
+// the type it points to, such as false or 0.
+func writeSpecFields(data map[string]any, spec any, rules
map[string]fieldRule) {
+ value := reflect.ValueOf(spec)
+ for i := range value.NumField() {
+ name := value.Type().Field(i).Name
+ field, ok := rules[name]
+ if !ok {
+ panic(fmt.Sprintf(
+ "airflow: the serializer has no rule for
%s.%s", value.Type().Name(), name,
+ ))
+ }
+ if field.key == "" || field.skip {
+ continue
+ }
+ encoded, set := encodeSpecValue(value.Field(i), field.set)
+ if !set || (field.schemaDefault != nil && isSameJSON(encoded,
field.schemaDefault)) {
+ continue
+ }
+ data[field.key] = encoded
+ }
+}
+
+// encodeSpecValue returns the value of a spec field in the form that Python's
serializer writes for
+// the property, or false when the field is unset. set says that Python keeps
the list in a set.
+func encodeSpecValue(value reflect.Value, set bool) (any, bool) {
+ if value.Kind() == reflect.Pointer {
+ if value.IsNil() {
+ return nil, false
+ }
+ value = value.Elem()
+ } else if value.IsZero() {
+ return nil, false
+ }
+ switch v := value.Interface().(type) {
+ case time.Time:
+ // The zero Time moved to a location with In is not the zero
value of time.Time, so value.IsZero
+ // above misses it.
+ if v.IsZero() {
+ return nil, false
+ }
+ return encodeTime(v), true
+ case time.Duration:
+ return encodeDuration(v), true
+ }
+ switch value.Kind() {
+ case reflect.String:
+ return value.String(), true
+ case reflect.Bool:
+ return value.Bool(), true
+ case reflect.Int:
+ return int(value.Int()), true
+ case reflect.Float64:
+ return value.Float(), true
+ case reflect.Slice:
+ if value.Type().Elem().Kind() == reflect.String {
+ items := make([]string, value.Len())
+ for i := range items {
+ items[i] = value.Index(i).String()
+ }
+ if set {
+ slices.Sort(items)
+ items = slices.Compact(items)
+ }
+ return items, true
+ }
+ }
+ panic(fmt.Sprintf("airflow: the serializer cannot write a spec field of
type %s", value.Type()))
+}
+
+// isSameJSON reports whether two encoded values are the same JSON value. Like
Python, it takes 1
+// and 1.0 for the same number.
+func isSameJSON(a, b any) bool {
+ if x, ok := jsonNumber(a); ok {
+ y, ok := jsonNumber(b)
+ return ok && x == y
+ }
+ return a == b
+}
+
+func jsonNumber(value any) (float64, bool) {
+ switch v := value.(type) {
+ case int:
+ return float64(v), true
+ case float64:
+ return v, true
+ }
+ return 0, false
+}
+
+// encodeTime returns t as Python's serializer writes a datetime: the seconds
since the Unix epoch,
+// to the microsecond. A Python datetime holds nothing finer than a
microsecond.
+func encodeTime(t time.Time) float64 { return float64(t.UnixMicro()) / 1e6 }
+
+// checkTime returns an error for a time that a Python datetime cannot hold,
which is a time whose
+// year in UTC is not from 1 to 9999. Airflow would reject a serialized Dag
with such a time when it
+// loads the Dag. field names the field that holds t. The zero Time passes,
because it means that
+// the field is unset.
+func checkTime(field string, t time.Time) error {
+ if year := t.UTC().Year(); !t.IsZero() && (year < 1 || year > 9999) {
+ return fmt.Errorf(
+ "%s is %s in UTC; Airflow takes a time only from year 1
to year 9999",
+ field, t.UTC().Format(time.RFC3339Nano),
+ )
+ }
+ return nil
+}
+
+// encodeDuration returns d as Python's serializer writes a timedelta: a
number of seconds, to the
+// microsecond. A Python timedelta holds nothing finer than a microsecond.
+func encodeDuration(d time.Duration) float64 { return
float64(d.Microseconds()) / 1e6 }
+
+// serialize returns the serialized Dag that Airflow stores for d. A
DagFileParsingResult carries it
+// as the data of one entry of serialized_dags. Python's
DagSerialization.to_dict returns the same
+// shape for a Dag authored in Python. fileloc is the path of the file that
declares the Dag, and
+// relativeFileloc is that path relative to the root of its Dag bundle.
+//
+// serialize panics unless d is registered. Registration expands each edge to
or from a task group
+// into edges between tasks, which the downstream_task_ids of a serialized Dag
hold.
+func (d *DagRef) serialize(fileloc, relativeFileloc string) map[string]any {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+
+ if !d.registered {
+ panic(fmt.Sprintf(
+ "airflow: Dag %q is not registered, so its group edges
are not expanded yet", d.dagID,
+ ))
+ }
+ dag := map[string]any{
+ "dag_id": d.dagID,
+ "fileloc": fileloc,
+ "relative_fileloc": relativeFileloc,
+ // A Go Dag has no timezone of its own. Airflow reads its cron
expression in UTC, the timezone
+ // that serializeTimetable writes.
+ "timezone": "UTC",
+ "timetable": serializeTimetable(d.spec.Schedule),
+ "tasks": d.serializeTasksLocked(),
+ "dag_dependencies": d.serializeDagDependenciesLocked(),
+ "task_group": d.serializeTaskGroupsLocked(),
+ "edge_info": d.serializeEdgeInfoLocked(),
+ // Python's serializer writes params, deadline and
allowed_run_types for every Dag. A Go Dag
+ // cannot set any of them yet.
+ "params": []any{},
+ "deadline": nil,
+ "allowed_run_types": nil,
+ }
+ // writeSpecFields leaves out each field that the Dag does not set.
That includes max_active_tasks,
+ // max_active_runs, max_consecutive_failed_dag_runs, catchup and
disable_bundle_versioning, which a
+ // Python Dag takes from the Airflow config when it does not set them.
A bundle cannot read the
+ // config, so Airflow fills those five in from its own config when it
receives the Dag.
+ writeSpecFields(dag, d.spec, dagSpecRules)
+ return map[string]any{"__version": dagSerializationVersion, "dag": dag}
+}
+
+// serializeTasksLocked writes the tasks of d in the order they were added, as
Python writes the
+// tasks of a Dag. The caller holds d.mu.
+func (d *DagRef) serializeTasksLocked() []any {
+ tasks := make([]any, len(d.tasks))
+ for i, task := range d.tasks {
+ tasks[i] = d.serializeTaskLocked(task)
+ }
+ return tasks
+}
+
+// serializeTaskLocked writes one task of d. The caller holds d.mu.
+func (d *DagRef) serializeTaskLocked(task *TaskRef) map[string]any {
+ data := map[string]any{"task_id": task.taskID}
+ spec := task.spec
+ if task.triggerDagRun != nil {
+ writeTriggerDagRun(data, *task.triggerDagRun)
+ } else {
+ data["task_type"] = goTaskType
+ data["_task_module"] = goTaskModule
+ data["language"] = goLanguage
+ // Python writes template_fields for every operator. A Go task
has no template fields, so the list
+ // is empty.
+ data["template_fields"] = []string{}
+ // is_stub makes the API server send the task its arguments
from _arg_bindings when the
+ // task runs, as it does for a @task.stub task of a Python Dag.
+ data["is_stub"] = true
+ if len(task.inputs) > 0 {
+ data["_arg_bindings"] =
serializeArgBindings(task.inputs)
+ }
+ if task.ifRef != nil {
Review Comment:
Heads up for the merge with #74321: that PR renames this field from `ifRef`
(`*IfRef`) to `decider` (an interface). This check needs to become
`task.decider != nil`, and `_can_skip_downstream` needs the same type-switch
handling `markRegistered` uses for `IfRef`/`SwitchRef`. Otherwise this won't
compile, or worse, gets "fixed" by only checking `*IfRef` and Switch tasks
silently lose `_can_skip_downstream`.
##########
go-sdk/airflow/trigger_dag_run.go:
##########
@@ -161,5 +172,63 @@ func copyConf(conf map[string]any) (map[string]any, error)
{
if err := dec.Decode(&copied); err != nil {
return nil, err
}
+ if _, err := resolveNumbers(copied, ""); err != nil {
+ return nil, err
+ }
return copied, nil
}
+
+// resolveNumbers replaces each json.Number in value with an int64, a uint64
or a float64, and
+// returns value. It changes a map or a slice in value in place. path names
value in an error, as
+// in ["rows"][2].
+func resolveNumbers(value any, path string) (any, error) {
+ switch v := value.(type) {
+ case json.Number:
+ return resolveNumber(v, path)
+ case map[string]any:
+ // The keys are sorted, so that a conf with more than one bad
number always gets the same
+ // error.
+ for _, key := range slices.Sorted(maps.Keys(v)) {
+ resolved, err := resolveNumbers(v[key],
path+"["+strconv.Quote(key)+"]")
+ if err != nil {
+ return nil, err
+ }
+ v[key] = resolved
+ }
+ case []any:
+ for i, item := range v {
+ resolved, err := resolveNumbers(item,
fmt.Sprintf("%s[%d]", path, i))
+ if err != nil {
+ return nil, err
+ }
+ v[i] = resolved
+ }
+ }
+ return value, nil
+}
+
+func resolveNumber(n json.Number, path string) (any, error) {
+ if i, err := n.Int64(); err == nil {
+ return i, nil
+ }
+ if u, err := strconv.ParseUint(n.String(), 10, 64); err == nil {
+ return u, nil
+ }
+ f, err := n.Float64()
+ // encoding/json writes a float64 such as 1e20 with neither a point nor
an exponent, so a number
+ // written like an integer can come from a float64. Such a number is
read as a float64 only when
+ // the float64 formats back to the same digits. Otherwise the number is
an integer that does not
+ // fit in 64 bits.
+ if !strings.ContainsAny(n.String(), ".eE") &&
Review Comment:
The round-trip check doesn't catch all 64-bit overflow:
`strconv.FormatFloat(1e20, 'f', -1, 64)` happens to equal
`"100000000000000000000"`, so `Conf` holding a number like 10^20 is silently
accepted as `float64` instead of erroring "does not fit in 64 bits" as the doc
promises. Checking magnitude directly (e.g. via `math/big`) instead of relying
on the round-trip text match would catch this.
##########
go-sdk/airflow/serialize.go:
##########
@@ -0,0 +1,779 @@
+// 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 (
+ "cmp"
+ "fmt"
+ "reflect"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// A serialized Dag is the Dag JSON that Airflow stores for a Dag and that its
scheduler reads.
+// Airflow core owns the format.
airflow-core/src/airflow/serialization/schema.json describes it,
+// and Python's DagSerialization writes it for a Dag authored in Python. The
serializer in this file
+// does not write the same bytes as Python. Python picks the fields to leave
out with a
+// client_defaults table that a bundle never gets, so Python writes some
fields at their schema
+// default, such as retry_delay, that this serializer leaves out. Airflow
reads a missing field as
+// its schema default, so it loads the same Dag from either serialization.
+// scripts/ci/lang_sdk_serialization/compare.py checks that for the Dags in
test_dags.yaml in the
+// same directory.
+
+// dagSerializationVersion is the __version of a serialized Dag. It is the
SERIALIZER_VERSION that
+// Airflow core's DagSerialization checks when it loads a serialized Dag. The
supervisor schema has
+// versions named by date, which Airflow negotiates with each runtime, but the
Dag JSON that a
+// bundle returns has only this number.
+const dagSerializationVersion = 3
+
+// A task that runs a Go function gets this task_type and _task_module, where
a task of a Python Dag
+// names the class and the module of its operator. Airflow never imports
_task_module, so the two
+// can name the coordinator that runs the task instead, as the TypeScript SDK
does for its tasks.
+// Nothing in Airflow reads language. It tells a reader of the serialized Dag
which SDK wrote the
+// task.
+const (
+ goTaskType = "GoOperator"
+ goTaskModule = "airflow.sdk.coordinators.executable"
+ goLanguage = "go"
+)
+
+// Python's serializer writes these values for a TriggerDagRunOperator. The
operator class is in
+//
providers/standard/src/airflow/providers/standard/operators/trigger_dagrun.py.
+const (
+ triggerDagRunTaskType = "TriggerDagRunOperator"
+ triggerDagRunTaskModule =
"airflow.providers.standard.operators.trigger_dagrun"
+ triggerDagRunUIColor = "#ffefeb"
+)
+
+var triggerDagRunTemplateFields = []string{
+ "trigger_dag_id",
+ "trigger_run_id",
+ "logical_date",
+ "conf",
+ "wait_for_completion",
+ "skip_when_already_exists",
+}
+
+// The ui_color and ui_fgcolor that Python's TaskGroup uses when the Dag
author sets neither.
+const (
+ defaultGroupUIColor = "CornflowerBlue"
+ defaultGroupUIFgColor = "#000"
+)
+
+// A Schedule maps to one of these timetables. Python's DAG picks the same
ones for a schedule
+// string while [scheduler] create_cron_data_intervals is false, which is its
default. When the
+// option is true, Python picks CronDataIntervalTimetable for a cron
expression instead. A bundle
+// cannot read the option, so a Go Dag always gets CronTriggerTimetable
+// (https://github.com/apache/airflow/issues/67938).
+const (
+ nullTimetable = "airflow.timetables.simple.NullTimetable"
+ onceTimetable = "airflow.timetables.simple.OnceTimetable"
+ continuousTimetable = "airflow.timetables.simple.ContinuousTimetable"
+ cronTriggerTimetable = "airflow.timetables.trigger.CronTriggerTimetable"
+)
+
+// cronPresets maps each cron preset to the expression that Python records for
it. It holds the same
+// entries as cron_presets in airflow-core/src/airflow/utils/dates.py.
+var cronPresets = map[string]string{
+ "@hourly": "0 * * * *",
+ "@daily": "0 0 * * *",
+ "@weekly": "0 0 * * 0",
+ "@monthly": "0 0 1 * *",
+ "@quarterly": "0 0 1 */3 *",
+ "@yearly": "0 0 1 1 *",
+}
+
+// croniterAliases are the presets that croniter, which reads a cron
expression for Airflow, accepts
+// in upper or lower case, such as "@Daily". Python passes such a preset to
croniter unchanged
+// unless the preset is a key of cronPresets, and a Go Dag does the same.
+var croniterAliases = map[string]bool{
+ "@midnight": true,
+ "@hourly": true,
+ "@daily": true,
+ "@weekly": true,
+ "@monthly": true,
+ "@yearly": true,
+ "@annually": true,
+}
+
+// cronItem matches one comma-separated item of a field of a cron expression.
An item is one value,
+// or values joined by -, / or #. A value is one of these:
+// - a number, with or without an L or a W before or after it, such as 15W
or L5
+// - one of *, ?, L, W and R
+// - R with a range, such as R(0-30)
+// - three letters, as in a month or weekday name such as JAN or MON
+//
+// cronItem checks only the shape of an item. It takes some items that
croniter rejects, such as a
+// minute of 61 or a weekday of 5L. croniter rejects those only when Airflow
tries to schedule the
+// Dag.
+var cronItem = regexp.MustCompile(`^(?i:` + cronValue + `(?:[-/#]` + cronValue
+ `)*)$`)
+
+const cronValue =
`(?:[0-9]+[LW]?|[LW][0-9]+|[*?LWR]|R\([0-9]+-[0-9]+\)|[A-Z]{3})`
+
+// checkSchedule returns an error unless the Schedule is empty, a preset, or
shaped like a cron
+// expression of five to seven fields. Airflow does not check a cron
expression when it loads a
+// serialized Dag. Without this check, a Schedule such as "every day" would
reach Airflow, and
+// Airflow would never schedule the Dag, with only a line in its log to say
why.
+func checkSchedule(schedule string) error {
+ switch schedule {
+ case "", "@once", "@continuous":
+ return nil
+ }
+ if _, ok := cronPresets[schedule]; ok ||
croniterAliases[strings.ToLower(schedule)] {
+ return nil
+ }
+ fields := strings.Fields(schedule)
+ shaped := len(fields) >= 5 && len(fields) <= 7
+ for _, field := range fields {
+ for item := range strings.SplitSeq(field, ",") {
+ shaped = shaped && cronItem.MatchString(item)
+ }
+ }
+ if !shaped {
+ return fmt.Errorf(
+ "airflow.DagSpec.Schedule is %q, which is not a cron
expression or a preset; set a "+
+ "cron expression such as \"0 3 * * *\", a
preset such as \"@daily\", \"@once\" or "+
+ "\"@continuous\", or no Schedule for a Dag that
runs only when something triggers it",
+ schedule,
+ )
+ }
+ return nil
+}
+
+// serializeTimetable returns the timetable that Python's DAG builds from the
same schedule string.
+// Dag has already checked the Schedule with checkSchedule.
+func serializeTimetable(schedule string) map[string]any {
+ switch schedule {
+ case "":
+ return map[string]any{"__type": nullTimetable, "__var":
map[string]any{}}
+ case "@once":
+ return map[string]any{"__type": onceTimetable, "__var":
map[string]any{}}
+ case "@continuous":
+ return map[string]any{"__type": continuousTimetable, "__var":
map[string]any{}}
+ }
+ expression := schedule
+ if preset, ok := cronPresets[schedule]; ok {
+ expression = preset
+ }
+ return map[string]any{
+ "__type": cronTriggerTimetable,
+ "__var": map[string]any{
+ "expression": expression,
+ "timezone": "UTC",
+ "interval": 0.0,
+ "run_immediately": false,
+ },
+ }
+}
+
+// fieldRule says how the serializer writes one field of DagSpec, TaskSpec or
TaskGroupSpec. Each of
+// the three structs has a table with an entry for every field. When the
generator adds a field to a
+// struct, TestSpecRulesCoverEveryField fails until the table gets an entry
for it.
+type fieldRule struct {
+ // key is the property of the serialization schema that the field sets.
A field with no key, such
+ // as DagSpec.Schedule, has code of its own that writes it, which the
comment on its entry names.
+ key string
+ // schemaDefault is the default that the serialization schema gives the
property, or nil when the
+ // schema has no default for it. writeSpecFields leaves out a value
equal to the default, because
+ // Airflow reads a missing property as its schema default.
+ schemaDefault any
+ // set marks a list that Python keeps in a set, so Python writes the
list sorted and without
+ // repeats. writeSpecFields writes it the same way.
+ set bool
+ // skip marks a field that has a key but that writeSpecFields never
writes. The comment on its
+ // entry says why.
+ skip bool
+}
+
+var dagSpecRules = map[string]fieldRule{
+ "Catchup": {key: "catchup"},
+ "DagDisplayName": {key: "dag_display_name"},
+ "DagrunTimeout": {key: "dagrun_timeout"},
+ "Description": {key: "description"},
+ "DisableBundleVersioning": {key: "disable_bundle_versioning"},
+ "DocMD": {key: "doc_md"},
+ "EndDate": {key: "end_date"},
+ "FailFast": {key: "fail_fast", schemaDefault: false},
+ "IsPausedUponCreation": {key: "is_paused_upon_creation"},
+ "MaxActiveRuns": {key: "max_active_runs"},
+ "MaxActiveTasks": {key: "max_active_tasks"},
+ "MaxConsecutiveFailedDagRuns": {key: "max_consecutive_failed_dag_runs"},
+ // serializeTaskLocked writes Queue as the queue of each Go task whose
TaskSpec sets no Queue.
+ "Queue": {},
+ "RenderTemplateAsNativeObj": {key: "render_template_as_native_obj",
schemaDefault: false},
+ // serializeTimetable writes Schedule as the timetable.
+ "Schedule": {},
+ "StartDate": {key: "start_date"},
+ "Tags": {key: "tags", set: true},
+}
+
+var taskSpecRules = map[string]fieldRule{
+ "TaskDisplayName": {key: "_task_display_name"},
+ "DependsOnPast": {key: "depends_on_past", schemaDefault: false},
+ "DoXComPush": {key: "do_xcom_push", schemaDefault: true},
+ "DocMD": {key: "doc_md"},
+ // Python writes email_on_failure and email_on_retry only for an
operator that has an email
+ // recipient, and a TaskSpec has no field for a recipient.
+ "EmailOnFailure": {key: "email_on_failure", schemaDefault:
true, skip: true},
Review Comment:
`EmailOnFailure`/`EmailOnRetry` are public, settable `*bool` fields that
`writeSpecFields` discards via `skip: true`, with nothing in the field's doc
comment warning that setting them has no effect. Worth a doc note, or wiring
them through. (`schemaDefault: true` on both is also dead code, `skip`
short-circuits before it's read.)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]