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 687134f3803 Java SDK: Serialize coordinator client send/receive for 
thread safety (#69080)
687134f3803 is described below

commit 687134f3803ec81987417a1f4aeeaae888aad916
Author: PoAn Yang <[email protected]>
AuthorDate: Wed Jul 1 19:14:46 2026 +0900

    Java SDK: Serialize coordinator client send/receive for thread safety 
(#69080)
    
    * Java SDK: Serialize coordinator client send/receive for thread safety
    
    A Java task may call the SDK client from multiple threads. Every call goes 
through
    one request/response channel to the supervisor. So it must be thread safe.
    
    Signed-off-by: PoAn Yang <[email protected]>
    
    * Move requestId outside the lock
    
    Signed-off-by: PoAn Yang <[email protected]>
    
    ---------
    
    Signed-off-by: PoAn Yang <[email protected]>
---
 .../java_sdk_tests/test_java_sdk_dag.py            |  25 +++++
 .../apache/airflow/example/AnnotationExample.java  |  24 +++++
 .../example/src/resources/dags/java_examples.py    |   5 +
 .../org/apache/airflow/sdk/execution/Comm.kt       |  25 +++--
 .../org/apache/airflow/sdk/execution/CommTest.kt   | 107 +++++++++++++++++++++
 5 files changed, 177 insertions(+), 9 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 1e26a872ef5..867fb098aa3 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
@@ -140,6 +140,31 @@ class TestJavaSDKAnnotationExample:
             f"Expected 'transform' XCom to be a positive integer (millisecond 
timestamp), got {value!r}"
         )
 
+    def test_concurrent_client_calls_succeed(self):
+        """A Java task calling the client from many threads must succeed."""
+        resp = self.airflow_client.trigger_dag(
+            "java_annotation_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_annotation_example",
+            run_id=run_id,
+            timeout=_JAVA_TASK_TIMEOUT,
+        )
+
+        ti_resp = 
self.airflow_client.get_task_instances(dag_id="java_annotation_example", 
run_id=run_id)
+        ti_map = {ti["task_id"]: ti for ti in ti_resp.get("task_instances", 
[])}
+        concurrent_ti = ti_map.get("concurrent", {})
+
+        assert concurrent_ti.get("state") == "success", (
+            f"Java 'concurrent' task did not succeed.\n"
+            f"  task state : {concurrent_ti.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/AnnotationExample.java 
b/java-sdk/example/src/java/org/apache/airflow/example/AnnotationExample.java
index 85c4162f8ca..bb715a73cb5 100644
--- 
a/java-sdk/example/src/java/org/apache/airflow/example/AnnotationExample.java
+++ 
b/java-sdk/example/src/java/org/apache/airflow/example/AnnotationExample.java
@@ -21,7 +21,10 @@ package org.apache.airflow.example;
 
 import static java.lang.System.Logger.Level.INFO;
 
+import java.util.ArrayList;
 import java.util.Date;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
 import org.apache.airflow.sdk.*;
 
 @SuppressWarnings("DuplicatedCode")
@@ -72,4 +75,25 @@ public class AnnotationExample {
     }
     log.log(INFO, "Recovered on retry, try number {0}", context.ti.tryNumber);
   }
