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

jason810496 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new fa9e5c7aaca Fix Java SDK numeric XCom casting and remove unused XCom 
key field (#68983)
fa9e5c7aaca is described below

commit fa9e5c7aacac4966abc5f8dd24447c6fc2cc9530
Author: PoAn Yang <[email protected]>
AuthorDate: Thu Jul 2 14:34:19 2026 +0900

    Fix Java SDK numeric XCom casting and remove unused XCom key field (#68983)
    
    * Fix Java SDK numeric XCom casting
    
    Generate accessors that widen through Number instead: primitive params use
    ((Number) x).intValue(), and boxed params use 
Optional.ofNullable(...).orElse(null)
    so an absent XCom stays null. Add a java_xcom_casting_example DAG and 
processor
    tests covering int->long->double widening and an absent boxed value.
    
    Signed-off-by: PoAn Yang <[email protected]>
    
    * Remove unused key field from Java SDK Builder.XCom annotation
    
    Signed-off-by: PoAn Yang <[email protected]>
    
    * Add float cross-type coverage to Java SDK XCom casting tests
    
    Signed-off-by: PoAn Yang <[email protected]>
    
    * Remove unused comment
    
    Signed-off-by: PoAn Yang <[email protected]>
    
    ---------
    
    Signed-off-by: PoAn Yang <[email protected]>
---
 .../java_sdk_tests/test_java_sdk_dag.py            | 26 ++++++++
 .../airflow/example/ExampleBundleBuilder.java      |  5 +-
 .../apache/airflow/example/XComCastingExample.java | 77 ++++++++++++++++++++++
 .../example/src/resources/dags/java_examples.py    | 36 ++++++++++
 .../org/apache/airflow/sdk/BuilderProcessor.kt     | 46 +++++++++++--
 .../kotlin/org/apache/airflow/sdk/BuilderTest.kt   | 65 +++++++++++++++++-
 .../main/kotlin/org/apache/airflow/sdk/Builder.kt  |  2 -
 7 files changed, 246 insertions(+), 11 deletions(-)

diff --git 
a/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_dag.py 
b/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_dag.py
index 867fb098aa3..e634ece89b7 100644
--- 
a/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_dag.py
+++ 
b/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_dag.py
@@ -165,6 +165,32 @@ class TestJavaSDKAnnotationExample:
             f"  all tasks  : { {k: v.get('state') for k, v in ti_map.items()} 
}"
         )
 
