From d4720f55938e906f60f68bbc311458f808303b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20Kobyli=C5=84ski?= Date: Wed, 13 May 2026 00:14:11 +0200 Subject: [PATCH 1/7] fix: Liquibase coexistence and opt-out + fix NoClassDefFoundError on Spring Boot 3.5.x without liquibase-core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #38. Fixes a related undocumented NoClassDefFoundError that prevented startup on Spring Boot 3.5.x consumers without `liquibase-core` on the classpath (e.g. Flyway-only users). ## What changed ### Production code - Extract `okapi*Liquibase` factory methods into dedicated `PostgresLiquibaseConfiguration` / `MysqlLiquibaseConfiguration` inner classes with class-level `@ConditionalOnClass(SpringLiquibase)`. Spring evaluates the class-level condition via string-name lookup before any method introspection, so `Class.getDeclaredMethods()` is never called on a class whose `SpringLiquibase` return type would trigger `NoClassDefFoundError`. - Add `@AutoConfigureAfter(name = [3.x path, 4.x path])` on `OutboxAutoConfiguration` so Spring Boot's `LiquibaseAutoConfiguration` registers its own `liquibase` bean first. Without this, okapi's `okapiPostgresLiquibase` (typed `SpringLiquibase`) would shadow Spring Boot's type-based `@ConditionalOnMissingBean(SpringLiquibase)` guard, silently suppressing the host application's own changelog (issue #38 Mode 1). - Keep `@ConditionalOnMissingBean(name = "okapiPostgresLiquibase")` (name-based, not type-based) so a user-supplied `@Bean SpringLiquibase liquibase()` coexists with okapi's bean instead of being skipped, while `@Bean("okapiPostgresLiquibase")` still cleanly overrides okapi's default. - New `okapi.liquibase.enabled` property (default `true`) for explicit opt-out when the host app includes okapi's changelog from its own master changelog. - New `LiquibaseDisabledNotice` inner config logs a WARN-level breadcrumb on `enabled=false`, linking a future "relation okapi_outbox does not exist" runtime error back to the startup decision. ### Test coverage Adopts the testing pattern PR #41 introduced for the analogous Micrometer ordering bug (uses `spring-boot-starter-actuator` + reflection meta-test that fails when the declared class names don't resolve). - `LiquibaseE2ETest`: new `coexistence with the host application's own SpringLiquibase via Spring Boot autoconfig` test on both Postgres and MySQL — pulls in Spring Boot's real `LiquibaseAutoConfiguration` via `resolveSpringBootClass(...)`, sets `spring.liquibase.change-log`, and verifies both beans register and both changelogs run. Plus a `multi-datasource` test exercising `okapi.datasource-qualifier`. - `LiquibaseAutoConfigurationTest`: structural assertions pin the architectural decisions (no SpringLiquibase return type on unguarded classes; class-level `@ConditionalOnClass(SpringLiquibase)` on both new config classes; name-based `@ConditionalOnMissingBean`) plus a reflection meta-test for the `@AutoConfigureAfter` contract. Captures the `LiquibaseDisabledNotice` WARN via logback's `ListAppender` so removing the log body breaks the test, not just removing the class. User name-based override is exercised end-to-end. - Slice tests (`DataSourceQualifierAutoConfigurationTest`, `OutboxProcessorAutoConfigurationTest`, `OutboxPurgerAutoConfigurationTest`): add `okapi.liquibase.enabled=false` so they don't run Liquibase against the fake `SimpleDriverDataSource`. - Empirically verified on Spring Boot 3.5.12 (CI matrix) and 4.0.6 (default). ## Test plan - [x] `./gradlew :okapi-spring-boot:test` on Spring Boot 4.0.6 (default) - [x] `./gradlew :okapi-spring-boot:test -PspringBootVersion=3.5.12 -PspringVersion=6.2.17` - [x] `./gradlew :okapi-spring-boot:ktlintCheck` - [x] Sanity-checked four likely regressions: removing the 3.x `@AutoConfigureAfter` path, removing the annotation entirely, switching to type-based `@ConditionalOnMissingBean`, and moving the Liquibase bean back into `PostgresStoreConfiguration` — each one fails a distinct test in the new suite. --- gradle/libs.versions.toml | 2 + okapi-spring-boot/build.gradle.kts | 3 + .../okapi/springboot/OkapiProperties.kt | 16 +- .../springboot/OutboxAutoConfiguration.kt | 122 +++++-- .../spring-configuration-metadata.json | 18 ++ ...ataSourceQualifierAutoConfigurationTest.kt | 1 + .../LiquibaseAutoConfigurationTest.kt | 305 +++++++++++++++++- .../okapi/springboot/LiquibaseE2ETest.kt | 189 +++++++++++ .../OutboxProcessorAutoConfigurationTest.kt | 6 + .../OutboxPurgerAutoConfigurationTest.kt | 1 + .../okapi/springboot/test-app-changelog.xml | 20 ++ 11 files changed, 637 insertions(+), 46 deletions(-) create mode 100644 okapi-spring-boot/src/test/resources/com/softwaremill/okapi/springboot/test-app-changelog.xml diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ed98b22..a278909 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ springBoot = "4.0.6" vanniktechPublish = "0.36.0" wiremock = "3.13.2" slf4j = "2.0.17" +logback = "1.5.20" assertj = "3.27.7" h2 = "2.4.240" micrometer = "1.16.5" @@ -53,6 +54,7 @@ micrometerTest = { module = "io.micrometer:micrometer-test", version.ref = "micr wiremock = { module = "org.wiremock:wiremock", version.ref = "wiremock" } slf4jApi = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } slf4jSimple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } +logbackClassic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } h2 = { module = "com.h2database:h2", version.ref = "h2" } jmhCore = { module = "org.openjdk.jmh:jmh-core", version.ref = "jmh" } jmhGeneratorAnnprocess = { module = "org.openjdk.jmh:jmh-generator-annprocess", version.ref = "jmh" } diff --git a/okapi-spring-boot/build.gradle.kts b/okapi-spring-boot/build.gradle.kts index 96ad04c..9033019 100644 --- a/okapi-spring-boot/build.gradle.kts +++ b/okapi-spring-boot/build.gradle.kts @@ -45,6 +45,9 @@ dependencies { testImplementation(libs.micrometerCore) // Brings in the metrics auto-config jar so @AutoConfigureAfter targets are resolvable in tests. testImplementation(libs.springBootStarterActuator) + // Logback's ListAppender is used to capture and assert WARN-level log output (e.g. the + // LiquibaseDisabledNotice breadcrumb) — slf4j-simple does not provide an introspectable appender. + testImplementation(libs.logbackClassic) } // CI version override: ./gradlew :okapi-spring-boot:test -PspringBootVersion=4.0.4 -PspringVersion=7.0.6 diff --git a/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OkapiProperties.kt b/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OkapiProperties.kt index 099130e..d67d66c 100644 --- a/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OkapiProperties.kt +++ b/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OkapiProperties.kt @@ -16,14 +16,20 @@ data class OkapiProperties( } /** - * Liquibase tracking-table names used by okapi's bundled migrations. + * Liquibase auto-configuration settings for okapi's bundled migrations. * - * Defaults to dedicated tables (`okapi_databasechangelog` / `okapi_databasechangeloglock`) - * so okapi's migration history is isolated from the host application's. Override via - * `okapi.liquibase.changelog-table` / `okapi.liquibase.changelog-lock-table` to point at - * existing tables (e.g. `databasechangelog`) when migrating from a setup that shared them. + * - [enabled]: when `false`, okapi's `SpringLiquibase` bean is not registered; the application + * is responsible for applying okapi's changelog (e.g. via its own ``). + * Default: `true`. Disable when okapi's bean would shadow the application's own + * `SpringLiquibase` (Spring Boot's `LiquibaseAutoConfiguration` uses + * `@ConditionalOnMissingBean(SpringLiquibase::class)` by type). + * - [changelogTable] / [changelogLockTable]: tracking-table names. Defaults to dedicated + * tables (`okapi_databasechangelog` / `okapi_databasechangeloglock`) so okapi's migration + * history is isolated from the host application's. Override to point at existing tables + * (e.g. `databasechangelog`) when migrating from a setup that shared them. */ data class Liquibase( + val enabled: Boolean = true, val changelogTable: String = "okapi_databasechangelog", val changelogLockTable: String = "okapi_databasechangeloglock", ) { diff --git a/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OutboxAutoConfiguration.kt b/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OutboxAutoConfiguration.kt index efd3e16..f318b9b 100644 --- a/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OutboxAutoConfiguration.kt +++ b/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OutboxAutoConfiguration.kt @@ -13,9 +13,10 @@ import com.softwaremill.okapi.core.RetryPolicy import com.softwaremill.okapi.mysql.MysqlOutboxStore import com.softwaremill.okapi.postgres.PostgresOutboxStore import liquibase.integration.spring.SpringLiquibase +import org.slf4j.LoggerFactory import org.springframework.beans.factory.ObjectProvider import org.springframework.boot.autoconfigure.AutoConfiguration -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.boot.autoconfigure.AutoConfigureAfter import org.springframework.boot.autoconfigure.condition.ConditionalOnClass import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty @@ -27,6 +28,8 @@ import org.springframework.transaction.support.TransactionTemplate import java.time.Clock import javax.sql.DataSource +private val LIQUIBASE_DISABLED_LOGGER = LoggerFactory.getLogger("com.softwaremill.okapi.springboot.OutboxAutoConfiguration") + /** * Spring Boot autoconfiguration for the outbox processing pipeline. * @@ -50,8 +53,25 @@ import javax.sql.DataSource * Multi-datasource support: * - Set `okapi.datasource-qualifier` to the bean name of the [DataSource] that holds the outbox table. * When not set, the primary (or single) DataSource is used. + * + * Liquibase coexistence: + * - Auto-config is ordered after Spring Boot's `LiquibaseAutoConfiguration` (3.x and 4.x package + * paths covered) so that the application's own auto-configured `liquibase` bean registers first. + * Spring Boot's `@ConditionalOnMissingBean(SpringLiquibase::class)` then sees its own bean and + * stops looking, leaving okapi free to add its uniquely-named `okapiPostgresLiquibase` / + * `okapiMysqlLiquibase` next to it. Both run on startup with their own changelogs. + * - Set `okapi.liquibase.enabled=false` to opt out entirely (e.g. when including okapi's + * changelog from the application's master changelog). */ @AutoConfiguration +@AutoConfigureAfter( + name = [ + // Spring Boot 3.x — package path + "org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration", + // Spring Boot 4.x — package was reorganized into a separate module + "org.springframework.boot.liquibase.autoconfigure.LiquibaseAutoConfiguration", + ], +) @EnableConfigurationProperties(OkapiProperties::class, OutboxPurgerProperties::class, OutboxProcessorProperties::class) class OutboxAutoConfiguration( private val dataSources: Map, @@ -145,8 +165,7 @@ class OutboxAutoConfiguration( } /** - * Auto-configures [PostgresOutboxStore] and Liquibase schema migration - * when `outbox-postgres` is on the classpath. + * Auto-configures [PostgresOutboxStore] when `okapi-postgres` is on the classpath. * Skipped if the application provides its own [OutboxStore] bean. */ @Configuration(proxyBeanMethods = false) @@ -162,24 +181,6 @@ class OutboxAutoConfiguration( connectionProvider = SpringConnectionProvider(resolveDataSource(dataSources, primaryDataSource, okapiProperties)), clock = clock.getIfAvailable { Clock.systemUTC() }, ) - - /** - * Runs okapi's bundled PostgreSQL changelog (creates `okapi_outbox` and its indexes) - * on application startup. Tracks its history in dedicated tables to keep okapi's - * migrations isolated from the host application's. Override the tracking-table names - * via `okapi.liquibase.changelog-table` / `okapi.liquibase.changelog-lock-table` - * (see [OkapiProperties.Liquibase]). - */ - @Bean("okapiPostgresLiquibase") - @ConditionalOnClass(SpringLiquibase::class) - @ConditionalOnBean(value = [DataSource::class, PostgresOutboxStore::class]) - @ConditionalOnMissingBean(name = ["okapiPostgresLiquibase"]) - fun okapiPostgresLiquibase(): SpringLiquibase = SpringLiquibase().apply { - dataSource = resolveDataSource(dataSources, primaryDataSource, okapiProperties) - changeLog = "classpath:com/softwaremill/okapi/db/changelog.xml" - databaseChangeLogTable = okapiProperties.liquibase.changelogTable - databaseChangeLogLockTable = okapiProperties.liquibase.changelogLockTable - } } /** When both Postgres and MySQL modules are on the classpath, [PostgresStoreConfiguration] takes priority. */ @@ -196,17 +197,78 @@ class OutboxAutoConfiguration( connectionProvider = SpringConnectionProvider(resolveDataSource(dataSources, primaryDataSource, okapiProperties)), clock = clock.getIfAvailable { Clock.systemUTC() }, ) + } + + /** + * Auto-configures okapi's PostgreSQL Liquibase migration. + * + * **Why a separate class with class-level [ConditionalOnClass]:** placing + * `@ConditionalOnClass(SpringLiquibase::class)` on the class level (rather than on the + * `@Bean` method) ensures Spring evaluates the condition via string-name classpath lookup + * **before** any introspection of the class's methods. Without this, JVM + * `Class.getDeclaredMethods()` would resolve [SpringLiquibase] in the method return type + * during configuration parsing — which fails with `NoClassDefFoundError` whenever + * `liquibase-core` is absent from the consumer's classpath (it is `compileOnly` in + * okapi-spring-boot, e.g. Flyway-only consumers do not pull it in). + * + * **Coexistence with the host application's own [SpringLiquibase]:** + * `@ConditionalOnMissingBean(SpringLiquibase::class)` is intentionally **not** used — + * okapi's bean is named `okapiPostgresLiquibase` and runs its own bundled changelog with + * dedicated tracking tables (`okapi_databasechangelog`/`okapi_databasechangeloglock` by + * default). It coexists alongside Spring Boot's auto-configured `liquibase` bean. Disable + * okapi's bean via `okapi.liquibase.enabled=false` if the host already includes okapi's + * changelog from its own master. + */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(SpringLiquibase::class, PostgresOutboxStore::class) + @ConditionalOnProperty(prefix = "okapi.liquibase", name = ["enabled"], havingValue = "true", matchIfMissing = true) + class PostgresLiquibaseConfiguration( + private val dataSources: Map, + private val primaryDataSource: DataSource, + private val okapiProperties: OkapiProperties, + ) { + @Bean("okapiPostgresLiquibase") + @ConditionalOnMissingBean(name = ["okapiPostgresLiquibase"]) + fun okapiPostgresLiquibase(): SpringLiquibase = SpringLiquibase().apply { + dataSource = resolveDataSource(dataSources, primaryDataSource, okapiProperties) + changeLog = "classpath:com/softwaremill/okapi/db/changelog.xml" + databaseChangeLogTable = okapiProperties.liquibase.changelogTable + databaseChangeLogLockTable = okapiProperties.liquibase.changelogLockTable + } + } - /** - * Runs okapi's bundled MySQL changelog (creates `okapi_outbox` and its indexes) - * on application startup. Tracks its history in dedicated tables to keep okapi's - * migrations isolated from the host application's. Override the tracking-table names - * via `okapi.liquibase.changelog-table` / `okapi.liquibase.changelog-lock-table` - * (see [OkapiProperties.Liquibase]). - */ + /** + * Logs a single startup warning when okapi's Liquibase auto-config is explicitly opted out + * (`okapi.liquibase.enabled=false`). Without this, a user who flipped the flag months ago + * has no breadcrumb when they later see "relation okapi_outbox does not exist" at first + * publish — the link to the opt-out is in the startup log, not the error. + */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(prefix = "okapi.liquibase", name = ["enabled"], havingValue = "false") + class LiquibaseDisabledNotice { + init { + LIQUIBASE_DISABLED_LOGGER.warn( + "okapi.liquibase.enabled=false — okapi will NOT create or migrate the okapi_outbox schema. " + + "Ensure your application's migration tool applies " + + "classpath:com/softwaremill/okapi/db/changelog.xml " + + "(or classpath:com/softwaremill/okapi/db/mysql/changelog.xml for MySQL).", + ) + } + } + + /** + * Auto-configures okapi's MySQL Liquibase migration. See [PostgresLiquibaseConfiguration] + * for rationale on the class-level conditional placement. + */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(SpringLiquibase::class, MysqlOutboxStore::class) + @ConditionalOnProperty(prefix = "okapi.liquibase", name = ["enabled"], havingValue = "true", matchIfMissing = true) + class MysqlLiquibaseConfiguration( + private val dataSources: Map, + private val primaryDataSource: DataSource, + private val okapiProperties: OkapiProperties, + ) { @Bean("okapiMysqlLiquibase") - @ConditionalOnClass(SpringLiquibase::class) - @ConditionalOnBean(value = [DataSource::class, MysqlOutboxStore::class]) @ConditionalOnMissingBean(name = ["okapiMysqlLiquibase"]) fun okapiMysqlLiquibase(): SpringLiquibase = SpringLiquibase().apply { dataSource = resolveDataSource(dataSources, primaryDataSource, okapiProperties) diff --git a/okapi-spring-boot/src/main/resources/META-INF/spring-configuration-metadata.json b/okapi-spring-boot/src/main/resources/META-INF/spring-configuration-metadata.json index 0e18021..1ac1771 100644 --- a/okapi-spring-boot/src/main/resources/META-INF/spring-configuration-metadata.json +++ b/okapi-spring-boot/src/main/resources/META-INF/spring-configuration-metadata.json @@ -81,6 +81,24 @@ "type": "java.time.Duration", "defaultValue": "15s", "description": "How often gauge metrics (okapi.entries.count, okapi.entries.lag.seconds) are refreshed from the outbox store. Each refresh runs one transaction with two queries. Requires okapi-micrometer on the classpath." + }, + { + "name": "okapi.liquibase.enabled", + "type": "java.lang.Boolean", + "defaultValue": true, + "description": "Whether okapi's bundled Liquibase migration runs on startup. Set to false when the host application includes okapi's changelog from its own master changelog (in which case the application is responsible for applying classpath:com/softwaremill/okapi/db/changelog.xml or the MySQL equivalent)." + }, + { + "name": "okapi.liquibase.changelog-table", + "type": "java.lang.String", + "defaultValue": "okapi_databasechangelog", + "description": "Liquibase tracking-table name for okapi's bundled migrations. Defaults to a dedicated table to keep okapi's migration history isolated from the host application's. Override to point at an existing shared table (e.g. databasechangelog)." + }, + { + "name": "okapi.liquibase.changelog-lock-table", + "type": "java.lang.String", + "defaultValue": "okapi_databasechangeloglock", + "description": "Liquibase tracking lock-table name for okapi's bundled migrations. Override only when migrating from a setup that shared databasechangeloglock." } ] } diff --git a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/DataSourceQualifierAutoConfigurationTest.kt b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/DataSourceQualifierAutoConfigurationTest.kt index 8bb5c15..01c875f 100644 --- a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/DataSourceQualifierAutoConfigurationTest.kt +++ b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/DataSourceQualifierAutoConfigurationTest.kt @@ -23,6 +23,7 @@ class DataSourceQualifierAutoConfigurationTest : FunSpec({ .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java)) .withBean(OutboxStore::class.java, { stubStore() }) .withBean(MessageDeliverer::class.java, { stubDeliverer() }) + .withPropertyValues("okapi.liquibase.enabled=false") test("no qualifier set, single datasource — uses that datasource") { val ds = SimpleDriverDataSource() diff --git a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseAutoConfigurationTest.kt b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseAutoConfigurationTest.kt index d802d74..fbc8389 100644 --- a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseAutoConfigurationTest.kt +++ b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseAutoConfigurationTest.kt @@ -1,30 +1,45 @@ package com.softwaremill.okapi.springboot +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender import com.softwaremill.okapi.core.DeliveryResult import com.softwaremill.okapi.core.MessageDeliverer import com.softwaremill.okapi.core.OutboxEntry import com.softwaremill.okapi.core.OutboxStatus import com.softwaremill.okapi.core.OutboxStore import io.kotest.assertions.throwables.shouldThrow +import io.kotest.assertions.withClue import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeSameInstanceAs +import liquibase.integration.spring.SpringLiquibase +import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.autoconfigure.AutoConfigureAfter +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.test.context.FilteredClassLoader import org.springframework.boot.test.context.runner.ApplicationContextRunner import org.springframework.jdbc.datasource.SimpleDriverDataSource import java.time.Instant import javax.sql.DataSource +import io.kotest.matchers.string.shouldContain as stringShouldContain /** - * Verifies that okapi configures dedicated Liquibase tracking tables (issue #37) so its migration - * history stays isolated from the host application's `databasechangelog`. + * Verifies bean wiring, property binding, and the structural invariants of okapi's Liquibase + * auto-configuration (issues #37 + #38 + the related NoClassDefFoundError fix). * - * The bean-wiring contexts instantiate the inner @Configuration classes directly to avoid - * `afterPropertiesSet()` (which would try to run Liquibase against a fake DataSource). - * - * The standalone property-binding test pins down the YAML contract — the keys - * `okapi.liquibase.changelog-table` / `okapi.liquibase.changelog-lock-table` and their mapping - * to the nested [OkapiProperties.Liquibase] data class. Without it, a refactor renaming the - * Kotlin fields would silently break user configuration. + * Slice tests instantiate the inner @Configuration classes directly to avoid `afterPropertiesSet()` + * (which would try to run Liquibase against a fake DataSource). Real-database coverage lives in + * [LiquibaseE2ETest]; the structural and reflection-based assertions in this file pin down the + * architectural decisions that the production code's KDoc explains, so a future "simplification" + * cannot silently undo them. */ class LiquibaseAutoConfigurationTest : FunSpec({ @@ -32,16 +47,20 @@ class LiquibaseAutoConfigurationTest : FunSpec({ val dataSources = mapOf("primary" to dataSource) fun postgresConfig(props: OkapiProperties = OkapiProperties()) = - OutboxAutoConfiguration.PostgresStoreConfiguration(dataSources, dataSource, props) + OutboxAutoConfiguration.PostgresLiquibaseConfiguration(dataSources, dataSource, props) fun mysqlConfig(props: OkapiProperties = OkapiProperties()) = - OutboxAutoConfiguration.MysqlStoreConfiguration(dataSources, dataSource, props) + OutboxAutoConfiguration.MysqlLiquibaseConfiguration(dataSources, dataSource, props) + // Default contextRunner disables Liquibase to keep slice tests focused on bean wiring rather + // than actual SpringLiquibase startup against a fake DataSource. Tests that exercise Liquibase + // explicitly opt back in. val contextRunner = ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java)) .withBean(OutboxStore::class.java, { stubStore() }) .withBean(MessageDeliverer::class.java, { stubDeliverer() }) .withBean(DataSource::class.java, { SimpleDriverDataSource() }) + .withOkapiLiquibaseDisabled() context("postgres liquibase") { test("uses dedicated changelog tables by default") { @@ -136,8 +155,272 @@ class LiquibaseAutoConfigurationTest : FunSpec({ rootCause.message shouldBe "okapi.liquibase.changelog-table must not be blank." } } + + context("okapi.liquibase.enabled property (issue #38 opt-out)") { + test("=false → okapi*Liquibase beans are skipped") { + // contextRunner sets okapi.liquibase.enabled=false by default + contextRunner.run { ctx -> + ctx.containsBean("okapiPostgresLiquibase") shouldBe false + ctx.containsBean("okapiMysqlLiquibase") shouldBe false + } + } + + test("=true → LiquibaseDisabledNotice is NOT registered (explicit opt-in path)") { + // The matchIfMissing=true default path is exercised by all the other Liquibase E2E + // tests (which omit the property). This test pins that the explicit string "true" + // is parsed and treated identically by checking the inverse: when enabled=true the + // LiquibaseDisabledNotice (gated on havingValue="false") must NOT register. + // + // FilteredClassLoader(SpringLiquibase) skips both Liquibase config classes so we + // don't try to run Liquibase against the fake DataSource — the only thing left to + // evaluate is LiquibaseDisabledNotice's @ConditionalOnProperty. + ApplicationContextRunner() + .withClassLoader(FilteredClassLoader(SpringLiquibase::class.java)) + .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java)) + .withBean(OutboxStore::class.java, { stubStore() }) + .withBean(MessageDeliverer::class.java, { stubDeliverer() }) + .withBean(DataSource::class.java, { SimpleDriverDataSource() }) + .withPropertyValues("okapi.liquibase.enabled=true") + .run { ctx -> + ctx.getBeansOfType(OutboxAutoConfiguration.LiquibaseDisabledNotice::class.java) + .isEmpty() shouldBe true + } + } + + test("=false → LiquibaseDisabledNotice IS registered AND logs the actionable WARN") { + // Symmetric to the test above: when opted out the breadcrumb config must register so + // its `init {}` block fires the WARN-level startup message. Without this assertion a + // future cleanup pass that "removes the unused class" or replaces the `init {}` block + // with a `@PostConstruct` that never runs would silently delete the operability + // promise the class exists to fulfil. + // + // Captures the actual log line so deletion of the warn() body is also caught — not + // just deletion of the class itself. + val notice = LoggerFactory.getLogger( + "com.softwaremill.okapi.springboot.OutboxAutoConfiguration", + ) as Logger + val appender = ListAppender().apply { start() } + notice.addAppender(appender) + + try { + ApplicationContextRunner() + .withClassLoader(FilteredClassLoader(SpringLiquibase::class.java)) + .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java)) + .withBean(OutboxStore::class.java, { stubStore() }) + .withBean(MessageDeliverer::class.java, { stubDeliverer() }) + .withBean(DataSource::class.java, { SimpleDriverDataSource() }) + .withPropertyValues("okapi.liquibase.enabled=false") + .run { ctx -> + ctx.getBeansOfType(OutboxAutoConfiguration.LiquibaseDisabledNotice::class.java) + .size shouldBe 1 + } + + val warnEvents = appender.list.filter { it.level == Level.WARN } + withClue( + "LiquibaseDisabledNotice should emit exactly one WARN-level startup log when " + + "okapi.liquibase.enabled=false, mentioning the property name and the changelog path " + + "so users have a breadcrumb when they later see 'relation okapi_outbox does not exist'.", + ) { + warnEvents.size shouldBe 1 + val message = warnEvents.single().formattedMessage + message stringShouldContain "okapi.liquibase.enabled=false" + message stringShouldContain "okapi/db/changelog.xml" + } + } finally { + notice.detachAppender(appender) + } + } + + test("=garbage → Spring binder rejects the value at startup") { + contextRunner + .withPropertyValues("okapi.liquibase.enabled=garbage") + .run { ctx -> + ctx.startupFailure.shouldNotBeNull() + } + } + } + + context("@ConditionalOnClass(SpringLiquibase) class-level skip path (NCDF guard)") { + // The real-world failure mode (autocomplete-test on Spring Boot 3.5.x without liquibase-core) + // is JVM-level: `Class.getDeclaredMethods0()` resolving the SpringLiquibase return type + // before any Spring condition evaluation. Empirical proof of the fix lives in the + // okapi-autocomplete-test reproducer — Spring's `FilteredClassLoader` only intercepts + // `loadClass()`, so it cannot trigger the same JVM-native introspection failure here. + // + // What this test exercises is the conditional-skip path: with SpringLiquibase filtered out + // of `loadClass()` lookups, Spring's class-level `@ConditionalOnClass` evaluates the + // condition via classname-string lookup and skips the entire configuration class. That + // is the mechanism the fix relies on; the JVM-introspection avoidance follows once the + // class is skipped (its methods are never inspected). + test("FilteredClassLoader hides SpringLiquibase → context loads, Liquibase configs skipped") { + contextRunner + .withClassLoader(FilteredClassLoader(SpringLiquibase::class.java)) + .run { ctx -> + ctx.startupFailure shouldBe null + ctx.containsBean("okapiPostgresLiquibase") shouldBe false + ctx.containsBean("okapiMysqlLiquibase") shouldBe false + } + } + + // Structural pin: the only @Bean methods declaring a SpringLiquibase return type must live + // inside @Configuration classes that are themselves gated by @ConditionalOnClass(SpringLiquibase). + // If a future refactor moves the Liquibase bean back into PostgresStoreConfiguration (or + // OutboxAutoConfiguration directly), Spring's condition machinery can no longer skip it + // before `Class.getDeclaredMethods()` introspects the return type — the original NCDF + // bug returns. FilteredClassLoader cannot detect that regression (it tests the loadClass + // path, not getDeclaredMethods); this assertion does. + test("only PostgresLiquibaseConfiguration / MysqlLiquibaseConfiguration declare SpringLiquibase return types") { + val unguardedClasses = listOf( + OutboxAutoConfiguration::class.java, + OutboxAutoConfiguration.PostgresStoreConfiguration::class.java, + OutboxAutoConfiguration.MysqlStoreConfiguration::class.java, + ) + unguardedClasses.forEach { configClass -> + withClue( + "$configClass declares a SpringLiquibase return type — it must be moved to a " + + "class with class-level @ConditionalOnClass(SpringLiquibase) or the NCDF " + + "bug returns on consumers without liquibase-core", + ) { + configClass.declaredMethods.filter { it.returnType == SpringLiquibase::class.java } + .map { it.name } + .shouldBeEmpty() + } + } + } + + test("PostgresLiquibaseConfiguration / MysqlLiquibaseConfiguration are gated by @ConditionalOnClass(SpringLiquibase)") { + listOf( + OutboxAutoConfiguration.PostgresLiquibaseConfiguration::class.java, + OutboxAutoConfiguration.MysqlLiquibaseConfiguration::class.java, + ).forEach { configClass -> + val annotation = configClass.getAnnotation(ConditionalOnClass::class.java) + withClue( + "$configClass must carry class-level @ConditionalOnClass(SpringLiquibase) " + + "so Spring skips it without method introspection", + ) { + annotation.shouldNotBeNull() + annotation.value.toList() shouldContain SpringLiquibase::class + } + } + } + + // Reflection-based meta-test, modeled on PR #41 (okapi-spring-boot Micrometer fix). + // `@AutoConfigureAfter(name = ...)` silently drops entries whose class is missing — if NONE + // resolve, the ordering hint is a no-op. This is the failure mode of the Micrometer + // regression (#36): a Spring Boot 4.0-only path was declared while the project also runs + // on 3.5.x. + // + // Two-layer assertion: + // 1. Unconditional structural check: annotation is present and non-empty. + // 2. Runtime classpath check: at least one declared name resolves IF Spring Boot's + // Liquibase autoconfig is on the classpath at all (3.5.x ships it in + // spring-boot-autoconfigure; 4.0.x does not). + test("@AutoConfigureAfter on OutboxAutoConfiguration is structurally sound and resolvable when applicable") { + val annotation = OutboxAutoConfiguration::class.java.getAnnotation(AutoConfigureAfter::class.java) + annotation.shouldNotBeNull() + + val declaredNames = annotation.name.toList() + withClue("@AutoConfigureAfter must declare at least one target name; an empty list silently no-ops the ordering contract") { + declaredNames.shouldNotBeEmpty() + } + + val classLoader = OutboxAutoConfiguration::class.java.classLoader + val knownSpringBootLiquibasePaths = listOf( + "org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration", + "org.springframework.boot.liquibase.autoconfigure.LiquibaseAutoConfiguration", + ) + val resolvableSpringBootPaths = knownSpringBootLiquibasePaths.filter { canLoadClass(it, classLoader) } + if (resolvableSpringBootPaths.isEmpty()) { + // No Spring Boot Liquibase autoconfig on this runtime — ordering is a no-op and + // there is nothing to shadow. Structural check above already pinned the annotation + // shape; nothing further to assert. + return@test + } + + val resolvableDeclaredNames = declaredNames.filter { canLoadClass(it, classLoader) } + withClue( + "@AutoConfigureAfter declares $declaredNames but none resolve on this Spring Boot runtime " + + "($resolvableSpringBootPaths is on the classpath); the ordering hint is silently ignored " + + "and OutboxAutoConfiguration may run before Spring Boot's LiquibaseAutoConfiguration, " + + "shadowing the host application's liquibase bean.", + ) { + resolvableDeclaredNames.shouldNotBeEmpty() + } + } + } + + context("@ConditionalOnMissingBean is name-based (design pin for issue #38 coexistence)") { + // PostgresLiquibaseConfiguration's KDoc explains: name-based, NOT type-based. A future + // "simplification" to type-based @ConditionalOnMissingBean(SpringLiquibase) silently + // shadows the host application's own SpringLiquibase bean (Mode 1 of issue #38). + // These tests pin the architectural decision against that single-token regression. + + test("okapiPostgresLiquibase factory uses @ConditionalOnMissingBean(name = ...)") { + val method = OutboxAutoConfiguration.PostgresLiquibaseConfiguration::class.java + .getDeclaredMethod("okapiPostgresLiquibase") + val cond = method.getAnnotation(ConditionalOnMissingBean::class.java) + cond.shouldNotBeNull() + cond.name.toList() shouldContain "okapiPostgresLiquibase" + withClue( + "type-based @ConditionalOnMissingBean(SpringLiquibase) would skip okapi's bean " + + "whenever the host app provides its own — re-introducing the issue #38 Mode 1 " + + "shadowing bug", + ) { + cond.value.toList().shouldBeEmpty() + } + } + + test("okapiMysqlLiquibase factory uses @ConditionalOnMissingBean(name = ...)") { + val method = OutboxAutoConfiguration.MysqlLiquibaseConfiguration::class.java + .getDeclaredMethod("okapiMysqlLiquibase") + val cond = method.getAnnotation(ConditionalOnMissingBean::class.java) + cond.shouldNotBeNull() + cond.name.toList() shouldContain "okapiMysqlLiquibase" + cond.value.toList().shouldBeEmpty() + } + + test("user @Bean(\"okapiPostgresLiquibase\") replaces okapi's default bean") { + // Pins that the documented override mechanism actually works: a host app supplying + // its own bean by the well-known name takes precedence over okapi's auto-configured + // factory (because @ConditionalOnMissingBean(name = "okapiPostgresLiquibase") sees + // the user's definition and skips okapi's). Switching to type-based + // @ConditionalOnMissingBean(SpringLiquibase) silently breaks this — the user's bean + // still registers, but okapi's also registers, producing two SpringLiquibase beans. + // + // setShouldRun(false) prevents the user's bean from actually running Liquibase + // against the fake DataSource. MysqlOutboxStore is hidden via FilteredClassLoader + // so the MySQL Liquibase config class is also skipped (otherwise it would try to + // run okapi's MySQL changelog against the fake DataSource). + val userBean = SpringLiquibase().apply { setShouldRun(false) } + ApplicationContextRunner() + .withClassLoader(FilteredClassLoader(com.softwaremill.okapi.mysql.MysqlOutboxStore::class.java)) + .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java)) + .withBean(OutboxStore::class.java, { stubStore() }) + .withBean(MessageDeliverer::class.java, { stubDeliverer() }) + .withBean(DataSource::class.java, { SimpleDriverDataSource() }) + .withBean("okapiPostgresLiquibase", SpringLiquibase::class.java, { userBean }) + // okapi.liquibase.enabled defaults to true (no opt-out): the override decision + // must come from @ConditionalOnMissingBean, not from the property. + .run { ctx -> + ctx.getBean("okapiPostgresLiquibase", SpringLiquibase::class.java) shouldBeSameInstanceAs userBean + // Exactly one SpringLiquibase bean — the user's. okapi's factory must have been + // skipped by @ConditionalOnMissingBean(name = "okapiPostgresLiquibase"). + ctx.getBeansOfType(SpringLiquibase::class.java).size shouldBe 1 + } + } + } }) +private fun ApplicationContextRunner.withOkapiLiquibaseDisabled(): ApplicationContextRunner = + withPropertyValues("okapi.liquibase.enabled=false") + +private fun canLoadClass(fqcn: String, classLoader: ClassLoader): Boolean = try { + Class.forName(fqcn, false, classLoader) + true +} catch (_: ClassNotFoundException) { + false +} + private fun stubStore() = object : OutboxStore { override fun persist(entry: OutboxEntry) = entry override fun claimPending(limit: Int) = emptyList() diff --git a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseE2ETest.kt b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseE2ETest.kt index f53bdd6..2364f8e 100644 --- a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseE2ETest.kt +++ b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseE2ETest.kt @@ -4,15 +4,19 @@ import com.mysql.cj.jdbc.MysqlDataSource import com.softwaremill.okapi.core.DeliveryResult import com.softwaremill.okapi.core.MessageDeliverer import com.softwaremill.okapi.core.OutboxEntry +import com.softwaremill.okapi.mysql.MysqlOutboxStore import com.softwaremill.okapi.postgres.PostgresOutboxStore import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldNotContain import io.kotest.matchers.nulls.shouldBeNull +import liquibase.integration.spring.SpringLiquibase import org.postgresql.ds.PGSimpleDataSource +import org.springframework.beans.factory.support.BeanDefinitionBuilder import org.springframework.boot.autoconfigure.AutoConfigurations import org.springframework.boot.test.context.FilteredClassLoader import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.context.support.GenericApplicationContext import org.testcontainers.containers.MySQLContainer import org.testcontainers.containers.PostgreSQLContainer import javax.sql.DataSource @@ -30,14 +34,17 @@ import javax.sql.DataSource class LiquibaseE2ETest : FunSpec({ val postgres = PostgreSQLContainer("postgres:16") + val postgresSecondary = PostgreSQLContainer("postgres:16") val mysql = MySQLContainer("mysql:8.0") beforeSpec { postgres.start() + postgresSecondary.start() mysql.start() } afterSpec { postgres.stop() + postgresSecondary.stop() mysql.stop() } @@ -63,7 +70,12 @@ class LiquibaseE2ETest : FunSpec({ } } + // Hide MysqlOutboxStore from the classpath: both `okapi-postgres` and `okapi-mysql` are on + // the test classpath, and MysqlStoreConfiguration would otherwise activate first + // (alphabetical M < P) and create the wrong outboxStore bean type for okapiPostgresLiquibase + // to inject. fun runner(ds: DataSource) = ApplicationContextRunner() + .withClassLoader(FilteredClassLoader(MysqlOutboxStore::class.java)) .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java)) .withBean(MessageDeliverer::class.java, { stubDeliverer() }) .withBean(DataSource::class.java, { ds }) @@ -108,6 +120,124 @@ class LiquibaseE2ETest : FunSpec({ tables shouldNotContain "okapi_databasechangelog" } } + + test("coexistence with the host application's own SpringLiquibase via Spring Boot autoconfig (issue #38 Mode 1)") { + // High-fidelity test for issue #38: the host application uses Spring Boot's standard + // `LiquibaseAutoConfiguration` + `spring.liquibase.change-log` to set up its own + // SpringLiquibase. Okapi must run AFTER that autoconfig (`@AutoConfigureAfter`), so + // Spring Boot's `@ConditionalOnMissingBean(SpringLiquibase)` sees its own bean register + // first and does NOT skip itself. Otherwise okapi's bean shadows the host's silently. + // + // The previous version of this test pre-registered the user bean via withBean(...), + // which bypasses autoconfig ordering entirely (the bean was always present before any + // autoconfig ran) and therefore could not detect a missing or wrong @AutoConfigureAfter. + // + // Here we pull Spring Boot's real `LiquibaseAutoConfiguration` into the runner, so the + // ordering contract is exercised end-to-end. On Spring Boot 4.0.x the autoconfig is + // not on the classpath (Liquibase autoconfig was moved out of spring-boot-autoconfigure + // and a dedicated `spring-boot-liquibase` artifact is not pulled into okapi-spring-boot + // tests), so the test is skipped — the CI matrix's 3.5.x dimension provides the actual + // coverage; the structural / reflection assertions in [LiquibaseAutoConfigurationTest] + // are the regression net on 4.0.x. + val springBootLiquibaseAutoConfig = resolveSpringBootClass( + "org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration", + "org.springframework.boot.liquibase.autoconfigure.LiquibaseAutoConfiguration", + ) ?: return@test // skip on 4.0.x where Spring Boot Liquibase autoconfig is absent + + resetSchema() // ensure prior tests in this context didn't leave okapi_* tables + val ds = dataSource() + + ApplicationContextRunner() + .withClassLoader(FilteredClassLoader(MysqlOutboxStore::class.java)) + .withConfiguration( + AutoConfigurations.of( + OutboxAutoConfiguration::class.java, + springBootLiquibaseAutoConfig, + ), + ) + .withBean(MessageDeliverer::class.java, { stubDeliverer() }) + .withBean(DataSource::class.java, { ds }) + .withPropertyValues( + "okapi.processor.enabled=false", + "okapi.purger.enabled=false", + "spring.liquibase.change-log=classpath:/com/softwaremill/okapi/springboot/test-app-changelog.xml", + ) + .run { ctx -> + ctx.startupFailure.shouldBeNull() + + // Spring Boot's autoconfig registered its bean (name "liquibase") AND + // okapi registered its bean ("okapiPostgresLiquibase"). Both ran on the same + // DataSource. If @AutoConfigureAfter were missing, Spring Boot's @ConditionalOnMissingBean + // (by type) would see okapi's bean first and silently skip its own — `liquibase` + // would be missing here. + val liquibaseBeans = ctx.getBeansOfType(SpringLiquibase::class.java) + liquibaseBeans.keys shouldContain "liquibase" + liquibaseBeans.keys shouldContain "okapiPostgresLiquibase" + + val tables = listTables(ds) + // Okapi's tables (dedicated tracking + outbox) + tables shouldContain "okapi_databasechangelog" + tables shouldContain "okapi_databasechangeloglock" + tables shouldContain "okapi_outbox" + // App's tables (Spring Boot Liquibase's default tracking + the table the + // test changelog creates) + tables shouldContain "databasechangelog" + tables shouldContain "databasechangeloglock" + tables shouldContain "app_table_marker" + } + } + + test("multi-datasource: okapi targets the DataSource named by `okapi.datasource-qualifier`") { + // Two PostgreSQL instances. The primary holds unrelated application data; okapi must + // run its migrations against the secondary, named via `okapi.datasource-qualifier`. + // `DataSourceQualifierAutoConfigurationTest` covers the resolver semantics with stubs; + // this E2E verifies the autoconfigured `okapiPostgresLiquibase` bean actually targets + // the qualified DataSource on real DDL. + val primaryDs = dataSource() + val secondaryDs = PGSimpleDataSource().apply { + setURL(postgresSecondary.jdbcUrl) + user = postgresSecondary.username + password = postgresSecondary.password + } + + // beforeEach resets the primary; reset the secondary too so repeated runs stay clean. + secondaryDs.connection.use { conn -> + conn.createStatement().use { stmt -> + stmt.execute("DROP SCHEMA public CASCADE") + stmt.execute("CREATE SCHEMA public") + } + } + + ApplicationContextRunner() + .withClassLoader(FilteredClassLoader(MysqlOutboxStore::class.java)) + .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java)) + .withBean(MessageDeliverer::class.java, { stubDeliverer() }) + .withInitializer { context -> + val bd = BeanDefinitionBuilder.genericBeanDefinition(DataSource::class.java) { primaryDs } + .beanDefinition + bd.isPrimary = true + (context as GenericApplicationContext).registerBeanDefinition("primaryDs", bd) + } + .withBean("secondaryDs", DataSource::class.java, { secondaryDs }) + .withPropertyValues( + "okapi.processor.enabled=false", + "okapi.purger.enabled=false", + "okapi.datasource-qualifier=secondaryDs", + ) + .run { ctx -> + ctx.startupFailure.shouldBeNull() + + val secondaryTables = listTables(secondaryDs) + secondaryTables shouldContain "okapi_outbox" + secondaryTables shouldContain "okapi_databasechangelog" + secondaryTables shouldContain "okapi_databasechangeloglock" + + val primaryTables = listTables(primaryDs) + primaryTables shouldNotContain "okapi_outbox" + primaryTables shouldNotContain "okapi_databasechangelog" + primaryTables shouldNotContain "okapi_databasechangeloglock" + } + } } context("mysql") { @@ -186,9 +316,68 @@ class LiquibaseE2ETest : FunSpec({ tables shouldNotContain "okapi_databasechangelog" } } + + test("coexistence with the host application's own SpringLiquibase via Spring Boot autoconfig (issue #38 Mode 1)") { + // Mirror of the Postgres coexistence test on MySQL — the bug class is not adapter- + // specific, but Liquibase's MySQL adapter has its own quirks (lock-table charset, + // FOREIGN_KEY_CHECKS) so we exercise the same scenario against a real MySQL container. + val springBootLiquibaseAutoConfig = resolveSpringBootClass( + "org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration", + "org.springframework.boot.liquibase.autoconfigure.LiquibaseAutoConfiguration", + ) ?: return@test // skip on Spring Boot 4.0.x where the autoconfig is absent + + resetSchema() + val ds = dataSource() + + ApplicationContextRunner() + .withClassLoader(FilteredClassLoader(PostgresOutboxStore::class.java)) + .withConfiguration( + AutoConfigurations.of( + OutboxAutoConfiguration::class.java, + springBootLiquibaseAutoConfig, + ), + ) + .withBean(MessageDeliverer::class.java, { stubDeliverer() }) + .withBean(DataSource::class.java, { ds }) + .withPropertyValues( + "okapi.processor.enabled=false", + "okapi.purger.enabled=false", + "spring.liquibase.change-log=classpath:/com/softwaremill/okapi/springboot/test-app-changelog.xml", + ) + .run { ctx -> + ctx.startupFailure.shouldBeNull() + + val liquibaseBeans = ctx.getBeansOfType(SpringLiquibase::class.java) + liquibaseBeans.keys shouldContain "liquibase" + liquibaseBeans.keys shouldContain "okapiMysqlLiquibase" + + val tables = listTables(ds) + tables shouldContain "okapi_databasechangelog" + tables shouldContain "okapi_databasechangeloglock" + tables shouldContain "okapi_outbox" + tables shouldContain "databasechangelog" + tables shouldContain "databasechangeloglock" + tables shouldContain "app_table_marker" + } + } } }) +// Loads the first Spring Boot autoconfig FQCN that resolves on the runtime classpath, or null +// if none do. Used to bridge the 3.5.x (`org.springframework.boot.autoconfigure.*`) and 4.0.x +// (`org.springframework.boot..autoconfigure.*`) package layouts in tests that pull in +// Spring Boot's own auto-configurations alongside okapi's. +private fun resolveSpringBootClass(vararg candidateFqcns: String): Class<*>? { + val classLoader = LiquibaseE2ETest::class.java.classLoader + return candidateFqcns.firstNotNullOfOrNull { fqcn -> + try { + Class.forName(fqcn, false, classLoader) + } catch (_: ClassNotFoundException) { + null + } + } +} + private fun stubDeliverer() = object : MessageDeliverer { override val type = "stub" override fun deliver(entry: OutboxEntry) = DeliveryResult.Success diff --git a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxProcessorAutoConfigurationTest.kt b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxProcessorAutoConfigurationTest.kt index 38a60ac..8939f00 100644 --- a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxProcessorAutoConfigurationTest.kt +++ b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxProcessorAutoConfigurationTest.kt @@ -30,6 +30,9 @@ class OutboxProcessorAutoConfigurationTest : FunSpec({ .withBean(OutboxStore::class.java, { stubStore() }) .withBean(MessageDeliverer::class.java, { stubDeliverer() }) .withBean(DataSource::class.java, { SimpleDriverDataSource() }) + // Liquibase migration is exercised end-to-end against real DBs; disable it here so slice + // tests don't try to run okapi's Postgres changelog against a fake DataSource. + .withPropertyValues("okapi.liquibase.enabled=false") test("processor bean is created by default") { contextRunner.run { ctx -> @@ -159,6 +162,9 @@ class OutboxProcessorAutoConfigurationTest : FunSpec({ .withBean(OutboxStore::class.java, { stubStore() }) .withBean(MessageDeliverer::class.java, { stubDeliverer() }) .withBean(DataSource::class.java, { SimpleDriverDataSource() }) + // Disable okapi's Liquibase so it doesn't try to run against the fake DataSource — + // this test isolates the metrics auto-config ordering, not Liquibase. + .withPropertyValues("okapi.liquibase.enabled=false") .run { ctx -> ctx.getBean(io.micrometer.core.instrument.MeterRegistry::class.java).shouldNotBeNull() ctx.getBean(MicrometerOutboxListener::class.java).shouldNotBeNull() diff --git a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxPurgerAutoConfigurationTest.kt b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxPurgerAutoConfigurationTest.kt index 9c89735..73f1a40 100644 --- a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxPurgerAutoConfigurationTest.kt +++ b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxPurgerAutoConfigurationTest.kt @@ -25,6 +25,7 @@ class OutboxPurgerAutoConfigurationTest : FunSpec({ .withBean(OutboxStore::class.java, { stubStore() }) .withBean(MessageDeliverer::class.java, { stubDeliverer() }) .withBean(DataSource::class.java, { SimpleDriverDataSource() }) + .withPropertyValues("okapi.liquibase.enabled=false") test("purger bean is created by default") { contextRunner.run { ctx -> diff --git a/okapi-spring-boot/src/test/resources/com/softwaremill/okapi/springboot/test-app-changelog.xml b/okapi-spring-boot/src/test/resources/com/softwaremill/okapi/springboot/test-app-changelog.xml new file mode 100644 index 0000000..48a7210 --- /dev/null +++ b/okapi-spring-boot/src/test/resources/com/softwaremill/okapi/springboot/test-app-changelog.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + From 9755663b91a15db991eefaa2acca8eba3385c80d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20Kobyli=C5=84ski?= Date: Thu, 14 May 2026 12:08:13 +0200 Subject: [PATCH 2/7] fix: prevent dual Liquibase activation when both okapi-postgres and okapi-mysql are on the classpath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When both store modules are on the consumer's classpath, the previous gating (class-level @ConditionalOnClass on each *LiquibaseConfiguration) only checked classpath presence — so both okapiPostgresLiquibase and okapiMysqlLiquibase beans registered against the same DataSource. At startup the second Liquibase ran wrong-engine DDL and failed with a duplicate-object error (e.g. relation idx_okapi_outbox_status_last_attempt already exists). Extract the Liquibase configs into a separate OkapiLiquibaseAutoConfiguration annotated @AutoConfiguration(after = OutboxAutoConfiguration) so each engine's @ConditionalOnBean(OutboxStore) gate fires AFTER the store factories have registered the winning bean. Within a single auto-config those gates would evaluate before sibling beans are visible and always skip. Move the @AutoConfigureAfter ordering vs. Spring Boot's own Liquibase auto-config to the new class (where the Liquibase concern lives) and update the corresponding structural test to use AnnotatedElementUtils.findMergedAnnotation so the @AliasFor-ed attributes on @AutoConfiguration are picked up. Tests: - LiquibaseAutoConfigurationTest: new regression test pinning that with both modules visible, exactly one okapi*Liquibase activates (matching the OutboxStore winner). - LiquibaseE2ETest: new regression test exercising the same against a real Postgres database, and clarified the rationale for the FilteredClassLoader(MysqlOutboxStore) used in the other tests. --- .../OkapiLiquibaseAutoConfiguration.kt | 140 ++++++++++++++++++ .../springboot/OutboxAutoConfiguration.kt | 103 +------------ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../LiquibaseAutoConfigurationTest.kt | 110 +++++++++++--- .../okapi/springboot/LiquibaseE2ETest.kt | 50 ++++++- 5 files changed, 280 insertions(+), 124 deletions(-) create mode 100644 okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OkapiLiquibaseAutoConfiguration.kt diff --git a/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OkapiLiquibaseAutoConfiguration.kt b/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OkapiLiquibaseAutoConfiguration.kt new file mode 100644 index 0000000..1e12f79 --- /dev/null +++ b/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OkapiLiquibaseAutoConfiguration.kt @@ -0,0 +1,140 @@ +package com.softwaremill.okapi.springboot + +import com.softwaremill.okapi.mysql.MysqlOutboxStore +import com.softwaremill.okapi.postgres.PostgresOutboxStore +import liquibase.integration.spring.SpringLiquibase +import org.slf4j.LoggerFactory +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.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import javax.sql.DataSource + +private val LIQUIBASE_DISABLED_LOGGER = LoggerFactory.getLogger("com.softwaremill.okapi.springboot.OkapiLiquibaseAutoConfiguration") + +/** + * Auto-configures okapi's bundled Liquibase migrations. + * + * **Why a separate auto-config from [OutboxAutoConfiguration]:** the engine-specific Liquibase + * configs must activate based on which `OutboxStore` bean actually won precedence (Postgres takes + * priority when both `okapi-postgres` and `okapi-mysql` are on the classpath). Within a single + * `@AutoConfiguration` pass, `@ConditionalOnBean` cannot reliably observe sibling beans defined + * in the same auto-config — Spring's `OnBeanCondition` runs at REGISTER_BEAN phase and is + * evaluated together with the conditions of the bean it is supposed to observe. Splitting + * Liquibase into a downstream auto-config (`@AutoConfigureAfter(OutboxAutoConfiguration)`) + * guarantees that by the time Liquibase conditions are evaluated, the chosen `*OutboxStore` + * bean has already been fully registered and is visible to `@ConditionalOnBean`. + * + * **Ordering vs. Spring Boot's own [SpringLiquibase] auto-config:** ordered after Spring Boot's + * `LiquibaseAutoConfiguration` (3.x and 4.x package paths covered) so the host application's + * own `liquibase` bean registers first. Spring Boot's `@ConditionalOnMissingBean(SpringLiquibase)` + * then sees its own bean and stops looking, leaving okapi free to add its uniquely-named + * `okapiPostgresLiquibase` / `okapiMysqlLiquibase` next to it. Both run on startup with their + * own changelogs and dedicated tracking tables. + * + * Opt out entirely via `okapi.liquibase.enabled=false`. + */ +@AutoConfiguration( + after = [OutboxAutoConfiguration::class], + afterName = [ + // Spring Boot 3.x — package path + "org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration", + // Spring Boot 4.x — package was reorganized into a separate module + "org.springframework.boot.liquibase.autoconfigure.LiquibaseAutoConfiguration", + ], +) +@EnableConfigurationProperties(OkapiProperties::class) +class OkapiLiquibaseAutoConfiguration { + + /** + * Auto-configures okapi's PostgreSQL Liquibase migration. + * + * **Why a separate class with class-level [ConditionalOnClass]:** placing + * `@ConditionalOnClass(SpringLiquibase::class)` on the class level (rather than on the + * `@Bean` method) ensures Spring evaluates the condition via string-name classpath lookup + * **before** any introspection of the class's methods. Without this, JVM + * `Class.getDeclaredMethods()` would resolve [SpringLiquibase] in the method return type + * during configuration parsing — which fails with `NoClassDefFoundError` whenever + * `liquibase-core` is absent from the consumer's classpath (it is `compileOnly` in + * okapi-spring-boot, e.g. Flyway-only consumers do not pull it in). + * + * **Why class-level [ConditionalOnBean]:** this auto-config is processed AFTER + * [OutboxAutoConfiguration] (see `@AutoConfigureAfter` on the outer class), so at the + * time `@ConditionalOnBean(PostgresOutboxStore)` is evaluated, the chosen `*OutboxStore` + * bean is already registered and visible. When MySQL wins precedence instead, this gate + * skips the entire class — preventing the dual-Liquibase / wrong-engine-DDL startup + * failure that would otherwise occur with both modules on the classpath. + * + * **Coexistence with the host application's own [SpringLiquibase]:** + * `@ConditionalOnMissingBean(SpringLiquibase::class)` is intentionally **not** used — + * okapi's bean is named `okapiPostgresLiquibase` and runs its own bundled changelog with + * dedicated tracking tables (`okapi_databasechangelog`/`okapi_databasechangeloglock` by + * default). It coexists alongside Spring Boot's auto-configured `liquibase` bean. Disable + * okapi's bean via `okapi.liquibase.enabled=false` if the host already includes okapi's + * changelog from its own master. + */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(SpringLiquibase::class, PostgresOutboxStore::class) + @ConditionalOnBean(PostgresOutboxStore::class) + @ConditionalOnProperty(prefix = "okapi.liquibase", name = ["enabled"], havingValue = "true", matchIfMissing = true) + class PostgresLiquibaseConfiguration( + private val dataSources: Map, + private val primaryDataSource: DataSource, + private val okapiProperties: OkapiProperties, + ) { + @Bean("okapiPostgresLiquibase") + @ConditionalOnMissingBean(name = ["okapiPostgresLiquibase"]) + fun okapiPostgresLiquibase(): SpringLiquibase = SpringLiquibase().apply { + dataSource = OutboxAutoConfiguration.resolveDataSource(dataSources, primaryDataSource, okapiProperties) + changeLog = "classpath:com/softwaremill/okapi/db/changelog.xml" + databaseChangeLogTable = okapiProperties.liquibase.changelogTable + databaseChangeLogLockTable = okapiProperties.liquibase.changelogLockTable + } + } + + /** + * Auto-configures okapi's MySQL Liquibase migration. See [PostgresLiquibaseConfiguration] + * for rationale on the conditional placement. + */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(SpringLiquibase::class, MysqlOutboxStore::class) + @ConditionalOnBean(MysqlOutboxStore::class) + @ConditionalOnProperty(prefix = "okapi.liquibase", name = ["enabled"], havingValue = "true", matchIfMissing = true) + class MysqlLiquibaseConfiguration( + private val dataSources: Map, + private val primaryDataSource: DataSource, + private val okapiProperties: OkapiProperties, + ) { + @Bean("okapiMysqlLiquibase") + @ConditionalOnMissingBean(name = ["okapiMysqlLiquibase"]) + fun okapiMysqlLiquibase(): SpringLiquibase = SpringLiquibase().apply { + dataSource = OutboxAutoConfiguration.resolveDataSource(dataSources, primaryDataSource, okapiProperties) + changeLog = "classpath:com/softwaremill/okapi/db/mysql/changelog.xml" + databaseChangeLogTable = okapiProperties.liquibase.changelogTable + databaseChangeLogLockTable = okapiProperties.liquibase.changelogLockTable + } + } + + /** + * Logs a single startup warning when okapi's Liquibase auto-config is explicitly opted out + * (`okapi.liquibase.enabled=false`). Without this, a user who flipped the flag months ago + * has no breadcrumb when they later see "relation okapi_outbox does not exist" at first + * publish — the link to the opt-out is in the startup log, not the error. + */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(prefix = "okapi.liquibase", name = ["enabled"], havingValue = "false") + class LiquibaseDisabledNotice { + init { + LIQUIBASE_DISABLED_LOGGER.warn( + "okapi.liquibase.enabled=false — okapi will NOT create or migrate the okapi_outbox schema. " + + "Ensure your application's migration tool applies " + + "classpath:com/softwaremill/okapi/db/changelog.xml " + + "(or classpath:com/softwaremill/okapi/db/mysql/changelog.xml for MySQL).", + ) + } + } +} diff --git a/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OutboxAutoConfiguration.kt b/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OutboxAutoConfiguration.kt index f318b9b..2eb6940 100644 --- a/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OutboxAutoConfiguration.kt +++ b/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OutboxAutoConfiguration.kt @@ -12,11 +12,8 @@ import com.softwaremill.okapi.core.OutboxStore import com.softwaremill.okapi.core.RetryPolicy import com.softwaremill.okapi.mysql.MysqlOutboxStore import com.softwaremill.okapi.postgres.PostgresOutboxStore -import liquibase.integration.spring.SpringLiquibase -import org.slf4j.LoggerFactory import org.springframework.beans.factory.ObjectProvider import org.springframework.boot.autoconfigure.AutoConfiguration -import org.springframework.boot.autoconfigure.AutoConfigureAfter import org.springframework.boot.autoconfigure.condition.ConditionalOnClass import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty @@ -28,8 +25,6 @@ import org.springframework.transaction.support.TransactionTemplate import java.time.Clock import javax.sql.DataSource -private val LIQUIBASE_DISABLED_LOGGER = LoggerFactory.getLogger("com.softwaremill.okapi.springboot.OutboxAutoConfiguration") - /** * Spring Boot autoconfiguration for the outbox processing pipeline. * @@ -54,24 +49,11 @@ private val LIQUIBASE_DISABLED_LOGGER = LoggerFactory.getLogger("com.softwaremil * - Set `okapi.datasource-qualifier` to the bean name of the [DataSource] that holds the outbox table. * When not set, the primary (or single) DataSource is used. * - * Liquibase coexistence: - * - Auto-config is ordered after Spring Boot's `LiquibaseAutoConfiguration` (3.x and 4.x package - * paths covered) so that the application's own auto-configured `liquibase` bean registers first. - * Spring Boot's `@ConditionalOnMissingBean(SpringLiquibase::class)` then sees its own bean and - * stops looking, leaving okapi free to add its uniquely-named `okapiPostgresLiquibase` / - * `okapiMysqlLiquibase` next to it. Both run on startup with their own changelogs. - * - Set `okapi.liquibase.enabled=false` to opt out entirely (e.g. when including okapi's - * changelog from the application's master changelog). + * Liquibase support is provided by [OkapiLiquibaseAutoConfiguration], which is ordered after this + * auto-config so that its `@ConditionalOnBean(OutboxStore::class)` gates can observe which + * store bean actually won precedence. */ @AutoConfiguration -@AutoConfigureAfter( - name = [ - // Spring Boot 3.x — package path - "org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration", - // Spring Boot 4.x — package was reorganized into a separate module - "org.springframework.boot.liquibase.autoconfigure.LiquibaseAutoConfiguration", - ], -) @EnableConfigurationProperties(OkapiProperties::class, OutboxPurgerProperties::class, OutboxProcessorProperties::class) class OutboxAutoConfiguration( private val dataSources: Map, @@ -199,85 +181,6 @@ class OutboxAutoConfiguration( ) } - /** - * Auto-configures okapi's PostgreSQL Liquibase migration. - * - * **Why a separate class with class-level [ConditionalOnClass]:** placing - * `@ConditionalOnClass(SpringLiquibase::class)` on the class level (rather than on the - * `@Bean` method) ensures Spring evaluates the condition via string-name classpath lookup - * **before** any introspection of the class's methods. Without this, JVM - * `Class.getDeclaredMethods()` would resolve [SpringLiquibase] in the method return type - * during configuration parsing — which fails with `NoClassDefFoundError` whenever - * `liquibase-core` is absent from the consumer's classpath (it is `compileOnly` in - * okapi-spring-boot, e.g. Flyway-only consumers do not pull it in). - * - * **Coexistence with the host application's own [SpringLiquibase]:** - * `@ConditionalOnMissingBean(SpringLiquibase::class)` is intentionally **not** used — - * okapi's bean is named `okapiPostgresLiquibase` and runs its own bundled changelog with - * dedicated tracking tables (`okapi_databasechangelog`/`okapi_databasechangeloglock` by - * default). It coexists alongside Spring Boot's auto-configured `liquibase` bean. Disable - * okapi's bean via `okapi.liquibase.enabled=false` if the host already includes okapi's - * changelog from its own master. - */ - @Configuration(proxyBeanMethods = false) - @ConditionalOnClass(SpringLiquibase::class, PostgresOutboxStore::class) - @ConditionalOnProperty(prefix = "okapi.liquibase", name = ["enabled"], havingValue = "true", matchIfMissing = true) - class PostgresLiquibaseConfiguration( - private val dataSources: Map, - private val primaryDataSource: DataSource, - private val okapiProperties: OkapiProperties, - ) { - @Bean("okapiPostgresLiquibase") - @ConditionalOnMissingBean(name = ["okapiPostgresLiquibase"]) - fun okapiPostgresLiquibase(): SpringLiquibase = SpringLiquibase().apply { - dataSource = resolveDataSource(dataSources, primaryDataSource, okapiProperties) - changeLog = "classpath:com/softwaremill/okapi/db/changelog.xml" - databaseChangeLogTable = okapiProperties.liquibase.changelogTable - databaseChangeLogLockTable = okapiProperties.liquibase.changelogLockTable - } - } - - /** - * Logs a single startup warning when okapi's Liquibase auto-config is explicitly opted out - * (`okapi.liquibase.enabled=false`). Without this, a user who flipped the flag months ago - * has no breadcrumb when they later see "relation okapi_outbox does not exist" at first - * publish — the link to the opt-out is in the startup log, not the error. - */ - @Configuration(proxyBeanMethods = false) - @ConditionalOnProperty(prefix = "okapi.liquibase", name = ["enabled"], havingValue = "false") - class LiquibaseDisabledNotice { - init { - LIQUIBASE_DISABLED_LOGGER.warn( - "okapi.liquibase.enabled=false — okapi will NOT create or migrate the okapi_outbox schema. " + - "Ensure your application's migration tool applies " + - "classpath:com/softwaremill/okapi/db/changelog.xml " + - "(or classpath:com/softwaremill/okapi/db/mysql/changelog.xml for MySQL).", - ) - } - } - - /** - * Auto-configures okapi's MySQL Liquibase migration. See [PostgresLiquibaseConfiguration] - * for rationale on the class-level conditional placement. - */ - @Configuration(proxyBeanMethods = false) - @ConditionalOnClass(SpringLiquibase::class, MysqlOutboxStore::class) - @ConditionalOnProperty(prefix = "okapi.liquibase", name = ["enabled"], havingValue = "true", matchIfMissing = true) - class MysqlLiquibaseConfiguration( - private val dataSources: Map, - private val primaryDataSource: DataSource, - private val okapiProperties: OkapiProperties, - ) { - @Bean("okapiMysqlLiquibase") - @ConditionalOnMissingBean(name = ["okapiMysqlLiquibase"]) - fun okapiMysqlLiquibase(): SpringLiquibase = SpringLiquibase().apply { - dataSource = resolveDataSource(dataSources, primaryDataSource, okapiProperties) - changeLog = "classpath:com/softwaremill/okapi/db/mysql/changelog.xml" - databaseChangeLogTable = okapiProperties.liquibase.changelogTable - databaseChangeLogLockTable = okapiProperties.liquibase.changelogLockTable - } - } - companion object { internal fun resolveDataSource( dataSources: Map, diff --git a/okapi-spring-boot/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/okapi-spring-boot/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index e19af6d..264b2c9 100644 --- a/okapi-spring-boot/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/okapi-spring-boot/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,2 +1,3 @@ com.softwaremill.okapi.springboot.OutboxAutoConfiguration +com.softwaremill.okapi.springboot.OkapiLiquibaseAutoConfiguration com.softwaremill.okapi.springboot.OkapiMicrometerAutoConfiguration diff --git a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseAutoConfigurationTest.kt b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseAutoConfigurationTest.kt index fbc8389..5b51383 100644 --- a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseAutoConfigurationTest.kt +++ b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseAutoConfigurationTest.kt @@ -20,12 +20,14 @@ import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeSameInstanceAs import liquibase.integration.spring.SpringLiquibase import org.slf4j.LoggerFactory +import org.springframework.beans.factory.config.BeanPostProcessor import org.springframework.boot.autoconfigure.AutoConfigurations import org.springframework.boot.autoconfigure.AutoConfigureAfter import org.springframework.boot.autoconfigure.condition.ConditionalOnClass import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.test.context.FilteredClassLoader import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.core.annotation.AnnotatedElementUtils import org.springframework.jdbc.datasource.SimpleDriverDataSource import java.time.Instant import javax.sql.DataSource @@ -47,16 +49,16 @@ class LiquibaseAutoConfigurationTest : FunSpec({ val dataSources = mapOf("primary" to dataSource) fun postgresConfig(props: OkapiProperties = OkapiProperties()) = - OutboxAutoConfiguration.PostgresLiquibaseConfiguration(dataSources, dataSource, props) + OkapiLiquibaseAutoConfiguration.PostgresLiquibaseConfiguration(dataSources, dataSource, props) fun mysqlConfig(props: OkapiProperties = OkapiProperties()) = - OutboxAutoConfiguration.MysqlLiquibaseConfiguration(dataSources, dataSource, props) + OkapiLiquibaseAutoConfiguration.MysqlLiquibaseConfiguration(dataSources, dataSource, props) // Default contextRunner disables Liquibase to keep slice tests focused on bean wiring rather // than actual SpringLiquibase startup against a fake DataSource. Tests that exercise Liquibase // explicitly opt back in. val contextRunner = ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java)) + .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java, OkapiLiquibaseAutoConfiguration::class.java)) .withBean(OutboxStore::class.java, { stubStore() }) .withBean(MessageDeliverer::class.java, { stubDeliverer() }) .withBean(DataSource::class.java, { SimpleDriverDataSource() }) @@ -176,13 +178,13 @@ class LiquibaseAutoConfigurationTest : FunSpec({ // evaluate is LiquibaseDisabledNotice's @ConditionalOnProperty. ApplicationContextRunner() .withClassLoader(FilteredClassLoader(SpringLiquibase::class.java)) - .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java)) + .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java, OkapiLiquibaseAutoConfiguration::class.java)) .withBean(OutboxStore::class.java, { stubStore() }) .withBean(MessageDeliverer::class.java, { stubDeliverer() }) .withBean(DataSource::class.java, { SimpleDriverDataSource() }) .withPropertyValues("okapi.liquibase.enabled=true") .run { ctx -> - ctx.getBeansOfType(OutboxAutoConfiguration.LiquibaseDisabledNotice::class.java) + ctx.getBeansOfType(OkapiLiquibaseAutoConfiguration.LiquibaseDisabledNotice::class.java) .isEmpty() shouldBe true } } @@ -197,7 +199,7 @@ class LiquibaseAutoConfigurationTest : FunSpec({ // Captures the actual log line so deletion of the warn() body is also caught — not // just deletion of the class itself. val notice = LoggerFactory.getLogger( - "com.softwaremill.okapi.springboot.OutboxAutoConfiguration", + "com.softwaremill.okapi.springboot.OkapiLiquibaseAutoConfiguration", ) as Logger val appender = ListAppender().apply { start() } notice.addAppender(appender) @@ -205,13 +207,15 @@ class LiquibaseAutoConfigurationTest : FunSpec({ try { ApplicationContextRunner() .withClassLoader(FilteredClassLoader(SpringLiquibase::class.java)) - .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java)) + .withConfiguration( + AutoConfigurations.of(OutboxAutoConfiguration::class.java, OkapiLiquibaseAutoConfiguration::class.java), + ) .withBean(OutboxStore::class.java, { stubStore() }) .withBean(MessageDeliverer::class.java, { stubDeliverer() }) .withBean(DataSource::class.java, { SimpleDriverDataSource() }) .withPropertyValues("okapi.liquibase.enabled=false") .run { ctx -> - ctx.getBeansOfType(OutboxAutoConfiguration.LiquibaseDisabledNotice::class.java) + ctx.getBeansOfType(OkapiLiquibaseAutoConfiguration.LiquibaseDisabledNotice::class.java) .size shouldBe 1 } @@ -274,6 +278,7 @@ class LiquibaseAutoConfigurationTest : FunSpec({ OutboxAutoConfiguration::class.java, OutboxAutoConfiguration.PostgresStoreConfiguration::class.java, OutboxAutoConfiguration.MysqlStoreConfiguration::class.java, + OkapiLiquibaseAutoConfiguration::class.java, ) unguardedClasses.forEach { configClass -> withClue( @@ -290,8 +295,8 @@ class LiquibaseAutoConfigurationTest : FunSpec({ test("PostgresLiquibaseConfiguration / MysqlLiquibaseConfiguration are gated by @ConditionalOnClass(SpringLiquibase)") { listOf( - OutboxAutoConfiguration.PostgresLiquibaseConfiguration::class.java, - OutboxAutoConfiguration.MysqlLiquibaseConfiguration::class.java, + OkapiLiquibaseAutoConfiguration.PostgresLiquibaseConfiguration::class.java, + OkapiLiquibaseAutoConfiguration.MysqlLiquibaseConfiguration::class.java, ).forEach { configClass -> val annotation = configClass.getAnnotation(ConditionalOnClass::class.java) withClue( @@ -315,8 +320,14 @@ class LiquibaseAutoConfigurationTest : FunSpec({ // 2. Runtime classpath check: at least one declared name resolves IF Spring Boot's // Liquibase autoconfig is on the classpath at all (3.5.x ships it in // spring-boot-autoconfigure; 4.0.x does not). - test("@AutoConfigureAfter on OutboxAutoConfiguration is structurally sound and resolvable when applicable") { - val annotation = OutboxAutoConfiguration::class.java.getAnnotation(AutoConfigureAfter::class.java) + test("@AutoConfigureAfter on OkapiLiquibaseAutoConfiguration is structurally sound and resolvable when applicable") { + // Use AnnotatedElementUtils so the @AutoConfigureAfter meta-annotation declared on + // @AutoConfiguration (and aliased via @AliasFor) is also picked up — JDK's plain + // getAnnotation(...) only looks at direct annotations. + val annotation = AnnotatedElementUtils.findMergedAnnotation( + OkapiLiquibaseAutoConfiguration::class.java, + AutoConfigureAfter::class.java, + ) annotation.shouldNotBeNull() val declaredNames = annotation.name.toList() @@ -324,7 +335,7 @@ class LiquibaseAutoConfigurationTest : FunSpec({ declaredNames.shouldNotBeEmpty() } - val classLoader = OutboxAutoConfiguration::class.java.classLoader + val classLoader = OkapiLiquibaseAutoConfiguration::class.java.classLoader val knownSpringBootLiquibasePaths = listOf( "org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration", "org.springframework.boot.liquibase.autoconfigure.LiquibaseAutoConfiguration", @@ -341,7 +352,7 @@ class LiquibaseAutoConfigurationTest : FunSpec({ withClue( "@AutoConfigureAfter declares $declaredNames but none resolve on this Spring Boot runtime " + "($resolvableSpringBootPaths is on the classpath); the ordering hint is silently ignored " + - "and OutboxAutoConfiguration may run before Spring Boot's LiquibaseAutoConfiguration, " + + "and OkapiLiquibaseAutoConfiguration may run before Spring Boot's LiquibaseAutoConfiguration, " + "shadowing the host application's liquibase bean.", ) { resolvableDeclaredNames.shouldNotBeEmpty() @@ -356,7 +367,7 @@ class LiquibaseAutoConfigurationTest : FunSpec({ // These tests pin the architectural decision against that single-token regression. test("okapiPostgresLiquibase factory uses @ConditionalOnMissingBean(name = ...)") { - val method = OutboxAutoConfiguration.PostgresLiquibaseConfiguration::class.java + val method = OkapiLiquibaseAutoConfiguration.PostgresLiquibaseConfiguration::class.java .getDeclaredMethod("okapiPostgresLiquibase") val cond = method.getAnnotation(ConditionalOnMissingBean::class.java) cond.shouldNotBeNull() @@ -371,7 +382,7 @@ class LiquibaseAutoConfigurationTest : FunSpec({ } test("okapiMysqlLiquibase factory uses @ConditionalOnMissingBean(name = ...)") { - val method = OutboxAutoConfiguration.MysqlLiquibaseConfiguration::class.java + val method = OkapiLiquibaseAutoConfiguration.MysqlLiquibaseConfiguration::class.java .getDeclaredMethod("okapiMysqlLiquibase") val cond = method.getAnnotation(ConditionalOnMissingBean::class.java) cond.shouldNotBeNull() @@ -379,6 +390,59 @@ class LiquibaseAutoConfigurationTest : FunSpec({ cond.value.toList().shouldBeEmpty() } + test("dual-module classpath: only ONE okapi*Liquibase bean activates — matching OutboxStore winner") { + // Pins the OutboxStore-precedence contract for Liquibase auto-config (issue #38 + // / KOJAK-80). Both `okapi-postgres` and `okapi-mysql` are on the test classpath + // (build.gradle.kts:35-36). The `*OutboxStore` factories share + // `@ConditionalOnMissingBean(OutboxStore::class)`, so exactly ONE store bean wins. + // The Liquibase configs MUST mirror that precedence: registering both + // `okapiPostgresLiquibase` and `okapiMysqlLiquibase` against the same DataSource + // would let the second-evaluated Liquibase apply wrong-engine DDL at startup and + // fail (duplicate index, wrong-engine syntax, or shared tracking-table collisions). + // + // The production fix lives in [OkapiLiquibaseAutoConfiguration] — a separate + // `@AutoConfiguration(after = OutboxAutoConfiguration)` so that the per-engine + // `@ConditionalOnBean(OutboxStore)` gates fire AFTER the store factories have + // registered their winning bean. Within a single auto-config those gates would + // evaluate before sibling beans are visible and would always skip. + // + // SuppressSpringLiquibaseRun prevents afterPropertiesSet() from trying to migrate + // a fake DataSource — we're asserting bean activation, not migration behaviour. + ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java, OkapiLiquibaseAutoConfiguration::class.java)) + .withBean(MessageDeliverer::class.java, { stubDeliverer() }) + .withBean(DataSource::class.java, { SimpleDriverDataSource() }) + .withInitializer { ctx -> + ctx.beanFactory.addBeanPostProcessor(SuppressSpringLiquibaseRun()) + } + .run { ctx -> + ctx.startupFailure shouldBe null + + val storeBean = ctx.getBean(OutboxStore::class.java) + val expected = when (storeBean) { + is com.softwaremill.okapi.postgres.PostgresOutboxStore -> "okapiPostgresLiquibase" + is com.softwaremill.okapi.mysql.MysqlOutboxStore -> "okapiMysqlLiquibase" + else -> error("unexpected OutboxStore type ${storeBean::class}") + } + val active = listOf("okapiPostgresLiquibase", "okapiMysqlLiquibase") + .filter { ctx.containsBean(it) } + + // Diagnostic: when the assertion fails, surface what each registered + // SpringLiquibase bean would have done (changelog path + dataSource + // identity) so the reader sees concretely why dual activation is broken. + val diagnostic = active.joinToString("\n") { name -> + val bean = ctx.getBean(name, SpringLiquibase::class.java) + " $name → changelog=${bean.changeLog}, dataSource=${System.identityHashCode(bean.dataSource)}" + } + withClue( + "Active OutboxStore is ${storeBean::class.simpleName}; expected exactly the " + + "matching Liquibase bean ($expected) to activate, but found: $active\n$diagnostic", + ) { + active shouldBe listOf(expected) + } + } + } + test("user @Bean(\"okapiPostgresLiquibase\") replaces okapi's default bean") { // Pins that the documented override mechanism actually works: a host app supplying // its own bean by the well-known name takes precedence over okapi's auto-configured @@ -394,7 +458,7 @@ class LiquibaseAutoConfigurationTest : FunSpec({ val userBean = SpringLiquibase().apply { setShouldRun(false) } ApplicationContextRunner() .withClassLoader(FilteredClassLoader(com.softwaremill.okapi.mysql.MysqlOutboxStore::class.java)) - .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java)) + .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java, OkapiLiquibaseAutoConfiguration::class.java)) .withBean(OutboxStore::class.java, { stubStore() }) .withBean(MessageDeliverer::class.java, { stubDeliverer() }) .withBean(DataSource::class.java, { SimpleDriverDataSource() }) @@ -414,6 +478,18 @@ class LiquibaseAutoConfigurationTest : FunSpec({ private fun ApplicationContextRunner.withOkapiLiquibaseDisabled(): ApplicationContextRunner = withPropertyValues("okapi.liquibase.enabled=false") +/** + * Disables [SpringLiquibase.afterPropertiesSet] migration runs by flipping `shouldRun=false` + * before init methods fire. Lets dual-activation tests inspect registered beans without + * actually trying to apply a changelog against a fake DataSource. + */ +private class SuppressSpringLiquibaseRun : BeanPostProcessor { + override fun postProcessBeforeInitialization(bean: Any, beanName: String): Any { + if (bean is SpringLiquibase) bean.setShouldRun(false) + return bean + } +} + private fun canLoadClass(fqcn: String, classLoader: ClassLoader): Boolean = try { Class.forName(fqcn, false, classLoader) true diff --git a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseE2ETest.kt b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseE2ETest.kt index 2364f8e..6ead6d0 100644 --- a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseE2ETest.kt +++ b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseE2ETest.kt @@ -10,6 +10,7 @@ import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldNotContain import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe import liquibase.integration.spring.SpringLiquibase import org.postgresql.ds.PGSimpleDataSource import org.springframework.beans.factory.support.BeanDefinitionBuilder @@ -70,13 +71,17 @@ class LiquibaseE2ETest : FunSpec({ } } - // Hide MysqlOutboxStore from the classpath: both `okapi-postgres` and `okapi-mysql` are on - // the test classpath, and MysqlStoreConfiguration would otherwise activate first - // (alphabetical M < P) and create the wrong outboxStore bean type for okapiPostgresLiquibase - // to inject. + // Hide MysqlOutboxStore from the classpath so that PostgresStoreConfiguration is the only + // store factory that activates and `okapiPostgresLiquibase` is the only Liquibase bean + // that registers (its `@ConditionalOnBean(PostgresOutboxStore)` gate matches the winner). + // Without this filter the OutboxStore precedence in the test JVM is non-deterministic + // between Postgres and MySQL, and these tests need to deterministically exercise the + // Postgres path against a real Postgres DataSource. The dual-module coexistence (both + // modules visible, only the matching Liquibase activates) is covered by + // ["both okapi-postgres and okapi-mysql on classpath: exactly one okapi*Liquibase activates..."]. fun runner(ds: DataSource) = ApplicationContextRunner() .withClassLoader(FilteredClassLoader(MysqlOutboxStore::class.java)) - .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java)) + .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java, OkapiLiquibaseAutoConfiguration::class.java)) .withBean(MessageDeliverer::class.java, { stubDeliverer() }) .withBean(DataSource::class.java, { ds }) .withPropertyValues( @@ -86,6 +91,35 @@ class LiquibaseE2ETest : FunSpec({ beforeEach { resetSchema() } + test("both okapi-postgres and okapi-mysql on classpath: exactly one okapi*Liquibase activates against a real Postgres database") { + // Regression test for issue #38 / KOJAK-80. Before the per-engine + // @ConditionalOnBean(OutboxStore) gate on each *LiquibaseConfiguration, both + // `okapiPostgresLiquibase` and `okapiMysqlLiquibase` registered against the same + // DataSource and the second-evaluated Liquibase failed at startup with a + // duplicate-object error from the wrong-engine changelog + // (e.g. ERROR: relation "idx_okapi_outbox_status_last_attempt" already exists). + // + // No FilteredClassLoader here — both `okapi-postgres` and `okapi-mysql` are visible + // on the runtime classpath, mirroring a real consumer that pulls in both modules + // (intentionally or transitively). + val ds = dataSource() + + ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java, OkapiLiquibaseAutoConfiguration::class.java)) + .withBean(MessageDeliverer::class.java, { stubDeliverer() }) + .withBean(DataSource::class.java, { ds }) + .withPropertyValues( + "okapi.processor.enabled=false", + "okapi.purger.enabled=false", + ) + .run { ctx -> + ctx.startupFailure.shouldBeNull() + val activeLiquibase = listOf("okapiPostgresLiquibase", "okapiMysqlLiquibase") + .filter { ctx.containsBean(it) } + activeLiquibase.size shouldBe 1 + } + } + test("autoconfig creates okapi_databasechangelog and runs okapi migrations") { val ds = dataSource() @@ -152,6 +186,7 @@ class LiquibaseE2ETest : FunSpec({ .withConfiguration( AutoConfigurations.of( OutboxAutoConfiguration::class.java, + OkapiLiquibaseAutoConfiguration::class.java, springBootLiquibaseAutoConfig, ), ) @@ -210,7 +245,7 @@ class LiquibaseE2ETest : FunSpec({ ApplicationContextRunner() .withClassLoader(FilteredClassLoader(MysqlOutboxStore::class.java)) - .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java)) + .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java, OkapiLiquibaseAutoConfiguration::class.java)) .withBean(MessageDeliverer::class.java, { stubDeliverer() }) .withInitializer { context -> val bd = BeanDefinitionBuilder.genericBeanDefinition(DataSource::class.java) { primaryDs } @@ -272,7 +307,7 @@ class LiquibaseE2ETest : FunSpec({ // try to run Postgres-specific Liquibase changesets against this MySQL container. fun runner(ds: DataSource) = ApplicationContextRunner() .withClassLoader(FilteredClassLoader(PostgresOutboxStore::class.java)) - .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java)) + .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java, OkapiLiquibaseAutoConfiguration::class.java)) .withBean(MessageDeliverer::class.java, { stubDeliverer() }) .withBean(DataSource::class.java, { ds }) .withPropertyValues( @@ -334,6 +369,7 @@ class LiquibaseE2ETest : FunSpec({ .withConfiguration( AutoConfigurations.of( OutboxAutoConfiguration::class.java, + OkapiLiquibaseAutoConfiguration::class.java, springBootLiquibaseAutoConfig, ), ) From 9b9375b3866451219f92f8aba6e308657f6cbb79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20Kobyli=C5=84ski?= Date: Thu, 14 May 2026 12:26:37 +0200 Subject: [PATCH 3/7] =?UTF-8?q?chore:=20address=20review=20nits=20?= =?UTF-8?q?=E2=80=94=20drop=20dead=20liquibase-disabled=20props,=20add=20m?= =?UTF-8?q?etadata=20group?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `okapi.liquibase.enabled=false` from slice tests that no longer register OkapiLiquibaseAutoConfiguration (OutboxProcessor / OutboxPurger / DataSourceQualifier auto-config tests). The property was dead code after splitting Liquibase out of OutboxAutoConfiguration. - Add the missing `okapi.liquibase` group entry to spring-configuration-metadata.json so IDE tooling surfaces the binding type (OkapiProperties$Liquibase) and a group-level description, matching the existing `okapi.purger` / `okapi.processor` / `okapi.metrics` group entries. --- .../resources/META-INF/spring-configuration-metadata.json | 5 +++++ .../springboot/DataSourceQualifierAutoConfigurationTest.kt | 1 - .../springboot/OutboxProcessorAutoConfigurationTest.kt | 6 ------ .../okapi/springboot/OutboxPurgerAutoConfigurationTest.kt | 1 - 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/okapi-spring-boot/src/main/resources/META-INF/spring-configuration-metadata.json b/okapi-spring-boot/src/main/resources/META-INF/spring-configuration-metadata.json index 1ac1771..23d5a8a 100644 --- a/okapi-spring-boot/src/main/resources/META-INF/spring-configuration-metadata.json +++ b/okapi-spring-boot/src/main/resources/META-INF/spring-configuration-metadata.json @@ -19,6 +19,11 @@ "name": "okapi.metrics", "type": "com.softwaremill.okapi.springboot.OkapiMetricsProperties", "description": "Okapi Micrometer metrics configuration (okapi-micrometer module)." + }, + { + "name": "okapi.liquibase", + "type": "com.softwaremill.okapi.springboot.OkapiProperties$Liquibase", + "description": "Liquibase auto-configuration settings for okapi's bundled migrations." } ], "properties": [ diff --git a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/DataSourceQualifierAutoConfigurationTest.kt b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/DataSourceQualifierAutoConfigurationTest.kt index 01c875f..8bb5c15 100644 --- a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/DataSourceQualifierAutoConfigurationTest.kt +++ b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/DataSourceQualifierAutoConfigurationTest.kt @@ -23,7 +23,6 @@ class DataSourceQualifierAutoConfigurationTest : FunSpec({ .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java)) .withBean(OutboxStore::class.java, { stubStore() }) .withBean(MessageDeliverer::class.java, { stubDeliverer() }) - .withPropertyValues("okapi.liquibase.enabled=false") test("no qualifier set, single datasource — uses that datasource") { val ds = SimpleDriverDataSource() diff --git a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxProcessorAutoConfigurationTest.kt b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxProcessorAutoConfigurationTest.kt index 8939f00..38a60ac 100644 --- a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxProcessorAutoConfigurationTest.kt +++ b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxProcessorAutoConfigurationTest.kt @@ -30,9 +30,6 @@ class OutboxProcessorAutoConfigurationTest : FunSpec({ .withBean(OutboxStore::class.java, { stubStore() }) .withBean(MessageDeliverer::class.java, { stubDeliverer() }) .withBean(DataSource::class.java, { SimpleDriverDataSource() }) - // Liquibase migration is exercised end-to-end against real DBs; disable it here so slice - // tests don't try to run okapi's Postgres changelog against a fake DataSource. - .withPropertyValues("okapi.liquibase.enabled=false") test("processor bean is created by default") { contextRunner.run { ctx -> @@ -162,9 +159,6 @@ class OutboxProcessorAutoConfigurationTest : FunSpec({ .withBean(OutboxStore::class.java, { stubStore() }) .withBean(MessageDeliverer::class.java, { stubDeliverer() }) .withBean(DataSource::class.java, { SimpleDriverDataSource() }) - // Disable okapi's Liquibase so it doesn't try to run against the fake DataSource — - // this test isolates the metrics auto-config ordering, not Liquibase. - .withPropertyValues("okapi.liquibase.enabled=false") .run { ctx -> ctx.getBean(io.micrometer.core.instrument.MeterRegistry::class.java).shouldNotBeNull() ctx.getBean(MicrometerOutboxListener::class.java).shouldNotBeNull() diff --git a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxPurgerAutoConfigurationTest.kt b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxPurgerAutoConfigurationTest.kt index 73f1a40..9c89735 100644 --- a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxPurgerAutoConfigurationTest.kt +++ b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxPurgerAutoConfigurationTest.kt @@ -25,7 +25,6 @@ class OutboxPurgerAutoConfigurationTest : FunSpec({ .withBean(OutboxStore::class.java, { stubStore() }) .withBean(MessageDeliverer::class.java, { stubDeliverer() }) .withBean(DataSource::class.java, { SimpleDriverDataSource() }) - .withPropertyValues("okapi.liquibase.enabled=false") test("purger bean is created by default") { contextRunner.run { ctx -> From 5055a7469c18b29c165ef78b5d36f56ed4035bce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20Kobyli=C5=84ski?= Date: Thu, 14 May 2026 12:29:19 +0200 Subject: [PATCH 4/7] test: user-override test now actually exercises @ConditionalOnMissingBean(name) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the test pre-registered a stub OutboxStore bean. That satisfied @ConditionalOnMissingBean(OutboxStore::class) on PostgresStoreConfiguration, so no PostgresOutboxStore was created. PostgresLiquibaseConfiguration's class-level @ConditionalOnBean(PostgresOutboxStore::class) then skipped the whole class — and okapiPostgresLiquibase()'s method-level @ConditionalOnMissingBean was never even evaluated. The test passed for the wrong reason. Drop the stub so the auto-config registers a real PostgresOutboxStore; add SuppressSpringLiquibaseRun so SpringLiquibase doesn't try to migrate the fake DataSource. Assert that OutboxStore is the real Postgres impl to make the class-level gate's success explicit, then verify the method-level override behaviour as before. Also fix the inline comment — claimed type-based @ConditionalOnMissingBean would produce two SpringLiquibase beans, but type-based on SpringLiquibase would also see the user's bean and skip okapi's, giving one bean. The reflection-based meta-test on the same file is what actually pins name-based vs type-based; this runtime test verifies the override path. --- .../LiquibaseAutoConfigurationTest.kt | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseAutoConfigurationTest.kt b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseAutoConfigurationTest.kt index 5b51383..6fe7584 100644 --- a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseAutoConfigurationTest.kt +++ b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseAutoConfigurationTest.kt @@ -17,6 +17,7 @@ import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldNotBeEmpty import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf import io.kotest.matchers.types.shouldBeSameInstanceAs import liquibase.integration.spring.SpringLiquibase import org.slf4j.LoggerFactory @@ -446,26 +447,37 @@ class LiquibaseAutoConfigurationTest : FunSpec({ test("user @Bean(\"okapiPostgresLiquibase\") replaces okapi's default bean") { // Pins that the documented override mechanism actually works: a host app supplying // its own bean by the well-known name takes precedence over okapi's auto-configured - // factory (because @ConditionalOnMissingBean(name = "okapiPostgresLiquibase") sees - // the user's definition and skips okapi's). Switching to type-based - // @ConditionalOnMissingBean(SpringLiquibase) silently breaks this — the user's bean - // still registers, but okapi's also registers, producing two SpringLiquibase beans. + // factory because of @ConditionalOnMissingBean(name = "okapiPostgresLiquibase") on + // okapiPostgresLiquibase(). // - // setShouldRun(false) prevents the user's bean from actually running Liquibase - // against the fake DataSource. MysqlOutboxStore is hidden via FilteredClassLoader - // so the MySQL Liquibase config class is also skipped (otherwise it would try to - // run okapi's MySQL changelog against the fake DataSource). + // To make this test actually exercise the method-level @ConditionalOnMissingBean, + // we must let the auto-config register a real PostgresOutboxStore — otherwise the + // class-level @ConditionalOnBean(PostgresOutboxStore::class) on + // PostgresLiquibaseConfiguration would skip the entire class before the method-level + // gate is ever evaluated, and the test would pass for the wrong reason. + // + // MysqlOutboxStore is hidden via FilteredClassLoader so MysqlLiquibaseConfiguration's + // gate fails (no MysqlOutboxStore class available) and only PostgresLiquibase is in + // scope. SuppressSpringLiquibaseRun + setShouldRun(false) prevent any SpringLiquibase + // bean from actually running migrations against the fake DataSource. val userBean = SpringLiquibase().apply { setShouldRun(false) } ApplicationContextRunner() .withClassLoader(FilteredClassLoader(com.softwaremill.okapi.mysql.MysqlOutboxStore::class.java)) .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java, OkapiLiquibaseAutoConfiguration::class.java)) - .withBean(OutboxStore::class.java, { stubStore() }) .withBean(MessageDeliverer::class.java, { stubDeliverer() }) .withBean(DataSource::class.java, { SimpleDriverDataSource() }) .withBean("okapiPostgresLiquibase", SpringLiquibase::class.java, { userBean }) + .withInitializer { ctx -> + ctx.beanFactory.addBeanPostProcessor(SuppressSpringLiquibaseRun()) + } // okapi.liquibase.enabled defaults to true (no opt-out): the override decision // must come from @ConditionalOnMissingBean, not from the property. .run { ctx -> + // The auto-config registered a real PostgresOutboxStore — confirms that the + // class-level @ConditionalOnBean(PostgresOutboxStore::class) gate passed and + // the method-level @ConditionalOnMissingBean was actually evaluated. + ctx.getBean(OutboxStore::class.java).shouldBeInstanceOf() + ctx.getBean("okapiPostgresLiquibase", SpringLiquibase::class.java) shouldBeSameInstanceAs userBean // Exactly one SpringLiquibase bean — the user's. okapi's factory must have been // skipped by @ConditionalOnMissingBean(name = "okapiPostgresLiquibase"). From 766301b92a0c3d5f28eae5a67b0a8caf8b9adcf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20Kobyli=C5=84ski?= Date: Thu, 14 May 2026 13:27:30 +0200 Subject: [PATCH 5/7] refactor: move Postgres changelog under com/softwaremill/okapi/db/postgres/ for engine-symmetric layout Postgres lived at com/softwaremill/okapi/db/changelog.xml while MySQL was under com/softwaremill/okapi/db/mysql/changelog.xml. Asymmetric. Now both engines live under their own subdirectory: com/softwaremill/okapi/db/postgres/changelog.xml com/softwaremill/okapi/db/mysql/changelog.xml Future engines (e.g. db/oracle/, db/mssql/) slot in cleanly. Updates the changeLog string in OkapiLiquibaseAutoConfiguration, the LiquibaseDisabledNotice WARN message, README, spring-configuration-metadata description, and all test / integration-test / benchmark support code that included the changelog via classpath path. The Postgres changelog.xml uses relativeToChangelogFile="true" for its SQL includes, so moving the directory does not require updates inside the XML itself. --- README.md | 2 +- .../okapi/benchmarks/support/PostgresBenchmarkSupport.kt | 2 +- .../softwaremill/okapi/test/support/PostgresTestSupport.kt | 2 +- .../okapi/test/transaction/ConnectionLeakProofTest.kt | 2 +- .../okapi/test/transaction/MultiDataSourceTransactionTest.kt | 2 +- .../okapi/db/{ => postgres}/001__create_outbox_table.sql | 0 .../okapi/db/{ => postgres}/002__add_delivery_type_column.sql | 0 .../okapi/db/{ => postgres}/003__add_purger_index.sql | 0 .../okapi/db/{ => postgres}/004__add_claim_index.sql | 0 .../com/softwaremill/okapi/db/{ => postgres}/changelog.xml | 0 .../okapi/springboot/OkapiLiquibaseAutoConfiguration.kt | 4 ++-- .../resources/META-INF/spring-configuration-metadata.json | 2 +- .../okapi/springboot/LiquibaseAutoConfigurationTest.kt | 2 +- 13 files changed, 9 insertions(+), 9 deletions(-) rename okapi-postgres/src/main/resources/com/softwaremill/okapi/db/{ => postgres}/001__create_outbox_table.sql (100%) rename okapi-postgres/src/main/resources/com/softwaremill/okapi/db/{ => postgres}/002__add_delivery_type_column.sql (100%) rename okapi-postgres/src/main/resources/com/softwaremill/okapi/db/{ => postgres}/003__add_purger_index.sql (100%) rename okapi-postgres/src/main/resources/com/softwaremill/okapi/db/{ => postgres}/004__add_claim_index.sql (100%) rename okapi-postgres/src/main/resources/com/softwaremill/okapi/db/{ => postgres}/changelog.xml (100%) diff --git a/README.md b/README.md index bb417a5..40583cd 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Okapi implements the [transactional outbox pattern](https://softwaremill.com/mic Okapi ships Liquibase changelogs that create the outbox table and its indexes: -- `classpath:com/softwaremill/okapi/db/changelog.xml` — PostgreSQL (from `okapi-postgres`) +- `classpath:com/softwaremill/okapi/db/postgres/changelog.xml` — PostgreSQL (from `okapi-postgres`) - `classpath:com/softwaremill/okapi/db/mysql/changelog.xml` — MySQL (from `okapi-mysql`) When `okapi-spring-boot` is on the classpath, these run automatically against the configured `DataSource` on application startup. Without Spring Boot, point your own Liquibase setup at the paths above and pass an `outboxTable` change-log parameter (see below). diff --git a/okapi-benchmarks/src/jmh/kotlin/com/softwaremill/okapi/benchmarks/support/PostgresBenchmarkSupport.kt b/okapi-benchmarks/src/jmh/kotlin/com/softwaremill/okapi/benchmarks/support/PostgresBenchmarkSupport.kt index a97e5a7..1e1dd4e 100644 --- a/okapi-benchmarks/src/jmh/kotlin/com/softwaremill/okapi/benchmarks/support/PostgresBenchmarkSupport.kt +++ b/okapi-benchmarks/src/jmh/kotlin/com/softwaremill/okapi/benchmarks/support/PostgresBenchmarkSupport.kt @@ -47,7 +47,7 @@ class PostgresBenchmarkSupport { private fun runLiquibase() { val connection = DriverManager.getConnection(container.jdbcUrl, container.username, container.password) val db = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(JdbcConnection(connection)) - Liquibase("com/softwaremill/okapi/db/changelog.xml", ClassLoaderResourceAccessor(), db).use { it.update("") } + Liquibase("com/softwaremill/okapi/db/postgres/changelog.xml", ClassLoaderResourceAccessor(), db).use { it.update("") } connection.close() } } diff --git a/okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/support/PostgresTestSupport.kt b/okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/support/PostgresTestSupport.kt index 1d9bc45..5a5b371 100644 --- a/okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/support/PostgresTestSupport.kt +++ b/okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/support/PostgresTestSupport.kt @@ -40,7 +40,7 @@ class PostgresTestSupport { private fun runLiquibase() { val connection = DriverManager.getConnection(container.jdbcUrl, container.username, container.password) val db = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(JdbcConnection(connection)) - Liquibase("com/softwaremill/okapi/db/changelog.xml", ClassLoaderResourceAccessor(), db).use { it.update("") } + Liquibase("com/softwaremill/okapi/db/postgres/changelog.xml", ClassLoaderResourceAccessor(), db).use { it.update("") } connection.close() } } diff --git a/okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/transaction/ConnectionLeakProofTest.kt b/okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/transaction/ConnectionLeakProofTest.kt index cba7c23..ee57036 100644 --- a/okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/transaction/ConnectionLeakProofTest.kt +++ b/okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/transaction/ConnectionLeakProofTest.kt @@ -105,6 +105,6 @@ class ConnectionLeakProofTest : FunSpec({ private fun runLiquibase(container: PostgreSQLContainer) { DriverManager.getConnection(container.jdbcUrl, container.username, container.password).use { connection -> val db = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(JdbcConnection(connection)) - Liquibase("com/softwaremill/okapi/db/changelog.xml", ClassLoaderResourceAccessor(), db).use { it.update("") } + Liquibase("com/softwaremill/okapi/db/postgres/changelog.xml", ClassLoaderResourceAccessor(), db).use { it.update("") } } } diff --git a/okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/transaction/MultiDataSourceTransactionTest.kt b/okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/transaction/MultiDataSourceTransactionTest.kt index 0f77226..bf6396e 100644 --- a/okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/transaction/MultiDataSourceTransactionTest.kt +++ b/okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/transaction/MultiDataSourceTransactionTest.kt @@ -139,6 +139,6 @@ class MultiDataSourceTransactionTest : FunSpec({ private fun runLiquibase(container: PostgreSQLContainer) { val connection = DriverManager.getConnection(container.jdbcUrl, container.username, container.password) val db = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(JdbcConnection(connection)) - Liquibase("com/softwaremill/okapi/db/changelog.xml", ClassLoaderResourceAccessor(), db).use { it.update("") } + Liquibase("com/softwaremill/okapi/db/postgres/changelog.xml", ClassLoaderResourceAccessor(), db).use { it.update("") } connection.close() } diff --git a/okapi-postgres/src/main/resources/com/softwaremill/okapi/db/001__create_outbox_table.sql b/okapi-postgres/src/main/resources/com/softwaremill/okapi/db/postgres/001__create_outbox_table.sql similarity index 100% rename from okapi-postgres/src/main/resources/com/softwaremill/okapi/db/001__create_outbox_table.sql rename to okapi-postgres/src/main/resources/com/softwaremill/okapi/db/postgres/001__create_outbox_table.sql diff --git a/okapi-postgres/src/main/resources/com/softwaremill/okapi/db/002__add_delivery_type_column.sql b/okapi-postgres/src/main/resources/com/softwaremill/okapi/db/postgres/002__add_delivery_type_column.sql similarity index 100% rename from okapi-postgres/src/main/resources/com/softwaremill/okapi/db/002__add_delivery_type_column.sql rename to okapi-postgres/src/main/resources/com/softwaremill/okapi/db/postgres/002__add_delivery_type_column.sql diff --git a/okapi-postgres/src/main/resources/com/softwaremill/okapi/db/003__add_purger_index.sql b/okapi-postgres/src/main/resources/com/softwaremill/okapi/db/postgres/003__add_purger_index.sql similarity index 100% rename from okapi-postgres/src/main/resources/com/softwaremill/okapi/db/003__add_purger_index.sql rename to okapi-postgres/src/main/resources/com/softwaremill/okapi/db/postgres/003__add_purger_index.sql diff --git a/okapi-postgres/src/main/resources/com/softwaremill/okapi/db/004__add_claim_index.sql b/okapi-postgres/src/main/resources/com/softwaremill/okapi/db/postgres/004__add_claim_index.sql similarity index 100% rename from okapi-postgres/src/main/resources/com/softwaremill/okapi/db/004__add_claim_index.sql rename to okapi-postgres/src/main/resources/com/softwaremill/okapi/db/postgres/004__add_claim_index.sql diff --git a/okapi-postgres/src/main/resources/com/softwaremill/okapi/db/changelog.xml b/okapi-postgres/src/main/resources/com/softwaremill/okapi/db/postgres/changelog.xml similarity index 100% rename from okapi-postgres/src/main/resources/com/softwaremill/okapi/db/changelog.xml rename to okapi-postgres/src/main/resources/com/softwaremill/okapi/db/postgres/changelog.xml diff --git a/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OkapiLiquibaseAutoConfiguration.kt b/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OkapiLiquibaseAutoConfiguration.kt index 1e12f79..1818280 100644 --- a/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OkapiLiquibaseAutoConfiguration.kt +++ b/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OkapiLiquibaseAutoConfiguration.kt @@ -90,7 +90,7 @@ class OkapiLiquibaseAutoConfiguration { @ConditionalOnMissingBean(name = ["okapiPostgresLiquibase"]) fun okapiPostgresLiquibase(): SpringLiquibase = SpringLiquibase().apply { dataSource = OutboxAutoConfiguration.resolveDataSource(dataSources, primaryDataSource, okapiProperties) - changeLog = "classpath:com/softwaremill/okapi/db/changelog.xml" + changeLog = "classpath:com/softwaremill/okapi/db/postgres/changelog.xml" databaseChangeLogTable = okapiProperties.liquibase.changelogTable databaseChangeLogLockTable = okapiProperties.liquibase.changelogLockTable } @@ -132,7 +132,7 @@ class OkapiLiquibaseAutoConfiguration { LIQUIBASE_DISABLED_LOGGER.warn( "okapi.liquibase.enabled=false — okapi will NOT create or migrate the okapi_outbox schema. " + "Ensure your application's migration tool applies " + - "classpath:com/softwaremill/okapi/db/changelog.xml " + + "classpath:com/softwaremill/okapi/db/postgres/changelog.xml " + "(or classpath:com/softwaremill/okapi/db/mysql/changelog.xml for MySQL).", ) } diff --git a/okapi-spring-boot/src/main/resources/META-INF/spring-configuration-metadata.json b/okapi-spring-boot/src/main/resources/META-INF/spring-configuration-metadata.json index 23d5a8a..78305fe 100644 --- a/okapi-spring-boot/src/main/resources/META-INF/spring-configuration-metadata.json +++ b/okapi-spring-boot/src/main/resources/META-INF/spring-configuration-metadata.json @@ -91,7 +91,7 @@ "name": "okapi.liquibase.enabled", "type": "java.lang.Boolean", "defaultValue": true, - "description": "Whether okapi's bundled Liquibase migration runs on startup. Set to false when the host application includes okapi's changelog from its own master changelog (in which case the application is responsible for applying classpath:com/softwaremill/okapi/db/changelog.xml or the MySQL equivalent)." + "description": "Whether okapi's bundled Liquibase migration runs on startup. Set to false when the host application includes okapi's changelog from its own master changelog (in which case the application is responsible for applying classpath:com/softwaremill/okapi/db/postgres/changelog.xml or the MySQL equivalent)." }, { "name": "okapi.liquibase.changelog-table", diff --git a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseAutoConfigurationTest.kt b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseAutoConfigurationTest.kt index 6fe7584..f31ec54 100644 --- a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseAutoConfigurationTest.kt +++ b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/LiquibaseAutoConfigurationTest.kt @@ -229,7 +229,7 @@ class LiquibaseAutoConfigurationTest : FunSpec({ warnEvents.size shouldBe 1 val message = warnEvents.single().formattedMessage message stringShouldContain "okapi.liquibase.enabled=false" - message stringShouldContain "okapi/db/changelog.xml" + message stringShouldContain "okapi/db/postgres/changelog.xml" } } finally { notice.detachAppender(appender) From e5e0e0129e836116cc9d442041e51283ee83d46a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20Kobyli=C5=84ski?= Date: Thu, 14 May 2026 13:40:54 +0200 Subject: [PATCH 6/7] docs(README): drop internal Jira link, point at public GitHub issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README referenced the SoftwareMill-internal KOJAK-14 Jira epic for performance roadmap tracking. Public docs shouldn't link to private trackers — external readers can't follow the link. Replace with the public GitHub issues list, which serves the same purpose for the intended audience. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 40583cd..419c90d 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ Throughput baseline (single instance, sync sequential delivery, MacBook M3 Max, | HTTP @ webhook latency 20 ms | ~33 msg/s | ~36 msg/s | | HTTP @ webhook latency 100 ms | ~9 msg/s | ~9 msg/s | -These numbers reflect the current sync-sequential delivery model. Throughput is bounded by per-message round-trip time × batch size. Performance work to lift these limits (async batch delivery, multi-threaded scheduler) is tracked under the [KOJAK-14 epic](https://softwaremill.atlassian.net/browse/KOJAK-14). +These numbers reflect the current sync-sequential delivery model. Throughput is bounded by per-message round-trip time × batch size. Performance work to lift these limits (async batch delivery, multi-threaded scheduler) is tracked in the [project issues](https://github.com/softwaremill/okapi/issues). Full methodology, raw JMH results, and reproduction instructions: [`benchmarks/`](benchmarks/). From be7df98ce0075f1be77a28d04a3f39516e6d0c91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20Kobyli=C5=84ski?= Date: Thu, 14 May 2026 13:48:39 +0200 Subject: [PATCH 7/7] docs(README): drop unverifiable performance-roadmap claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous text claimed performance work was "tracked in [project issues]", but the public okapi GitHub repo has no issues for async batch delivery or multi-threaded scheduler — that work lives in a private tracker. Dropping the sentence rather than pointing at an empty issue list. The Performance section retains its value: throughput baseline table, sync-sequential model context, and pointer to the benchmarks module for reproducibility. Future-work descriptions will land in release notes / CHANGELOG when each item ships. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 419c90d..1130bb2 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ Throughput baseline (single instance, sync sequential delivery, MacBook M3 Max, | HTTP @ webhook latency 20 ms | ~33 msg/s | ~36 msg/s | | HTTP @ webhook latency 100 ms | ~9 msg/s | ~9 msg/s | -These numbers reflect the current sync-sequential delivery model. Throughput is bounded by per-message round-trip time × batch size. Performance work to lift these limits (async batch delivery, multi-threaded scheduler) is tracked in the [project issues](https://github.com/softwaremill/okapi/issues). +These numbers reflect the current sync-sequential delivery model. Throughput is bounded by per-message round-trip time × batch size. Full methodology, raw JMH results, and reproduction instructions: [`benchmarks/`](benchmarks/).