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 abd837a382c Java SDK: Change coordinator comm to use multiplexing 
(#69254)
abd837a382c is described below

commit abd837a382cc71cd0a90f0cb2e9dffedcce7343a
Author: PoAn Yang <[email protected]>
AuthorDate: Thu Jul 30 14:54:30 2026 +0900

    Java SDK: Change coordinator comm to use multiplexing (#69254)
    
    Serializing each client's whole send → receive → deserialize round trip
    behind one lock (#69080) allows only a single request in flight at a
    time, so a task issuing concurrent calls over the shared comm socket
    pays a full, serialized round trip per call.
    Every frame already carries a request id, so a single background
    dispatcher can read the socket and route each response by id, with the
    lock guarding only the write.
    
    Signed-off-by: PoAn Yang <[email protected]>
---
 .../main/kotlin/org/apache/airflow/sdk/Server.kt   |  29 ++-
 .../org/apache/airflow/sdk/execution/Comm.kt       | 170 +++++++++++------
 .../org/apache/airflow/sdk/execution/Frame.kt      |  18 +-
 .../org/apache/airflow/sdk/execution/Logger.kt     |   8 +-
 .../kotlin/org/apache/airflow/sdk/ServerTest.kt    | 142 ++++++++++++++
 .../org/apache/airflow/sdk/execution/CommTest.kt   | 210 ++++++++++++++++++---
 6 files changed, 492 insertions(+), 85 deletions(-)

diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Server.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Server.kt
index 6f82ca3c4c3..654baba944b 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Server.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Server.kt
@@ -33,6 +33,9 @@ import kotlinx.coroutines.runBlocking
 import org.apache.airflow.sdk.execution.CoordinatorComm
 import org.apache.airflow.sdk.execution.LogSender
 import org.apache.airflow.sdk.execution.Logger
+import org.apache.airflow.sdk.execution.comm.ErrorResponse
+import org.apache.airflow.sdk.execution.comm.StartupDetails
+import org.apache.airflow.sdk.execution.runTask
 import kotlin.text.substringAfterLast
 import kotlin.text.substringBeforeLast
 
@@ -147,10 +150,11 @@ class Server(
           aSocket(SelectorManager(Dispatchers.IO)).tcp().connect(comm).use { 
socket ->
             logger.debug("Connected comm", mapOf("addr" to comm))
             CoordinatorComm(
-              bundle,
               socket.openReadChannel(),
               socket.openWriteChannel(autoFlush = true),
-            ).startProcessing()
+            ).use { coordinator ->
+              dispatchTask(bundle, coordinator)
+            }
           }
         } finally {
           deferral.complete(Unit)
@@ -164,4 +168,25 @@ class Server(
         }
       }
     }
+
+  internal suspend fun dispatchTask(
+    bundle: Bundle,
+    coordinator: CoordinatorComm,
+  ) {
+    val frame = coordinator.readMessage()
+    when (val body = frame.body) {
+      is StartupDetails -> runTaskAndReport(bundle, body, coordinator)
+      is ErrorResponse -> throw ApiError("[${body.error}] ${body.detail}")
+      else -> throw ApiError("Unexpected initial frame (id=${frame.id})")
+    }
+  }
+
+  private suspend fun runTaskAndReport(
+    bundle: Bundle,
+    startup: StartupDetails,
+    coordinator: CoordinatorComm,
+  ) {
+    val result = runTask(bundle, startup, coordinator)
+    coordinator.communicate<Unit>(result)
+  }
 }
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 3312d99919c..34815d7b7f1 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,12 +23,18 @@ 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.CancellationException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
 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
-import org.apache.airflow.sdk.execution.comm.StartupDetails
+import java.util.concurrent.ConcurrentHashMap
 import kotlin.concurrent.atomics.AtomicInt
 import kotlin.concurrent.atomics.ExperimentalAtomicApi
 
