Skip to content

Commit fa9afda

Browse files
authored
fix: Liquibase coexistence + NCDF on Spring Boot 3.5.x without liquibase-core (KOJAK-80) (#42)
## Summary Closes GitHub issue #38 and JIRA KOJAK-80. Also 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) — discovered during the work. ### NCDF guard — fix for the related startup bug - Move `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`. ### Issue #38 Mode 1 / KOJAK-80 §1 — host's `liquibase` bean shadowed - 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`) shadows Spring Boot's type-based `@ConditionalOnMissingBean(SpringLiquibase)` guard, silently suppressing the host application's own changelog. - **Decision re. ordering direction**: KOJAK-80 suggested `@AutoConfigureBefore` as the leading candidate. The PR uses `@AutoConfigureAfter` — `Before` would put okapi's bean first and trigger the exact shadowing it's meant to prevent (Spring Boot's `@ConditionalOnMissingBean(SpringLiquibase)` is type-based and would skip its own bean if okapi's is registered first). Rationale documented in the KDoc and the JIRA comment on KOJAK-80. - **No `@DependsOn` KDoc**: KOJAK-80 also asked for a note about host beans declaring `@DependsOn("okapiPostgresLiquibase")` to handle FKs into `okapi_outbox`. That motivating case is an anti-pattern (the purger needs to delete `okapi_outbox` rows; a host FK would block it and stall the queue) — documenting it would encourage misuse. The 99.999% of users whose changelogs don't reference okapi's tables get a working setup with no extra ceremony. Decision logged on KOJAK-80. - Keep `@ConditionalOnMissingBean(name = ...)` (not type-based) so user-supplied `@Bean SpringLiquibase liquibase()` coexists with okapi's bean, while `@Bean("okapiPostgresLiquibase")` still cleanly overrides okapi's default. ### Opt-out - 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 plan Adopts the testing pattern PR #41 introduced for the analogous Micrometer ordering bug (real Spring Boot autoconfig + reflection meta-test). - **Real autoconfig coexistence E2E** (Postgres + MySQL): pulls Spring Boot's `LiquibaseAutoConfiguration` via `resolveSpringBootClass(...)`, sets `spring.liquibase.change-log`, asserts both beans register and both changelogs run. - **Multi-DataSource E2E** (KOJAK-80 §2): two real Postgres containers, `okapi.datasource-qualifier=secondaryDs`, asserts `okapi_outbox` lands on the secondary and not the primary. - **Structural pins** (`LiquibaseAutoConfigurationTest`): - No `SpringLiquibase` return type on `OutboxAutoConfiguration` / `PostgresStoreConfiguration` / `MysqlStoreConfiguration` (NCDF guard would otherwise return). - Class-level `@ConditionalOnClass(SpringLiquibase)` on both new Liquibase configs. - Name-based `@ConditionalOnMissingBean` (type-based would re-introduce Mode 1). - `@AutoConfigureAfter` annotation is structurally sound; declared names resolve when Spring Boot Liquibase autoconfig is on classpath. - **Log capture**: `LiquibaseDisabledNotice` WARN message captured via logback `ListAppender` — deletion of the warn body breaks the test, not just deletion of the class. - **User override**: end-to-end test for `@Bean("okapiPostgresLiquibase")` taking precedence over okapi's default. ### Verified - [x] `./gradlew :okapi-spring-boot:test` — 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 — each fails a distinct test in the new suite: - Remove 3.x `@AutoConfigureAfter` path → meta-test failure on 3.5.12 - Remove `@AutoConfigureAfter` entirely → unconditional structural check failure - Switch to type-based `@ConditionalOnMissingBean` → name-pin failure - Move Liquibase bean back into `PostgresStoreConfiguration` → NCDF structural guard failure - [x] Empirically verified on `okapi-autocomplete-test` (Spring Boot 3.5.7, no `liquibase-core` on classpath) — app starts cleanly; the original `NoClassDefFoundError` is gone.
1 parent 6cadb48 commit fa9afda

20 files changed

Lines changed: 821 additions & 65 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ Okapi implements the [transactional outbox pattern](https://softwaremill.com/mic
9999

100100
Okapi ships Liquibase changelogs that create the outbox table and its indexes:
101101

102-
- `classpath:com/softwaremill/okapi/db/changelog.xml` — PostgreSQL (from `okapi-postgres`)
102+
- `classpath:com/softwaremill/okapi/db/postgres/changelog.xml` — PostgreSQL (from `okapi-postgres`)
103103
- `classpath:com/softwaremill/okapi/db/mysql/changelog.xml` — MySQL (from `okapi-mysql`)
104104

105105
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).
@@ -273,7 +273,7 @@ Throughput baseline (single instance, sync sequential delivery, MacBook M3 Max,
273273
| HTTP @ webhook latency 20 ms | ~33 msg/s | ~36 msg/s |
274274
| HTTP @ webhook latency 100 ms | ~9 msg/s | ~9 msg/s |
275275

276-
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).
276+
These numbers reflect the current sync-sequential delivery model. Throughput is bounded by per-message round-trip time × batch size.
277277

278278
Full methodology, raw JMH results, and reproduction instructions: [`benchmarks/`](benchmarks/).
279279

‎gradle/libs.versions.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ springBoot = "4.0.6"
1414
vanniktechPublish = "0.36.0"
1515
wiremock = "3.13.2"
1616
slf4j = "2.0.18"
17+
logback = "1.5.20"
1718
assertj = "3.27.7"
1819
h2 = "2.4.240"
1920
micrometer = "1.16.5"
@@ -53,6 +54,7 @@ micrometerTest = { module = "io.micrometer:micrometer-test", version.ref = "micr
5354
wiremock = { module = "org.wiremock:wiremock", version.ref = "wiremock" }
5455
slf4jApi = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
5556
slf4jSimple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" }
57+
logbackClassic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
5658
h2 = { module = "com.h2database:h2", version.ref = "h2" }
5759
jmhCore = { module = "org.openjdk.jmh:jmh-core", version.ref = "jmh" }
5860
jmhGeneratorAnnprocess = { module = "org.openjdk.jmh:jmh-generator-annprocess", version.ref = "jmh" }

‎okapi-benchmarks/src/jmh/kotlin/com/softwaremill/okapi/benchmarks/support/PostgresBenchmarkSupport.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ class PostgresBenchmarkSupport {
4747
private fun runLiquibase() {
4848
val connection = DriverManager.getConnection(container.jdbcUrl, container.username, container.password)
4949
val db = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(JdbcConnection(connection))
50-
Liquibase("com/softwaremill/okapi/db/changelog.xml", ClassLoaderResourceAccessor(), db).use { it.update("") }
50+
Liquibase("com/softwaremill/okapi/db/postgres/changelog.xml", ClassLoaderResourceAccessor(), db).use { it.update("") }
5151
connection.close()
5252
}
5353
}

‎okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/support/PostgresTestSupport.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class PostgresTestSupport {
4040
private fun runLiquibase() {
4141
val connection = DriverManager.getConnection(container.jdbcUrl, container.username, container.password)
4242
val db = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(JdbcConnection(connection))
43-
Liquibase("com/softwaremill/okapi/db/changelog.xml", ClassLoaderResourceAccessor(), db).use { it.update("") }
43+
Liquibase("com/softwaremill/okapi/db/postgres/changelog.xml", ClassLoaderResourceAccessor(), db).use { it.update("") }
4444
connection.close()
4545
}
4646
}

‎okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/transaction/ConnectionLeakProofTest.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,6 @@ class ConnectionLeakProofTest : FunSpec({
105105
private fun runLiquibase(container: PostgreSQLContainer<Nothing>) {
106106
DriverManager.getConnection(container.jdbcUrl, container.username, container.password).use { connection ->
107107
val db = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(JdbcConnection(connection))
108-
Liquibase("com/softwaremill/okapi/db/changelog.xml", ClassLoaderResourceAccessor(), db).use { it.update("") }
108+
Liquibase("com/softwaremill/okapi/db/postgres/changelog.xml", ClassLoaderResourceAccessor(), db).use { it.update("") }
109109
}
110110
}

‎okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/transaction/MultiDataSourceTransactionTest.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,6 @@ class MultiDataSourceTransactionTest : FunSpec({
139139
private fun runLiquibase(container: PostgreSQLContainer<Nothing>) {
140140
val connection = DriverManager.getConnection(container.jdbcUrl, container.username, container.password)
141141
val db = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(JdbcConnection(connection))
142-
Liquibase("com/softwaremill/okapi/db/changelog.xml", ClassLoaderResourceAccessor(), db).use { it.update("") }
142+
Liquibase("com/softwaremill/okapi/db/postgres/changelog.xml", ClassLoaderResourceAccessor(), db).use { it.update("") }
143143
connection.close()
144144
}

okapi-postgres/src/main/resources/com/softwaremill/okapi/db/001__create_outbox_table.sql renamed to okapi-postgres/src/main/resources/com/softwaremill/okapi/db/postgres/001__create_outbox_table.sql

File renamed without changes.

okapi-postgres/src/main/resources/com/softwaremill/okapi/db/002__add_delivery_type_column.sql renamed to okapi-postgres/src/main/resources/com/softwaremill/okapi/db/postgres/002__add_delivery_type_column.sql

File renamed without changes.

okapi-postgres/src/main/resources/com/softwaremill/okapi/db/003__add_purger_index.sql renamed to okapi-postgres/src/main/resources/com/softwaremill/okapi/db/postgres/003__add_purger_index.sql

File renamed without changes.

okapi-postgres/src/main/resources/com/softwaremill/okapi/db/004__add_claim_index.sql renamed to okapi-postgres/src/main/resources/com/softwaremill/okapi/db/postgres/004__add_claim_index.sql

File renamed without changes.

0 commit comments

Comments
 (0)