From 5fc81f9750a784af398202b9ceac6d3737177f7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Wokacz?= Date: Thu, 30 Jul 2026 11:27:18 +0200 Subject: [PATCH 1/2] fix(spring-config): init postgres or mysql datastore conditionally Fail fast when both are on the class path. --- .../springboot/OutboxAutoConfiguration.kt | 43 ++++++++- .../LiquibaseAutoConfigurationTest.kt | 95 +++++++++++-------- .../okapi/springboot/LiquibaseE2ETest.kt | 41 +++++--- 3 files changed, 123 insertions(+), 56 deletions(-) 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 af58379..e98054b 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 @@ -23,6 +23,7 @@ import org.springframework.boot.autoconfigure.AutoConfiguration import org.springframework.boot.autoconfigure.condition.ConditionalOnClass import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean @@ -51,7 +52,10 @@ import javax.sql.DataSource * Optional beans with defaults: * - [OutboxStore] — auto-configured to [PostgresOutboxStore] or [MysqlOutboxStore] * depending on which module (`okapi-postgres` / `okapi-mysql`) is on the classpath. - * If both are present, Postgres takes priority. Override by defining your own `@Bean OutboxStore`. + * If *both* are present, startup fails fast (issue #90) rather than silently picking one — + * there is no safe default: the wrong engine's DDL/SQL would run against your database, and the + * app would look healthy while never actually delivering anything. Define an explicit + * `@Bean OutboxStore` to disambiguate, or remove the unused module. * - [Clock] — defaults to [Clock.systemUTC] * - [RetryPolicy] — defaults to `maxRetries = 5` * @@ -232,8 +236,20 @@ class OutboxAutoConfiguration( ) } + /** + * Auto-detects [PostgresOutboxStore] when `okapi-postgres` is on the classpath -- but only + * when `okapi-mysql` is NOT also present. Without the [ConditionalOnMissingClass] guard, this + * class and [MysqlStoreConfiguration] would both pass their own `@ConditionalOnClass` check + * whenever both modules are on the classpath, and *which* of the two nested `@Configuration` + * classes Spring happens to process first (undocumented, not declaration-order-guaranteed) + * would silently decide the winner -- see issue #90, where MySQL won in practice despite the + * KDoc's claim that "Postgres takes priority" and nothing in the code ever enforced that. + * [AmbiguousStoreConfiguration] is the only store-detecting config left active when both + * classes are present, and it fails fast instead of guessing. + */ @Configuration(proxyBeanMethods = false) @ConditionalOnClass(PostgresOutboxStore::class) + @ConditionalOnMissingClass("com.softwaremill.okapi.mysql.MysqlOutboxStore") class PostgresStoreConfiguration( private val dataSources: Map, private val primaryDataSource: DataSource, @@ -246,9 +262,10 @@ class OutboxAutoConfiguration( ) } - /** When both Postgres and MySQL modules are on the classpath, [PostgresStoreConfiguration] takes priority. */ + /** Symmetric to [PostgresStoreConfiguration] -- see its KDoc for the dual-classpath rationale. */ @Configuration(proxyBeanMethods = false) @ConditionalOnClass(MysqlOutboxStore::class) + @ConditionalOnMissingClass("com.softwaremill.okapi.postgres.PostgresOutboxStore") class MysqlStoreConfiguration( private val dataSources: Map, private val primaryDataSource: DataSource, @@ -261,6 +278,28 @@ class OutboxAutoConfiguration( ) } + /** + * Fails startup fast when both `okapi-postgres` and `okapi-mysql` are on the classpath and no + * explicit `@Bean OutboxStore` was supplied (issue #90). [PostgresStoreConfiguration] and + * [MysqlStoreConfiguration] both exclude themselves in this situation (see their KDoc), so this + * is the only store-detecting config left active -- there is no safe default to silently pick: + * the wrong engine's DDL/SQL would run against the database, and the app would look healthy at + * startup while never actually delivering anything. + */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(PostgresOutboxStore::class, MysqlOutboxStore::class) + class AmbiguousStoreConfiguration { + @Bean + @ConditionalOnMissingBean(OutboxStore::class) + fun outboxStore(): OutboxStore = error( + "Both okapi-postgres and okapi-mysql are on the classpath -- okapi cannot determine which " + + "OutboxStore to use. There is no safe default: silently picking one risks applying the " + + "wrong engine's DDL/SQL against your database while the app looks healthy at startup " + + "(see issue #90). Fix: remove the unused module (okapi-postgres or okapi-mysql), or " + + "define an explicit @Bean OutboxStore to disambiguate.", + ) + } + companion object { private val logger = LoggerFactory.getLogger(OutboxAutoConfiguration::class.java) 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 4cfe78f..45425bb 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.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import io.kotest.matchers.types.shouldBeSameInstanceAs +import io.kotest.matchers.types.shouldNotBeInstanceOf import liquibase.integration.spring.SpringLiquibase import org.slf4j.LoggerFactory import org.springframework.beans.factory.config.BeanPostProcessor @@ -391,25 +392,26 @@ 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 - // (see okapi-spring-boot/build.gradle.kts testImplementation declarations). 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). + test("dual-module classpath: startup fails fast instead of silently picking a store (issue #90)") { + // Historically (issue #38 / KOJAK-80) this test pinned "exactly one Liquibase + // activates, matching whichever OutboxStore won the undocumented nested- + // @Configuration processing-order race." That race is exactly what issue #90 + // reported going wrong in practice: MySQL silently won over the KDoc's claimed + // "Postgres takes priority", applying MySQL DDL/SQL (FORCE INDEX, etc.) against a + // Postgres database — the app started cleanly, looked healthy, and then failed every + // processor tick with a syntax error, never having delivered anything. // - // 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. + // The fix (see OutboxAutoConfiguration KDoc) removes the race entirely instead of + // making it deterministic: AmbiguousStoreConfiguration is the only store-detecting + // config left active when both okapi-postgres and okapi-mysql are on the classpath, + // and it fails startup outright rather than guessing. With no OutboxStore bean ever + // created, neither *LiquibaseConfiguration's @ConditionalOnBean(OutboxStore) gate + // can fire either — the original issue #38 dual-registration hazard is now + // structurally impossible here, not merely probabilistically avoided. // - // SuppressSpringLiquibaseRun prevents afterPropertiesSet() from trying to migrate - // a fake DataSource — we're asserting bean activation, not migration behaviour. + // Both `okapi-postgres` and `okapi-mysql` are on the test classpath (see + // okapi-spring-boot/build.gradle.kts testImplementation declarations) — no + // FilteredClassLoader needed to reach the ambiguous scenario. ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java, OkapiLiquibaseAutoConfiguration::class.java)) .withBean(MessageDeliverer::class.java, { stubDeliverer() }) @@ -419,30 +421,43 @@ class LiquibaseAutoConfigurationTest : FunSpec({ ctx.beanFactory.addBeanPostProcessor(SuppressSpringLiquibaseRun()) } .run { ctx -> - ctx.startupFailure shouldBe null + // Once startupFailure is non-null, AssertableApplicationContext is an + // "unstarted" proxy -- any other method (containsBean, getBean, ...) throws + // IllegalStateException. The failed-to-create-OutboxStore assertion below is + // the whole story: no OutboxStore means neither *LiquibaseConfiguration's + // @ConditionalOnBean(OutboxStore) gate could possibly have fired either. + val failure = ctx.startupFailure + failure.shouldNotBeNull() + val chain = generateSequence(failure as Throwable?) { it.cause }.toList() + val message = chain.mapNotNull { it.message }.joinToString(" | ") + message stringShouldContain "okapi-postgres" + message stringShouldContain "okapi-mysql" + message stringShouldContain "OutboxStore" + } + } - 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("dual-module classpath + explicit @Bean OutboxStore: escape hatch still works, no startup failure") { + // Symmetric to the test above: the documented override ("define an explicit + // @Bean OutboxStore to disambiguate") must still let the app start normally even + // with both modules on the classpath. AmbiguousStoreConfiguration's own + // @ConditionalOnMissingBean(OutboxStore::class) is what makes this work — a + // pre-existing user bean means it never even attempts to fire. + ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java, OkapiLiquibaseAutoConfiguration::class.java)) + .withBean(MessageDeliverer::class.java, { stubDeliverer() }) + .withBean(DataSource::class.java, { SimpleDriverDataSource() }) + .withBean(TransactionRunner::class.java, { noOpTransactionRunner() }) + .withBean(OutboxStore::class.java, { stubStore() }) + .run { ctx -> + ctx.startupFailure shouldBe null + // Neither okapi-postgres's nor okapi-mysql's own factory won -- the escape + // hatch bean is what's actually wired in, not a coincidental auto-detected one. + ctx.getBean(OutboxStore::class.java) + .shouldNotBeInstanceOf() + ctx.getBean(OutboxStore::class.java) + .shouldNotBeInstanceOf() + ctx.containsBean("okapiPostgresLiquibase") shouldBe false + ctx.containsBean("okapiMysqlLiquibase") shouldBe false } } 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 9a2719f..e7a7a6b 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 @@ -8,7 +8,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 io.kotest.matchers.nulls.shouldNotBeNull import liquibase.integration.spring.SpringLiquibase import org.postgresql.ds.PGSimpleDataSource import org.springframework.beans.factory.support.BeanDefinitionBuilder @@ -72,11 +72,12 @@ class LiquibaseE2ETest : FunSpec({ // 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..."]. + // Without this filter, both okapi-postgres and okapi-mysql being visible on the test + // classpath makes AmbiguousStoreConfiguration fail startup outright (issue #90) -- these + // tests need a real store to deterministically exercise the Postgres path against a real + // Postgres DataSource. The dual-module fail-fast behavior itself is covered by + // ["both okapi-postgres and okapi-mysql on classpath: startup fails fast, no Liquibase + // bean touches the real Postgres database"]. fun runner(ds: DataSource) = ApplicationContextRunner() .withClassLoader(FilteredClassLoader(MysqlOutboxStore::class.java)) .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java, OkapiLiquibaseAutoConfiguration::class.java)) @@ -89,13 +90,21 @@ 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 + test("both okapi-postgres and okapi-mysql on classpath: startup fails fast, no Liquibase bean touches the real Postgres database") { + // Originally a 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). + // duplicate-object error from the wrong-engine changelog. + // + // That gate fixed the dual-registration crash, but left the underlying ambiguity + // (which OutboxStore wins) an undocumented nested-@Configuration processing-order + // race — which is exactly what issue #90 found going wrong in practice: MySQL won + // silently, its DDL/SQL got applied to the Postgres database, and the app looked + // healthy at startup while every processor tick failed afterward. The fix + // (AmbiguousStoreConfiguration in OutboxAutoConfiguration) now fails startup + // immediately instead of picking a winner — before any OutboxStore, and therefore + // before any *LiquibaseConfiguration, ever touches this real Postgres database. // // No FilteredClassLoader here — both `okapi-postgres` and `okapi-mysql` are visible // on the runtime classpath, mirroring a real consumer that pulls in both modules @@ -111,10 +120,14 @@ class LiquibaseE2ETest : FunSpec({ "okapi.purger.enabled=false", ) .run { ctx -> - ctx.startupFailure.shouldBeNull() - val activeLiquibase = listOf("okapiPostgresLiquibase", "okapiMysqlLiquibase") - .filter { ctx.containsBean(it) } - activeLiquibase.size shouldBe 1 + // Once startupFailure is non-null, AssertableApplicationContext is an + // "unstarted" proxy -- containsBean()/getBean() would throw + // IllegalStateException. No OutboxStore bean means neither + // *LiquibaseConfiguration's @ConditionalOnBean(OutboxStore) gate could + // possibly have fired, so the real Postgres database was never touched either + // -- verified directly below instead. + ctx.startupFailure.shouldNotBeNull() + listTables(ds) shouldNotContain "okapi_outbox" } } From a75f00129c33adf9f8fc476bef138d511d4087ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Wokacz?= Date: Thu, 30 Jul 2026 12:07:01 +0200 Subject: [PATCH 2/2] fix(spring-config): respect the order - postgres is first before mysql --- .../springboot/OutboxAutoConfiguration.kt | 61 +++++++------------ .../LiquibaseAutoConfigurationTest.kt | 60 ++++++++---------- .../okapi/springboot/LiquibaseE2ETest.kt | 38 ++++++------ 3 files changed, 67 insertions(+), 92 deletions(-) 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 e98054b..0f43017 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 @@ -23,11 +23,11 @@ import org.springframework.boot.autoconfigure.AutoConfiguration import org.springframework.boot.autoconfigure.condition.ConditionalOnClass import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass 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 org.springframework.core.annotation.Order import org.springframework.jdbc.datasource.DelegatingDataSource import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.support.ResourceTransactionManager @@ -51,11 +51,11 @@ import javax.sql.DataSource * * Optional beans with defaults: * - [OutboxStore] — auto-configured to [PostgresOutboxStore] or [MysqlOutboxStore] - * depending on which module (`okapi-postgres` / `okapi-mysql`) is on the classpath. - * If *both* are present, startup fails fast (issue #90) rather than silently picking one — - * there is no safe default: the wrong engine's DDL/SQL would run against your database, and the - * app would look healthy while never actually delivering anything. Define an explicit - * `@Bean OutboxStore` to disambiguate, or remove the unused module. + * depending on which module (`okapi-postgres` / `okapi-mysql`) is on the classpath. If *both* + * are present, Postgres takes priority — enforced deterministically via `@Order` on + * [PostgresStoreConfiguration] / [MysqlStoreConfiguration] (issue #90: this was previously only + * documented, not enforced, and MySQL could silently win instead depending on undocumented + * nested-`@Configuration` processing order). Override by defining your own `@Bean OutboxStore`. * - [Clock] — defaults to [Clock.systemUTC] * - [RetryPolicy] — defaults to `maxRetries = 5` * @@ -237,19 +237,22 @@ class OutboxAutoConfiguration( } /** - * Auto-detects [PostgresOutboxStore] when `okapi-postgres` is on the classpath -- but only - * when `okapi-mysql` is NOT also present. Without the [ConditionalOnMissingClass] guard, this - * class and [MysqlStoreConfiguration] would both pass their own `@ConditionalOnClass` check - * whenever both modules are on the classpath, and *which* of the two nested `@Configuration` - * classes Spring happens to process first (undocumented, not declaration-order-guaranteed) - * would silently decide the winner -- see issue #90, where MySQL won in practice despite the - * KDoc's claim that "Postgres takes priority" and nothing in the code ever enforced that. - * [AmbiguousStoreConfiguration] is the only store-detecting config left active when both - * classes are present, and it fails fast instead of guessing. + * Auto-detects [PostgresOutboxStore] when `okapi-postgres` is on the classpath. + * + * **Precedence when both `okapi-postgres` and `okapi-mysql` are present:** `@Order(1)` here + * vs. `@Order(2)` on [MysqlStoreConfiguration] deterministically makes Postgres win. Spring's + * `ConfigurationClassParser.processMemberClasses` sorts sibling nested `@Configuration` + * candidates via `OrderComparator` *before* processing them, so `PostgresStoreConfiguration`'s + * `@Bean outboxStore()` is always registered first — by the time `MysqlStoreConfiguration`'s + * own `@ConditionalOnMissingBean(OutboxStore::class)` is evaluated, Postgres's bean already + * exists and MySQL's is skipped. Before this annotation existed (issue #90), nothing enforced + * an order at all: which of the two candidates Spring happened to process first was + * undocumented and not declaration-order-guaranteed, and in practice MySQL could silently win + * — applying its DDL/SQL against a Postgres database while the app looked healthy at startup. */ @Configuration(proxyBeanMethods = false) @ConditionalOnClass(PostgresOutboxStore::class) - @ConditionalOnMissingClass("com.softwaremill.okapi.mysql.MysqlOutboxStore") + @Order(1) class PostgresStoreConfiguration( private val dataSources: Map, private val primaryDataSource: DataSource, @@ -262,10 +265,10 @@ class OutboxAutoConfiguration( ) } - /** Symmetric to [PostgresStoreConfiguration] -- see its KDoc for the dual-classpath rationale. */ + /** Loses precedence to [PostgresStoreConfiguration] when both are present -- see its KDoc. */ @Configuration(proxyBeanMethods = false) @ConditionalOnClass(MysqlOutboxStore::class) - @ConditionalOnMissingClass("com.softwaremill.okapi.postgres.PostgresOutboxStore") + @Order(2) class MysqlStoreConfiguration( private val dataSources: Map, private val primaryDataSource: DataSource, @@ -278,28 +281,6 @@ class OutboxAutoConfiguration( ) } - /** - * Fails startup fast when both `okapi-postgres` and `okapi-mysql` are on the classpath and no - * explicit `@Bean OutboxStore` was supplied (issue #90). [PostgresStoreConfiguration] and - * [MysqlStoreConfiguration] both exclude themselves in this situation (see their KDoc), so this - * is the only store-detecting config left active -- there is no safe default to silently pick: - * the wrong engine's DDL/SQL would run against the database, and the app would look healthy at - * startup while never actually delivering anything. - */ - @Configuration(proxyBeanMethods = false) - @ConditionalOnClass(PostgresOutboxStore::class, MysqlOutboxStore::class) - class AmbiguousStoreConfiguration { - @Bean - @ConditionalOnMissingBean(OutboxStore::class) - fun outboxStore(): OutboxStore = error( - "Both okapi-postgres and okapi-mysql are on the classpath -- okapi cannot determine which " + - "OutboxStore to use. There is no safe default: silently picking one risks applying the " + - "wrong engine's DDL/SQL against your database while the app looks healthy at startup " + - "(see issue #90). Fix: remove the unused module (okapi-postgres or okapi-mysql), or " + - "define an explicit @Bean OutboxStore to disambiguate.", - ) - } - companion object { private val logger = LoggerFactory.getLogger(OutboxAutoConfiguration::class.java) 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 45425bb..556fe5a 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 @@ -392,26 +392,29 @@ class LiquibaseAutoConfigurationTest : FunSpec({ cond.value.toList().shouldBeEmpty() } - test("dual-module classpath: startup fails fast instead of silently picking a store (issue #90)") { + test("dual-module classpath: PostgresOutboxStore deterministically wins, only okapiPostgresLiquibase activates (issue #90)") { // Historically (issue #38 / KOJAK-80) this test pinned "exactly one Liquibase - // activates, matching whichever OutboxStore won the undocumented nested- - // @Configuration processing-order race." That race is exactly what issue #90 - // reported going wrong in practice: MySQL silently won over the KDoc's claimed - // "Postgres takes priority", applying MySQL DDL/SQL (FORCE INDEX, etc.) against a - // Postgres database — the app started cleanly, looked healthy, and then failed every - // processor tick with a syntax error, never having delivered anything. + // activates, matching whichever OutboxStore won the nested-@Configuration processing + // order" — without asserting *which* one. That undocumented, non-deterministic race + // is exactly what issue #90 found going wrong in practice: MySQL silently won over + // the KDoc's claimed "Postgres takes priority", applying MySQL DDL/SQL (FORCE INDEX, + // etc.) against a Postgres database — the app started cleanly, looked healthy, and + // then failed every processor tick with a syntax error, never having delivered + // anything. // - // The fix (see OutboxAutoConfiguration KDoc) removes the race entirely instead of - // making it deterministic: AmbiguousStoreConfiguration is the only store-detecting - // config left active when both okapi-postgres and okapi-mysql are on the classpath, - // and it fails startup outright rather than guessing. With no OutboxStore bean ever - // created, neither *LiquibaseConfiguration's @ConditionalOnBean(OutboxStore) gate - // can fire either — the original issue #38 dual-registration hazard is now - // structurally impossible here, not merely probabilistically avoided. + // The fix (see OutboxAutoConfiguration KDoc) makes the precedence deterministic + // instead of merely documented: @Order(1) on PostgresStoreConfiguration vs @Order(2) + // on MysqlStoreConfiguration makes Spring register Postgres's OutboxStore bean first, + // so MysqlStoreConfiguration's own @ConditionalOnMissingBean(OutboxStore::class) then + // correctly sees it's not needed. Liquibase mirrors that winner via its per-engine + // @ConditionalOnBean(OutboxStore) gates. // // Both `okapi-postgres` and `okapi-mysql` are on the test classpath (see // okapi-spring-boot/build.gradle.kts testImplementation declarations) — no - // FilteredClassLoader needed to reach the ambiguous scenario. + // FilteredClassLoader needed to reach the dual-module scenario. + // + // 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() }) @@ -421,27 +424,18 @@ class LiquibaseAutoConfigurationTest : FunSpec({ ctx.beanFactory.addBeanPostProcessor(SuppressSpringLiquibaseRun()) } .run { ctx -> - // Once startupFailure is non-null, AssertableApplicationContext is an - // "unstarted" proxy -- any other method (containsBean, getBean, ...) throws - // IllegalStateException. The failed-to-create-OutboxStore assertion below is - // the whole story: no OutboxStore means neither *LiquibaseConfiguration's - // @ConditionalOnBean(OutboxStore) gate could possibly have fired either. - val failure = ctx.startupFailure - failure.shouldNotBeNull() - val chain = generateSequence(failure as Throwable?) { it.cause }.toList() - val message = chain.mapNotNull { it.message }.joinToString(" | ") - message stringShouldContain "okapi-postgres" - message stringShouldContain "okapi-mysql" - message stringShouldContain "OutboxStore" + ctx.startupFailure shouldBe null + ctx.getBean(OutboxStore::class.java).shouldBeInstanceOf() + ctx.containsBean("okapiPostgresLiquibase") shouldBe true + ctx.containsBean("okapiMysqlLiquibase") shouldBe false } } - test("dual-module classpath + explicit @Bean OutboxStore: escape hatch still works, no startup failure") { - // Symmetric to the test above: the documented override ("define an explicit - // @Bean OutboxStore to disambiguate") must still let the app start normally even - // with both modules on the classpath. AmbiguousStoreConfiguration's own - // @ConditionalOnMissingBean(OutboxStore::class) is what makes this work — a - // pre-existing user bean means it never even attempts to fire. + test("dual-module classpath + explicit @Bean OutboxStore: escape hatch overrides the Postgres-wins default") { + // Pins that the documented override ("define an explicit @Bean OutboxStore to + // disambiguate") still works with both modules on the classpath: the escape-hatch + // bean satisfies PostgresStoreConfiguration's own @ConditionalOnMissingBean(OutboxStore::class) + // first (it's @Order(1)), so neither okapi-postgres's nor okapi-mysql's factory fires. ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java, OkapiLiquibaseAutoConfiguration::class.java)) .withBean(MessageDeliverer::class.java, { stubDeliverer() }) 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 e7a7a6b..6d15a43 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 @@ -2,13 +2,15 @@ package com.softwaremill.okapi.springboot import com.mysql.cj.jdbc.MysqlDataSource import com.softwaremill.okapi.core.MessageDeliverer +import com.softwaremill.okapi.core.OutboxStore 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 io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf import liquibase.integration.spring.SpringLiquibase import org.postgresql.ds.PGSimpleDataSource import org.springframework.beans.factory.support.BeanDefinitionBuilder @@ -72,12 +74,9 @@ class LiquibaseE2ETest : FunSpec({ // 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, both okapi-postgres and okapi-mysql being visible on the test - // classpath makes AmbiguousStoreConfiguration fail startup outright (issue #90) -- these - // tests need a real store to deterministically exercise the Postgres path against a real - // Postgres DataSource. The dual-module fail-fast behavior itself is covered by - // ["both okapi-postgres and okapi-mysql on classpath: startup fails fast, no Liquibase - // bean touches the real Postgres database"]. + // Not strictly necessary since @Order(1) on PostgresStoreConfiguration already makes it + // win deterministically when both modules are visible (see the dual-module test below), + // but keeping the filter here isolates these tests from that mechanism entirely. fun runner(ds: DataSource) = ApplicationContextRunner() .withClassLoader(FilteredClassLoader(MysqlOutboxStore::class.java)) .withConfiguration(AutoConfigurations.of(OutboxAutoConfiguration::class.java, OkapiLiquibaseAutoConfiguration::class.java)) @@ -90,7 +89,7 @@ class LiquibaseE2ETest : FunSpec({ beforeEach { resetSchema() } - test("both okapi-postgres and okapi-mysql on classpath: startup fails fast, no Liquibase bean touches the real Postgres database") { + test("both okapi-postgres and okapi-mysql on classpath: Postgres wins and migrates the real Postgres database") { // Originally a 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 @@ -102,9 +101,10 @@ class LiquibaseE2ETest : FunSpec({ // race — which is exactly what issue #90 found going wrong in practice: MySQL won // silently, its DDL/SQL got applied to the Postgres database, and the app looked // healthy at startup while every processor tick failed afterward. The fix - // (AmbiguousStoreConfiguration in OutboxAutoConfiguration) now fails startup - // immediately instead of picking a winner — before any OutboxStore, and therefore - // before any *LiquibaseConfiguration, ever touches this real Postgres database. + // (@Order(1)/@Order(2) on PostgresStoreConfiguration/MysqlStoreConfiguration in + // OutboxAutoConfiguration) makes Postgres win deterministically instead. This test + // proves it end-to-end: PostgresOutboxStore wins, okapiPostgresLiquibase is the only + // Liquibase bean, and it successfully migrates this real Postgres database. // // No FilteredClassLoader here — both `okapi-postgres` and `okapi-mysql` are visible // on the runtime classpath, mirroring a real consumer that pulls in both modules @@ -120,14 +120,14 @@ class LiquibaseE2ETest : FunSpec({ "okapi.purger.enabled=false", ) .run { ctx -> - // Once startupFailure is non-null, AssertableApplicationContext is an - // "unstarted" proxy -- containsBean()/getBean() would throw - // IllegalStateException. No OutboxStore bean means neither - // *LiquibaseConfiguration's @ConditionalOnBean(OutboxStore) gate could - // possibly have fired, so the real Postgres database was never touched either - // -- verified directly below instead. - ctx.startupFailure.shouldNotBeNull() - listTables(ds) shouldNotContain "okapi_outbox" + ctx.startupFailure.shouldBeNull() + ctx.getBean(OutboxStore::class.java).shouldBeInstanceOf() + ctx.containsBean("okapiPostgresLiquibase") shouldBe true + ctx.containsBean("okapiMysqlLiquibase") shouldBe false + + val tables = listTables(ds) + tables shouldContain "okapi_databasechangelog" + tables shouldContain "okapi_outbox" } }