This is an automated email from the ASF dual-hosted git repository.

henry3260 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 f070997ba63 Go SDK: order tasks with Before, After and Label (#74119)
f070997ba63 is described below

commit f070997ba63e7d3c88eb035bf9398025567e864f
Author: Henry Chen <[email protected]>
AuthorDate: Sun Oct 4 01:15:51 2026 +0800

    Go SDK: order tasks with Before, After and Label (#74119)
    
    * Go SDK: order tasks with Before, After and Label
    
    A Dag authored in Go can now order tasks that exchange no data, the way
    Python's >> and << do, and label the edge that reaches a task.
    
        loaded.Before(notified, cleaned)  // loaded >> [notify, cleanup]
        cleaned.After(extracted)          // cleanup << extracted
    
        loaded.Before(airflow.Label(emptyNotice, "when empty"))
    
    Both verbs are variadic, so one call fans out, and both return their
    argument set as one Node, so a.Before(b, c).Before(d) is a >> [b, c] >> d.
    
    airflow.Label wraps the endpoint rather than the call, so each edge of a
    fan-out can carry a label of its own. Declaring an edge the Dag already
    has only applies the label, which is how an airflow.Inputs edge gets one.
    
    * Go SDK: check an edge verb's tasks even when it points at none
    
    Before and After with no node declare no edge, but they now still reject a
    Dag that has been registered and a *TaskRef that DagRef.Task did not
    return, so a mistake in the receiver is not hidden by an empty fan-out.
    
    The label a verb puts on an edge is now settled in one place, mergeLabel,
    which both the check over the call's pairs and the recording step use. An
    edge verb enumerates its pairs once and records what it checked, so the
    checks and the recording cannot drift apart, and addEdgeLocked keeps only
    the cycle check, which cannot run up front.
    
    An edge given two labels in one call now says so rather than reporting the
    first as already on the edge, which no earlier declaration had put there.
    
    * Go SDK: let a second edge label replace the first, as Python does
    
    Declaring a label on an edge that already carries one now replaces it,
    which is what Python's DAG.set_edge_info does: "this will overwrite,
    rather than merge with, existing info". It used to panic, a rule the Go
    SDK had of its own.
    
    A label on the receiver of an edge verb is still dropped, since Label
    marks the edge that reaches a node and the receiver is the node an edge
    leaves from. Python is silent there too: Label("x") >> b, with nothing
    upstream of the label, sets no label either. Tests now pin both, and the
    nesting that Label(Label(x, "inner"), "outer") reads as.
    
    * Go SDK: cover the cycle an Inputs edge can close
    
    DagRef.Task records the edges Inputs declares so that the cycle check sees
    them, which is the case ADR-0008 names: b := dag.Task(B, Inputs(a)) then
    b.Before(a) is a genuine cycle in accepted syntax. Only the upstream and
    downstream lists were asserted, so deleting that recording failed no test
    over the cycle itself.
    
    * Go SDK: reject a Dag's cycles at Register, over the whole graph
    
    Before, After and Inputs each walked the graph to see whether the edge they
    were recording closed a cycle, which made building a Dag cost one walk per
    edge: a 8,000-task chain declared in reverse took seconds. Register now
    walks the graph once, after the Dag is whole, as the Java SDK's
    Bundle.register does and the TypeScript SDK plans to. The same chain builds
    in tens of milliseconds.
    
    Recording an edge no longer panics, so a fan-out that is rejected leaves the
    Dag exactly as it was, and the caveat about a cycle keeping the earlier
    edges of its call is gone with it. A Dag whose dependencies contain a cycle
    stays unregistered, so it can still be corrected.
    
    A task ordered against itself is still rejected where it is declared: that
    needs no walk.
    
    * Go SDK: order a condition's Then and Else tasks after it
    
    IfRef.Then and IfRef.Else stored the task on the condition without recording
    an edge, so the ordering their doc states, that the two run after the
    condition, was in no Dag the SDK builds: the task had no upstream, and a
    cycle running through a condition passed Register.
    
            read := dag.Task(readRows)
            loaded := dag.Task(load)
            dag.If(hasRows, airflow.Inputs(read)).Then(loaded)
            loaded.Before(read) // read -> hasRows -> load -> read
    
    Naming a task now records the edge from the condition to it, which is also
    the upstream the serialized Dag needs. Declaring that edge again, as an
    author who writes it out does, changes nothing.
---
 go-sdk/airflow/bundle.go                    |  12 +-
 go-sdk/airflow/bundle_test.go               |  54 +++
 go-sdk/airflow/dag.go                       |  29 +-
 go-sdk/airflow/if.go                        |   7 +
 go-sdk/airflow/if_test.go                   |  50 +++
 go-sdk/airflow/node.go                      | 383 ++++++++++++++++++++
 go-sdk/airflow/node_test.go                 | 538 ++++++++++++++++++++++++++++
 go-sdk/airflow/testdata/foreignnode/main.go |  39 ++
 8 files changed, 1105 insertions(+), 7 deletions(-)

diff --git a/go-sdk/airflow/bundle.go b/go-sdk/airflow/bundle.go
index 91b2773fd75..d100231d832 100644
--- a/go-sdk/airflow/bundle.go
+++ b/go-sdk/airflow/bundle.go
@@ -79,12 +79,16 @@ type Registerable interface{ registerable() }
 // Add every task to a Dag before registering the Dag. [DagRef.Task], 
[DagRef.If], [IfRef.Then]
 // and [IfRef.Else] panic once the Dag is registered.
 //
+// Register is where a Dag's task dependencies are checked for a cycle, over 
the whole graph at
+// once: [TaskRef.Before], [TaskRef.After] and [Inputs] each record an edge 
without walking the
+// graph, so building a Dag stays linear in its edges however many a task has.
+//
 // Register panics if a task handler with the same dag_id and task_id is 
already registered,
 // if a Dag with the same dag_id is already registered, if a task handler and 
a Dag have the
-// same dag_id, or if [BundleRef.Serve] has already been called: registration 
closes when
-// serving starts. A task handler runs a task of a Python Dag, so its dag_id 
cannot also belong
-// to a Dag authored in Go. Register also panics if a Dag has a condition from 
[DagRef.If]
-// without a task from [IfRef.Then].
+// same dag_id, if the task dependencies of a Dag contain a cycle, or if 
[BundleRef.Serve] has
+// already been called: registration closes when serving starts. A task 
handler runs a task of a
+// Python Dag, so its dag_id cannot also belong to a Dag authored in Go. 
Register also panics if a
+// Dag has a condition from [DagRef.If] without a task from [IfRef.Then].
 func (b *BundleRef) Register(items ...Registerable) {
        if b.closed.Load() {
                panic(
diff --git a/go-sdk/airflow/bundle_test.go b/go-sdk/airflow/bundle_test.go
index 31a654c453f..7a5874e29b4 100644
--- a/go-sdk/airflow/bundle_test.go
+++ b/go-sdk/airflow/bundle_test.go
@@ -217,6 +217,60 @@ func TestRegisterRejectsATaskHandlerWithTheDagIDOfADag(t 
*testing.T) {
        }
 }
 
+// TestRegisterRejectsACycle covers the check that reads a Dag's edges as a 
whole. Before and
+// After record an edge without walking the graph, so a cycle between other 
tasks is Register's to
+// find, and every edge of the call that closed it is recorded until then.
+func TestRegisterRejectsACycle(t *testing.T) {
+       dag := Dag("etl")
+       extracted := orderedTask(t, dag, "extract")
+       loaded := orderedTask(t, dag, "load")
+       notified := orderedTask(t, dag, "notify")
+       extracted.Before(loaded)
+       loaded.Before(notified, extracted)
+
+       assert.PanicsWithValue(t,
+               `airflow.BundleRef.Register: the task dependencies of Dag "etl" 
contain a cycle: `+
+                       `extract -> load -> extract`,
+               func() { Bundle().Register(dag) },
+       )
+       // The verbs recorded what they were given, and the Dag can still be 
corrected.
+       assertTasks(t, loaded.downstreams, notified, extracted)
+       assert.False(t, dag.registered)
+}
+
+// TestRegisterRejectsACycleThroughAnInputsEdge pins that the check sees the 
edges Inputs
+// declared, which is what DagRef.Task recording them is for. It is the case 
ADR-0008 names:
+// b := dag.Task(B, Inputs(a)) followed by b.Before(a) is a genuine cycle in 
accepted syntax.
+func TestRegisterRejectsACycleThroughAnInputsEdge(t *testing.T) {
+       dag := Dag("etl")
+       read := dag.Task(readRows)
+       counted := dag.Task(countRows, Inputs(read))
+       notified := orderedTask(t, dag, "notify")
+       counted.Before(notified)
+       counted.Before(read)
+
+       assert.PanicsWithValue(t,
+               `airflow.BundleRef.Register: the task dependencies of Dag "etl" 
contain a cycle: `+
+                       `readRows -> countRows -> readRows`,
+               func() { Bundle().Register(dag) },
+       )
+}
+
+// TestRegisterTakesADagWhoseTasksShareADownstream pins that the walk follows 
a diamond, where a
+// task is reached twice without any cycle.
+func TestRegisterTakesADagWhoseTasksShareADownstream(t *testing.T) {
+       dag := Dag("etl")
+       extracted := orderedTask(t, dag, "extract")
+       notified := orderedTask(t, dag, "notify")
+       cleaned := orderedTask(t, dag, "cleanup")
+       done := orderedTask(t, dag, "done")
+       extracted.Before(notified, cleaned).Before(done)
+
+       Bundle().Register(dag)
+
+       assert.True(t, dag.registered)
+}
+
 func TestRegisterRejectsNilDag(t *testing.T) {
        var dag *DagRef
        assert.PanicsWithValue(t, "airflow.BundleRef.Register: cannot register 
a nil *airflow.DagRef",
diff --git a/go-sdk/airflow/dag.go b/go-sdk/airflow/dag.go
index bbfbf022dc6..8a45c867eb4 100644
--- a/go-sdk/airflow/dag.go
+++ b/go-sdk/airflow/dag.go
@@ -38,6 +38,10 @@ type DagRef struct {
        registered bool
        tasks      []*TaskRef
        tasksByID  map[string]*TaskRef
+       // edgeLabels holds every edge of the Dag, whether Inputs, Before or 
After declared it, and
+       // the label that Label put on it. An edge with no label maps to the 
empty string, so a
+       // lookup reports whether the edge has been declared.
+       edgeLabels map[edgeKey]string
 }
 
 // Dag returns an empty Dag with the given dag_id. An optional [DagSpec] holds 
the rest of the
@@ -74,7 +78,8 @@ func (*DagRef) registerable() {}
 
 // TaskRef is a task that [DagRef.Task] added to a Dag. Pass it to [Inputs] to 
give its result
 // to a task that DagRef.Task or [DagRef.If] adds later. Pass it to 
[IfRef.Then] or [IfRef.Else]
-// to run it on one side of a condition.
+// to run it on one side of a condition. A TaskRef is a [Node], so 
[TaskRef.Before] and
+// [TaskRef.After] order it against another task.
 type TaskRef struct {
        dag    *DagRef
        taskID string
@@ -85,7 +90,12 @@ type TaskRef struct {
        // inputs holds the tasks that Inputs passed, in the order of the 
parameters they fill. Each
        // of them is an upstream task of this one.
        inputs []*TaskRef
-       task   bundle.Task
+       // upstreams and downstreams hold the edges of the task, in the order 
they were declared and
+       // without a repeat, so that an edge is recorded in both directions. 
Inputs, Before and After
+       // all record an edge here.
+       upstreams   []*TaskRef
+       downstreams []*TaskRef
+       task        bundle.Task
        // triggerDagRun is the checked copy of the TriggerDagRunSpec of a task 
from TriggerDagRun.
        // It is nil for a task that runs a Go function. A task from 
TriggerDagRun runs no Go
        // function, so its resultType, inputs and task are nil.
@@ -270,11 +280,18 @@ func (d *DagRef) addTask(method string, fn any, opts 
[]TaskOption, ifRef *IfRef)
        }
        d.tasksByID[taskID] = task
        d.tasks = append(d.tasks, task)
+       // Inputs passes a task once per parameter it fills, so the same task 
can arrive twice. The
+       // edge is one either way, and the task is new, so no edge to it 
carries a label to settle.
+       for _, upstream := range upstreams {
+               d.addEdgeLocked(upstream, task, "")
+       }
        return task
 }
 
 // markRegistered marks d as registered, which stops any further change to d. 
It panics instead
-// when a condition from If has no task from Then.
+// when a condition from If has no task from Then, or when the edges of d 
close a cycle. The Dag
+// is whole by then, so one walk of the graph answers for every edge its tasks 
declared, and a Dag
+// that fails a check stays unregistered and can still be corrected.
 func (d *DagRef) markRegistered() {
        d.mu.Lock()
        defer d.mu.Unlock()
@@ -288,6 +305,12 @@ func (d *DagRef) markRegistered() {
                        ))
                }
        }
+       if cycle := d.cycleLocked(); cycle != nil {
+               panic(fmt.Sprintf(
+                       "airflow.BundleRef.Register: the task dependencies of 
Dag %q contain a cycle: %s",
+                       d.dagID, strings.Join(cycle, " -> "),
+               ))
+       }
        d.registered = true
 }
 
diff --git a/go-sdk/airflow/if.go b/go-sdk/airflow/if.go
index 2e49f7409d6..2d5b79d7912 100644
--- a/go-sdk/airflow/if.go
+++ b/go-sdk/airflow/if.go
@@ -64,6 +64,9 @@ type IfRef struct {
 //   - false skips the task from Then
 //   - an error fails the task, which then skips nothing
 //
+// A task from Then or Else runs after the condition, so naming it records an 
edge from the
+// condition to it, as [TaskRef.Before] would. Declaring that edge as well 
changes nothing.
+//
 // The condition skips only that one task. In Python, a branch skips every 
task directly after it
 // that it does not follow, and a short circuit skips every task after it. 
After the skip, the
 // trigger rule of each task after the skipped task decides whether that task 
runs. A task that
@@ -172,6 +175,10 @@ func (g *IfRef) setTask(side string, task *TaskRef) {
                ))
        }
        *slot = task
+       // The task runs after the condition, so it is a downstream task of it. 
Recording the edge is
+       // what puts the condition in the serialized Dag as the task's 
upstream, and what lets
+       // registration see a cycle that runs through a condition.
+       d.addEdgeLocked(g.task, task, "")
 }
 
 // wrapCondition wraps fn as the task of g. The task skips the side of g that 
the result of fn
diff --git a/go-sdk/airflow/if_test.go b/go-sdk/airflow/if_test.go
index cf51c330b1e..2f5730547c3 100644
--- a/go-sdk/airflow/if_test.go
+++ b/go-sdk/airflow/if_test.go
@@ -259,6 +259,39 @@ func TestThenAndElseNameTheTasksOfTheCondition(t 
*testing.T) {
        assert.Same(t, reported, reversed.elseTask)
 }
 
+// TestThenAndElseRecordTheEdgeFromTheCondition pins that naming a task orders 
it after the
+// condition, which is what puts the condition in the serialized Dag as the 
task's upstream.
+func TestThenAndElseRecordTheEdgeFromTheCondition(t *testing.T) {
+       dag := Dag("etl")
+       loaded := dag.Task(load)
+       reported := dag.Task(reportEmpty)
+
+       gate := dag.If(isReady)
+       gate.Then(loaded)
+       gate.Else(reported)
+
+       assertTasks(t, gate.task.downstreams, loaded, reported)
+       assertTasks(t, loaded.upstreams, gate.task)
+       assertTasks(t, reported.upstreams, gate.task)
+       assertEdgeLabel(t, dag, "isReady", "load", "")
+       assertEdgeLabel(t, dag, "isReady", "reportEmpty", "")
+}
+
+// TestDeclaringTheEdgeOfAConditionAgainChangesNothing covers an author who 
also writes the edge
+// that Then records, which is as idempotent as declaring any edge twice.
+func TestDeclaringTheEdgeOfAConditionAgainChangesNothing(t *testing.T) {
+       dag := Dag("etl")
+       loaded := dag.Task(load)
+
+       gate := dag.If(isReady)
+       gate.Then(loaded)
+       gate.task.Before(Label(loaded, "when ready"))
+
+       assertTasks(t, gate.task.downstreams, loaded)
+       assertTasks(t, loaded.upstreams, gate.task)
+       assertEdgeLabel(t, dag, "isReady", "load", "when ready")
+}
+
 func TestThenAndElseRejectATaskOutsideTheDag(t *testing.T) {
        dag := Dag("etl")
        loaded := dag.Task(load)
@@ -485,6 +518,23 @@ func runCondition(gate *IfRef, results, earlier 
map[string]any) (*conditionClien
        return client, gate.task.task.Execute(ctx, discardLogger(), args)
 }
 
+// TestRegisterRejectsACycleThroughACondition pins that the edge Then records 
is one the cycle
+// check walks, so a cycle that runs through a condition is rejected like any 
other.
+func TestRegisterRejectsACycleThroughACondition(t *testing.T) {
+       dag := Dag("etl")
+       read := dag.Task(readRows)
+       loaded := dag.Task(load)
+       dag.If(hasRows, Inputs(read)).Then(loaded)
+       loaded.Before(read)
+
+       assert.PanicsWithValue(t,
+               `airflow.BundleRef.Register: the task dependencies of Dag "etl" 
contain a cycle: `+
+                       `readRows -> hasRows -> load -> readRows`,
+               func() { Bundle().Register(dag) },
+       )
+       assert.False(t, dag.registered)
+}
+
 func TestConditionSkipsTheSideThatItDoesNotTake(t *testing.T) {
        tests := []struct {
                name     string
diff --git a/go-sdk/airflow/node.go b/go-sdk/airflow/node.go
new file mode 100644
index 00000000000..6b0cefab74d
--- /dev/null
+++ b/go-sdk/airflow/node.go
@@ -0,0 +1,383 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package airflow
+
+import (
+       "fmt"
+       "slices"
+)
+
+// Node is what an edge between tasks connects. A [TaskRef] is one, so the 
task that
+// [DagRef.Task] returns is an edge endpoint.
+//
+// Before and After declare the order-only edges that Python writes with >> 
and <<, for tasks
+// that have to run in an order but pass no data. [Inputs] declares the edge 
that carries a
+// result.
+//
+// Both verbs are variadic, so one call fans out, and both return their 
argument set as one Node
+// rather than the receiver. That is what makes a chain run the way it reads:
+//
+//     loaded.Before(notified, cleaned)       // loaded >> [notify, cleanup]
+//     cleaned.After(extracted)               // cleanup << extracted
+//     extracted.Before(loaded).Before(done)  // extract >> load >> done
+//
+// The second Before above starts from load, not from extract.
+//
+// Node is the Go counterpart of Python's DAGNode, where set_upstream and 
set_downstream live.
+// Its node method is unexported, so a type declared outside package airflow 
can be a Node only by
+// embedding one, and Before and After reject such a type as an argument.
+type Node interface {
+       // Before makes the receiver an upstream task of every node, as 
Python's >> does.
+       Before(nodes ...Node) Node
+       // After makes the receiver a downstream task of every node, as 
Python's << does.
+       After(nodes ...Node) Node
+       // node seals the interface. Only a type that package airflow declares 
is a Node, so
+       // endpoints can read what one stands for.
+       node()
+}
+
+// nodeEndpoint is one task that a Node stands for, with the label that 
[Label] carries into the
+// edge verb the Node is passed to.
+type nodeEndpoint struct {
+       task  *TaskRef
+       label string
+}
+
+// nodeSet is the argument set that Before and After return as one Node, and 
the Node that
+// [Label] returns. A label belongs to the edge of the verb the Node is passed 
to, so a nodeSet
+// carries its labels no further once it is the receiver of the next verb.
+type nodeSet []nodeEndpoint
+
+func (nodeSet) node() {}
+
+// unlabelled returns the set with its labels dropped. A label belongs to the 
one verb it was
+// passed to, so the Node a verb returns, which stands for the tasks it 
pointed at, carries none.
+func (s nodeSet) unlabelled() nodeSet {
+       plain := make(nodeSet, len(s))
+       for i, endpoint := range s {
+               plain[i] = nodeEndpoint{task: endpoint.task}
+       }
+       return plain
+}
+
+func (s nodeSet) Before(nodes ...Node) Node { return declareEdges(s, nodes, 
dirBefore) }
+
+func (s nodeSet) After(nodes ...Node) Node { return declareEdges(s, nodes, 
dirAfter) }
+
+func (*TaskRef) node() {}
+
+// endpoints returns the tasks that node stands for. The Node method is 
unexported, so the types
+// of this package are the only Nodes, and a struct that embeds one is all 
that reaches the
+// default case. where names the Node in a panic, such as 
"airflow.Node.Before: nodes[0]".
+func endpoints(where string, node Node) []nodeEndpoint {
+       switch node := node.(type) {
+       case *TaskRef:
+               return []nodeEndpoint{{task: node}}
+       case nodeSet:
+               return node
+       default:
+               panic(fmt.Sprintf(
+                       "%s has type %T, which is not a node that package 
airflow defines", where, node,
+               ))
+       }
+}
+
+// Before makes the task an upstream task of every node, which is Python's
+// loaded >> [notify, cleanup]:
+//
+//     loaded.Before(notified, cleaned)
+//
+// The edge carries no data, so a task that only comes after another takes no 
parameter for it.
+// Use [Inputs] for an edge that passes a result.
+//
+// Before returns the nodes it was given as one Node, not the receiver, so a 
chain fans out from
+// them: loaded.Before(notified, cleaned).Before(done) is loaded >> [notify, 
cleanup] >> done.
+// Wrap a node in [Label] to label the edge that reaches it.
+//
+// Declaring an edge that the Dag already has changes nothing, other than to 
apply a [Label].
+//
+// Before panics if:
+//   - a node is nil, or is a task that [DagRef.Task] did not return
+//   - a node belongs to another Dag
+//   - the edge would make a task depend on itself
+//   - the Dag is already registered
+//
+// Every check runs before the call records any edge, so a fan-out that panics 
leaves the Dag as
+// it was. Edges that close a cycle between other tasks are 
[BundleRef.Register]'s to reject,
+// over the whole graph at once.
+func (t *TaskRef) Before(nodes ...Node) Node { return declareEdges(t, nodes, 
dirBefore) }
+
+// After makes the task a downstream task of every node, which is Python's 
cleanup << extracted:
+//
+//     cleaned.After(extracted)
+//
+// It is Before with the direction reversed, and it panics for the same 
reasons. Like Before, it
+// returns the nodes it was given as one Node, so 
cleaned.After(extracted).After(started) is
+// cleanup << extract << start.
+func (t *TaskRef) After(nodes ...Node) Node { return declareEdges(t, nodes, 
dirAfter) }
+
+// Label puts text on the edge that an edge verb declares to node, as Python's
+// loaded >> Label("when empty") >> notify_empty does:
+//
+//     loaded.Before(Label(emptyNotice, "when empty"))
+//
+// The label wraps the endpoint rather than the call, so each edge of a 
fan-out can carry a label
+// of its own:
+//
+//     checked.Before(Label(processed, "rows found"), Label(emptyNotice, "no 
rows"))
+//
+// A label is the Node's only in the verb it is passed to. The Node that Label 
returns stands for
+// node itself, and the Node a verb returns carries no label on either side of 
the next verb. So a
+// label on the receiver of a verb has no edge to land on and is dropped, the 
way Python's
+// Label("x") >> b alone sets no label.
+//
+// A label declared on an edge that already carries one replaces it, as 
Python's DAG.set_edge_info
+// does.
+//
+// An [Inputs] edge is labelled by declaring it again, which is idempotent:
+//
+//     transformed := dag.Task(transform, Inputs(extracted))
+//     extracted.Before(Label(transformed, "rows"))
+//
+// Label panics if node is nil or text is empty.
+func Label(node Node, text string) Node {
+       if node == nil {
+               panic("airflow.Label: got a nil airflow.Node")
+       }
+       if text == "" {
+               panic("airflow.Label: got an empty label; pass the text to put 
on the edge")
+       }
+       labelling := endpoints("airflow.Label: node", node)
+       labelled := make(nodeSet, len(labelling))
+       for i, endpoint := range labelling {
+               labelled[i] = nodeEndpoint{task: endpoint.task, label: text}
+       }
+       return labelled
+}
+
+// edgeKey identifies an edge of a Dag. The task_ids of a Dag are unique, so 
they name the ends.
+type edgeKey struct{ upstream, downstream string }
+
+// edgeDir is which way an edge verb points: [TaskRef.Before] from its 
receiver, and
+// [TaskRef.After] at it.
+type edgeDir int
+
+const (
+       dirBefore edgeDir = iota
+       dirAfter
+)
+
+func (dir edgeDir) String() string {
+       if dir == dirAfter {
+               return "After"
+       }
+       return "Before"
+}
+
+// order returns the two ends of the edge between an endpoint of the receiver 
and one of the nodes
+// the verb was given.
+func (dir edgeDir) order(recv, arg *TaskRef) (upstream, downstream *TaskRef) {
+       if dir == dirAfter {
+               return arg, recv
+       }
+       return recv, arg
+}
+
+// pendingEdge is an edge that declareEdges has checked and is about to record.
+type pendingEdge struct {
+       upstream, downstream *TaskRef
+       key                  edgeKey
+       label                string
+}
+
+// declareEdges records an edge from every endpoint of receiver to every node 
it was given, or
+// the other way round for After, and returns those nodes as one Node.
+func declareEdges(receiver Node, nodes []Node, dir edgeDir) Node {
+       where := "airflow.Node." + dir.String()
+       args := make(nodeSet, 0, len(nodes))
+       for i, node := range nodes {
+               if node == nil {
+                       panic(fmt.Sprintf("%s: nodes[%d] is a nil 
airflow.Node", where, i))
+               }
+               args = append(args, endpoints(fmt.Sprintf("%s: nodes[%d]", 
where, i), node)...)
+       }
+       ends := endpoints(where+": the receiver", receiver)
+       all := slices.Concat(ends, args)
+       if len(all) == 0 {
+               return nodeSet(nil)
+       }
+
+       dag := edgeDag(where, all)
+
+       dag.mu.Lock()
+       defer dag.mu.Unlock()
+
+       if dag.registered {
+               panic(fmt.Sprintf(
+                       "%s: Dag %q has already been registered; declare every 
edge before Register",
+                       where, dag.dagID,
+               ))
+       }
+       for _, endpoint := range all {
+               // A zero TaskRef and a copy of a TaskRef get here.
+               if dag.tasksByID[endpoint.task.taskID] != endpoint.task {
+                       panic(fmt.Sprintf(
+                               "%s: Dag %q got a *airflow.TaskRef that 
DagRef.Task did not return",
+                               where, dag.dagID,
+                       ))
+               }
+       }
+       // A verb with no node to point at, which a spread of an empty slice 
reaches, declares no
+       // edge. So does one on the empty set that such a verb returned.
+       if len(ends) == 0 || len(args) == 0 {
+               return args.unlabelled()
+       }
+
+       // Check and merge every pair the call declares before it records any 
of them.
+       pending := make([]pendingEdge, 0, len(ends)*len(args))
+       at := make(map[edgeKey]int, len(ends)*len(args))
+       for _, end := range ends {
+               for _, arg := range args {
+                       upstream, downstream := dir.order(end.task, arg.task)
+                       if upstream == downstream {
+                               panic(fmt.Sprintf(
+                                       "%s: Dag %q: task %q cannot depend on 
itself",
+                                       where, dag.dagID, upstream.taskID,
+                               ))
+                       }
+                       key := edgeKey{upstream: upstream.taskID, downstream: 
downstream.taskID}
+                       i, declared := at[key]
+                       if !declared {
+                               i = len(pending)
+                               at[key] = i
+                               pending = append(pending, pendingEdge{
+                                       upstream: upstream, downstream: 
downstream, key: key,
+                                       label: dag.edgeLabels[key],
+                               })
+                       }
+                       // The label belongs to the node the verb was given, in 
either direction.
+                       pending[i].label = mergeLabel(pending[i].label, 
arg.label)
+               }
+       }
+       for _, edge := range pending {
+               dag.addEdgeLocked(edge.upstream, edge.downstream, edge.label)
+       }
+       return args.unlabelled()
+}
+
+// mergeLabel returns the label an edge carries once label is declared on it. 
A declaration that
+// carries no label leaves the edge's own label alone, which is what makes 
redeclaring an edge
+// idempotent, and one that carries a label overwrites it, as Python's 
DAG.set_edge_info does.
+func mergeLabel(declared, label string) string {
+       if label == "" {
+               return declared
+       }
+       return label
+}
+
+// edgeDag returns the Dag that every end of an edge belongs to. It panics 
unless each end is a
+// task of that one Dag.
+func edgeDag(where string, ends []nodeEndpoint) *DagRef {
+       var first *TaskRef
+       for _, end := range ends {
+               task := end.task
+               switch {
+               case task == nil:
+                       panic(fmt.Sprintf("%s: got a nil *airflow.TaskRef", 
where))
+               case task.dag == nil:
+                       panic(fmt.Sprintf(
+                               "%s: got a *airflow.TaskRef that DagRef.Task 
did not return", where,
+                       ))
+               case first == nil:
+                       first = task
+               case task.dag != first.dag:
+                       panic(fmt.Sprintf(
+                               "%s: cannot declare an edge between task %q of 
Dag %q and task %q of Dag %q; "+
+                                       "an edge connects tasks of one Dag",
+                               where, first.taskID, first.dag.dagID, 
task.taskID, task.dag.dagID,
+                       ))
+               }
+       }
+       return first.dag
+}
+
+// addEdgeLocked records one edge of d, which the caller holds d.mu for. An 
edge d already has is
+// recorded once, and label is what it carries from here on, so a caller that 
declares an edge
+// again settles the label with [mergeLabel] first. Whether the edges of a Dag 
close a cycle is
+// [BundleRef.Register]'s to answer, over the whole graph at once.
+func (d *DagRef) addEdgeLocked(upstream, downstream *TaskRef, label string) {
+       if d.edgeLabels == nil {
+               d.edgeLabels = make(map[edgeKey]string)
+       }
+       key := edgeKey{upstream: upstream.taskID, downstream: downstream.taskID}
+       if _, exists := d.edgeLabels[key]; !exists {
+               upstream.downstreams = append(upstream.downstreams, downstream)
+               downstream.upstreams = append(downstream.upstreams, upstream)
+       }
+       d.edgeLabels[key] = label
+}
+
+// cycleLocked returns the task_ids on a cycle of the Dag, closed by the task 
it starts from
+// again, and nil when the Dag is acyclic. The caller holds d.mu. Registration 
calls it once, so
+// building a Dag walks the graph once rather than once per edge.
+func (d *DagRef) cycleLocked() []string {
+       const (
+               unvisited = iota
+               onPath
+               settled
+       )
+       state := make(map[*TaskRef]int, len(d.tasks))
+       var path []*TaskRef
+       // walk is (non-tailrec-eligible) recursive, so a Dag whose 
dependencies nest deeper than the
+       // stack takes would overflow it. Airflow's own Dag serialization 
recurses over a Dag too.
+       var walk func(task *TaskRef) []string
+       walk = func(task *TaskRef) []string {
+               state[task] = onPath
+               path = append(path, task)
+               for _, downstream := range task.downstreams {
+                       switch state[downstream] {
+                       case onPath:
+                               // The cycle is the path from downstream 
onwards, closed by downstream again.
+                               return append(taskIDs(path[slices.Index(path, 
downstream):]), downstream.taskID)
+                       case unvisited:
+                               if cycle := walk(downstream); cycle != nil {
+                                       return cycle
+                               }
+                       }
+               }
+               path = path[:len(path)-1]
+               state[task] = settled
+               return nil
+       }
+       for _, task := range d.tasks {
+               if state[task] == unvisited {
+                       if cycle := walk(task); cycle != nil {
+                               return cycle
+                       }
+               }
+       }
+       return nil
+}
+
+func taskIDs(tasks []*TaskRef) []string {
+       ids := make([]string, len(tasks))
+       for i, task := range tasks {
+               ids[i] = task.taskID
+       }
+       return ids
+}
diff --git a/go-sdk/airflow/node_test.go b/go-sdk/airflow/node_test.go
new file mode 100644
index 00000000000..e284a9f092d
--- /dev/null
+++ b/go-sdk/airflow/node_test.go
@@ -0,0 +1,538 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package airflow
+
+import (
+       "fmt"
+       "os"
+       "os/exec"
+       "reflect"
+       "sync"
+       "testing"
+
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+// Embedding promotes node, so this struct compiles as a Node.
+type wrappedNode struct{ Node }
+
+// orderedTask adds a task that passes no data, which is what an order-only 
edge connects.
+func orderedTask(t *testing.T, dag *DagRef, taskID string) *TaskRef {
+       t.Helper()
+       return dag.Task(ping, TaskSpec{TaskID: taskID})
+}
+
+func assertTasks(t *testing.T, got []*TaskRef, want ...*TaskRef) {
+       t.Helper()
+       require.Len(t, got, len(want))
+       for i := range want {
+               assert.Same(t, want[i], got[i], "task %d", i)
+       }
+}
+
+func assertEdgeLabel(t *testing.T, dag *DagRef, upstream, downstream, want 
string) {
+       t.Helper()
+       label, declared := dag.edgeLabels[edgeKey{upstream: upstream, 
downstream: downstream}]
+       require.True(t, declared, "the edge %s -> %s was not declared", 
upstream, downstream)
+       assert.Equal(t, want, label)
+}
+
+func TestBeforeDeclaresTheEdgeInBothDirections(t *testing.T) {
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+       cleaned := orderedTask(t, dag, "cleanup")
+
+       loaded.Before(cleaned)
+
+       assertTasks(t, loaded.downstreams, cleaned)
+       assertTasks(t, cleaned.upstreams, loaded)
+       assert.Empty(t, loaded.upstreams)
+       assert.Empty(t, cleaned.downstreams)
+       assertEdgeLabel(t, dag, "load", "cleanup", "")
+}
+
+func TestAfterDeclaresTheEdgeBeforeWouldDeclare(t *testing.T) {
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+       cleaned := orderedTask(t, dag, "cleanup")
+
+       cleaned.After(loaded)
+
+       assertTasks(t, loaded.downstreams, cleaned)
+       assertTasks(t, cleaned.upstreams, loaded)
+}
+
+func TestEdgeVerbsFanOut(t *testing.T) {
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+       notified := orderedTask(t, dag, "notify")
+       cleaned := orderedTask(t, dag, "cleanup")
+
+       loaded.Before(notified, cleaned)
+
+       assertTasks(t, loaded.downstreams, notified, cleaned)
+       assertTasks(t, notified.upstreams, loaded)
+       assertTasks(t, cleaned.upstreams, loaded)
+}
+
+// TestBeforeReturnsItsArgumentSet pins what makes a chain work. Before 
returns the nodes it was
+// given, so a.Before(b, c).Before(d) is a >> [b, c] >> d. Returning the 
receiver would read like
+// a chain and mean a second fan-out from a.
+func TestBeforeReturnsItsArgumentSet(t *testing.T) {
+       dag := Dag("etl")
+       extracted := orderedTask(t, dag, "extract")
+       notified := orderedTask(t, dag, "notify")
+       cleaned := orderedTask(t, dag, "cleanup")
+       done := orderedTask(t, dag, "done")
+
+       extracted.Before(notified, cleaned).Before(done)
+
+       assertTasks(t, extracted.downstreams, notified, cleaned)
+       assertTasks(t, done.upstreams, notified, cleaned)
+       assertTasks(t, notified.downstreams, done)
+       assertTasks(t, cleaned.downstreams, done)
+}
+
+func TestAfterReturnsItsArgumentSet(t *testing.T) {
+       dag := Dag("etl")
+       started := orderedTask(t, dag, "start")
+       notified := orderedTask(t, dag, "notify")
+       cleaned := orderedTask(t, dag, "cleanup")
+       done := orderedTask(t, dag, "done")
+
+       done.After(notified, cleaned).After(started)
+
+       assertTasks(t, done.upstreams, notified, cleaned)
+       assertTasks(t, started.downstreams, notified, cleaned)
+       assertTasks(t, notified.upstreams, started)
+}
+
+func TestEdgeVerbsReturnTheWholeArgumentSetOfEveryNode(t *testing.T) {
+       dag := Dag("etl")
+       extracted := orderedTask(t, dag, "extract")
+       notified := orderedTask(t, dag, "notify")
+       cleaned := orderedTask(t, dag, "cleanup")
+       done := orderedTask(t, dag, "done")
+
+       // The set that the first verb returns is one node of the second.
+       extracted.Before(extracted.Before(notified, cleaned), done)
+
+       assertTasks(t, extracted.downstreams, notified, cleaned, done)
+}
+
+func TestInputsDeclaresAnEdgeInBothDirections(t *testing.T) {
+       dag := Dag("etl")
+       read := dag.Task(readRows)
+       counted := dag.Task(countRows, Inputs(read))
+
+       assertTasks(t, read.downstreams, counted)
+       assertTasks(t, counted.upstreams, read)
+       assertEdgeLabel(t, dag, "readRows", "countRows", "")
+}
+
+func TestInputsDeclaresOneEdgePerTaskItRepeats(t *testing.T) {
+       dag := Dag("etl")
+       read := dag.Task(readRows)
+       compared := dag.Task(compareRows, Inputs(read, read))
+
+       assertTasks(t, read.downstreams, compared)
+       assertTasks(t, compared.upstreams, read)
+}
+
+func TestRedeclaringAnEdgeIsIdempotent(t *testing.T) {
+       dag := Dag("etl")
+       read := dag.Task(readRows)
+       counted := dag.Task(countRows, Inputs(read))
+
+       read.Before(counted)
+       counted.After(read)
+
+       assertTasks(t, read.downstreams, counted)
+       assertTasks(t, counted.upstreams, read)
+}
+
+func TestLabelLabelsTheEdgeToTheNodeItWraps(t *testing.T) {
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+       emptyNotice := orderedTask(t, dag, "notify_empty")
+
+       loaded.Before(Label(emptyNotice, "when empty"))
+
+       assertTasks(t, loaded.downstreams, emptyNotice)
+       assertEdgeLabel(t, dag, "load", "notify_empty", "when empty")
+}
+
+func TestLabelLabelsEachEdgeOfAFanOut(t *testing.T) {
+       dag := Dag("etl")
+       checked := orderedTask(t, dag, "check")
+       processed := orderedTask(t, dag, "process")
+       emptyNotice := orderedTask(t, dag, "notify_empty")
+
+       checked.Before(Label(processed, "rows found"), Label(emptyNotice, "no 
rows"))
+
+       assertEdgeLabel(t, dag, "check", "process", "rows found")
+       assertEdgeLabel(t, dag, "check", "notify_empty", "no rows")
+}
+
+func TestAfterLabelsTheEdgeToTheNodeItWasGiven(t *testing.T) {
+       dag := Dag("etl")
+       extracted := orderedTask(t, dag, "extract")
+       cleaned := orderedTask(t, dag, "cleanup")
+
+       cleaned.After(Label(extracted, "always"))
+
+       assertEdgeLabel(t, dag, "extract", "cleanup", "always")
+}
+
+// TestRedeclaringAnInputsEdgeLabelsIt pins how a data edge gets a label: 
Inputs takes none, so
+// the edge it declared is declared again, which only applies the label.
+func TestRedeclaringAnInputsEdgeLabelsIt(t *testing.T) {
+       dag := Dag("etl")
+       read := dag.Task(readRows)
+       counted := dag.Task(countRows, Inputs(read))
+
+       read.Before(Label(counted, "rows"))
+
+       assertTasks(t, read.downstreams, counted)
+       assertTasks(t, counted.upstreams, read)
+       assertEdgeLabel(t, dag, "readRows", "countRows", "rows")
+}
+
+func TestRedeclaringALabelledEdgeKeepsTheLabel(t *testing.T) {
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+       cleaned := orderedTask(t, dag, "cleanup")
+
+       loaded.Before(Label(cleaned, "always"))
+       loaded.Before(cleaned)
+       cleaned.After(Label(loaded, "always"))
+
+       assertEdgeLabel(t, dag, "load", "cleanup", "always")
+}
+
+// TestASecondLabelOnAnEdgeReplacesTheFirst follows Python, where 
DAG.set_edge_info overwrites
+// rather than merges, so the last label declared on an edge is the one it 
carries.
+func TestASecondLabelOnAnEdgeReplacesTheFirst(t *testing.T) {
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+       cleaned := orderedTask(t, dag, "cleanup")
+
+       loaded.Before(Label(cleaned, "always"))
+       loaded.Before(Label(cleaned, "when empty"))
+
+       assertEdgeLabel(t, dag, "load", "cleanup", "when empty")
+       assertTasks(t, loaded.downstreams, cleaned)
+}
+
+// TestTheLastLabelOfOneCallWins covers the same rule within a single fan-out, 
where both labels
+// reach the edge through one call.
+func TestTheLastLabelOfOneCallWins(t *testing.T) {
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+       cleaned := orderedTask(t, dag, "cleanup")
+
+       loaded.Before(Label(cleaned, "always"), Label(cleaned, "when empty"))
+
+       assertEdgeLabel(t, dag, "load", "cleanup", "when empty")
+       assertTasks(t, loaded.downstreams, cleaned)
+}
+
+// TestALabelOnTheReceiverOfAVerbIsDropped pins the silence Python has too: 
Label marks the edge
+// that reaches a node, so a label on the node an edge leaves from has no edge 
to land on.
+// Python's Label("x") >> b, with nothing upstream of the label, sets no label 
either.
+func TestALabelOnTheReceiverOfAVerbIsDropped(t *testing.T) {
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+       cleaned := orderedTask(t, dag, "cleanup")
+
+       Label(loaded, "always").Before(cleaned)
+
+       assertTasks(t, loaded.downstreams, cleaned)
+       assertEdgeLabel(t, dag, "load", "cleanup", "")
+}
+
+// TestTheOuterLabelOfANestedLabelWins pins what nesting means: the outer 
Label relabels the
+// endpoints the inner one marked, rather than the two combining.
+func TestTheOuterLabelOfANestedLabelWins(t *testing.T) {
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+       cleaned := orderedTask(t, dag, "cleanup")
+
+       loaded.Before(Label(Label(cleaned, "inner"), "outer"))
+
+       assertEdgeLabel(t, dag, "load", "cleanup", "outer")
+}
+
+// TestLabelBelongsToTheVerbItIsPassedTo pins that a label is on the edge of 
the call it appears
+// in. The Node that a verb returns stands for the tasks it pointed at, so it 
carries no label on
+// either side of the next verb: as its receiver, and as one of its nodes.
+func TestLabelBelongsToTheVerbItIsPassedTo(t *testing.T) {
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+       cleaned := orderedTask(t, dag, "cleanup")
+       done := orderedTask(t, dag, "done")
+       extracted := orderedTask(t, dag, "extract")
+
+       labelled := loaded.Before(Label(cleaned, "always"))
+       labelled.Before(done)
+       extracted.Before(labelled)
+
+       assertEdgeLabel(t, dag, "load", "cleanup", "always")
+       assertEdgeLabel(t, dag, "cleanup", "done", "")
+       assertEdgeLabel(t, dag, "extract", "cleanup", "")
+}
+
+// TestLabelPassesThroughNoVerbTwice covers the same rule for After, which 
reads the label from
+// the node it was given rather than from its receiver.
+func TestLabelPassesThroughNoVerbTwice(t *testing.T) {
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+       cleaned := orderedTask(t, dag, "cleanup")
+       notified := orderedTask(t, dag, "notify")
+
+       notified.After(cleaned.After(Label(loaded, "always")))
+
+       assertEdgeLabel(t, dag, "load", "cleanup", "always")
+       assertEdgeLabel(t, dag, "load", "notify", "")
+}
+
+func TestLabelPanicsOnANilNode(t *testing.T) {
+       assert.PanicsWithValue(t,
+               "airflow.Label: got a nil airflow.Node",
+               func() { Label(nil, "when empty") },
+       )
+}
+
+func TestLabelPanicsOnAnEmptyLabel(t *testing.T) {
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+
+       assert.PanicsWithValue(t,
+               "airflow.Label: got an empty label; pass the text to put on the 
edge",
+               func() { Label(loaded, "") },
+       )
+}
+
+func TestEdgeVerbsRejectANilNode(t *testing.T) {
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+       cleaned := orderedTask(t, dag, "cleanup")
+
+       assert.PanicsWithValue(t,
+               "airflow.Node.Before: nodes[1] is a nil airflow.Node",
+               func() { loaded.Before(cleaned, nil) },
+       )
+       assert.PanicsWithValue(t,
+               "airflow.Node.After: nodes[0] is a nil airflow.Node",
+               func() { loaded.After(nil) },
+       )
+       assert.PanicsWithValue(t,
+               "airflow.Node.Before: got a nil *airflow.TaskRef",
+               func() { loaded.Before((*TaskRef)(nil)) },
+       )
+}
+
+func TestEdgeVerbsRejectATaskThatTaskDidNotReturn(t *testing.T) {
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+       copied := *loaded
+
+       assert.PanicsWithValue(t,
+               "airflow.Node.Before: got a *airflow.TaskRef that DagRef.Task 
did not return",
+               func() { (&TaskRef{}).Before(loaded) },
+       )
+       assert.PanicsWithValue(t,
+               `airflow.Node.Before: Dag "etl" got a *airflow.TaskRef that 
DagRef.Task did not return`,
+               func() { loaded.Before(&copied) },
+       )
+}
+
+func TestEdgeVerbsRejectATaskOfAnotherDag(t *testing.T) {
+       loaded := orderedTask(t, Dag("etl"), "load")
+       cleaned := orderedTask(t, Dag("reporting"), "cleanup")
+
+       assert.PanicsWithValue(t,
+               `airflow.Node.Before: cannot declare an edge between task 
"load" of Dag "etl" and `+
+                       `task "cleanup" of Dag "reporting"; an edge connects 
tasks of one Dag`,
+               func() { loaded.Before(cleaned) },
+       )
+}
+
+func TestEdgeVerbsRejectATaskBeforeItself(t *testing.T) {
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+
+       assert.PanicsWithValue(t,
+               `airflow.Node.Before: Dag "etl": task "load" cannot depend on 
itself`,
+               func() { loaded.Before(loaded) },
+       )
+       assert.Empty(t, loaded.downstreams)
+}
+
+// TestEdgeVerbsRecordNoEdgeWhenAPairIsRejected pins that the checks run over 
the whole fan-out
+// before any of it is recorded, the way DagRef.Task settles every check 
before it writes a task.
+func TestEdgeVerbsRecordNoEdgeWhenAPairIsRejected(t *testing.T) {
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+       cleaned := orderedTask(t, dag, "cleanup")
+       notified := orderedTask(t, dag, "notify")
+
+       assert.PanicsWithValue(t,
+               `airflow.Node.Before: Dag "etl": task "load" cannot depend on 
itself`,
+               func() { loaded.Before(cleaned, loaded) },
+       )
+       assert.PanicsWithValue(t,
+               `airflow.Node.After: Dag "etl": task "load" cannot depend on 
itself`,
+               func() { loaded.After(notified, loaded) },
+       )
+
+       assert.Empty(t, loaded.downstreams)
+       assert.Empty(t, loaded.upstreams)
+       assert.Empty(t, cleaned.upstreams)
+       assert.Empty(t, notified.downstreams)
+       assert.Empty(t, dag.edgeLabels)
+}
+
+func TestEdgeVerbsAfterRegisterPanic(t *testing.T) {
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+       cleaned := orderedTask(t, dag, "cleanup")
+       Bundle().Register(dag)
+
+       assert.PanicsWithValue(t,
+               `airflow.Node.Before: Dag "etl" has already been registered; `+
+                       `declare every edge before Register`,
+               func() { loaded.Before(cleaned) },
+       )
+       assert.Empty(t, loaded.downstreams)
+}
+
+func TestEdgeVerbsWithNoNodeDeclareNoEdge(t *testing.T) {
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+       cleaned := orderedTask(t, dag, "cleanup")
+
+       // A spread of an empty slice reaches a verb with no node, and the 
empty set it returns
+       // carries a chain no further.
+       var none []Node
+       loaded.Before(none...).Before(cleaned)
+       loaded.After(none...)
+
+       assert.Empty(t, loaded.downstreams)
+       assert.Empty(t, loaded.upstreams)
+       assert.Empty(t, cleaned.upstreams)
+       assert.Empty(t, dag.edgeLabels)
+}
+
+// TestEdgeVerbsCheckTheTasksEvenWithNoNode pins that a verb with no node to 
point at still checks
+// the tasks it was called on. It declares no edge, but a Dag that can no 
longer take one, or a
+// task the Dag never returned, is a mistake either way.
+func TestEdgeVerbsCheckTheTasksEvenWithNoNode(t *testing.T) {
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+       var none []Node
+
+       assert.PanicsWithValue(t,
+               "airflow.Node.Before: got a *airflow.TaskRef that DagRef.Task 
did not return",
+               func() { (&TaskRef{}).Before(none...) },
+       )
+       assert.PanicsWithValue(t,
+               "airflow.Node.After: got a nil *airflow.TaskRef",
+               func() { (*TaskRef)(nil).After(none...) },
+       )
+
+       Bundle().Register(dag)
+       assert.PanicsWithValue(t,
+               `airflow.Node.Before: Dag "etl" has already been registered; `+
+                       `declare every edge before Register`,
+               func() { loaded.Before(none...) },
+       )
+}
+
+func TestEdgeVerbsAreSafeForConcurrentUse(t *testing.T) {
+       const workers = 8
+
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+       tasks := make([]*TaskRef, workers)
+       for worker := range workers {
+               tasks[worker] = orderedTask(t, dag, fmt.Sprintf("notify_%d", 
worker))
+       }
+
+       var wg sync.WaitGroup
+       for worker := range workers {
+               wg.Add(1)
+               go func() {
+                       defer wg.Done()
+                       loaded.Before(tasks[worker])
+                       tasks[worker].After(loaded)
+               }()
+       }
+       wg.Wait()
+
+       assert.Len(t, loaded.downstreams, workers)
+       assert.Len(t, dag.edgeLabels, workers)
+}
+
+// TestEdgeVerbsRejectANodeTheyDoNotDefine covers the one gap in the seal: 
embedding promotes the
+// node method, so a struct that embeds a Node compiles as one.
+func TestEdgeVerbsRejectANodeTheyDoNotDefine(t *testing.T) {
+       dag := Dag("etl")
+       loaded := orderedTask(t, dag, "load")
+       cleaned := orderedTask(t, dag, "cleanup")
+
+       assert.PanicsWithValue(t,
+               "airflow.Node.Before: nodes[0] has type airflow.wrappedNode, "+
+                       "which is not a node that package airflow defines",
+               func() { loaded.Before(wrappedNode{cleaned}) },
+       )
+       assert.PanicsWithValue(t,
+               "airflow.Label: node has type airflow.wrappedNode, "+
+                       "which is not a node that package airflow defines",
+               func() { Label(wrappedNode{cleaned}, "when empty") },
+       )
+       assert.Empty(t, loaded.downstreams)
+}
+
+func TestNodeIsSealed(t *testing.T) {
+       typ := reflect.TypeFor[Node]()
+       require.Equal(t, 3, typ.NumMethod())
+       // reflect reports a package path only for an unexported method.
+       method, ok := typ.MethodByName("node")
+       require.True(t, ok, "Node has no unexported node method to seal it")
+       assert.Equal(t, "github.com/apache/airflow/go-sdk/airflow", 
method.PkgPath)
+}
+
+func TestNodeRejectsForeignTypes(t *testing.T) {
+       if testing.Short() {
+               t.Skip("shells out to `go build`")
+       }
+       if _, err := exec.LookPath("go"); err != nil {
+               t.Skip("go toolchain not on PATH")
+       }
+
+       out, err := exec.Command("go", "build", "-o", os.DevNull, 
"./testdata/foreignnode").
+               CombinedOutput()
+
+       require.Error(t, err, "a type defined outside package airflow must not 
compile as a Node")
+       // The test checks only the two type names, so that a change in the 
wording of the compiler
+       // error does not break it.
+       assert.Contains(t, string(out), "foreignNode")
+       assert.Contains(t, string(out), "airflow.Node")
+}
diff --git a/go-sdk/airflow/testdata/foreignnode/main.go 
b/go-sdk/airflow/testdata/foreignnode/main.go
new file mode 100644
index 00000000000..74af5d1abd0
--- /dev/null
+++ b/go-sdk/airflow/testdata/foreignnode/main.go
@@ -0,0 +1,39 @@
+// 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.
+
+// Command foreignnode tries to use a node that package airflow did not define 
as an edge
+// endpoint. It must not compile. TestNodeRejectsForeignTypes builds it and 
expects that failure.
+package main
+
+import "github.com/apache/airflow/go-sdk/airflow"
+
+type foreignNode struct{}
+
+func (foreignNode) Before(...airflow.Node) airflow.Node { return nil }
+
+func (foreignNode) After(...airflow.Node) airflow.Node { return nil }
+
+// An unexported method name belongs to the package that declares it, so this 
method is not the
+// node method of airflow.Node even though it is spelled the same.
+func (foreignNode) node() {}
+
+func extract(airflow.Context) error { return nil }
+
+func main() {
+       dag := airflow.Dag("etl")
+       dag.Task(extract).Before(foreignNode{})
+}

Reply via email to