Skip to content
Merged
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
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@ Until `1.0.0`, breaking changes may appear in any release and are flagged with *
history; the resulting schema is unchanged. Existing installations from an earlier
release: the `outbox:001` changeset checksum changed — they must start on a fresh
okapi schema, or clear okapi's rows from `okapi_databasechangelog`, before upgrading.
- **`OutboxProcessorScheduler` / `OutboxPurgerScheduler` constructors now require a
non-null `TransactionRunner`** (previously a nullable `TransactionTemplate?`, with
`OutboxPurgerScheduler`'s parameter defaulted to `null`). Spring Boot autoconfig users
are unaffected — the autoconfig derives a `TransactionRunner` from any
`PlatformTransactionManager` on the classpath. Users constructing the schedulers
directly must supply a `TransactionRunner` (e.g. `SpringTransactionRunner(template)` or
a thin lambda wrapping their framework's native transaction primitive). The previous
null-default silently degraded `FOR UPDATE SKIP LOCKED` to JDBC auto-commit, permitting
duplicate delivery across processor instances. ([#49](https://github.com/softwaremill/okapi/pull/49))
- **`okapi-spring-boot` autoconfig refuses to start when it cannot verify the
PlatformTransactionManager↔outbox-DataSource binding** in a multi-DataSource context.
Specifically: if `extractDataSource` cannot determine the PTM's DataSource (e.g. JTA,
Exposed's `SpringTransactionManager`, or any PTM that exposes neither a `DataSource`
resourceFactory nor a public `getDataSource()`), AND the context has ≥2 `DataSource`
beans, AND `okapi.transaction-manager-qualifier` is not set, the context refresh now
fails with an actionable message. `okapi.datasource-qualifier` alone is not
sufficient — it picks the outbox DataSource but does not constrain which PTM
brackets it. Single-DataSource contexts and setups that explicitly name the PTM
via `okapi.transaction-manager-qualifier` are unaffected. Escape hatch: supply an
explicit `@Bean TransactionRunner` to bypass validation.
([#49](https://github.com/softwaremill/okapi/pull/49))

### Added

Expand All @@ -34,6 +55,20 @@ Until `1.0.0`, breaking changes may appear in any release and are flagged with *
- `okapi.liquibase.changelog-lock-table` — likewise for `databaseChangeLogLockTable`.
Default: `okapi_databasechangeloglock`.

### Fixed

- **`okapi.transaction-manager-qualifier` is now honoured in Spring Boot apps that
load `TransactionAutoConfiguration`.** Previously the qualifier was silently
ignored whenever a unique `TransactionTemplate` was present in the context —
which Spring Boot's `TransactionAutoConfiguration` registers out of the box around
the @Primary `PlatformTransactionManager`. The factory short-circuited on the
auto-TT's bound PTM and never consulted the qualifier. In a multi-PTM setup this
meant the @Primary PTM was used even when the user explicitly named a different
one. Resolution rule is now: explicit qualifier > auto-wired TT; when the
qualified PTM matches the unique TT's PTM the TT is reused verbatim (preserving
its `timeout`/`isolation`/`propagation`), otherwise a fresh `TransactionTemplate`
is built around the qualified PTM. ([#49](https://github.com/softwaremill/okapi/pull/49))

### Migration from 0.2.x

These are breaking changes; existing deployments must take action before the first
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,31 @@ springOutboxPublisher.publish(
> **Note:** Spring and Kafka versions are not forced by okapi — you control them.
> Okapi uses plain JDBC internally — it works with any `PlatformTransactionManager` (JPA, JDBC, jOOQ, Exposed, etc.).

`okapi-spring-boot` requires a `TransactionRunner` bean to bracket each scheduler tick in a transaction. The autoconfiguration derives one from any `PlatformTransactionManager` on the classpath (`spring-boot-starter-jdbc` or `spring-boot-starter-data-jpa` provide one out of the box) — no extra wiring needed in typical setups. If your application has no `PlatformTransactionManager` (single-instance, no transaction infrastructure) you must opt in explicitly:

```kotlin
@Bean
fun outboxTransactionRunner(): TransactionRunner = object : TransactionRunner {
override fun <T> runInTransaction(block: () -> T): T = block()
}
```

Without bracketing, `FOR UPDATE SKIP LOCKED` collapses to the single SELECT statement under JDBC auto-commit, which silently allows duplicate delivery across processor instances. This opt-in is intentionally manual to keep accidental misconfiguration out of multi-instance deployments.

**Multi-DataSource contexts.** If your application has multiple `DataSource` beans and uses a `PlatformTransactionManager` from which okapi cannot extract a `DataSource` (JTA, Exposed's `SpringTransactionManager`, JPA without a JDBC `DataSource`), the autoconfiguration refuses to start until you set `okapi.transaction-manager-qualifier` to the bean name of the PTM that brackets the outbox `DataSource`. `okapi.datasource-qualifier` alone is not sufficient: it picks the outbox `DataSource` but does not constrain which PTM brackets it. Alternative escape hatch: supply your own `@Bean TransactionRunner`. Single-DataSource setups and PTMs whose `DataSource` can be introspected (`DataSourceTransactionManager`, `JpaTransactionManager`, `HibernateTransactionManager`) are unaffected.

When `okapi.transaction-manager-qualifier` is set, it takes precedence over any auto-wired `TransactionTemplate` — including the one Spring Boot's `TransactionAutoConfiguration` registers around the `@Primary` `PlatformTransactionManager`. If the qualifier names a different PTM than that auto-TT wraps, okapi builds a fresh `TransactionTemplate` around the qualified PTM (so the qualifier's intent is honoured) and any custom timeout/isolation/propagation on the auto-wired TT is not inherited — a WARN is logged in that case.

**Constructing schedulers directly (non-autoconfig usage).** When wiring `OutboxProcessorScheduler` / `OutboxPurgerScheduler` manually (Ktor, custom Spring contexts without autoconfig, etc.), supply a `TransactionRunner` explicitly — the parameter is required, with no default:

```kotlin
OutboxProcessorScheduler(
outboxProcessor = processor,
transactionRunner = SpringTransactionRunner(template), // or your framework's equivalent
config = OutboxSchedulerConfig(...),
)
```

## How It Works

Okapi implements the [transactional outbox pattern](https://softwaremill.com/microservices-101/) (see also: [microservices.io description](https://microservices.io/patterns/data/transactional-outbox.html)):
Expand Down
12 changes: 12 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ h2 = "2.4.240"
micrometer = "1.16.5"
jmh = "1.37"
jmhGradlePlugin = "0.7.3"
pitestGradlePlugin = "1.19.0"
pitestTool = "1.17.0"
pitestJunit5Plugin = "1.2.1"
# Hibernate ORM 7.x (the line Spring Framework 7.x targets); integration-tests only, to exercise
# JpaTransactionManager fail-fast extraction.
hibernate = "7.1.4.Final"

[libraries]
kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
Expand All @@ -44,10 +50,14 @@ kafkaClients = { module = "org.apache.kafka:kafka-clients", version.ref = "kafka
springContext = { module = "org.springframework:spring-context", version.ref = "spring" }
springTx = { module = "org.springframework:spring-tx", version.ref = "spring" }
springJdbc = { module = "org.springframework:spring-jdbc", version.ref = "spring" }
springOrm = { module = "org.springframework:spring-orm", version.ref = "spring" }
hibernateCore = { module = "org.hibernate.orm:hibernate-core", version.ref = "hibernate" }
springBootAutoconfigure = { module = "org.springframework.boot:spring-boot-autoconfigure", version.ref = "springBoot" }
springBootStarterValidation = { module = "org.springframework.boot:spring-boot-starter-validation", version.ref = "springBoot" }
springBootTest = { module = "org.springframework.boot:spring-boot-test", version.ref = "springBoot" }
springBootStarterActuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springBoot" }
# Spring Boot 4.0 split TransactionAutoConfiguration into a dedicated module (was in spring-boot-autoconfigure in 3.x).
springBootTransaction = { module = "org.springframework.boot:spring-boot-transaction", version.ref = "springBoot" }
assertjCore = { module = "org.assertj:assertj-core", version.ref = "assertj" }
micrometerCore = { module = "io.micrometer:micrometer-core", version.ref = "micrometer" }
micrometerTest = { module = "io.micrometer:micrometer-test", version.ref = "micrometer" }
Expand All @@ -62,3 +72,5 @@ jmhGeneratorAnnprocess = { module = "org.openjdk.jmh:jmh-generator-annprocess",
[plugins]
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
jmh = { id = "me.champeau.jmh", version.ref = "jmhGradlePlugin" }
# Mutation-testing — opt-in only, declared in okapi-spring-boot but applied conditionally
pitest = { id = "info.solidsoft.pitest", version.ref = "pitestGradlePlugin" }
13 changes: 13 additions & 0 deletions okapi-integration-tests/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,18 @@ dependencies {
testImplementation(libs.springContext)
testImplementation(libs.springTx)
testImplementation(libs.springBootAutoconfigure)
testImplementation(libs.springBootTest)
testImplementation(libs.springJdbc)
// Spring Boot 4.x doesn't pull AssertJ transitively but ApplicationContextRunner needs it
testImplementation(libs.assertjCore)

// Exposed-Spring bridge (proves autoconfig works with non-DataSourceTransactionManager PTMs)
testImplementation(libs.exposedCore)
testImplementation(libs.exposedJdbc)
testImplementation(libs.exposedSpringTransaction)

// JPA + Hibernate — proves extractDataSource() pulls JpaTransactionManager.getDataSource()
// and validatePtmDataSourceMatch fails fast on PTM↔DataSource mismatch under JPA.
testImplementation(libs.springOrm)
testImplementation(libs.hibernateCore)
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,26 @@ class PostgresTestSupport {
}
}

private fun runLiquibase() {
val connection = DriverManager.getConnection(container.jdbcUrl, container.username, container.password)
val db = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(JdbcConnection(connection))
Liquibase("com/softwaremill/okapi/db/postgres/changelog.xml", ClassLoaderResourceAccessor(), db).use { it.update("") }
connection.close()
private fun runLiquibase() = runOkapiLiquibaseOn(container)
}

/**
* Applies okapi's PostgreSQL Liquibase changelog to the given container. For tests that manage
* their own PostgreSQL containers (e.g. 2-DataSource setups) and can't use the single-container
* `PostgresTestSupport` class.
*/
fun runOkapiLiquibaseOn(container: PostgreSQLContainer<Nothing>) {
DriverManager.getConnection(container.jdbcUrl, container.username, container.password).use { conn ->
val db = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(JdbcConnection(conn))
Liquibase("com/softwaremill/okapi/db/postgres/changelog.xml", ClassLoaderResourceAccessor(), db).use {
it.update("")
}
}
}

/** Builds a plain `PGSimpleDataSource` pointing at the given container. */
fun pgDataSourceOf(container: PostgreSQLContainer<Nothing>): DataSource = PGSimpleDataSource().apply {
setURL(container.jdbcUrl)
user = container.username
password = container.password
}
Loading