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 eb52b8b3511 [v3-4-test] Cover YAML-generated Java Dags in the java_sdk 
e2e test (#74710) (#75002)
eb52b8b3511 is described below

commit eb52b8b35115f004960bd7810685e52b13f10835
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sun Oct 18 14:20:44 2026 +0100

    [v3-4-test] Cover YAML-generated Java Dags in the java_sdk e2e test 
(#74710) (#75002)
---
 airflow-e2e-tests/docker/java.yml                  |  6 ++
 airflow-e2e-tests/java-native-bundle/build.gradle  |  1 +
 .../apache/airflow/e2e/NativeBundleBuilder.java    | 12 ++-
 .../apache/airflow/e2e/nativedag/DynamicDags.java  | 74 ++++++++++++++++++
 .../e2e_test_utils/dynamic_dags.py                 | 71 +++++++++++++++++
 .../go_sdk_tests/test_go_sdk_dynamic_dag.py        | 47 +++---------
 .../java_sdk_tests/test_java_sdk_dynamic_dag.py    | 89 ++++++++++++++++++++++
 7 files changed, 260 insertions(+), 40 deletions(-)

diff --git a/airflow-e2e-tests/docker/java.yml 
b/airflow-e2e-tests/docker/java.yml
index 3204e395132..1dadf0e5f65 100644
--- a/airflow-e2e-tests/docker/java.yml
+++ b/airflow-e2e-tests/docker/java.yml
@@ -29,6 +29,12 @@
 services:
   airflow-dag-processor:
     image: airflow-java-worker
+    environment:
+      # The dynamic Dag test swaps a YAML file and waits for the Dag IDs to 
change. Re-parse the
+      # bundle every few seconds, and mark a Dag stale soon after it leaves 
the YAML.
+      AIRFLOW__DAG_PROCESSOR__MIN_FILE_PROCESS_INTERVAL: '5'
+      AIRFLOW__DAG_PROCESSOR__STALE_DAG_THRESHOLD: '10'
+      AIRFLOW__SCHEDULER__PARSING_CLEANUP_INTERVAL: '5'
 
   airflow-worker:
     image: airflow-java-worker
diff --git a/airflow-e2e-tests/java-native-bundle/build.gradle 
b/airflow-e2e-tests/java-native-bundle/build.gradle
index 0e572c073fc..59ff66f77af 100644
--- a/airflow-e2e-tests/java-native-bundle/build.gradle
+++ b/airflow-e2e-tests/java-native-bundle/build.gradle
@@ -30,6 +30,7 @@ dependencies {
     
annotationProcessor("org.apache.airflow:airflow-sdk-processor:${projectVersion}")
     implementation("org.apache.airflow:airflow-sdk:${projectVersion}")
     implementation("org.apache.airflow:airflow-sdk-jpl:${projectVersion}")
+    
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.22.2")
 }
 
 java {
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 b204371c372..0c52b3fe426 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
@@ -19,8 +19,11 @@
 
 package org.apache.airflow.e2e;
 
+import java.io.IOException;
+import java.net.URISyntaxException;
 import java.util.List;
 import org.apache.airflow.e2e.nativedag.AnnotationDag;
+import org.apache.airflow.e2e.nativedag.DynamicDags;
 import org.apache.airflow.e2e.nativedag.TargetDag;
 import org.apache.airflow.sdk.*;
 
@@ -140,11 +143,14 @@ public class NativeBundleBuilder {
     return dag;
   }
 
-  public static Bundle build() {
-    return new 
Bundle().register(buildDag()).register(AnnotationDag.class).register(TargetDag.build());
+  public static Bundle build() throws IOException, URISyntaxException {
+    var bundle =
+        new 
Bundle().register(buildDag()).register(AnnotationDag.class).register(TargetDag.build());
+    DynamicDags.load().forEach(bundle::register);
+    return bundle;
   }
 
-  public static void main(String[] args) {
+  public static void main(String[] args) throws IOException, 
URISyntaxException {
     Server.create(args).serve(build());
   }
 }
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
new file mode 100644
index 00000000000..88f4cf7268f
--- /dev/null
+++ 
b/airflow-e2e-tests/java-native-bundle/src/java/org/apache/airflow/e2e/nativedag/DynamicDags.java
@@ -0,0 +1,74 @@
+/*
+ * 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.
+ */
+
+// "native" is a Java keyword, so the native Dags live in "nativedag".
+package org.apache.airflow.e2e.nativedag;
+
+import static org.apache.airflow.e2e.NativeBundleBuilder.QUEUE;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.airflow.sdk.*;
+
+/**
+ * Native Java Dags generated from the {@code dags.yaml} next to the bundle 
JAR, one per entry.
+ *
+ * <p>Both the Dag processor and the worker run the JAR, and each run reads 
the YAML again, so
+ * swapping only the YAML changes the Dags with no rebuild. Without a {@code 
dags.yaml} next to the
+ * JAR, as when the Gradle plugin runs the main class at build time or before 
the e2e test writes
+ * one, there are no Dags, and the bundle's other Dags still parse.
+ */
+public class DynamicDags {
+  private static final Map<String, String> GREETINGS = new HashMap<>();
+
+  public static class Greet implements Task {
+    @Override
+    public void execute(Context context, Client client) {
+      client.setXCom(GREETINGS.get(context.ti.dagId));
+    }
+  }
+
+  public static List<DagDef> load() throws IOException, URISyntaxException {
+    var jar = 
Path.of(DynamicDags.class.getProtectionDomain().getCodeSource().getLocation().toURI());
+    var path = jar.resolveSibling("dags.yaml");
+    var dags = new ArrayList<DagDef>();
+    if (!Files.isRegularFile(path)) {
+      return dags;
+    }
+    for (var entry : new ObjectMapper(new 
YAMLFactory()).readTree(path.toFile()).path("dags")) {
+      var dagId = entry.path("dag_id").asText();
+      GREETINGS.put(dagId, entry.path("greeting").asText());
+      var dag =
+          new DagDef(dagId)
+              .config("catchup", false)
+              .config("tags", List.of("java-sdk", "java_dynamic"));
+      dag.task("greet", Greet.class).config("queue", QUEUE);
+      dags.add(dag);
+    }
+    return dags;
+  }
+}
diff --git 
a/airflow-e2e-tests/tests/airflow_e2e_tests/e2e_test_utils/dynamic_dags.py 
b/airflow-e2e-tests/tests/airflow_e2e_tests/e2e_test_utils/dynamic_dags.py
new file mode 100644
index 00000000000..4af37cecc71
--- /dev/null
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/e2e_test_utils/dynamic_dags.py
@@ -0,0 +1,71 @@
+# 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.
+"""Helpers for the e2e tests of language SDK Dags generated from a 
``dags.yaml`` in the Dag bundle.
+
+Each entry of the YAML has a ``dag_id`` and a ``greeting``, and its Dag has 
one ``greet`` task that
+returns the greeting.
+"""
+
+from __future__ import annotations
+
+import os
+from datetime import UTC, datetime
+from typing import TYPE_CHECKING
+
+import yaml
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+    from airflow_e2e_tests.e2e_test_utils.clients import AirflowClient
+
+
+def write_yaml_atomically(path: Path, greetings: dict[str, str]) -> None:
+    """Replace the YAML in one step, so the Dag processor never reads half of 
it."""
+    entries = [{"dag_id": dag_id, "greeting": greeting} for dag_id, greeting 
in greetings.items()]
+    tmp_path = path.with_name(f"{path.name}.tmp")
+    tmp_path.write_text(yaml.safe_dump({"dags": entries}))
+    os.replace(tmp_path, path)
+
+
+def swap_yaml_and_check(
+    client: AirflowClient,
+    yaml_path: Path,
+    greetings: dict[str, str],
+    *,
+    previous: set[str],
+    tags: list[str],
+    timeout: int,
+    task_timeout: int,
+) -> set[str]:
+    """Swap in a YAML of *greetings*, then check the Dags, a run of each, and 
the Dags it dropped."""
+    write_yaml_atomically(yaml_path, greetings)
+    client.wait_for_dag_ids(set(greetings), tags=tags, timeout=timeout)
+
+    run_ids = {
+        dag_id: client.trigger_dag(dag_id, json={"logical_date": 
datetime.now(UTC).isoformat()})["dag_run_id"]
+        for dag_id in greetings
+    }
+    for dag_id, run_id in run_ids.items():
+        state = client.wait_for_dag_run(dag_id=dag_id, run_id=run_id, 
timeout=task_timeout)
+        assert state == "success", f"{dag_id} run {run_id} ended {state!r}"
+        xcom = client.get_xcom_value(dag_id=dag_id, task_id="greet", 
run_id=run_id, key="return_value")
+        assert xcom["value"] == greetings[dag_id], f"{dag_id} returned 
{xcom['value']!r}"
+
+    for dag_id in previous - set(greetings):
+        assert client.get_dag(dag_id)["is_stale"], f"{dag_id} left the YAML 
but is not stale"
+    return set(greetings)
diff --git 
a/airflow-e2e-tests/tests/airflow_e2e_tests/go_sdk_tests/test_go_sdk_dynamic_dag.py
 
b/airflow-e2e-tests/tests/airflow_e2e_tests/go_sdk_tests/test_go_sdk_dynamic_dag.py
index ca9c3e07269..a5dcf0ab894 100644
--- 
a/airflow-e2e-tests/tests/airflow_e2e_tests/go_sdk_tests/test_go_sdk_dynamic_dag.py
+++ 
b/airflow-e2e-tests/tests/airflow_e2e_tests/go_sdk_tests/test_go_sdk_dynamic_dag.py
@@ -28,13 +28,10 @@ with no rebuild, and checks that the live Dag IDs follow it 
and that each live D
 
 from __future__ import annotations
 
-import os
-from datetime import UTC, datetime
 from pathlib import Path
 
-import yaml
-
 from airflow_e2e_tests.e2e_test_utils.clients import AirflowClient
+from airflow_e2e_tests.e2e_test_utils.dynamic_dags import swap_yaml_and_check
 
 # The Dag processor execs the binary to parse it, so the Dags appear some time 
after the stack is up.
 _PARSE_TIMEOUT = 600
@@ -46,48 +43,20 @@ _GO_TASK_TIMEOUT = 600
 _TAGS = ["go_dynamic"]
 
 
-def _write_yaml_atomically(path: Path, greetings: dict[str, str]) -> None:
-    """Replace the YAML in one step, so the Dag processor never reads half of 
it."""
-    entries = [{"dag_id": dag_id, "greeting": greeting} for dag_id, greeting 
in greetings.items()]
-    tmp_path = path.with_name(f"{path.name}.tmp")
-    tmp_path.write_text(yaml.safe_dump({"dags": entries}))
-    os.replace(tmp_path, path)
-
-
-def _swap_yaml_and_check(
-    client: AirflowClient, yaml_path: Path, greetings: dict[str, str], 
previous: set[str], timeout: int
-) -> set[str]:
-    """Swap in a YAML of *greetings*, then check the Dags, a run of each, and 
the Dags it dropped."""
-    _write_yaml_atomically(yaml_path, greetings)
-    client.wait_for_dag_ids(set(greetings), tags=_TAGS, timeout=timeout)
-
-    run_ids = {
-        dag_id: client.trigger_dag(dag_id, json={"logical_date": 
datetime.now(UTC).isoformat()})["dag_run_id"]
-        for dag_id in greetings
-    }
-    for dag_id, run_id in run_ids.items():
-        state = client.wait_for_dag_run(dag_id=dag_id, run_id=run_id, 
timeout=_GO_TASK_TIMEOUT)
-        assert state == "success", f"{dag_id} run {run_id} ended {state!r}"
-        xcom = client.get_xcom_value(dag_id=dag_id, task_id="greet", 
run_id=run_id, key="return_value")
-        assert xcom["value"] == greetings[dag_id], f"{dag_id} returned 
{xcom['value']!r}"
-
-    for dag_id in previous - set(greetings):
-        assert client.get_dag(dag_id)["is_stale"], f"{dag_id} left the YAML 
but is not stale"
-    return set(greetings)
-
-
 def test_swapping_the_yaml_changes_the_live_dags(airflow_dags_path: Path):
     client = AirflowClient()
     yaml_path = airflow_dags_path / "go-dynamic" / "dags.yaml"
 
-    live = _swap_yaml_and_check(
+    live = swap_yaml_and_check(
         client,
         yaml_path,
         {"go_dynamic_a": "hello from a", "go_dynamic_b": "hello from b", 
"go_dynamic_c": "hello from c"},
         previous=set(),
+        tags=_TAGS,
         timeout=_PARSE_TIMEOUT,
+        task_timeout=_GO_TASK_TIMEOUT,
     )
-    live = _swap_yaml_and_check(
+    live = swap_yaml_and_check(
         client,
         yaml_path,
         {
@@ -98,13 +67,17 @@ def 
test_swapping_the_yaml_changes_the_live_dags(airflow_dags_path: Path):
             "go_dynamic_e": "hello from e",
         },
         previous=live,
+        tags=_TAGS,
         timeout=_RELOAD_TIMEOUT,
+        task_timeout=_GO_TASK_TIMEOUT,
     )
     # a keeps its Dag ID but gets a new greeting, so its run shows the Dag was 
rebuilt from the new YAML.
-    _swap_yaml_and_check(
+    swap_yaml_and_check(
         client,
         yaml_path,
         {"go_dynamic_a": "hello again from a", "go_dynamic_f": "hello from f"},
         previous=live,
+        tags=_TAGS,
         timeout=_RELOAD_TIMEOUT,
+        task_timeout=_GO_TASK_TIMEOUT,
     )
diff --git 
a/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_dynamic_dag.py
 
b/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_dynamic_dag.py
new file mode 100644
index 00000000000..d22e48c55d4
--- /dev/null
+++ 
b/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_dynamic_dag.py
@@ -0,0 +1,89 @@
+# 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.
+"""E2E test for the native Java Dags that ``java-native-bundle`` generates 
from a YAML file.
+
+Run with::
+
+    E2E_TEST_MODE=java_sdk uv run --project airflow-e2e-tests pytest \\
+        tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_dynamic_dag.py 
-xvs
+
+``conftest._setup_java_sdk_integration`` puts the bundle JAR into the Dags 
folder, with no ``dags.yaml``
+next to it yet. The JAR generates one Dag for each entry of the YAML the test 
writes there, so the test
+swaps only the YAML, with no rebuild, and checks that the live Dag IDs follow 
it and that each live Dag
+runs.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from airflow_e2e_tests.e2e_test_utils.clients import AirflowClient
+from airflow_e2e_tests.e2e_test_utils.dynamic_dags import swap_yaml_and_check
+
+# The Dag processor starts a JVM to parse the JAR, so the Dags appear some 
time after the stack is up.
+_PARSE_TIMEOUT = 600
+# After that, the Dag processor re-parses every few seconds (see java.yml), 
and a Dag that left the
+# YAML goes stale a few seconds after that.
+_RELOAD_TIMEOUT = 180
+_JAVA_TASK_TIMEOUT = 600
+
+_TAGS = ["java_dynamic"]
+
+
+def test_swapping_the_yaml_changes_the_live_dags(airflow_dags_path: Path):
+    client = AirflowClient()
+    yaml_path = airflow_dags_path / "java-native" / "dags.yaml"
+
+    live = swap_yaml_and_check(
+        client,
+        yaml_path,
+        {
+            "java_dynamic_a": "hello from a",
+            "java_dynamic_b": "hello from b",
+            "java_dynamic_c": "hello from c",
+        },
+        previous=set(),
+        tags=_TAGS,
+        timeout=_PARSE_TIMEOUT,
+        task_timeout=_JAVA_TASK_TIMEOUT,
+    )
+    live = swap_yaml_and_check(
+        client,
+        yaml_path,
+        {
+            "java_dynamic_a": "hello from a",
+            "java_dynamic_b": "hello from b",
+            "java_dynamic_c": "hello from c",
+            "java_dynamic_d": "hello from d",
+            "java_dynamic_e": "hello from e",
+        },
+        previous=live,
+        tags=_TAGS,
+        timeout=_RELOAD_TIMEOUT,
+        task_timeout=_JAVA_TASK_TIMEOUT,
+    )
+    # a keeps its Dag ID but gets a new greeting. The greeting is not in the 
serialized Dag, so its run
+    # shows that the worker built the Dag from the new YAML, not that the Dag 
processor rebuilt it.
+    swap_yaml_and_check(
+        client,
+        yaml_path,
+        {"java_dynamic_a": "hello again from a", "java_dynamic_f": "hello from 
f"},
+        previous=live,
+        tags=_TAGS,
+        timeout=_RELOAD_TIMEOUT,
+        task_timeout=_JAVA_TASK_TIMEOUT,
+    )

Reply via email to