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 30a3a6d7434 Go SDK: run TriggerDagRun tasks in the Go runtime (#74417)
30a3a6d7434 is described below
commit 30a3a6d74348f8550027e284382c3ccd1b5274c4
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Fri Oct 9 16:34:12 2026 +0800
Go SDK: run TriggerDagRun tasks in the Go runtime (#74417)
* Go SDK: add the supervisor calls a TriggerDagRun task makes
A TriggerDagRun task triggers a Dag run, reads its state and checks whether
the Dag is paused. The runtime has no client for these three requests yet. An
existing run_id comes back as a result instead of an error, because the task
decides whether that skips or fails it.
* Go SDK: take the logical date of TriggerDagRun as a time.Time
The Go runtime runs a TriggerDagRun task itself and renders no templates,
so a string cannot hold a date it must compute. A time.Time lets the runtime
send the date as it is, and the zero value keeps the Python default of now. The
conformance case and the Go fixture move to a literal datetime.
* Go SDK: run TriggerDagRun tasks in the Go runtime
A task from airflow.TriggerDagRun had no Task in the bundle, so a Go Dag
needed a Python worker to run it. The bundle now returns a TriggerTask, and
RunTask runs it the way TriggerDagRunOperator does: the same run_id and date
rules, the link and trigger_run_id XComs, polling, and a deferral to
DagStateTrigger that resumes in the same runtime. It reads its config from the
environment and renders no templates.
* Go SDK: give a TriggerDagRun task the Dag's queue
The Go runtime runs a TriggerDagRun task now, so it belongs on the
coordinator that the Dag queue maps to. Before, the task kept no queue and went
to a Python worker. A queue in the TaskSpec still wins, as for any other task,
and a Dag with no queue leaves the task without one.
* Go SDK: document how the runtime runs a TriggerDagRun task
Say how the task names its run, what Deferrable needs, and that the
arguments are not templated and OpenLineage parent info is not added.
* Go SDK: reject a templated DagID or RunID of TriggerDagRun
Mark the skipped and deferred states supported, since a TriggerDagRun
task now emits both.
---------
---
.../lang_sdk_fixtures/go_native.json | 4 +-
go-sdk/README.md | 4 +-
go-sdk/airflow/bundle.go | 13 +-
go-sdk/airflow/bundle_test.go | 59 +-
go-sdk/airflow/conformance_test.go | 7 +
go-sdk/airflow/serialize.go | 36 +-
go-sdk/airflow/serialize_test.go | 56 +-
go-sdk/airflow/spec.gen.go | 3 +-
go-sdk/airflow/trigger_dag_run.go | 82 ++-
go-sdk/airflow/trigger_dag_run_test.go | 26 +-
go-sdk/capabilities.yaml | 15 +-
go-sdk/internal/bundle/trigger.go | 64 ++
go-sdk/internal/genspec/authoring.go | 2 +-
go-sdk/pkg/execution/client.go | 46 ++
go-sdk/pkg/execution/client_test.go | 136 +++++
go-sdk/pkg/execution/task_runner.go | 38 +-
go-sdk/pkg/execution/trigger_runner.go | 418 +++++++++++++
go-sdk/pkg/execution/trigger_runner_test.go | 648 +++++++++++++++++++++
scripts/ci/lang_sdk_serialization/test_dags.yaml | 2 +-
19 files changed, 1595 insertions(+), 64 deletions(-)
diff --git
a/airflow-core/tests/unit/dag_processing/lang_sdk_fixtures/go_native.json
b/airflow-core/tests/unit/dag_processing/lang_sdk_fixtures/go_native.json
index 17df5644bf1..031f73bebd1 100644
--- a/airflow-core/tests/unit/dag_processing/lang_sdk_fixtures/go_native.json
+++ b/airflow-core/tests/unit/dag_processing/lang_sdk_fixtures/go_native.json
@@ -1621,7 +1621,7 @@
"ratio": 0.5,
"rows": 2
},
- "logical_date": "{{ ds }}",
+ "logical_date": "2026-09-30 00:00:00+00:00",
"skip_when_already_exists": true,
"task_id": "trigger_all",
"task_type": "TriggerDagRunOperator",
@@ -1637,7 +1637,7 @@
"conf": "py"
},
"trigger_dag_id": "conformance_minimal",
- "trigger_run_id": "triggered_{{ run_id }}",
+ "trigger_run_id": "triggered_by_conformance",
"ui_color": "#ffefeb",
"wait_for_completion": true
}
diff --git a/go-sdk/README.md b/go-sdk/README.md
index a865e52f24b..5ca2568faaa 100644
--- a/go-sdk/README.md
+++ b/go-sdk/README.md
@@ -337,8 +337,8 @@ prek hook regenerate it.
| state: `success` | MUST | ✓ | 3.3 | |
| state: `failed` | MUST | ✓ | 3.3 | |
| state: `up_for_retry` | MUST | ✓ | 3.3 | RetryTask |
-| state: `skipped` | SHOULD | ✗ | – | runtime does not emit TaskState skipped
yet |
-| state: `deferred` | MAY | ✗ | – | runtime does not emit DeferTask yet |
+| state: `skipped` | SHOULD | ✓ | 3.4 | airflow.TriggerDagRun with
SkipWhenAlreadyExists only |
+| state: `deferred` | MAY | ✓ | 3.4 | airflow.TriggerDagRun with Deferrable
only; resumes in the Go runtime |
| state: `up_for_reschedule` | MAY | ✗ | – | runtime does not emit
RescheduleTask yet |
| state: `awaiting_input` | MAY | ✗ | – | runtime does not emit AwaitInputTask
yet |
| state: `removed` | MAY | ✓ | 3.3 | |
diff --git a/go-sdk/airflow/bundle.go b/go-sdk/airflow/bundle.go
index 61bcd77aa8f..8349b5d4a90 100644
--- a/go-sdk/airflow/bundle.go
+++ b/go-sdk/airflow/bundle.go
@@ -229,8 +229,9 @@ func (m *dagMap) ListDagSourceFiles() map[string]string {
return files
}
-// lookupTask returns the Go function of a task of a registered Dag. A task
from TriggerDagRun has
-// none, because a Python worker runs it.
+// lookupTask returns the task of a registered Dag. It is the Go function of a
task from DagRef.Task,
+// or a bundle.TriggerTask with the options of a task from TriggerDagRun,
which the runtime runs
+// itself.
func (m *dagMap) lookupTask(dagID, taskID string) (bundle.Task, bool) {
m.mu.Lock()
dag, ok := m.dags[dagID]
@@ -242,7 +243,13 @@ func (m *dagMap) lookupTask(dagID, taskID string)
(bundle.Task, bool) {
dag.mu.Lock()
defer dag.mu.Unlock()
ref, ok := dag.tasksByID[taskID]
- if !ok || ref.task == nil {
+ if !ok {
+ return nil, false
+ }
+ if ref.triggerDagRun != nil {
+ return &bundle.TriggerTask{Spec:
triggerSpec(*ref.triggerDagRun)}, true
+ }
+ if ref.task == nil {
return nil, false
}
return ref.task, true
diff --git a/go-sdk/airflow/bundle_test.go b/go-sdk/airflow/bundle_test.go
index 1ddc3b2b109..95b24e12b1c 100644
--- a/go-sdk/airflow/bundle_test.go
+++ b/go-sdk/airflow/bundle_test.go
@@ -25,6 +25,7 @@ import (
"reflect"
"sync"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -471,7 +472,6 @@ func TestServeLooksUpTheTasksOfDagsAndTaskHandlers(t
*testing.T) {
assert.NotNil(t, task)
}
for _, id := range [][2]string{
- {"native_etl", "trigger"},
{"native_etl", "load"},
{"py_etl", "extract"},
{"unknown", "extract"},
@@ -480,3 +480,60 @@ func TestServeLooksUpTheTasksOfDagsAndTaskHandlers(t
*testing.T) {
assert.False(t, ok, "%s.%s", id[0], id[1])
}
}
+
+func TestServeLooksUpATriggerDagRunTaskWithACopyOfItsSpec(t *testing.T) {
+ poke := 30 * time.Second
+ logicalDate := time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC)
+ dag := Dag("native_etl")
+ dag.Task(TriggerDagRun(TriggerDagRunSpec{
+ DagID: "downstream_etl",
+ RunID: "etl_downstream",
+ Note: "from etl",
+ Conf: map[string]any{"tables": []any{"rows"}},
+ LogicalDate: logicalDate,
+ ResetDagRun: true,
+ WaitForCompletion: true,
+ SkipWhenAlreadyExists: true,
+ FailWhenDagIsPaused: true,
+ PokeInterval: &poke,
+ AllowedStates: []DagRunState{DagRunStateSuccess,
DagRunStateQueued},
+ FailedStates: []DagRunState{},
+ Deferrable: ptr(true),
+ }), TaskSpec{TaskID: "trigger"})
+ dag.Task(TriggerDagRun(TriggerDagRunSpec{DagID: "bare"}),
TaskSpec{TaskID: "bare"})
+ b := Bundle()
+ b.Register(dag)
+ source := coordinatorSource{&b.taskHandlers, &b.dags}
+
+ task, ok := source.LookupTask("native_etl", "trigger")
+ require.True(t, ok)
+ trigger, ok := task.(*bundle.TriggerTask)
+ require.True(t, ok, "got %T", task)
+ assert.Equal(t, bundle.TriggerSpec{
+ DagID: "downstream_etl",
+ RunID: "etl_downstream",
+ Note: "from etl",
+ Conf: map[string]any{"tables": []any{"rows"}},
+ LogicalDate: logicalDate,
+ ResetDagRun: true,
+ WaitForCompletion: true,
+ SkipWhenAlreadyExists: true,
+ FailWhenDagIsPaused: true,
+ PokeInterval: &poke,
+ AllowedStates: []string{"success", "queued"},
+ FailedStates: []string{},
+ Deferrable: ptr(true),
+ }, trigger.Spec)
+
+ // The runtime gets a copy, so that nothing it does changes the task.
+ trigger.Spec.Conf["tables"].([]any)[0] = "changed"
+ again, _ := source.LookupTask("native_etl", "trigger")
+ assert.Equal(t, []any{"rows"},
again.(*bundle.TriggerTask).Spec.Conf["tables"])
+
+ task, ok = source.LookupTask("native_etl", "bare")
+ require.True(t, ok)
+ spec := task.(*bundle.TriggerTask).Spec
+ assert.Nil(t, spec.AllowedStates)
+ assert.Nil(t, spec.FailedStates, "an unset FailedStates stays nil,
which means the default")
+ assert.Nil(t, spec.Conf)
+}
diff --git a/go-sdk/airflow/conformance_test.go
b/go-sdk/airflow/conformance_test.go
index b8887a230e5..149ad6aed40 100644
--- a/go-sdk/airflow/conformance_test.go
+++ b/go-sdk/airflow/conformance_test.go
@@ -289,6 +289,13 @@ func conformanceTriggerDagRun(
key,
)
}
+ if moment, ok := target.(*time.Time); ok {
+ // The value is spelled as Python's str(datetime), such
as "2026-09-30 00:00:00+00:00".
+ parsed, err := time.Parse("2006-01-02 15:04:05Z07:00",
value.Value)
+ require.NoError(t, err, "%s: %s", label, key)
+ *moment = parsed
+ continue
+ }
require.NoError(t, value.Decode(target), "%s: %s", label, key)
}
return spec
diff --git a/go-sdk/airflow/serialize.go b/go-sdk/airflow/serialize.go
index cfbb7d3d8b7..bd294b85b4f 100644
--- a/go-sdk/airflow/serialize.go
+++ b/go-sdk/airflow/serialize.go
@@ -210,7 +210,7 @@ var dagSpecRules = specRules{
fields: dagSpecFields,
set: map[string]bool{"Tags": true},
skip: map[string]bool{
- // serializeTaskLocked writes Queue as the queue of each Go
task whose TaskSpec sets no Queue.
+ // serializeTaskLocked writes Queue as the queue of each task
whose TaskSpec sets no Queue.
"Queue": true,
// serializeTimetable writes Schedule as the timetable.
"Schedule": true,
@@ -330,6 +330,17 @@ func jsonNumber(value any) (float64, bool) {
// to the microsecond. A Python datetime holds nothing finer than a
microsecond.
func encodeTime(t time.Time) float64 { return float64(t.UnixMicro()) / 1e6 }
+// pythonStr returns t as Python's str(datetime) writes it in UTC, such as
+// "2026-09-30 00:00:00+00:00". It writes the microseconds, six digits, only
when they are not zero.
+func pythonStr(t time.Time) string {
+ t = t.UTC()
+ layout := "2006-01-02 15:04:05"
+ if t.Nanosecond()/1000 != 0 {
+ layout += ".000000"
+ }
+ return t.Format(layout) + "+00:00"
+}
+
// 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
@@ -425,9 +436,10 @@ func (d *DagRef) serializeTaskLocked(task *TaskRef)
map[string]any {
// condition.
data["_can_skip_downstream"] = true
}
- if spec.Queue == "" {
- spec.Queue = d.spec.Queue
- }
+ }
+ // A task from TriggerDagRun takes the queue of the Dag too, because
the Go runtime runs it.
+ if spec.Queue == "" {
+ spec.Queue = d.spec.Queue
}
writeSpecFields(data, spec, taskSpecRules)
if len(task.downstreams) > 0 {
@@ -461,12 +473,12 @@ func serializeArgBindings(inputs []taskInput) []any {
}
// writeTriggerDagRun writes a task from TriggerDagRun as Python's serializer
writes a
-// TriggerDagRunOperator, because a Python worker runs the task.
+// TriggerDagRunOperator, so that the Airflow UI and the Dag dependencies
treat it as one.
//
// For a Python Dag, Python's serializer writes the template fields of the
operator and leaves out
-// its other parameters. A Python worker gets those other parameters by
parsing the Python Dag file
-// again. A Go Dag has no Python Dag file, so writeTriggerDagRun also writes
each other parameter
-// that spec sets, under the name that TriggerDagRunOperator gives the
parameter.
+// its other parameters. A Go Dag has no Python Dag file, so
writeTriggerDagRun also writes each
+// other parameter that spec sets, under the name that TriggerDagRunOperator
gives the parameter.
+// The Go runtime runs the task from its own copy of spec and reads none of
this.
func writeTriggerDagRun(data map[string]any, spec TriggerDagRunSpec) {
data["task_type"] = triggerDagRunTaskType
data["_task_module"] = triggerDagRunTaskModule
@@ -481,8 +493,12 @@ func writeTriggerDagRun(data map[string]any, spec
TriggerDagRunSpec) {
}
// Python leaves out a template field that is None, but the default
logical_date is NOTSET, a
// sentinel that lets TriggerDagRunOperator pick the logical date.
Python writes the sentinel as
- // the string NOTSET, and Airflow loads the string as it is.
- data["logical_date"] = cmp.Or(spec.LogicalDate, "NOTSET")
+ // the string NOTSET, and Airflow loads the string as it is. Python
writes a datetime in a
+ // template field as str(datetime), not as a {"__type": "datetime"}
object.
+ data["logical_date"] = "NOTSET"
+ if !spec.LogicalDate.IsZero() {
+ data["logical_date"] = pythonStr(spec.LogicalDate)
+ }
if spec.Conf != nil {
data["conf"] = copyJSON(spec.Conf)
}
diff --git a/go-sdk/airflow/serialize_test.go b/go-sdk/airflow/serialize_test.go
index 7f2337106e1..174791840c8 100644
--- a/go-sdk/airflow/serialize_test.go
+++ b/go-sdk/airflow/serialize_test.go
@@ -355,7 +355,7 @@ func TestSerializeWritesTimesAndDurationsToTheMicrosecond(t
*testing.T) {
assert.NotContains(t, got, "end_date")
}
-func TestSerializeGivesEachGoTaskTheQueueOfTheDag(t *testing.T) {
+func TestSerializeGivesEachTaskTheQueueOfTheDag(t *testing.T) {
dag := Dag("etl", DagSpec{Queue: "golang"})
dag.Task(extract)
dag.Task(ping, TaskSpec{Queue: "golang_large"})
@@ -371,9 +371,13 @@ func TestSerializeGivesEachGoTaskTheQueueOfTheDag(t
*testing.T) {
assert.Equal(t, "golang", serializedTask(t, got, "extract")["queue"])
assert.Equal(t, "golang_large", serializedTask(t, got, "ping")["queue"])
assert.Equal(t, "golang", serializedTask(t, got, "hasRows")["queue"])
- // A task from TriggerDagRun runs on a Python worker.
- assert.NotContains(t, serializedTask(t, got, "trigger"), "queue")
+ // The Go runtime runs a task from TriggerDagRun, so it takes the queue
as a Go task does.
+ assert.Equal(t, "golang", serializedTask(t, got, "trigger")["queue"])
assert.Equal(t, "python", serializedTask(t, got,
"trigger_on_python")["queue"])
+
+ noQueue := Dag("etl")
+ noQueue.Task(TriggerDagRun(TriggerDagRunSpec{DagID: "reports"}),
TaskSpec{TaskID: "trigger"})
+ assert.NotContains(t, serializedTask(t, serializedDag(t, noQueue),
"trigger"), "queue")
}
func TestSerializeWritesTheTimetableOfTheSchedule(t *testing.T) {
@@ -901,9 +905,9 @@ func
TestSerializeWritesATriggerDagRunAsATriggerDagRunOperator(t *testing.T) {
dag := Dag("etl", DagSpec{Queue: "golang"})
dag.Task(TriggerDagRun(TriggerDagRunSpec{
DagID: "downstream_etl",
- RunID: "{{ run_id }}",
+ RunID: "etl_downstream",
Conf: map[string]any{"rows": 2, "nested":
map[string]any{"ok": true}},
- LogicalDate: "{{ ds }}",
+ LogicalDate: time.Date(2026, 1, 31, 8, 30, 0,
500000000, time.UTC),
RunAfter: time.Date(2026, 2, 1, 0, 0, 0, 0,
time.UTC),
ResetDagRun: true,
WaitForCompletion: true,
@@ -934,8 +938,8 @@ func
TestSerializeWritesATriggerDagRunAsATriggerDagRunOperator(t *testing.T) {
"template_fields_renderers": {"conf": "py"},
"_operator_extra_links": {"Triggered DAG":
"_link_TriggerDagRunLink"},
"trigger_dag_id": "downstream_etl",
- "trigger_run_id": "{{ run_id }}",
- "logical_date": "{{ ds }}",
+ "trigger_run_id": "etl_downstream",
+ "logical_date": "2026-01-31 08:30:00.500000+00:00",
"conf": {"rows": 2, "nested": {"ok": true}},
"wait_for_completion": true,
"skip_when_already_exists": true,
@@ -947,10 +951,48 @@ func
TestSerializeWritesATriggerDagRunAsATriggerDagRunOperator(t *testing.T) {
"fail_when_dag_is_paused": true,
"note": "from etl",
"deferrable": false,
+ "queue": "golang",
"retries": 1
}`, got)
}
+func TestSerializeWritesTheLogicalDateOfATriggerDagRunAsPythonStr(t
*testing.T) {
+ tests := []struct {
+ name string
+ date time.Time
+ want string
+ }{
+ {
+ "whole seconds",
+ time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC),
+ "2026-09-30 00:00:00+00:00",
+ },
+ {
+ "microseconds are not trimmed",
+ time.Date(2026, 9, 30, 1, 2, 3, 120000000, time.UTC),
+ "2026-09-30 01:02:03.120000+00:00",
+ },
+ {
+ "another zone is written in UTC",
+ time.Date(2026, 9, 30, 9, 0, 0, 0,
time.FixedZone("UTC+9", 9*3600)),
+ "2026-09-30 00:00:00+00:00",
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ dag := Dag("etl")
+ dag.Task(
+ TriggerDagRun(TriggerDagRunSpec{DagID:
"reports", LogicalDate: tc.date}),
+ TaskSpec{TaskID: "trigger"},
+ )
+
+ got := serializedTask(t, serializedDag(t, dag),
"trigger")
+
+ assert.Equal(t, tc.want, got["logical_date"])
+ })
+ }
+}
+
func TestSerializeWritesOnlyTheTriggerDagRunOptionsThatAreSet(t *testing.T) {
dag := Dag("etl")
dag.Task(TriggerDagRun(TriggerDagRunSpec{DagID: "reports"}),
TaskSpec{TaskID: "trigger"})
diff --git a/go-sdk/airflow/spec.gen.go b/go-sdk/airflow/spec.gen.go
index 001b7ac7995..f13b1fa32cf 100644
--- a/go-sdk/airflow/spec.gen.go
+++ b/go-sdk/airflow/spec.gen.go
@@ -64,8 +64,7 @@ type DagSpec struct {
// Queue is the queue that each task of the Dag runs on, unless the
TaskSpec of
// the task sets a Queue. The queue_to_coordinator option in the [sdk]
section of
// the Airflow configuration maps the queue to the coordinator that
runs Go code.
- // A task from TriggerDagRun runs on a Python worker, so it does not
take this
- // queue.
+ // A task from TriggerDagRun takes the queue too, because the Go
runtime runs it.
Queue string
// RenderTemplateAsNativeObj corresponds to the JSON schema field
diff --git a/go-sdk/airflow/trigger_dag_run.go
b/go-sdk/airflow/trigger_dag_run.go
index b5c3c098c9e..ddc7da06218 100644
--- a/go-sdk/airflow/trigger_dag_run.go
+++ b/go-sdk/airflow/trigger_dag_run.go
@@ -27,6 +27,8 @@ import (
"strconv"
"strings"
"time"
+
+ "github.com/apache/airflow/go-sdk/internal/bundle"
)
// TriggerDagRunSpec holds the options of a task that triggers a Dag run.
[TriggerDagRun] takes a
@@ -36,21 +38,24 @@ import (
// leaves its parameter at the Python default. PokeInterval and Deferrable are
pointers so that a
// pointer to 0 or false can set that value instead of leaving the default.
//
-// DagID, RunID, LogicalDate and the values in Conf are templated. They can
hold Jinja such as
-// "{{ ds }}", which Airflow renders when the task runs.
+// The task does not render templates. Note and the values in Conf are sent as
they are, so a
+// string such as "{{ ds }}" reaches the new Dag run unchanged. DagID and
RunID must not contain
+// "{{", since Airflow rejects such a dag_id or run_id. The task does not offer
+// openlineage_inject_parent_info, so it adds nothing to Conf.
type TriggerDagRunSpec struct {
// DagID is the dag_id of the Dag to trigger. It is required.
DagID string
- // RunID is the run_id of the new Dag run. When RunID is empty, Airflow
generates one.
+ // RunID is the run_id of the new Dag run. When RunID is empty, the
task names the run after
+ // the time it can start, such as "manual__2026-09-30T00:00:00+00:00",
and adds an underscore
+ // and eight random characters when the run has no logical date.
RunID string
// Conf is the conf of the new Dag run. Each value must marshal to
JSON, and each integer in
// Conf must fit in 64 bits.
Conf map[string]any
- // LogicalDate is the logical date of the new Dag run, as an ISO 8601
string such as
- // "2026-09-30T00:00:00+00:00" or a template such as "{{ ds }}". When
LogicalDate is empty and
- // RunAfter is the zero Time, the logical date is the time the task
runs. When LogicalDate is
- // empty and RunAfter is set, the new Dag run has no logical date.
- LogicalDate string
+ // LogicalDate is the logical date of the new Dag run. When LogicalDate
is the zero Time and
+ // RunAfter is also the zero Time, the logical date is the time the
task runs. When LogicalDate
+ // is the zero Time and RunAfter is set, the new Dag run has no logical
date.
+ LogicalDate time.Time
// RunAfter is the earliest time at which the new Dag run can start.
When RunAfter is the zero
// Time, the new Dag run can start as soon as the task triggers it.
RunAfter time.Time
@@ -77,9 +82,11 @@ type TriggerDagRunSpec struct {
FailWhenDagIsPaused bool
// Note is the note of the new Dag run.
Note string
- // Deferrable makes a task that waits defer instead of holding a worker
slot. When Deferrable
- // is nil, the task follows the default_deferrable option in the
operators section of the
- // Airflow configuration.
+ // Deferrable makes a task that waits defer instead of holding a worker
slot. The wait then
+ // runs in the Airflow triggerer, which needs the standard provider
installed, and the task
+ // resumes in the Go runtime when the new Dag run finishes. Deferrable
has no effect on a task
+ // that does not wait. When Deferrable is nil, the task follows the
default_deferrable option
+ // in the operators section of the Airflow configuration.
Deferrable *bool
}
@@ -97,10 +104,12 @@ type TriggerDagRunTask struct {
// airflow.TaskSpec{TaskID: "trigger_downstream"},
// )
//
-// The task runs no Go code. Once [BundleRef.Serve] serves the Dags from
[Dag], Airflow will run
-// the task as TriggerDagRunOperator on a Python worker. So the task does not
take the Queue of the
-// [DagSpec], which routes the tasks that run Go code. A [TaskSpec] can set a
Queue for the task,
-// to pick the Python workers that run it.
+// The task runs no Go code. Once [BundleRef.Serve] serves the Dags from
[Dag], the Go runtime runs
+// the task as TriggerDagRunOperator does, so a Dag from [Dag] needs no Python
worker for it. The
+// task takes the Queue of the [DagSpec], unless its [TaskSpec] sets one. The
runtime reads the
+// settings it needs from its environment: [api] base_url for the link to the
new Dag run, and
+// [operators] default_deferrable for a spec that leaves Deferrable nil. Set
Deferrable to defer the
+// wait, which hands it to the Python triggerer as the DagStateTrigger of the
standard provider.
//
// Because the task has no Go function to take a task_id from, DagRef.Task
needs a [TaskSpec]
// that sets TaskID. For the same reason, the task takes no [Inputs], and it
returns no result
@@ -117,6 +126,16 @@ func copyTriggerDagRunSpec(spec TriggerDagRunSpec)
(TriggerDagRunSpec, error) {
if spec.DagID == "" {
return TriggerDagRunSpec{},
errors.New("airflow.TriggerDagRunSpec has no DagID")
}
+ for _, field := range []struct{ name, value string }{
+ {"DagID", spec.DagID}, {"RunID", spec.RunID},
+ } {
+ if strings.Contains(field.value, "{{") {
+ return TriggerDagRunSpec{}, fmt.Errorf(
+ "airflow.TriggerDagRunSpec.%s is %q; the task
does not render templates",
+ field.name, field.value,
+ )
+ }
+ }
if poke := spec.PokeInterval; poke != nil && (*poke < 0 ||
*poke%time.Second != 0) {
return TriggerDagRunSpec{}, fmt.Errorf(
"airflow.TriggerDagRunSpec.PokeInterval is %v; "+
@@ -141,6 +160,9 @@ func copyTriggerDagRunSpec(spec TriggerDagRunSpec)
(TriggerDagRunSpec, error) {
if err := checkTime("airflow.TriggerDagRunSpec.RunAfter",
spec.RunAfter); err != nil {
return TriggerDagRunSpec{}, err
}
+ if err := checkTime("airflow.TriggerDagRunSpec.LogicalDate",
spec.LogicalDate); err != nil {
+ return TriggerDagRunSpec{}, err
+ }
conf, err := copyConf(spec.Conf)
if err != nil {
return TriggerDagRunSpec{},
fmt.Errorf("airflow.TriggerDagRunSpec.Conf: %w", err)
@@ -153,6 +175,36 @@ func copyTriggerDagRunSpec(spec TriggerDagRunSpec)
(TriggerDagRunSpec, error) {
return copied, nil
}
+// triggerSpec returns the options of spec for the runtime, which cannot
import this package. The
+// checked spec is a deep copy, but the runtime gets one of its own, so that
nothing the runtime
+// does can change the task.
+func triggerSpec(spec TriggerDagRunSpec) bundle.TriggerSpec {
+ copied := copySpec(spec)
+ out := bundle.TriggerSpec{
+ DagID: copied.DagID,
+ RunID: copied.RunID,
+ Note: copied.Note,
+ LogicalDate: copied.LogicalDate,
+ RunAfter: copied.RunAfter,
+ ResetDagRun: copied.ResetDagRun,
+ WaitForCompletion: copied.WaitForCompletion,
+ SkipWhenAlreadyExists: copied.SkipWhenAlreadyExists,
+ FailWhenDagIsPaused: copied.FailWhenDagIsPaused,
+ PokeInterval: copied.PokeInterval,
+ Deferrable: copied.Deferrable,
+ }
+ if copied.Conf != nil {
+ out.Conf = copyJSON(copied.Conf).(map[string]any)
+ }
+ if len(copied.AllowedStates) > 0 {
+ out.AllowedStates = dagRunStateNames(copied.AllowedStates)
+ }
+ if copied.FailedStates != nil {
+ out.FailedStates = dagRunStateNames(copied.FailedStates)
+ }
+ return out
+}
+
// copyConf copies conf by way of JSON, so it also rejects a conf that JSON
cannot hold.
//
// Each number in the copy is an int64, a uint64 or a float64, which
encoding/json and msgpack both
diff --git a/go-sdk/airflow/trigger_dag_run_test.go
b/go-sdk/airflow/trigger_dag_run_test.go
index ab2f8fea5a8..fdfe813c645 100644
--- a/go-sdk/airflow/trigger_dag_run_test.go
+++ b/go-sdk/airflow/trigger_dag_run_test.go
@@ -33,9 +33,9 @@ func TestTriggerDagRunIsATask(t *testing.T) {
gate := dag.Task(extract)
spec := TriggerDagRunSpec{
DagID: "downstream_etl",
- RunID: "{{ run_id }}_downstream",
+ RunID: "etl_downstream",
Conf: map[string]any{"source": "etl"},
- LogicalDate: "{{ ds }}",
+ LogicalDate: time.Date(2026, 9, 29, 0, 0, 0, 0,
time.UTC),
RunAfter: time.Date(2026, 9, 30, 0, 0, 0, 0,
time.UTC),
ResetDagRun: true,
WaitForCompletion: true,
@@ -111,6 +111,20 @@ func TestTriggerDagRunRejectsAnInvalidSpec(t *testing.T) {
trigger:
TriggerDagRun(TriggerDagRunSpec{WaitForCompletion: true}),
want: "airflow.TriggerDagRunSpec has no DagID",
},
+ {
+ name: "templated DagID",
+ trigger: TriggerDagRun(TriggerDagRunSpec{DagID: "{{
params.target }}"}),
+ want: `airflow.TriggerDagRunSpec.DagID is "{{
params.target }}"; ` +
+ "the task does not render templates",
+ },
+ {
+ name: "templated RunID",
+ trigger: TriggerDagRun(
+ TriggerDagRunSpec{DagID: "downstream_etl",
RunID: "triggered_{{ run_id }}"},
+ ),
+ want: `airflow.TriggerDagRunSpec.RunID is "triggered_{{
run_id }}"; ` +
+ "the task does not render templates",
+ },
{
name: "negative PokeInterval",
trigger: TriggerDagRun(
@@ -158,6 +172,14 @@ func TestTriggerDagRunRejectsAnInvalidSpec(t *testing.T) {
want: "airflow.TriggerDagRunSpec.RunAfter is
10000-01-01T00:00:00Z in UTC; " +
"Airflow takes a time only from year 1 to year
9999",
},
+ {
+ name: "LogicalDate before year 1 in UTC",
+ trigger: TriggerDagRun(TriggerDagRunSpec{
+ DagID: "downstream_etl", LogicalDate:
time.Date(0, 12, 31, 0, 0, 0, 0, time.UTC),
+ }),
+ want: "airflow.TriggerDagRunSpec.LogicalDate is
0000-12-31T00:00:00Z in UTC; " +
+ "Airflow takes a time only from year 1 to year
9999",
+ },
{
name: "Conf with an integer that does not fit in 64
bits",
trigger: TriggerDagRun(TriggerDagRunSpec{
diff --git a/go-sdk/capabilities.yaml b/go-sdk/capabilities.yaml
index cabf5d45871..4527ffd1da1 100644
--- a/go-sdk/capabilities.yaml
+++ b/go-sdk/capabilities.yaml
@@ -23,8 +23,9 @@ min_airflow_version: "3.4"
# bundle metadata. The render hook fails if the two disagree.
supervisor_schema_version: "2026-10-30"
-# The task runner terminates a task with SucceedTask, RetryTask, or TaskState
(failed/removed); it
-# does not yet emit skipped, DeferTask, RescheduleTask, or AwaitInputTask.
+# The task runner terminates a task with SucceedTask, RetryTask, or TaskState
(failed/removed); only
+# an airflow.TriggerDagRun task emits TaskState skipped or DeferTask. It does
not emit RescheduleTask
+# or AwaitInputTask.
states:
success:
supported: true
@@ -37,11 +38,13 @@ states:
since: "3.3"
note: "RetryTask"
skipped:
- supported: false
- note: "runtime does not emit TaskState skipped yet"
+ supported: true
+ since: "3.4"
+ note: "airflow.TriggerDagRun with SkipWhenAlreadyExists only"
deferred:
- supported: false
- note: "runtime does not emit DeferTask yet"
+ supported: true
+ since: "3.4"
+ note: "airflow.TriggerDagRun with Deferrable only; resumes in the Go
runtime"
up_for_reschedule:
supported: false
note: "runtime does not emit RescheduleTask yet"
diff --git a/go-sdk/internal/bundle/trigger.go
b/go-sdk/internal/bundle/trigger.go
new file mode 100644
index 00000000000..2b45418f531
--- /dev/null
+++ b/go-sdk/internal/bundle/trigger.go
@@ -0,0 +1,64 @@
+// 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 bundle
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "time"
+
+ "github.com/apache/airflow/go-sdk/pkg/binding"
+)
+
+// TriggerSpec is what the runtime needs to run a task from
airflow.TriggerDagRun. It holds the
+// options of the task as the author wrote them, so the runtime applies the
defaults. The states
+// are the names of Dag run states.
+type TriggerSpec struct {
+ DagID string
+ RunID string
+ Note string
+ Conf map[string]any
+ LogicalDate time.Time
+ RunAfter time.Time
+ ResetDagRun bool
+ WaitForCompletion bool
+ SkipWhenAlreadyExists bool
+ FailWhenDagIsPaused bool
+ // PokeInterval is nil when the author left it unset.
+ PokeInterval *time.Duration
+ // AllowedStates is empty when the author left it unset.
+ AllowedStates []string
+ // FailedStates is nil when the author left it unset, and empty when no
state fails the task.
+ FailedStates []string
+ // Deferrable is nil when the author left it unset.
+ Deferrable *bool
+}
+
+// TriggerTask is the Task of a task from airflow.TriggerDagRun. It runs no Go
code, so the runtime
+// recognizes it and runs the task itself.
+type TriggerTask struct {
+ Spec TriggerSpec
+}
+
+var _ Task = (*TriggerTask)(nil)
+
+// Execute always fails, because the runtime runs a TriggerTask without
calling it.
+func (*TriggerTask) Execute(context.Context, *slog.Logger, []binding.Arg)
error {
+ return errors.New("a TriggerDagRun task is run by the runtime, so
Execute is never called")
+}
diff --git a/go-sdk/internal/genspec/authoring.go
b/go-sdk/internal/genspec/authoring.go
index fd6c407cd8f..5481af5915f 100644
--- a/go-sdk/internal/genspec/authoring.go
+++ b/go-sdk/internal/genspec/authoring.go
@@ -123,7 +123,7 @@ var dagShape = authoringShape{
// them there.
"queue": {
"type": "string",
- "description": "Queue is the queue that each task of
the Dag runs on, unless the TaskSpec of the task sets a Queue. The
queue_to_coordinator option in the [sdk] section of the Airflow configuration
maps the queue to the coordinator that runs Go code. A task from TriggerDagRun
runs on a Python worker, so it does not take this queue.",
+ "description": "Queue is the queue that each task of
the Dag runs on, unless the TaskSpec of the task sets a Queue. The
queue_to_coordinator option in the [sdk] section of the Airflow configuration
maps the queue to the coordinator that runs Go code. A task from TriggerDagRun
takes the queue too, because the Go runtime runs it.",
},
},
}
diff --git a/go-sdk/pkg/execution/client.go b/go-sdk/pkg/execution/client.go
index 610a5c53a5c..8fda6b0864b 100644
--- a/go-sdk/pkg/execution/client.go
+++ b/go-sdk/pkg/execution/client.go
@@ -43,6 +43,7 @@ const (
errCodeConnectionNotFound = "CONNECTION_NOT_FOUND"
errCodeXComNotFound = "XCOM_NOT_FOUND"
errCodeTaskStoreNotFound = "TASK_STORE_NOT_FOUND"
+ errCodeDagRunExists =
string(genmodels.ErrorTypeDAGRUNALREADYEXISTS)
)
// A language SDK runtime cannot read Airflow config, so the supervisor passes
@@ -361,6 +362,51 @@ func (c *CoordinatorClient) skipDownstreamTasks(ctx
context.Context, taskIDs []s
return err
}
+// triggerDagRun asks the supervisor to create a Dag run. It reports
alreadyExists, not an error,
+// when a Dag run with the same run_id exists, because a trigger task decides
what to do then.
+func (c *CoordinatorClient) triggerDagRun(
+ ctx context.Context,
+ msg genmodels.TriggerDagRun,
+) (alreadyExists bool, err error) {
+ if _, err := c.comm.Communicate(ctx, msg); err != nil {
+ var apiErr *APIError
+ if errors.As(err, &apiErr) && apiErr.Err == errCodeDagRunExists
{
+ return true, nil
+ }
+ return false, err
+ }
+ return false, nil
+}
+
+// getDagRunState asks the supervisor for the state of a Dag run.
+func (c *CoordinatorClient) getDagRunState(
+ ctx context.Context,
+ dagID, runID string,
+) (string, error) {
+ resp, err := c.comm.Communicate(ctx, genmodels.GetDagRunState{DagID:
dagID, RunID: runID})
+ if err != nil {
+ return "", err
+ }
+ var result genmodels.DagRunStateResult
+ if err := decodeBody(resp, &result); err != nil {
+ return "", fmt.Errorf("decoding Dag run state result: %w", err)
+ }
+ return string(result.State), nil
+}
+
+// isDagPaused asks the supervisor whether a Dag is paused.
+func (c *CoordinatorClient) isDagPaused(ctx context.Context, dagID string)
(bool, error) {
+ resp, err := c.comm.Communicate(ctx, genmodels.GetDag{DagID: dagID})
+ if err != nil {
+ return false, err
+ }
+ var result genmodels.DagResult
+ if err := decodeBody(resp, &result); err != nil {
+ return false, fmt.Errorf("decoding Dag result: %w", err)
+ }
+ return result.IsPaused, nil
+}
+
// TaskStateStore returns the task state store scoped to this task instance.
func (c *CoordinatorClient) TaskStateStore() sdk.TaskStateStore {
return taskStateStore{client: c}
diff --git a/go-sdk/pkg/execution/client_test.go
b/go-sdk/pkg/execution/client_test.go
index bd8dfd3f6e7..ec61b474ec8 100644
--- a/go-sdk/pkg/execution/client_test.go
+++ b/go-sdk/pkg/execution/client_test.go
@@ -25,12 +25,14 @@ import (
"log/slog"
"math"
"os"
+ "strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "github.com/apache/airflow/go-sdk/pkg/execution/genmodels"
"github.com/apache/airflow/go-sdk/sdk"
)
@@ -1132,3 +1134,137 @@ func TestCoordinatorClientDeleteXCom(t *testing.T) {
})
}
}
+
+// clientWithReply returns a client whose supervisor answers its one request
with body and errBody,
+// and the buffer that holds the request it sends.
+func clientWithReply(
+ t *testing.T,
+ body, errBody map[string]any,
+) (*CoordinatorClient, *bytes.Buffer) {
+ t.Helper()
+ var responseBuf bytes.Buffer
+ require.NoError(t, writeFrame(&responseBuf, encodeResponseFrame(t, 0,
body, errBody)))
+ var requestBuf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ comm := NewCoordinatorComm(&responseBuf, &requestBuf, logger)
+ return NewCoordinatorClient(comm, testTIID), &requestBuf
+}
+
+// TestCoordinatorClientTriggerDagRun verifies the TriggerDagRun frame, that a
Dag run which already
+// exists is reported instead of returned as an error, and that any other
supervisor error is
+// returned.
+func TestCoordinatorClientTriggerDagRun(t *testing.T) {
+ logicalDate := time.Date(2026, 9, 30, 1, 2, 3, 0, time.UTC)
+ tests := []struct {
+ name string
+ errBody map[string]any
+ wantAlreadyExists bool
+ wantErrCode string
+ }{
+ {name: "triggered"},
+ {
+ name: "already exists",
+ errBody: map[string]any{
+ "type": "ErrorResponse",
+ "error": "DAGRUN_ALREADY_EXISTS",
+ "detail": map[string]any{"message": "exists"},
+ },
+ wantAlreadyExists: true,
+ },
+ {
+ name: "other error",
+ errBody: map[string]any{
+ "type": "ErrorResponse",
+ "error": "API_SERVER_ERROR",
+ "detail": map[string]any{"status_code": 500},
+ },
+ wantErrCode: "API_SERVER_ERROR",
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client, requestBuf := clientWithReply(t, nil,
tc.errBody)
+
+ alreadyExists, err := client.triggerDagRun(
+ context.Background(),
+ genmodels.TriggerDagRun{
+ DagID: "downstream",
+ RunID: "run_1",
+ LogicalDate: logicalDate,
+ Conf: &genmodels.Conf{"k": "v"},
+ },
+ )
+ assert.Equal(t, tc.wantAlreadyExists, alreadyExists)
+ if tc.wantErrCode != "" {
+ var apiErr *APIError
+ require.ErrorAs(t, err, &apiErr)
+ assert.Equal(t, tc.wantErrCode, apiErr.Err)
+ } else {
+ require.NoError(t, err)
+ }
+
+ sent, err := readFrame(requestBuf)
+ require.NoError(t, err)
+ got := rawToMap(t, sent.Body)
+ assert.Equal(t, "TriggerDagRun", got["type"])
+ assert.Equal(t, "downstream", got["dag_id"])
+ assert.Equal(t, "run_1", got["run_id"])
+ assert.Equal(t, map[string]any{"k": "v"}, got["conf"])
+ assert.NotContains(t, got, "run_after", "an unset
run_after must stay off the wire")
+ })
+ }
+}
+
+func TestCoordinatorClientGetDagRunState(t *testing.T) {
+ client, requestBuf := clientWithReply(
+ t, map[string]any{"type": "DagRunStateResult", "state":
"running"}, nil,
+ )
+
+ state, err := client.getDagRunState(context.Background(), "downstream",
"run_1")
+ require.NoError(t, err)
+ assert.Equal(t, "running", state)
+
+ sent, err := readFrame(requestBuf)
+ require.NoError(t, err)
+ assert.Equal(t, map[string]any{
+ "type": "GetDagRunState",
+ "dag_id": "downstream",
+ "run_id": "run_1",
+ }, rawToMap(t, sent.Body))
+}
+
+func TestCoordinatorClientGetDagRunStateError(t *testing.T) {
+ client, _ := clientWithReply(t, nil, map[string]any{
+ "type": "ErrorResponse",
+ "error": "DAGRUN_NOT_FOUND",
+ "detail": map[string]any{"status_code": 404},
+ })
+
+ _, err := client.getDagRunState(context.Background(), "downstream",
"run_1")
+ var apiErr *APIError
+ require.ErrorAs(t, err, &apiErr)
+ assert.Equal(t, "DAGRUN_NOT_FOUND", apiErr.Err)
+}
+
+func TestCoordinatorClientIsDagPaused(t *testing.T) {
+ for _, paused := range []bool{true, false} {
+ t.Run(strconv.FormatBool(paused), func(t *testing.T) {
+ client, requestBuf := clientWithReply(t, map[string]any{
+ "type": "DagResult",
+ "dag_id": "downstream",
+ "is_paused": paused,
+ }, nil)
+
+ got, err := client.isDagPaused(context.Background(),
"downstream")
+ require.NoError(t, err)
+ assert.Equal(t, paused, got)
+
+ sent, err := readFrame(requestBuf)
+ require.NoError(t, err)
+ assert.Equal(t, map[string]any{
+ "type": "GetDag",
+ "dag_id": "downstream",
+ }, rawToMap(t, sent.Body))
+ })
+ }
+}
diff --git a/go-sdk/pkg/execution/task_runner.go
b/go-sdk/pkg/execution/task_runner.go
index 45bd228ff5a..16791640890 100644
--- a/go-sdk/pkg/execution/task_runner.go
+++ b/go-sdk/pkg/execution/task_runner.go
@@ -36,7 +36,8 @@ import (
// It looks up the task in the bundle, creates a CoordinatorClient for SDK
// calls, deletes the XComs that ti_context.xcom_keys_to_clear lists, executes
// the task, and returns the terminal body to ship as the final response frame:
-// one of genmodels.SucceedTask, TaskState, or RetryTask. When RunTask cannot
+// one of genmodels.SucceedTask, TaskState, RetryTask, or DeferTask. A task
from airflow.TriggerDagRun
+// is the only one that defers, and RunTask runs it without a Go function.
When RunTask cannot
// delete an XCom, or arg_bindings is invalid, the task fails without running.
//
// The supervisor owns the Execution-API state transitions, so the runtime only
@@ -71,13 +72,7 @@ func RunTask(
// context is a placeholder, because binding reads only the task
instance
// and Dag run from this value and builds the airflow.Context around the
// live task context.
- ti := sdk.TaskInstance{
- DagID: details.TI.DagID,
- RunID: details.TI.RunID,
- TaskID: details.TI.TaskID,
- MapIndex: mapIndexPtr(details.TI.MapIndex),
- TryNumber: details.TI.TryNumber,
- }
+ ti := taskInstanceOf(details)
dagRun := details.TIContext.DagRun
runtimeContext := sdk.NewTIRunContext(
context.Background(),
@@ -112,6 +107,11 @@ func RunTask(
}
}
+ // The runtime runs a TriggerDagRun task itself. It has no Go function
to bind arguments to.
+ if trigger, ok := task.(*bundle.TriggerTask); ok {
+ return runTriggerDagRun(ctx, details, trigger.Spec, client,
logger, defaultTriggerEnv)
+ }
+
args, err := convertArgBindings(details.TIContext.ArgBindings)
if err != nil {
logger.Error("Invalid arg_bindings spec from supervisor",
@@ -125,6 +125,16 @@ func RunTask(
return executeTask(ctx, task, args, details.TIContext.ShouldRetry,
logger)
}
+func taskInstanceOf(details *genmodels.StartupDetails) sdk.TaskInstance {
+ return sdk.TaskInstance{
+ DagID: details.TI.DagID,
+ RunID: details.TI.RunID,
+ TaskID: details.TI.TaskID,
+ MapIndex: mapIndexPtr(details.TI.MapIndex),
+ TryNumber: details.TI.TryNumber,
+ }
+}
+
// failTask returns the terminal body for a task that fails before it runs:
RetryTask when
// ti_context.should_retry is set, otherwise a FAILED TaskState.
func failTask(shouldRetry bool, err error) any {
@@ -282,10 +292,14 @@ func executeTask(
}
}
- // task_outlets / outlet_events must be sent as empty lists, not
omitted:
- // the supervisor's SucceedTask validation rejects a null/absent value
- // ("Input should be a valid list"). A task that emits no asset events
- // reports empty collections rather than None.
+ return succeedTask()
+}
+
+// succeedTask returns the terminal body of a task that succeeded.
task_outlets / outlet_events must
+// be sent as empty lists, not omitted: the supervisor's SucceedTask
validation rejects a
+// null/absent value ("Input should be a valid list"). A task that emits no
asset events reports
+// empty collections rather than None.
+func succeedTask() genmodels.SucceedTask {
return genmodels.SucceedTask{
EndDate: time.Now().UTC(),
TaskOutlets: &genmodels.TaskOutlets{},
diff --git a/go-sdk/pkg/execution/trigger_runner.go
b/go-sdk/pkg/execution/trigger_runner.go
new file mode 100644
index 00000000000..8c85ace9a27
--- /dev/null
+++ b/go-sdk/pkg/execution/trigger_runner.go
@@ -0,0 +1,418 @@
+// 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 execution
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "math/big"
+ "os"
+ "runtime/debug"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/apache/airflow/go-sdk/internal/bundle"
+ "github.com/apache/airflow/go-sdk/pkg/execution/genmodels"
+)
+
+// This file runs a task from airflow.TriggerDagRun the way
TriggerDagRunOperator.execute and
+// execute_complete in the standard provider do, with the difference that
nothing is Jinja-rendered.
+
+const (
+ dagStateTriggerClasspath =
"airflow.providers.standard.triggers.external_task.DagStateTrigger"
+ // triggerDagRunLinkXComKey is the XCom that the "Triggered DAG" extra
link reads.
+ triggerDagRunLinkXComKey = "_link_TriggerDagRunLink"
+ triggerRunIDXComKey = "trigger_run_id"
+ // triggerFailMethod is the next_method of a task whose trigger failed
or timed out.
+ triggerFailMethod = "__fail__"
+ executeCompleteMethod = "execute_complete"
+
+ defaultTriggerPokeInterval = 60 * time.Second
+)
+
+// The runtime cannot read airflow.cfg, so the coordinator exports these
settings.
+const (
+ apiBaseURLEnv = "AIRFLOW__API__BASE_URL"
+ defaultDeferrableEnv = "AIRFLOW__OPERATORS__DEFAULT_DEFERRABLE"
+ triggererQueuesEnabledEnv = "AIRFLOW__TRIGGERER__QUEUES_ENABLED"
+)
+
+const runIDSuffixChars =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+
+// triggerEnv holds what a trigger task takes from its surroundings, so a test
can replace it.
+type triggerEnv struct {
+ now func() time.Time
+ getenv func(string) string
+ sleep func(ctx context.Context, d time.Duration) error
+ randomSuffix func() string
+}
+
+var defaultTriggerEnv = triggerEnv{
+ now: time.Now,
+ getenv: os.Getenv,
+ sleep: func(ctx context.Context, d time.Duration) error {
+ timer := time.NewTimer(d)
+ defer timer.Stop()
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ return nil
+ }
+ },
+ randomSuffix: func() string {
+ out := make([]byte, 8)
+ for i := range out {
+ n, err := rand.Int(rand.Reader,
big.NewInt(int64(len(runIDSuffixChars))))
+ if err != nil {
+ panic(err)
+ }
+ out[i] = runIDSuffixChars[n.Int64()]
+ }
+ return string(out)
+ },
+}
+
+// runTriggerDagRun runs a trigger task and returns the terminal body for the
supervisor. It
+// returns a genmodels.DeferTask for a task that waits and is deferrable.
+func runTriggerDagRun(
+ ctx context.Context,
+ details *genmodels.StartupDetails,
+ spec bundle.TriggerSpec,
+ client *CoordinatorClient,
+ logger *slog.Logger,
+ env triggerEnv,
+) (result any) {
+ shouldRetry := details.TIContext.ShouldRetry
+ fail := func(message string) any {
+ logger.ErrorContext(ctx, "Task failed", "error", message)
+ return failTask(shouldRetry, errors.New(message))
+ }
+ defer func() {
+ if r := recover(); r != nil {
+ logger.ErrorContext(ctx, "Recovered panic in task",
+ "error", r, "stack", string(debug.Stack()),
+ )
+ result = failTask(shouldRetry, fmt.Errorf("panic: %v",
r))
+ }
+ }()
+ allowed := spec.AllowedStates
+ if len(allowed) == 0 {
+ allowed = []string{string(genmodels.DagRunStateSuccess)}
+ }
+ failed := spec.FailedStates
+ if failed == nil {
+ failed = []string{string(genmodels.DagRunStateFailed)}
+ }
+
+ if nextMethod := ifaceString(details.TIContext.NextMethod); nextMethod
!= "" {
+ return resumeTriggerDagRun(
+ nextMethod, details.TIContext.NextKwargs, spec.DagID,
allowed, failed, logger, fail,
+ )
+ }
+
+ logicalDate := triggerLogicalDate(spec, env.now().UTC())
+ runID := spec.RunID
+ if runID == "" {
+ // Python names the run after run_after, which is the logical
date when the task sets none.
+ runAfter := logicalDate
+ if !spec.RunAfter.IsZero() {
+ runAfter = spec.RunAfter
+ }
+ runID = "manual__" + pythonIsoformat(runAfter)
+ if logicalDate.IsZero() {
+ runID += "_" + env.randomSuffix()
+ }
+ }
+
+ if spec.FailWhenDagIsPaused {
+ paused, err := client.isDagPaused(ctx, spec.DagID)
+ if err != nil {
+ return fail(err.Error())
+ }
+ if paused {
+ return fail(fmt.Sprintf("Dag %s is paused", spec.DagID))
+ }
+ }
+
+ ti := taskInstanceOf(details)
+ link := dagRunURL(env.getenv(apiBaseURLEnv), spec.DagID, runID)
+ if err := client.PushXCom(ctx, ti, triggerDagRunLinkXComKey, link); err
!= nil {
+ return fail(err.Error())
+ }
+
+ logger.InfoContext(ctx, "Triggering Dag Run.", "trigger_dag_id",
spec.DagID)
+ msg := genmodels.TriggerDagRun{
+ DagID: spec.DagID,
+ RunID: runID,
+ ResetDagRun: spec.ResetDagRun,
+ }
+ if !logicalDate.IsZero() {
+ msg.LogicalDate = logicalDate
+ }
+ if !spec.RunAfter.IsZero() {
+ msg.RunAfter = spec.RunAfter.UTC()
+ }
+ if spec.Conf != nil {
+ conf := genmodels.Conf(spec.Conf)
+ msg.Conf = &conf
+ }
+ if spec.Note != "" {
+ msg.Note = spec.Note
+ }
+ alreadyExists, err := client.triggerDagRun(ctx, msg)
+ if err != nil {
+ return fail(err.Error())
+ }
+ if alreadyExists {
+ if spec.SkipWhenAlreadyExists {
+ logger.InfoContext(ctx,
+ "Dag Run already exists, skipping task as
skip_when_already_exists is set to True.",
+ "dag_id", spec.DagID,
+ )
+ return genmodels.TaskState{
+ State: genmodels.TaskStateStateSkipped,
+ EndDate: time.Now().UTC(),
+ }
+ }
+ logger.ErrorContext(ctx, "Dag Run already exists, marking task
as failed.",
+ "dag_id", spec.DagID,
+ )
+ return genmodels.TaskState{State:
genmodels.TaskStateStateFailed, EndDate: time.Now().UTC()}
+ }
+ logger.InfoContext(ctx, "Dag Run triggered successfully.",
"trigger_dag_id", spec.DagID)
+ if err := client.PushXCom(ctx, ti, triggerRunIDXComKey, runID); err !=
nil {
+ return fail(err.Error())
+ }
+
+ deferrable, err := triggerIsDeferrable(spec, env)
+ if err != nil {
+ return fail(err.Error())
+ }
+ if !spec.WaitForCompletion {
+ if deferrable {
+ logger.InfoContext(ctx,
+ "Ignoring deferrable=True because
wait_for_completion=False. "+
+ "Task will complete immediately without
waiting for the triggered DAG run.",
+ "trigger_dag_id", spec.DagID,
+ )
+ }
+ return succeedTask()
+ }
+
+ pokeInterval := defaultTriggerPokeInterval
+ if spec.PokeInterval != nil {
+ pokeInterval = *spec.PokeInterval
+ }
+ if deferrable {
+ logger.InfoContext(ctx, "Pausing task as DEFERRED.",
+ "trigger_dag_id", spec.DagID, "run_id", runID,
+ )
+ queuesEnabled, err := envBool(env.getenv,
triggererQueuesEnabledEnv)
+ if err != nil {
+ return fail(err.Error())
+ }
+ deferred := genmodels.DeferTask{
+ State: "deferred",
+ Classpath: dagStateTriggerClasspath,
+ // The keys are those of DagStateTrigger.serialize().
+ TriggerKwargs: &genmodels.TriggerKwargs{
+ "dag_id": spec.DagID,
+ "states": slices.Concat(allowed,
failed),
+ "poll_interval": int(pokeInterval /
time.Second),
+ "run_ids": []string{runID},
+ "execution_dates": nil,
+ },
+ NextMethod: executeCompleteMethod,
+ NextKwargs: &genmodels.NextKwargs{},
+ }
+ // The trigger runs on a triggerer that serves the queue of the
task only when
+ // [triggerer] queues_enabled is set.
+ if queuesEnabled && details.TI.Queue != "" {
+ deferred.Queue = details.TI.Queue
+ }
+ return deferred
+ }
+
+ for {
+ logger.InfoContext(ctx, "Waiting for dag run to complete
execution in allowed state.",
+ "dag_id", spec.DagID, "run_id", runID, "allowed_state",
allowed,
+ )
+ if err := env.sleep(ctx, pokeInterval); err != nil {
+ return fail(err.Error())
+ }
+ state, err := client.getDagRunState(ctx, spec.DagID, runID)
+ if err != nil {
+ return fail(err.Error())
+ }
+ if slices.Contains(failed, state) {
+ logger.ErrorContext(ctx, "DagRun finished with failed
state.",
+ "dag_id", spec.DagID, "state", state,
+ )
+ return fail(fmt.Sprintf("%s failed with failed state
%s", spec.DagID, state))
+ }
+ if slices.Contains(allowed, state) {
+ logger.InfoContext(ctx, "DagRun finished with allowed
state.",
+ "dag_id", spec.DagID, "state", state,
+ )
+ return succeedTask()
+ }
+ logger.DebugContext(ctx, "DagRun not yet in allowed or failed
state.",
+ "dag_id", spec.DagID, "state", state,
+ )
+ }
+}
+
+// triggerLogicalDate returns the logical date of the Dag run to trigger, or
the zero Time for a Dag
+// run with none, as TriggerDagRunOperator.execute picks it. It is now only
when the task sets
+// neither LogicalDate nor RunAfter.
+func triggerLogicalDate(spec bundle.TriggerSpec, now time.Time) time.Time {
+ switch {
+ case !spec.LogicalDate.IsZero():
+ return spec.LogicalDate.UTC()
+ case spec.RunAfter.IsZero():
+ return now
+ }
+ return time.Time{}
+}
+
+// triggerIsDeferrable returns whether the task defers, which is
spec.Deferrable or else
+// [operators] default_deferrable.
+func triggerIsDeferrable(spec bundle.TriggerSpec, env triggerEnv) (bool,
error) {
+ if spec.Deferrable != nil {
+ return *spec.Deferrable, nil
+ }
+ return envBool(env.getenv, defaultDeferrableEnv)
+}
+
+// envBool reads a setting that the coordinator exports as True or False. An
unset setting is
+// false, which is Python's fallback for the settings of a trigger task.
+func envBool(getenv func(string) string, name string) (bool, error) {
+ raw := strings.TrimSpace(getenv(name))
+ if raw == "" {
+ return false, nil
+ }
+ value, err := strconv.ParseBool(raw)
+ if err != nil {
+ return false, fmt.Errorf("%s is %q, which is not a boolean; use
True or False", name, raw)
+ }
+ return value, nil
+}
+
+// resumeTriggerDagRun finishes a task that came back from a deferral, as
BaseOperator.resume_execution
+// does: __fail__ fails the task, and execute_complete checks the states of
the Dag runs.
+func resumeTriggerDagRun(
+ nextMethod string,
+ nextKwargs *genmodels.NextKwargs,
+ dagID string,
+ allowed, failed []string,
+ logger *slog.Logger,
+ fail func(string) any,
+) any {
+ var kwargs genmodels.NextKwargs
+ if nextKwargs != nil {
+ kwargs = *nextKwargs
+ }
+ switch nextMethod {
+ case triggerFailMethod:
+ if traceback, ok := kwargs["traceback"].([]any); ok {
+ lines := make([]string, len(traceback))
+ for i, line := range traceback {
+ lines[i] = fmt.Sprint(line)
+ }
+ logger.Error("Trigger failed:\n" + strings.Join(lines,
"\n"))
+ }
+ message := "Unknown"
+ if value, ok := kwargs["error"]; ok && value != nil {
+ message = fmt.Sprint(value)
+ }
+ return fail(message)
+ case executeCompleteMethod:
+ default:
+ return fail(fmt.Sprintf("Task cannot resume with next_method
%q", nextMethod))
+ }
+
+ event, ok := decodeTriggerEvent(kwargs["event"])
+ runIDs, hasRunIDs := event["run_ids"].([]any)
+ if !ok || !hasRunIDs {
+ encoded, _ := json.Marshal(kwargs["event"])
+ return fail("Task resumed with an event it cannot read: " +
string(encoded))
+ }
+ var failedRunIDs []string
+ for _, id := range runIDs {
+ runID := fmt.Sprint(id)
+ state, _ := event[runID].(string)
+ if slices.Contains(failed, state) {
+ failedRunIDs = append(failedRunIDs, runID)
+ } else if slices.Contains(allowed, state) {
+ logger.Info("Triggered Dag run finished with allowed
state.",
+ "dag_id", dagID, "state", state, "run_id",
runID,
+ )
+ }
+ }
+ if len(failedRunIDs) > 0 {
+ states, _ := json.Marshal(failed)
+ runs, _ := json.Marshal(failedRunIDs)
+ return fail(fmt.Sprintf(
+ "%s failed with failed states %s for run_ids %s",
dagID, states, runs,
+ ))
+ }
+ return succeedTask()
+}
+
+// decodeTriggerEvent returns the data of the event that DagStateTrigger
fired. The event is the
+// pair (classpath, data). The triggerer stores it with serde, which writes a
tuple as
+// {"__classname__": "builtins.tuple", "__data__": [...]}.
+func decodeTriggerEvent(event any) (map[string]any, bool) {
+ if wrapped, ok := event.(map[string]any); ok &&
wrapped["__classname__"] == "builtins.tuple" {
+ event = wrapped["__data__"]
+ }
+ pair, ok := event.([]any)
+ if !ok || len(pair) != 2 {
+ return nil, false
+ }
+ data, ok := pair[1].(map[string]any)
+ return data, ok
+}
+
+// pythonIsoformat returns t in UTC as Python's datetime.isoformat() writes
it, such as
+// "2026-09-30T01:02:03.500000+00:00". It writes the microseconds, six digits,
only when they are
+// not zero.
+func pythonIsoformat(t time.Time) string {
+ t = t.UTC()
+ layout := "2006-01-02T15:04:05"
+ if t.Nanosecond()/1000 != 0 {
+ layout += ".000000"
+ }
+ return t.Format(layout) + "+00:00"
+}
+
+// dagRunURL returns the URL of a Dag run in the UI, as
build_airflow_dagrun_url does on
+// [api] base_url. An unset base_url is "/".
+func dagRunURL(baseURL, dagID, runID string) string {
+ if baseURL == "" {
+ baseURL = "/"
+ }
+ return strings.TrimRight(baseURL, "/") + "/dags/" + dagID + "/runs/" +
runID
+}
diff --git a/go-sdk/pkg/execution/trigger_runner_test.go
b/go-sdk/pkg/execution/trigger_runner_test.go
new file mode 100644
index 00000000000..d8cf537012d
--- /dev/null
+++ b/go-sdk/pkg/execution/trigger_runner_test.go
@@ -0,0 +1,648 @@
+// 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 execution
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "log/slog"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/apache/airflow/go-sdk/internal/bundle"
+ "github.com/apache/airflow/go-sdk/pkg/execution/genmodels"
+)
+
+var triggerNow = time.Date(2026, 9, 30, 1, 2, 3, 500000000, time.UTC)
+
+const triggerNowRunID = "manual__2026-09-30T01:02:03.500000+00:00"
+
+// triggerSupervisor answers the requests of a trigger task and keeps them in
order.
+type triggerSupervisor struct {
+ mu sync.Mutex
+ requests []map[string]any
+
+ paused bool
+ // triggerErr is the error code that TriggerDagRun answers with, if any.
+ triggerErr string
+ // states are the answers to GetDagRunState in turn. The last one
repeats.
+ states []string
+}
+
+func (s *triggerSupervisor) answer(req map[string]any) (body, errBody
map[string]any) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.requests = append(s.requests, req)
+ switch req["type"] {
+ case "GetDag":
+ return map[string]any{
+ "type": "DagResult", "dag_id": req["dag_id"],
"is_paused": s.paused,
+ }, nil
+ case "TriggerDagRun":
+ if s.triggerErr != "" {
+ return nil, map[string]any{
+ "type": "ErrorResponse", "error": s.triggerErr,
"detail": map[string]any{},
+ }
+ }
+ case "GetDagRunState":
+ state := s.states[0]
+ if len(s.states) > 1 {
+ s.states = s.states[1:]
+ }
+ return map[string]any{"type": "DagRunStateResult", "state":
state}, nil
+ }
+ return nil, nil
+}
+
+func (s *triggerSupervisor) sent() []map[string]any {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return append([]map[string]any(nil), s.requests...)
+}
+
+// types returns the type of each request that the task sent, with the key of
an XCom.
+func (s *triggerSupervisor) types() []string {
+ var types []string
+ for _, req := range s.sent() {
+ kind := req["type"].(string)
+ if kind == "SetXCom" {
+ kind += ":" + req["key"].(string)
+ }
+ types = append(types, kind)
+ }
+ return types
+}
+
+func (s *triggerSupervisor) request(t *testing.T, kind string) map[string]any {
+ t.Helper()
+ for _, req := range s.sent() {
+ if req["type"] == kind {
+ return req
+ }
+ }
+ require.Failf(t, "request not sent", "the task sent no %s: %v", kind,
s.types())
+ return nil
+}
+
+func (s *triggerSupervisor) comm(t *testing.T) *CoordinatorComm {
+ t.Helper()
+ reqR, reqW := io.Pipe()
+ respR, respW := io.Pipe()
+ t.Cleanup(func() {
+ reqR.Close()
+ reqW.Close()
+ respR.Close()
+ respW.Close()
+ })
+ go func() {
+ for {
+ frame, err := readFrame(reqR)
+ if err != nil {
+ return
+ }
+ body, errBody := s.answer(rawToMap(t, frame.Body))
+ if err := writeFrame(respW, encodeResponseFrame(t,
frame.ID, body, errBody)); err != nil {
+ return
+ }
+ }
+ }()
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ return NewCoordinatorComm(respR, reqW, logger)
+}
+
+// triggerTestEnv replaces the surroundings of a trigger task for the test. It
returns the
+// durations that the task slept.
+func triggerTestEnv(t *testing.T, settings map[string]string) *[]time.Duration
{
+ t.Helper()
+ var slept []time.Duration
+ saved := defaultTriggerEnv
+ defaultTriggerEnv = triggerEnv{
+ now: func() time.Time { return triggerNow },
+ getenv: func(name string) string { return settings[name] },
+ sleep: func(ctx context.Context, d time.Duration) error {
+ slept = append(slept, d)
+ return ctx.Err()
+ },
+ randomSuffix: func() string { return "AbCd1234" },
+ }
+ t.Cleanup(func() { defaultTriggerEnv = saved })
+ return &slept
+}
+
+func runTriggerTask(
+ t *testing.T,
+ spec bundle.TriggerSpec,
+ supervisor *triggerSupervisor,
+ adjust func(*genmodels.StartupDetails),
+) any {
+ t.Helper()
+ b := testBundle{"test_dag": testDag{"trigger":
&bundle.TriggerTask{Spec: spec}}}
+ details := newStartupDetails("trigger")
+ details.TI.Queue = "golang"
+ if adjust != nil {
+ adjust(details)
+ }
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ return RunTask(context.Background(), b, details, supervisor.comm(t),
logger)
+}
+
+func shouldRetry(details *genmodels.StartupDetails) {
details.TIContext.ShouldRetry = true }
+
+func TestTriggerDagRunFireAndForget(t *testing.T) {
+ triggerTestEnv(t, nil)
+ supervisor := &triggerSupervisor{}
+
+ result := runTriggerTask(t, bundle.TriggerSpec{DagID: "downstream"},
supervisor, nil)
+
+ assertSucceedTask(t, result)
+ assert.Equal(t,
+ []string{"SetXCom:_link_TriggerDagRunLink", "TriggerDagRun",
"SetXCom:trigger_run_id"},
+ supervisor.types(),
+ )
+ sent := supervisor.sent()
+ assert.Equal(t, "/dags/downstream/runs/"+triggerNowRunID,
sent[0]["value"])
+ assert.Equal(t, "run1", sent[0]["run_id"])
+ assert.Equal(t, "trigger", sent[0]["task_id"])
+ assert.Equal(t, triggerNowRunID, sent[1]["run_id"])
+ assert.Equal(t, triggerNowRunID, sent[2]["value"])
+}
+
+func TestTriggerDagRunLinkUsesTheBaseURL(t *testing.T) {
+ triggerTestEnv(t, map[string]string{apiBaseURLEnv:
"https://airflow.example.com//"})
+ supervisor := &triggerSupervisor{}
+
+ runTriggerTask(t, bundle.TriggerSpec{DagID: "downstream", RunID: "r1"},
supervisor, nil)
+
+ assert.Equal(t,
+ "https://airflow.example.com/dags/downstream/runs/r1",
+ supervisor.sent()[0]["value"],
+ )
+}
+
+func TestTriggerDagRunSendsTheOptionsAsWritten(t *testing.T) {
+ triggerTestEnv(t, nil)
+ supervisor := &triggerSupervisor{}
+ spec := bundle.TriggerSpec{
+ DagID: "downstream",
+ RunID: "downstream_x",
+ Note: "{{ ds }}",
+ Conf: map[string]any{"day": "{{ ds }}", "rows":
int64(2)},
+ ResetDagRun: true,
+ }
+
+ result := runTriggerTask(t, spec, supervisor, nil)
+
+ assertSucceedTask(t, result)
+ sent := supervisor.request(t, "TriggerDagRun")
+ assert.Equal(t, "downstream_x", sent["run_id"], "an explicit run_id is
not generated")
+ assert.Equal(t, "{{ ds }}", sent["note"])
+ assert.Equal(t, map[string]any{"day": "{{ ds }}", "rows": int8(2)},
sent["conf"])
+ assert.Equal(t, true, sent["reset_dag_run"])
+ assert.Equal(t, "downstream_x", supervisor.sent()[2]["value"])
+}
+
+func TestTriggerDagRunDatesAndRunID(t *testing.T) {
+ custom := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
+ runAfter := time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC)
+ tests := []struct {
+ name string
+ spec bundle.TriggerSpec
+ wantLogicalDate any
+ wantRunAfter any
+ wantRunID string
+ }{
+ {
+ name: "neither set",
+ spec: bundle.TriggerSpec{DagID: "d"},
+ wantLogicalDate: triggerNow,
+ wantRunID: triggerNowRunID,
+ },
+ {
+ name: "only RunAfter set",
+ spec: bundle.TriggerSpec{DagID: "d", RunAfter:
runAfter},
+ wantRunAfter: runAfter,
+ wantRunID:
"manual__2026-02-03T04:05:06+00:00_AbCd1234",
+ },
+ {
+ name: "only LogicalDate set",
+ spec: bundle.TriggerSpec{DagID: "d",
LogicalDate: custom},
+ wantLogicalDate: custom,
+ wantRunID: "manual__2026-01-02T03:04:05+00:00",
+ },
+ {
+ name: "both set",
+ spec: bundle.TriggerSpec{
+ DagID: "d",
+ LogicalDate: custom,
+ RunAfter: runAfter,
+ },
+ wantLogicalDate: custom,
+ wantRunAfter: runAfter,
+ wantRunID: "manual__2026-02-03T04:05:06+00:00",
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ triggerTestEnv(t, nil)
+ supervisor := &triggerSupervisor{}
+
+ runTriggerTask(t, tc.spec, supervisor, nil)
+
+ sent := supervisor.request(t, "TriggerDagRun")
+ assert.Equal(t, tc.wantRunID, sent["run_id"])
+ for key, want := range map[string]any{
+ "logical_date": tc.wantLogicalDate,
"run_after": tc.wantRunAfter,
+ } {
+ if want == nil {
+ assert.NotContains(t, sent, key)
+ continue
+ }
+ got, ok := sent[key].(time.Time)
+ require.True(t, ok, "%s is %T", key, sent[key])
+ assert.True(t, want.(time.Time).Equal(got), "%s
is %v, want %v", key, got, want)
+ }
+ })
+ }
+}
+
+func TestTriggerDagRunAlreadyExists(t *testing.T) {
+ tests := []struct {
+ name string
+ skip bool
+ want genmodels.TaskStateState
+ }{
+ {"skipped", true, genmodels.TaskStateStateSkipped},
+ {"failed", false, genmodels.TaskStateStateFailed},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ triggerTestEnv(t, nil)
+ supervisor := &triggerSupervisor{triggerErr:
"DAGRUN_ALREADY_EXISTS"}
+ spec := bundle.TriggerSpec{DagID: "downstream",
SkipWhenAlreadyExists: tc.skip}
+
+ // A task that may retry still ends in this state, as
in Python.
+ result := runTriggerTask(t, spec, supervisor,
shouldRetry)
+
+ assertTaskState(t, result, tc.want)
+ assert.Equal(t,
+ []string{"SetXCom:_link_TriggerDagRunLink",
"TriggerDagRun"},
+ supervisor.types(), "no trigger_run_id is
pushed for a run that was not created",
+ )
+ })
+ }
+}
+
+func TestTriggerDagRunErrorRetries(t *testing.T) {
+ triggerTestEnv(t, nil)
+ supervisor := &triggerSupervisor{triggerErr: "API_SERVER_ERROR"}
+
+ result := runTriggerTask(t, bundle.TriggerSpec{DagID: "downstream"},
supervisor, shouldRetry)
+ assertRetryTask(t, result, "API_SERVER_ERROR")
+
+ result = runTriggerTask(t, bundle.TriggerSpec{DagID: "downstream"},
supervisor, nil)
+ assertTaskState(t, result, genmodels.TaskStateStateFailed)
+}
+
+func TestTriggerDagRunFailsWhenTheDagIsPaused(t *testing.T) {
+ triggerTestEnv(t, nil)
+ spec := bundle.TriggerSpec{DagID: "downstream", FailWhenDagIsPaused:
true}
+
+ paused := &triggerSupervisor{paused: true}
+ assertRetryTask(t, runTriggerTask(t, spec, paused, shouldRetry), "Dag
downstream is paused")
+ assert.Equal(t, []string{"GetDag"}, paused.types(), "nothing is
triggered")
+
+ running := &triggerSupervisor{}
+ assertSucceedTask(t, runTriggerTask(t, spec, running, nil))
+ assert.Equal(t, "GetDag", running.types()[0])
+ assert.Contains(t, running.types(), "TriggerDagRun")
+
+ ignored := &triggerSupervisor{paused: true}
+ spec.FailWhenDagIsPaused = false
+ assertSucceedTask(t, runTriggerTask(t, spec, ignored, nil))
+ assert.NotContains(t, ignored.types(), "GetDag")
+}
+
+func TestTriggerDagRunPollsUntilAnAllowedState(t *testing.T) {
+ slept := triggerTestEnv(t, nil)
+ supervisor := &triggerSupervisor{states: []string{"queued", "running",
"success"}}
+ spec := bundle.TriggerSpec{DagID: "downstream", WaitForCompletion: true}
+
+ result := runTriggerTask(t, spec, supervisor, nil)
+
+ assertSucceedTask(t, result)
+ assert.Equal(t, []time.Duration{60 * time.Second, 60 * time.Second, 60
* time.Second}, *slept)
+ assert.Equal(t, triggerNowRunID, supervisor.request(t,
"GetDagRunState")["run_id"])
+}
+
+func TestTriggerDagRunPollFailsOnAFailedState(t *testing.T) {
+ slept := triggerTestEnv(t, nil)
+ supervisor := &triggerSupervisor{states: []string{"running", "failed"}}
+ poke := 5 * time.Second
+ spec := bundle.TriggerSpec{DagID: "downstream", WaitForCompletion:
true, PokeInterval: &poke}
+
+ result := runTriggerTask(t, spec, supervisor, shouldRetry)
+
+ assertRetryTask(t, result, "downstream failed with failed state failed")
+ assert.Equal(t, []time.Duration{poke, poke}, *slept)
+
+ supervisor = &triggerSupervisor{states: []string{"failed"}}
+ assertTaskState(t, runTriggerTask(t, spec, supervisor, nil),
genmodels.TaskStateStateFailed)
+}
+
+func TestTriggerDagRunPollHonorsTheStatesOfTheTask(t *testing.T) {
+ triggerTestEnv(t, nil)
+ // No state fails the task, so the failed Dag run is not an allowed
state to wait out.
+ supervisor := &triggerSupervisor{states: []string{"failed", "queued",
"failed", "running"}}
+ spec := bundle.TriggerSpec{
+ DagID: "downstream",
+ WaitForCompletion: true,
+ AllowedStates: []string{"running"},
+ FailedStates: []string{},
+ }
+
+ result := runTriggerTask(t, spec, supervisor, nil)
+
+ assertSucceedTask(t, result)
+ assert.Equal(t, 4, len(supervisor.types())-2-1, "four polls after the
link, trigger and run_id")
+}
+
+func TestTriggerDagRunPollStopsWhenTheTaskIsCancelled(t *testing.T) {
+ triggerTestEnv(t, nil)
+ defaultTriggerEnv.sleep = func(ctx context.Context, d time.Duration)
error {
+ timer := time.NewTimer(d)
+ defer timer.Stop()
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ return nil
+ }
+ }
+ supervisor := &triggerSupervisor{states: []string{"running"}}
+ poke := time.Hour
+ spec := bundle.TriggerSpec{DagID: "downstream", WaitForCompletion:
true, PokeInterval: &poke}
+ b := testBundle{"test_dag": testDag{"trigger":
&bundle.TriggerTask{Spec: spec}}}
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ ctx, cancel := context.WithCancel(context.Background())
+ time.AfterFunc(50*time.Millisecond, cancel)
+
+ done := make(chan any, 1)
+ go func() {
+ done <- RunTask(ctx, b, newStartupDetails("trigger"),
supervisor.comm(t), logger)
+ }()
+
+ select {
+ case result := <-done:
+ assertTaskState(t, result, genmodels.TaskStateStateFailed)
+ case <-time.After(5 * time.Second):
+ t.Fatal("the task kept sleeping after its context was
cancelled")
+ }
+ assert.NotContains(t, supervisor.types(), "GetDagRunState")
+}
+
+func TestTriggerDagRunDefers(t *testing.T) {
+ poke := 90 * time.Second
+ tests := []struct {
+ name string
+ settings map[string]string
+ spec bundle.TriggerSpec
+ wantQueue any
+ wantStates []any
+ wantPoll any
+ }{
+ {
+ name: "defaults, queues off",
+ spec: bundle.TriggerSpec{Deferrable: ptr(true)},
+ wantStates: []any{"success", "failed"},
+ wantPoll: int8(60),
+ },
+ {
+ name: "queues on",
+ settings:
map[string]string{triggererQueuesEnabledEnv: "True"},
+ spec: bundle.TriggerSpec{Deferrable: ptr(true)},
+ wantQueue: "golang",
+ wantStates: []any{"success", "failed"},
+ wantPoll: int8(60),
+ },
+ {
+ name: "default_deferrable, own states and interval",
+ settings: map[string]string{
+ defaultDeferrableEnv: "True",
+ triggererQueuesEnabledEnv: "False",
+ },
+ spec: bundle.TriggerSpec{
+ PokeInterval: &poke,
+ AllowedStates: []string{"success", "queued"},
+ FailedStates: []string{"failed"},
+ },
+ wantStates: []any{"success", "queued", "failed"},
+ wantPoll: int8(90),
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ slept := triggerTestEnv(t, tc.settings)
+ supervisor := &triggerSupervisor{}
+ tc.spec.DagID = "downstream"
+ tc.spec.WaitForCompletion = true
+
+ result := runTriggerTask(t, tc.spec, supervisor, nil)
+
+ assert.Empty(t, *slept, "a deferred task does not poll")
+ assert.NotContains(t, supervisor.types(),
"GetDagRunState")
+ deferred, ok := result.(genmodels.DeferTask)
+ require.True(t, ok, "expected DeferTask, got %T",
result)
+ wire, err := encodeRequest(0, deferred)
+ require.NoError(t, err)
+ var framed bytes.Buffer
+ require.NoError(t, writeFrame(&framed, wire))
+ frame, err := readFrame(&framed)
+ require.NoError(t, err)
+ want := map[string]any{
+ "type": "DeferTask",
+ "state": "deferred",
+ "classpath":
"airflow.providers.standard.triggers.external_task.DagStateTrigger",
+ "next_method": "execute_complete",
+ "next_kwargs": map[string]any{},
+ "trigger_kwargs": map[string]any{
+ "dag_id": "downstream",
+ "states": tc.wantStates,
+ "poll_interval": tc.wantPoll,
+ "run_ids":
[]any{triggerNowRunID},
+ "execution_dates": nil,
+ },
+ }
+ if tc.wantQueue != nil {
+ want["queue"] = tc.wantQueue
+ }
+ assert.Equal(t, want, rawToMap(t, frame.Body))
+ })
+ }
+}
+
+func TestTriggerDagRunDeferrableFalseOverridesTheConfig(t *testing.T) {
+ slept := triggerTestEnv(t, map[string]string{defaultDeferrableEnv:
"True"})
+ supervisor := &triggerSupervisor{states: []string{"success"}}
+ spec := bundle.TriggerSpec{DagID: "downstream", WaitForCompletion:
true, Deferrable: ptr(false)}
+
+ result := runTriggerTask(t, spec, supervisor, nil)
+
+ assertSucceedTask(t, result)
+ assert.Len(t, *slept, 1, "the task polled instead of deferring")
+}
+
+func TestTriggerDagRunIgnoresDeferrableWithoutWaiting(t *testing.T) {
+ triggerTestEnv(t, nil)
+ supervisor := &triggerSupervisor{}
+
+ result := runTriggerTask(t,
+ bundle.TriggerSpec{DagID: "downstream", Deferrable: ptr(true)},
supervisor, nil,
+ )
+
+ assertSucceedTask(t, result)
+}
+
+func TestTriggerDagRunRejectsAnInvalidSetting(t *testing.T) {
+ triggerTestEnv(t, map[string]string{defaultDeferrableEnv: "maybe"})
+ supervisor := &triggerSupervisor{}
+
+ result := runTriggerTask(t, bundle.TriggerSpec{DagID: "downstream"},
supervisor, shouldRetry)
+
+ assertRetryTask(t, result, defaultDeferrableEnv)
+}
+
+func TestTriggerDagRunResume(t *testing.T) {
+ tuple := func(data map[string]any) map[string]any {
+ return map[string]any{
+ "__classname__": "builtins.tuple",
+ "__data__": []any{dagStateTriggerClasspath, data},
+ }
+ }
+ tests := []struct {
+ name string
+ nextMethod string
+ kwargs map[string]any
+ // wantRetry is a substring of the retry reason, or empty when
the task succeeds.
+ wantRetry string
+ }{
+ {
+ name: "allowed state",
+ nextMethod: "execute_complete",
+ kwargs: map[string]any{
+ "event": tuple(map[string]any{"run_ids":
[]any{"r1"}, "r1": "success"}),
+ },
+ },
+ {
+ name: "failed state",
+ nextMethod: "execute_complete",
+ kwargs: map[string]any{
+ "event": tuple(
+ map[string]any{"run_ids": []any{"r1",
"r2"}, "r1": "success", "r2": "failed"},
+ ),
+ },
+ wantRetry: `downstream failed with failed states
["failed"] for run_ids ["r2"]`,
+ },
+ {
+ name: "event without the tuple wrapper",
+ nextMethod: "execute_complete",
+ kwargs: map[string]any{
+ "event": []any{
+ dagStateTriggerClasspath,
+ map[string]any{"run_ids": []any{"r1"},
"r1": "failed"},
+ },
+ },
+ wantRetry: "for run_ids",
+ },
+ {
+ name: "event that cannot be read",
+ nextMethod: "execute_complete",
+ kwargs: map[string]any{"event": "oops"},
+ wantRetry: `Task resumed with an event it cannot read:
"oops"`,
+ },
+ {
+ name: "no event",
+ nextMethod: "execute_complete",
+ wantRetry: "Task resumed with an event it cannot read:
null",
+ },
+ {
+ name: "trigger failed",
+ nextMethod: "__fail__",
+ kwargs: map[string]any{"error": "trigger crashed",
"traceback": []any{"line 1"}},
+ wantRetry: "trigger crashed",
+ },
+ {
+ name: "trigger failed without an error",
+ nextMethod: "__fail__",
+ wantRetry: "Unknown",
+ },
+ {
+ name: "unknown method",
+ nextMethod: "other",
+ wantRetry: `Task cannot resume with next_method
"other"`,
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ triggerTestEnv(t, nil)
+ supervisor := &triggerSupervisor{}
+ resume := func(details *genmodels.StartupDetails) {
+ details.TIContext.ShouldRetry = true
+ details.TIContext.NextMethod = tc.nextMethod
+ if tc.kwargs != nil {
+ kwargs :=
genmodels.NextKwargs(tc.kwargs)
+ details.TIContext.NextKwargs = &kwargs
+ }
+ }
+
+ result := runTriggerTask(t, bundle.TriggerSpec{DagID:
"downstream"}, supervisor, resume)
+
+ if tc.wantRetry == "" {
+ assertSucceedTask(t, result)
+ } else {
+ assertRetryTask(t, result, tc.wantRetry)
+ }
+ assert.Empty(t, supervisor.types(), "a resumed task
triggers nothing")
+ })
+ }
+}
+
+func TestTriggerDagRunResumeUsesTheStatesOfTheTask(t *testing.T) {
+ triggerTestEnv(t, nil)
+ supervisor := &triggerSupervisor{}
+ spec := bundle.TriggerSpec{DagID: "downstream", FailedStates:
[]string{"queued"}}
+ kwargs := genmodels.NextKwargs{"event": []any{
+ dagStateTriggerClasspath, map[string]any{"run_ids":
[]any{"r1"}, "r1": "failed"},
+ }}
+
+ result := runTriggerTask(t, spec, supervisor, func(details
*genmodels.StartupDetails) {
+ details.TIContext.NextMethod = "execute_complete"
+ details.TIContext.NextKwargs = &kwargs
+ })
+
+ assertSucceedTask(t, result)
+}
+
+func TestTriggerTaskExecuteIsNeverCalled(t *testing.T) {
+ err := (&bundle.TriggerTask{}).Execute(context.Background(), nil, nil)
+ assert.ErrorContains(t, err, "run by the runtime")
+}
diff --git a/scripts/ci/lang_sdk_serialization/test_dags.yaml
b/scripts/ci/lang_sdk_serialization/test_dags.yaml
index 174accfdd4f..5d3fbfad8bc 100644
--- a/scripts/ci/lang_sdk_serialization/test_dags.yaml
+++ b/scripts/ci/lang_sdk_serialization/test_dags.yaml
@@ -306,7 +306,7 @@ dags:
- task_id: trigger_all
trigger_dag_run:
trigger_dag_id: conformance_minimal
- trigger_run_id: "triggered_{{ run_id }}"
+ trigger_run_id: "triggered_by_conformance"
conf: {rows: 2, ratio: 0.5, nested: {tags: [a, b]}}
logical_date: "2026-09-30 00:00:00+00:00"
wait_for_completion: true