+
+  // Verify one supervisor channel can handle client calls across threads.
+  @Builder.Task(id = "concurrent")
+  public void concurrentClientCalls(Client client) throws Exception {
+    var pool = Executors.newFixedThreadPool(8);
+    try {
+      var calls = new ArrayList<Callable<String>>();
+      for (var i = 0; i < 32; i++) {
+        calls.add(() -> client.getConnection("test_http").host);
+      }
+      for (var future : pool.invokeAll(calls)) {
+        var host = future.get();
+        if (!"example.com".equals(host)) {
+          throw new RuntimeException("concurrent getConnection returned wrong 
host: " + host);
+        }
+      }
+      log.log(INFO, "All concurrent client calls returned the correct 
connection");
+    } finally {
+      pool.shutdown();
+    }
+  }
 }
diff --git a/java-sdk/example/src/resources/dags/java_examples.py 
b/java-sdk/example/src/resources/dags/java_examples.py
index e3d217e8eaa..38be0e9e947 100644
--- a/java-sdk/example/src/resources/dags/java_examples.py
+++ b/java-sdk/example/src/resources/dags/java_examples.py
@@ -41,6 +41,10 @@ def transform(): ...
 def load(): ...
 
 
[email protected](queue="java")
+def concurrent(): ...
+
+
 @task()
 def python_task_2(transformed):
     print("python_task_2")
@@ -61,6 +65,7 @@ def java_annotation_example():
     python_task_1() >> extract() >> transformed
     python_task_2(transformed)
     transformed >> load()
+    concurrent()
 
 
 java_interface_example()
diff --git 
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Comm.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Comm.kt
index 5d917af3291..3312d99919c 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Comm.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Comm.kt
@@ -23,6 +23,8 @@ import io.ktor.utils.io.ByteReadChannel
 import io.ktor.utils.io.ByteWriteChannel
 import io.ktor.utils.io.readByteArray
 import io.ktor.utils.io.writeByteArray
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
 import org.apache.airflow.sdk.ApiError
 import org.apache.airflow.sdk.Bundle
 import org.apache.airflow.sdk.execution.comm.ErrorResponse
@@ -56,6 +58,7 @@ class CoordinatorComm(
 
   private val nextId = AtomicInt(0)
   private var shutDownRequested = false
+  private val commMutex = Mutex()
 
   suspend fun startProcessing() {
     while (!shutDownRequested) {
@@ -110,17 +113,21 @@ class CoordinatorComm(
 
   @Throws(ApiError::class)
   suspend fun communicateImpl(body: Any): Any {
-    var frame: IncomingFrame? = null
+    val requestId = nextId.fetchAndAdd(1)
+    return commMutex.withLock {
+      var frame: IncomingFrame? = null
 
-    suspend fun handle(f: IncomingFrame) {
-      frame = f
-    }
-    sendMessage(nextId.fetchAndAdd(1), body)
-    processOnce(::handle)
-    if (frame == null) {
-      throw ApiError("No response received")
+      suspend fun handle(f: IncomingFrame) {
+        frame = f
+      }
+      sendMessage(requestId, body)
+      processOnce(::handle)
+      val received = frame ?: throw ApiError("No response received")
+      if (received.id != requestId) {
+        throw ApiError("response id ${received.id} does not match request id 
$requestId")
+      }
+      received.body ?: Unit
     }
-    return frame.body ?: Unit
   }
 
   @Throws(ApiError::class)
diff --git 
a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/CommTest.kt 
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/CommTest.kt
index 30a5db40a33..ead1d1dbe51 100644
--- a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/CommTest.kt
+++ b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/CommTest.kt
@@ -19,12 +19,27 @@
 
 package org.apache.airflow.sdk.execution
 
+import io.ktor.utils.io.ByteChannel
+import io.ktor.utils.io.readByteArray
+import io.ktor.utils.io.writeByteArray
+import kotlinx.coroutines.runBlocking
+import org.apache.airflow.sdk.ApiError
+import org.apache.airflow.sdk.Bundle
+import org.apache.airflow.sdk.execution.comm.GetVariable
 import org.apache.airflow.sdk.execution.comm.StartupDetails
+import org.apache.airflow.sdk.execution.comm.TaskInstance
+import org.apache.airflow.sdk.execution.comm.XComResult
 import org.junit.jupiter.api.Assertions
 import org.junit.jupiter.api.DisplayName
 import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Timeout
+import org.msgpack.core.MessagePack
+import java.io.ByteArrayOutputStream
 import java.time.OffsetDateTime
 import java.time.ZoneOffset
+import java.util.concurrent.ConcurrentLinkedQueue
+import java.util.concurrent.TimeUnit
+import org.apache.airflow.sdk.Client as PublicClient
 
 fun byteArrayFromHexString(hexString: String): ByteArray =
   hexString
@@ -83,4 +98,96 @@ class CommsTest {
 
     Assertions.assertEquals(expected, actual)
   }
+
+  private fun responseFrame(id: Int): ByteArray {
+    val out = ByteArrayOutputStream()
+    MessagePack.newDefaultPacker(out).use { packer ->
+      packer.packArrayHeader(3)
+      packer.packInt(id)
+      packer.packMapHeader(3)
+      packer.packString("type")
+      packer.packString("XComResult")
+      packer.packString("key")
+      packer.packString("return_value")
+      packer.packString("value")
+      packer.packInt(1)
+      packer.packNil()
+    }
+    return out.toByteArray()
+  }
+
+  @Test
+  @DisplayName("Should reject a response whose id does not match the request")
+  fun rejectsResponseWhoseIdDoesNotMatchRequest() {
+    val toClient = ByteChannel(autoFlush = true)
+    val fromClient = ByteChannel(autoFlush = true)
+    val comm = CoordinatorComm(Bundle(emptyList()), toClient, fromClient)
+
+    val error =
+      Assertions.assertThrows(ApiError::class.java) {
+        runBlocking {
+          // The first request is sent with id 0. The 99 doesn't match 0.
+          val payload = responseFrame(99)
+          toClient.writeByteArray(Frame.lengthPrefix(payload.size))
+          toClient.writeByteArray(payload)
+          comm.communicate<XComResult>(GetVariable().also { it.key = "k" })
+        }
+      }
+    Assertions.assertTrue(
+      error.message!!.contains("does not match"),
+      "expected an id-mismatch error, got: ${error.message}",
+    )
+  }
+
+  @Test
+  @DisplayName("Should stay correlated when the client is called from many 
threads")
+  @Timeout(value = 30, unit = TimeUnit.SECONDS)
+  fun publicClientSurvivesConcurrentThreadCalls() {
+    val toClient = ByteChannel(autoFlush = true)
+    val fromClient = ByteChannel(autoFlush = true)
+    val comm = CoordinatorComm(Bundle(emptyList()), toClient, fromClient)
+    val details =
+      StartupDetails().also {
+        it.ti =
+          TaskInstance().also { ti ->
+            ti.dagId = "d"
+            ti.runId = "r"
+          }
+      }
+    val client = PublicClient(details, CoordinatorClient(comm))
+    val n = 50
+
+    val server =
+      Thread {
+        runBlocking {
+          repeat(n) {
+            val prefix = fromClient.readByteArray(4)
+            val payload = 
fromClient.readByteArray(Frame.parseLengthPrefix(prefix))
+            val response = responseFrame(CoordinatorComm.decode(payload).id)
+            toClient.writeByteArray(Frame.lengthPrefix(response.size))
+            toClient.writeByteArray(response)
+          }
+        }
+      }
+    server.start()
+
+    val errors = ConcurrentLinkedQueue<Throwable>()
+    val results = ConcurrentLinkedQueue<Any?>()
+    val workers =
+      (1..n).map {
+        Thread {
+          try {
+            results.add(client.getXCom(taskId = "upstream"))
+          } catch (e: Throwable) {
+            errors.add(e)
+          }
+        }
+      }
+    workers.forEach { it.start() }
+    workers.forEach { it.join() }
+    server.join()
+
+    Assertions.assertTrue(errors.isEmpty(), "concurrent public-client calls 
failed: $errors")
+    Assertions.assertEquals(n, results.size)
+  }
 }

Reply via email to