mirror of
https://github.com/apache/airflow.git
synced 2026-11-04 07:12:48 +00:00
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 <payang@apache.org>
This commit is contained in:
1 parent
07f5458df8
commit
abd837a382
6 files changed
+493
-86
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
val waiter = CompletableDeferred<IncomingFrame>()
|
||||
|
||||
suspend fun handle(f: IncomingFrame) {
|
||||
frame = f
|
||||
stateMutex.withLock {
|
||||
readError?.let { throw it }
|
||||
pending[requestId] = waiter
|
||||
if (!dispatcherStarted) {
|
||||
dispatcherStarted = true
|
||||
dispatcherScope.launch { readLoop() }
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
} 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) }
|
||||
}
|
||||
}
|
||||
@@ -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) =
|
||||
|
||||
@@ -198,16 +198,16 @@ internal class Logger(
|
||||
arguments: Map<String, Any> = emptyMap(),
|
||||
) = log(Level.DEBUG, message, arguments)
|
||||
|
||||
fun error(
|
||||
message: String,
|
||||
arguments: Map<String, Any> = emptyMap(),
|
||||
) = log(Level.ERROR, message, arguments)
|
||||
|
||||
fun warning(
|
||||
message: String,
|
||||
arguments: Map<String, Any> = emptyMap(),
|
||||
) = log(Level.WARNING, message, arguments)
|
||||
|
||||
fun error(
|
||||
message: String,
|
||||
arguments: Map<String, Any> = emptyMap(),
|
||||
) = log(Level.ERROR, message, arguments)
|
||||
|
||||
private fun log(
|
||||
level: Level,
|
||||
event: String,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 error =
|
||||
Assertions.assertThrows(ApiError::class.java) {
|
||||
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" })
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user