Skip to content

fix: Liquibase coexistence + NCDF on Spring Boot 3.5.x without liquibase-core (KOJAK-80)#42

Open
endrju19 wants to merge 1 commit into
mainfrom
fix/issue-38-liquibase-coexistence
Open

fix: Liquibase coexistence + NCDF on Spring Boot 3.5.x without liquibase-core (KOJAK-80)#42
endrju19 wants to merge 1 commit into
mainfrom
fix/issue-38-liquibase-coexistence

Conversation

@endrju19
Copy link
Copy Markdown
Collaborator

@endrju19 endrju19 commented May 12, 2026

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 @AutoConfigureAfterBefore 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

  • ./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
  • 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
  • Empirically verified on okapi-autocomplete-test (Spring Boot 3.5.7, no liquibase-core on classpath) — app starts cleanly; the original NoClassDefFoundError is gone.

…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.
@endrju19 endrju19 changed the title fix: Liquibase coexistence + NCDF on Spring Boot 3.5.x without liquibase-core fix: Liquibase coexistence + NCDF on Spring Boot 3.5.x without liquibase-core (KOJAK-80) May 12, 2026
@endrju19 endrju19 force-pushed the fix/issue-38-liquibase-coexistence branch from 5647152 to d4720f5 Compare May 12, 2026 22:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant