DUE TO SPAM, SIGN-UP IS DISABLED. Goto Selfserve wiki signup and request an account.
Everything can be set up in a Breeze shell. Go is installed automatically; pass --sdk java to install Java to "breeze shell" and "breeze start-airflow".
Tasks executed by the Java or Go SDK are wired up through three pieces:
- A Python stub DAG file defines the DAG shape that the scheduler sees. Each language task is declared with @task.stub(queue="<name>"). The function body can simply be ... ; the stub is never actually executed in Python, so the body is always ignored.
- A language bundle that registers the same DAG ID and task IDs. This is what actually runs.
- Airflow configuration maps the queue name to a coordinator class and the bundle location.
Pre-setup: Python stub DAGs
Both SDK sections share a single stub DAG file. The queue name "sdk" is wired to a different coordinator in each section. Change the environment variable AIRFLOW_SDK_QUEUE_TO_COORDINATOR to select which SDK is under test.
Save as /files/dags/aip108_e2e.py :
from __future__ import annotations
from airflow.sdk import dag, task
@dag(schedule=None)
def aip108_e2e():
@task
def python_upstream():
return "hello_from_python"
@task.stub(queue="sdk")
def succeed_task(): ...
@task.stub(queue="sdk")
def variable_task(): ...
@task.stub(queue="sdk")
def connection_task(): ...
@task.stub(queue="sdk")
def xcom_task(): ...
@task.stub(queue="sdk")
def context_task(): ...
@task.stub(queue="sdk")
def taskflow_task(): ...
@task
def python_downstream(ti=None):
value = ti.xcom_pull(task_ids="xcom_task", key="return_value")
if value is None:
raise RuntimeError("expected an XCom value from xcom_task")
print("Cross-language XCom verified:", value)
py_up = python_upstream()
succeed_task()
variable_task()
connection_task()
context_task()
xcom = xcom_task()
py_up >> xcom >> python_downstream()
py_up >> taskflow_task()
@dag(schedule=None)
def aip108_fail_e2e():
@task.stub(queue="sdk")
def fail_task(): ...
@task.stub(queue="sdk")
def panic_task(): ...
fail_task()
panic_task()
aip108_e2e()
aip108_fail_e2e()
1. Testing the Java SDK
Prerequisites
Requirement | Version |
|---|---|
JDK | 11 |
Gradle | 8+ |
Airflow worker | HEAD |
Create a new Gradle project
mkdir /files/aip108-java-dag && cd /files/aip108-java-dag gradle init --type java-application --dsl kotlin
Replace the generated app/build.gradle.kts with the following. The SDK snapshot is available in the Apache snapshots repository (https://lists.apache.org/thread/w8wtbc9r397pglbklsc26qjsb2dqwn5h
).
// app/build.gradle.kts
plugins {
application
}
repositories {
mavenCentral()
maven {
url = uri("https://repository.apache.org/content/repositories/snapshots/")
mavenContent { snapshotsOnly() }
}
}
dependencies {
annotationProcessor("org.apache.airflow:airflow-sdk:1.0.0-SNAPSHOT")
implementation("org.apache.airflow:airflow-sdk:1.0.0-SNAPSHOT")
}
application {
mainClass = "com.example.airflow.MyBundle"
}
tasks.jar {
manifest {
attributes("Main-Class" to "com.example.airflow.MyBundle")
}
}
// settings.gradle.kts
include("app")
Write the tasks
The annotation processor generates MyDagBuilder from MyDag.java at compile time. MyBundle.java is the entry point that wires everything together.
Create app/src/main/java/com/example/airflow/MyDag.java :
package com.example.airflow;
import org.apache.airflow.sdk.*;
@Builder.Dag(id = "aip108_e2e")
public class MyDag {
@Builder.Task(id = "succeed_task")
public void succeedTask() {
System.out.println("succeed_task: returning normally");
}
@Builder.Task(id = "variable_task")
public void variableTask(Client client) {
Object value = client.getVariable("aip108_test_var");
System.out.println("variable_task: got variable value = " + value);
if (value == null) throw new RuntimeException("variable 'aip108_test_var' not found");
}
@Builder.Task(id = "connection_task")
public void connectionTask(Client client) {
var conn = client.getConnection("aip108_test_conn");
System.out.println("connection_task: host = " + conn.host);
}
@Builder.Task(id = "xcom_task")
public String xcomTask(Client client, @Builder.XCom(task = "python_upstream") Object upstream) {
System.out.println("xcom_task: pulled upstream XCom = " + upstream);
System.out.println("xcom_task: pushed XCom 'hello_from_java'");
return "hello_from_java";
}
// Injecting a Context parameter is the Java equivalent of Python's
// get_current_context(): the runtime fills it from the task's StartupDetails.
@Builder.Task(id = "context_task")
public void contextTask(Context context) {
System.out.println("context_task: dag_id = " + context.dagRun.dagId
+ ", run_id = " + context.ti.runId
+ ", task_id = " + context.ti.taskId
+ ", try_number = " + context.ti.tryNumber);
}
// TaskFlow-style typed XCom input: the upstream return value is injected as a
// typed parameter (cast to String here) instead of calling client.getXCom.
@Builder.Task(id = "taskflow_task")
public void taskflowTask(@Builder.XCom(task = "python_upstream") String fromPython) {
System.out.println("taskflow_task: typed upstream XCom = " + fromPython);
}
}
Create app/src/main/java/com/example/airflow/MyFailDag.java :
package com.example.airflow;
import org.apache.airflow.sdk.Builder;
@Builder.Dag(id = "aip108_fail_e2e")
public class MyFailDag {
@Builder.Task(id = "fail_task")
public void failTask() {
throw new RuntimeException("deliberate failure - expected in test JV-02");
}
@Builder.Task(id = "panic_task")
public void panicTask() {
throw new Error("deliberate panic - expected in test JV-03");
}
}
Create app/src/main/java/com/example/airflow/MyBundle.java :
package com.example.airflow;
import java.util.List;
import org.apache.airflow.sdk.*;
public class MyBundle implements BundleBuilder {
@Override
public Iterable<Dag> getDags() {
return List.of(MyDagBuilder.build(), MyFailDagBuilder.build());
}
public static void main(String[] args) {
Server.create(args).serve(new MyBundle().build());
}
}
Build and deploy JARs
Build the app JAR and stage all runtime dependencies:
cd /files/aip108-java-dag ./gradlew :app:installDist # Output: app/build/install/app/lib/ (app.jar + all runtime dependency JARs)
Verify the manifests contain the required attributes. Main-Class must be in the app JAR; Airflow-Supervisor-Schema-Version is carried by the SDK JAR:
unzip -p app/build/install/app/lib/app.jar META-INF/MANIFEST.MF # Expected: Main-Class: com.example.airflow.MyBundle unzip -p app/build/install/app/lib/airflow-sdk-*.jar META-INF/MANIFEST.MF # Expected: Airflow-Supervisor-Schema-Version: <date string>
Copy all JARs to a location the Airflow worker can read:
mkdir -p /files/java-jars cp app/build/install/app/lib/*.jar /files/java-jars/
Configure Airflow
Add to $AIRFLOW_HOME/airflow.cfg (or set the equivalent AIRFLOW__ environment variables):
[sdk]
coordinators = {
"java": {
"classpath": "airflow.sdk.coordinators.java.JavaCoordinator",
"kwargs": {
"jars_root": ["/files/java-jars"]
}
}
}
queue_to_coordinator = {"sdk": "java"}
Integration test cases
Test Case ID | Test Objective | Test Steps | Prerequisite | Expected Outcome | Execution Status |
|---|---|---|---|---|---|
JV-01 | (P0) Java task that returns normally reaches TI state success | Trigger aip108_e2e; wait for succeed_task TI. | JAR deployed; coordinator configured | TI state = success; task log contains "succeed_task: returning normally" | |
JV-02 | (P0) Java task that throws RuntimeException reaches TI state failed | Trigger aip108_fail_e2e; wait for fail_task TI. | — | TI state = failed; task log contains "deliberate failure" and a Java stack trace; DAG run of aip108_e2e is unaffected | |
JV-03 | (P0) Java task that throws Error reaches TI state failed (JVM crashes; worker detects exit) | Trigger aip108_fail_e2e; wait for panic_task TI. | — | TI state = failed; task log contains "deliberate panic"; JVM process exits abnormally (the SDK runner only catches Exception, so the Error is uncaught; the worker detects the exit and marks the TI failed) | |
JV-04 | (P0) Java task reads its runtime context via an injected Context parameter (get_current_context equivalent) | Trigger the full aip108_e2e DAG run; wait for context_task TI. | — | TI state = success; task log contains "context_task: dag_id = aip108_e2e", "task_id = context_task", and a non-empty run_id | |
JV-05 | (P0) Java task reads a Variable via the coordinator | Set aip108_test_var = hello in Airflow Admin (or via airflow variables set aip108_test_var hello); trigger variable_task. | Variable present | TI state = success; task log contains "got variable value = hello" | |
JV-06 | (P0) Java task reads a Connection via the coordinator | Create connection aip108_test_conn (type HTTP, host example.com); trigger connection_task. | Connection present | TI state = success; task log contains "host = example.com" | |
JV-07 | (P0) Java task pushes XCom; downstream Python task reads it | Trigger the full aip108_e2e DAG run; wait for python_downstream. | — | python_downstream TI state = success; log contains "Cross-language XCom verified: hello_from_java" | |
JV-08 | (P0) Java task reads XCom written by an upstream Python task | Trigger the full DAG run; wait for xcom_task. | — | Task log contains "pulled upstream XCom = hello_from_python" | |
JV-09 | (P0) Sequential client round-trips within a task are matched by request/response ID | Trigger aip108_e2e; wait for xcom_task TI. The annotation-generated wrapper issues GetXCom (upstream pull via @Builder.XCom) then SetXCom (return value) as two back-to-back comm socket exchanges. | — | Both round-trips return correct values; no cross-contamination; TI state = success; log contains "pulled upstream XCom = hello_from_python" and "pushed XCom 'hello_from_java'" | |
JV-10 | (P1) Missing task in bundle results in TI state removed | Edit aip108_e2e.py to add @task.stub(queue="sdk") def ghost_task(): ... without adding the corresponding task to Java; trigger ghost_task. | — | TI state = removed; JVM exits 0; no crash or hang | |
JV-11 | (P1) Missing variable raises an error in the task | Remove variable aip108_test_var; trigger variable_task. | Variable absent | TI state = failed; task log contains "variable 'aip108_test_var' not found" | |
JV-12 | (P1) Missing connection raises an error in the task | Remove connection aip108_test_conn; trigger connection_task. | Connection absent | TI state = failed; task log contains "connection 'aip108_test_conn' not found" | |
JV-13 | (P1) JAR not on classpath -> coordinator raises at startup | Set jars_root to an empty directory; trigger any Java task. | Empty jars_root | Worker logs a FileNotFoundError naming the missing JAR; TI state = failed | |
JV-14 | (P1) No JAR with Airflow-Supervisor-Schema-Version in jars_root -> coordinator rejects | Point jars_root at that directory and trigger any Java task. | jars_root with app.jar (no airflow-sdk-*.jar) | Coordinator raises FileNotFoundError mentioning Airflow-Supervisor-Schema-Version; TI state = failed; JVM never launched |
2. Testing the Go SDK
Prerequisites
Requirement | Version |
|---|---|
Go | 1.24+ |
airflow-go-pack | HEAD (see below) |
Airflow worker | HEAD |
Install airflow-go-pack:
cd <airflow-repo-root>/go-sdk go install ./cmd/airflow-go-pack # Binary lands in $(go env GOPATH)/bin/airflow-go-pack
Create a new Go module
mkdir /files/aip108-go-dag && cd /files/aip108-go-dag go mod init example.com/aip108-go-dag
The Go SDK has no tagged release yet, but it is a normal nested module (github.com/apache/airflow/go-sdk) in a public repo, so go get resolves it by commit. Pin the same commit your Airflow worker is built from so the bundle's supervisor_schema_version matches what the coordinator expects:
go get github.com/apache/airflow/go-sdk@<commit-hash>
go get records a pseudo-version (v0.0.0-<date>-<commit>) in go.mod and the checksum in go.sum, so the build is reproducible without a local checkout. If instead you are iterating on uncommitted local SDK changes, swap the go get for a replace at the local tree (go mod edit -replace=github.com/apache/airflow/go-sdk=<airflow-repo-root>/go-sdk).
After writing main.go (below), run go mod tidy to pull the indirect dependencies.
Write the tasks
A Go bundle is a single package main. RegisterDags is the source of truth for the bundle's dag_id/task_ids, and AddTaskWithName pins each task to the task_id declared in the Python stub. Task functions are plain Go funcs; the runtime injects arguments by type (context.Context, *slog.Logger, an sdk.Client or a narrower client interface, or a tagged struct for typed XCom input). Returning a non-nil error fails the task; a first return value is pushed as its return-value XCom. sdk.CurrentContext(ctx) exposes the runtime context (GO-13) and xcom:"<task_id>" struct tags pull typed upstream XComs (GO-14).
Both DAGs live in one bundle. Create main.go:
package main
import (
"context"
"fmt"
"log"
"log/slog"
v1 "github.com/apache/airflow/go-sdk/bundle/bundlev1"
"github.com/apache/airflow/go-sdk/bundle/bundlev1/bundlev1server"
"github.com/apache/airflow/go-sdk/sdk"
)
var (
bundleName = "aip108_go_e2e"
bundleVersion = "0.0"
)
type myBundle struct{}
var _ v1.BundleProvider = (*myBundle)(nil)
func (m *myBundle) GetBundleVersion() v1.BundleInfo {
return v1.BundleInfo{Name: bundleName, Version: &bundleVersion}
}
func (m *myBundle) RegisterDags(dagbag v1.Registry) error {
e2e := dagbag.AddDag("aip108_e2e")
e2e.AddTaskWithName("succeed_task", succeedTask)
e2e.AddTaskWithName("variable_task", variableTask)
e2e.AddTaskWithName("connection_task", connectionTask)
e2e.AddTaskWithName("xcom_task", xcomTask)
e2e.AddTaskWithName("context_task", contextTask)
e2e.AddTaskWithName("taskflow_task", taskflowTask)
fail := dagbag.AddDag("aip108_fail_e2e")
fail.AddTaskWithName("fail_task", failTask)
fail.AddTaskWithName("panic_task", panicTask)
return nil
}
func main() {
if err := bundlev1server.Serve(&myBundle{}); err != nil {
log.Fatal(err)
}
}
func succeedTask(log *slog.Logger) error {
log.Info("succeed_task: returning nil")
return nil
}
func variableTask(ctx context.Context, client sdk.VariableClient, log *slog.Logger) error {
val, err := client.GetVariable(ctx, "aip108_test_var")
if err != nil {
return err
}
log.InfoContext(ctx, "got variable", "value", val)
return nil
}
func connectionTask(ctx context.Context, client sdk.ConnectionClient, log *slog.Logger) error {
conn, err := client.GetConnection(ctx, "aip108_test_conn")
if err != nil {
return err
}
log.InfoContext(ctx, "got connection", "host", conn.Host)
return nil
}
func xcomTask(ctx context.Context, client sdk.XComClient, log *slog.Logger) (any, error) {
rc := sdk.CurrentContext(ctx)
upstream, err := client.GetXCom(ctx, rc.TI.DagID, rc.TI.RunID, "python_upstream", nil, "return_value", nil)
if err != nil {
return nil, err
}
log.InfoContext(ctx, "pulled upstream XCom", "value", upstream)
return "hello_from_go", nil
}
func contextTask(ctx context.Context, log *slog.Logger) error {
rc := sdk.CurrentContext(ctx)
log.InfoContext(ctx, "context_task",
"dag_id", rc.TI.DagID,
"run_id", rc.TI.RunID,
"task_id", rc.TI.TaskID,
"try_number", rc.TI.TryNumber)
return nil
}
type UpstreamInput struct {
FromPython string `xcom:"python_upstream"`
}
func taskflowTask(ctx context.Context, log *slog.Logger, in UpstreamInput) error {
log.InfoContext(ctx, "taskflow typed input", "from_python", in.FromPython)
return nil
}
func failTask() error {
return fmt.Errorf("deliberate failure - expected in test GO-02")
}
func panicTask() error {
panic("deliberate panic - expected in test GO-03")
}
Build the bundle
airflow-go-pack builds the package, execs the binary to capture its manifest, and appends the source + manifest + AFBNDL01 trailer. The root command is the packer (there is no pack subcommand); --output names the result:
A packed bundle is a plain executable identified by its AFBNDL01 trailer, not by a filename suffix, so it carries no file extension (the packer adds .exe only on Windows). Name the output accordingly:
cd /files/aip108-go-dag airflow-go-pack . --output /tmp/aip108-go-e2e # Wrote bundle /tmp/aip108-go-e2e (sdk=go/<version>, dags=2)
Inspect the bundle to confirm both DAGs and their tasks are registered. sdk.version reflects the linked SDK build: a v0.0.0-<date>-<commit> pseudo-version for a go get-pinned build, or (devel) for a local replace build:
airflow-go-pack inspect /tmp/aip108-go-e2e
airflow_bundle_metadata_version: "1.0"
sdk:
language: "go"
version: "(devel)"
supervisor_schema_version: "2026-06-16"
source: "main.go"
dags:
aip108_e2e:
tasks:
- "succeed_task"
- "variable_task"
- "connection_task"
- "xcom_task"
- "context_task"
- "taskflow_task"
aip108_fail_e2e:
tasks:
- "fail_task"
- "panic_task"
Copy the bundle to a location the Airflow worker can read (still no extension):
mkdir -p /files/go-bundles cp /tmp/aip108-go-e2e /files/go-bundles/
Cross-platform builds (develop on macOS, deploy on Linux)
The bundle is a native executable, so it must be built for the worker's OS/arch, not the developer's. On an Apple-silicon Mac targeting a linux/amd64 worker, pass -goos/-goarch to the packer:
airflow-go-pack --goos linux --goarch amd64 . --output /tmp/aip108-go-e2e
airflow-go-pack runs the freshly built binary to capture its manifest, so it cross-builds the deployable artifact and builds a throwaway host-arch binary solely for that introspection step. Use the -goos/-goarch flags rather than the GOOS/GOARCH environment variables: env vars would instead cross-build a host-unrunnable packer (and break the introspection step). To pack a pre-built cross binary instead, capture its manifest on the target platform (./bundle --airflow-metadata > meta.yaml) and pass --executable ./bundle --source main.go --airflow-metadata meta.yaml.
Configure Airflow
Add to $AIRFLOW_HOME/airflow.cfg :
[sdk]
coordinators = {
"go": {
"classpath": "airflow.sdk.coordinators.executable.ExecutableCoordinator",
"kwargs": {
"executables_root": ["/files/go-bundles"]
}
}
}
queue_to_coordinator = {"sdk": "go"}
Integration test cases
Test Case ID | Test Objective | Test Steps | Prerequisite | Expected Outcome | Execution Status |
|---|---|---|---|---|---|
GO-01 | (P0) Go task that returns nil reaches TI state success | Trigger aip108_e2e; wait for succeed_task TI. | Bundle deployed; coordinator configured | TI state = success; task log contains "succeed_task: returning nil" | |
GO-02 | (P0) Go task that returns an error reaches TI state failed | Trigger aip108_fail_e2e; wait for fail_task TI. | — | TI state = failed; task log contains "deliberate failure"; DAG run of aip108_e2e is unaffected | |
GO-03 | (P0) Panicking Go task reaches TI state failed (panic recovered) | Trigger aip108_fail_e2e; wait for panic_task TI. | — | TI state = failed; task log contains "deliberate panic"; bundle exits 0 (panic is caught by the runner, not the OS) | |
GO-04 | (P0) Go task reads its runtime context via sdk.CurrentContext (get_current_context equivalent) | Trigger the full aip108_e2e DAG run; wait for context_task TI. | — | TI state = success; task log contains "context_task" with dag_id=aip108_e2e, task_id=context_task, and a non-empty run_id | |
GO-05 | (P0) Go task reads a Variable via the coordinator | Set aip108_test_var = hello (airflow variables set aip108_test_var hello); trigger variable_task. | Variable present | TI state = success; task log contains "got variable" value=hello | |
GO-06 | (P0) Go task reads a Connection via the coordinator | Create connection aip108_test_conn (type HTTP, host example.com); trigger connection_task. | Connection present | TI state = success; task log contains "host=example.com" | |
GO-07 | (P0) Go task pushes XCom; downstream Python task reads it | Trigger the full aip108_e2e DAG run; wait for python_downstream. | — | python_downstream TI state = success; log contains "Cross-language XCom verified: hello_from_go" | |
GO-08 | (P0) Go task reads XCom written by an upstream Python task | Trigger the full DAG run; wait for xcom_task. | — | Task log contains "pulled upstream XCom" value=hello_from_python | |
GO-09 | (P0) Concurrent requests from Go task are matched by response ID | Trigger xcom_task: it issues a GetXCom (upstream pull) immediately followed by the return-value PushXCom, exercising request/response ID correlation over the comm socket. | — | Both round-trips return correct values; no cross-contamination; xcom_task TI state = success | |
GO-10 | (P1) Missing task in bundle results in TI state removed | Edit aip108_e2e.py to add @task.stub(queue="sdk") def ghost_task(): ... without adding the corresponding task to Go; trigger ghost_task. | — | TI state = removed; bundle exits 0; no crash or hang | |
GO-11 | (P0) Env var shadows coordinator for Variable reads | Set env var AIRFLOW_VAR_AIP108_TEST_VAR=from_env on the worker; trigger variable_task. | Env var set | TI state = success; task log shows "from_env" without a supervisor round-trip | |
GO-12 | (P1) Missing variable raises an error in the task | Remove variable aip108_test_var; trigger variable_task. | Variable absent | TI state = failed; task log contains "Task failed" with variable not found: "aip108_test_var" | |
GO-13 | (P1) Missing connection raises an error in the task | Remove connection aip108_test_conn; trigger connection_task. | Connection absent | TI state = failed; task log contains "Task failed" with connection not found: "aip108_test_conn" | |
GO-14 | (P1) Bundle binary not on executables_root -> coordinator raises | Set executables_root to an empty directory; trigger any Go task. | Empty executables_root | Worker logs FileNotFoundError naming the missing bundle; TI state = failed | |
GO-15 | (P1) Bundle with missing supervisor_schema_version -> coordinator rejects | Build a bundle by hand without the schema version field in the manifest YAML; place it in executables_root. | Bad bundle present | Coordinator raises FileNotFoundError mentioning the missing field; TI state = failed | |
GO-16 | (P1) Tampered bundle (hash mismatch) -> coordinator rejects | Flip one byte in the packed bundle binary; place it in executables_root. | Tampered bundle (see below) | Coordinator raises an integrity error; TI state = failed; original bundle not affected |
GO-16 setup: how to tamper the bundle
Back up the original, then flip one byte at a known offset well within the binary payload (offset 100 is safe; the metadata footer is at the end of the file).
Back the original up outside executables_root. The coordinator scans every executable under that directory, so a valid backup left alongside the tampered file would just be picked instead, masking the rejection.
# Back up the original outside the scan directory so it can be restored later
cp /files/go-bundles/aip108-go-e2e /tmp/aip108-go-e2e.orig
# Flip byte 100 in-place
python3 -c "
from pathlib import Path
data = bytearray(Path('/files/go-bundles/aip108-go-e2e').read_bytes())
data[100] ^= 0xFF
Path('/files/go-bundles/aip108-go-e2e').write_bytes(data)
"
Trigger any Go task and verify the coordinator rejects it. Then restore:
mv /tmp/aip108-go-e2e.orig /files/go-bundles/aip108-go-e2e