Repository: eclipse-apoapsis/ort-server
Pull request #2330: Add a mode to let workers handle only a single message
Author: login: mnonnenmacher
Created: 2025-03-22T09:38:00Z | Merged: 2025-03-22T20:33:16Z

Add a way to configure workers to handle only a single message. This is useful when using Keda instead of the Kubernetes transport to create Kubernetes jobs which is used in an Azure deployment because the Kubernetes transport does not support the required configuration, and because it is more straightforward to configure the Kubernetes jobs with YAML files instead of environment variables passed to the orchestrator.


Diff:
diff --git a/orchestrator/src/main/kotlin/Entrypoint.kt b/orchestrator/src/main/kotlin/Entrypoint.kt
index b224e65516..651b8eede6 100644
--- a/orchestrator/src/main/kotlin/Entrypoint.kt
+++ b/orchestrator/src/main/kotlin/Entrypoint.kt
@@ -59,6 +59,7 @@ import org.eclipse.apoapsis.ortserver.model.repositories.RepositoryRepository
 import org.eclipse.apoapsis.ortserver.model.repositories.ScannerJobRepository
 import org.eclipse.apoapsis.ortserver.transport.EndpointComponent
 import org.eclipse.apoapsis.ortserver.transport.EndpointHandler
+import org.eclipse.apoapsis.ortserver.transport.EndpointHandlerResult
 import org.eclipse.apoapsis.ortserver.transport.OrchestratorEndpoint
 import org.eclipse.apoapsis.ortserver.utils.logging.withMdcContext
 
@@ -104,6 +105,8 @@ class OrchestratorComponent : EndpointComponent<OrchestratorMessage>(Orchestrato
             is WorkerError -> orchestrator.handleWorkerError(message.header, payload)
             is LostSchedule -> orchestrator.handleLostSchedule(message.header, payload)
         }
