Detach materialized SQL rows from transaction lifetime

This commit is contained in:
Sergey Chernov 2026-04-17 20:38:40 +03:00
parent 07cb5a519c
commit 49fc700233
10 changed files with 119 additions and 41 deletions

View File

@ -205,7 +205,8 @@ assertThrows(RollbackException) {
- `columns` — positional `SqlColumn` metadata, available before iteration.
- `size()` — result row count.
- `isEmpty()` — fast emptiness check where possible.
- `iterator()` / `toList()` — normal row iteration.
- `iterator()` — normal row iteration while the transaction is active.
- `toList()` — materialize detached `SqlRow` snapshots that may be used after the transaction ends.
##### `SqlRow`
@ -370,14 +371,24 @@ PostgreSQL-specific notes:
#### Lifetime rules
Result sets and rows are valid only while their owning transaction is active.
`ResultSet` is valid only while its owning transaction is active.
`SqlRow` values are detached snapshots once materialized, so this pattern is valid:
```lyng
val rows = db.transaction { tx ->
tx.select("select name from person order by id").toList()
}
assertEquals("Ada", rows[0]["name"])
```
This means:
- do not keep `ResultSet` or `SqlRow` objects after the transaction block returns
- copy the values you need into ordinary Lyng objects inside the transaction
- do not keep `ResultSet` objects after the transaction block returns
- materialize rows with `toList()` inside the transaction when they must outlive it
The same lifetime rule applies to generated keys returned by `ExecutionResult.getGeneratedKeys()`.
The same rule applies to generated keys from `ExecutionResult.getGeneratedKeys()`: the `ResultSet` is transaction-scoped, but rows returned by `toList()` are detached.
---

View File

@ -65,7 +65,7 @@ db.transaction { tx ->
println(" #" + row["ID"] + " " + row["title"] + " done=" + row["done"] + " due=" + row["due_date"])
}
// If values need to survive after the transaction closes, copy them now.
// toList() materializes detached rows that stay usable after transaction close.
val snapshot = tx.select("select title, due_date from task order by id").toList()
assertEquals("Write a SQLite example", snapshot[0]["title"])
assertEquals(Date(2026, 4, 16), snapshot[1]["due_date"])

View File

@ -21,7 +21,6 @@ import net.sergeych.lyng.Arguments
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.asFacade
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjClass
@ -234,12 +233,10 @@ internal class SqlRuntimeTypes private constructor(
rowClass.addProperty("size", getter = {
val self = thisAs<SqlRowObj>()
self.lifetime.ensureActive(this)
ObjInt.of(self.values.size.toLong())
})
rowClass.addProperty("values", getter = {
val self = thisAs<SqlRowObj>()
self.lifetime.ensureActive(this)
ObjImmutableList(self.values)
})
@ -302,14 +299,13 @@ internal class SqlResultSetObj(
data: SqlResultSetData,
) : Obj() {
val columns: List<Obj> = data.columns.map { SqlColumnObj(types, it) }
val rows: List<Obj> = buildRows(types, lifetime, data)
val rows: List<Obj> = buildRows(types, data)
override val objClass: ObjClass
get() = types.resultSetClass
private fun buildRows(
types: SqlRuntimeTypes,
lifetime: SqlTransactionLifetime,
data: SqlResultSetData,
): List<Obj> {
val indexByName = linkedMapOf<String, MutableList<Int>>()
@ -317,14 +313,13 @@ internal class SqlResultSetObj(
indexByName.getOrPut(column.name.lowercase()) { mutableListOf() }.add(index)
}
return data.rows.map { rowValues ->
SqlRowObj(types, lifetime, rowValues, indexByName)
SqlRowObj(types, rowValues, indexByName)
}
}
}
internal class SqlRowObj(
val types: SqlRuntimeTypes,
val lifetime: SqlTransactionLifetime,
val values: List<Obj>,
private val indexByName: Map<String, List<Int>>,
) : Obj() {
@ -332,7 +327,6 @@ internal class SqlRowObj(
get() = types.rowClass
override suspend fun getAt(scope: Scope, index: Obj): Obj {
lifetime.ensureActive(scope.asFacade())
return when (index) {
is ObjInt -> {
val idx = index.value.toInt()

View File

@ -112,6 +112,34 @@ class LyngJdbcModuleTest {
assertEquals(3L, result.value)
}
@Test
fun testMaterializedRowsListCanBeReturnedFromTransaction() = runTest {
val scope = Script.newScope()
createJdbcModule(scope.importManager)
val jdbcModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.db.jdbc")
val db = jdbcModule.callFn("openH2", ObjString("mem:rows_return_${System.nanoTime()};DB_CLOSE_DELAY=-1"))
val rows = db.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("create table person(id bigint auto_increment primary key, name varchar(120) not null)")
)
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("insert into person(name) values(?)"), ObjString("Ada"))
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("insert into person(name) values(?)"), ObjString("Linus"))
tx.invokeInstanceMethod(requireScope(), "select", ObjString("select name from person order by id"))
.invokeInstanceMethod(requireScope(), "toList")
}
)
assertEquals("Ada", (rows.getAt(scope, ObjInt.Zero).getAt(scope, ObjString("name")) as ObjString).value)
assertEquals("Linus", (rows.getAt(scope, ObjInt.of(1)).getAt(scope, ObjString("name")) as ObjString).value)
}
private suspend fun scalarSelect(scope: net.sergeych.lyng.Scope, db: Obj, sql: String): Long {
val result = db.invokeInstanceMethod(
scope,

View File

@ -250,7 +250,7 @@ class LyngSqliteModuleTest {
}
@Test
fun testRowFailsAfterTransactionEnds() = runTest {
fun testMaterializedRowSurvivesAfterTransactionEnds() = runTest {
val scope = Script.newScope()
val db = openMemoryDb(scope)
var leakedRow: Obj = ObjNull
@ -265,12 +265,34 @@ class LyngSqliteModuleTest {
}
)
val error = assertFailsWith<ExecutionError> {
leakedRow.getAt(scope, ObjString("answer"))
}
val answer = leakedRow.getAt(scope, ObjString("answer")) as ObjInt
assertEquals(42L, answer.value)
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
assertTrue(error.errorMessage.contains("transaction is active"), error.errorMessage)
@Test
fun testMaterializedRowsListCanBeReturnedFromTransaction() = runTest {
val scope = Script.newScope()
val db = openMemoryDb(scope)
val rows = db.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("create table items(id integer primary key autoincrement, name text not null)")
)
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("insert into items(name) values(?)"), ObjString("alpha"))
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("insert into items(name) values(?)"), ObjString("beta"))
tx.invokeInstanceMethod(requireScope(), "select", ObjString("select name from items order by id"))
.invokeInstanceMethod(requireScope(), "toList")
}
)
assertEquals("alpha", stringValue(scope, rows.getAt(scope, ObjInt.Zero).getAt(scope, ObjString("name"))))
assertEquals("beta", stringValue(scope, rows.getAt(scope, ObjInt.of(1)).getAt(scope, ObjString("name"))))
}
@Test