@@ -44,10 +50,9 @@ data class OutgoingFrame(
 
 @OptIn(ExperimentalAtomicApi::class)
 class CoordinatorComm(
-  private val bundle: Bundle,
   private val reader: ByteReadChannel,
   private val writer: ByteWriteChannel,
-) {
+) : AutoCloseable {
   internal companion object {
     private val logger = Logger(CoordinatorComm::class)
 
@@ -57,38 +62,21 @@ class CoordinatorComm(
   }
 
   private val nextId = AtomicInt(0)
-  private var shutDownRequested = false
-  private val commMutex = Mutex()
+  private val writeMutex = Mutex()
+  private val stateMutex = Mutex()
+  private val pending = ConcurrentHashMap<Int, 
CompletableDeferred<IncomingFrame>>()
 
-  suspend fun startProcessing() {
-    while (!shutDownRequested) {
-      processOnce(::handleIncoming)
-    }
-    logger.debug("Goodbye")
-  }
+  @Volatile
+  private var readError: ApiError? = null
+  private var dispatcherStarted = false
 
-  private suspend fun processOnce(handle: suspend (IncomingFrame) -> Unit) {
-    val prefix = reader.readByteArray(4) // First 4 bytes as length.
-    if (prefix.size != 4) { // Something is terribly wrong. Let's bail.
-      logger.error("Need 4 prefix bytes", mapOf("actual" to prefix.size))
-      shutDownRequested = true
-      return
-    }
+  private val dispatcherScope = CoroutineScope(Dispatchers.IO + 
SupervisorJob())
 
-    val payloadLength = Frame.parseLengthPrefix(prefix)
-    val payload = reader.readByteArray(payloadLength)
-    if (payload.size != payloadLength) { // Something is terribly wrong. Let's 
bail.
-      logger.error(
-        "Payload length not right",
-        mapOf("expect" to payloadLength, "receive" to payload.size),
-      )
-      shutDownRequested = true
-      return
+  suspend fun readMessage(): IncomingFrame =
+    stateMutex.withLock {
+      check(!dispatcherStarted) { "readMessage cannot be used after the 
dispatcher has started" }
+      readFrame()
     }
-    val frame = decode(payload)
-    logger.debug("Handling", mapOf("id" to frame.id))
-    handle(frame)
-  }
 
   private suspend fun sendMessage(
     id: Int,
@@ -96,38 +84,41 @@ class CoordinatorComm(
   ) {
     val data = encode(OutgoingFrame(id, body))
     logger.debug("Sending", mapOf("id" to id, "body" to body))
-    writer.writeByteArray(Frame.lengthPrefix(data.size))
-    writer.writeByteArray(data)
-  }
-
-  suspend fun handleIncoming(frame: IncomingFrame) {
-    when (val request = frame.body) {
-      null -> {}
-      is ErrorResponse -> throw ApiError("[${request.error}] 
${request.detail}")
-      is StartupDetails -> {
-        communicate<Unit>(runTask(bundle, request, this))
-        shutDownRequested = true
-      }
+    writeMutex.withLock {
+      writer.writeByteArray(Frame.lengthPrefix(data.size) + data)
     }
   }
 
   @Throws(ApiError::class)
   suspend fun communicateImpl(body: Any): Any {
     val requestId = nextId.fetchAndAdd(1)
-    return commMutex.withLock {
-      var frame: IncomingFrame? = null
-
-      suspend fun handle(f: IncomingFrame) {
-        frame = f
+    val waiter = CompletableDeferred<IncomingFrame>()
+
+    stateMutex.withLock {
+      readError?.let { throw it }
+      pending[requestId] = waiter
+      if (!dispatcherStarted) {
+        dispatcherStarted = true
+        dispatcherScope.launch { readLoop() }
       }
+    }
+
+    // A close() concurrent with the block above either drains this waiter (it
+    // was registered first) or is visible here, so no caller is left awaiting 
a
+    // comm that is already gone.
+    readError?.let {
+      pending.remove(requestId)
+      throw it
+    }
+
+    try {
       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
+    } catch (e: Throwable) {
+      pending.remove(requestId)
+      throw e
     }
+
+    return waiter.await().body ?: Unit
   }
 
   @Throws(ApiError::class)
@@ -138,4 +129,73 @@ class CoordinatorComm(
       else -> throw ApiError("Unexpected response type 
${response::class.java}")
     }
   }
+
+  /**
+   * Stop the dispatcher and fail anything still awaiting a response.
+   */
+  override fun close() {
+    dispatcherScope.cancel()
+    failAllWaiters(readError ?: ApiError("Coordinator comm closed"))
+  }
+
+  private suspend fun readPayload(): ByteArray {
+    val prefix = reader.readByteArray(4) // First 4 bytes as length.
+    if (prefix.size != 4) {
+      throw ApiError("Coordinator socket closed while reading frame length")
+    }
+    val payloadLength = Frame.parseLengthPrefix(prefix)
+    val payload = reader.readByteArray(payloadLength)
+    if (payload.size != payloadLength) {
+      throw ApiError("Coordinator socket closed while reading frame payload")
+    }
+    return payload
+  }
+
+  private suspend fun readFrame(): IncomingFrame {
+    val frame = decode(readPayload())
+    logger.debug("Received", mapOf("id" to frame.id))
+    return frame
+  }
+
+  private suspend fun readLoop() {
+    while (true) {
+      val raw =
+        try {
+          Frame.decodeRaw(readPayload())
+        } catch (e: CancellationException) {
+          // Coroutine cancellation is delivered by throwing this exception, 
and
+          // cooperative cancellation requires rethrowing it so it propagates.
+          throw e
+        } catch (e: Throwable) {
+          failAllWaiters(e)
+          return
+        }
+      logger.debug("Received", mapOf("id" to raw.id))
+
+      val waiter = pending.remove(raw.id)
+      if (waiter == null) {
+        logger.warning("Discarding response with no matching request", 
mapOf("id" to raw.id))
+        continue
+      }
+
+      try {
+        waiter.complete(IncomingFrame(raw.id, Frame.decodeBody(raw)))
+      } catch (e: CancellationException) {
+        throw e
+      } catch (e: Throwable) {
+        waiter.completeExceptionally(
+          ApiError("Cannot decode response for request ${raw.id}: 
${e.message}")
+            .apply { initCause(e) },
+        )
+      }
+    }
+  }
+
+  private fun failAllWaiters(cause: Throwable) {
+    val error =
+      cause as? ApiError
+        ?: ApiError("Coordinator comm closed: ${cause.message}").apply { 
initCause(cause) }
+    readError = error
+    pending.keys.forEach { id -> 
pending.remove(id)?.completeExceptionally(error) }
+  }
 }
diff --git 
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Frame.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Frame.kt
index a3815d61401..4d488fae0fc 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Frame.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Frame.kt
@@ -28,6 +28,12 @@ import org.apache.airflow.sdk.execution.comm.Discriminator
 import org.msgpack.core.MessagePack
 import java.io.ByteArrayOutputStream
 
+data class RawFrame(
+  val id: Int,
+  val rawBody: Any?,
+  val rawError: Any?,
+)
+
 object Frame {
   private val mapper =
     ObjectMapper().apply {
@@ -43,7 +49,7 @@ object Frame {
     body: Any,
   ): ByteArray = encodeFrame(id, body)
 
-  fun decode(bytes: ByteArray): IncomingFrame {
+  fun decodeRaw(bytes: ByteArray): RawFrame {
     val unpacker = MessagePack.newDefaultUnpacker(bytes)
     val headerSize = unpacker.unpackArrayHeader()
     check(headerSize >= 1) { "Unexpected Task SDK frame arity $headerSize" }
@@ -53,8 +59,14 @@ object Frame {
     val rawError = if (headerSize >= 3) unpacker.unpackAny() else null
     unpacker.close()
 
-    val body = decodeMessage(rawError) ?: decodeMessage(rawBody)
-    return IncomingFrame(id, body)
+    return RawFrame(id, rawBody, rawError)
+  }
+
+  fun decodeBody(raw: RawFrame): Any? = decodeMessage(raw.rawError) ?: 
decodeMessage(raw.rawBody)
+
+  fun decode(bytes: ByteArray): IncomingFrame {
+    val raw = decodeRaw(bytes)
+    return IncomingFrame(raw.id, decodeBody(raw))
   }
 
   fun lengthPrefix(length: Int) =
diff --git 
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Logger.kt 
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Logger.kt
index f39490eb0a9..228d59e7240 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Logger.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Logger.kt
@@ -198,15 +198,15 @@ internal class Logger(
     arguments: Map<String, Any> = emptyMap(),
   ) = log(Level.DEBUG, message, arguments)
 
-  fun error(
+  fun warning(
     message: String,
     arguments: Map<String, Any> = emptyMap(),
-  ) = log(Level.ERROR, message, arguments)
+  ) = log(Level.WARNING, message, arguments)
 
-  fun warning(
+  fun error(
     message: String,
     arguments: Map<String, Any> = emptyMap(),
-  ) = log(Level.WARNING, message, arguments)
+  ) = log(Level.ERROR, message, arguments)
 
   private fun log(
     level: Level,
diff --git a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/ServerTest.kt 
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/ServerTest.kt
new file mode 100644
index 00000000000..6cae7a4c27b
--- /dev/null
+++ b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/ServerTest.kt
@@ -0,0 +1,142 @@
+/*
+ * 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.sdk
+
+import io.ktor.network.sockets.InetSocketAddress
+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.execution.CoordinatorComm
+import org.apache.airflow.sdk.execution.Frame
+import org.apache.airflow.sdk.execution.IncomingFrame
+import org.apache.airflow.sdk.execution.comm.TaskState
+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.util.concurrent.ArrayBlockingQueue
+import java.util.concurrent.TimeUnit
+
+class ServerTest {
+  private fun hexToBytes(hex: String): ByteArray =
+    hex
+      .split(' ', '\r', '\n')
+      .filter { it.isNotEmpty() }
+      .map { it.toUByte(16).toByte() }
+      .toByteArray()
+
+  private fun ackFrame(id: Int): ByteArray {
+    val out = ByteArrayOutputStream()
+    MessagePack.newDefaultPacker(out).use { packer ->
+      packer.packArrayHeader(2)
+      packer.packInt(id)
+      packer.packNil()
+    }
+    return out.toByteArray()
+  }
+
+  private suspend fun ByteChannel.writeFrame(payload: ByteArray) {
+    writeByteArray(Frame.lengthPrefix(payload.size))
+    writeByteArray(payload)
+  }
+
+  @Test
+  @DisplayName("Should run the task and report the result as a normal awaited 
request")
+  @Timeout(value = 30, unit = TimeUnit.SECONDS)
+  fun runsTaskAndReportsResultAsAwaitedRequest() {
+    val toServer = ByteChannel(autoFlush = true)
+    val fromServer = ByteChannel(autoFlush = true)
+    val comm = CoordinatorComm(toServer, fromServer)
+    val server = Server(InetSocketAddress("localhost", 0), 
InetSocketAddress("localhost", 0))
+
+    val reported = ArrayBlockingQueue<IncomingFrame>(1)
+    val supervisor =
+      Thread {
+        runBlocking {
+          // Deliver the StartupDetails frame (id 2, dag_id "c", task_id "a").
+          toServer.writeFrame(hexToBytes(STARTUP_HEX))
+          val prefix = fromServer.readByteArray(4)
+          val payload = 
fromServer.readByteArray(Frame.parseLengthPrefix(prefix))
+          val result = CoordinatorComm.decode(payload)
+          reported.put(result)
+          toServer.writeFrame(ackFrame(result.id))
+        }
+      }
+    supervisor.start()
+
+    runBlocking { server.dispatchTask(Bundle(emptyList()), comm) }
+    supervisor.join()
+
+    val result = reported.take()
+    Assertions.assertEquals(0, result.id)
+    Assertions.assertInstanceOf(TaskState::class.java, result.body)
+    Assertions.assertEquals(TaskState.State.REMOVED, (result.body as 
TaskState).state)
+    comm.close()
+  }
+
+  @Test
+  @DisplayName("Should fail when the initial frame is not startup details")
+  @Timeout(value = 30, unit = TimeUnit.SECONDS)
+  fun failsWhenInitialFrameIsNotStartupDetails() {
+    val toServer = ByteChannel(autoFlush = true)
+    val fromServer = ByteChannel(autoFlush = true)
+    val comm = CoordinatorComm(toServer, fromServer)
+    val server = Server(InetSocketAddress("localhost", 0), 
InetSocketAddress("localhost", 0))
+
+    Assertions.assertThrows(ApiError::class.java) {
+      runBlocking {
+        toServer.writeFrame(ackFrame(7))
+        server.dispatchTask(Bundle(emptyList()), comm)
+      }
+    }
+    comm.close()
+  }
+
+  private companion object {
+    // [2, msg, null] with msg coming from
+    // 
https://github.com/astronomer/airflow/blob/f39c8da8/task-sdk/tests/task_sdk/execution_time/test_comms.py#L73-L108
+    val STARTUP_HEX =
+      """
+      92 02 88 a4 74 79 70 65 ae 53 74 61 72 74 75 70 44 65 74 61 69 6c 73 a2 
74 69 86 a2 69 64 d9 24
+      34 64 38 32 38 61 36 32 2d 61 34 31 37 2d 34 39 33 36 2d 61 37 61 36 2d 
32 62 33 66 61 62 61 63
+      65 63 61 62 a7 74 61 73 6b 5f 69 64 a1 61 aa 74 72 79 5f 6e 75 6d 62 65 
72 01 a6 72 75 6e 5f 69
+      64 a1 62 a6 64 61 67 5f 69 64 a1 63 ae 64 61 67 5f 76 65 72 73 69 6f 6e 
5f 69 64 d9 24 34 64 38
+      32 38 61 36 32 2d 61 34 31 37 2d 34 39 33 36 2d 61 37 61 36 2d 32 62 33 
66 61 62 61 63 65 63 61
+      62 aa 74 69 5f 63 6f 6e 74 65 78 74 85 a7 64 61 67 5f 72 75 6e 8c a6 64 
61 67 5f 69 64 a1 63 a6
+      72 75 6e 5f 69 64 a1 62 ac 6c 6f 67 69 63 61 6c 5f 64 61 74 65 b4 32 30 
32 34 2d 31 32 2d 30 31
+      54 30 31 3a 30 30 3a 30 30 5a b3 64 61 74 61 5f 69 6e 74 65 72 76 61 6c 
5f 73 74 61 72 74 b4 32
+      30 32 34 2d 31 32 2d 30 31 54 30 30 3a 30 30 3a 30 30 5a b1 64 61 74 61 
5f 69 6e 74 65 72 76 61
+      6c 5f 65 6e 64 b4 32 30 32 34 2d 31 32 2d 30 31 54 30 31 3a 30 30 3a 30 
30 5a aa 73 74 61 72 74
+      5f 64 61 74 65 b4 32 30 32 34 2d 31 32 2d 30 31 54 30 31 3a 30 30 3a 30 
30 5a a9 72 75 6e 5f 61
+      66 74 65 72 b4 32 30 32 34 2d 31 32 2d 30 31 54 30 31 3a 30 30 3a 30 30 
5a a8 65 6e 64 5f 64 61
+      74 65 c0 a8 72 75 6e 5f 74 79 70 65 a6 6d 61 6e 75 61 6c a5 73 74 61 74 
65 a7 73 75 63 63 65 73
+      73 a4 63 6f 6e 66 c0 b5 63 6f 6e 73 75 6d 65 64 5f 61 73 73 65 74 5f 65 
76 65 6e 74 73 90 a9 6d
+      61 78 5f 74 72 69 65 73 00 ac 73 68 6f 75 6c 64 5f 72 65 74 72 79 c2 a9 
76 61 72 69 61 62 6c 65
+      73 c0 ab 63 6f 6e 6e 65 63 74 69 6f 6e 73 c0 a4 66 69 6c 65 a9 2f 64 65 
76 2f 6e 75 6c 6c aa 73
+      74 61 72 74 5f 64 61 74 65 b4 32 30 32 34 2d 31 32 2d 30 31 54 30 31 3a 
30 30 3a 30 30 5a ac 64
+      61 67 5f 72 65 6c 5f 70 61 74 68 a9 2f 64 65 76 2f 6e 75 6c 6c ab 62 75 
6e 64 6c 65 5f 69 6e 66
+      6f 82 a4 6e 61 6d 65 a8 61 6e 79 2d 6e 61 6d 65 a7 76 65 72 73 69 6f 6e 
ab 61 6e 79 2d 76 65 72
+      73 69 6f 6e b2 73 65 6e 74 72 79 5f 69 6e 74 65 67 72 61 74 69 6f 6e a0 
c0
+      """.trimIndent()
+  }
+}
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 ead1d1dbe51..978e377f52d 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
@@ -22,9 +22,10 @@ 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.async
 import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.supervisorScope
 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
@@ -99,7 +100,10 @@ class CommsTest {
     Assertions.assertEquals(expected, actual)
   }
 
-  private fun responseFrame(id: Int): ByteArray {
+  private fun responseFrame(
+    id: Int,
+    key: String = "return_value",
+  ): ByteArray {
     val out = ByteArrayOutputStream()
     MessagePack.newDefaultPacker(out).use { packer ->
       packer.packArrayHeader(3)
@@ -108,7 +112,7 @@ class CommsTest {
       packer.packString("type")
       packer.packString("XComResult")
       packer.packString("key")
-      packer.packString("return_value")
+      packer.packString(key)
       packer.packString("value")
       packer.packInt(1)
       packer.packNil()
@@ -116,27 +120,93 @@ class CommsTest {
     return out.toByteArray()
   }
 
+  private fun unknownTypeFrame(id: Int): ByteArray {
+    val out = ByteArrayOutputStream()
+    MessagePack.newDefaultPacker(out).use { packer ->
+      packer.packArrayHeader(3)
+      packer.packInt(id)
+      packer.packMapHeader(1)
+      packer.packString("type")
+      packer.packString("SomeFutureMessage")
+      packer.packNil()
+    }
+    return out.toByteArray()
+  }
+
+  private suspend fun ByteChannel.writeFrame(payload: ByteArray) {
+    writeByteArray(Frame.lengthPrefix(payload.size))
+    writeByteArray(payload)
+  }
+
+  private suspend fun ByteChannel.readOneRequest() {
+    val prefix = readByteArray(4)
+    readByteArray(Frame.parseLengthPrefix(prefix))
+  }
+
   @Test
-  @DisplayName("Should reject a response whose id does not match the request")
-  fun rejectsResponseWhoseIdDoesNotMatchRequest() {
+  @DisplayName("Should discard a frame with no matching waiter and still 
deliver the correct response")
+  @Timeout(value = 30, unit = TimeUnit.SECONDS)
+  fun discardsUnmatchedFrameAndDeliversCorrectResponse() {
     val toClient = ByteChannel(autoFlush = true)
     val fromClient = ByteChannel(autoFlush = true)
-    val comm = CoordinatorComm(Bundle(emptyList()), toClient, fromClient)
+    val comm = CoordinatorComm(toClient, fromClient)
+
+    val result =
+      runBlocking {
+        // A frame whose id (99) matches no in-flight request.
+        // The dispatcher must drop the stray frame and still hand id 0 to its 
waiter.
+        toClient.writeFrame(responseFrame(99, key = "stray"))
+        toClient.writeFrame(responseFrame(0, key = "return_value"))
+        comm.communicate<XComResult>(GetVariable().also { it.key = "k" })
+      }
 
-    val error =
-      Assertions.assertThrows(ApiError::class.java) {
+    Assertions.assertEquals("return_value", result.key)
+    comm.close()
+  }
+
+  @Test
+  @DisplayName("Should keep many requests in flight and match out-of-order 
responses")
+  @Timeout(value = 30, unit = TimeUnit.SECONDS)
+  fun allowsMultipleInFlightRequestsAnsweredOutOfOrder() {
+    val toClient = ByteChannel(autoFlush = true)
+    val fromClient = ByteChannel(autoFlush = true)
+    val comm = CoordinatorComm(toClient, fromClient)
+    val n = 10
+
+    // Collect every request before answering any, then reply in reverse order.
+    val server =
+      Thread {
         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" })
+          val ids =
+            (0 until n).map {
+              val prefix = fromClient.readByteArray(4)
+              val payload = 
fromClient.readByteArray(Frame.parseLengthPrefix(prefix))
+              CoordinatorComm.decode(payload).id
+            }
+          ids.reversed().forEach { toClient.writeFrame(responseFrame(it)) }
         }
       }
-    Assertions.assertTrue(
-      error.message!!.contains("does not match"),
-      "expected an id-mismatch error, got: ${error.message}",
-    )
+    server.start()
+
+    val errors = ConcurrentLinkedQueue<Throwable>()
+    val results = ConcurrentLinkedQueue<XComResult>()
+    val workers =
+      (1..n).map {
+        Thread {
+          try {
+            results.add(runBlocking { 
comm.communicate<XComResult>(GetVariable().also { it.key = "k" }) })
+          } catch (e: Throwable) {
+            errors.add(e)
+          }
+        }
+      }
+    workers.forEach { it.start() }
+    workers.forEach { it.join() }
+    server.join()
+
+    Assertions.assertTrue(errors.isEmpty(), "concurrent in-flight calls 
failed: $errors")
+    Assertions.assertEquals(n, results.size)
+    comm.close()
   }
 
   @Test
@@ -145,7 +215,7 @@ class CommsTest {
   fun publicClientSurvivesConcurrentThreadCalls() {
     val toClient = ByteChannel(autoFlush = true)
     val fromClient = ByteChannel(autoFlush = true)
-    val comm = CoordinatorComm(Bundle(emptyList()), toClient, fromClient)
+    val comm = CoordinatorComm(toClient, fromClient)
     val details =
       StartupDetails().also {
         it.ti =
@@ -163,9 +233,7 @@ class CommsTest {
           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)
+            
toClient.writeFrame(responseFrame(CoordinatorComm.decode(payload).id))
           }
         }
       }
@@ -189,5 +257,105 @@ class CommsTest {
 
     Assertions.assertTrue(errors.isEmpty(), "concurrent public-client calls 
failed: $errors")
     Assertions.assertEquals(n, results.size)
+    comm.close()
+  }
+
+  @Test
+  @DisplayName("Should fail a pending call when the coordinator socket closes")
+  @Timeout(value = 30, unit = TimeUnit.SECONDS)
+  fun failsPendingCallWhenSocketCloses() {
+    val toClient = ByteChannel(autoFlush = true)
+    val fromClient = ByteChannel(autoFlush = true)
+    val comm = CoordinatorComm(toClient, fromClient)
+
+    Assertions.assertThrows(ApiError::class.java) {
+      runBlocking {
+        val call = async { comm.communicate<XComResult>(GetVariable().also { 
it.key = "k" }) }
+        // No response is ever written; closing the read side must surface an
+        // error to the waiting caller instead of hanging forever.
+        toClient.flushAndClose()
+        call.await()
+      }
+    }
+    comm.close()
+  }
+
+  @Test
+  @DisplayName("Should fail a call that is still in flight when the comm is 
closed")
+  @Timeout(value = 30, unit = TimeUnit.SECONDS)
+  fun closeFailsInFlightCall() {
+    val toClient = ByteChannel(autoFlush = true)
+    val fromClient = ByteChannel(autoFlush = true)
+    val comm = CoordinatorComm(toClient, fromClient)
+
+    Assertions.assertThrows(ApiError::class.java) {
+      runBlocking {
+        val call = async { comm.communicate<XComResult>(GetVariable().also { 
it.key = "k" }) }
+        fromClient.readOneRequest()
+        // The waiter awaits in the caller's own scope, so closing the comm 
should make it stop waiting.
+        comm.close()
+        call.await()
+      }
+    }
+  }
+
+  @Test
+  @DisplayName("Should fail only the request whose response cannot be decoded")
+  @Timeout(value = 30, unit = TimeUnit.SECONDS)
+  fun undecodableResponseFailsOnlyItsOwnRequest() {
+    val toClient = ByteChannel(autoFlush = true)
+    val fromClient = ByteChannel(autoFlush = true)
+    val comm = CoordinatorComm(toClient, fromClient)
+
+    runBlocking {
+      supervisorScope {
+        // Start the calls one at a time so their ids are known: 0, then 1.
+        val undecodable = async { 
comm.communicate<XComResult>(GetVariable().also { it.key = "a" }) }
+        fromClient.readOneRequest()
+        val healthy = async { comm.communicate<XComResult>(GetVariable().also 
{ it.key = "b" }) }
+        fromClient.readOneRequest()
+
+        toClient.writeFrame(unknownTypeFrame(0))
+        toClient.writeFrame(responseFrame(1))
+
+        Assertions.assertInstanceOf(
+          ApiError::class.java,
+          runCatching { undecodable.await() }.exceptionOrNull(),
+          "a response naming an unknown type should fail its own request",
+        )
+        // The dispatcher survived the bad frame, so the other call still 
lands.
+        Assertions.assertEquals("return_value", healthy.await().key)
+      }
+    }
+    comm.close()
+  }
+
+  @Test
+  @DisplayName("Should keep the original cause when a malformed frame stops 
the dispatcher")
+  @Timeout(value = 30, unit = TimeUnit.SECONDS)
+  fun malformedFramePreservesOriginalCause() {
+    val toClient = ByteChannel(autoFlush = true)
+    val fromClient = ByteChannel(autoFlush = true)
+    val comm = CoordinatorComm(toClient, fromClient)
+
+    val failure =
+      runBlocking {
+        supervisorScope {
+          val call = async { comm.communicate<XComResult>(GetVariable().also { 
it.key = "k" }) }
+          fromClient.readOneRequest()
+          // Msgpack nil where the frame envelope belongs: without an id there 
is no
+          // request to attribute the failure to, so every waiter goes down 
with it.
+          toClient.writeFrame(byteArrayOf(0xc0.toByte()))
+          runCatching { call.await() }.exceptionOrNull()
+        }
+      }
+
+    Assertions.assertInstanceOf(ApiError::class.java, failure)
+    val causes = generateSequence(failure) { it.cause }.toList()
+    Assertions.assertTrue(
+      causes.any { it !is ApiError },
+      "the underlying decode failure should be preserved, got: $causes",
+    )
+    comm.close()
   }
 }

Reply via email to