From eafcc9630d67a8880b196073e75e5558c46fe61e Mon Sep 17 00:00:00 2001 From: James Watkins-Harvey Date: Tue, 24 Mar 2026 10:38:40 -0400 Subject: [PATCH] Add Spring Boot 4 support --- .github/workflows/ci.yml | 326 +++++++++--------- build.gradle | 13 +- gradle/versioning.gradle | 2 - .../build.gradle | 31 +- .../MetricsScopeAutoConfiguration.java | 14 +- .../NonRootNamespaceAutoConfiguration.java | 7 +- .../OpenTracingAutoConfiguration.java | 12 +- .../RootNamespaceAutoConfiguration.java | 7 +- .../ServiceStubsAutoConfiguration.java | 7 +- .../TestServerAutoConfiguration.java | 7 +- .../autoconfigure/AutoConfigOrderingTest.java | 145 ++++++++ 11 files changed, 377 insertions(+), 194 deletions(-) create mode 100644 temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/AutoConfigOrderingTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abfd4c8e28..964d0d9883 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,181 +13,193 @@ jobs: runs-on: ubuntu-latest-16-cores timeout-minutes: 30 steps: - - name: Checkout repo - uses: actions/checkout@v5 - with: - fetch-depth: 0 - submodules: recursive - ref: ${{ github.event.pull_request.head.sha }} - - - name: Set up Java - uses: actions/setup-java@v5 - with: - java-version: "23" - distribution: "temurin" - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v5 - - - name: Run unit tests - env: - USER: unittest - USE_DOCKER_SERVICE: false - run: ./gradlew --no-daemon test -x spotlessCheck -x spotlessApply -x spotlessJava -P edgeDepsTest - - - name: Run independent resource tuner test - env: - USER: unittest - USE_DOCKER_SERVICE: false - run: ./gradlew --no-daemon temporal-sdk:testResourceIndependent -x spotlessCheck -x spotlessApply -x spotlessJava -P edgeDepsTest - - - name: Publish Test Report - uses: mikepenz/action-junit-report@v6 - if: success() || failure() # always run even if the previous step fails - with: - report_paths: '**/build/test-results/test/TEST-*.xml' + - name: Checkout repo + uses: actions/checkout@v5 + with: + fetch-depth: 0 + submodules: recursive + ref: ${{ github.event.pull_request.head.sha }} + + - name: Set up Java + uses: actions/setup-java@v5 + with: + java-version: "23" + distribution: "temurin" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Run unit tests + env: + USER: unittest + USE_DOCKER_SERVICE: false + run: ./gradlew --no-daemon test -x spotlessCheck -x spotlessApply -x spotlessJava -P edgeDepsTest + + - name: Run independent resource tuner test + env: + USER: unittest + USE_DOCKER_SERVICE: false + run: ./gradlew --no-daemon temporal-sdk:testResourceIndependent -x spotlessCheck -x spotlessApply -x spotlessJava -P edgeDepsTest + + - name: Run Spring Boot 3 compatibility tests + env: + USER: unittest + USE_DOCKER_SERVICE: false + run: ./gradlew --no-daemon :temporal-spring-boot-autoconfigure:test -x spotlessCheck -x spotlessApply -x spotlessJava -P edgeDepsTest -P springBoot3Test + + - name: Run Spring Boot 4 compatibility tests + env: + USER: unittest + USE_DOCKER_SERVICE: false + run: ./gradlew --no-daemon :temporal-spring-boot-autoconfigure:test -x spotlessCheck -x spotlessApply -x spotlessJava -P edgeDepsTest -P springBoot4Test + + - name: Publish Test Report + uses: mikepenz/action-junit-report@v6 + if: success() || failure() # always run even if the previous step fails + with: + report_paths: "**/build/test-results/test/TEST-*.xml" unit_test_jdk8: name: Unit test with docker service [JDK8] runs-on: ubuntu-latest-16-cores timeout-minutes: 30 steps: - - name: Checkout repo - uses: actions/checkout@v5 - with: - fetch-depth: 0 - submodules: recursive - ref: ${{ github.event.pull_request.head.sha }} - - - name: Set up Java - uses: actions/setup-java@v5 - with: - java-version: | - 23 - 11 - distribution: "temurin" - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v5 - - - name: Start containerized server and dependencies - env: - TEMPORAL_CLI_VERSION: 1.6.1-server-1.31.0-151.0 - run: | - wget -O temporal_cli.tar.gz https://github.com/temporalio/cli/releases/download/v${TEMPORAL_CLI_VERSION}/temporal_cli_${TEMPORAL_CLI_VERSION}_linux_amd64.tar.gz - tar -xzf temporal_cli.tar.gz - chmod +x temporal - ./temporal server start-dev \ - --headless \ - --port 7233 \ - --http-port 7243 \ - --namespace UnitTest \ - --db-filename temporal.sqlite \ - --sqlite-pragma journal_mode=WAL \ - --sqlite-pragma synchronous=OFF \ - --search-attribute CustomKeywordField=Keyword \ - --search-attribute CustomStringField=Text \ - --search-attribute CustomTextField=Text \ - --search-attribute CustomIntField=Int \ - --search-attribute CustomDatetimeField=Datetime \ - --search-attribute CustomDoubleField=Double \ - --search-attribute CustomBoolField=Bool \ - --dynamic-config-value system.enableActivityEagerExecution=true \ - --dynamic-config-value history.MaxBufferedQueryCount=10000 \ - --dynamic-config-value frontend.workerVersioningDataAPIs=true \ - --dynamic-config-value history.enableRequestIdRefLinks=true \ - --dynamic-config-value 'component.callbacks.allowedAddresses=[{"Pattern":"localhost:7243","AllowInsecure":true}]' & - sleep 10s - - - name: Run unit tests - env: - USER: unittest - TEMPORAL_SERVICE_ADDRESS: localhost:7233 - USE_DOCKER_SERVICE: true - run: ./gradlew --no-daemon test -x spotlessCheck -x spotlessApply -x spotlessJava - - - name: Run Jackson 3 converter tests - env: - USER: unittest - USE_DOCKER_SERVICE: false - run: ./gradlew --no-daemon :temporal-sdk:jackson3Tests -x spotlessCheck -x spotlessApply -x spotlessJava - - - name: Run virtual thread tests - env: - USER: unittest - TEMPORAL_SERVICE_ADDRESS: localhost:7233 - USE_DOCKER_SERVICE: true - run: ./gradlew --no-daemon :temporal-sdk:virtualThreadTests -x spotlessCheck -x spotlessApply -x spotlessJava - - - name: Publish Test Report - uses: mikepenz/action-junit-report@v6 - if: success() || failure() # always run even if the previous step fails - with: - report_paths: '**/build/test-results/test/TEST-*.xml' + - name: Checkout repo + uses: actions/checkout@v5 + with: + fetch-depth: 0 + submodules: recursive + ref: ${{ github.event.pull_request.head.sha }} + + - name: Set up Java + uses: actions/setup-java@v5 + with: + java-version: | + 23 + 11 + distribution: "temurin" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Start containerized server and dependencies + env: + TEMPORAL_CLI_VERSION: 1.6.1-server-1.31.0-151.0 + run: | + wget -O temporal_cli.tar.gz https://github.com/temporalio/cli/releases/download/v${TEMPORAL_CLI_VERSION}/temporal_cli_${TEMPORAL_CLI_VERSION}_linux_amd64.tar.gz + tar -xzf temporal_cli.tar.gz + chmod +x temporal + ./temporal server start-dev \ + --headless \ + --port 7233 \ + --http-port 7243 \ + --namespace UnitTest \ + --db-filename temporal.sqlite \ + --sqlite-pragma journal_mode=WAL \ + --sqlite-pragma synchronous=OFF \ + --search-attribute CustomKeywordField=Keyword \ + --search-attribute CustomStringField=Text \ + --search-attribute CustomTextField=Text \ + --search-attribute CustomIntField=Int \ + --search-attribute CustomDatetimeField=Datetime \ + --search-attribute CustomDoubleField=Double \ + --search-attribute CustomBoolField=Bool \ + --dynamic-config-value system.enableActivityEagerExecution=true \ + --dynamic-config-value history.MaxBufferedQueryCount=10000 \ + --dynamic-config-value frontend.workerVersioningDataAPIs=true \ + --dynamic-config-value history.enableRequestIdRefLinks=true \ + --dynamic-config-value 'component.callbacks.allowedAddresses=[{"Pattern":"localhost:7243","AllowInsecure":true}]' & + sleep 10s + + - name: Run unit tests + env: + USER: unittest + TEMPORAL_SERVICE_ADDRESS: localhost:7233 + USE_DOCKER_SERVICE: true + run: ./gradlew --no-daemon test -x spotlessCheck -x spotlessApply -x spotlessJava + + - name: Run Jackson 3 converter tests + env: + USER: unittest + USE_DOCKER_SERVICE: false + run: ./gradlew --no-daemon :temporal-sdk:jackson3Tests -x spotlessCheck -x spotlessApply -x spotlessJava + + - name: Run virtual thread tests + env: + USER: unittest + TEMPORAL_SERVICE_ADDRESS: localhost:7233 + USE_DOCKER_SERVICE: true + run: ./gradlew --no-daemon :temporal-sdk:virtualThreadTests -x spotlessCheck -x spotlessApply -x spotlessJava + + - name: Publish Test Report + uses: mikepenz/action-junit-report@v6 + if: success() || failure() # always run even if the previous step fails + with: + report_paths: "**/build/test-results/test/TEST-*.xml" unit_test_cloud: name: Unit test with cloud runs-on: ubuntu-latest timeout-minutes: 30 steps: - - name: Checkout repo - uses: actions/checkout@v5 - with: - fetch-depth: 0 - submodules: recursive - ref: ${{ github.event.pull_request.head.sha }} - - - name: Set up Java - uses: actions/setup-java@v5 - with: - java-version: "11" - distribution: "temurin" - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v5 - - - name: Run cloud test - # Only supported in non-fork runs, since secrets are not available in forks. We intentionally - # are only doing this check on the step instead of the job so we require job passing in CI - # even for those that can't run this step. - if: ${{ github.event.pull_request.head.repo.full_name == '' || github.event.pull_request.head.repo.full_name == 'temporalio/sdk-java' }} - env: - USER: unittest - TEMPORAL_CLIENT_CLOUD_NAMESPACE: sdk-ci.a2dd6 - TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} - TEMPORAL_CLIENT_CLOUD_API_VERSION: 2024-05-13-00 - run: ./gradlew --no-daemon :temporal-sdk:test --tests '*CloudOperationsClientTest' - - - name: Publish Test Report - uses: mikepenz/action-junit-report@v6 - if: success() || failure() # always run even if the previous step fails - with: - report_paths: '**/build/test-results/test/TEST-*.xml' + - name: Checkout repo + uses: actions/checkout@v5 + with: + fetch-depth: 0 + submodules: recursive + ref: ${{ github.event.pull_request.head.sha }} + + - name: Set up Java + uses: actions/setup-java@v5 + with: + java-version: "11" + distribution: "temurin" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Run cloud test + # Only supported in non-fork runs, since secrets are not available in forks. We intentionally + # are only doing this check on the step instead of the job so we require job passing in CI + # even for those that can't run this step. + if: ${{ github.event.pull_request.head.repo.full_name == '' || github.event.pull_request.head.repo.full_name == 'temporalio/sdk-java' }} + env: + USER: unittest + TEMPORAL_CLIENT_CLOUD_NAMESPACE: sdk-ci.a2dd6 + TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} + TEMPORAL_CLIENT_CLOUD_API_VERSION: 2024-05-13-00 + run: ./gradlew --no-daemon :temporal-sdk:test --tests '*CloudOperationsClientTest' + + - name: Publish Test Report + uses: mikepenz/action-junit-report@v6 + if: success() || failure() # always run even if the previous step fails + with: + report_paths: "**/build/test-results/test/TEST-*.xml" code_format: name: Code format runs-on: ubuntu-latest timeout-minutes: 20 steps: - - name: Checkout repo - uses: actions/checkout@v5 - with: - fetch-depth: 0 - submodules: recursive - ref: ${{ github.event.pull_request.head.sha }} - - - name: Set up Java - uses: actions/setup-java@v5 - with: - java-version: "11" - distribution: "temurin" - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v5 - - - name: Run copyright and code format checks - run: ./gradlew --no-daemon spotlessCheck - + - name: Checkout repo + uses: actions/checkout@v5 + with: + fetch-depth: 0 + submodules: recursive + ref: ${{ github.event.pull_request.head.sha }} + + - name: Set up Java + uses: actions/setup-java@v5 + with: + java-version: "11" + distribution: "temurin" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Run copyright and code format checks + run: ./gradlew --no-daemon spotlessCheck + build_native_images: name: Build native test server uses: ./.github/workflows/build-native-image.yml diff --git a/build.gradle b/build.gradle index 393dcc4524..9d9df68fa0 100644 --- a/build.gradle +++ b/build.gradle @@ -55,9 +55,16 @@ ext { cronUtilsVersion = '9.2.1' // for test server only - // Spring Boot 3 requires Java 17, java-sdk builds against 2.x version because we support Java 8. - // We do test compatibility with Spring Boot 3 in integration tests. - springBootVersion = project.hasProperty("edgeDepsTest") ? '3.1.12' : '2.7.18'// [2.4.0,) + // This is the version of Spring Boot we use to _build the SDK_. This has to be in the + // range [2.7.0, 3.2.0) at this time, because `@ConstructorBinding` changed path in SB 3.2.0. + springBootVersion = '2.7.18' // [2.7.0, 3.2.0) + + // These are the version of Spring Boot we use to test the Spring Boot integration in various + // scenarios. SB 3 and 4 require Java 17, and can therefore only be tested with 'edgeDepsTest' + // enabled. SB 2.x runs on Java 8, and can be tested with or without 'edgeDepsTest' enabled. + springBoot2Version = '2.7.18' // [2.7.0,) + springBoot3Version = '3.5.12' // [3.1.0,) + springBoot4Version = '4.0.2' // [4.0.0,) // test scoped // we don't upgrade to 1.3 and 1.4 because they require slf4j 2.x diff --git a/gradle/versioning.gradle b/gradle/versioning.gradle index dd206238ee..7cdddffae3 100644 --- a/gradle/versioning.gradle +++ b/gradle/versioning.gradle @@ -55,8 +55,6 @@ group = 'io.temporal' version = getVersionName() subprojects { - apply plugin: "com.palantir.git-version" - group = 'io.temporal' version = getVersionName() } \ No newline at end of file diff --git a/temporal-spring-boot-autoconfigure/build.gradle b/temporal-spring-boot-autoconfigure/build.gradle index 4f678eb4c6..651d8c7c51 100644 --- a/temporal-spring-boot-autoconfigure/build.gradle +++ b/temporal-spring-boot-autoconfigure/build.gradle @@ -5,6 +5,16 @@ ext { otShimVersion = "${otelVersion}-alpha" } +if (project.hasProperty("springBoot3Test") || project.hasProperty("springBoot4Test")) { + if (!project.hasProperty("edgeDepsTest")) { + // Spring Boot 3 and 4 require Java 17. Non-edge tests run against Java 8, so wouldn't work. + throw new IllegalArgumentException("springBoot3Test or springBoot4Test requires edgeDepsTest") + } + if (project.hasProperty("springBoot3Test") && project.hasProperty("springBoot4Test")) { + throw new IllegalArgumentException("Can't specify both springBoot3Test and springBoot4Test") + } +} + dependencies { api(platform("org.springframework.boot:spring-boot-dependencies:$springBootVersion")) api(platform("io.opentelemetry:opentelemetry-bom:$otelVersion")) @@ -31,14 +41,25 @@ dependencies { testImplementation project(':temporal-testing') testImplementation "org.mockito:mockito-core:${mockitoVersion}" - testImplementation group: 'ch.qos.logback', name: 'logback-classic', version: "${logbackVersion}" testImplementation "org.springframework.boot:spring-boot-starter-test" + // For auto-configuration ordering tests (metrics) + testImplementation "org.springframework.boot:spring-boot-starter-actuator" + // For auto-configuration ordering tests (OpenTelemetry) + testImplementation "io.opentelemetry:opentelemetry-sdk" - testImplementation('org.slf4j:slf4j-api') { - version { - strictly "${slf4jVersion}" - } + // Override the SB 2.7 BOM for test classpath so tests run against SB 3 or 4. + // Main classes are still compiled against SB 2.7. We're simply + // validating that the published JAR works at runtime with SB 3 or 4. + if (project.hasProperty("springBoot4Test")) { + testImplementation(platform("org.springframework.boot:spring-boot-dependencies:$springBoot4Version")) + testRuntimeOnly "org.junit.platform:junit-platform-launcher" + // SB4 modularized auto-configs into separate modules that + // spring-boot-starter-actuator no longer transitively includes. + testImplementation "org.springframework.boot:spring-boot-opentelemetry" + } else if (project.hasProperty("springBoot3Test")) { + testImplementation(platform("org.springframework.boot:spring-boot-dependencies:$springBoot3Version")) + testRuntimeOnly "org.junit.platform:junit-platform-launcher" } } diff --git a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/MetricsScopeAutoConfiguration.java b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/MetricsScopeAutoConfiguration.java index a54e3e77c3..b39d2a35d9 100644 --- a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/MetricsScopeAutoConfiguration.java +++ b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/MetricsScopeAutoConfiguration.java @@ -7,15 +7,17 @@ import io.temporal.common.reporter.MicrometerClientStatsReporter; import javax.annotation.Nullable; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -@Configuration -@AutoConfigureAfter( - name = - "org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration") +@AutoConfiguration( + afterName = { + // SB 2.7 / 3.x + "org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration", + // SB 4.0 + "org.springframework.boot.micrometer.metrics.autoconfigure.CompositeMeterRegistryAutoConfiguration" + }) @ConditionalOnBean(MeterRegistry.class) public class MetricsScopeAutoConfiguration { @Bean(name = "temporalMetricsScope", destroyMethod = "close") diff --git a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/NonRootNamespaceAutoConfiguration.java b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/NonRootNamespaceAutoConfiguration.java index b8d332cbe4..7a70e2ebaf 100644 --- a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/NonRootNamespaceAutoConfiguration.java +++ b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/NonRootNamespaceAutoConfiguration.java @@ -12,7 +12,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.BeansException; -import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -21,15 +21,14 @@ import org.springframework.context.ApplicationListener; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; -import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; import org.springframework.context.event.ApplicationContextEvent; import org.springframework.context.event.ContextClosedEvent; import org.springframework.context.event.ContextRefreshedEvent; -@Configuration +@AutoConfiguration( + after = {RootNamespaceAutoConfiguration.class, ServiceStubsAutoConfiguration.class}) @EnableConfigurationProperties(TemporalProperties.class) -@AutoConfigureAfter({RootNamespaceAutoConfiguration.class, ServiceStubsAutoConfiguration.class}) @ConditionalOnBean(ServiceStubsAutoConfiguration.class) @Conditional(NamespacesPresentCondition.class) @ConditionalOnExpression( diff --git a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/OpenTracingAutoConfiguration.java b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/OpenTracingAutoConfiguration.java index 24fd47b909..39eac25d74 100644 --- a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/OpenTracingAutoConfiguration.java +++ b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/OpenTracingAutoConfiguration.java @@ -4,17 +4,21 @@ import io.opentelemetry.opentracingshim.OpenTracingShim; import io.opentracing.Tracer; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -@Configuration +@AutoConfiguration( + afterName = { + "org.springframework.cloud.sleuth.autoconfig.otel.OtelAutoConfiguration", + "org.springframework.boot.actuate.autoconfigure.tracing.OpenTelemetryAutoConfiguration", + "org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryAutoConfiguration", + "org.springframework.boot.opentelemetry.autoconfigure.OpenTelemetrySdkAutoConfiguration" + }) @ConditionalOnClass(io.opentelemetry.api.OpenTelemetry.class) @ConditionalOnBean(io.opentelemetry.api.OpenTelemetry.class) -@AutoConfigureAfter(name = "org.springframework.cloud.sleuth.autoconfig.otel.OtelAutoConfiguration") public class OpenTracingAutoConfiguration { @ConditionalOnMissingBean(Tracer.class) @Bean(name = "temporalOtTracer") diff --git a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/RootNamespaceAutoConfiguration.java b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/RootNamespaceAutoConfiguration.java index 70a1cb5c65..9ff3908bb5 100644 --- a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/RootNamespaceAutoConfiguration.java +++ b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/RootNamespaceAutoConfiguration.java @@ -35,7 +35,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; -import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -44,13 +44,12 @@ import org.springframework.context.ApplicationListener; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; -import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.DependsOn; import org.springframework.context.annotation.Primary; -@Configuration +@AutoConfiguration( + after = {ServiceStubsAutoConfiguration.class, OpenTracingAutoConfiguration.class}) @EnableConfigurationProperties(TemporalProperties.class) -@AutoConfigureAfter({ServiceStubsAutoConfiguration.class, OpenTracingAutoConfiguration.class}) @ConditionalOnBean(ServiceStubsAutoConfiguration.class) @ConditionalOnExpression( "${spring.temporal.test-server.enabled:false} || '${spring.temporal.connection.target:}'.length() > 0") diff --git a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/ServiceStubsAutoConfiguration.java b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/ServiceStubsAutoConfiguration.java index 022cd7f9c0..5aa907810e 100644 --- a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/ServiceStubsAutoConfiguration.java +++ b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/ServiceStubsAutoConfiguration.java @@ -15,16 +15,13 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; -import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -@Configuration +@AutoConfiguration(after = {MetricsScopeAutoConfiguration.class, TestServerAutoConfiguration.class}) @EnableConfigurationProperties(TemporalProperties.class) -@AutoConfigureAfter( - value = {MetricsScopeAutoConfiguration.class, TestServerAutoConfiguration.class}) @ConditionalOnExpression( "${spring.temporal.test-server.enabled:false} || '${spring.temporal.connection.target:}'.length() > 0") public class ServiceStubsAutoConfiguration { diff --git a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/TestServerAutoConfiguration.java b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/TestServerAutoConfiguration.java index ae4a42d543..97f712d483 100644 --- a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/TestServerAutoConfiguration.java +++ b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/TestServerAutoConfiguration.java @@ -28,22 +28,21 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; -import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; /** Provides a client based on `spring.temporal.testServer` section */ -@Configuration +@AutoConfiguration( + after = {OpenTracingAutoConfiguration.class, MetricsScopeAutoConfiguration.class}) @EnableConfigurationProperties(TemporalProperties.class) @ConditionalOnClass(name = "io.temporal.testing.TestWorkflowEnvironment") @ConditionalOnProperty( prefix = "spring.temporal", name = "test-server.enabled", havingValue = "true") -@AutoConfigureAfter({OpenTracingAutoConfiguration.class, MetricsScopeAutoConfiguration.class}) public class TestServerAutoConfiguration { private static final Logger log = LoggerFactory.getLogger(TestServerAutoConfiguration.class); diff --git a/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/AutoConfigOrderingTest.java b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/AutoConfigOrderingTest.java new file mode 100644 index 0000000000..38dd85c828 --- /dev/null +++ b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/AutoConfigOrderingTest.java @@ -0,0 +1,145 @@ +package io.temporal.spring.boot.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.uber.m3.tally.Scope; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.opentracing.Span; +import io.opentracing.Tracer; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +/** + * Tests that {@link MetricsScopeAutoConfiguration} and {@link OpenTracingAutoConfiguration} + * correctly order themselves after their dependency auto-configurations via + * {@code @AutoConfiguration(afterName=...)}. + * + *

