Paul Rule
06/24/2025, 2:59 AMnewSuspendedTransaction
block is close, TransactionManager.currentOrNull()
returns a not null value - I would expect it to be null.
If I remove the delay()
call, it will work as expected.
get("/") {
require(TransactionManager.currentOrNull() == null) { "1 - transaction should not exist at the start" }
newSuspendedTransaction {
require(TransactionManager.currentOrNull() != null) { "2 - transaction should exist now" }
delay(2000) // Simulate some processing time - if I remove this delay things will work as I expect
}
// this next line fails
require(TransactionManager.currentOrNull() == null) { "3 - transaction should not exist at the end" }
call.respondText("OK")
}
To make it even more mysterious I cannot get this scenario to fail in a test - the following code works fine, and TransactionManager.currentOrNull()
returns null after the newSuspendedTransaction
scope closes.
suspend fun main() {
val database = TestPostgresDatabase
val fixtures = Fixtures(database).initialise()
coroutineScope {
launch(Dispatchers.IO) {
require(TransactionManager.currentOrNull() == null) { "1 - transaction should not exist at the start" }
newSuspendedTransaction {
require(TransactionManager.currentOrNull() != null) { "2 - transaction should exist now" }
delay(2000) // Simulate some processing time
}
require(TransactionManager.currentOrNull() == null) { "3 - transaction should not exist at the end" }
println("Transaction completed successfully ${TransactionManager.currentOrNull()}")
}.join()
}
}
Can anyone tell me what I’m doing wrong or missing please? What I'm trying to is test if a transaction is already in progress or not using TransactionManager.currentOrNull()
so I know when to initialise my connection with row level security parameters. For the most part its working well but there is this one condition where there is a network call - which must result in yielding similar to the delay above, and then I get left with a closed connection...Paul Rule
06/24/2025, 10:32 AMdelay()
inside the newSuspendedTransaction
block, the coroutine gets suspended and may resume on a different thread. However, the transaction state in Exposed is stored in a ThreadLocal
variable. Here's what happens:
1. Before delay: Transaction runs on Thread A, ThreadLocal
contains the transaction
2. During delay: Coroutine suspends, transaction completes, but ThreadLocal
on Thread A still holds the reference
3. After delay: Coroutine resumes on Thread B, but Thread B's ThreadLocal
may still contain a stale transaction reference from a previous operation
4. I don't know why tests don't show up this problem but I assume that when running in the KTOR environment a lot more is happening and thus different threads are being used - while in tests no such complexity existed.
The solution appears to be to use <http://Dispatchers.IO|Dispatchers.IO>
- eg wrap my transaction with
withContext(<http://Dispatchers.IO|Dispatchers.IO>) { ... }
as in:
get("/") {
require(TransactionManager.currentOrNull() == null) { "1 - transaction should not exist at the start" }
withContext(<http://Dispatchers.IO|Dispatchers.IO>) {
newSuspendedTransaction {
require(TransactionManager.currentOrNull() != null) { "2 - transaction should exist now" }
delay(2000) // Simulate some processing time - if I remove this delay things will work as I expect
}
}
// this next line fails
require(TransactionManager.currentOrNull() == null) { "3 - transaction should not exist at the end" }
call.respondText("OK")
}
This seems to work properly - it also seems to be recommend to "*Always use `Dispatchers.IO`* for database operations in Ktor".
While its great I've found a solution to my problem, if anyone has further insight it would be welcome - I'm not sure why this complexity needs to be exposed in the code, and who knows what else I might be missing...Adam Chęciński
06/24/2025, 5:03 PMnewSuspendedTransaction(<http://Dispatchers.IO|Dispatchers.IO>)
or newSuspendedTransaction(currentCoroutineContext())
?
Something like this
get("/") {
require(TransactionManager.currentOrNull() == null) { "1 - transaction should not exist at the start" }
newSuspendedTransaction(<http://Dispatchers.IO|Dispatchers.IO>) {
require(TransactionManager.currentOrNull() != null) { "2 - transaction should exist now" }
delay(2000) // Simulate some processing time - if I remove this delay things will work as I expect
}
// this next line fails
require(TransactionManager.currentOrNull() == null) { "3 - transaction should not exist at the end" }
call.respondText("OK")
}
koji.lin
06/26/2025, 4:12 AMPaul Rule
06/26/2025, 7:03 AMPaul Rule
06/26/2025, 7:07 AMkoji.lin
06/26/2025, 8:30 AMPaul Rule
06/30/2025, 4:05 AMPaul Rule
06/30/2025, 4:46 AMPaul Rule
06/30/2025, 4:47 AMPaul Rule
06/30/2025, 4:47 AMPaul Rule
06/30/2025, 5:13 AM