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

jason810496 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-4-test by this push:
     new fa07926f3bd [v3-4-test] Java SDK: Let a native Java Dag set one queue 
for all its tasks (#74538) (#75402)
fa07926f3bd is described below

commit fa07926f3bdda3ed7a431a9b302010012014d00a
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Oct 23 12:24:57 2026 +0800

    [v3-4-test] Java SDK: Let a native Java Dag set one queue for all its tasks 
(#74538) (#75402)
    
    * Java SDK: Let a native Java Dag set one queue for all its tasks
    
    Every task of a native Java Dag runs on the Java coordinator, so each one
    needs a queue that queue_to_coordinator routes there. Airflow's Dag has no
    queue setting, so authors had to repeat the same queue on every task. The
    Go and TypeScript SDKs already take a Dag-level queue that each task
    inherits unless it sets its own; this gives Java the same.
    
    * Reject a blank Dag-level queue and fix the native Dag queue docs
    
    A blank queue matched no coordinator, so every task stayed queued.
    The parsing docs still said there was no Dag-level queue and routed a
    queue the examples do not use.
    
    * Reject a blank Dag-level queue in the annotation processor
    
    * Carry the blank rule on the schema field, for Dag and task queues alike
    
    * Set the queue once per Dag in the e2e native bundle
    
    * Put the blank check on Field and tidy the comments
    
    ---------
    (cherry picked from commit fc9c4b0b0036a481da1ae773e76f6885a470a20f)
    
    Co-authored-by: Jason(Zhe-You) Liu 
<[email protected]>
    Co-authored-by: Rahul Vats <[email protected]>
---
 .../language-sdks/java.rst                         | 15 +++--
 .../apache/airflow/e2e/NativeBundleBuilder.java    | 36 ++++++------
 .../airflow/e2e/nativedag/AnnotationDag.java       | 25 +++++----
 .../apache/airflow/e2e/nativedag/DynamicDags.java  |  5 +-
 .../apache/airflow/e2e/nativedag/TargetDag.java    |  5 +-
 .../java_sdk_tests/test_java_sdk_native_dag.py     | 17 +++---
 .../example/nativedag/AnnotationExample.java       |  1 +
 .../example/nativedag/InterfaceExample.java        |  1 +
 .../airflow/example/nativedag/TargetExample.java   |  1 +
 .../org/apache/airflow/sdk/AirflowProcessor.kt     |  6 +-
 .../org/apache/airflow/sdk/AirflowProcessorTest.kt | 53 +++++++++++++++++-
 java-sdk/sdk/build.gradle.kts                      | 28 +++++++++-
 .../main/kotlin/org/apache/airflow/sdk/DagDef.kt   | 17 +++---
 .../kotlin/org/apache/airflow/sdk/TriggerDagRun.kt |  1 +
 .../org/apache/airflow/sdk/execution/Serde.kt      | 10 +++-
 .../org/apache/airflow/sdk/internal/Fields.kt      | 22 +++++++-
 .../kotlin/org/apache/airflow/sdk/DagDefTest.kt    | 28 ++++++++++
 .../org/apache/airflow/sdk/execution/SerdeTest.kt  | 64 ++++++++++++++++++++++
 18 files changed, 271 insertions(+), 64 deletions(-)

diff --git a/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst 
b/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst
index f1ba8c35874..a41abe2a5b0 100644
--- a/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst
+++ b/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst
@@ -352,7 +352,8 @@ Annotate a plain Java class and let the SDK generate the 
boilerplate at compile
    * - ``@Airflow.Dag(id = "...")``
      - Marks a class as a Dag that Java itself owns.  Attributes 
(``schedule``, ``description``,
        ``tags``, ``catchup``, …) are Airflow's own Dag settings; only 
attributes written
-       explicitly are applied.  See :ref:`java-sdk/native-dags`.
+       explicitly are applied.  ``queue`` is the queue each task runs on 
unless the task sets its
+       own.  See :ref:`java-sdk/native-dags`.
    * - ``@Airflow.Task(id = "...")``
      - Marks a method as a task of a Java-owned Dag.  If ``id`` is omitted the 
method name is
        used.  Further attributes (``retries``, ``queue``, ``retryDelay``, …) 
are Airflow's own
@@ -642,6 +643,11 @@ Native Java Dags
 A Dag can also be authored entirely in Java: the annotations (or the 
``DagDef`` / ``TaskDef``
 objects) carry the configuration, and Java declares the graph.
 
+Every task of the Dag runs on the Java coordinator, so it needs a queue that
+:ref:`queue_to_coordinator <java-sdk/coordinator-config>` sends there. Set 
``queue`` once on the Dag, with
+``@Airflow.Dag(queue = "java")`` or ``dag.config("queue", "java")``, and each 
task inherits it, including a
+``TriggerDagRun`` task. A task's own ``queue`` wins over the Dag's. A 
``queue`` that is set cannot be blank.
+
 Building the Dag in Java
 ~~~~~~~~~~~~~~~~~~~~~~~~
 
@@ -922,7 +928,7 @@ The Dag processor runs the JAR's main class to list its 
Dags, so it needs a Java
         "kwargs": {"java_executable": "/usr/lib/jvm/java-17-openjdk/bin/java"}
       }
     }
-    queue_to_coordinator = {"java-native": "java-native"}
+    queue_to_coordinator = {"java": "java-native"}
 
 Once a ``JavaCoordinator`` is configured, the Dag processor parses the 