+
+        EndpointHandlerResult.CONTINUE
     }
 
     override fun customModules(): List<Module> {
diff --git a/transport/activemqartemis/src/main/kotlin/ArtemisMessageReceiverFactory.kt b/transport/activemqartemis/src/main/kotlin/ArtemisMessageReceiverFactory.kt
index f8cfedaa01..fafee599b5 100644
--- a/transport/activemqartemis/src/main/kotlin/ArtemisMessageReceiverFactory.kt
+++ b/transport/activemqartemis/src/main/kotlin/ArtemisMessageReceiverFactory.kt
@@ -27,6 +27,7 @@ import org.apache.qpid.jms.JmsConnectionFactory
 import org.eclipse.apoapsis.ortserver.config.ConfigManager
 import org.eclipse.apoapsis.ortserver.transport.Endpoint
 import org.eclipse.apoapsis.ortserver.transport.EndpointHandler
+import org.eclipse.apoapsis.ortserver.transport.EndpointHandlerResult
 import org.eclipse.apoapsis.ortserver.transport.MessageReceiverFactory
 import org.eclipse.apoapsis.ortserver.transport.json.JsonSerializer
 import org.eclipse.apoapsis.ortserver.utils.logging.withMdcContext
@@ -81,6 +82,8 @@ class ArtemisMessageReceiverFactory : MessageReceiverFactory {
             do {
                 runCatching {
                     val jmsMessage = consumer.receiveSave()
+                    var result = EndpointHandlerResult.CONTINUE
+
                     if (jmsMessage != null) {
                         val message = ArtemisMessageConverter.toTransportMessage(jmsMessage, serializer)
 
@@ -94,12 +97,12 @@ class ArtemisMessageReceiverFactory : MessageReceiverFactory {
                                 message.payload.javaClass.name
                             )
 
-                            handler(message)
+                            result = handler(message)
                         }
                     }
 
                     // jmsMessage is null when there is an unrecoverable error with the consumer; so exit the loop.
-                    running = jmsMessage != null
+                    running = result == EndpointHandlerResult.CONTINUE && jmsMessage != null
                 }.onFailure { exception ->
                     logger.error("Error during message processing.", exception)
                 }
diff --git a/transport/activemqartemis/src/test/kotlin/ArtemisMessageReceiverFactoryTest.kt b/transport/activemqartemis/src/test/kotlin/ArtemisMessageReceiverFactoryTest.kt
index 11c1cfb234..573903708f 100644
--- a/transport/activemqartemis/src/test/kotlin/ArtemisMessageReceiverFactoryTest.kt
+++ b/transport/activemqartemis/src/test/kotlin/ArtemisMessageReceiverFactoryTest.kt
@@ -20,15 +20,20 @@
 package org.eclipse.apoapsis.ortserver.transport.artemis
 
 import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.nulls.beNull
+import io.kotest.matchers.should
 
 import jakarta.jms.Session
 import jakarta.jms.TextMessage
 
+import java.util.concurrent.TimeUnit
+
 import org.apache.qpid.jms.JmsConnectionFactory
 
 import org.eclipse.apoapsis.ortserver.model.orchestrator.AnalyzerWorkerError
 import org.eclipse.apoapsis.ortserver.model.orchestrator.AnalyzerWorkerResult
 import org.eclipse.apoapsis.ortserver.model.orchestrator.OrchestratorMessage
+import org.eclipse.apoapsis.ortserver.transport.EndpointHandlerResult
 import org.eclipse.apoapsis.ortserver.transport.RUN_ID_PROPERTY
 import org.eclipse.apoapsis.ortserver.transport.TRACE_PROPERTY
 import org.eclipse.apoapsis.ortserver.transport.json.JsonSerializer
@@ -86,6 +91,32 @@ class ArtemisMessageReceiverFactoryTest : StringSpec({
             messageQueue.checkMessage(traceId, runId, payload)
         }
     }
+
+    "Message receiving is stopped when the handler returns STOP" {
+        val serializer = JsonSerializer.forType<OrchestratorMessage>()
+        val config = startArtemisContainer("orchestrator", "receiver")
+        val messageQueue = startReceiver(config, EndpointHandlerResult.STOP)
+
+        val connectionFactory = JmsConnectionFactory(config.getString("orchestrator.receiver.serverUri"))
+        connectionFactory.createConnection().use { connection ->
+            val session = connection.createSession()
+            val queue = session.createQueue(TEST_QUEUE_NAME)
+            val producer = session.createProducer(queue)
+
+            val traceId1 = "trace1"
+            val runId1 = 1L
+            val payload1 = AnalyzerWorkerError(21)
+            val traceId2 = "trace2"
+            val runId2 = 2L
+            val payload2 = AnalyzerWorkerResult(42)
+
+            producer.send(serializer.createMessage(session, traceId1, runId1, payload1))
+            producer.send(serializer.createMessage(session, traceId2, runId2, payload2))
+
+            messageQueue.checkMessage(traceId1, runId1, payload1)
+            messageQueue.poll(2, TimeUnit.SECONDS) should beNull()
+        }
+    }
 })
 
 /**
diff --git a/transport/kubernetes/src/test/kotlin/KubernetesMessageReceiverFactoryTest.kt b/transport/kubernetes/src/test/kotlin/KubernetesMessageReceiverFactoryTest.kt
index e09a058dcc..111113e1bf 100644
--- a/transport/kubernetes/src/test/kotlin/KubernetesMessageReceiverFactoryTest.kt
+++ b/transport/kubernetes/src/test/kotlin/KubernetesMessageReceiverFactoryTest.kt
@@ -36,6 +36,7 @@ import io.mockk.verify
 import org.eclipse.apoapsis.ortserver.config.ConfigManager
 import org.eclipse.apoapsis.ortserver.model.orchestrator.AnalyzerRequest
 import org.eclipse.apoapsis.ortserver.transport.AnalyzerEndpoint
+import org.eclipse.apoapsis.ortserver.transport.EndpointHandlerResult
 import org.eclipse.apoapsis.ortserver.transport.Message
 import org.eclipse.apoapsis.ortserver.transport.MessageHeader
 import org.eclipse.apoapsis.ortserver.transport.RUN_ID_PROPERTY
@@ -73,6 +74,7 @@ class KubernetesMessageReceiverFactoryTest : StringSpec({
             var receivedMessage: Message<AnalyzerRequest>? = null
             KubernetesMessageReceiverFactory().createReceiver(AnalyzerEndpoint, configManager) { message ->
                 receivedMessage = message
+                EndpointHandlerResult.CONTINUE
             }
 
             receivedMessage.shouldNotBeNull {
diff --git a/transport/rabbitmq/src/main/kotlin/RabbitMqMessageReceiverFactory.kt b/transport/rabbitmq/src/main/kotlin/RabbitMqMessageReceiverFactory.kt
index 23cadc13c7..ad1e6aa1b9 100644
--- a/transport/rabbitmq/src/main/kotlin/RabbitMqMessageReceiverFactory.kt
+++ b/transport/rabbitmq/src/main/kotlin/RabbitMqMessageReceiverFactory.kt
@@ -30,15 +30,19 @@ import com.rabbitmq.client.Envelope
 import java.util.concurrent.Executors
 import java.util.concurrent.ThreadFactory
 
+import kotlin.coroutines.coroutineContext
+
 import kotlinx.coroutines.cancel
 import kotlinx.coroutines.channels.ProducerScope
 import kotlinx.coroutines.channels.awaitClose
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.cancellable
 
 import org.eclipse.apoapsis.ortserver.config.ConfigManager
 import org.eclipse.apoapsis.ortserver.transport.Endpoint
 import org.eclipse.apoapsis.ortserver.transport.EndpointHandler
+import org.eclipse.apoapsis.ortserver.transport.EndpointHandlerResult
 import org.eclipse.apoapsis.ortserver.transport.Message
 import org.eclipse.apoapsis.ortserver.transport.MessageReceiverFactory
 import org.eclipse.apoapsis.ortserver.transport.json.JsonSerializer
@@ -62,7 +66,11 @@ class RabbitMqMessageReceiverFactory : MessageReceiverFactory {
         configManager: ConfigManager,
         handler: EndpointHandler<T>
     ) {
-        createMessageFlow(from, configManager).collect(handler)
+        createMessageFlow(from, configManager).cancellable().collect { message ->
+            if (handler(message) == EndpointHandlerResult.STOP) {
+                coroutineContext.cancel()
+            }
+        }
     }
 
     /**
diff --git a/transport/rabbitmq/src/test/kotlin/RabbitMqMessageReceiverFactoryTest.kt b/transport/rabbitmq/src/test/kotlin/RabbitMqMessageReceiverFactoryTest.kt
index 085531d926..b91c5f39f6 100644
--- a/transport/rabbitmq/src/test/kotlin/RabbitMqMessageReceiverFactoryTest.kt
+++ b/transport/rabbitmq/src/test/kotlin/RabbitMqMessageReceiverFactoryTest.kt
@@ -22,10 +22,15 @@ package org.eclipse.apoapsis.ortserver.transport.rabbitmq
 import com.rabbitmq.client.ConnectionFactory
 
 import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.nulls.beNull
+import io.kotest.matchers.should
+
+import java.util.concurrent.TimeUnit
 
 import org.eclipse.apoapsis.ortserver.model.orchestrator.AnalyzerWorkerError
 import org.eclipse.apoapsis.ortserver.model.orchestrator.AnalyzerWorkerResult
 import org.eclipse.apoapsis.ortserver.model.orchestrator.OrchestratorMessage
+import org.eclipse.apoapsis.ortserver.transport.EndpointHandlerResult
 import org.eclipse.apoapsis.ortserver.transport.MessageHeader
 import org.eclipse.apoapsis.ortserver.transport.json.JsonSerializer
 import org.eclipse.apoapsis.ortserver.transport.rabbitmq.RabbitMqMessageConverter.toAmqpProperties
@@ -122,4 +127,51 @@ class RabbitMqMessageReceiverFactoryTest : StringSpec({
             messageQueue.checkMessage(traceId, runId, payload)
         }
     }
+
+    "Message receiving is stopped when the handler returns STOP" {
+        val serializer = JsonSerializer.forType<OrchestratorMessage>()
+        val config = startRabbitMqContainer("orchestrator", "receiver")
+
+        val connectionFactory = ConnectionFactory().apply {
+            setUri(config.getString("orchestrator.receiver.serverUri"))
+        }
+
+        connectionFactory.newConnection().use { connection ->
+            val channel = connection.createChannel().also {
+                it.queueDeclare(
+                    /* queue = */ TEST_QUEUE_NAME,
+                    /* durable = */ false,
+                    /* exclusive = */ false,
+                    /* autoDelete = */ false,
+                    /* arguments = */ emptyMap()
+                )
+            }
+
+            val messageQueue = startReceiver(config, EndpointHandlerResult.STOP)
+
+            val traceId1 = "trace1"
+            val runId1 = 1L
+            val payload1 = AnalyzerWorkerError(1)
+            val traceId2 = "trace2"
+            val runId2 = 2L
+            val payload2 = AnalyzerWorkerResult(42)
+
+            channel.basicPublish(
+                "",
+                TEST_QUEUE_NAME,
+                MessageHeader(traceId1, runId1).toAmqpProperties(),
+                serializer.toJson(payload1).toByteArray()
+            )
+
+            channel.basicPublish(
+                "",
+                TEST_QUEUE_NAME,
+                MessageHeader(traceId2, runId2).toAmqpProperties(),
+                serializer.toJson(payload2).toByteArray()
+            )
+
+            messageQueue.checkMessage(traceId1, runId1, payload1)
+            messageQueue.poll(2, TimeUnit.SECONDS) should beNull()
+        }
+    }
 })
