Skip to content

Commit 88b116d

Browse files
feat(stdlib): D04 Transactions as linear scopes (ACID via affine) (#275)
## Summary Ships **D04** — "Transactions as linear scopes" — from this session's DB-theory inventory's ship-this-month quick-win list. Adds `stdlib/Transactions.eph` — a self-contained stdlib slice that encodes the transaction concept as a linear handle whose begin/commit/abort triad is statically checked. The unconsumed-linear error already encodes "you forgot to commit/abort". This is essentially what `aqueduct.eph` prototypes by hand. ## Surface area - `Transaction` — linear handle (`Transaction!`) - `Isolation` — ANSI SQL-92 levels + Snapshot - `Outcome<A, E>` — `Committed(A) | Aborted(E) | RolledBack` - Lifecycle: `begin / begin_with / commit / abort / rollback` - Borrowed access: `execute(&txn, sql)`, `query<A>(&txn, sql)` - Higher-order sugar: `with_transaction`, `with_transaction_at` ## Pedigree - Gray, *The Transaction Concept: Virtues and Limitations* (VLDB 1981) - Bernstein, Hadzilacos, Goodman, *Concurrency Control and Recovery in Database Systems* (1987) ch. 2 - Berenson et al., *A Critique of ANSI SQL Isolation Levels* (SIGMOD 1995) — Snapshot isolation - This session's DB-theory inventory ranked D04 as a top-5 ship-this-month quick win (zero prereqs) ## Future work - D05 — isolation levels as algebraic effects (handlers per level) - D06 — serializability proofs via conflict graph - D07 — write-ahead log via Linear Echo 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fcba392 commit 88b116d

1 file changed

Lines changed: 343 additions & 0 deletions

File tree

stdlib/Transactions.eph

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
//
4+
// Ephapax stdlib — Transactions as linear scopes (ACID via affine).
5+
//
6+
// Citations:
7+
// Gray, J. "The Transaction Concept: Virtues and Limitations."
8+
// Proceedings of the 7th International Conference on Very Large
9+
// Data Bases (VLDB '81), 144-154. September 1981.
10+
//
11+
// Bernstein, P. A.; Hadzilacos, V.; Goodman, N. "Concurrency
12+
// Control and Recovery in Database Systems." Addison-Wesley,
13+
// 1987. (Chapter 2 — the canonical ACID treatment.)
14+
//
15+
// The transaction concept — atomicity, consistency, isolation,
16+
// durability — has historically required an external runtime
17+
// (lock manager, WAL, recovery scheduler) to enforce. Linear
18+
// types let us encode the begin/commit/abort triad directly in
19+
// the type system:
20+
//
21+
// * `begin` returns a LINEAR handle `Transaction!`.
22+
// * The handle is consumed EXACTLY ONCE by `commit`, `abort`,
23+
// or `rollback`.
24+
// * Borrowed access (`&Transaction`) interleaves `execute` and
25+
// `query` calls within the scope without releasing the handle.
26+
//
27+
// Forgetting to commit or abort is a STATIC type error: the
28+
// unconsumed-linear diagnostic at the close of the enclosing
29+
// region/function says exactly "you forgot to finalise the
30+
// transaction." This is the L1 contribution — atomicity becomes
31+
// a structural property of the program text, not a runtime check.
32+
//
33+
// Pedigree thread:
34+
// Gray 1981 introduced the transaction as a unit of consistency
35+
// and recovery. Bernstein/Hadzilacos/Goodman 1987 §2 formalised
36+
// the ACID properties. Wadler 1990 ("Linear types can change the
37+
// world!") sketched the linear-handle pattern for resources.
38+
// Ephapax inherits all three lines.
39+
//
40+
// Fit in ephapax:
41+
// This module abstracts over the manual `region r { let! txn =
42+
// begin@r(...); ...; commit(txn) }` pattern. The L1 region
43+
// capability is the source of atomicity: a transaction handle's
44+
// resource lifetime is bounded by an enclosing region, so an
45+
// unwinding panic at the region boundary CANNOT leave a half-
46+
// committed transaction floating. L2 affine + L3 echo provide
47+
// isolation; L4 dyadic-mix is what makes `let txn` (affine query
48+
// result) and `let! txn` (linear handle) coexist in one scope.
49+
//
50+
// Companion stdlib modules to expect later:
51+
// - `stdlib/Isolation.eph` (D05 — isolation-level effect system)
52+
// - `stdlib/Serializable.eph` (D06 — serializability proofs)
53+
// - `stdlib/Wal.eph` (D07 — write-ahead log via Linear Echo)
54+
55+
module Transactions
56+
57+
// --- Isolation levels ---
58+
//
59+
// The four isolation levels canonised by ANSI SQL-92 §4.28 plus
60+
// Berenson et al. 1995 "A Critique of ANSI SQL Isolation Levels"
61+
// (SIGMOD '95). `Snapshot` is included for parity with Postgres /
62+
// Oracle multi-version concurrency control even though it is not
63+
// in ANSI SQL.
64+
//
65+
// Affine: a level descriptor carries no resource obligation; it is
66+
// freely dropped, copied (by reconstruction), and compared.
67+
type Isolation =
68+
| ReadCommitted
69+
| RepeatableRead
70+
| Serializable
71+
| Snapshot
72+
73+
// --- The transaction handle ---
74+
//
75+
// A `Transaction` is a LINEAR handle. Its construction (`begin`)
76+
// allocates a transaction-control block in the underlying engine;
77+
// its consumption (`commit` / `abort` / `rollback`) is the moment
78+
// at which the engine publishes or discards the in-flight write
79+
// set.
80+
//
81+
// Linearity is the ACID guarantee:
82+
// * Atomicity: every `Transaction!` is finalised exactly once,
83+
// so partial commits are unrepresentable.
84+
// * Durability: the consumption point IS the durability
85+
// observable side effect — there is no "did I forget to
86+
// flush?" question.
87+
// * Isolation + Consistency are delegated to the engine and
88+
// surfaced via `Isolation` (D05 will lift them into effects).
89+
//
90+
// Capability story: a transaction handle is bound to the L1
91+
// region active at its `begin` site. Calling `commit` from a
92+
// deeper region is well-typed; calling it from a SHALLOWER region
93+
// is rejected by region inference (the handle would outlive its
94+
// capability).
95+
type Transaction = Transaction(i64)
96+
97+
// --- Outcome of a closed transaction ---
98+
//
99+
// `with_transaction` (below) collapses the linear-handle lifecycle
100+
// into a values-only result. `Committed(a)` carries the body's Ok
101+
// payload; `Aborted(e)` carries the body's Err; `RolledBack`
102+
// signals an engine-initiated rollback (deadlock victim, serial-
103+
// ization failure, etc.).
104+
//
105+
// Affine: outcomes hold no resource — the handle has already been
106+
// consumed by the time an `Outcome` exists.
107+
type Outcome<A, E> =
108+
| Committed(A)
109+
| Aborted(E)
110+
| RolledBack
111+
112+
// --- Lifecycle: begin ---
113+
//
114+
// `begin` opens a transaction at the engine's DEFAULT isolation
115+
// level (whatever the underlying engine reports — typically
116+
// `ReadCommitted` for Postgres, `RepeatableRead` for MySQL).
117+
//
118+
// Linearity: the returned handle MUST be passed to `commit`,
119+
// `abort`, or `rollback` exactly once. The unconsumed-linear
120+
// diagnostic at the end of the enclosing scope IS the "you forgot
121+
// to finalise the transaction" error.
122+
//
123+
// L1 capability: the handle is bound to the active region;
124+
// `begin` is effectively `begin@r` at the source level when
125+
// inferred.
126+
fn begin() -> Transaction! {
127+
__intrinsic_transaction_begin()
128+
}
129+
130+
// --- Lifecycle: begin_with ---
131+
//
132+
// As `begin`, but pins the isolation level explicitly. Use
133+
// `Serializable` when you need conflict-serializable semantics;
134+
// use `Snapshot` for read-mostly analytic workloads under MVCC.
135+
//
136+
// The isolation descriptor is consumed affinely (passed by value);
137+
// it is not retained in the handle's type today. D05 will lift the
138+
// level into the handle's type to make isolation-mismatch
139+
// diagnoses static.
140+
fn begin_with(iso: Isolation) -> Transaction! {
141+
__intrinsic_transaction_begin_with(iso)
142+
}
143+
144+
// --- Lifecycle: commit ---
145+
//
146+
// Publishes the in-flight write set, releases the engine's locks,
147+
// and consumes the linear handle. After `commit` returns, the
148+
// handle binding is gone (consumed); reusing it is a static type
149+
// error (linear use-after-consume).
150+
//
151+
// Durability: the observable side effect of `commit` is the
152+
// engine's flush-and-acknowledge cycle. Crash-recovery is delegated
153+
// to the engine's WAL today; D07 will surface WAL events through
154+
// Linear Echo so the type system can witness durability.
155+
fn commit(txn: Transaction!) -> () {
156+
__intrinsic_transaction_commit(txn)
157+
}
158+
159+
// --- Lifecycle: abort ---
160+
//
161+
// Discards the in-flight write set, releases locks, and consumes
162+
// the linear handle. Use when the application detects a logical
163+
// failure (e.g. a precondition violation) and wants to roll back
164+
// explicitly.
165+
//
166+
// Synonym: `rollback` (below) — provided for readers who learned
167+
// transactions from the SQL `ROLLBACK` keyword.
168+
fn abort(txn: Transaction!) -> () {
169+
__intrinsic_transaction_abort(txn)
170+
}
171+
172+
// --- Lifecycle: rollback ---
173+
//
174+
// Alias of `abort` for clarity at call sites that already use the
175+
// SQL vocabulary. Both consume the linear handle identically.
176+
fn rollback(txn: Transaction!) -> () {
177+
__intrinsic_transaction_abort(txn)
178+
}
179+
180+
// --- Borrowed access: execute ---
181+
//
182+
// Executes a statement that returns no rows (INSERT / UPDATE /
183+
// DELETE / DDL). Returns the affected-row count on success, an
184+
// engine-string error on failure.
185+
//
186+
// Borrow: `&Transaction` is a SHARED borrow. Multiple `execute` /
187+
// `query` calls can interleave within the transaction's scope
188+
// without releasing the linear handle. The borrow ends at the
189+
// statement boundary; the handle is reusable on the next line.
190+
//
191+
// L1 capability: the borrow is scoped to the region active at the
192+
// call site; it cannot escape via closure capture.
193+
fn execute(txn: &Transaction, sql: String) -> Result<i64, String> {
194+
__intrinsic_transaction_execute(txn, sql)
195+
}
196+
197+
// --- Borrowed access: query ---
198+
//
199+
// Executes a statement that returns rows. Type parameter `A` is
200+
// the row-shape the caller expects; engine-level row decoding is
201+
// delegated to `__intrinsic_transaction_query`, which fails with
202+
// `Err` on shape-mismatch.
203+
//
204+
// Borrow: identical to `execute` — shared borrow, statement-
205+
// boundary scope.
206+
//
207+
// Note: a row-streaming variant (returning `Iter<A>` instead of
208+
// `A`) is deferred — it depends on `stdlib/Iter.eph`, which does
209+
// not yet exist.
210+
fn query<A>(txn: &Transaction, sql: String) -> Result<A, String> {
211+
__intrinsic_transaction_query(txn, sql)
212+
}
213+
214+
// --- Convenience: with_transaction ---
215+
//
216+
// Higher-order sugar over the manual lifecycle. Opens a fresh
217+
// transaction at the engine default, runs `body` against the
218+
// linear handle, and either commits (on `Ok`) or aborts (on `Err`).
219+
// The result is collapsed into an `Outcome`.
220+
//
221+
// Atomicity guarantee: `body` MUST consume the handle along every
222+
// control path — this is enforced statically by the linear type.
223+
// The wrapper itself does not finalise the handle; it relies on
224+
// `body` to do so. (The function-signature contract is "body takes
225+
// `Transaction!` and returns `Result`"; the linear-checker proves
226+
// the handle is consumed inside.)
227+
//
228+
// Idiomatic usage:
229+
//
230+
// let outcome = with_transaction((txn) => {
231+
// let _ = execute(&txn, "UPDATE accounts SET balance = ...")
232+
// let _ = execute(&txn, "UPDATE accounts SET balance = ...")
233+
// commit(txn)
234+
// Ok(())
235+
// })
236+
//
237+
// Note: the linear handle threads through `body` and is consumed
238+
// by the explicit `commit`/`abort`/`rollback` call inside. A
239+
// future revision (post-D05) may auto-finalise on Ok/Err by
240+
// inspecting the body's return.
241+
fn with_transaction<A, E>(body: (Transaction!) -> Result<A, E>) -> Outcome<A, E> {
242+
let! txn = begin()
243+
match body(txn) of
244+
| Ok(a) => Committed(a)
245+
| Err(e) => Aborted(e)
246+
end
247+
}
248+
249+
// --- Convenience: with_transaction_at ---
250+
//
251+
// As `with_transaction`, but pins the isolation level. Equivalent
252+
// to opening with `begin_with(iso)` and otherwise delegating.
253+
fn with_transaction_at<A, E>(
254+
iso: Isolation,
255+
body: (Transaction!) -> Result<A, E>
256+
) -> Outcome<A, E> {
257+
let! txn = begin_with(iso)
258+
match body(txn) of
259+
| Ok(a) => Committed(a)
260+
| Err(e) => Aborted(e)
261+
end
262+
}
263+
264+
// --- Pattern guide ---
265+
//
266+
// 1. Manual form (region + let!) — Aqueduct-style:
267+
//
268+
// fn transfer(amount: i32) -> Result<(), String> {
269+
// region r {
270+
// let! txn = begin@r()
271+
// let _ = execute(&txn, "UPDATE accounts SET ... id=1")
272+
// let _ = execute(&txn, "UPDATE accounts SET ... id=2")
273+
// commit(txn)
274+
// Ok(())
275+
// }
276+
// }
277+
//
278+
// Linearity guarantees: forgetting `commit(txn)` is rejected at
279+
// compile time; the unconsumed-linear diagnostic names `txn`
280+
// and points at the region boundary.
281+
//
282+
// 2. Sugar form (with_transaction) — for the common
283+
// "all-or-nothing on body Ok/Err" idiom:
284+
//
285+
// fn transfer(amount: i32) -> Outcome<(), String> {
286+
// with_transaction((txn) => {
287+
// let r1 = execute(&txn, "UPDATE accounts SET ... id=1")
288+
// match r1 of
289+
// | Err(e) => { abort(txn); Err(e) }
290+
// | Ok(_) => {
291+
// let r2 = execute(&txn, "UPDATE accounts SET ... id=2")
292+
// match r2 of
293+
// | Err(e) => { abort(txn); Err(e) }
294+
// | Ok(_) => { commit(txn); Ok(()) }
295+
// end
296+
// }
297+
// end
298+
// })
299+
// }
300+
//
301+
// The wrapper collapses two outcomes (commit-on-Ok and abort-on-
302+
// Err) into a single `Outcome` value; `body` must still consume
303+
// the linear handle, so the inner `commit`/`abort` calls remain
304+
// explicit. A future macro-style revision may elide them.
305+
//
306+
// 3. Pinned-isolation form:
307+
//
308+
// let outcome = with_transaction_at(Serializable, (txn) => {
309+
// // ... reads and writes against &txn ...
310+
// commit(txn)
311+
// Ok(())
312+
// })
313+
314+
// --- Future work (not in this slice) ---
315+
//
316+
// D05. Isolation-level effects: lift `Isolation` into the handle's
317+
// type, so a `Transaction<ReadCommitted>` and a
318+
// `Transaction<Serializable>` are distinct. Static rejection
319+
// of "this query needs Serializable but the handle is
320+
// RepeatableRead" becomes possible.
321+
//
322+
// D06. Serializability proofs: a Coq mechanisation in
323+
// `formal/Serializable.v` proving that the sequential-
324+
// composition of a list of `with_transaction` blocks is
325+
// conflict-serializable under the standard Bernstein 1987 §2
326+
// definition. Requires a model of the schedule lattice.
327+
//
328+
// D07. WAL via Linear Echo: durability becomes type-visible.
329+
// `commit(txn)` produces a `LogEcho!` linear obligation that
330+
// MUST be consumed by `flush(echo)` — losing the echo is the
331+
// "you forgot to fsync" diagnostic. Pedigree: Mohan et al.
332+
// ARIES (1992).
333+
//
334+
// D09. Provenance: thread a why-provenance value through
335+
// `execute`/`query` so that the answer to "what writes
336+
// contributed to this read?" is type-derivable. Pedigree:
337+
// Buneman et al. (TODS 2001) and Green et al. (PODS 2007).
338+
//
339+
// Coq mechanisation: out of scope for this PR. A `formal/
340+
// Transactions.v` follow-up would mechanise (a) the linear-
341+
// finalisation lemma (every `begin` is reachable by exactly one
342+
// `commit`/`abort`/`rollback`) and (b) the with_transaction
343+
// soundness theorem (Outcome reflects body's Result, never both).

0 commit comments

Comments
 (0)