View File

@ -186,7 +186,7 @@ class LyngSqliteModuleNativeTest {
}
@Test
fun testRowFailsAfterTransactionEnds() = runTest {
fun testMaterializedRowSurvivesAfterTransactionEnds() = runTest {
val scope = Script.newScope()
val db = openMemoryDb(scope)
var leakedRow: Obj = ObjNull
@ -201,12 +201,34 @@ class LyngSqliteModuleNativeTest {
}
)
val error = assertFailsWith<ExecutionError> {
leakedRow.getAt(scope, ObjString("answer"))
}
val answer = leakedRow.getAt(scope, ObjString("answer")) as ObjInt
assertEquals(42L, answer.value)
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
assertTrue(error.errorMessage.contains("transaction is active"), error.errorMessage)
@Test
fun testMaterializedRowsListCanBeReturnedFromTransaction() = runTest {
val scope = Script.newScope()
val db = openMemoryDb(scope)
val rows = db.invokeInstanceMethod(
scope,
"transaction",
ObjExternCallable.fromBridge {
val tx = requiredArg<Obj>(0)
tx.invokeInstanceMethod(
requireScope(),
"execute",
ObjString("create table items(id integer primary key autoincrement, name text not null)")
)
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("insert into items(name) values(?)"), ObjString("alpha"))
tx.invokeInstanceMethod(requireScope(), "execute", ObjString("insert into items(name) values(?)"), ObjString("beta"))
tx.invokeInstanceMethod(requireScope(), "select", ObjString("select name from items order by id"))
.invokeInstanceMethod(requireScope(), "toList")
}
)
assertEquals("alpha", stringValue(scope, rows.getAt(scope, ObjInt.Zero).getAt(scope, ObjString("name"))))
assertEquals("beta", stringValue(scope, rows.getAt(scope, ObjInt.of(1)).getAt(scope, ObjString("name"))))
}
@Test

