feat: migrate OutboxStore from Exposed to plain JDBC (KOJAK-64)#26
Merged
feat: migrate OutboxStore from Exposed to plain JDBC (KOJAK-64)#26
Conversation
ramafasa
reviewed
Apr 16, 2026
ramafasa
approved these changes
Apr 16, 2026
endrju19
added a commit
that referenced
this pull request
Apr 24, 2026
ConnectionProvider.getConnection(): Connection returned a JDBC connection
with an implicit "do not close" contract that only held inside an active
Spring transaction. Outside one, DataSourceUtils.getConnection fetches a
fresh pool connection that the caller is contractually required to
release via DataSourceUtils.releaseConnection - and the bare-getter
shape left no hook to do so. Every store call made outside a Spring
transaction (processor/purger tick without a PlatformTransactionManager,
actuator-style read calls, etc.) leaked a pooled connection.
Migrate ConnectionProvider to an execute-around API:
fun <T> withConnection(block: (Connection) -> T): T
The provider now owns both acquire and release. SpringConnectionProvider
pairs DataSourceUtils.getConnection with DataSourceUtils.releaseConnection
in a try/finally - a no-op when the connection is transaction-bound,
a pool-return otherwise. ExposedConnectionProvider reads from the
active Exposed TransactionManager (no cleanup - Exposed owns lifecycle).
Test JdbcConnectionProvider keeps the ThreadLocal-backed scope.
PostgresOutboxStore and MysqlOutboxStore were rewritten to nest their
PreparedStatement.use { } blocks inside withConnection { }, making the
connection scope explicit on each call site.
OutboxAutoConfiguration KDoc previously claimed "if PlatformTransactionManager
is absent, each store call runs in its own transaction" - true only at
the JDBC auto-commit level, misleading about the concurrency semantics.
Rewrote the note to spell out what auto-commit means here and to
recommend configuring a PlatformTransactionManager for multi-instance
deployments.
Coverage:
* SpringConnectionProviderTest - unit (H2 + TransactionSynchronizationManager):
tx-bound reuse, outside-tx release, exception-safety, repeat.
* ConnectionLeakProofTest - integration (Postgres Testcontainer):
wraps the real DataSource with a counting proxy, asserts
opened == closed across read-only and full-lifecycle store calls,
and asserts the effects (claim returns the persisted entry,
DELIVERED count transitions through 1 and 0 around the purge).
* MysqlConnectionLeakProofTest - integration (MySQL Testcontainer):
same assertions against the MySQL driver, whose pool-return
semantics differ from pgjdbc.
CountingDataSource was extracted to okapi-integration-tests/support so
the Postgres and MySQL leak-proof tests share the same invariant check:
opened increments after the delegate returns a connection, closed
increments after the delegate's close() returns - so a throwing delegate
cannot silently inflate the counter.
Addresses review feedback on PR #26.
Replace Exposed ORM with plain JDBC in PostgresOutboxStore and MysqlOutboxStore. This eliminates the coupling to Exposed's transaction mechanism, allowing okapi to work transparently with any Spring PlatformTransactionManager — JPA/Hibernate, JDBC, jOOQ, MyBatis, etc. Key changes: - Add ConnectionProvider interface to okapi-core - Add SpringConnectionProvider using DataSourceUtils.getConnection() - Rewrite PostgresOutboxStore and MysqlOutboxStore to plain JDBC - Remove Exposed dependencies from okapi-postgres and okapi-mysql - Delete OutboxTable.kt from both modules - Add TransactionRunner to OutboxPurger (fixes connection leak) - Fix null parameter handling (setNull instead of setTimestamp(null)) - Parameterize status in removeDeliveredBefore SQL - Change spring-jdbc from compileOnly to implementation
Move ExposedTransactionContextValidator out of okapi-core into a dedicated okapi-exposed module. Add ExposedConnectionProvider and ExposedTransactionRunner for Ktor/standalone Exposed apps. okapi-core is now free of Exposed dependencies — only plain JDK types.
ConnectionProvider.getConnection(): Connection returned a JDBC connection
with an implicit "do not close" contract that only held inside an active
Spring transaction. Outside one, DataSourceUtils.getConnection fetches a
fresh pool connection that the caller is contractually required to
release via DataSourceUtils.releaseConnection - and the bare-getter
shape left no hook to do so. Every store call made outside a Spring
transaction (processor/purger tick without a PlatformTransactionManager,
actuator-style read calls, etc.) leaked a pooled connection.
Migrate ConnectionProvider to an execute-around API:
fun <T> withConnection(block: (Connection) -> T): T
The provider now owns both acquire and release. SpringConnectionProvider
pairs DataSourceUtils.getConnection with DataSourceUtils.releaseConnection
in a try/finally - a no-op when the connection is transaction-bound,
a pool-return otherwise. ExposedConnectionProvider reads from the
active Exposed TransactionManager (no cleanup - Exposed owns lifecycle).
Test JdbcConnectionProvider keeps the ThreadLocal-backed scope.
PostgresOutboxStore and MysqlOutboxStore were rewritten to nest their
PreparedStatement.use { } blocks inside withConnection { }, making the
connection scope explicit on each call site.
OutboxAutoConfiguration KDoc previously claimed "if PlatformTransactionManager
is absent, each store call runs in its own transaction" - true only at
the JDBC auto-commit level, misleading about the concurrency semantics.
Rewrote the note to spell out what auto-commit means here and to
recommend configuring a PlatformTransactionManager for multi-instance
deployments.
Dependency scope: okapi-spring-boot now has production use of
DataSourceUtils from spring-jdbc, so the spring-jdbc dependency moves
from implementation to compileOnly to match the rest of the Spring
framework dependencies in this module. Every okapi-spring-boot consumer
already has spring-jdbc on their classpath via spring-boot-starter-jdbc
or spring-boot-starter-data-jpa, so compileOnly avoids forcing a
specific version transitively.
Coverage:
* SpringConnectionProviderTest - unit (H2 + TransactionSynchronizationManager):
tx-bound reuse, outside-tx release, exception-safety, repeat.
* ConnectionLeakProofTest - integration (Postgres Testcontainer):
wraps the real DataSource with a counting proxy, asserts
opened == closed across read-only and full-lifecycle store calls,
and asserts the effects (claim returns the persisted entry,
DELIVERED count transitions through 1 and 0 around the purge).
* MysqlConnectionLeakProofTest - integration (MySQL Testcontainer):
same assertions against the MySQL driver, whose pool-return
semantics differ from pgjdbc.
CountingDataSource was extracted to okapi-integration-tests/support so
the Postgres and MySQL leak-proof tests share the same invariant check.
Both the opened and closed counters increment only after the wrapped
delegate operation succeeds; if wrapping a freshly opened connection
fails, the delegate is closed before rethrowing so no physical
connection escapes. The runLiquibase helpers in both leak-proof tests
use DriverManager.getConnection(...).use { } so a Liquibase setup
failure cannot leak a connection in the very tests that prove absence
of leaks.
ObservabilityEndToEndTest (added on main after this branch was cut,
KOJAK-44) was written against the pre-migration Exposed-based
PostgresOutboxStore API; updated here to use the JDBC ConnectionProvider
via PostgresTestSupport.jdbc.withTransaction { } so it keeps passing
after the JDBC migration.
Addresses review feedback on PR #26.
0b12cf8 to
9470e4d
Compare
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
PostgresOutboxStoreandMysqlOutboxStorewith plain JDBC via newConnectionProviderfun interfaceSpringConnectionProvider(usesDataSourceUtils.getConnection()) for seamless Spring integration with anyPlatformTransactionManager(JPA, JDBC, jOOQ, MyBatis)OutboxTableExposed table definitions from both postgres and mysql modulesokapi-postgres,okapi-mysql, andokapi-spring-bootMotivation
Exposed as an internal implementation detail of OutboxStore forced consumers to:
compileOnlyin okapi leaked at runtime)SpringTransactionManagerinstead of standard Spring TMsJpaFlushingTransactionManagerfor JPA coexistenceWith
ConnectionProvider, okapi borrows the JDBC connection from whatever transaction mechanism the app uses — zero coupling.Key changes
okapi-coreConnectionProviderfun interfaceokapi-postgresPostgresOutboxStore(connectionProvider, clock)— pure JDBCokapi-mysqlMysqlOutboxStore(connectionProvider, clock)— pure JDBCokapi-spring-bootSpringConnectionProvider(dataSource)auto-configuredokapi-spring-bootOutboxPurgernow takes optionalTransactionRunnerokapi-integration-testsJdbcConnectionProvidertest helper, updated all testsValidated by
5 standalone example apps (JPA, JDBC, multi-datasource, jOOQ, Ktor+Exposed) — all passing JUnit tests and smoke tests against this branch.
Test plan
./gradlew test)