executable JARs of every Dag bundle,
 so it needs this ``[sdk]`` configuration and a JRE. With one 
``JavaCoordinator``, it parses them all.
@@ -953,9 +959,8 @@ several Java coordinators, an unmapped bundle's JARs fail 
to parse too.
   ``Main-Class`` are parsed. List JARs that should not be parsed in 
``.airflowignore``.
 * Two JARs in the bundle that set the same ``Main-Class`` fail to parse, 
because the JVM would load
   the classes of only one of them. Keep one in the bundle.
-* Set ``queue`` on every task, with ``@Airflow.Task(queue = "java-native")`` or
-  ``TaskDef.config("queue", "java-native")``, so it runs on the coordinator's 
queue. There is no
-  Dag-level queue yet.
+* Set ``queue`` on the Dag, as :ref:`java-sdk/native-dags` shows, so its tasks 
run on a queue that
+  ``queue_to_coordinator`` routes to a ``JavaCoordinator``, such as ``java`` 
above.
 * A task runs the JAR of its Dag on the ``JavaCoordinator`` that its queue 
routes to,
   which need not be the one that parsed the JAR. For example, a queue can 
route to a coordinator that uses another JDK.
   A task whose queue routes to another kind of coordinator fails without 
retries. A task whose JAR is missing,
diff --git 
a/airflow-e2e-tests/java-native-bundle/src/java/org/apache/airflow/e2e/NativeBundleBuilder.java
 
b/airflow-e2e-tests/java-native-bundle/src/java/org/apache/airflow/e2e/NativeBundleBuilder.java
index 0c52b3fe426..b20f324c413 100644
--- 
a/airflow-e2e-tests/java-native-bundle/src/java/org/apache/airflow/e2e/NativeBundleBuilder.java
+++ 
b/airflow-e2e-tests/java-native-bundle/src/java/org/apache/airflow/e2e/NativeBundleBuilder.java
@@ -31,9 +31,9 @@ import org.apache.airflow.sdk.*;
  * Bundle for the native Dag E2E tests: Dags declared entirely in Java, parsed 
by the Dag processor
  * from this JAR.
  *