+    def test_numeric_xcom_casting(self):
+        """Numeric XComs are read across declared types (int -> long -> 
double, and a wire
+        double back as a float), and a boxed param stays null when its XCom is 
absent."""
+        resp = self.airflow_client.trigger_dag(
+            "java_xcom_casting_example",
+            json={"logical_date": datetime.now(timezone.utc).isoformat()},
+        )
+        run_id = resp["dag_run_id"]
+
+        dag_state = self.airflow_client.wait_for_dag_run(
+            dag_id="java_xcom_casting_example",
+            run_id=run_id,
+            timeout=_JAVA_TASK_TIMEOUT,
+        )
+
+        ti_resp = 
self.airflow_client.get_task_instances(dag_id="java_xcom_casting_example", 
run_id=run_id)
+        ti_map = {ti["task_id"]: ti for ti in ti_resp.get("task_instances", 
[])}
+
+        for task_id in ("widen_to_double", "consume_nullable", 
"consume_float"):
+            assert ti_map.get(task_id, {}).get("state") == "success", (
+                f"Java {task_id!r} task did not succeed.\n"
+                f"  task state : {ti_map.get(task_id, {}).get('state')!r}\n"
+                f"  dag state  : {dag_state!r}\n"
+                f"  all tasks  : { {k: v.get('state') for k, v in 
ti_map.items()} }"
+            )
+
     def test_load_retried_then_succeeded(self):
         """``load`` fails once (UP_FOR_RETRY) then succeeds on the second 
attempt.
 
diff --git 
a/java-sdk/example/src/java/org/apache/airflow/example/ExampleBundleBuilder.java
 
b/java-sdk/example/src/java/org/apache/airflow/example/ExampleBundleBuilder.java
index 7c8d9955dc0..f63d6c4d743 100644
--- 
a/java-sdk/example/src/java/org/apache/airflow/example/ExampleBundleBuilder.java
+++ 
b/java-sdk/example/src/java/org/apache/airflow/example/ExampleBundleBuilder.java
@@ -27,7 +27,10 @@ public class ExampleBundleBuilder implements BundleBuilder {
   @NotNull
   @Override
   public Iterable<Dag> getDags() {
-    return List.of(InterfaceExampleBuilder.build(), 
AnnotationExampleBuilder.build());
+    return List.of(
+        InterfaceExampleBuilder.build(),
+        AnnotationExampleBuilder.build(),
+        XComCastingExampleBuilder.build());
   }
 
   public static void main(String[] args) {
diff --git 
a/java-sdk/example/src/java/org/apache/airflow/example/XComCastingExample.java 
b/java-sdk/example/src/java/org/apache/airflow/example/XComCastingExample.java
new file mode 100644
index 00000000000..c4945af865d
--- /dev/null
+++ 
b/java-sdk/example/src/java/org/apache/airflow/example/XComCastingExample.java
@@ -0,0 +1,77 @@
+/*
+ * 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 org.apache.airflow.example;
+
+import static java.lang.System.Logger.Level.INFO;
+
+import org.apache.airflow.sdk.*;
+
[email protected](id = "java_xcom_casting_example")
+public class XComCastingExample {
+  private static final System.Logger log = 
System.getLogger(XComCastingExample.class.getName());
+
+  @Builder.Task(id = "produce_number")
+  public int produceNumber() {
+    log.log(INFO, "Producing int 7");
+    return 7;
+  }
+
+  // Any primitive numeric type (byte, short, int, long, float, double) and 
its boxed form works the same way.
+  @Builder.Task(id = "widen_to_long")
+  public long widenToLong(@Builder.XCom(task = "produce_number") long value) {
+    log.log(INFO, "Got long {0}", value);
+    return value + 1;
+  }
+
+  @Builder.Task(id = "widen_to_double")
+  public void widenToDouble(@Builder.XCom(task = "widen_to_long") double 
value) {
+    log.log(INFO, "Got double {0}", value);
+    if (value != 8.0) {
+      throw new RuntimeException("expected 8.0 but got " + value);
+    }
+  }
+
+  @Builder.Task(id = "produce_nothing")
+  public void produceNothing() {
+    // Pushes no return_value XCom.
+  }
+
+  @Builder.Task(id = "consume_nullable")
+  public void consumeNullable(@Builder.XCom(task = "produce_nothing") Integer 
value) {
+    log.log(INFO, "Got nullable int {0}", value);
+    if (value != null) {
+      throw new RuntimeException("expected null but got " + value);
+    }
+  }
+
+  @Builder.Task(id = "produce_fraction")
+  public double produceFraction() {
+    log.log(INFO, "Producing double 1.5");
+    return 1.5;
+  }
+
+  @Builder.Task(id = "consume_float")
+  public void consumeFloat(@Builder.XCom(task = "produce_fraction") float 
value) {
+    log.log(INFO, "Got float {0}", value);
+    if (value != 1.5f) {
+      throw new RuntimeException("expected 1.5 but got " + value);
+    }
+  }
+}
diff --git a/java-sdk/example/src/resources/dags/java_examples.py 
b/java-sdk/example/src/resources/dags/java_examples.py
index 38be0e9e947..5426911b0fa 100644
--- a/java-sdk/example/src/resources/dags/java_examples.py
+++ b/java-sdk/example/src/resources/dags/java_examples.py
@@ -45,6 +45,34 @@ def load(): ...
 def concurrent(): ...
 
 
[email protected](queue="java")
+def produce_number(): ...
+
+
[email protected](queue="java")
+def widen_to_long(): ...
+
+
[email protected](queue="java")
+def widen_to_double(): ...
+
+
[email protected](queue="java")
+def produce_nothing(): ...
+
+
[email protected](queue="java")
+def consume_nullable(): ...
+
+
[email protected](queue="java")
+def produce_fraction(): ...
+
+
[email protected](queue="java")
+def consume_float(): ...
+
+
 @task()
 def python_task_2(transformed):
     print("python_task_2")
@@ -68,5 +96,13 @@ def java_annotation_example():
     concurrent()
 
 
+@dag(dag_id="java_xcom_casting_example")
+def java_xcom_casting_example():
+    produce_number() >> widen_to_long() >> widen_to_double()
+    produce_nothing() >> consume_nullable()
+    produce_fraction() >> consume_float()
+
+
 java_interface_example()
 java_annotation_example()
+java_xcom_casting_example()
diff --git 
a/java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/BuilderProcessor.kt 
b/java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/BuilderProcessor.kt
index 979615c12f2..b764c00a994 100644
--- 
a/java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/BuilderProcessor.kt
+++ 
b/java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/BuilderProcessor.kt
@@ -22,10 +22,12 @@
 package org.apache.airflow.sdk
 
 import com.squareup.javapoet.ClassName
+import com.squareup.javapoet.CodeBlock
 import com.squareup.javapoet.JavaFile
 import com.squareup.javapoet.MethodSpec
 import com.squareup.javapoet.TypeName
 import com.squareup.javapoet.TypeSpec
+import java.util.Optional
 import javax.annotation.processing.AbstractProcessor
 import javax.annotation.processing.ProcessingEnvironment
 import javax.annotation.processing.RoundEnvironment
@@ -161,12 +163,7 @@ class BuilderProcessor : AbstractProcessor() {
         }
       }
     required.forEach {
-      executeSpec.addStatement(
-        $$"var $L = ($T) client.getXCom($S)",
-        it.paramName,
-        with(TypeName.get(it.paramType)) { if (isPrimitive) box() else this },
-        it.taskId,
-      )
+      executeSpec.addStatement($$"var $L = $L", it.paramName, xcomAccess(it))
     }
     if (inner.returnType.kind == TypeKind.VOID) {
       $$"new $T().$L($L)"
@@ -203,6 +200,43 @@ private data class RequiredXCom(
   val taskId: String,
 )
 
+private val NUMBER_ACCESSORS: Map<TypeName, String> =
+  buildMap {
+    mapOf(
+      TypeName.BYTE to "byteValue",
+      TypeName.SHORT to "shortValue",
+      TypeName.INT to "intValue",
+      TypeName.LONG to "longValue",
+      TypeName.FLOAT to "floatValue",
+      TypeName.DOUBLE to "doubleValue",
+    ).forEach { (primitive, accessor) ->
+      put(primitive, accessor)
+      put(primitive.box(), accessor)
+    }
+  }
+
+private fun xcomAccess(xcom: RequiredXCom): CodeBlock {
+  val call = CodeBlock.of($$"client.getXCom($S)", xcom.taskId)
+  val type = TypeName.get(xcom.paramType)
+  val accessor = NUMBER_ACCESSORS[type]
+  val number = ClassName.get(Number::class.java)
+  // Wire integers decode to Long and floats to Double, so a direct 
(Integer)/(Float)
+  // cast throws ClassCastException; widen via Number instead.
+  return when {
+    accessor == null -> CodeBlock.of($$"($T) $L", if (type.isPrimitive) 
type.box() else type, call)
+    type.isPrimitive -> CodeBlock.of($$"(($T) $L).$L()", number, call, 
accessor)
+    else ->
+      CodeBlock.of(
+        $$"$T.ofNullable(($T) $L).map($T::$L).orElse(null)",
+        ClassName.get(Optional::class.java),
+        number,
+        call,
+        number,
+        accessor,
+      )
+  }
+}
+
 private data class BuildTaskResult(
   val spec: TypeSpec,
 )
diff --git 
a/java-sdk/processor/src/test/kotlin/org/apache/airflow/sdk/BuilderTest.kt 
b/java-sdk/processor/src/test/kotlin/org/apache/airflow/sdk/BuilderTest.kt
index bd3aae81d13..692374d9e3c 100644
--- a/java-sdk/processor/src/test/kotlin/org/apache/airflow/sdk/BuilderTest.kt
+++ b/java-sdk/processor/src/test/kotlin/org/apache/airflow/sdk/BuilderTest.kt
@@ -69,6 +69,7 @@ class BuilderTest {
       """,
       )
 
