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. - `columns` — positional `SqlColumn` metadata, available before iteration.
- `size()` — result row count. - `size()` — result row count.
- `isEmpty()` — fast emptiness check where possible. - `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` ##### `SqlRow`
@ -370,14 +371,24 @@ PostgreSQL-specific notes:
#### Lifetime rules #### 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: This means:
- do not keep `ResultSet` or `SqlRow` objects after the transaction block returns - do not keep `ResultSet` objects after the transaction block returns
- copy the values you need into ordinary Lyng objects inside the transaction - 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"]) 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() val snapshot = tx.select("select title, due_date from task order by id").toList()
assertEquals("Write a SQLite example", snapshot[0]["title"]) assertEquals("Write a SQLite example", snapshot[0]["title"])
assertEquals(Date(2026, 4, 16), snapshot[1]["due_date"]) 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.ModuleScope
import net.sergeych.lyng.Scope import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScopeFacade import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.asFacade
import net.sergeych.lyng.obj.Obj import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBool import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjClass import net.sergeych.lyng.obj.ObjClass
@ -234,12 +233,10 @@ internal class SqlRuntimeTypes private constructor(
rowClass.addProperty("size", getter = { rowClass.addProperty("size", getter = {
val self = thisAs<SqlRowObj>() val self = thisAs<SqlRowObj>()
self.lifetime.ensureActive(this)
ObjInt.of(self.values.size.toLong()) ObjInt.of(self.values.size.toLong())
}) })
rowClass.addProperty("values", getter = { rowClass.addProperty("values", getter = {
val self = thisAs<SqlRowObj>() val self = thisAs<SqlRowObj>()
self.lifetime.ensureActive(this)
ObjImmutableList(self.values) ObjImmutableList(self.values)
}) })
@ -302,14 +299,13 @@ internal class SqlResultSetObj(
data: SqlResultSetData, data: SqlResultSetData,
) : Obj() { ) : Obj() {
val columns: List<Obj> = data.columns.map { SqlColumnObj(types, it) } 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 override val objClass: ObjClass
get() = types.resultSetClass get() = types.resultSetClass
private fun buildRows( private fun buildRows(
types: SqlRuntimeTypes, types: SqlRuntimeTypes,
lifetime: SqlTransactionLifetime,
data: SqlResultSetData, data: SqlResultSetData,
): List<Obj> { ): List<Obj> {
val indexByName = linkedMapOf<String, MutableList<Int>>() val indexByName = linkedMapOf<String, MutableList<Int>>()
@ -317,14 +313,13 @@ internal class SqlResultSetObj(
indexByName.getOrPut(column.name.lowercase()) { mutableListOf() }.add(index) indexByName.getOrPut(column.name.lowercase()) { mutableListOf() }.add(index)
} }
return data.rows.map { rowValues -> return data.rows.map { rowValues ->
SqlRowObj(types, lifetime, rowValues, indexByName) SqlRowObj(types, rowValues, indexByName)
} }
} }
} }
internal class SqlRowObj( internal class SqlRowObj(
val types: SqlRuntimeTypes, val types: SqlRuntimeTypes,
val lifetime: SqlTransactionLifetime,
val values: List<Obj>, val values: List<Obj>,
private val indexByName: Map<String, List<Int>>, private val indexByName: Map<String, List<Int>>,
) : Obj() { ) : Obj() {
@ -332,7 +327,6 @@ internal class SqlRowObj(
get() = types.rowClass get() = types.rowClass
override suspend fun getAt(scope: Scope, index: Obj): Obj { override suspend fun getAt(scope: Scope, index: Obj): Obj {
lifetime.ensureActive(scope.asFacade())
return when (index) { return when (index) {
is ObjInt -> { is ObjInt -> {
val idx = index.value.toInt() val idx = index.value.toInt()

View File

@ -112,6 +112,34 @@ class LyngJdbcModuleTest {
assertEquals(3L, result.value) 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 { private suspend fun scalarSelect(scope: net.sergeych.lyng.Scope, db: Obj, sql: String): Long {
val result = db.invokeInstanceMethod( val result = db.invokeInstanceMethod(
scope, scope,

View File

@ -250,7 +250,7 @@ class LyngSqliteModuleTest {
} }
@Test @Test
fun testRowFailsAfterTransactionEnds() = runTest { fun testMaterializedRowSurvivesAfterTransactionEnds() = runTest {
val scope = Script.newScope() val scope = Script.newScope()
val db = openMemoryDb(scope) val db = openMemoryDb(scope)
var leakedRow: Obj = ObjNull var leakedRow: Obj = ObjNull
@ -265,12 +265,34 @@ class LyngSqliteModuleTest {
} }
) )
val error = assertFailsWith<ExecutionError> { val answer = leakedRow.getAt(scope, ObjString("answer")) as ObjInt
leakedRow.getAt(scope, ObjString("answer")) assertEquals(42L, answer.value)
} }
assertEquals("SqlUsageException", error.errorObject.objClass.className) @Test
assertTrue(error.errorMessage.contains("transaction is active"), error.errorMessage) 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 @Test

View File

@ -186,7 +186,7 @@ class LyngSqliteModuleNativeTest {
} }
@Test @Test
fun testRowFailsAfterTransactionEnds() = runTest { fun testMaterializedRowSurvivesAfterTransactionEnds() = runTest {
val scope = Script.newScope() val scope = Script.newScope()
val db = openMemoryDb(scope) val db = openMemoryDb(scope)
var leakedRow: Obj = ObjNull var leakedRow: Obj = ObjNull
@ -201,12 +201,34 @@ class LyngSqliteModuleNativeTest {
} }
) )
val error = assertFailsWith<ExecutionError> { val answer = leakedRow.getAt(scope, ObjString("answer")) as ObjInt
leakedRow.getAt(scope, ObjString("answer")) assertEquals(42L, answer.value)
} }
assertEquals("SqlUsageException", error.errorObject.objClass.className) @Test
assertTrue(error.errorMessage.contains("transaction is active"), error.errorMessage) 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 @Test

View File

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

View File

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

View File

@ -42,11 +42,11 @@ class SqlRow(
- iteration to the end or canceled iteration should close the underlying - iteration to the end or canceled iteration should close the underlying
resources automatically resources automatically
- using the result set after its transaction ends is invalid - using the result set after its transaction ends is invalid
- rows obtained from the result set are also invalid after the owning - rows obtained from the result set stay usable after the owning
transaction ends, even if the implementation had already buffered them transaction ends once they have been materialized
If user code wants row data to survive independently, it should copy the Calling [toList] while the transaction is active is the normal way to
values it needs into ordinary Lyng objects while the transaction is active. detach rows for use after the transaction block returns.
*/ */
interface ResultSet : Iterable<SqlRow> { 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 The provider may stream rows or buffer them, but it must preserve the core
contract: contract:
- result sets are valid only while the owning transaction is active - result sets are valid only while the owning transaction is active
- rows obtained from a result set are also invalid after the owning - rows obtained from a result set should stay usable after the owning
transaction ends, even if they were already buffered transaction ends once they were materialized, e.g. with `toList()`
- iteration closes underlying resources when finished or canceled - iteration closes underlying resources when finished or canceled
- `isEmpty()` should be cheap where possible - `isEmpty()` should be cheap where possible
- `size()` may consume or buffer the full result - `size()` may consume or buffer the full result