diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index c1a7c3a..5eab840 100644 --- a/.github/workflows/development.yml +++ b/.github/workflows/development.yml @@ -45,11 +45,6 @@ jobs: - name: Build with Maven run: ./mvnw clean verify -U -B -ntp -T4 - # itest - - name: Run itest - run: ./mvnw integration-test failsafe:verify -Pitest -ntp -U -B -T4 - - # - name: Upload coverage to Codecov # if: github.event_name == 'push' && github.actor != 'dependabot[bot]' # uses: codecov/codecov-action@v1.0.2 diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 2a0059c..921e863 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -45,7 +45,7 @@ jobs: # Publish release - name: Deploy a new release version to Maven Central - run: ./mvnw clean deploy -B -ntp -DskipTests -DskipExamples -Prelease -Dgpg.keyname="${{ secrets.GPG_KEYNAME }}" + run: ./mvnw clean deploy -B -ntp -DskipTests -DskipITests -DskipExamples -Prelease -Dgpg.keyname="${{ secrets.GPG_KEYNAME }}" env: OSS_CENTRAL_USERNAME: ${{ secrets.SONATYPE_USERNAME }} OSS_CENTRAL_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} diff --git a/docs/idempotency.md b/docs/idempotency.md new file mode 100644 index 0000000..6531677 --- /dev/null +++ b/docs/idempotency.md @@ -0,0 +1,98 @@ +--- +title: Idempotency Registry +--- + +The Idempotency Registry is a feature designed to prevent duplicate worker invocations. It ensures that if a task is processed multiple times (e.g., due to network issues or retries in the process engine), the worker logic is only executed once, and the previous result is returned for subsequent calls. + +## How it Works + +When a worker is triggered, the process engine worker: +1. Checks the `IdempotencyRegistry` if a result already exists for the given `taskId`. +2. If a result exists, it skips the worker execution and returns the stored result. +3. If no result exists, it executes the worker. +4. After successful execution, it registers the result in the `IdempotencyRegistry`. + +## Implementations + +There are three available implementations of the `IdempotencyRegistry`: + +| Implementation | Description | Recommended Use | +| --- | --- | --- | +| `NoOpIdempotencyRegistry` | Does nothing. No results are stored or retrieved. | Default, use if idempotency is handled elsewhere. | +| `InMemoryIdempotencyRegistry` | Stores results in a local `ConcurrentHashMap`. | Testing or non-clustered environments. | +| `JpaIdempotencyRegistry` | Stores results in a database using JPA. | Production, clustered environments. | + +## Setup Procedures + +### In-Memory Registry + +To use the in-memory registry, you need to provide a bean of type `IdempotencyRegistry` in your Spring configuration: + +```kotlin +@Configuration +class IdempotencyConfiguration { + + @Bean + fun idempotencyRegistry(): IdempotencyRegistry = InMemoryIdempotencyRegistry() + +} +``` + +> **Warning:** The `InMemoryIdempotencyRegistry` is not suitable for clustered environments as the state is not shared between nodes. + +### JPA-based Registry + +The JPA-based registry is suitable for production environments. It persists the results in the database, allowing multiple instances of the worker to share the same idempotency state. + +#### 1. Add Dependency + +Add the following dependency to your `pom.xml`: + +```xml + + dev.bpm-crafters.process-engine-worker + process-engine-worker-spring-boot-idempotency-registry-jpa + ${process-engine-worker.version} + +``` + +The `JpaIdempotencyAutoConfiguration` will automatically register the `JpaIdempotencyRegistry` if an `EntityManager` is present and no other `IdempotencyRegistry` bean is defined. + +#### 2. Database Schema (Liquibase) + +The JPA registry requires a table named `task_log_entry_`. You can use the following Liquibase changeSet to create it: + +```yaml +databaseChangeLog: + - changeSet: + id: create-idempotency-table + author: bpm-crafters + changes: + - createTable: + tableName: task_log_entry_ + columns: + - column: + name: task_id_ + type: varchar(100) + constraints: + nullable: false + primaryKey: true + primaryKeyName: task_log_entry_pk_ + - column: + name: process_instance_id_ + type: varchar(100) + constraints: + nullable: false + - column: + name: created_at_ + type: timestamp + constraints: + nullable: false + - column: + name: result_ + type: blob # or bytea for PostgreSQL + constraints: + nullable: false +``` + +> **Note:** The `result_` column type should be suitable for storing binary data (e.g., `blob` for most databases, `bytea` for PostgreSQL). diff --git a/docs/process-engine-worker.md b/docs/process-engine-worker.md index 88b1db3..63d50e9 100644 --- a/docs/process-engine-worker.md +++ b/docs/process-engine-worker.md @@ -37,6 +37,15 @@ public class MySmartWorker { } ``` +The `@ProcessEngineWorker` annotation supports the following properties: + +| Property | Type | Default | Description | +|----------------|------------|------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `topic` | `String` | `""` | Topic name to subscribe this worker for. Alias for `value`. | +| `autoComplete` | `boolean` | `true` | Flag indicating if the task should be automatically completed after the worker execution. If the return type is `Map`, it will overrule this setting and auto-complete with the returned payload. | +| `completion` | `enum` | `DEFAULT` | Configures when the worker completes a task if `autoComplete` is active. Possible values are `DEFAULT`, `BEFORE_COMMIT`, and `AFTER_COMMIT`. Has no effect if the worker is not transactional. | +| `lockDuration` | `long` | `-1` | Optional lock duration in seconds for this worker. If set to `-1` (default), the global configuration of the process engine adapter will be used. (Available since `0.8.0`) | + ## Method parameter resolution Parameter resolution of the method annotated with `ProcessEngineWorker` is based on a set of strategies diff --git a/itest/pom.xml b/itest/pom.xml new file mode 100644 index 0000000..65ddb76 --- /dev/null +++ b/itest/pom.xml @@ -0,0 +1,43 @@ + + 4.0.0 + + + dev.bpm-crafters.process-engine-worker + process-engine-worker-root + 0.7.2-SNAPSHOT + ../pom.xml + + + process-engine-worker-itest + pom + ITest: Parent for the integration testing. + + + + true + true + true + + + + spring-boot-integration-testing + spring-boot-starter-integration-test + + + + + + dev.bpm-crafters.process-engine-worker + process-engine-worker-spring-boot-starter + ${project.version} + + + dev.bpm-crafters.process-engine-worker + process-engine-worker-spring-boot-idempotency-registry-jpa + ${project.version} + + + + + diff --git a/itest/spring-boot-integration-testing/pom.xml b/itest/spring-boot-integration-testing/pom.xml new file mode 100644 index 0000000..3f45a8b --- /dev/null +++ b/itest/spring-boot-integration-testing/pom.xml @@ -0,0 +1,149 @@ + + 4.0.0 + + dev.bpm-crafters.process-engine-worker + process-engine-worker-itest + 0.7.2-SNAPSHOT + ../pom.xml + + + process-engine-worker-integration-testing + ITest: Testing utilities for integration testing of the library. + + + + + org.testcontainers + testcontainers-bom + 2.0.3 + import + pom + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + import + pom + + + + org.mockito.kotlin + mockito-kotlin + ${mockito.version} + + + + + + + ${project.groupId} + process-engine-worker-spring-boot-starter + ${project.version} + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + com.fasterxml.jackson.module + jackson-module-kotlin + + + dev.bpm-crafters.process-engine-adapters + process-engine-adapter-camunda-platform-c7-remote-spring-boot-starter + ${process-engine-adapters-c7.version} + + + io.holunda.c7 + c7-rest-client-spring-boot-starter-feign + ${c7.version} + + + org.camunda.bpm.springboot + camunda-bpm-spring-boot-starter-external-task-client + ${camunda-bpm-spring-boot-starter-external-task-client.version} + + + + io.micrometer + micrometer-core + + + org.assertj + assertj-core + + + org.awaitility + awaitility-kotlin + + + org.jetbrains.kotlin + kotlin-test-junit5 + + + org.junit.jupiter + junit-jupiter + + + org.junit.jupiter + junit-jupiter-params + + + org.mockito.kotlin + mockito-kotlin + + + org.postgresql + postgresql + + + org.springframework + spring-beans + + + org.springframework + spring-context + + + org.springframework + spring-tx + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-test + + + org.testcontainers + junit-jupiter + + + org.testcontainers + postgresql + + + + diff --git a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/AbstractTransactionalBehaviorTest.kt b/itest/spring-boot-integration-testing/src/main/kotlin/dev/bpmcrafters/processengine/worker/itest/AbstractBehaviorITestBase.kt similarity index 54% rename from spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/AbstractTransactionalBehaviorTest.kt rename to itest/spring-boot-integration-testing/src/main/kotlin/dev/bpmcrafters/processengine/worker/itest/AbstractBehaviorITestBase.kt index 9fb81ca..26cd440 100644 --- a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/AbstractTransactionalBehaviorTest.kt +++ b/itest/spring-boot-integration-testing/src/main/kotlin/dev/bpmcrafters/processengine/worker/itest/AbstractBehaviorITestBase.kt @@ -1,42 +1,30 @@ -package dev.bpmcrafters.processengine.worker.itest.camunda7.external - -import dev.bpmcrafters.processengine.worker.TestHelper -import dev.bpmcrafters.processengine.worker.TestHelper.Camunda7RunTestContainer -import dev.bpmcrafters.processengine.worker.itest.camunda7.external.application.MyEntity -import dev.bpmcrafters.processengine.worker.itest.camunda7.external.application.MyEntityService -import dev.bpmcrafters.processengine.worker.itest.camunda7.external.application.TestApplication -import dev.bpmcrafters.processengineapi.deploy.DeployBundleCommand +package dev.bpmcrafters.processengine.worker.itest + import dev.bpmcrafters.processengineapi.deploy.DeploymentApi -import dev.bpmcrafters.processengineapi.deploy.NamedResource import dev.bpmcrafters.processengineapi.process.StartProcessApi -import dev.bpmcrafters.processengineapi.process.StartProcessByDefinitionCmd -import org.assertj.core.api.Assertions.assertThat +import org.camunda.bpm.client.ExternalTaskClient import org.camunda.community.rest.client.api.ExternalTaskApiClient import org.camunda.community.rest.client.api.HistoryApiClient +import org.camunda.community.rest.client.api.IncidentApiClient import org.camunda.community.rest.client.api.ProcessInstanceApiClient -import org.camunda.community.rest.client.model.ExternalTaskDto -import org.camunda.community.rest.client.model.ExternalTaskQueryDto -import org.camunda.community.rest.client.model.HistoricActivityInstanceDto -import org.camunda.community.rest.client.model.HistoricActivityInstanceQueryDto +import org.camunda.community.rest.client.model.* import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertySource import org.testcontainers.junit.jupiter.Container import org.testcontainers.junit.jupiter.Testcontainers -@SpringBootTest(classes = [TestApplication::class]) @Testcontainers @ActiveProfiles("itest") -abstract class AbstractTransactionalBehaviorTest { +abstract class AbstractBehaviorITestBase { companion object { @Container @JvmStatic - val camundaContainer = Camunda7RunTestContainer("run-7.24.0") + val camundaContainer = TestHelper.Camunda7RunTestContainer("run-7.24.0") @Container @JvmStatic @@ -53,31 +41,31 @@ abstract class AbstractTransactionalBehaviorTest { registry.add("feign.client.config.default.url") { "http://localhost:${camundaContainer.firstMappedPort}/engine-rest/" } } } + @Autowired - private lateinit var deploymentApi: DeploymentApi + protected lateinit var deploymentApi: DeploymentApi + @Autowired - private lateinit var startProcessApi: StartProcessApi + protected lateinit var startProcessApi: StartProcessApi + @Autowired - private lateinit var externalTaskApiClient: ExternalTaskApiClient + protected lateinit var externalTaskApiClient: ExternalTaskApiClient + @Autowired - private lateinit var historyApiClient: HistoryApiClient + protected lateinit var incidentApiClient: IncidentApiClient + @Autowired - private lateinit var processInstanceApiClient: ProcessInstanceApiClient + protected lateinit var historyApiClient: HistoryApiClient + @Autowired - private lateinit var myEntityService: MyEntityService + protected lateinit var processInstanceApiClient: ProcessInstanceApiClient + @Autowired - private lateinit var camundaTaskClient: org.camunda.bpm.client.ExternalTaskClient + protected lateinit var camundaTaskClient: ExternalTaskClient @BeforeEach fun setUp() { camundaTaskClient.start() - deploymentApi.deploy( - DeployBundleCommand( - listOf(NamedResource.fromClasspath("bpmn/example-process.bpmn")) - ) - ).get().let { deployment -> - assertThat(deployment).isNotNull - } } @AfterEach @@ -96,7 +84,7 @@ abstract class AbstractTransactionalBehaviorTest { true, // active false, // suspended false, // with incident - null, null,null, null, // incident + null, null, null, null, // incident null, null, null, // tenant null, // activity id in null, null, // root process instance @@ -111,6 +99,34 @@ abstract class AbstractTransactionalBehaviorTest { .body as List } + protected fun unlockExternalTask(taskId: String) { + externalTaskApiClient.unlock(taskId) + } + + protected fun getIncidents(processInstanceId: String) = incidentApiClient.getIncidents( + null, + null, + null, + null, + null, + null, + processInstanceId, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ).body as List + protected fun getHistoricActivityInstances(processInstanceId: String, activityId: String): List { return historyApiClient.queryHistoricActivityInstances( 0, @@ -121,32 +137,4 @@ abstract class AbstractTransactionalBehaviorTest { }) .body as List } - - protected fun startProcess( - name: String, - verified: Boolean, - simulateRandomTechnicalError: Boolean = false, - apiCallShouldFail: Boolean = false - ): String { - return startProcessApi.startProcess( - StartProcessByDefinitionCmd( - definitionKey = "example_process", - mapOf( - "name" to name, - "verified" to verified, - "simulateRandomTechnicalError" to simulateRandomTechnicalError, - "apiCallShouldFail" to apiCallShouldFail - ) - ) - ).get() - .instanceId - } - - protected fun entityExistsForName(name: String): Boolean { - return findEntityByName(name) != null - } - - protected fun findEntityByName(name: String): MyEntity? { - return myEntityService.findByName(name) - } } diff --git a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/TestHelper.kt b/itest/spring-boot-integration-testing/src/main/kotlin/dev/bpmcrafters/processengine/worker/itest/TestHelper.kt similarity index 79% rename from spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/TestHelper.kt rename to itest/spring-boot-integration-testing/src/main/kotlin/dev/bpmcrafters/processengine/worker/itest/TestHelper.kt index 8692809..3e3509b 100644 --- a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/TestHelper.kt +++ b/itest/spring-boot-integration-testing/src/main/kotlin/dev/bpmcrafters/processengine/worker/itest/TestHelper.kt @@ -1,15 +1,18 @@ -package dev.bpmcrafters.processengine.worker +package dev.bpmcrafters.processengine.worker.itest import org.testcontainers.containers.GenericContainer import org.testcontainers.containers.PostgreSQLContainer import org.testcontainers.containers.wait.strategy.Wait import org.testcontainers.utility.DockerImageName - - object TestHelper { - class Camunda7RunTestContainer(tag: String) : GenericContainer("camunda/camunda-bpm-platform:$tag") { + /** + * Camunda 7 Run test container. + */ + class Camunda7RunTestContainer(tag: String) : GenericContainer( + "camunda/camunda-bpm-platform:$tag" + ) { init { withCommand("./camunda.sh", "--rest") @@ -23,6 +26,9 @@ object TestHelper { } } + /** + * PostgreSQL container. + */ fun postgresContainer() = PostgreSQLContainer(DockerImageName.parse("postgres:16-alpine")) .withDatabaseName("integration-test") .withUsername("integration-user") diff --git a/itest/spring-boot-starter-integration-test/pom.xml b/itest/spring-boot-starter-integration-test/pom.xml new file mode 100644 index 0000000..75ad873 --- /dev/null +++ b/itest/spring-boot-starter-integration-test/pom.xml @@ -0,0 +1,60 @@ + + 4.0.0 + + dev.bpm-crafters.process-engine-worker + process-engine-worker-itest + 0.7.2-SNAPSHOT + ../pom.xml + + + process-engine-worker-spring-boot-starter-integration-test + ITest: Integration tests of process-engine-worker + + + + dev.bpm-crafters.process-engine-worker + process-engine-worker-spring-boot-idempotency-registry-jpa + + + + ${project.groupId} + process-engine-worker-integration-testing + ${project.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + *ITest.class + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + *ITest.class + + + + + integration-tests + integration-test + + integration-test + + + + + + + + diff --git a/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/InMemoryIdempotencyRegistryConfiguration.kt b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/InMemoryIdempotencyRegistryConfiguration.kt new file mode 100644 index 0000000..ed7f5c2 --- /dev/null +++ b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/InMemoryIdempotencyRegistryConfiguration.kt @@ -0,0 +1,12 @@ +package dev.bpmcrafters.processengine.worker.fixture + +import dev.bpmcrafters.processengine.worker.idempotency.IdempotencyRegistry +import dev.bpmcrafters.processengine.worker.idempotency.InMemoryIdempotencyRegistry +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Primary + +class InMemoryIdempotencyRegistryConfiguration { + @Bean + @Primary + fun inMemIdempotencyRegistry(): IdempotencyRegistry = InMemoryIdempotencyRegistry() +} diff --git a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/application/TestApplication.kt b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/TestApplication.kt similarity index 66% rename from spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/application/TestApplication.kt rename to itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/TestApplication.kt index edadbeb..65731c6 100644 --- a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/application/TestApplication.kt +++ b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/TestApplication.kt @@ -1,18 +1,22 @@ -package dev.bpmcrafters.processengine.worker.itest.camunda7.external.application +package dev.bpmcrafters.processengine.worker.fixture import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.datatype.jdk8.Jdk8Module import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.domain.EntityScan import org.springframework.context.annotation.Bean +import org.springframework.data.jpa.repository.config.EnableJpaRepositories import java.text.SimpleDateFormat @SpringBootApplication +@EntityScan(basePackageClasses = [MyEntity::class]) +@EnableJpaRepositories(basePackageClasses = [MyEntityRepository::class]) class TestApplication { @Bean - fun objectMapper(): ObjectMapper = jacksonObjectMapper().apply{ + fun objectMapper(): ObjectMapper = jacksonObjectMapper().apply { registerModule(Jdk8Module()) registerModule(JavaTimeModule()) dateFormat = SimpleDateFormat("yyyy-MM-dd'T'hh:MM:ss.SSSz") diff --git a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/application/exceptions.kt b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/exceptions.kt similarity index 70% rename from spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/application/exceptions.kt rename to itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/exceptions.kt index 38aa3b6..0cdfaf1 100644 --- a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/application/exceptions.kt +++ b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/exceptions.kt @@ -1,4 +1,4 @@ -package dev.bpmcrafters.processengine.worker.itest.camunda7.external.application +package dev.bpmcrafters.processengine.worker.fixture import dev.bpmcrafters.processengine.worker.BpmnErrorOccurred diff --git a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/application/persistence.kt b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/persistence.kt similarity index 93% rename from spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/application/persistence.kt rename to itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/persistence.kt index 430392f..37ec55b 100644 --- a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/application/persistence.kt +++ b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/persistence.kt @@ -1,4 +1,4 @@ -package dev.bpmcrafters.processengine.worker.itest.camunda7.external.application +package dev.bpmcrafters.processengine.worker.fixture import jakarta.persistence.Column import jakarta.persistence.Entity diff --git a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/application/services.kt b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/services.kt similarity index 77% rename from spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/application/services.kt rename to itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/services.kt index a13736a..11d0157 100644 --- a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/application/services.kt +++ b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/services.kt @@ -1,4 +1,4 @@ -package dev.bpmcrafters.processengine.worker.itest.camunda7.external.application +package dev.bpmcrafters.processengine.worker.fixture import org.springframework.stereotype.Service @@ -24,7 +24,6 @@ class MyEntityService(private val repository: MyEntityRepository) { return entity } - fun findByName(name: String): MyEntity? { - return repository.findByName(name) - } + fun findByName(name: String): MyEntity? = repository.findByName(name) + } diff --git a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/application/AbstractExampleProcessWorker.kt b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/worker/AbstractExampleProcessWorker.kt similarity index 92% rename from spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/application/AbstractExampleProcessWorker.kt rename to itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/worker/AbstractExampleProcessWorker.kt index 0e2b9a5..149dceb 100644 --- a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/application/AbstractExampleProcessWorker.kt +++ b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/worker/AbstractExampleProcessWorker.kt @@ -1,5 +1,6 @@ -package dev.bpmcrafters.processengine.worker.itest.camunda7.external.application +package dev.bpmcrafters.processengine.worker.fixture.worker +import dev.bpmcrafters.processengine.worker.fixture.MyEntityService import dev.bpmcrafters.processengineapi.CommonRestrictions import dev.bpmcrafters.processengineapi.task.TaskInformation import io.github.oshai.kotlinlogging.KotlinLogging diff --git a/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/worker/WorkerWithFailJobException.kt b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/worker/WorkerWithFailJobException.kt new file mode 100644 index 0000000..a76c28a --- /dev/null +++ b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/worker/WorkerWithFailJobException.kt @@ -0,0 +1,41 @@ +package dev.bpmcrafters.processengine.worker.fixture.worker + +import dev.bpmcrafters.processengine.worker.FailJobException +import dev.bpmcrafters.processengine.worker.ProcessEngineWorker +import dev.bpmcrafters.processengine.worker.Variable +import dev.bpmcrafters.processengine.worker.fixture.MyEntityService +import dev.bpmcrafters.processengineapi.task.TaskInformation +import org.camunda.community.rest.client.api.ProcessInstanceApiClient +import java.time.Duration + +class WorkerWithFailJobException( + myEntityService: MyEntityService, + processInstanceApiClient: ProcessInstanceApiClient, +) : AbstractExampleProcessWorker( + myEntityService = myEntityService, + processInstanceApiClient = processInstanceApiClient +) { + @ProcessEngineWorker( + topic = "example.create-entity" + ) + override fun createEntity( + task: TaskInformation, + @Variable(name = "name") name: String, + @Variable(name = "verified") verified: Boolean, + @Variable(name = "simulateRandomTechnicalError") simulateRandomTechnicalError: Boolean, + @Variable(name = "apiCallShouldFail") apiCallShouldFail: Boolean + ): Map { + if (simulateRandomTechnicalError) { + val message = "Simulating a technical error for task ${task.taskId}" + throw FailJobException(message = message, retryCount = 3, retryBackoff = Duration.ofSeconds(10)) + } + return super.createEntity(task, name, verified, false, apiCallShouldFail) + } + + @ProcessEngineWorker( + topic = "example.verify-entity", + ) + override fun verifyEntity(@Variable(name = "id") id: String) { + super.verifyEntity(id) + } +} diff --git a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/WorkerWithTransactionalAnnotation.kt b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/worker/WorkerWithTransactionalAnnotation.kt similarity index 57% rename from spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/WorkerWithTransactionalAnnotation.kt rename to itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/worker/WorkerWithTransactionalAnnotation.kt index 8a00553..fdb41ac 100644 --- a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/WorkerWithTransactionalAnnotation.kt +++ b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/worker/WorkerWithTransactionalAnnotation.kt @@ -1,16 +1,15 @@ -package dev.bpmcrafters.processengine.worker.itest.camunda7.external +package dev.bpmcrafters.processengine.worker.fixture.worker import dev.bpmcrafters.processengine.worker.ProcessEngineWorker import dev.bpmcrafters.processengine.worker.Variable -import dev.bpmcrafters.processengine.worker.itest.camunda7.external.application.AbstractExampleProcessWorker -import dev.bpmcrafters.processengine.worker.itest.camunda7.external.application.MyEntityService +import dev.bpmcrafters.processengine.worker.fixture.MyEntityService import dev.bpmcrafters.processengineapi.task.TaskInformation import org.camunda.community.rest.client.api.ProcessInstanceApiClient import org.springframework.transaction.annotation.Transactional open class WorkerWithTransactionalAnnotation( - myEntityService: MyEntityService, - processInstanceApiClient: ProcessInstanceApiClient, + myEntityService: MyEntityService, + processInstanceApiClient: ProcessInstanceApiClient, ) : AbstractExampleProcessWorker(myEntityService = myEntityService, processInstanceApiClient = processInstanceApiClient) { @Transactional @@ -18,11 +17,11 @@ open class WorkerWithTransactionalAnnotation( topic = "example.create-entity" ) override fun createEntity( - task: TaskInformation, - @Variable(name = "name") name: String, - @Variable(name = "verified") verified: Boolean, - @Variable(name = "simulateRandomTechnicalError") simulateRandomTechnicalError: Boolean, - @Variable(name = "apiCallShouldFail") apiCallShouldFail: Boolean + task: TaskInformation, + @Variable(name = "name") name: String, + @Variable(name = "verified") verified: Boolean, + @Variable(name = "simulateRandomTechnicalError") simulateRandomTechnicalError: Boolean, + @Variable(name = "apiCallShouldFail") apiCallShouldFail: Boolean ): Map { return super.createEntity(task, name, verified, simulateRandomTechnicalError, apiCallShouldFail) } diff --git a/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/worker/WorkerWithoutTransactionalAnnotation.kt b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/worker/WorkerWithoutTransactionalAnnotation.kt new file mode 100644 index 0000000..5457612 --- /dev/null +++ b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/fixture/worker/WorkerWithoutTransactionalAnnotation.kt @@ -0,0 +1,36 @@ +package dev.bpmcrafters.processengine.worker.fixture.worker + +import dev.bpmcrafters.processengine.worker.ProcessEngineWorker +import dev.bpmcrafters.processengine.worker.Variable +import dev.bpmcrafters.processengine.worker.fixture.MyEntityService +import dev.bpmcrafters.processengineapi.task.TaskInformation +import org.camunda.community.rest.client.api.ProcessInstanceApiClient + +class WorkerWithoutTransactionalAnnotation( + myEntityService: MyEntityService, + processInstanceApiClient: ProcessInstanceApiClient, +) : AbstractExampleProcessWorker( + myEntityService = myEntityService, + processInstanceApiClient = processInstanceApiClient +) { + + @ProcessEngineWorker( + topic = "example.create-entity" + ) + override fun createEntity( + task: TaskInformation, + @Variable(name = "name") name: String, + @Variable(name = "verified") verified: Boolean, + @Variable(name = "simulateRandomTechnicalError") simulateRandomTechnicalError: Boolean, + @Variable(name = "apiCallShouldFail") apiCallShouldFail: Boolean + ): Map { + return super.createEntity(task, name, verified, simulateRandomTechnicalError, apiCallShouldFail) + } + + @ProcessEngineWorker( + topic = "example.verify-entity", + ) + override fun verifyEntity(@Variable(name = "id") id: String) { + super.verifyEntity(id) + } +} diff --git a/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/FixtureITestBase.kt b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/FixtureITestBase.kt new file mode 100644 index 0000000..755966f --- /dev/null +++ b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/FixtureITestBase.kt @@ -0,0 +1,62 @@ +package dev.bpmcrafters.processengine.worker.itest + +import dev.bpmcrafters.processengine.worker.fixture.MyEntity +import dev.bpmcrafters.processengine.worker.fixture.MyEntityService +import dev.bpmcrafters.processengine.worker.fixture.TestApplication +import dev.bpmcrafters.processengineapi.deploy.DeployBundleCommand +import dev.bpmcrafters.processengineapi.deploy.NamedResource +import dev.bpmcrafters.processengineapi.process.StartProcessByDefinitionCmd +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.annotation.DirtiesContext + +@SpringBootTest(classes = [TestApplication::class]) +@DirtiesContext +abstract class FixtureITestBase : AbstractBehaviorITestBase() { + + @Autowired + private lateinit var myEntityService: MyEntityService + + fun entityExistsForName(name: String): Boolean { + return findEntityByName(name) != null + } + + fun findEntityByName(name: String): MyEntity? { + return myEntityService.findByName(name) + } + + @BeforeEach + fun deploy() { + deploymentApi.deploy( + DeployBundleCommand( + listOf(NamedResource.Companion.fromClasspath("bpmn/example-process.bpmn")) + ) + ).get().let { deployment -> + Assertions.assertThat(deployment).isNotNull + } + } + + protected fun startProcess( + name: String, + verified: Boolean, + simulateRandomTechnicalError: Boolean = false, + apiCallShouldFail: Boolean = false + ): String { + return startProcessApi.startProcess( + StartProcessByDefinitionCmd( + definitionKey = "example_process", + mapOf( + "name" to name, + "verified" to verified, + "simulateRandomTechnicalError" to simulateRandomTechnicalError, + "apiCallShouldFail" to apiCallShouldFail + ) + ) + ).get() + .instanceId + } + + +} diff --git a/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/idempotency/IdempotencyITest.kt b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/idempotency/IdempotencyITest.kt new file mode 100644 index 0000000..b247b40 --- /dev/null +++ b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/idempotency/IdempotencyITest.kt @@ -0,0 +1,84 @@ +package dev.bpmcrafters.processengine.worker.itest.idempotency + +import dev.bpmcrafters.processengine.worker.fixture.InMemoryIdempotencyRegistryConfiguration +import dev.bpmcrafters.processengine.worker.fixture.TestApplication +import dev.bpmcrafters.processengine.worker.fixture.worker.WorkerWithTransactionalAnnotation +import dev.bpmcrafters.processengine.worker.fixture.worker.WorkerWithoutTransactionalAnnotation +import dev.bpmcrafters.processengine.worker.idempotency.IdempotencyRegistry +import dev.bpmcrafters.processengine.worker.itest.FixtureITestBase +import dev.bpmcrafters.processengineapi.task.ServiceTaskCompletionApi +import org.assertj.core.api.Assertions +import org.awaitility.Awaitility +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.mockito.kotlin.* +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean +import java.util.* +import java.util.concurrent.TimeUnit + +@TestPropertySource( + properties = [ + "dev.bpm-crafters.process-api.worker.complete-tasks-before-commit=false" + ] +) +@MockitoSpyBean(types = [IdempotencyRegistry::class]) +@MockitoSpyBean(name = "c7remote-service-task-completion-api", types = [ServiceTaskCompletionApi::class]) +abstract class IdempotencyITest : FixtureITestBase() { + + @Autowired + private lateinit var idempotencyRegistry: IdempotencyRegistry + + @Autowired + private lateinit var serviceTaskCompletionApi: ServiceTaskCompletionApi + + @Test + fun `fetching the same task does not execute business logic again`() { + val name = "Big or Lil' Someone ${UUID.randomUUID()}" + doThrow(IllegalStateException("Many things have gone wrong while completing a task")) + .`when`(serviceTaskCompletionApi) + .completeTask(any()) + val processInstanceId = startProcess(name, verified = true) + Assertions.assertThat(processInstanceIsRunning(processInstanceId)).isTrue() + Awaitility.await().atMost(30, TimeUnit.SECONDS).untilAsserted { + val entity = findEntityByName(name) + Assertions.assertThat(entity).isNotNull + } + doCallRealMethod().`when`(serviceTaskCompletionApi).completeTask(any()) + unlockExternalTask(getExternalTasks(processInstanceId).first().id!!) + print(idempotencyRegistry) + Awaitility.await().atMost(30, TimeUnit.SECONDS).untilAsserted { + Assertions.assertThat(processInstanceIsRunning(processInstanceId)).isFalse + } + val inOrder = inOrder(idempotencyRegistry) + inOrder.verify(idempotencyRegistry).getTaskResult(any()) + inOrder.verify(idempotencyRegistry).register(any(), any()) + inOrder.verify(idempotencyRegistry).getTaskResult(any()) + inOrder.verify(idempotencyRegistry, never()).register(any(), any()) + } + + + @Nested + @Import( + InMemoryIdempotencyRegistryConfiguration::class, + WorkerWithoutTransactionalAnnotation::class + ) + class InMemoryIdempotencyWithoutTransactionITest : IdempotencyITest() + + @Nested + @Import( + InMemoryIdempotencyRegistryConfiguration::class, + WorkerWithTransactionalAnnotation::class + ) + class InMemoryIdempotencyWithTransactionITest : IdempotencyITest() + + @Nested + @Import(WorkerWithTransactionalAnnotation::class) + class JpaIdempotencyWithTransactionITest : IdempotencyITest() + +} + diff --git a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/ExternalTaskCompletionAfterCommitTest.kt b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/registrar/ExternalTaskCompletionAfterCommitITest.kt similarity index 72% rename from spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/ExternalTaskCompletionAfterCommitTest.kt rename to itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/registrar/ExternalTaskCompletionAfterCommitITest.kt index c1ddcba..25c2f83 100644 --- a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/ExternalTaskCompletionAfterCommitTest.kt +++ b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/registrar/ExternalTaskCompletionAfterCommitITest.kt @@ -1,21 +1,27 @@ -package dev.bpmcrafters.processengine.worker.itest.camunda7.external +package dev.bpmcrafters.processengine.worker.itest.registrar +import dev.bpmcrafters.processengine.worker.fixture.worker.WorkerWithTransactionalAnnotation +import dev.bpmcrafters.processengine.worker.itest.FixtureITestBase import org.assertj.core.api.Assertions.assertThat import org.awaitility.Awaitility.await import org.junit.jupiter.api.Test import org.springframework.context.annotation.Import import org.springframework.test.context.TestPropertySource import java.util.* +import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit.SECONDS + @Import(WorkerWithTransactionalAnnotation::class) -@TestPropertySource(properties = [ - "dev.bpm-crafters.process-api.worker.complete-tasks-before-commit=false" -]) -class ExternalTaskCompletionAfterCommitTest : AbstractTransactionalBehaviorTest() { +@TestPropertySource( + properties = [ + "dev.bpm-crafters.process-api.worker.complete-tasks-before-commit=false" + ] +) +class ExternalTaskCompletionAfterCommitITest : FixtureITestBase() { @Test - fun `successful worker completes task after of transaction has been committed`() { + fun `successful worker completes task after transaction has been committed`() { val name = "Big or Lil' Someone ${UUID.randomUUID()}" val pi = startProcess(name = name, verified = true) assertThat(processInstanceIsRunning(pi)).isTrue() diff --git a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/ExternalTaskCompletionBeforeCommitTest.kt b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/registrar/ExternalTaskCompletionBeforeCommitITest.kt similarity index 85% rename from spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/ExternalTaskCompletionBeforeCommitTest.kt rename to itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/registrar/ExternalTaskCompletionBeforeCommitITest.kt index f9fcf39..e15af2e 100644 --- a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/ExternalTaskCompletionBeforeCommitTest.kt +++ b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/registrar/ExternalTaskCompletionBeforeCommitITest.kt @@ -1,18 +1,23 @@ -package dev.bpmcrafters.processengine.worker.itest.camunda7.external +package dev.bpmcrafters.processengine.worker.itest.registrar +import dev.bpmcrafters.processengine.worker.fixture.worker.WorkerWithTransactionalAnnotation +import dev.bpmcrafters.processengine.worker.itest.FixtureITestBase import org.assertj.core.api.Assertions.assertThat import org.awaitility.Awaitility.await import org.junit.jupiter.api.Test import org.springframework.context.annotation.Import import org.springframework.test.context.TestPropertySource import java.util.* +import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit.SECONDS @Import(WorkerWithTransactionalAnnotation::class) -@TestPropertySource(properties = [ - "dev.bpm-crafters.process-api.worker.complete-tasks-before-commit=true" -]) -class ExternalTaskCompletionBeforeCommitTest : AbstractTransactionalBehaviorTest() { +@TestPropertySource( + properties = [ + "dev.bpm-crafters.process-api.worker.complete-tasks-before-commit=true" + ] +) +class ExternalTaskCompletionBeforeCommitITest : FixtureITestBase() { @Test fun `happy path create two verified valid entity`() { diff --git a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/ExternalTaskCompletionWithoutTransactionTest.kt b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/registrar/ExternalTaskCompletionWithoutTransactionITest.kt similarity index 61% rename from spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/ExternalTaskCompletionWithoutTransactionTest.kt rename to itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/registrar/ExternalTaskCompletionWithoutTransactionITest.kt index 3422c67..7f71f52 100644 --- a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/ExternalTaskCompletionWithoutTransactionTest.kt +++ b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/registrar/ExternalTaskCompletionWithoutTransactionITest.kt @@ -1,49 +1,17 @@ -package dev.bpmcrafters.processengine.worker.itest.camunda7.external +package dev.bpmcrafters.processengine.worker.itest.registrar -import dev.bpmcrafters.processengine.worker.ProcessEngineWorker -import dev.bpmcrafters.processengine.worker.Variable -import dev.bpmcrafters.processengine.worker.itest.camunda7.external.application.AbstractExampleProcessWorker -import dev.bpmcrafters.processengine.worker.itest.camunda7.external.application.MyEntityService -import dev.bpmcrafters.processengineapi.task.TaskInformation +import dev.bpmcrafters.processengine.worker.fixture.worker.WorkerWithoutTransactionalAnnotation +import dev.bpmcrafters.processengine.worker.itest.FixtureITestBase import org.assertj.core.api.Assertions.assertThat import org.awaitility.Awaitility.await -import org.camunda.community.rest.client.api.ProcessInstanceApiClient import org.junit.jupiter.api.Test import org.springframework.context.annotation.Import import java.util.* +import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit.SECONDS -@Import(ExternalTaskCompletionWithoutTransactionTest.WorkerWithoutTransactionalAnnotation::class) -class ExternalTaskCompletionWithoutTransactionTest : AbstractTransactionalBehaviorTest() { - - class WorkerWithoutTransactionalAnnotation( - myEntityService: MyEntityService, - processInstanceApiClient: ProcessInstanceApiClient, - ) : AbstractExampleProcessWorker( - myEntityService = myEntityService, - processInstanceApiClient = processInstanceApiClient - ) { - - @ProcessEngineWorker( - topic = "example.create-entity" - ) - override fun createEntity( - task: TaskInformation, - @Variable(name = "name") name: String, - @Variable(name = "verified") verified: Boolean, - @Variable(name = "simulateRandomTechnicalError") simulateRandomTechnicalError: Boolean, - @Variable(name = "apiCallShouldFail") apiCallShouldFail: Boolean - ): Map { - return super.createEntity(task, name, verified, simulateRandomTechnicalError, apiCallShouldFail) - } - - @ProcessEngineWorker( - topic = "example.verify-entity", - ) - override fun verifyEntity(@Variable(name = "id") id: String) { - super.verifyEntity(id) - } - } +@Import(WorkerWithoutTransactionalAnnotation::class) +class ExternalTaskCompletionWithoutTransactionITest : FixtureITestBase() { @Test fun `happy path create two verified valid entity`() { diff --git a/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/registrar/ExternalTaskFailJobExceptionITest.kt b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/registrar/ExternalTaskFailJobExceptionITest.kt new file mode 100644 index 0000000..d4bb300 --- /dev/null +++ b/itest/spring-boot-starter-integration-test/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/registrar/ExternalTaskFailJobExceptionITest.kt @@ -0,0 +1,36 @@ +package dev.bpmcrafters.processengine.worker.itest.registrar + +import dev.bpmcrafters.processengine.worker.fixture.worker.WorkerWithFailJobException +import dev.bpmcrafters.processengine.worker.itest.FixtureITestBase +import org.assertj.core.api.Assertions.assertThat +import org.awaitility.Awaitility.await +import org.junit.jupiter.api.Test +import org.springframework.context.annotation.Import +import org.springframework.test.context.TestPropertySource +import java.util.* +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeUnit.SECONDS +import kotlin.time.Duration.Companion.seconds + +@Import(WorkerWithFailJobException::class) +@TestPropertySource( + properties = [ + "dev.bpm-crafters.process-api.worker.complete-tasks-before-commit=true" + ] +) +class ExternalTaskFailJobExceptionITest : FixtureITestBase() { + + @Test + fun `fail job exception will fail job with specified retries`() { + val name = "Big or Lil' Someone ${UUID.randomUUID()}" + val pi = startProcess(name = name, verified = true, simulateRandomTechnicalError = true) + assertThat(processInstanceIsRunning(pi)).isTrue() + await().atMost(30, SECONDS).untilAsserted { + val task = getExternalTasks(pi)[0] + assertThat(task.errorMessage).isEqualTo("Simulating a technical error for task ${task.id}") + assertThat(task.retries!!).isEqualTo(3) + } + assertThat(entityExistsForName(name)).isFalse + } + +} diff --git a/spring-boot-starter/src/test/resources/application-itest.yaml b/itest/spring-boot-starter-integration-test/src/test/resources/application-itest.yaml similarity index 100% rename from spring-boot-starter/src/test/resources/application-itest.yaml rename to itest/spring-boot-starter-integration-test/src/test/resources/application-itest.yaml diff --git a/spring-boot-starter/src/test/resources/bpmn/example-process.bpmn b/itest/spring-boot-starter-integration-test/src/test/resources/bpmn/example-process.bpmn similarity index 100% rename from spring-boot-starter/src/test/resources/bpmn/example-process.bpmn rename to itest/spring-boot-starter-integration-test/src/test/resources/bpmn/example-process.bpmn diff --git a/pom.xml b/pom.xml index 3ab5d7b..e9df17a 100644 --- a/pom.xml +++ b/pom.xml @@ -30,8 +30,8 @@ + spring-boot-idempotency-registry-jpa spring-boot-starter - @@ -103,6 +103,18 @@ examples + + + + + !skipITests + + + itest + + itest + + @@ -125,6 +137,7 @@ spring no-arg + jpa all-open diff --git a/spring-boot-idempotency-registry-jpa/pom.xml b/spring-boot-idempotency-registry-jpa/pom.xml new file mode 100644 index 0000000..1be0971 --- /dev/null +++ b/spring-boot-idempotency-registry-jpa/pom.xml @@ -0,0 +1,38 @@ + + 4.0.0 + + dev.bpm-crafters.process-engine-worker + process-engine-worker-root + 0.7.2-SNAPSHOT + ../pom.xml + + + process-engine-worker-spring-boot-idempotency-registry-jpa + ${project.artifactId} + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + import + pom + + + + + + + ${project.groupId} + process-engine-worker-spring-boot-starter + ${project.version} + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + diff --git a/spring-boot-idempotency-registry-jpa/src/main/kotlin/dev/bpmcrafters/processengine/worker/configuration/JpaIdempotencyAutoConfiguration.kt b/spring-boot-idempotency-registry-jpa/src/main/kotlin/dev/bpmcrafters/processengine/worker/configuration/JpaIdempotencyAutoConfiguration.kt new file mode 100644 index 0000000..9f2e410 --- /dev/null +++ b/spring-boot-idempotency-registry-jpa/src/main/kotlin/dev/bpmcrafters/processengine/worker/configuration/JpaIdempotencyAutoConfiguration.kt @@ -0,0 +1,27 @@ +package dev.bpmcrafters.processengine.worker.configuration + +import dev.bpmcrafters.processengine.worker.idempotency.IdempotencyRegistry +import dev.bpmcrafters.processengine.worker.idempotency.JpaIdempotencyRegistry +import dev.bpmcrafters.processengine.worker.idempotency.TaskLogEntry +import dev.bpmcrafters.processengine.worker.idempotency.TaskLogEntryRepository +import org.springframework.boot.autoconfigure.AutoConfiguration +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.context.annotation.Bean +import org.springframework.data.jpa.repository.config.EnableJpaRepositories + +/** + * Autoconfiguration for JPA-based Idempotency Registry. + * @since 0.8.0 + */ +@AutoConfiguration(before = [ProcessEngineWorkerAutoConfiguration::class]) +@ConditionalOnMissingBean(IdempotencyRegistry::class) +@EntityScan(basePackageClasses = [TaskLogEntry::class]) +@EnableJpaRepositories(basePackageClasses = [TaskLogEntryRepository::class]) +class JpaIdempotencyAutoConfiguration { + + @Bean + fun jpaIdempotencyRegistry(taskLogEntryRepository: TaskLogEntryRepository): IdempotencyRegistry = + JpaIdempotencyRegistry(taskLogEntryRepository) + +} diff --git a/spring-boot-idempotency-registry-jpa/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/JpaIdempotencyRegistry.kt b/spring-boot-idempotency-registry-jpa/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/JpaIdempotencyRegistry.kt new file mode 100644 index 0000000..ee0444a --- /dev/null +++ b/spring-boot-idempotency-registry-jpa/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/JpaIdempotencyRegistry.kt @@ -0,0 +1,32 @@ +package dev.bpmcrafters.processengine.worker.idempotency + +import dev.bpmcrafters.processengineapi.CommonRestrictions +import dev.bpmcrafters.processengineapi.task.TaskInformation +import org.springframework.data.repository.findByIdOrNull +import java.time.Clock + +/** + * JPA Idempotency Registry implementation. + * @since 0.8.0 + */ +class JpaIdempotencyRegistry( + val taskLogEntryRepository: TaskLogEntryRepository, + val clock: Clock = Clock.systemUTC() +) : IdempotencyRegistry { + + override fun register(taskInformation: TaskInformation, result: Map) { + taskLogEntryRepository.save( + TaskLogEntry( + taskInformation.taskId, + taskInformation.meta[CommonRestrictions.PROCESS_INSTANCE_ID] as String, + clock.instant(), + result + ) + ) + } + + override fun getTaskResult(taskInformation: TaskInformation): Map? = taskLogEntryRepository + .findByIdOrNull(taskInformation.taskId) + ?.result + +} diff --git a/spring-boot-idempotency-registry-jpa/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/MapConverter.kt b/spring-boot-idempotency-registry-jpa/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/MapConverter.kt new file mode 100644 index 0000000..8a64775 --- /dev/null +++ b/spring-boot-idempotency-registry-jpa/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/MapConverter.kt @@ -0,0 +1,30 @@ +package dev.bpmcrafters.processengine.worker.idempotency + +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.ObjectInputStream +import java.io.ObjectOutputStream + +@Converter(autoApply = false) +internal class MapConverter : AttributeConverter, ByteArray> { + + override fun convertToDatabaseColumn(attribute: Map?): ByteArray? { + if (attribute == null) { + return null + } + ByteArrayOutputStream().use { + ObjectOutputStream(it).writeObject(attribute) + return it.toByteArray() + } + } + + override fun convertToEntityAttribute(dbData: ByteArray?): Map? { + if (dbData == null) { + return null + } + @Suppress("UNCHECKED_CAST") + return ObjectInputStream(ByteArrayInputStream(dbData)).readObject() as Map + } +} diff --git a/spring-boot-idempotency-registry-jpa/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/TaskLogEntry.kt b/spring-boot-idempotency-registry-jpa/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/TaskLogEntry.kt new file mode 100644 index 0000000..6841175 --- /dev/null +++ b/spring-boot-idempotency-registry-jpa/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/TaskLogEntry.kt @@ -0,0 +1,26 @@ +package dev.bpmcrafters.processengine.worker.idempotency + +import jakarta.persistence.* +import java.time.Instant + +@Entity +@Table(name = "task_log_entry_") +class TaskLogEntry( + @Id + @Column(name = "task_id_", nullable = false, length = 100) + val taskId: String, + @Column(name = "process_instance_id_", nullable = false, length = 100) + val processInstanceId: String, + @Column(name = "created_at_", nullable = false) + val createdAt: Instant, + @Column(name = "result_", nullable = false) + @Lob + @Convert(converter = MapConverter::class) + val result: Map +) { + + override fun toString(): String { + return "TaskLogEntry(taskId='$taskId', processInstanceId='$processInstanceId', createdAt=$createdAt, result=$result)" + } + +} diff --git a/spring-boot-idempotency-registry-jpa/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/TaskLogEntryRepository.kt b/spring-boot-idempotency-registry-jpa/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/TaskLogEntryRepository.kt new file mode 100644 index 0000000..7790ce2 --- /dev/null +++ b/spring-boot-idempotency-registry-jpa/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/TaskLogEntryRepository.kt @@ -0,0 +1,8 @@ +package dev.bpmcrafters.processengine.worker.idempotency + +import org.springframework.data.repository.CrudRepository + +/** + * JPA Repository for task log entry. + */ +interface TaskLogEntryRepository : CrudRepository diff --git a/spring-boot-idempotency-registry-jpa/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-boot-idempotency-registry-jpa/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..bba19b2 --- /dev/null +++ b/spring-boot-idempotency-registry-jpa/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +dev.bpmcrafters.processengine.worker.configuration.JpaIdempotencyAutoConfiguration diff --git a/spring-boot-starter/pom.xml b/spring-boot-starter/pom.xml index 5eadaea..18192c5 100644 --- a/spring-boot-starter/pom.xml +++ b/spring-boot-starter/pom.xml @@ -13,13 +13,6 @@ - - org.testcontainers - testcontainers-bom - 2.0.3 - import - pom - org.springframework.boot spring-boot-dependencies @@ -85,58 +78,6 @@ spring-boot-configuration-processor true - - - - org.awaitility - awaitility-kotlin - test - - - org.springframework.boot - spring-boot-starter-test - test - - - org.springframework.boot - spring-boot-starter-data-jpa - test - - - - org.testcontainers - junit-jupiter - test - - - org.testcontainers - postgresql - test - - - org.postgresql - postgresql - test - - - - dev.bpm-crafters.process-engine-adapters - process-engine-adapter-camunda-platform-c7-remote-spring-boot-starter - ${process-engine-adapters-c7.version} - test - - - org.camunda.bpm.springboot - camunda-bpm-spring-boot-starter-external-task-client - ${camunda-bpm-spring-boot-starter-external-task-client.version} - test - - - io.holunda.c7 - c7-rest-client-spring-boot-starter-feign - ${c7.version} - test - diff --git a/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/configuration/ProcessEngineWorkerAutoConfiguration.kt b/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/configuration/ProcessEngineWorkerAutoConfiguration.kt index 615f2c8..e748bfb 100644 --- a/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/configuration/ProcessEngineWorkerAutoConfiguration.kt +++ b/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/configuration/ProcessEngineWorkerAutoConfiguration.kt @@ -1,6 +1,8 @@ package dev.bpmcrafters.processengine.worker.configuration import com.fasterxml.jackson.databind.ObjectMapper +import dev.bpmcrafters.processengine.worker.idempotency.IdempotencyRegistry +import dev.bpmcrafters.processengine.worker.idempotency.NoOpIdempotencyRegistry import dev.bpmcrafters.processengine.worker.registrar.* import dev.bpmcrafters.processengine.worker.registrar.metrics.ProcessEngineWorkerMetricsMicrometer import dev.bpmcrafters.processengine.worker.registrar.metrics.ProcessEngineWorkerMetricsNoOp @@ -66,4 +68,11 @@ class ProcessEngineWorkerAutoConfiguration { return ProcessEngineWorkerMetricsNoOp } + /** + * Fallback to a no-op idempotency registry. + */ + @Bean + @ConditionalOnMissingBean(IdempotencyRegistry::class) + fun defaultIdempotencyRegistry(): IdempotencyRegistry = NoOpIdempotencyRegistry() + } diff --git a/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/IdempotencyRegistry.kt b/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/IdempotencyRegistry.kt new file mode 100644 index 0000000..392ee6f --- /dev/null +++ b/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/IdempotencyRegistry.kt @@ -0,0 +1,25 @@ +package dev.bpmcrafters.processengine.worker.idempotency + +import dev.bpmcrafters.processengineapi.task.TaskInformation + +/** + * Idempotency registry used to avoid duplicate worker invocations. + * @since 0.8.0 + */ +interface IdempotencyRegistry { + + /** + * Registers the result of a task invocation. + * @param taskInformation the to register the result for. + * @param result the result of worker invocation. + */ + fun register(taskInformation: TaskInformation, result: Map) + + /** + * Gets the result of a task invocation. + * @param taskInformation the task to get the result for. + * @return the result payload of a previous worker invocation if it exists. + */ + fun getTaskResult(taskInformation: TaskInformation): Map? + +} diff --git a/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/InMemoryIdempotencyRegistry.kt b/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/InMemoryIdempotencyRegistry.kt new file mode 100644 index 0000000..65e2a9f --- /dev/null +++ b/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/InMemoryIdempotencyRegistry.kt @@ -0,0 +1,35 @@ +package dev.bpmcrafters.processengine.worker.idempotency + +import dev.bpmcrafters.processengineapi.task.TaskInformation +import jakarta.transaction.TransactionSynchronizationRegistry +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager +import java.util.concurrent.ConcurrentHashMap + +/** + * In-memory local implementation of the registry. + * + * Usage is discouraged because it can only take into account a single process instance. + * I.e., don't use it in a clustered environment. + */ +class InMemoryIdempotencyRegistry : IdempotencyRegistry { + + private val results = ConcurrentHashMap>() + + override fun register(taskInformation: TaskInformation, result: Map) { + if (TransactionSynchronizationManager.isActualTransactionActive()) { + TransactionSynchronizationManager.registerSynchronization(object : TransactionSynchronization { + + override fun afterCommit() { + results[taskInformation.taskId] = result + } + + }) + } else { + results[taskInformation.taskId] = result + } + } + + override fun getTaskResult(taskInformation: TaskInformation): Map? = results[taskInformation.taskId] + +} diff --git a/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/NoOpIdempotencyRegistry.kt b/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/NoOpIdempotencyRegistry.kt new file mode 100644 index 0000000..c642d2f --- /dev/null +++ b/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/idempotency/NoOpIdempotencyRegistry.kt @@ -0,0 +1,11 @@ +package dev.bpmcrafters.processengine.worker.idempotency + +import dev.bpmcrafters.processengineapi.task.TaskInformation + +class NoOpIdempotencyRegistry : IdempotencyRegistry { + + override fun register(taskInformation: TaskInformation, result: Map) {} + + override fun getTaskResult(taskInformation: TaskInformation): Map? = null + +} diff --git a/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/registrar/ParameterResolver.kt b/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/registrar/ParameterResolver.kt index d229995..2bcaaaa 100644 --- a/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/registrar/ParameterResolver.kt +++ b/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/registrar/ParameterResolver.kt @@ -112,7 +112,7 @@ open class ParameterResolver private constructor( ) { it.javaClass.simpleName } }" } - matchingStrategies.first().apply( + matchingStrategies.single().apply( ParameterResolutionStrategy.Wrapper( parameter, taskInformation, diff --git a/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/registrar/ProcessEngineStarterRegistrar.kt b/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/registrar/ProcessEngineStarterRegistrar.kt index d1c1605..d440533 100644 --- a/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/registrar/ProcessEngineStarterRegistrar.kt +++ b/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/registrar/ProcessEngineStarterRegistrar.kt @@ -16,6 +16,8 @@ import dev.bpmcrafters.processengineapi.task.SubscribeForTaskCmd import dev.bpmcrafters.processengineapi.task.TaskInformation import dev.bpmcrafters.processengineapi.task.TaskSubscriptionApi import dev.bpmcrafters.processengineapi.task.TaskType +import dev.bpmcrafters.processengine.worker.idempotency.IdempotencyRegistry +import dev.bpmcrafters.processengineapi.task.* import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.beans.factory.config.BeanPostProcessor import org.springframework.boot.autoconfigure.AutoConfiguration @@ -49,7 +51,9 @@ class ProcessEngineStarterRegistrar( @param:Lazy private val transactionalTemplate: TransactionTemplate, @param:Lazy - private val processEngineWorkerMetrics: ProcessEngineWorkerMetrics + private val processEngineWorkerMetrics: ProcessEngineWorkerMetrics, + @param:Lazy + private val idempotencyRegistry: IdempotencyRegistry ) : BeanPostProcessor { private val exceptionResolver = ExceptionResolver() @@ -153,25 +157,25 @@ class ProcessEngineStarterRegistrar( // depending on transactional annotations, execute either in a new transaction or direct if (isTransactional) { val completeBeforeCommit = completeBeforeCommit(completion) - val result = transactionalTemplate.execute { - val result = workerAndApiInvocation(taskInformation, payload, actionWithResult) + val txResult = transactionalTemplate.execute { + val result = workerAndApiInvocation(taskInformation, payload, actionWithResult, payloadReturnType, method) if (autoCompleteTask && completeBeforeCommit) { logger.trace { "PROCESS-ENGINE-WORKER-016: auto completing task ${taskInformation.taskId} before commit" } - completeTask(taskInformation, payloadReturnType, method, result) + completeTask(taskInformation, result) processEngineWorkerMetrics.taskCompleted(topic) } result } if (autoCompleteTask && !completeBeforeCommit) { logger.trace { "PROCESS-ENGINE-WORKER-016: auto completing task ${taskInformation.taskId} after commit" } - completeTask(taskInformation, payloadReturnType, method, result) + completeTask(taskInformation, requireNotNull(txResult)) processEngineWorkerMetrics.taskCompleted(topic) } } else { - val result = workerAndApiInvocation(taskInformation, payload, actionWithResult) + val resultPayload = workerAndApiInvocation(taskInformation, payload, actionWithResult, payloadReturnType, method) if (autoCompleteTask) { logger.trace { "PROCESS-ENGINE-WORKER-016: auto completing task ${taskInformation.taskId} (there is and was no transaction)" } - completeTask(taskInformation, payloadReturnType, method, result) + completeTask(taskInformation, resultPayload) processEngineWorkerMetrics.taskCompleted(topic) } } @@ -186,28 +190,40 @@ class ProcessEngineStarterRegistrar( /* * Encapsulates as a function to call it directly or inside of transaction. + * Includes idempotency protection and returns the result right away, if already invoked. */ private fun workerAndApiInvocation( taskInformation: TaskInformation, payload: Map, actionWithResult: TaskHandlerWithResult, - ): Any? { - logger.trace { "PROCESS-ENGINE-WORKER-015: invoking external task worker for ${taskInformation.taskId}" } - val result = actionWithResult.invoke(taskInformation, payload) - logger.trace { "PROCESS-ENGINE-WORKER-017: successfully invoked external task worker for ${taskInformation.taskId}" } + payloadReturnType: Boolean, + method: Method + ): Map { + var result = idempotencyRegistry.getTaskResult(taskInformation) + if (result == null) { + logger.trace { "PROCESS-ENGINE-WORKER-015: invoking external task worker for ${taskInformation.taskId}" } + val typedResult = actionWithResult.invoke(taskInformation, payload) + logger.trace { "PROCESS-ENGINE-WORKER-017: successfully invoked external task worker for ${taskInformation.taskId}" } + // convert + result = if (payloadReturnType) { + resultResolver.resolve(method = method, result = typedResult) + } else { + mapOf() + } + idempotencyRegistry.register(taskInformation, payload) + } return result } - private fun completeTask(taskInformation: TaskInformation, payloadReturnType: Boolean, method: Method, result: Any?) { + /* + * Completes the task. + */ + private fun completeTask(taskInformation: TaskInformation, payload: Map) { taskCompletionApi.completeTask( CompleteTaskCmd( taskId = taskInformation.taskId ) { - if (payloadReturnType) { - resultResolver.resolve(method = method, result = result) - } else { - mapOf() - } + payload } ).get() } @@ -235,7 +251,7 @@ class ProcessEngineStarterRegistrar( } } else { try { - var retry = calculateRetry(taskInformation = taskInformation, cause = cause) + val retry = calculateRetry(taskInformation = taskInformation, cause = cause) taskCompletionApi.failTask( FailTaskCmd( taskId = taskInformation.taskId, diff --git a/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/registrar/ResultResolver.kt b/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/registrar/ResultResolver.kt index 00653cb..086847b 100644 --- a/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/registrar/ResultResolver.kt +++ b/spring-boot-starter/src/main/kotlin/dev/bpmcrafters/processengine/worker/registrar/ResultResolver.kt @@ -64,7 +64,7 @@ open class ResultResolver( */ data class ResultResolutionStrategy( val resultMatcher: Predicate, // pass the entire method to the macher - val resultConverter: (value: Any?) -> Map + val resultConverter: (value: Any?) -> Map ) /** @@ -82,7 +82,7 @@ open class ResultResolver( * @param result result. * @return process payload map. */ - open fun resolve(method: Method, result: Any?): Map = strategies + open fun resolve(method: Method, result: Any?): Map = strategies .first { it.resultMatcher.test(method) } .resultConverter .invoke(result) diff --git a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/idempotency/InMemoryIdempotencyRegistryTest.kt b/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/idempotency/InMemoryIdempotencyRegistryTest.kt new file mode 100644 index 0000000..5cc6675 --- /dev/null +++ b/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/idempotency/InMemoryIdempotencyRegistryTest.kt @@ -0,0 +1,23 @@ +package dev.bpmcrafters.processengine.worker.idempotency + +import dev.bpmcrafters.processengineapi.task.TaskInformation +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.util.* + +class InMemoryIdempotencyRegistryTest { + + private val registry = InMemoryIdempotencyRegistry() + + @Test + fun `should register result`() { + val taskId = TaskInformation(taskId = UUID.randomUUID().toString(), meta = mapOf()) + assertThat(registry.getTaskResult(taskId)).isNull() + + val result = mapOf("A" to "the B") + registry.register(taskId, result) + + assertThat(registry.getTaskResult(taskId)).isSameAs(result) + } + +} diff --git a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/ExternalTaskFailJobExceptionTest.kt b/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/ExternalTaskFailJobExceptionTest.kt deleted file mode 100644 index c3f5214..0000000 --- a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/itest/camunda7/external/ExternalTaskFailJobExceptionTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -package dev.bpmcrafters.processengine.worker.itest.camunda7.external - -import dev.bpmcrafters.processengine.worker.FailJobException -import dev.bpmcrafters.processengine.worker.ProcessEngineWorker -import dev.bpmcrafters.processengine.worker.Variable -import dev.bpmcrafters.processengine.worker.itest.camunda7.external.application.AbstractExampleProcessWorker -import dev.bpmcrafters.processengine.worker.itest.camunda7.external.application.MyEntityService -import dev.bpmcrafters.processengineapi.task.TaskInformation -import org.assertj.core.api.Assertions.assertThat -import org.awaitility.Awaitility.await -import org.camunda.community.rest.client.api.ProcessInstanceApiClient -import org.junit.jupiter.api.Test -import org.springframework.context.annotation.Import -import org.springframework.test.context.TestPropertySource -import java.time.Duration -import java.util.* -import java.util.concurrent.TimeUnit.SECONDS - -@Import(ExternalTaskFailJobExceptionTest.WorkerWithFailJobException::class) -@TestPropertySource(properties = [ - "dev.bpm-crafters.process-api.worker.complete-tasks-before-commit=true" -]) -class ExternalTaskFailJobExceptionTest : AbstractTransactionalBehaviorTest() { - - class WorkerWithFailJobException( - myEntityService: MyEntityService, - processInstanceApiClient: ProcessInstanceApiClient, - ) : AbstractExampleProcessWorker( - myEntityService = myEntityService, - processInstanceApiClient = processInstanceApiClient - ) { - @ProcessEngineWorker( - topic = "example.create-entity" - ) - override fun createEntity( - task: TaskInformation, - @Variable(name = "name") name: String, - @Variable(name = "verified") verified: Boolean, - @Variable(name = "simulateRandomTechnicalError") simulateRandomTechnicalError: Boolean, - @Variable(name = "apiCallShouldFail") apiCallShouldFail: Boolean - ): Map { - if (simulateRandomTechnicalError) { - val message = "Simulating a technical error for task ${task.taskId}" - throw FailJobException(message = message, retryCount = 3, retryBackoff = Duration.ofSeconds(10)) - } - return super.createEntity(task, name, verified, false, apiCallShouldFail) - } - - @ProcessEngineWorker( - topic = "example.verify-entity", - ) - override fun verifyEntity(@Variable(name = "id") id: String) { - super.verifyEntity(id) - } -} - - - @Test - fun `fail job exception will fail job with specified retries`() { - val name = "Big or Lil' Someone ${UUID.randomUUID()}" - val pi = startProcess(name = name, verified = true, simulateRandomTechnicalError = true) - assertThat(processInstanceIsRunning(pi)).isTrue() - await().atMost(30, SECONDS).untilAsserted { - val task = getExternalTasks(pi)[0] - assertThat(task.errorMessage).isEqualTo("Simulating a technical error for task ${task.id}") - assertThat(task.retries!!).isEqualTo(3) - } - assertThat(entityExistsForName(name)).isFalse - } - -} diff --git a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/registrar/ProcessEngineIdempotencyTest.kt b/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/registrar/ProcessEngineIdempotencyTest.kt new file mode 100644 index 0000000..17cef68 --- /dev/null +++ b/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/registrar/ProcessEngineIdempotencyTest.kt @@ -0,0 +1,89 @@ +package dev.bpmcrafters.processengine.worker.registrar + +import dev.bpmcrafters.processengine.worker.configuration.ProcessEngineWorkerProperties +import dev.bpmcrafters.processengine.worker.idempotency.InMemoryIdempotencyRegistry +import dev.bpmcrafters.processengineapi.task.ServiceTaskCompletionApi +import dev.bpmcrafters.processengineapi.task.TaskInformation +import dev.bpmcrafters.processengineapi.task.TaskSubscriptionApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.springframework.transaction.support.TransactionCallback +import org.springframework.transaction.support.TransactionTemplate +import java.util.* +import java.util.concurrent.CompletableFuture + +class ProcessEngineIdempotencyTest { + + private val properties = ProcessEngineWorkerProperties() + private val taskSubscriptionApi = mock() + private val taskCompletionApi = mock { + on { completeTask(any()) } doReturn CompletableFuture.completedFuture(null) + } + private val variableConverter = mock() + private val parameterResolver = ParameterResolver.builder().build() + private val resultResolver = ResultResolver.builder().build() + private val transactionalTemplate = mock { + on { execute(any>()) } doAnswer { invocation -> + val callback = invocation.getArgument>(0) + callback.doInTransaction(mock()) + } + } + private val metrics = mock() + private val idempotencyRegistry = InMemoryIdempotencyRegistry() + + private val registrar = ProcessEngineStarterRegistrar( + properties, + taskSubscriptionApi, + taskCompletionApi, + variableConverter, + parameterResolver, + resultResolver, + transactionalTemplate, + metrics, + idempotencyRegistry + ) + + @Suppress("UNCHECKED_CAST") + @Test + fun `should not invoke annotated method again for same task`() { + // Given a worker method and a counting action + var invocationCount = 0 + val taskId = UUID.randomUUID().toString() + val taskInfo = TaskInformation(taskId = taskId, meta = mapOf()) + val payload = mapOf() + + val actionWithResult = ProcessEngineStarterRegistrar.TaskHandlerWithResult { _, _ -> + invocationCount++ + "result-$invocationCount" + } + + // Access private method workerAndApiInvocation via reflection to exercise idempotency logic + val method = ProcessEngineStarterRegistrar::class.java.getDeclaredMethod( + "workerAndApiInvocation", + TaskInformation::class.java, + Map::class.java, + ProcessEngineStarterRegistrar.TaskHandlerWithResult::class.java, + Boolean::class.javaPrimitiveType, + java.lang.reflect.Method::class.java + ) + method.isAccessible = true + + // When - first call processes and stores result + val result1 = method.invoke(registrar, taskInfo, payload, actionWithResult, false, Any::class.java.methods[0]) as Map? + + // Then - worker invoked once + assertThat(invocationCount).isEqualTo(1) + assertThat(result1).isEmpty() + + // When - second call with same TaskInformation + val result2 = method.invoke(registrar, taskInfo, payload, actionWithResult, false, Any::class.java.methods[0]) as Map? + + // Then - worker not invoked again, cached result returned + assertThat(invocationCount).isEqualTo(1) + assertThat(result2).isEmpty() + } +} diff --git a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/registrar/ProcessEngineRegistrarRetryTest.kt b/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/registrar/ProcessEngineRegistrarRetryTest.kt index 6b7af3a..2622a1b 100644 --- a/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/registrar/ProcessEngineRegistrarRetryTest.kt +++ b/spring-boot-starter/src/test/kotlin/dev/bpmcrafters/processengine/worker/registrar/ProcessEngineRegistrarRetryTest.kt @@ -18,6 +18,7 @@ internal class ProcessEngineRegistrarRetryTest { mock(), mock(), mock(), + mock(), mock() )