fix: Liquibase coexistence + NCDF on Spring Boot 3.5.x without liquibase-core (KOJAK-80)#42
Merged
Merged
Conversation
…Spring Boot 3.5.x without liquibase-core 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.
5647152 to
d4720f5
Compare
…kapi-mysql are on the classpath 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(<X>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.
…metadata group - 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.
…Bean(name) 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.
…tgres/ 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.
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.
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.
…e-coexistence # Conflicts: # gradle/libs.versions.toml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes GitHub issue #38 and JIRA KOJAK-80. Also fixes a related undocumented
NoClassDefFoundErrorthat prevented startup on Spring Boot 3.5.x consumers withoutliquibase-coreon the classpath (e.g. Flyway-only users) — discovered during the work.NCDF guard — fix for the related startup bug
okapi*Liquibasefactory methods into dedicatedPostgresLiquibaseConfiguration/MysqlLiquibaseConfigurationinner classes with class-level@ConditionalOnClass(SpringLiquibase). Spring evaluates the class-level condition via string-name lookup before any method introspection, soClass.getDeclaredMethods()is never called on a class whoseSpringLiquibasereturn type would triggerNoClassDefFoundError.Issue #38 Mode 1 / KOJAK-80 §1 — host's
liquibasebean shadowed@AutoConfigureAfter(name = [3.x path, 4.x path])onOutboxAutoConfigurationso Spring Boot'sLiquibaseAutoConfigurationregisters its ownliquibasebean first. Without this, okapi'sokapiPostgresLiquibase(typedSpringLiquibase) shadows Spring Boot's type-based@ConditionalOnMissingBean(SpringLiquibase)guard, silently suppressing the host application's own changelog.@AutoConfigureBeforeas the leading candidate. The PR uses@AutoConfigureAfter—Beforewould 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.@DependsOnKDoc: KOJAK-80 also asked for a note about host beans declaring@DependsOn("okapiPostgresLiquibase")to handle FKs intookapi_outbox. That motivating case is an anti-pattern (the purger needs to deleteokapi_outboxrows; 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.@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
okapi.liquibase.enabledproperty (defaulttrue) for explicit opt-out when the host app includes okapi's changelog from its own master changelog.LiquibaseDisabledNoticeinner config logs a WARN-level breadcrumb onenabled=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).
LiquibaseAutoConfigurationviaresolveSpringBootClass(...), setsspring.liquibase.change-log, asserts both beans register and both changelogs run.okapi.datasource-qualifier=secondaryDs, assertsokapi_outboxlands on the secondary and not the primary.LiquibaseAutoConfigurationTest):SpringLiquibasereturn type onOutboxAutoConfiguration/PostgresStoreConfiguration/MysqlStoreConfiguration(NCDF guard would otherwise return).@ConditionalOnClass(SpringLiquibase)on both new Liquibase configs.@ConditionalOnMissingBean(type-based would re-introduce Mode 1).@AutoConfigureAfterannotation is structurally sound; declared names resolve when Spring Boot Liquibase autoconfig is on classpath.LiquibaseDisabledNoticeWARN message captured via logbackListAppender— deletion of the warn body breaks the test, not just deletion of the class.@Bean("okapiPostgresLiquibase")taking precedence over okapi's default.Verified
./gradlew :okapi-spring-boot:test— Spring Boot 4.0.6 (default)./gradlew :okapi-spring-boot:test -PspringBootVersion=3.5.12 -PspringVersion=6.2.17./gradlew :okapi-spring-boot:ktlintCheck@AutoConfigureAfterpath → meta-test failure on 3.5.12@AutoConfigureAfterentirely → unconditional structural check failure@ConditionalOnMissingBean→ name-pin failurePostgresStoreConfiguration→ NCDF structural guard failureokapi-autocomplete-test(Spring Boot 3.5.7, noliquibase-coreon classpath) — app starts cleanly; the originalNoClassDefFoundErroris gone.