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
21 changes: 21 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 Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,29 @@ 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.

**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