|
| 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