diff --git a/transport/spi/src/main/kotlin/MessageReceiverFactory.kt b/transport/spi/src/main/kotlin/MessageReceiverFactory.kt
index 99b8069ec5..d5ce447349 100644
--- a/transport/spi/src/main/kotlin/MessageReceiverFactory.kt
+++ b/transport/spi/src/main/kotlin/MessageReceiverFactory.kt
@@ -27,9 +27,21 @@ import org.eclipse.apoapsis.ortserver.config.Path
 import org.slf4j.LoggerFactory
 
 /**
- * Definition of a message handler function of an endpoint. The function is passed the message it should handle.
+ * Definition of a message handler function for an endpoint. The function is passed the message it should handle and
+ * returns whether the handler shall receive further messages.
  */
-typealias EndpointHandler<T> = suspend (Message<T>) -> Unit
+typealias EndpointHandler<T> = suspend (Message<T>) -> EndpointHandlerResult
+
+/**
+ * The result of handling a message by an [EndpointHandler].
+ */
+enum class EndpointHandlerResult {
+    /** The handler shall receive further messages. */
+    CONTINUE,
+
+    /** The handler shall not receive further messages. */
+    STOP
+}
 
 /**
  * Factory interface for setting up a receiver for an [Endpoint].
diff --git a/transport/spi/src/test/kotlin/EndpointComponentTest.kt b/transport/spi/src/test/kotlin/EndpointComponentTest.kt
index 89b73970b5..59f8e0de11 100644
--- a/transport/spi/src/test/kotlin/EndpointComponentTest.kt
+++ b/transport/spi/src/test/kotlin/EndpointComponentTest.kt
@@ -109,6 +109,8 @@ private class EndpointForTesting : EndpointComponent<OrchestratorMessage>(Orches
         publisher.publish(AnalyzerEndpoint, response)
 
         service.process()
+
+        EndpointHandlerResult.CONTINUE
     }
 
     override fun customModules(): List<Module> {
diff --git a/transport/spi/src/testFixtures/kotlin/Utils.kt b/transport/spi/src/testFixtures/kotlin/Utils.kt
index 112af313f2..965b54c1d0 100644
--- a/transport/spi/src/testFixtures/kotlin/Utils.kt
+++ b/transport/spi/src/testFixtures/kotlin/Utils.kt
@@ -34,6 +34,7 @@ import kotlinx.coroutines.runBlocking
 
 import org.eclipse.apoapsis.ortserver.config.ConfigManager
 import org.eclipse.apoapsis.ortserver.model.orchestrator.OrchestratorMessage
+import org.eclipse.apoapsis.ortserver.transport.EndpointHandlerResult
 import org.eclipse.apoapsis.ortserver.transport.Message
 import org.eclipse.apoapsis.ortserver.transport.MessageReceiverFactory
 import org.eclipse.apoapsis.ortserver.transport.OrchestratorEndpoint
@@ -66,11 +67,15 @@ fun createConfigManager(
  * Start a receiver that is initialized from the given [configManager]. Since the receiver blocks, this has to be done
  * in a separate thread. Return a queue that can be polled to obtain the received messages.
  */