View File

@ -44,11 +44,11 @@ extern class SqlRow {
- iteration to the end or canceled iteration should close the underlying
resources automatically
- using the result set after its transaction ends is invalid
- rows obtained from the result set are also invalid after the owning
transaction ends, even if the implementation had already buffered them
- rows obtained from the result set stay usable after the owning
transaction ends once they have been materialized
If user code wants row data to survive independently, it should copy the
values it needs into ordinary Lyng objects while the transaction is active.
Calling `toList()` while the transaction is active is the normal way to
detach rows for use after the transaction block returns.
*/
extern class ResultSet : Iterable<SqlRow> {
/*
@ -84,7 +84,8 @@ extern class ExecutionResult {
other result set.
If the statement produced no generated values, the returned result set
is empty.
is empty. Call `toList()` if generated-key rows must outlive the
transaction.
*/
fun getGeneratedKeys(): ResultSet
}

View File

@ -61,10 +61,10 @@ Notes:
rather than silently degrading to some other visible type.
- `ResultSet` should stay iterable, but also expose `isEmpty()` for cheap
emptiness checks where possible and `size()` as a separate operation.
- `ResultSet` and all `SqlRow` instances obtained from it are valid only while
the owning transaction is active. After transaction end, any further row or
result-set access should fail with `SqlUsageException`, even if the provider
had buffered data internally.
- `ResultSet` is valid only while the owning transaction is active.
- Materialized `SqlRow` values should be detached snapshots, so
`transaction { tx.select(...).toList() }` is a valid pattern and the rows
remain usable after transaction end.
- Portable SQL parameter values should match the row conversion set: `null`,
`Bool`, `Int`, `Double`, `Decimal`, `String`, `Buffer`,
`Date`, `DateTime`, and `Instant`.

View File

@ -42,11 +42,11 @@ class SqlRow(
- iteration to the end or canceled iteration should close the underlying
resources automatically
- using the result set after its transaction ends is invalid
- rows obtained from the result set are also invalid after the owning
transaction ends, even if the implementation had already buffered them
- rows obtained from the result set stay usable after the owning
transaction ends once they have been materialized
If user code wants row data to survive independently, it should copy the
values it needs into ordinary Lyng objects while the transaction is active.
Calling [toList] while the transaction is active is the normal way to
detach rows for use after the transaction block returns.
*/
interface ResultSet : Iterable<SqlRow> {
/*

View File

@ -96,8 +96,8 @@ row-id behavior are all connection-local.
The provider may stream rows or buffer them, but it must preserve the core
contract:
- result sets are valid only while the owning transaction is active
- rows obtained from a result set are also invalid after the owning
transaction ends, even if they were already buffered
- rows obtained from a result set should stay usable after the owning
transaction ends once they were materialized, e.g. with `toList()`
- iteration closes underlying resources when finished or canceled
- `isEmpty()` should be cheap where possible
- `size()` may consume or buffer the full result