Skip to content

Commit 1ac5418

Browse files
committed
More docs on transactions, one more transaction spec
1 parent 61fc20c commit 1ac5418

2 files changed

Lines changed: 87 additions & 0 deletions

File tree

README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,77 @@ try {
398398
}
399399
```
400400

401+
### How transactions work under the hood (coroutines-aware)
402+
403+
Terpal controllers are implemented to be coroutine-first. Instead of relying on thread-local storage or
404+
manually passing a JDBC Connection (or driver session) around, Terpal attaches the current session and
405+
transaction state to the CoroutineContext. This makes the correct connection automatically available to
406+
all suspend functions that run within the same coroutine scope (and its children), even if those
407+
functions hop threads on Dispatchers.IO.
408+
409+
Key pieces involved (from the controller core):
410+
- CoroutineSession(session, sessionKey): A CoroutineContext element that stores the current driver session
411+
(e.g. a JDBC Connection). When you call ctx.withConnection { ... } or enter a ctx.transaction { ... },
412+
Terpal installs a CoroutineSession into the context using withContext(... + Dispatchers.IO).
413+
- CoroutineTransaction: A CoroutineContext element that marks that a transaction is in progress. The
414+
outer transaction installs this marker and is responsible for commit/rollback. Nested transactions
415+
detect the marker and reuse the same connection and enclosing transaction, avoiding starting a second
416+
physical transaction.
417+
418+
Because these are regular CoroutineContext elements, the session/transaction information is propagated to
419+
child coroutines created with coroutineScope/withContext/launch within the same parent scope. No thread-local
420+
is required, and you never need to pass a Connection by hand.
421+
422+
Practical implications:
423+
- Inside ctx.transaction { ... } you can call .run() directly on queries/actions without passing ctx; the
424+
correct connection is discovered from the coroutine context.
425+
- Suspending and switching threads (e.g. to Dispatchers.IO) does not lose the connection—the context element
426+
comes along for the ride.
427+
- Flows: When you stream results, Terpal uses flowOn with the same CoroutineSession so the same connection is
428+
used consistently for the pipeline, and it is properly closed at completion.
429+
430+
### Nested transactions
431+
432+
Terpal supports nesting transaction blocks. The semantics are:
433+
- The first (outermost) transaction actually begins the database transaction and is responsible for
434+
committing or rolling back.
435+
- Inner transaction blocks detect that a transaction is already in progress via CoroutineTransaction and
436+
simply execute within the same connection/transaction scope. They do not commit independently; any failure
437+
that escapes to the outer block will cause the outer transaction to roll back.
438+
439+
Example:
440+
```kotlin
441+
ctx.transaction {
442+
// Outer transaction started
443+
insertPerson(1, "Joe")
444+
445+
// Inner block sees the active transaction and reuses it
446+
ctx.transaction {
447+
insertPerson(2, "Jim")
448+
}
449+
450+
// If an exception is thrown here, both inserts are rolled back together
451+
}
452+
```
453+
454+
### Launching child coroutines inside a transaction
455+
456+
Since Terpal stores the connection/transaction in the CoroutineContext, launching child coroutines inside a
457+
transaction is safe as long as they remain children of the transaction scope. All of them will transparently
458+
see and use the same connection.
459+
460+
```kotlin
461+
ctx.transaction {
462+
coroutineScope {
463+
launch { Sql("INSERT INTO Person (id, firstName, lastName) VALUES (3, 'Ann', 'Smith')").action().run() }
464+
launch { Sql("INSERT INTO Person (id, firstName, lastName) VALUES (4, 'Bob', 'Jones')").action().run() }
465+
}
466+
// Both launches used the same connection/transaction
467+
}
468+
```
469+
470+
If any child fails and the exception escapes the transaction block, the whole transaction is rolled back.
471+
401472

402473
## Custom Parameters
403474
When a variable used in a Sql clause e.g. `Sql("... $dollar_sign_variable ...")` it needs

terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/postgres/TransactionSpec.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import io.exoquery.sql.run
99
import io.kotest.assertions.throwables.shouldThrow
1010
import io.kotest.core.spec.style.FreeSpec
1111
import io.kotest.matchers.shouldBe
12+
import kotlinx.coroutines.coroutineScope
13+
import kotlinx.coroutines.launch
1214
import kotlinx.serialization.Serializable
1315

1416
class TransactionSpec: FreeSpec({
@@ -61,5 +63,19 @@ class TransactionSpec: FreeSpec({
6163
}
6264
select().runOn(ctx) shouldBe listOf(joe)
6365
}
66+
"child coroutines share the same transaction connection" {
67+
val ann = Person(3, "Ann", "Smith", 333)
68+
val bob = Person(4, "Bob", "Jones", 444)
69+
70+
ctx.transaction {
71+
coroutineScope {
72+
launch { insert(ann).run() }
73+
launch { insert(bob).run() }
74+
}
75+
// Both launches used the same connection/transaction
76+
}
77+
78+
select().runOn(ctx).toSet() shouldBe setOf(ann, bob)
79+
}
6480
}
6581
})

0 commit comments

Comments
 (0)