-fun startReceiver(configManager: ConfigManager): LinkedBlockingQueue<Message<OrchestratorMessage>> {
+fun startReceiver(
+    configManager: ConfigManager,
+    result: EndpointHandlerResult = EndpointHandlerResult.CONTINUE
+): LinkedBlockingQueue<Message<OrchestratorMessage>> {
     val queue = LinkedBlockingQueue<Message<OrchestratorMessage>>()
 
-    fun handler(message: Message<OrchestratorMessage>) {
+    fun handler(message: Message<OrchestratorMessage>): EndpointHandlerResult {
         queue.offer(message)
+        return result
     }
 
     thread {
diff --git a/transport/sqs/src/main/kotlin/SqsMessageReceiverFactory.kt b/transport/sqs/src/main/kotlin/SqsMessageReceiverFactory.kt
index 178dee4f11..1beaba0752 100644
--- a/transport/sqs/src/main/kotlin/SqsMessageReceiverFactory.kt
+++ b/transport/sqs/src/main/kotlin/SqsMessageReceiverFactory.kt
@@ -30,6 +30,7 @@ import kotlinx.coroutines.isActive
 import org.eclipse.apoapsis.ortserver.config.ConfigManager
 import org.eclipse.apoapsis.ortserver.transport.Endpoint
 import org.eclipse.apoapsis.ortserver.transport.EndpointHandler
+import org.eclipse.apoapsis.ortserver.transport.EndpointHandlerResult
 import org.eclipse.apoapsis.ortserver.transport.Message
 import org.eclipse.apoapsis.ortserver.transport.MessageReceiverFactory
 import org.eclipse.apoapsis.ortserver.transport.RUN_ID_PROPERTY
@@ -81,7 +82,9 @@ class SqsMessageReceiverFactory : MessageReceiverFactory {
             waitTimeSeconds = 20
         }
 
-        while (coroutineContext.isActive) {
+        var result = EndpointHandlerResult.CONTINUE
+
+        while (result == EndpointHandlerResult.CONTINUE && coroutineContext.isActive) {
             val receiveResponse = client.receiveMessage(receiveRequest)
 
             receiveResponse.messages?.forEach { sqsMessage ->
@@ -109,7 +112,7 @@ class SqsMessageReceiverFactory : MessageReceiverFactory {
                         "traceId" to ortMessage.header.traceId,
                         "ortRunId" to ortMessage.header.ortRunId.toString()
                     ) {
-                        handler(ortMessage)
+                        result = handler(ortMessage)
                     }
                 }.onFailure {
                     logger.error("Error during message body processing.", it)
diff --git a/transport/sqs/src/test/kotlin/SqsMessageReceiverFactoryTest.kt b/transport/sqs/src/test/kotlin/SqsMessageReceiverFactoryTest.kt
index fd6cd5c138..24b28c4557 100644
--- a/transport/sqs/src/test/kotlin/SqsMessageReceiverFactoryTest.kt
+++ b/transport/sqs/src/test/kotlin/SqsMessageReceiverFactoryTest.kt
@@ -26,11 +26,16 @@ import aws.sdk.kotlin.services.sqs.model.GetQueueUrlResponse
 import aws.sdk.kotlin.services.sqs.model.SendMessageRequest
 
 import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.nulls.beNull
+import io.kotest.matchers.should
+
+import java.util.concurrent.TimeUnit
 
 import org.eclipse.apoapsis.ortserver.config.Path
 import org.eclipse.apoapsis.ortserver.model.orchestrator.AnalyzerWorkerError
 import org.eclipse.apoapsis.ortserver.model.orchestrator.AnalyzerWorkerResult
 import org.eclipse.apoapsis.ortserver.model.orchestrator.OrchestratorMessage
+import org.eclipse.apoapsis.ortserver.transport.EndpointHandlerResult
 import org.eclipse.apoapsis.ortserver.transport.MessageHeader
 import org.eclipse.apoapsis.ortserver.transport.json.JsonSerializer
 import org.eclipse.apoapsis.ortserver.transport.testing.TEST_QUEUE_NAME
@@ -109,4 +114,22 @@ class SqsMessageReceiverFactoryTest : StringSpec({
 
         messageQueue.checkMessage("", ortRunId, payload)
     }
+
+    "Message receiving is stopped when the handler returns STOP" {
+        val messageQueue = startReceiver(configManager, EndpointHandlerResult.STOP)
+
+        val traceId1 = "trace1"
+        val ortRunId1 = 1L
+        val payload1 = AnalyzerWorkerError(1)
+
+        val traceId2 = "trace2"
+        val ortRunId2 = 2L
+        val payload2 = AnalyzerWorkerError(2)
+
+        queueResponse.sendMessage(traceId1, ortRunId1, serializer.toJson(payload1))
+        queueResponse.sendMessage(traceId2, ortRunId2, serializer.toJson(payload2))
+
+        messageQueue.checkMessage(traceId1, ortRunId1, payload1)
+        messageQueue.poll(2, TimeUnit.SECONDS) should beNull()
+    }
 })
diff --git a/workers/advisor/src/main/kotlin/advisor/AdvisorComponent.kt b/workers/advisor/src/main/kotlin/advisor/AdvisorComponent.kt
index 5759971223..41fb201428 100644
--- a/workers/advisor/src/main/kotlin/advisor/AdvisorComponent.kt
+++ b/workers/advisor/src/main/kotlin/advisor/AdvisorComponent.kt
@@ -37,6 +37,7 @@ import org.eclipse.apoapsis.ortserver.model.repositories.OrtRunRepository
 import org.eclipse.apoapsis.ortserver.transport.AdvisorEndpoint
 import org.eclipse.apoapsis.ortserver.transport.EndpointComponent
 import org.eclipse.apoapsis.ortserver.transport.EndpointHandler
+import org.eclipse.apoapsis.ortserver.transport.EndpointHandlerResult
 import org.eclipse.apoapsis.ortserver.transport.Message
 import org.eclipse.apoapsis.ortserver.transport.MessagePublisher
 import org.eclipse.apoapsis.ortserver.transport.OrchestratorEndpoint
@@ -82,6 +83,9 @@ class AdvisorComponent : EndpointComponent<AdvisorRequest>(AdvisorEndpoint) {
 
             if (response != null) publisher.publish(OrchestratorEndpoint, response)
         }
+
+        val handleSingleJobOnly = configManager.config.getBoolean("advisor.handleSingleJobOnly")
+        if (handleSingleJobOnly) EndpointHandlerResult.STOP else EndpointHandlerResult.CONTINUE
     }
 
     override fun customModules(): List<Module> =
diff --git a/workers/advisor/src/main/resources/application.conf b/workers/advisor/src/main/resources/application.conf
index da92eb62b6..9cd60ce476 100644
--- a/workers/advisor/src/main/resources/application.conf
+++ b/workers/advisor/src/main/resources/application.conf
@@ -35,6 +35,9 @@ configManager {
 }
 
 advisor {
+  handleSingleJobOnly = false
+  handleSingleJobOnly = ${?ADVISOR_HANDLE_SINGLE_JOB_ONLY}
+
   receiver {
     type = "kubernetes"
     type = ${?ADVISOR_RECEIVER_TRANSPORT_TYPE}
diff --git a/workers/analyzer/src/main/kotlin/analyzer/AnalyzerComponent.kt b/workers/analyzer/src/main/kotlin/analyzer/AnalyzerComponent.kt
index 7e846e07ea..c1e75e2844 100644
--- a/workers/analyzer/src/main/kotlin/analyzer/AnalyzerComponent.kt
+++ b/workers/analyzer/src/main/kotlin/analyzer/AnalyzerComponent.kt
@@ -26,6 +26,7 @@ import org.eclipse.apoapsis.ortserver.model.orchestrator.AnalyzerWorkerResult
 import org.eclipse.apoapsis.ortserver.transport.AnalyzerEndpoint
 import org.eclipse.apoapsis.ortserver.transport.EndpointComponent
 import org.eclipse.apoapsis.ortserver.transport.EndpointHandler
+import org.eclipse.apoapsis.ortserver.transport.EndpointHandlerResult
 import org.eclipse.apoapsis.ortserver.transport.Message
 import org.eclipse.apoapsis.ortserver.transport.MessagePublisher
 import org.eclipse.apoapsis.ortserver.transport.OrchestratorEndpoint
@@ -75,6 +76,9 @@ class AnalyzerComponent : EndpointComponent<AnalyzerRequest>(AnalyzerEndpoint) {
 
             if (response != null) publisher.publish(OrchestratorEndpoint, response)
         }
+
+        val handleSingleJobOnly = configManager.config.getBoolean("analyzer.handleSingleJobOnly")
+        if (handleSingleJobOnly) EndpointHandlerResult.STOP else EndpointHandlerResult.CONTINUE
     }
 
     override fun customModules(): List<Module> = listOf(
diff --git a/workers/analyzer/src/main/resources/application.conf b/workers/analyzer/src/main/resources/application.conf
index a8286c8f5e..6dc674c6ce 100644
--- a/workers/analyzer/src/main/resources/application.conf
+++ b/workers/analyzer/src/main/resources/application.conf
@@ -35,6 +35,9 @@ configManager {
 }
 
 analyzer {
+  handleSingleJobOnly = false
+  handleSingleJobOnly = ${?ANALYZER_HANDLE_SINGLE_JOB_ONLY}
+
   receiver {
     type = "kubernetes"
     type = ${?ANALYZER_RECEIVER_TRANSPORT_TYPE}
diff --git a/workers/config/src/main/kotlin/ConfigComponent.kt b/workers/config/src/main/kotlin/ConfigComponent.kt
index b50b3e7855..b1cc963ca1 100644
--- a/workers/config/src/main/kotlin/ConfigComponent.kt
+++ b/workers/config/src/main/kotlin/ConfigComponent.kt
@@ -26,6 +26,7 @@ import org.eclipse.apoapsis.ortserver.model.orchestrator.ConfigWorkerResult
 import org.eclipse.apoapsis.ortserver.transport.ConfigEndpoint
 import org.eclipse.apoapsis.ortserver.transport.EndpointComponent
 import org.eclipse.apoapsis.ortserver.transport.EndpointHandler
+import org.eclipse.apoapsis.ortserver.transport.EndpointHandlerResult
 import org.eclipse.apoapsis.ortserver.transport.Message
 import org.eclipse.apoapsis.ortserver.transport.MessagePublisher
 import org.eclipse.apoapsis.ortserver.transport.OrchestratorEndpoint
@@ -65,6 +66,9 @@ class ConfigComponent : EndpointComponent<ConfigRequest>(ConfigEndpoint) {
         }
 
         publisher.publish(OrchestratorEndpoint, Message(message.header, responsePayload))
+
+        val handleSingleJobOnly = configManager.config.getBoolean("config.handleSingleJobOnly")
+        if (handleSingleJobOnly) EndpointHandlerResult.STOP else EndpointHandlerResult.CONTINUE
     }
 
     override fun customModules(): List<Module> = listOf(configModule(), databaseModule(), workerContextModule())
diff --git a/workers/config/src/main/resources/application.conf b/workers/config/src/main/resources/application.conf
index 2d52f701d6..f1c3aa3e9f 100644
--- a/workers/config/src/main/resources/application.conf
+++ b/workers/config/src/main/resources/application.conf
@@ -35,6 +35,9 @@ configManager {
 }
 
 config {
+  handleSingleJobOnly = false
+  handleSingleJobOnly = ${?CONFIG_HANDLE_SINGLE_JOB_ONLY}
+
   receiver {
     type = "kubernetes"
     type = ${?CONFIG_RECEIVER_TRANSPORT_TYPE}
diff --git a/workers/evaluator/src/main/kotlin/evaluator/EvaluatorComponent.kt b/workers/evaluator/src/main/kotlin/evaluator/EvaluatorComponent.kt
index 91193a5fe5..0043804b36 100644
--- a/workers/evaluator/src/main/kotlin/evaluator/EvaluatorComponent.kt
+++ b/workers/evaluator/src/main/kotlin/evaluator/EvaluatorComponent.kt
@@ -26,6 +26,7 @@ import org.eclipse.apoapsis.ortserver.model.orchestrator.EvaluatorWorkerResult
 import org.eclipse.apoapsis.ortserver.storage.Storage
 import org.eclipse.apoapsis.ortserver.transport.EndpointComponent
 import org.eclipse.apoapsis.ortserver.transport.EndpointHandler
+import org.eclipse.apoapsis.ortserver.transport.EndpointHandlerResult
 import org.eclipse.apoapsis.ortserver.transport.EvaluatorEndpoint
 import org.eclipse.apoapsis.ortserver.transport.Message
 import org.eclipse.apoapsis.ortserver.transport.MessagePublisher
@@ -75,6 +76,9 @@ class EvaluatorComponent : EndpointComponent<EvaluatorRequest>(EvaluatorEndpoint
 
             if (response != null) publisher.publish(OrchestratorEndpoint, response)
         }
+
+        val handleSingleJobOnly = configManager.config.getBoolean("evaluator.handleSingleJobOnly")
+        if (handleSingleJobOnly) EndpointHandlerResult.STOP else EndpointHandlerResult.CONTINUE
     }
 
     override fun customModules(): List<Module> =
diff --git a/workers/evaluator/src/main/resources/application.conf b/workers/evaluator/src/main/resources/application.conf
index 239177c3ea..9f3a7b3d9e 100644
--- a/workers/evaluator/src/main/resources/application.conf
+++ b/workers/evaluator/src/main/resources/application.conf
@@ -35,16 +35,19 @@ configManager {
 }
 
 evaluator {
+  handleSingleJobOnly = false
+  handleSingleJobOnly = ${?EVALUATOR_HANDLE_SINGLE_JOB_ONLY}
+
   receiver {
-  type = "kubernetes"
-  type = ${?EVALUATOR_RECEIVER_TRANSPORT_TYPE}
-  serverUri = "amqp://localhost:61616"
-  serverUri = ${?EVALUATOR_RECEIVER_TRANSPORT_SERVER_URI}
-  queueName = "evaluator_queue"
-  queueName = ${?EVALUATOR_RECEIVER_TRANSPORT_QUEUE_NAME}
-  username = "username"
-  username = ${?EVALUATOR_RECEIVER_TRANSPORT_USERNAME}
-  password = "password"
-  password = ${?EVALUATOR_RECEIVER_TRANSPORT_PASSWORD}
+    type = "kubernetes"
+    type = ${?EVALUATOR_RECEIVER_TRANSPORT_TYPE}
+    serverUri = "amqp://localhost:61616"
+    serverUri = ${?EVALUATOR_RECEIVER_TRANSPORT_SERVER_URI}
+    queueName = "evaluator_queue"
+    queueName = ${?EVALUATOR_RECEIVER_TRANSPORT_QUEUE_NAME}
+    username = "username"
+    username = ${?EVALUATOR_RECEIVER_TRANSPORT_USERNAME}
+    password = "password"
+    password = ${?EVALUATOR_RECEIVER_TRANSPORT_PASSWORD}
   }
 }
diff --git a/workers/notifier/src/main/kotlin/notifier/NotifierComponent.kt b/workers/notifier/src/main/kotlin/notifier/NotifierComponent.kt
index 740b893144..eccf33c2bf 100644
--- a/workers/notifier/src/main/kotlin/notifier/NotifierComponent.kt
+++ b/workers/notifier/src/main/kotlin/notifier/NotifierComponent.kt
@@ -25,6 +25,7 @@ import org.eclipse.apoapsis.ortserver.model.orchestrator.NotifierWorkerError
 import org.eclipse.apoapsis.ortserver.model.orchestrator.NotifierWorkerResult
 import org.eclipse.apoapsis.ortserver.transport.EndpointComponent
 import org.eclipse.apoapsis.ortserver.transport.EndpointHandler
+import org.eclipse.apoapsis.ortserver.transport.EndpointHandlerResult
 import org.eclipse.apoapsis.ortserver.transport.Message
 import org.eclipse.apoapsis.ortserver.transport.MessagePublisher
 import org.eclipse.apoapsis.ortserver.transport.NotifierEndpoint
@@ -70,6 +71,9 @@ class NotifierComponent : EndpointComponent<NotifierRequest>(NotifierEndpoint) {
 
             if (response != null) publisher.publish(OrchestratorEndpoint, response)
         }
+
+        val handleSingleJobOnly = configManager.config.getBoolean("notifier.handleSingleJobOnly")
+        if (handleSingleJobOnly) EndpointHandlerResult.STOP else EndpointHandlerResult.CONTINUE
     }
 
     override fun customModules() =
diff --git a/workers/notifier/src/main/resources/application.conf b/workers/notifier/src/main/resources/application.conf
index a89e99d1c1..24130aed1e 100644
--- a/workers/notifier/src/main/resources/application.conf
+++ b/workers/notifier/src/main/resources/application.conf
@@ -35,6 +35,9 @@ configManager {
 }
 
 notifier {
+  handleSingleJobOnly = false
+  handleSingleJobOnly = ${?NOTIFIER_HANDLE_SINGLE_JOB_ONLY}
+
   receiver {
     type = "kubernetes"
     type = ${?NOTIFIER_RECEIVER_TRANSPORT_TYPE}
diff --git a/workers/reporter/src/main/kotlin/reporter/ReporterComponent.kt b/workers/reporter/src/main/kotlin/reporter/ReporterComponent.kt
index 4f7455bdb7..1ccc554a3c 100644
--- a/workers/reporter/src/main/kotlin/reporter/ReporterComponent.kt
+++ b/workers/reporter/src/main/kotlin/reporter/ReporterComponent.kt
@@ -29,6 +29,7 @@ import org.eclipse.apoapsis.ortserver.model.orchestrator.ReporterWorkerResult
 import org.eclipse.apoapsis.ortserver.storage.Storage
 import org.eclipse.apoapsis.ortserver.transport.EndpointComponent
 import org.eclipse.apoapsis.ortserver.transport.EndpointHandler
+import org.eclipse.apoapsis.ortserver.transport.EndpointHandlerResult
 import org.eclipse.apoapsis.ortserver.transport.Message
 import org.eclipse.apoapsis.ortserver.transport.MessagePublisher
 import org.eclipse.apoapsis.ortserver.transport.OrchestratorEndpoint
@@ -120,6 +121,9 @@ class ReporterComponent : EndpointComponent<ReporterRequest>(ReporterEndpoint) {
 
             if (response != null) publisher.publish(OrchestratorEndpoint, response)
         }
+
+        val handleSingleJobOnly = configManager.config.getBoolean("reporter.handleSingleJobOnly")
+        if (handleSingleJobOnly) EndpointHandlerResult.STOP else EndpointHandlerResult.CONTINUE
     }
 
     override fun customModules(): List<Module> =
diff --git a/workers/reporter/src/main/resources/application.conf b/workers/reporter/src/main/resources/application.conf
index 944eec0cd9..1d17147b7b 100644
--- a/workers/reporter/src/main/resources/application.conf
+++ b/workers/reporter/src/main/resources/application.conf
@@ -35,6 +35,9 @@ configManager {
 }
 
 reporter {
+  handleSingleJobOnly = false
+  handleSingleJobOnly = ${?REPORTER_HANDLE_SINGLE_JOB_ONLY}
+
   receiver {
     type = "kubernetes"
     type = ${?REPORTER_RECEIVER_TRANSPORT_TYPE}
diff --git a/workers/scanner/src/main/kotlin/scanner/ScannerComponent.kt b/workers/scanner/src/main/kotlin/scanner/ScannerComponent.kt
index 84e08c1654..3f99b5e9ae 100644
--- a/workers/scanner/src/main/kotlin/scanner/ScannerComponent.kt
+++ b/workers/scanner/src/main/kotlin/scanner/ScannerComponent.kt
@@ -26,6 +26,7 @@ import org.eclipse.apoapsis.ortserver.model.orchestrator.ScannerWorkerResult
 import org.eclipse.apoapsis.ortserver.storage.Storage
 import org.eclipse.apoapsis.ortserver.transport.EndpointComponent
 import org.eclipse.apoapsis.ortserver.transport.EndpointHandler
+import org.eclipse.apoapsis.ortserver.transport.EndpointHandlerResult
 import org.eclipse.apoapsis.ortserver.transport.Message
 import org.eclipse.apoapsis.ortserver.transport.MessagePublisher
 import org.eclipse.apoapsis.ortserver.transport.OrchestratorEndpoint
@@ -77,6 +78,9 @@ class ScannerComponent : EndpointComponent<ScannerRequest>(ScannerEndpoint) {
 
             if (response != null) publisher.publish(OrchestratorEndpoint, response)
         }
+
+        val handleSingleJobOnly = configManager.config.getBoolean("scanner.handleSingleJobOnly")
+        if (handleSingleJobOnly) EndpointHandlerResult.STOP else EndpointHandlerResult.CONTINUE
     }
 
     override fun customModules(): List<Module> = listOf(
diff --git a/workers/scanner/src/main/resources/application.conf b/workers/scanner/src/main/resources/application.conf
index 39f24939bd..4e2fb67566 100644
--- a/workers/scanner/src/main/resources/application.conf
+++ b/workers/scanner/src/main/resources/application.conf
@@ -35,6 +35,9 @@ configManager {
 }
 
 scanner {
+  handleSingleJobOnly = false
+  handleSingleJobOnly = ${?SCANNER_HANDLE_SINGLE_JOB_ONLY}
+
   receiver {
     type = "kubernetes"
     type = ${?SCANNER_RECEIVER_TRANSPORT_TYPE}


Reviews:
user: login: sschuberth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-22T12:33:41Z
user: login: mnonnenmacher
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-22T12:40:10Z
user: login: sschuberth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-22T12:43:20Z
user: login: mnonnenmacher
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-22T18:06:01Z
user: login: mnonnenmacher
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-22T18:06:32Z
user: login: sschuberth
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-22T18:37:01Z
user: login: sschuberth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-23T07:07:06Z

Review comments:
diff_hunk: @@ -81,6 +82,8 @@ class ArtemisMessageReceiverFactory : MessageReceiverFactory {
             do {
                 runCatching {
                     val jmsMessage = consumer.receiveSave()
+                    var result = EndpointHandlerResult.CONTINUE
path: transport/activemqartemis/src/main/kotlin/ArtemisMessageReceiverFactory.kt
user: login: sschuberth
body: IMO it would be nicer to (be able to) do

                    val result = if (jmsMessage == null) {
                        EndpointHandlerResult.CONTINUE
                    } else {
                        val message = ArtemisMessageConverter.toTransportMessage(jmsMessage, serializer)

                        withMdcContext(
                            "traceId" to message.header.traceId,
                            "ortRunId" to message.header.ortRunId.toString()
                        ) {
                            logger.debug(
                                "Received message '{}' with payload of type {}.",
                                message.header.traceId,
                                message.payload.javaClass.name
                            )

                            handler(message)
                        }
                    }

This requires only a trivial change for `withMdcContext` to return `T`. I'll leave it to you if you want to do that; otherwise this can also be done as a follow-up.
created_at: 2025-03-22T12:30:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -84,7 +84,8 @@ class AdvisorComponent : EndpointComponent<AdvisorRequest>(AdvisorEndpoint) {
             if (response != null) publisher.publish(OrchestratorEndpoint, response)
         }
 
-        EndpointHandlerResult.CONTINUE
+        val handleSingleJob = configManager.config.getBoolean("advisor.handleSingleJob")
path: workers/advisor/src/main/kotlin/advisor/AdvisorComponent.kt
user: login: sschuberth
body: Maybe better call this `singleJobOnly`? That would emphasize that they cannot *also* process single jobs, but *only* those.
created_at: 2025-03-22T12:32:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -84,7 +84,8 @@ class AdvisorComponent : EndpointComponent<AdvisorRequest>(AdvisorEndpoint) {
             if (response != null) publisher.publish(OrchestratorEndpoint, response)
         }
 
-        EndpointHandlerResult.CONTINUE
+        val handleSingleJob = configManager.config.getBoolean("advisor.handleSingleJob")
path: workers/advisor/src/main/kotlin/advisor/AdvisorComponent.kt
user: login: mnonnenmacher
body: I think I'd like to keep "handle", what about `handleSingleJobOnly`?
created_at: 2025-03-22T12:40:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -84,7 +84,8 @@ class AdvisorComponent : EndpointComponent<AdvisorRequest>(AdvisorEndpoint) {
             if (response != null) publisher.publish(OrchestratorEndpoint, response)
         }
 
-        EndpointHandlerResult.CONTINUE
+        val handleSingleJob = configManager.config.getBoolean("advisor.handleSingleJob")
path: workers/advisor/src/main/kotlin/advisor/AdvisorComponent.kt
user: login: sschuberth
body: I like it. A bit longer, but telling.
created_at: 2025-03-22T12:43:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -84,7 +84,8 @@ class AdvisorComponent : EndpointComponent<AdvisorRequest>(AdvisorEndpoint) {
             if (response != null) publisher.publish(OrchestratorEndpoint, response)
         }
 
-        EndpointHandlerResult.CONTINUE
+        val handleSingleJob = configManager.config.getBoolean("advisor.handleSingleJob")
path: workers/advisor/src/main/kotlin/advisor/AdvisorComponent.kt
user: login: mnonnenmacher
body: Done.
created_at: 2025-03-22T18:06:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -81,6 +82,8 @@ class ArtemisMessageReceiverFactory : MessageReceiverFactory {
             do {
                 runCatching {
                     val jmsMessage = consumer.receiveSave()
+                    var result = EndpointHandlerResult.CONTINUE
path: transport/activemqartemis/src/main/kotlin/ArtemisMessageReceiverFactory.kt
user: login: mnonnenmacher
body: I'd rather keep this separate and then check if there are also other places that would benefit from letting `withMdcContext` return a value.
created_at: 2025-03-22T18:06:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -81,6 +82,8 @@ class ArtemisMessageReceiverFactory : MessageReceiverFactory {
             do {
                 runCatching {
                     val jmsMessage = consumer.receiveSave()
+                    var result = EndpointHandlerResult.CONTINUE
path: transport/activemqartemis/src/main/kotlin/ArtemisMessageReceiverFactory.kt
user: login: sschuberth
body: See https://github.com/eclipse-apoapsis/ort-server/pull/2331.
created_at: 2025-03-23T07:07:06Z
author_association: CONTRIBUTOR

PR comments:
user: login: oheger-bosch
created_at: 2025-03-24T06:20:46Z
body: Instead of adding more complexity to the transport implementations and worker configurations, would it be a better option to implement a dedicated Keda transport?
author_association: CONTRIBUTOR
user: login: mnonnenmacher
created_at: 2025-03-24T07:56:14Z
body: > Instead of adding more complexity to the transport implementations and worker configurations, would it be a better option to implement a dedicated Keda transport?

No, Keda is just watching metrics and scaling jobs/deployments based on them, it cannot transfer any data to the created jobs.
author_association: CONTRIBUTOR