Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" }
Expand Down
3 changes: 3 additions & 0 deletions okapi-spring-boot/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<include file="..."/>`).
* 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",
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*
Expand All @@ -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<String, DataSource>,
Expand Down Expand Up @@ -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)
Expand All @@ -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. */
Expand All @@ -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<String, DataSource>,
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<String, DataSource>,
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading