Skip to content

feat: EXPOSED-818 Add Spring reactive transaction manager module#2685

Open
bog-walk wants to merge 7 commits into
mainfrom
bog-walk/pr-spring-reactive-tm
Open

feat: EXPOSED-818 Add Spring reactive transaction manager module#2685
bog-walk wants to merge 7 commits into
mainfrom
bog-walk/pr-spring-reactive-tm

Conversation

@bog-walk
Copy link
Copy Markdown
Member

@bog-walk bog-walk commented Dec 8, 2025

Description

Summary of the change: Add new module to hold SpringReactiveTransactionManager, for use of spring-r2dbc with exposed-r2dbc.

New module structure:

// EXISTING
-- exposed-core + exposed-jdbc
  -- spring7-transaction
    -- exposed-spring-boot4-starter

// NEW
-- exposed-core + exposed-r2dbc
  -- spring7-reactive-transaction

Package comparison looks like:

- spring7-transaction

import org.jetbrains.exposed.v1.spring7.transaction.SpringTransactionManager

- spring7-reactive-transaction

import org.jetbrains.exposed.v1.spring7.reactive.transaction.SpringReactiveTransactionManager

Detailed description:

  • Why: It's not currently possible to use Spring R2DBC + Exposed, in the same way that exists for Spring JDBC + Exposed, without relying on a hack for compatibility. This also is the first step to providing a Spring Boot Starter R2DBC module with autoconfiguration.

  • What:

    • Add new module spring7-reactive-transaction
    • Implement SpringReactiveTransactionManager that extends Spring AbstractReactiveTransactionManager
    • Add equivalent unit tests from existing spring7-transaction test directory

Type of Change

Please mark the relevant options with an "X":

  • New feature

Affected databases:

  • All

Checklist

  • Unit tests are in place
  • The build is green (including the Detekt check)
  • All public methods affected by my PR has up to date API docs
  • Documentation for my change is up to date

Related Issues

EXPOSED-818

@bog-walk bog-walk changed the title feat: Add Spring reactive transaction manager module feat: EXPOSED-818 Add Spring reactive transaction manager module Dec 8, 2025
Comment on lines +115 to +116
// @OptIn(InternalApi::class)
// val outer = ThreadLocalTransactionsStack.getTransactionOrNull(databaseToUse) as? R2dbcTransaction
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to the comments in ExposedTransactionManagerTest.kt/testConnectionCombineWithExposedTransaction2()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now the only failing test. My next thought on how to resolve: maybe a custom ContinuationInterceptor???

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am testing a simpler alternative first: Adding ?: with the returned logic above. So suspendTransaction() would first check coroutine context for a parent, otherwise it would check the stack.

I have created a branch off main with this addition only: bog-walk/test-suspend-transaction-mismatch
To see what happens & to also assess whether there are ever any cases when context does not match stack.
At least in my opinion, there should never be a mismatch; this Spring callback-context issue should be the only instance of such an issue.

The branch passes all tests on TC.
I am now reviewing logs for any mismatch errors.

@obabichevjb Could I ask for assistance on 2 things?

  1. Next step --> I will test this branch on mavenLocal for all Exposed sample projects + my own test projects. Could you also try the branch on any test projects you might have kept locally? Like from when you were solving the "No transaction in context" issues.
  2. I remember that getTransactionOrNull(databaseToUse) may have some search-inefficiency issues. And with this change it would now be run whenever the context returns null. Overall, do you think there would be too much overhead adding this to suspendTransaction()?

@bog-walk bog-walk requested review from e5l and obabichevjb December 8, 2025 06:43
@bog-walk
Copy link
Copy Markdown
Member Author

bog-walk commented Dec 8, 2025

@e5l @obabichevjb I'm opening this up for review a bit early to get feedback about 2 things:

  1. Module naming & structure, with the future spring boot starter in mind (as seen in description above).
    • The final proposed structure can be seen on my test branch
  2. Everything works except for unpopped transactions potentially leaking over into future transactions. If a new transaction is initialized in doBegin() and added to the stack, it reaches the end of its lifecycle and is popped in doCleanupAfterCompletion(). The issue is that this cleanup callback may happen on a different thread than the one where the transaction was created.
    • My solution was to remove any use of the stack in SpringReactiveTransactionManager and rely entirely on synchronizationManager to pass the current transaction & context. And it worked up until any moment when Exposed internally requests TransactionManager.current(), leading to no transaction in context errors.
    • So I'm still working on how to handle thread switching as part of the spring transaction lifecycle & any thoughts would be welcomed.

Copy link
Copy Markdown
Member

@e5l e5l left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

left comments

@bog-walk bog-walk force-pushed the bog-walk/pr-spring-reactive-tm branch from c6d64ae to b67f06e Compare February 6, 2026 01:25
@bog-walk bog-walk force-pushed the bog-walk/pr-spring-reactive-tm branch from b67f06e to 5be8995 Compare February 23, 2026 04:59
@bog-walk bog-walk force-pushed the bog-walk/pr-spring-reactive-tm branch from eed5c28 to 27187d4 Compare March 6, 2026 06:14
@bog-walk bog-walk requested review from e5l and obabichevjb March 6, 2026 06:22
Comment on lines +14 to +16
/** Retrieves the current underlying database connection object in use. */
suspend fun activeConnection(): OriginalConnection = connection

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't particularly want to implement this, but it solves the issue of the exact active connection being passed to Spring ConnectionHolder versus an awaited value from the publisher.

With JDBC, the transaction simply holds a single Connection instance, so it is straightforward to get when synchronizing Exposed & Spring transaction, so that more low-level transactional operations like JdbcTemplate can use the same connection our transactions use.

I wanted to achieve the same functionality for R2DBC using its native DatabaseClient. But Exposed would occasionally retrieve the incorrect connection instance (when the operation happens outside a callback). This essentially will open up access to the private var localConnection in the implementation.

}

// force new coroutine to start in current thread so that potential callbacks can access correct stack
val newConnectionMono: Mono<Boolean> = mono(Dispatchers.Unconfined) {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dispatchers.Unconfined is how I got the transaction stacks to stop leaking across threads. It basically provides the same result as reactor Mono in all the other callbacks, but still let's us call suspend functions when needed.

@e5l How bad of a code smell is this? Is it valid use?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will work in this specific case, but we will have the same issue on line 152.

It looks like you're trying to reuse a thread-local transaction manager and pin execution to the specific thread. It will work in simple cases, but will break if we are dispatched on a different thread (and it looks like it is possible).

It looks like the solution is located around using reactor context (we can check if it is possible to use existing integration or reimplement for our needs
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-reactor/kotlinx.coroutines.reactor/-reactor-context/)

@bog-walk
Copy link
Copy Markdown
Member Author

bog-walk commented Mar 6, 2026

Hi @e5l @obabichevjb , another review request for a big PR, but the important files to check are primarily:

SpringReactiveTransactionManager.kt
R2dbcExposedConnection.kt
R2dbcConnectionImpl.kt
Transaction.kt (for comment on suspendTransaction() change - see test branch)

Also, any thoughts on final module naming + package structure, as detailed in the PR description above.

🙏

@bog-walk bog-walk force-pushed the bog-walk/pr-spring-reactive-tm branch from 49e5345 to ce4969f Compare March 9, 2026 21:06
}

// force new coroutine to start in current thread so that potential callbacks can access correct stack
val newConnectionMono: Mono<Boolean> = mono(Dispatchers.Unconfined) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will work in this specific case, but we will have the same issue on line 152.

It looks like you're trying to reuse a thread-local transaction manager and pin execution to the specific thread. It will work in simple cases, but will break if we are dispatched on a different thread (and it looks like it is possible).

It looks like the solution is located around using reactor context (we can check if it is possible to use existing integration or reimplement for our needs
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-reactor/kotlinx.coroutines.reactor/-reactor-context/)

@bog-walk bog-walk force-pushed the bog-walk/pr-spring-reactive-tm branch from ce4969f to 605aec7 Compare March 19, 2026 00:54
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.

3 participants