Skip to content

feat: migrate OutboxStore from Exposed to plain JDBC (KOJAK-64)#26

Merged
endrju19 merged 7 commits intomainfrom
feat/jdbc-outbox-store
Apr 24, 2026
Merged

feat: migrate OutboxStore from Exposed to plain JDBC (KOJAK-64)#26
endrju19 merged 7 commits intomainfrom
feat/jdbc-outbox-store

Conversation

@endrju19
Copy link
Copy Markdown
Collaborator

Summary

  • Replaces Exposed ORM in PostgresOutboxStore and MysqlOutboxStore with plain JDBC via new ConnectionProvider fun interface
  • Adds SpringConnectionProvider (uses DataSourceUtils.getConnection()) for seamless Spring integration with any PlatformTransactionManager (JPA, JDBC, jOOQ, MyBatis)
  • Removes OutboxTable Exposed table definitions from both postgres and mysql modules
  • Removes Exposed dependencies from okapi-postgres, okapi-mysql, and okapi-spring-boot

Motivation

Exposed as an internal implementation detail of OutboxStore forced consumers to:

  1. Add 5+ Exposed runtime dependencies (compileOnly in okapi leaked at runtime)
  2. Use Exposed's SpringTransactionManager instead of standard Spring TMs
  3. Create hacks like JpaFlushingTransactionManager for JPA coexistence

With ConnectionProvider, okapi borrows the JDBC connection from whatever transaction mechanism the app uses — zero coupling.

Key changes

Module Change
okapi-core New ConnectionProvider fun interface
okapi-postgres PostgresOutboxStore(connectionProvider, clock) — pure JDBC
okapi-mysql MysqlOutboxStore(connectionProvider, clock) — pure JDBC
okapi-spring-boot SpringConnectionProvider(dataSource) auto-configured
okapi-spring-boot OutboxPurger now takes optional TransactionRunner
okapi-integration-tests JdbcConnectionProvider test helper, updated all tests

Validated by

5 standalone example apps (JPA, JDBC, multi-datasource, jOOQ, Ktor+Exposed) — all passing JUnit tests and smoke tests against this branch.

Test plan

  • All existing integration tests pass (./gradlew test)
  • Concurrency tests pass (PostgreSQL + MySQL)
  • E2E tests pass (HTTP + Kafka delivery)
  • CI green

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.
@endrju19 endrju19 force-pushed the feat/jdbc-outbox-store branch from 0b12cf8 to 9470e4d Compare April 24, 2026 07:58
@endrju19 endrju19 merged commit 1459f3c into main Apr 24, 2026
8 checks passed
@endrju19 endrju19 deleted the feat/jdbc-outbox-store branch April 24, 2026 08:32
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.

2 participants