+    assertThat(compilation).succeeded()
     assertThat(compilation)
       .generatedSourceFile("org.apache.airflow.example.TestExampleBuilder")
       .hasSourceEquivalentTo(
@@ -77,7 +78,7 @@ class BuilderTest {
          package org.apache.airflow.example;
 
          import java.lang.Exception;
-         import java.lang.Integer;
+         import java.lang.Number;
          import java.lang.Override;
          import org.apache.airflow.sdk.Client;
          import org.apache.airflow.sdk.Context;
@@ -107,7 +108,7 @@ class BuilderTest {
            public static final class T3 implements Task {
              @Override
              public void execute(Context context, Client client) throws 
Exception {
-               var value = (Integer) client.getXCom("t2");
+               var value = ((Number) client.getXCom("t2")).intValue();
                new TestExample().t3(context, value);
              }
            }
@@ -116,6 +117,66 @@ class BuilderTest {
       )
   }
 
+  @Test
+  @DisplayName("widen primitive numerics directly and boxed numerics 
null-safely")
+  fun generateBuilderWidensNumericXCom() {
+    val compilation =
+      compile(
+        """
+        package org.apache.airflow.example;
+        import org.apache.airflow.sdk.Builder;
+        @Builder.Dag
+        public class TestExample {
+          @Builder.Task
+          public void t(
+              @Builder.XCom(task = "a") int i,
+              @Builder.XCom(task = "b") long l,
+              @Builder.XCom(task = "c") double d,
+              @Builder.XCom(task = "f") float fl,
+              @Builder.XCom(task = "e") Integer boxed) {}
+        }
+      """,
+      )
+
+    assertThat(compilation).succeeded()
+    assertThat(compilation)
+      .generatedSourceFile("org.apache.airflow.example.TestExampleBuilder")
+      .hasSourceEquivalentTo(
+        "org.apache.airflow.example.TestExampleBuilder",
+        """
+         package org.apache.airflow.example;
+
+         import java.lang.Exception;
+         import java.lang.Number;
+         import java.lang.Override;
+         import java.util.Optional;
+         import org.apache.airflow.sdk.Client;
+         import org.apache.airflow.sdk.Context;
+         import org.apache.airflow.sdk.Dag;
+         import org.apache.airflow.sdk.Task;
+
+         public final class TestExampleBuilder {
+           public static Dag build() {
+             var dag = new Dag("TestExample");
+             dag.addTask("t", T.class);
+             return dag;
+           }
+           public static final class T implements Task {
+             @Override
+             public void execute(Context context, Client client) throws 
Exception {
+               var i = ((Number) client.getXCom("a")).intValue();
+               var l = ((Number) client.getXCom("b")).longValue();
+               var d = ((Number) client.getXCom("c")).doubleValue();
+               var fl = ((Number) client.getXCom("f")).floatValue();
+               var boxed = Optional.ofNullable((Number) 
client.getXCom("e")).map(Number::intValue).orElse(null);
+               new TestExample().t(i, l, d, fl, boxed);
+             }
+           }
+         }
+        """,
+      )
+  }
+
   @Test
   @DisplayName("generate builder for dag class with custom dag id")
   fun generateBuilderWithCustomDagId() {
diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Builder.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Builder.kt
index c12a53f83e5..3a5b84d2daf 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Builder.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Builder.kt
@@ -79,12 +79,10 @@ class Builder internal constructor() {
    *
    * @param task The task ID to pull. If empty or not given, the annotated
    *    parameter's name is used by default.
-   * @param key The XCom key to pull. Defaults to the task's return value.
    */
   @Target(AnnotationTarget.VALUE_PARAMETER)
   @MustBeDocumented
   annotation class XCom(
     val task: String = "",
-    val key: String = Client.XCOM_RETURN_KEY,
   )
 }

Reply via email to