- * <p>This is the bundle's main class, so it is the Dag source the Airflow UI 
shows. Every task sets
- * {@code queue}, which {@code queue_to_coordinator} routes to the {@code 
java-jdk} coordinator.
- * The {@code java-native} coordinator only parses this JAR.
+ * <p>This is the bundle's main class, so it is the Dag source the Airflow UI 
shows. Each Dag sets
+ * {@code queue} once and every task inherits it, which {@code 
queue_to_coordinator} routes to the
+ * {@code java-jdk} coordinator. The {@code java-native} coordinator only 
parses this JAR.
  */
 public class NativeBundleBuilder {
   public static final String QUEUE = "java-native";
@@ -110,34 +110,30 @@ public class NativeBundleBuilder {
         new DagDef("java_native_e2e")
             .config("description", "Native Java Dag of the Airflow E2E tests")
             .config("catchup", false)
-            .config("tags", List.of("java-sdk", "native", "e2e"));
+            .config("tags", List.of("java-sdk", "native", "e2e"))
+            .config("queue", QUEUE);
 
-    var extract = dag.task("extract", Extract.class).config("queue", QUEUE);
-    var transform = dag.task("transform", Transform.class).config("queue", 
QUEUE);
-    var load = dag.task("load", Load.class).config("queue", QUEUE);
+    var extract = dag.task("extract", Extract.class);
+    var transform = dag.task("transform", Transform.class);
+    var load = dag.task("load", Load.class);
 
     transform.after(extract).before(load);
 
-    var reportMany = dag.task("report_many", ReportMany.class).config("queue", 
QUEUE);
-    var reportFew = dag.task("report_few", ReportFew.class).config("queue", 
QUEUE);
-    dag.If("has_rows", HasRows.class).after(transform).config("queue", 
QUEUE).Then(reportMany).Else(reportFew);
+    var reportMany = dag.task("report_many", ReportMany.class);
+    var reportFew = dag.task("report_few", ReportFew.class);
+    dag.If("has_rows", 
HasRows.class).after(transform).Then(reportMany).Else(reportFew);
 
-    var reportLong = dag.task("report_long", ReportLong.class).config("queue", 
QUEUE);
-    var reportShort = dag.task("report_short", 
ReportShort.class).config("queue", QUEUE);
-    dag.Switch("pick_report", PickReport.class)
-        .after(transform)
-        .config("queue", QUEUE)
-        .Case(reportLong)
-        .Case(reportShort);
+    var reportLong = dag.task("report_long", ReportLong.class);
+    var reportShort = dag.task("report_short", ReportShort.class);
+    dag.Switch("pick_report", 
PickReport.class).after(transform).Case(reportLong).Case(reportShort);
 
     // A task that starts a run of another Dag; it runs no Java code.
-    var trigger =
-        dag.task("trigger_downstream", new 
TriggerDagRun("java_native_target_e2e")).config("queue", QUEUE);
+    var trigger = dag.task("trigger_downstream", new 
TriggerDagRun("java_native_target_e2e"));
     load.before(trigger);
 
     // Ordering-only edge: the checks group runs after extract, with no data 
flowing.
     var checks = dag.taskGroup("checks");
-    checks.task("audit", Audit.class).config("queue", QUEUE);
+    checks.task("audit", Audit.class);
     extract.before(checks);
 
     return dag;
diff --git 
a/airflow-e2e-tests/java-native-bundle/src/java/org/apache/airflow/e2e/nativedag/AnnotationDag.java
 
b/airflow-e2e-tests/java-native-bundle/src/java/org/apache/airflow/e2e/nativedag/AnnotationDag.java
index 78531c2ccb5..3e245c3074f 100644
--- 
a/airflow-e2e-tests/java-native-bundle/src/java/org/apache/airflow/e2e/nativedag/AnnotationDag.java
+++ 
b/airflow-e2e-tests/java-native-bundle/src/java/org/apache/airflow/e2e/nativedag/AnnotationDag.java
@@ -24,58 +24,59 @@ import static 
org.apache.airflow.e2e.NativeBundleBuilder.QUEUE;
 
 import org.apache.airflow.sdk.*;
 
-/** A native Java Dag declared with annotations; its tasks use the interface 
Dag's queue. */
+/** A native Java Dag declared with annotations; it sets the same queue as the 
interface Dag. */
 @Airflow.Dag(
     id = "java_native_annotation_e2e",
     description = "Native Java Dag of the Airflow E2E tests, declared with 
annotations",
     catchup = false,
+    queue = QUEUE,
     tags = {"java-sdk", "native", "e2e"})
 public class AnnotationDag {
-  @Airflow.Task(id = "extract", queue = QUEUE)
+  @Airflow.Task(id = "extract")
   public long extract() {
     return 42L;
   }
 
-  @Airflow.Task(id = "transform", queue = QUEUE)
+  @Airflow.Task(id = "transform")
   public long transform(long extracted, double factor) {
     return (long) (extracted * factor);
   }
 
   /** Fails the run unless the value reached it, so a successful run proves 
the XCom flow. */
-  @Airflow.Task(id = "load", queue = QUEUE)
+  @Airflow.Task(id = "load")
   public void load(long transformed) {
     if (transformed != 63L) {
       throw new IllegalStateException("load expected 63 from transform, got " 
+ transformed);
     }
   }
 
-  @Airflow.Task(id = "report_many", queue = QUEUE)
+  @Airflow.Task(id = "report_many")
   public void reportMany() {}
 
-  @Airflow.Task(id = "report_few", queue = QUEUE)
+  @Airflow.Task(id = "report_few")
   public void reportFew() {}
 
   /** Picks one of {@link #reportMany} and {@link #reportFew}; the other is 
skipped. */
-  @Airflow.If(id = "has_rows", queue = QUEUE)
+  @Airflow.If(id = "has_rows")
   public boolean hasRows(long transformed) {
     return transformed > 0;
   }
 
-  @Airflow.Task(id = "report_long", queue = QUEUE)
+  @Airflow.Task(id = "report_long")
   public void reportLong() {}
 
-  @Airflow.Task(id = "report_short", queue = QUEUE)
+  @Airflow.Task(id = "report_short")
   public void reportShort() {}
 
   /** Names the one of {@link #reportLong} and {@link #reportShort} that runs; 
the other is skipped. */
-  @Airflow.Switch(id = "pick_report", queue = QUEUE)
+  @Airflow.Switch(id = "pick_report")
   public Class<? extends Task> pickReport(long transformed) {
     return transformed > 100 ? AnnotationDagBuilder.ReportLong.class : 
AnnotationDagBuilder.ReportShort.class;
   }
 
   // A task that starts a run of another Dag. The method runs when this Dag is
   // built, not when the task runs, so it takes no arguments.
-  @Airflow.Task(id = "trigger_downstream", queue = QUEUE)
+  @Airflow.Task(id = "trigger_downstream")
   public TriggerDagRun triggerDownstream() {
     return new TriggerDagRun("java_native_target_e2e");
   }
@@ -84,7 +85,7 @@ public class AnnotationDag {
   // the task "checks.audit".
   @Airflow.TaskGroup(id = "checks")
   static class Checks {
-    @Airflow.Task(id = "audit", queue = QUEUE)
+    @Airflow.Task(id = "audit")
     public void audit() {}
   }
 
diff --git 
a/airflow-e2e-tests/java-native-bundle/src/java/org/apache/airflow/e2e/nativedag/DynamicDags.java
 
b/airflow-e2e-tests/java-native-bundle/src/java/org/apache/airflow/e2e/nativedag/DynamicDags.java
index 88f4cf7268f..4014c5337d7 100644
--- 
a/airflow-e2e-tests/java-native-bundle/src/java/org/apache/airflow/e2e/nativedag/DynamicDags.java
+++ 
b/airflow-e2e-tests/java-native-bundle/src/java/org/apache/airflow/e2e/nativedag/DynamicDags.java
@@ -65,8 +65,9 @@ public class DynamicDags {
       var dag =
           new DagDef(dagId)
               .config("catchup", false)
-              .config("tags", List.of("java-sdk", "java_dynamic"));
-      dag.task("greet", Greet.class).config("queue", QUEUE);
+              .config("tags", List.of("java-sdk", "java_dynamic"))
+              .config("queue", QUEUE);
+      dag.task("greet", Greet.class);
       dags.add(dag);
     }
     return dags;
diff --git 
a/airflow-e2e-tests/java-native-bundle/src/java/org/apache/airflow/e2e/nativedag/TargetDag.java
 
b/airflow-e2e-tests/java-native-bundle/src/java/org/apache/airflow/e2e/nativedag/TargetDag.java
index 2e1be90d106..d5ad4e1adbe 100644
--- 
a/airflow-e2e-tests/java-native-bundle/src/java/org/apache/airflow/e2e/nativedag/TargetDag.java
+++ 
b/airflow-e2e-tests/java-native-bundle/src/java/org/apache/airflow/e2e/nativedag/TargetDag.java
@@ -39,8 +39,9 @@ public class TargetDag {
         new DagDef("java_native_target_e2e")
             .config("description", "Native Java Dag the e2e's other native 
Dags trigger")
             .config("catchup", false)
-            .config("tags", List.of("java-sdk", "native", "e2e"));
-    dag.task("receive", Receive.class).config("queue", QUEUE);
+            .config("tags", List.of("java-sdk", "native", "e2e"))
+            .config("queue", QUEUE);
+    dag.task("receive", Receive.class);
     return dag;
   }
 }
diff --git 
a/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_native_dag.py
 
b/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_native_dag.py
index 41219f40170..54000e47326 100644
--- 
a/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_native_dag.py
+++ 
b/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_native_dag.py
@@ -24,11 +24,12 @@ Run with::
 
 No Python file declares these Dags. The 
``airflow-e2e-tests/java-native-bundle`` JAR sits in the Dag
 bundle. There are four ``JavaCoordinator``s, so ``[sdk] 
dag_bundle_to_coordinator`` picks the
-``java-native`` one to parse it: the Dag processor runs the JAR through it. 
Each task sets
-``queue="java-native"``, which routes to ``java-jdk``. That coordinator runs 
the same JAR from the Dag's
-own bundle, whatever its ``task_handler_bundle_name`` says. 
``java_native_e2e`` is declared with the interface
-API, ``java_native_annotation_e2e`` with annotations. Both also exercise a 
task group, an ``If``, a
-``Switch`` and a ``TriggerDagRun`` of ``java_native_target_e2e``, the third 
Dag the JAR declares.
+``java-native`` one to parse it: the Dag processor runs the JAR through it. 
Each Dag sets
+``queue="java-native"`` once and every task inherits it, which routes to 
``java-jdk``. That
+coordinator runs the same JAR from the Dag's own bundle, whatever its 
``task_handler_bundle_name``
+says. ``java_native_e2e`` is declared with the interface API, 
``java_native_annotation_e2e`` with
+annotations. Both also exercise a task group, an ``If``, a ``Switch`` and a 
``TriggerDagRun`` of
+``java_native_target_e2e``, the third Dag the JAR declares.
 """
 
 from __future__ import annotations
@@ -43,7 +44,7 @@ from airflow_e2e_tests.e2e_test_utils.clients import 
AirflowClient
 # The Dag processor starts a JVM to parse the JAR, and each task starts 
another.
 _JAVA_TASK_TIMEOUT = 600
 
-# The queue the Dag's tasks set, which ``queue_to_coordinator`` routes to 
``java-jdk``.
+# The queue the Dag sets for all its tasks, which ``queue_to_coordinator`` 
routes to ``java-jdk``.
 _QUEUE = "java-native"
 
 # The Dag ``trigger_downstream`` starts a run of; not triggered through the 
client itself.
@@ -149,8 +150,8 @@ def test_the_graph_is_the_one_java_declared(parsed_dags: 
AirflowClient, native_d
 
 
 @_by_dag_id
-def test_every_task_sets_the_native_queue(parsed_dags: AirflowClient, 
native_dag: _NativeDag):
-    """The Java DSL has no Dag-level queue, so each task sets its own."""
+def test_every_task_takes_the_dag_queue(parsed_dags: AirflowClient, 
native_dag: _NativeDag):
+    """The Dag sets ``queue`` once, and every task runs on it."""
     tasks = parsed_dags.get_tasks(native_dag.dag_id).get("tasks", [])
 
     assert {task["task_id"]: task["queue"] for task in tasks} == 
dict.fromkeys(native_dag.downstream, _QUEUE)
diff --git 
a/java-sdk/example/src/java/org/apache/airflow/example/nativedag/AnnotationExample.java
 
b/java-sdk/example/src/java/org/apache/airflow/example/nativedag/AnnotationExample.java
index 6e751dd724a..e0cfb645833 100644
--- 
a/java-sdk/example/src/java/org/apache/airflow/example/nativedag/AnnotationExample.java
+++ 
b/java-sdk/example/src/java/org/apache/airflow/example/nativedag/AnnotationExample.java
@@ -31,6 +31,7 @@ import org.apache.airflow.sdk.*;
     id = "java_native_annotation_example",
     description = "Pure-Java Dag authored with annotations",
     schedule = "@daily",
+    queue = "java",
     startDate = "2026-01-01T00:00:00Z",
     catchup = false,
     tags = {"example", "java-sdk"})
diff --git 
a/java-sdk/example/src/java/org/apache/airflow/example/nativedag/InterfaceExample.java
 
b/java-sdk/example/src/java/org/apache/airflow/example/nativedag/InterfaceExample.java
index 47a8f6786af..a90378ad35f 100644
--- 
a/java-sdk/example/src/java/org/apache/airflow/example/nativedag/InterfaceExample.java
+++ 
b/java-sdk/example/src/java/org/apache/airflow/example/nativedag/InterfaceExample.java
@@ -101,6 +101,7 @@ public class InterfaceExample {
         new DagDef("java_native_interface_example")
             .config("description", "Pure-Java Dag authored with the interface 
API")
             .config("schedule", "@daily")
+            .config("queue", "java")
             .config("catchup", false)
             .config("tags", List.of("example", "java-sdk"));
 
diff --git 
a/java-sdk/example/src/java/org/apache/airflow/example/nativedag/TargetExample.java
 
b/java-sdk/example/src/java/org/apache/airflow/example/nativedag/TargetExample.java
index cf8d4514295..f428650dc51 100644
--- 
a/java-sdk/example/src/java/org/apache/airflow/example/nativedag/TargetExample.java
+++ 
b/java-sdk/example/src/java/org/apache/airflow/example/nativedag/TargetExample.java
@@ -41,6 +41,7 @@ public class TargetExample {
     var dag =
         new DagDef("java_native_target_example")
             .config("description", "Pure-Java Dag that the other native 
examples trigger")
+            .config("queue", "java")
             .config("catchup", false)
             .config("tags", List.of("example", "java-sdk"));
     dag.task("receive", Receive.class);
diff --git 
a/java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/AirflowProcessor.kt 
b/java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/AirflowProcessor.kt
index 36d2f1cfdd6..53cfab63946 100644
--- 
a/java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/AirflowProcessor.kt
+++ 
b/java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/AirflowProcessor.kt
@@ -712,7 +712,11 @@ class AirflowProcessor : AbstractProcessor() {
     value: AnnotationValue,
   ): CodeBlock =
     when (field.type) {
-      FieldType.STRING -> CodeBlock.of($$"$S", value.value)
+      FieldType.STRING -> {
+        val text = value.value as String
+        field.checkString(text) { "Annotation attribute '${field.attribute}'" }
+        CodeBlock.of($$"$S", text)
+      }
       FieldType.BOOLEAN, FieldType.INTEGER, FieldType.NUMBER -> 
CodeBlock.of($$"$L", value.value)
       FieldType.STRING_ARRAY -> {
         @Suppress("UNCHECKED_CAST")
diff --git 
a/java-sdk/processor/src/test/kotlin/org/apache/airflow/sdk/AirflowProcessorTest.kt
 
b/java-sdk/processor/src/test/kotlin/org/apache/airflow/sdk/AirflowProcessorTest.kt
index 11be0f957f1..e41f6b82bbd 100644
--- 
a/java-sdk/processor/src/test/kotlin/org/apache/airflow/sdk/AirflowProcessorTest.kt
+++ 
b/java-sdk/processor/src/test/kotlin/org/apache/airflow/sdk/AirflowProcessorTest.kt
@@ -403,7 +403,7 @@ class AirflowProcessorTest {
         """
         package org.apache.airflow.example;
         import org.apache.airflow.sdk.Airflow;
-        @Airflow.Dag(id = "cfg", schedule = "@daily", tags = {"a", "b"}, 
catchup = true,
+        @Airflow.Dag(id = "cfg", schedule = "@daily", queue = "java", tags = 
{"a", "b"}, catchup = true,
             startDate = "2026-01-01T00:00:00Z")
         public class TestExample {
           @Airflow.Task(retries = 2, queue = "q", retryDelay = "PT5M", 
retryExponentialBackoff = 1.5)
@@ -442,6 +442,7 @@ class AirflowProcessorTest {
            public static DagDef build() {
              var dag = DagSource.declaredBy(new DagDef("cfg"), 
TestExample.class);
              dag.config("schedule", "@daily");
+             dag.config("queue", "java");
              dag.config("tags", List.of("a", "b"));
              dag.config("catchup", true);
              dag.config("start_date", 
OffsetDateTime.parse("2026-01-01T00:00:00Z"));
@@ -2074,6 +2075,56 @@ class AirflowProcessorTest {
     )
   }
 
+  @Test
+  @DisplayName("reject a blank Dag-level queue")
+  fun rejectBlankDagQueue() {
+    for (queue in listOf("", " ")) {
+      val compilation =
+        compile(
+          """
+          package org.apache.airflow.example;
+          import org.apache.airflow.sdk.Airflow;
+          @Airflow.Dag(queue = "$queue")
+          public class TestExample {
+            @Airflow.Task public void t() {}
+
+            @Airflow.Deps
+            static class Wiring implements TestExampleDeps {
+              void depends() { t(); }
+            }
+          }
+        """,
+        )
+      assertThat(compilation).failed()
+      assertThat(compilation).hadErrorContaining("Annotation attribute 'queue' 
must not be blank")
+    }
+  }
+
+  @Test
+  @DisplayName("reject a blank task-level queue")
+  fun rejectBlankTaskQueue() {
+    for (queue in listOf("", " ")) {
+      val compilation =
+        compile(
+          """
+          package org.apache.airflow.example;
+          import org.apache.airflow.sdk.Airflow;
+          @Airflow.Dag
+          public class TestExample {
+            @Airflow.Task(queue = "$queue") public void t() {}
+
+            @Airflow.Deps
+            static class Wiring implements TestExampleDeps {
+              void depends() { t(); }
+            }
+          }
+        """,
+        )
+      assertThat(compilation).failed()
+      assertThat(compilation).hadErrorContaining("Annotation attribute 'queue' 
must not be blank")
+    }
+  }
+
   @Test
   @DisplayName("escape quotes and backslashes in a string-array attribute")
   fun generateBuilderEscapesStringArrayValues() {
diff --git a/java-sdk/sdk/build.gradle.kts b/java-sdk/sdk/build.gradle.kts
index 2ba31187168..825f481b950 100644
--- a/java-sdk/sdk/build.gradle.kts
+++ b/java-sdk/sdk/build.gradle.kts
@@ -284,8 +284,15 @@ abstract class GenerateDagDslTask : DefaultTask() {
         val attrDefault: String,
         val defaultJson: String?,
         val doc: String,
+        val notBlank: Boolean = false,
     )
 
+    // Schema keys whose value cannot be blank, in every table that holds them.
+    private val notBlankKeys = setOf("queue")
+
+    private fun constrained(fields: List<DslField>): List<DslField> =
+        fields.map { if (it.key in notBlankKeys) it.copy(notBlank = true) else 
it }
+
     private fun camelCase(key: String): String =
         key
             .split('_')
@@ -461,6 +468,22 @@ abstract class GenerateDagDslTask : DefaultTask() {
                         "`\"@once\"`, `\"@continuous\"`, a cron expression, or 
empty for no schedule.",
                     ),
                 )
+                // The schema has no Dag-level queue; a Python Dag sets one 
through default_args instead.
+                // Every task of a Java Dag runs on the Java coordinator, so 
one queue on the Dag routes all of them there.
+                if (!dagProps.path("queue").isMissingNode) {
+                    throw GradleException("The schema now has a Dag-level 
'queue'; resolve it from the schema instead")
+                }
+                add(
+                    DslField(
+                        "queue",
+                        "queue",
+                        "STRING",
+                        "String",
+                        quote(""),
+                        null,
+                        "Queue each task of the Dag runs on, unless the task 
sets its own `queue`. It cannot be blank.",
+                    ),
+                )
                 dagAllowlist.forEach { key ->
                     val prop = dagProps.path(key)
                     if (prop.isMissingNode) {
@@ -471,7 +494,7 @@ abstract class GenerateDagDslTask : DefaultTask() {
                             ?: throw GradleException("Dag allowlist key '$key' 
is not a scalar the DSL can express"),
                     )
                 }
-            }
+            }.let(::constrained)
 
         val excludedSeen = mutableSetOf<String>()
         val taskFields =
@@ -490,7 +513,7 @@ abstract class GenerateDagDslTask : DefaultTask() {
                     val override = if (key == "retry_exponential_backoff") 
"Double" else null
                     resolveField(key, prop, override)?.let { add(it) }
                 }
-            }
+            }.let(::constrained)
         (excludedTaskKeys - excludedSeen).takeIf { it.isNotEmpty() }?.let {
             throw GradleException("Excluded task keys match no eligible schema 
property; remove or fix: $it")
         }
@@ -756,6 +779,7 @@ abstract class GenerateDagDslTask : DefaultTask() {
                     "          \"${f.attribute}\",",
                     "          FieldType.${f.fieldType},",
                     "          $defaultRepr,",
+                    "          ${f.notBlank},",
                     "        ),",
                 ).joinToString("\n")
             }
diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagDef.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagDef.kt
index 913817d0909..5964b4552b1 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagDef.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/DagDef.kt
@@ -87,13 +87,16 @@ class DagDef(
    * mismatched value types are rejected on the call, so mistakes surface where
    * the Dag is defined.
    *
+   * `"queue"` is the one key Airflow's Dag has no setting for.
+   * It is the queue every task of the Dag runs on, unless the task sets its 
own `"queue"`, and it cannot be blank.
+   *
    * @param key Airflow Dag setting name.
    * @param value Value matching the key's schema type. Durations take
    *    [java.time.Duration], date-times [java.time.OffsetDateTime] or
    *    [java.time.Instant], string arrays any `Iterable` of `String`.
    * @return This Dag, for chaining.
-   * @throws IllegalArgumentException if the key is unknown or the value type
-   *    does not match.
+   * @throws IllegalArgumentException if the key is unknown, the value type
+   *    does not match, or `"queue"` is blank.
    */
   fun config(
     key: String,
@@ -542,17 +545,17 @@ class TaskDef(
    * Sets one task-level configuration value.
    *
    * Keys are Airflow's own task setting names (for example `"retries"`,
-   * `"queue"`, `"retry_delay"`); unknown keys and mismatched
-   * value types are rejected on the call, so mistakes surface where the task 
is
-   * defined.
+   * `"queue"`, `"retry_delay"`); unknown keys, mismatched
+   * value types and a blank `"queue"` are rejected on the call, so mistakes
+   * surface where the task is defined.
    *
    * @param key Airflow task setting name, e.g. `"retries"`.
    * @param value Value matching the key's schema type. Durations take
    *    [java.time.Duration], date-times [java.time.OffsetDateTime] or
    *    [java.time.Instant].
    * @return This task definition, for chaining.
-   * @throws IllegalArgumentException if the key is unknown or the value type
-   *    does not match.
+   * @throws IllegalArgumentException if the key is unknown, the value type
+   *    does not match, or `"queue"` is blank.
    */
   fun config(
     key: String,
diff --git 
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/TriggerDagRun.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/TriggerDagRun.kt
index 15a6a5b3d0d..c1cff8e9a8e 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/TriggerDagRun.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/TriggerDagRun.kt
@@ -66,6 +66,7 @@ private val TRIGGER_FIELDS: Map<String, Field> =
  * The task runs no Java code and takes no arguments. It pushes the triggered
  * run's ID, and the link the "Triggered DAG" extra link reads. It renders no
  * templates, so a value such as `"{{ ds }}"` reaches the new run unchanged.
+ * Like a Java task, it runs on the Dag's `queue` unless the task sets its own.
  *
  * @param dagId `trigger_dag_id`: the Dag to trigger.
  * @throws IllegalArgumentException if [dagId] is empty.
diff --git 
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Serde.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Serde.kt
index 0482fc96ad8..c01592b8eea 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Serde.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Serde.kt
@@ -102,6 +102,7 @@ internal fun serializeDag(
     }
   }
 
+  val dagQueue = dag.dagConfig["queue"] as String?
   val result =
     linkedMapOf<String, Any?>(
       "dag_id" to dag.id,
@@ -109,7 +110,7 @@ internal fun serializeDag(
       "relative_fileloc" to relativeFileloc,
       "timezone" to dagTimezone(dag.dagConfig),
       "timetable" to serializeTimetable(dag.id, dag.dagConfig),
-      "tasks" to dag.tasks.map { (taskId, def) -> serializeTask(taskId, def, 
downstream[taskId]) },
+      "tasks" to dag.tasks.map { (taskId, def) -> serializeTask(taskId, def, 
downstream[taskId], dagQueue) },
       "dag_dependencies" to serializeDagDependencies(dag),
       "task_group" to serializeTaskGroups(dag, expansion),
       "edge_info" to serializeEdgeInfo(dag, expansion),
@@ -124,11 +125,13 @@ internal fun serializeDag(
 /**
  * Converts one task to the Airflow serialization format. `downstream` is the
  * inverted view of the Dag's upstream edges, sorted for stable JSON.
+ * `dagQueue` is the Dag's queue, which the task takes unless it sets its own.
  */
 private fun serializeTask(
   taskId: String,
   def: TaskDef,
   downstream: List<String>?,
+  dagQueue: String?,
 ): Map<String, Any?> {
   val data = linkedMapOf<String, Any?>("task_id" to taskId)
   val trigger = def.trigger
@@ -150,13 +153,16 @@ private fun serializeTask(
   }
   // Lets NotPreviouslySkippedDep re-skip a cleared downstream, as Python's 
SkipMixin does.
   if (def.decider != null) data["_can_skip_downstream"] = true
+  // A trigger task takes the Dag's queue too, because the Java runtime runs 
it.
+  val config =
+    if (dagQueue == null || "queue" in def.configValues) def.configValues else 
def.configValues + ("queue" to dagQueue)
   // Emit only config entries that differ from their schema default, mirroring
   // Python BaseSerialization's "omit hard-coded default" behavior, which the 
Go
   // and TypeScript SDKs mirror too. Operator fields are stored unwrapped, so 
the
   // __type encoding is stripped. If core grows a task-level 
fill_config_defaults,
   // every SDK has to keep explicitly set values instead, or an explicit 
retries=0
   // reads as unset and picks up the configured default.
-  def.configValues.forEach { (key, value) ->
+  config.forEach { (key, value) ->
     if (key !in OMITTED_TASK_KEYS && 
!matchesSchemaDefault(SchemaFields.TASK[key], value)) {
       data[key] = unwrapTypeEncoding(serializeValue(value))
     }
diff --git 
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/internal/Fields.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/internal/Fields.kt
index 0afea428eed..bd036885171 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/internal/Fields.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/internal/Fields.kt
@@ -57,13 +57,28 @@ private val DAG_RUN_STATE_NAMES = listOf("queued", 
"running", "success", "failed
  * @property type Accepted value shape.
  * @property defaultJson Schema default as raw JSON, or `null` when the schema
  *    declares no default.
+ * @property notBlank Whether a value for this key must not be blank.
  */
 class Field(
   val key: String,
   val attribute: String,
   val type: FieldType,
   val defaultJson: String?,
-)
+  val notBlank: Boolean = false,
+) {
+  /**
+   * Enforces the constraints this key carries on a string value. [describe]
+   * names the value, so each caller keeps its own wording.
+   *
+   * @throws IllegalArgumentException if [text] breaks a constraint.
+   */
+  fun checkString(
+    text: String,
+    describe: () -> String,
+  ) {
+    require(!notBlank || text.isNotBlank()) { "${describe()} must not be 
blank" }
+  }
+}
 
 /**
  * Validates one `config(key, value)` call against a schema field table and
@@ -91,7 +106,10 @@ internal fun checkConfigValue(
       "Value for $scope config key '$key' must be $expected, got: 
${value.javaClass.name}",
     )
   return when (field.type) {
-    FieldType.STRING -> value as? String ?: mismatch("a String")
+    FieldType.STRING ->
+      (value as? String ?: mismatch("a String")).also { text ->
+        field.checkString(text) { "Value for $scope config key '$key'" }
+      }
     FieldType.BOOLEAN -> value as? Boolean ?: mismatch("a Boolean")
     FieldType.NUMBER -> value as? Number ?: mismatch("a Number")
     FieldType.INTEGER ->
diff --git a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/DagDefTest.kt 
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/DagDefTest.kt
index 70bb24552e0..23f35e6668e 100644
--- a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/DagDefTest.kt
+++ b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/DagDefTest.kt
@@ -175,6 +175,7 @@ internal class DagDefTest {
         .config("dagrun_timeout", Duration.ofMinutes(5))
         .config("start_date", OffsetDateTime.parse("2026-01-01T00:00:00Z"))
         .config("tags", listOf("a", "b"))
+        .config("queue", "java")
 
     Assertions.assertEquals(
       mapOf(
@@ -185,6 +186,7 @@ internal class DagDefTest {
         "dagrun_timeout" to Duration.ofMinutes(5),
         "start_date" to OffsetDateTime.parse("2026-01-01T00:00:00Z"),
         "tags" to listOf("a", "b"),
+        "queue" to "java",
       ),
       dag.dagConfig,
     )
@@ -226,6 +228,32 @@ internal class DagDefTest {
     Assertions.assertEquals("Value for Dag config key 'description' must not 
be null", error.message)
   }
 
+  @Test
+  @DisplayName("Should reject a blank dag queue")
+  fun shouldRejectBlankDagQueue() {
+    for (queue in listOf("", "  ")) {
+      val error =
+        Assertions.assertThrows(IllegalArgumentException::class.java) {
+          DagDef("dag").config("queue", queue)
+        }
+
+      Assertions.assertEquals("Value for Dag config key 'queue' must not be 
blank", error.message)
+    }
+  }
+
+  @Test
+  @DisplayName("Should reject a blank task queue")
+  fun shouldRejectBlankTaskQueue() {
+    for (queue in listOf("", "  ")) {
+      val error =
+        Assertions.assertThrows(IllegalArgumentException::class.java) {
+          DagDef("dag").task<Unit>("extract", 
NoOp::class.java).config("queue", queue)
+        }
+
+      Assertions.assertEquals("Value for task config key 'queue' must not be 
blank", error.message)
+    }
+  }
+
   @Test
   @DisplayName("Should reject non-integral values for integer dag config keys")
   fun shouldRejectFractionalIntegerValue() {
diff --git 
a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/SerdeTest.kt 
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/SerdeTest.kt
index e025d9a5b15..d8f71edb92a 100644
--- a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/SerdeTest.kt
+++ b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/SerdeTest.kt
@@ -22,10 +22,13 @@ package org.apache.airflow.sdk.execution
 import org.apache.airflow.sdk.Arg
 import org.apache.airflow.sdk.Bundle
 import org.apache.airflow.sdk.Client
+import org.apache.airflow.sdk.ConditionTask
 import org.apache.airflow.sdk.Context
 import org.apache.airflow.sdk.DagDef
+import org.apache.airflow.sdk.SwitchTask
 import org.apache.airflow.sdk.Task
 import org.apache.airflow.sdk.TaskDef
+import org.apache.airflow.sdk.TriggerDagRun
 import org.apache.airflow.sdk.execution.comm.DagFileParseRequest
 import org.apache.airflow.sdk.internal.Refs
 import org.junit.jupiter.api.Assertions.assertEquals
@@ -44,6 +47,20 @@ private class SerdeNoopTask : Task {
   ) = Unit
 }
 
+private class SerdeCondition : ConditionTask {
+  override fun decide(
+    context: Context,
+    client: Client,
+  ) = true
+}
+
+private class SerdeSwitch : SwitchTask {
+  override fun choose(
+    context: Context,
+    client: Client,
+  ) = SerdeNoopTask::class.java
+}
+
 @Suppress("UNCHECKED_CAST")
 private fun taskData(
   serialized: Map<String, Any?>,
@@ -54,6 +71,12 @@ private fun taskData(
   return tasks[index]["__var"] as Map<String, Any?>
 }
 
+@Suppress("UNCHECKED_CAST")
+private fun queuesByTaskId(serialized: Map<String, Any?>): Map<String, Any?> =
+  (serialized["tasks"] as List<Map<String, Any?>>)
+    .map { it["__var"] as Map<String, Any?> }
+    .associate { it["task_id"] as String to it["queue"] }
+
 internal class SerdeTest {
   @Test
   @DisplayName("Should emit required dag fields and leave unset config-backed 
fields out")
@@ -186,6 +209,47 @@ internal class SerdeTest {
     )
   }
 
+  @Test
+  @DisplayName("Should give every task the Dag's queue unless the task sets 
its own")
+  fun shouldGiveEachTaskTheDagQueue() {
+    val dag = DagDef("d").config("queue", "java")
+    val extract = dag.task<Unit>("extract", SerdeNoopTask::class.java)
+    dag.task<Unit>("heavy", SerdeNoopTask::class.java).config("queue", 
"java_large")
+    dag.task<Unit>("on_default", SerdeNoopTask::class.java).config("queue", 
"default")
+    dag.If("has_rows", SerdeCondition::class.java).after(extract)
+    dag.Switch("pick", SerdeSwitch::class.java).after(extract)
+    dag.task("trigger", TriggerDagRun("reports"))
+    dag.task("trigger_on_python", TriggerDagRun("reports")).config("queue", 
"python")
+
+    val serialized = serializeDag(dag, "", ".")
+
+    assertEquals(
+      mapOf(
+        "extract" to "java",
+        "heavy" to "java_large",
+        "on_default" to null,
+        "has_rows" to "java",
+        "pick" to "java",
+        "trigger" to "java",
+        "trigger_on_python" to "python",
+      ),
+      queuesByTaskId(serialized),
+    )
+    assertFalse("queue" in serialized)
+  }
+
+  @Test
+  @DisplayName("Should leave the queue out when the Dag's queue is the schema 
default or unset")
+  fun shouldLeaveOutDefaultOrUnsetDagQueue() {
+    val onDefault = DagDef("d").config("queue", "default")
+    onDefault.task<Unit>("t", SerdeNoopTask::class.java)
+    val unset = DagDef("d")
+    unset.task<Unit>("t", SerdeNoopTask::class.java)
+
+    assertFalse("queue" in taskData(serializeDag(onDefault, "", "."), 0))
+    assertFalse("queue" in taskData(serializeDag(unset, "", "."), 0))
+  }
+
   @Test
   @DisplayName("Should serialize nested task groups with their own edges")
   fun shouldSerializeTaskGroups() {

Reply via email to