Without correct ordering, {@code @ConditionalOnBean} evaluates before the dependency beans + * exist, silently skipping our entire auto-configuration. This happens because our FQCNs ({@code + * io.temporal...}) sort alphabetically before Spring's ({@code org.springframework...}), which is + * the default processing order when no ordering constraints are specified. + */ +class AutoConfigOrderingTest { + + /** + * Try to load a class by name, returning the first match found. Returns null if none of the class + * names exist on the classpath. + */ + private static Class findClass(String... classNames) { + for (String name : classNames) { + try { + return Class.forName(name); + } catch (ClassNotFoundException ignored) { + } + } + return null; + } + + /** + * Verifies that {@link MetricsScopeAutoConfiguration} runs after {@code + * CompositeMeterRegistryAutoConfiguration} and finds the {@code MeterRegistry} bean it creates. + * The {@code CompositeMeterRegistryAutoConfiguration} class moved packages in Spring Boot 4 — + * both old and new names must be in {@code @AutoConfiguration(afterName=...)}. + */ + @Test + void metricsScopeCreatedWithAutoConfigOrdering() { + Class compositeClass = + findClass( + // SB 2.7 / 3.x + "org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration", + // SB 4.0 + "org.springframework.boot.micrometer.metrics.autoconfigure.CompositeMeterRegistryAutoConfiguration"); + + assumeTrue(compositeClass != null, "CompositeMeterRegistryAutoConfiguration not on classpath"); + + // MetricsAutoConfiguration provides the Clock bean that CompositeMeterRegistryAutoConfiguration + // inner configs need to activate. + List> autoConfigs = new ArrayList<>(); + autoConfigs.add(MetricsScopeAutoConfiguration.class); + autoConfigs.add(compositeClass); + Class metricsAutoConfig = + findClass( + // SB 2.7 / 3.x + "org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration", + // SB 4.0 + "org.springframework.boot.micrometer.metrics.autoconfigure.MetricsAutoConfiguration"); + if (metricsAutoConfig != null) { + autoConfigs.add(metricsAutoConfig); + } + + // A SimpleMeterRegistry gives the CompositeMeterRegistry a concrete child + // to delegate to; without it, increments are silently dropped. + SimpleMeterRegistry testRegistry = new SimpleMeterRegistry(); + + new ApplicationContextRunner() + .withBean(MeterRegistry.class, () -> testRegistry) + .withConfiguration(AutoConfigurations.of(autoConfigs.toArray(new Class[0]))) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasBean("temporalMetricsScope"); + + // Verify metrics flow end-to-end: tally Scope → MicrometerClientStatsReporter + // → MeterRegistry. Record a counter, flush via close(), and check Micrometer. + Scope scope = context.getBean("temporalMetricsScope", Scope.class); + scope.counter("test_counter").inc(1); + scope.close(); + + assertThat(testRegistry.find("test_counter").counter()) + .as("Counter recorded via tally Scope should appear in MeterRegistry") + .isNotNull(); + assertThat(testRegistry.find("test_counter").counter().count()).isEqualTo(1.0); + }); + } + + /** + * Verifies that {@link OpenTracingAutoConfiguration} runs after the OpenTelemetry + * auto-configuration and finds the {@code OpenTelemetry} bean it creates. The producing class + * moved across Spring Boot versions: + * + *

+ * + *

On SB 2.7 (no native OTel auto-config) this test is skipped. + */ + @Test + void openTracingTracerCreatedWithAutoConfigOrdering() { + Class otelAutoConfig = + findClass( + // SB 3.2+ (generic OTel auto-config, uses ObjectProvider for optional deps) + "org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryAutoConfiguration", + // SB 4.0 (relocated to separate module) + "org.springframework.boot.opentelemetry.autoconfigure.OpenTelemetrySdkAutoConfiguration"); + + assumeTrue(otelAutoConfig != null, "OpenTelemetry auto-configuration not on classpath"); + + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(OpenTracingAutoConfiguration.class, otelAutoConfig)) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasBean("temporalOtTracer"); + + // Verify the Tracer is backed by OpenTelemetry (not a noop): + // build a span and confirm it produces a valid trace ID. + Tracer tracer = context.getBean("temporalOtTracer", Tracer.class); + Span span = tracer.buildSpan("test-operation").start(); + try { + assertThat(span.context().toTraceId()) + .as("Tracer should produce valid trace IDs (backed by OpenTelemetry)") + .isNotBlank() + .isNotEqualTo("00000000000000000000000000000000"); + } finally { + span.finish(); + } + }); + } +}