diff --git a/api-impl/src/main/kotlin/dev/bpmcrafters/processengineapi/impl/task/AbstractTaskSubscriptionApiImpl.kt b/api-impl/src/main/kotlin/dev/bpmcrafters/processengineapi/impl/task/AbstractTaskSubscriptionApiImpl.kt index f23bc6d5..94acd2a5 100644 --- a/api-impl/src/main/kotlin/dev/bpmcrafters/processengineapi/impl/task/AbstractTaskSubscriptionApiImpl.kt +++ b/api-impl/src/main/kotlin/dev/bpmcrafters/processengineapi/impl/task/AbstractTaskSubscriptionApiImpl.kt @@ -2,6 +2,7 @@ package dev.bpmcrafters.processengineapi.impl.task import dev.bpmcrafters.processengineapi.Empty import dev.bpmcrafters.processengineapi.task.SubscribeForTaskCmd +import dev.bpmcrafters.processengineapi.task.TaskHandlerInterceptor import dev.bpmcrafters.processengineapi.task.TaskSubscription import dev.bpmcrafters.processengineapi.task.TaskSubscriptionApi import dev.bpmcrafters.processengineapi.task.UnsubscribeFromTaskCmd @@ -14,8 +15,9 @@ private val logger = KotlinLogging.logger {} * Abstract task subscription api implementation, using subscription repository and a list of completion strategies. * @since 0.0.2 */ -abstract class AbstractTaskSubscriptionApiImpl( - private val subscriptionRepository: SubscriptionRepository +abstract class AbstractTaskSubscriptionApiImpl @JvmOverloads constructor( + private val subscriptionRepository: SubscriptionRepository, + private val interceptors: List = emptyList() ) : TaskSubscriptionApi { override fun subscribeForTask(cmd: SubscribeForTaskCmd): CompletableFuture { @@ -23,7 +25,12 @@ abstract class AbstractTaskSubscriptionApiImpl( taskDescriptionKey = cmd.taskDescriptionKey, payloadDescription = cmd.payloadDescription, restrictions = cmd.restrictions, - action = cmd.action, + action = if (interceptors.isEmpty()) cmd.action else InterceptingTaskHandler( + delegate = cmd.action, + interceptors = interceptors, + taskDescriptionKey = cmd.taskDescriptionKey, + taskType = cmd.taskType + ), taskType = cmd.taskType, termination = cmd.termination ).let { diff --git a/api-impl/src/main/kotlin/dev/bpmcrafters/processengineapi/impl/task/InterceptingTaskHandler.kt b/api-impl/src/main/kotlin/dev/bpmcrafters/processengineapi/impl/task/InterceptingTaskHandler.kt new file mode 100644 index 00000000..c416992b --- /dev/null +++ b/api-impl/src/main/kotlin/dev/bpmcrafters/processengineapi/impl/task/InterceptingTaskHandler.kt @@ -0,0 +1,42 @@ +package dev.bpmcrafters.processengineapi.impl.task + +import dev.bpmcrafters.processengineapi.task.TaskHandler +import dev.bpmcrafters.processengineapi.task.TaskHandlerInterceptor +import dev.bpmcrafters.processengineapi.task.TaskHandlerInterceptorChain +import dev.bpmcrafters.processengineapi.task.TaskHandlerInterceptorContext +import dev.bpmcrafters.processengineapi.task.TaskHandlerOutcome +import dev.bpmcrafters.processengineapi.task.TaskInformation +import dev.bpmcrafters.processengineapi.task.TaskType + +/** + * Wraps a [TaskHandler] and runs a list of [TaskHandlerInterceptor]s around its execution. + * Interceptors run in the order of the given list; the wrapped [delegate] is invoked last. + * @since 1.7 + */ +class InterceptingTaskHandler( + private val delegate: TaskHandler, + private val interceptors: List, + private val taskDescriptionKey: String?, + private val taskType: TaskType +) : TaskHandler { + + override fun accept(taskInformation: TaskInformation, payload: Map) { + val context = TaskHandlerInterceptorContext(taskInformation, payload, taskDescriptionKey, taskType) + TaskHandlerOutcomeRecorder.recordingFor(taskInformation.taskId) { + proceed(context, 0, taskInformation, payload) + } + } + + private fun proceed( + context: TaskHandlerInterceptorContext, + index: Int, + taskInformation: TaskInformation, + payload: Map + ): TaskHandlerOutcome = + if (index < interceptors.size) { + interceptors[index].intercept(context) { proceed(context, index + 1, taskInformation, payload) } + } else { + delegate.accept(taskInformation, payload) + TaskHandlerOutcomeRecorder.recordedOutcome() + } +} diff --git a/api-impl/src/main/kotlin/dev/bpmcrafters/processengineapi/impl/task/OutcomeRecordingServiceTaskCompletionApi.kt b/api-impl/src/main/kotlin/dev/bpmcrafters/processengineapi/impl/task/OutcomeRecordingServiceTaskCompletionApi.kt new file mode 100644 index 00000000..1c40bd25 --- /dev/null +++ b/api-impl/src/main/kotlin/dev/bpmcrafters/processengineapi/impl/task/OutcomeRecordingServiceTaskCompletionApi.kt @@ -0,0 +1,37 @@ +package dev.bpmcrafters.processengineapi.impl.task + +import dev.bpmcrafters.processengineapi.Empty +import dev.bpmcrafters.processengineapi.task.CompleteTaskByErrorCmd +import dev.bpmcrafters.processengineapi.task.CompleteTaskCmd +import dev.bpmcrafters.processengineapi.task.FailTaskCmd +import dev.bpmcrafters.processengineapi.task.ServiceTaskCompletionApi +import dev.bpmcrafters.processengineapi.task.TaskHandlerInterceptor +import dev.bpmcrafters.processengineapi.task.TaskHandlerOutcome +import java.util.concurrent.CompletableFuture + +/** + * Decorates a [ServiceTaskCompletionApi] and reports every completion or failure requested by a + * task handler as [TaskHandlerOutcome] to the [TaskHandlerInterceptor]s of the running execution. + * + * The outcome is only reported if the handler calls this API in the thread executing the handler. + * @since 1.7 + */ +class OutcomeRecordingServiceTaskCompletionApi( + private val delegate: ServiceTaskCompletionApi +) : ServiceTaskCompletionApi { + + override fun completeTask(cmd: CompleteTaskCmd): CompletableFuture = + delegate.completeTask(cmd).also { + TaskHandlerOutcomeRecorder.record(cmd.taskId, TaskHandlerOutcome.Completed) + } + + override fun completeTaskByError(cmd: CompleteTaskByErrorCmd): CompletableFuture = + delegate.completeTaskByError(cmd).also { + TaskHandlerOutcomeRecorder.record(cmd.taskId, TaskHandlerOutcome.CompletedByError(cmd.errorCode, cmd.errorMessage)) + } + + override fun failTask(cmd: FailTaskCmd): CompletableFuture = + delegate.failTask(cmd).also { + TaskHandlerOutcomeRecorder.record(cmd.taskId, TaskHandlerOutcome.Failed(cmd.reason, cmd.errorDetails)) + } +} diff --git a/api-impl/src/main/kotlin/dev/bpmcrafters/processengineapi/impl/task/TaskHandlerOutcomeRecorder.kt b/api-impl/src/main/kotlin/dev/bpmcrafters/processengineapi/impl/task/TaskHandlerOutcomeRecorder.kt new file mode 100644 index 00000000..89ddd1c6 --- /dev/null +++ b/api-impl/src/main/kotlin/dev/bpmcrafters/processengineapi/impl/task/TaskHandlerOutcomeRecorder.kt @@ -0,0 +1,32 @@ +package dev.bpmcrafters.processengineapi.impl.task + +import dev.bpmcrafters.processengineapi.task.TaskHandlerOutcome + +/** + * Keeps the [TaskHandlerOutcome] reported for the task currently handled by the executing thread. + * @since 1.7 + */ +internal object TaskHandlerOutcomeRecorder { + + private class Recording(val taskId: String) { + var outcome: TaskHandlerOutcome = TaskHandlerOutcome.Undetermined + } + + private val currentRecording = ThreadLocal() + + fun recordingFor(taskId: String, execution: () -> T): T { + val enclosingRecording = currentRecording.get() + currentRecording.set(Recording(taskId)) + try { + return execution() + } finally { + if (enclosingRecording == null) currentRecording.remove() else currentRecording.set(enclosingRecording) + } + } + + fun record(taskId: String, outcome: TaskHandlerOutcome) { + currentRecording.get()?.takeIf { it.taskId == taskId }?.outcome = outcome + } + + fun recordedOutcome(): TaskHandlerOutcome = currentRecording.get()?.outcome ?: TaskHandlerOutcome.Undetermined +} diff --git a/api-impl/src/test/kotlin/dev/bpmcrafters/processengineapi/impl/task/AbstractTaskSubscriptionApiImplTest.kt b/api-impl/src/test/kotlin/dev/bpmcrafters/processengineapi/impl/task/AbstractTaskSubscriptionApiImplTest.kt index 8a9f48e0..846cdb8b 100644 --- a/api-impl/src/test/kotlin/dev/bpmcrafters/processengineapi/impl/task/AbstractTaskSubscriptionApiImplTest.kt +++ b/api-impl/src/test/kotlin/dev/bpmcrafters/processengineapi/impl/task/AbstractTaskSubscriptionApiImplTest.kt @@ -4,6 +4,9 @@ import dev.bpmcrafters.processengineapi.CommonRestrictions import dev.bpmcrafters.processengineapi.MetaInfo import dev.bpmcrafters.processengineapi.MetaInfoAware import dev.bpmcrafters.processengineapi.task.SubscribeForTaskCmd +import dev.bpmcrafters.processengineapi.task.TaskHandler +import dev.bpmcrafters.processengineapi.task.TaskHandlerInterceptor +import dev.bpmcrafters.processengineapi.task.TaskInformation import dev.bpmcrafters.processengineapi.task.TaskType import dev.bpmcrafters.processengineapi.task.UnsubscribeFromTaskCmd import org.assertj.core.api.Assertions @@ -13,7 +16,10 @@ internal class AbstractTaskSubscriptionApiImplTest { private val subscriptionRepository: SubscriptionRepository = InMemSubscriptionRepository() private val taskSubscriptionApi = MyTaskSubscriptionApiImpl(subscriptionRepository) - class MyTaskSubscriptionApiImpl(subscriptionRepository: SubscriptionRepository) : AbstractTaskSubscriptionApiImpl(subscriptionRepository) { + class MyTaskSubscriptionApiImpl( + subscriptionRepository: SubscriptionRepository, + interceptors: List = emptyList() + ) : AbstractTaskSubscriptionApiImpl(subscriptionRepository, interceptors) { override fun meta(instance: MetaInfoAware): MetaInfo { TODO("Not yet implemented") @@ -46,4 +52,51 @@ internal class AbstractTaskSubscriptionApiImplTest { Assertions.assertThat(this.subscriptionRepository.getTaskSubscriptions()).isEmpty() } + + @Test + fun `keeps original action when no interceptors are configured`() { + val action = TaskHandler { _, _ -> } + + val handle = taskSubscriptionApi.subscribeForTask( + SubscribeForTaskCmd( + CommonRestrictions.builder().withProcessDefinitionKey("process-key").build(), + TaskType.EXTERNAL, + null, + setOf("var1"), + action, + { _ -> } + ) + ).get() as TaskSubscriptionHandle + + Assertions.assertThat(handle.action).isSameAs(action) + } + + @Test + fun `wraps action so interceptor runs on delivery and unsubscribe still works`() { + val trace = mutableListOf() + val interceptor = TaskHandlerInterceptor { _, chain -> + trace += "before" + chain.proceed().also { trace += "after" } + } + val api = MyTaskSubscriptionApiImpl(subscriptionRepository, listOf(interceptor)) + val action = TaskHandler { _, _ -> trace += "action" } + + val handle = api.subscribeForTask( + SubscribeForTaskCmd( + CommonRestrictions.builder().withProcessDefinitionKey("process-key").build(), + TaskType.EXTERNAL, + null, + setOf("var1"), + action, + { _ -> } + ) + ).get() as TaskSubscriptionHandle + + Assertions.assertThat(handle.action).isNotSameAs(action) + handle.action.accept(TaskInformation("task-id", emptyMap()), emptyMap()) + Assertions.assertThat(trace).containsExactly("before", "action", "after") + + api.unsubscribe(UnsubscribeFromTaskCmd(handle)).get() + Assertions.assertThat(subscriptionRepository.getTaskSubscriptions()).isEmpty() + } } diff --git a/api-impl/src/test/kotlin/dev/bpmcrafters/processengineapi/impl/task/InterceptingTaskHandlerTest.kt b/api-impl/src/test/kotlin/dev/bpmcrafters/processengineapi/impl/task/InterceptingTaskHandlerTest.kt new file mode 100644 index 00000000..3820b79f --- /dev/null +++ b/api-impl/src/test/kotlin/dev/bpmcrafters/processengineapi/impl/task/InterceptingTaskHandlerTest.kt @@ -0,0 +1,77 @@ +package dev.bpmcrafters.processengineapi.impl.task + +import dev.bpmcrafters.processengineapi.task.TaskHandler +import dev.bpmcrafters.processengineapi.task.TaskHandlerInterceptor +import dev.bpmcrafters.processengineapi.task.TaskHandlerInterceptorContext +import dev.bpmcrafters.processengineapi.task.TaskHandlerOutcome +import dev.bpmcrafters.processengineapi.task.TaskInformation +import dev.bpmcrafters.processengineapi.task.TaskType +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test + +internal class InterceptingTaskHandlerTest { + + private val taskInformation = TaskInformation("task-id", mapOf("meta-key" to "meta-value")) + private val payload = mapOf("var1" to "value1") + + @Test + fun `runs interceptors around delegate in list order`() { + val trace = mutableListOf() + val first = TaskHandlerInterceptor { _, chain -> + trace += "first-before" + chain.proceed().also { trace += "first-after" } + } + val second = TaskHandlerInterceptor { _, chain -> + trace += "second-before" + chain.proceed().also { trace += "second-after" } + } + val delegate = TaskHandler { _, _ -> trace += "delegate" } + + InterceptingTaskHandler(delegate, listOf(first, second), "task-key", TaskType.EXTERNAL) + .accept(taskInformation, payload) + + assertThat(trace).containsExactly( + "first-before", "second-before", "delegate", "second-after", "first-after" + ) + } + + @Test + fun `provides context to interceptors`() { + var captured: TaskHandlerInterceptorContext? = null + val interceptor = TaskHandlerInterceptor { context, chain -> + captured = context + chain.proceed() + } + + InterceptingTaskHandler(TaskHandler { _, _ -> }, listOf(interceptor), "task-key", TaskType.USER) + .accept(taskInformation, payload) + + assertThat(captured).isEqualTo( + TaskHandlerInterceptorContext(taskInformation, payload, "task-key", TaskType.USER) + ) + } + + @Test + fun `interceptor without proceed prevents delegate invocation`() { + var delegateCalled = false + val interceptor = TaskHandlerInterceptor { _, _ -> TaskHandlerOutcome.Undetermined } + val delegate = TaskHandler { _, _ -> delegateCalled = true } + + InterceptingTaskHandler(delegate, listOf(interceptor), "task-key", TaskType.EXTERNAL) + .accept(taskInformation, payload) + + assertThat(delegateCalled).isFalse() + } + + @Test + fun `delegate exception propagates through the chain`() { + val interceptor = TaskHandlerInterceptor { _, chain -> chain.proceed() } + val delegate = TaskHandler { _, _ -> throw IllegalStateException("boom") } + + assertThatThrownBy { + InterceptingTaskHandler(delegate, listOf(interceptor), "task-key", TaskType.EXTERNAL) + .accept(taskInformation, payload) + }.isInstanceOf(IllegalStateException::class.java).hasMessage("boom") + } +} diff --git a/api-impl/src/test/kotlin/dev/bpmcrafters/processengineapi/impl/task/OutcomeRecordingServiceTaskCompletionApiTest.kt b/api-impl/src/test/kotlin/dev/bpmcrafters/processengineapi/impl/task/OutcomeRecordingServiceTaskCompletionApiTest.kt new file mode 100644 index 00000000..eff582b1 --- /dev/null +++ b/api-impl/src/test/kotlin/dev/bpmcrafters/processengineapi/impl/task/OutcomeRecordingServiceTaskCompletionApiTest.kt @@ -0,0 +1,116 @@ +package dev.bpmcrafters.processengineapi.impl.task + +import dev.bpmcrafters.processengineapi.Empty +import dev.bpmcrafters.processengineapi.task.CompleteTaskByErrorCmd +import dev.bpmcrafters.processengineapi.task.CompleteTaskCmd +import dev.bpmcrafters.processengineapi.task.FailTaskCmd +import dev.bpmcrafters.processengineapi.task.ServiceTaskCompletionApi +import dev.bpmcrafters.processengineapi.task.TaskHandler +import dev.bpmcrafters.processengineapi.task.TaskHandlerInterceptor +import dev.bpmcrafters.processengineapi.task.TaskHandlerOutcome +import dev.bpmcrafters.processengineapi.task.TaskInformation +import dev.bpmcrafters.processengineapi.task.TaskType +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test +import java.util.concurrent.CompletableFuture + +internal class OutcomeRecordingServiceTaskCompletionApiTest { + + private val taskInformation = TaskInformation("task-id", emptyMap()) + private val delegatedCommands = mutableListOf() + private val completionApi = OutcomeRecordingServiceTaskCompletionApi(object : ServiceTaskCompletionApi { + override fun completeTask(cmd: CompleteTaskCmd) = delegated(cmd) + override fun completeTaskByError(cmd: CompleteTaskByErrorCmd) = delegated(cmd) + override fun failTask(cmd: FailTaskCmd) = delegated(cmd) + }) + + private fun delegated(cmd: Any): CompletableFuture { + delegatedCommands += cmd + return CompletableFuture.completedFuture(Empty) + } + + private fun outcomeSeenByInterceptorOf(handler: TaskHandler): TaskHandlerOutcome { + var seenOutcome: TaskHandlerOutcome? = null + val interceptor = TaskHandlerInterceptor { _, chain -> chain.proceed().also { seenOutcome = it } } + InterceptingTaskHandler(handler, listOf(interceptor), "task-key", TaskType.EXTERNAL) + .accept(taskInformation, emptyMap()) + return seenOutcome!! + } + + @Test + fun `reports failed task`() { + val cmd = FailTaskCmd("task-id", "reason", "details") + + val outcome = outcomeSeenByInterceptorOf { _, _ -> completionApi.failTask(cmd).get() } + + assertThat(outcome).isEqualTo(TaskHandlerOutcome.Failed("reason", "details")) + assertThat(delegatedCommands).containsExactly(cmd) + } + + @Test + fun `reports task completed by error`() { + val cmd = CompleteTaskByErrorCmd("task-id", "error-code", "message") + + val outcome = outcomeSeenByInterceptorOf { _, _ -> completionApi.completeTaskByError(cmd).get() } + + assertThat(outcome).isEqualTo(TaskHandlerOutcome.CompletedByError("error-code", "message")) + assertThat(delegatedCommands).containsExactly(cmd) + } + + @Test + fun `reports completed task`() { + val cmd = CompleteTaskCmd("task-id") { emptyMap() } + + val outcome = outcomeSeenByInterceptorOf { _, _ -> completionApi.completeTask(cmd).get() } + + assertThat(outcome).isEqualTo(TaskHandlerOutcome.Completed) + assertThat(delegatedCommands).containsExactly(cmd) + } + + @Test + fun `reports undetermined outcome if handler does not finish the task`() { + val outcome = outcomeSeenByInterceptorOf { _, _ -> } + + assertThat(outcome).isEqualTo(TaskHandlerOutcome.Undetermined) + } + + @Test + fun `ignores outcome of another task`() { + val outcome = outcomeSeenByInterceptorOf { _, _ -> + completionApi.failTask(FailTaskCmd("other-task-id", "reason", null)).get() + } + + assertThat(outcome).isEqualTo(TaskHandlerOutcome.Undetermined) + } + + @Test + fun `ignores outcome reported by another thread`() { + val outcome = outcomeSeenByInterceptorOf { _, _ -> + CompletableFuture.runAsync { completionApi.failTask(FailTaskCmd("task-id", "reason", null)).get() }.get() + } + + assertThat(outcome).isEqualTo(TaskHandlerOutcome.Undetermined) + } + + @Test + fun `does not leak outcome of a throwing handler into the next execution`() { + assertThatThrownBy { + outcomeSeenByInterceptorOf { _, _ -> + completionApi.failTask(FailTaskCmd("task-id", "reason", null)).get() + throw IllegalStateException("boom") + } + }.isInstanceOf(IllegalStateException::class.java) + + assertThat(outcomeSeenByInterceptorOf { _, _ -> }).isEqualTo(TaskHandlerOutcome.Undetermined) + } + + @Test + fun `delegates without a running handler execution`() { + val cmd = FailTaskCmd("task-id", "reason", null) + + completionApi.failTask(cmd).get() + + assertThat(delegatedCommands).containsExactly(cmd) + } +} diff --git a/api/src/main/kotlin/dev/bpmcrafters/processengineapi/CommonRestrictions.kt b/api/src/main/kotlin/dev/bpmcrafters/processengineapi/CommonRestrictions.kt index 2043f35f..45fd75b9 100644 --- a/api/src/main/kotlin/dev/bpmcrafters/processengineapi/CommonRestrictions.kt +++ b/api/src/main/kotlin/dev/bpmcrafters/processengineapi/CommonRestrictions.kt @@ -11,6 +11,11 @@ object CommonRestrictions { */ const val ACTIVITY_ID = "activityId" + /** + * Id of the runtime instance of a BPMN activity. + */ + const val ACTIVITY_INSTANCE_ID = "activityInstanceId" + /** * Logical business key. */ diff --git a/api/src/main/kotlin/dev/bpmcrafters/processengineapi/task/TaskHandlerInterceptor.kt b/api/src/main/kotlin/dev/bpmcrafters/processengineapi/task/TaskHandlerInterceptor.kt new file mode 100644 index 00000000..6d31cb29 --- /dev/null +++ b/api/src/main/kotlin/dev/bpmcrafters/processengineapi/task/TaskHandlerInterceptor.kt @@ -0,0 +1,30 @@ +package dev.bpmcrafters.processengineapi.task + +/** + * Intercepts the execution of a [TaskHandler] of a subscription. + * + * Lets you add cross-cutting concerns (logging, tracing, auditing) around every task execution + * without replacing the delivery layer or resorting to AOP. + * + * Interceptors wrap the call `subscription.action.accept(taskInformation, payload)` performed by + * every delivery. They run in the delivery thread, inside the delivery's try/catch, and apply to + * every [TaskType]. An interceptor may filter on [TaskHandlerInterceptorContext.taskType]. + * + * @since 1.7 + */ +fun interface TaskHandlerInterceptor { + /** + * Intercepts the task handler execution. + * Call [TaskHandlerInterceptorChain.proceed] to continue with the next interceptor and finally + * the wrapped handler. Skipping the call prevents the handler from being invoked. An exception + * thrown here propagates like a handler exception. + * + * A handler may report a failure or a BPMN error itself instead of throwing an exception. In this + * case [TaskHandlerInterceptorChain.proceed] returns normally and the returned [TaskHandlerOutcome] + * tells what happened. + * @param context context of the intercepted execution. + * @param chain chain to proceed with. + * @return outcome of the execution, usually the one returned by [TaskHandlerInterceptorChain.proceed]. + */ + fun intercept(context: TaskHandlerInterceptorContext, chain: TaskHandlerInterceptorChain): TaskHandlerOutcome +} diff --git a/api/src/main/kotlin/dev/bpmcrafters/processengineapi/task/TaskHandlerInterceptorChain.kt b/api/src/main/kotlin/dev/bpmcrafters/processengineapi/task/TaskHandlerInterceptorChain.kt new file mode 100644 index 00000000..d3a1fafc --- /dev/null +++ b/api/src/main/kotlin/dev/bpmcrafters/processengineapi/task/TaskHandlerInterceptorChain.kt @@ -0,0 +1,13 @@ +package dev.bpmcrafters.processengineapi.task + +/** + * Chain to continue an intercepted [TaskHandler] execution. + * @since 1.7 + */ +fun interface TaskHandlerInterceptorChain { + /** + * Proceeds with the next interceptor and finally the wrapped handler. + * @return outcome reported by the wrapped handler. + */ + fun proceed(): TaskHandlerOutcome +} diff --git a/api/src/main/kotlin/dev/bpmcrafters/processengineapi/task/TaskHandlerInterceptorContext.kt b/api/src/main/kotlin/dev/bpmcrafters/processengineapi/task/TaskHandlerInterceptorContext.kt new file mode 100644 index 00000000..4cefa81c --- /dev/null +++ b/api/src/main/kotlin/dev/bpmcrafters/processengineapi/task/TaskHandlerInterceptorContext.kt @@ -0,0 +1,24 @@ +package dev.bpmcrafters.processengineapi.task + +/** + * Immutable context of an intercepted [TaskHandler] execution. + * @since 1.7 + */ +data class TaskHandlerInterceptorContext( + /** + * Information about the task being delivered. + */ + val taskInformation: TaskInformation, + /** + * Payload variables delivered to the handler. + */ + val payload: Map, + /** + * Task description key of the subscription, if any. + */ + val taskDescriptionKey: String?, + /** + * Type of the intercepted task. + */ + val taskType: TaskType +) diff --git a/api/src/main/kotlin/dev/bpmcrafters/processengineapi/task/TaskHandlerOutcome.kt b/api/src/main/kotlin/dev/bpmcrafters/processengineapi/task/TaskHandlerOutcome.kt new file mode 100644 index 00000000..a1cd70d9 --- /dev/null +++ b/api/src/main/kotlin/dev/bpmcrafters/processengineapi/task/TaskHandlerOutcome.kt @@ -0,0 +1,48 @@ +package dev.bpmcrafters.processengineapi.task + +/** + * Outcome of an intercepted [TaskHandler] execution, as reported by the handler via the + * [ServiceTaskCompletionApi] in the thread executing the handler. + * @since 1.7 + */ +sealed interface TaskHandlerOutcome { + + /** + * The handler completed the task. + */ + data object Completed : TaskHandlerOutcome + + /** + * The handler completed the task by throwing a BPMN error. + */ + data class CompletedByError( + /** + * Error code. + */ + val errorCode: String, + /** + * Optional error details. + */ + val errorMessage: String? + ) : TaskHandlerOutcome + + /** + * The handler failed the task because of a technical failure. + */ + data class Failed( + /** + * Failure reason. + */ + val reason: String, + /** + * Optional failure details. + */ + val errorDetails: String? + ) : TaskHandlerOutcome + + /** + * The handler returned without reporting an outcome in the executing thread, for example because + * the task is completed later or by another thread, or because the handler was not invoked. + */ + data object Undetermined : TaskHandlerOutcome +} diff --git a/api/src/test/kotlin/dev/bpmcrafters/processengineapi/task/TaskHandlerInterceptorContextTest.kt b/api/src/test/kotlin/dev/bpmcrafters/processengineapi/task/TaskHandlerInterceptorContextTest.kt new file mode 100644 index 00000000..0d5f173e --- /dev/null +++ b/api/src/test/kotlin/dev/bpmcrafters/processengineapi/task/TaskHandlerInterceptorContextTest.kt @@ -0,0 +1,20 @@ +package dev.bpmcrafters.processengineapi.task + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class TaskHandlerInterceptorContextTest { + + @Test + fun `exposes the intercepted execution details`() { + val taskInformation = TaskInformation("task-id", mapOf("meta-key" to "meta-value")) + val payload = mapOf("var1" to "value1") + + val context = TaskHandlerInterceptorContext(taskInformation, payload, "task-key", TaskType.EXTERNAL) + + assertThat(context.taskInformation).isEqualTo(taskInformation) + assertThat(context.payload).isEqualTo(payload) + assertThat(context.taskDescriptionKey).isEqualTo("task-key") + assertThat(context.taskType).isEqualTo(TaskType.EXTERNAL) + } +} diff --git a/docs/task-subscription-api.md b/docs/task-subscription-api.md index c333f402..a0466a68 100644 --- a/docs/task-subscription-api.md +++ b/docs/task-subscription-api.md @@ -52,4 +52,53 @@ public class TaskSubscriber { } +``` + +## Intercepting task handlers + +Cross-cutting concerns such as logging, tracing, or auditing can wrap every task delivery via a +`TaskHandlerInterceptor`. An interceptor wraps the call of the subscription action for both +registration paths (`@ProcessEngineWorker` and manual `subscribeForTask`) and applies to every +`TaskType`. Filter on `context.getTaskType()` if an interceptor should only act on a specific type. + +Interceptors run in the delivery thread, inside the delivery's try/catch. An exception thrown by an +interceptor is treated like a handler exception and leads to failure handling or a retry by the +adapter. The context is immutable; interceptors observe the delivery but do not mutate payload or +task information. + +A handler may report a failure or a BPMN error itself instead of throwing an exception, as the +`process-engine-worker` does. `chain.proceed()` then returns normally, and the returned +`TaskHandlerOutcome` tells what happened: `Completed`, `CompletedByError`, `Failed`, or +`Undetermined`. The outcome is only known if the handler finishes the task through the +`ServiceTaskCompletionApi` in the delivery thread. A task finished later or by another thread +results in `Undetermined`. + +Register interceptors as Spring beans in the adapter starter. The order follows `@Order`/`Ordered`. + +```java + +@Component +@Slf4j +@Order(0) +public class LoggingTaskHandlerInterceptor implements TaskHandlerInterceptor { + + @Override + public TaskHandlerOutcome intercept(TaskHandlerInterceptorContext context, TaskHandlerInterceptorChain chain) { + log.info("[TASK]: Handling {} of type {}", context.getTaskInformation().getTaskId(), context.getTaskType()); + try { + TaskHandlerOutcome outcome = chain.proceed(); + if (outcome instanceof TaskHandlerOutcome.Failed failed) { + log.error("[TASK]: Failed handling {}: {}", context.getTaskInformation().getTaskId(), failed.getReason()); + } else { + log.info("[TASK]: Handled {}", context.getTaskInformation().getTaskId()); + } + return outcome; + } catch (RuntimeException e) { + log.error("[TASK]: Failed handling {}", context.getTaskInformation().getTaskId(), e); + throw e; + } + } +} + + ```