Sign in

    transaction

    Software Transactional Memory for MoonBit: TVars, atomic and composable transactions, retry/or_else blocking, TMVar and TQueue, plus an async runner.

    stm
    transactional-memory
    concurrency
    async
    tvar
    tmvar
    tqueue
    Download zip
    Author
    Version
    0.2.0
    License
    Apache-2.0
    Last updated
    5 hours ago
    Downloads
    11

    #azhzx/transaction

    CI License: Apache-2.0

    Software Transactional Memory (STM) for MoonBit: shared TVars updated by atomic, composable transactions, with blocking that composes (retry / or_else) instead of deadlocking.

    Documentation: English manual · 中文手册

    ///|
    test "atomic transfer between accounts" {
    let alice = @transaction.TVar::new(100)
    let bob = @transaction.TVar::new(20)
    @transaction.atomically(txn => {
    let funds = txn.read(alice)
    txn.check(funds >= 30) // block (retry) while funds are short
    txn.write(alice, funds - 30)
    txn.modify(bob, balance => balance + 30)
    })
    inspect(alice.peek(), content="70")
    inspect(bob.peek(), content="50")
    }

    #Why STM

    Locks do not compose: acquiring two locks in different orders deadlocks, and combining guarded operations into one atomic step requires exposing locks. STM composes instead:

    • Atomicity — every atomically block applies all of its TVar writes together or not at all.
    • Isolation — a transaction body sees one consistent snapshot; uncommitted writes of other transactions are never visible.
    • Compositional blocking — txn.retry() blocks the current task until something it read changes, and txn.or_else(first, second) combines two blocking operations into one that wakes when either can proceed. No condition variables, no deadlocks, no missed wake-ups.
    • Automatic restart — if a read variable is committed by another transaction mid-body, the body is rolled back and re-run on a fresh snapshot. You write straight-line code; the runtime handles interference.

    The classic trade-off applies: a transaction body may run more than once, so keep side effects (I/O, logging) out of it or make them idempotent.

    #Packages

    PackagePurposeDepends on
    azhzx/transactionCore: TVar, Txn, atomically, TMVar, TQueuenothing beyond core
    azhzx/transaction/runneratomically_async: transactions whose body may awaitmoonbitlang/async

    Backend support:

    Packagewasmwasm-gcjsnative
    core✓✓✓✓
    runner✓—✓✓

    The core package stays dependency-free and checks on every backend. The runner needs moonbitlang/async, whose current release has no wasm-gc entry point; the affected packages declare supported_targets accordingly.

    #Quick start

    Install the module:

    moon add azhzx/transaction

    Then import the packages you need in moon.pkg:

    ///|
    import {
    "azhzx/transaction",
    "azhzx/transaction/runner",
    } // only if you need async transactions

    Inside a transaction, read and write go through the Txn handle, never the TVar directly. Outside, peek reads the last committed value without starting a transaction:

    ///|
    test "read, write, and peek" {
    let counter = @transaction.TVar::new(0)
    let doubled = @transaction.atomically(txn => {
    txn.modify(counter, x => x + 1)
    txn.read(counter) * 2
    })
    inspect(doubled, content="2")
    inspect(counter.peek(), content="1")
    }

    #Blocking and choice

    txn.check(cond) retries until cond holds, and txn.retry() retries unconditionally. Under the async runner the task parks (it does not spin) and is woken by the next commit to a variable the transaction read:

    ///|
    test "retry blocks the transaction, not the program" {
    let balance : @transaction.TVar[Int] = @transaction.TVar::new(0)
    try
    @transaction.atomically(txn => {
    txn.check(txn.read(balance) >= 10)
    txn.modify(balance, x => x - 10)
    })
    catch {
    err => assert_true(@transaction.is_retry(err))
    } noraise {
    _ => fail("expected the transaction to block")
    }
    inspect(balance.peek(), content="0")
    }

    or_else composes alternatives: take from either mailbox, in one atomic step, without polling:

    ///|
    test "or_else tries the first branch, then the second" {
    let urgent : @transaction.TMVar[String] = @transaction.TMVar::new()
    let regular = @transaction.TMVar::full("regular-mail")
    let message = @transaction.atomically(txn => {
    txn.or_else(t => urgent.take(t), t => regular.take(t))
    })
    inspect(message, content="regular-mail")
    }

    Synchronous atomically propagates Retry to the caller (it cannot park — see is_retry above). To block the current task instead, use the async runner:

    ///|
    async test "consumer parks until a producer commits" {
    let jobs : @transaction.TQueue[Int] = @transaction.TQueue::new()
    let received = Ref(0)
    @aio.with_task_group(group => {
    group.spawn_bg(() => {
    received.val = @runner.atomically_async(txn => jobs.pop(txn))
    })
    @runner.atomically_async(txn => jobs.push(txn, 42))
    })
    inspect(received.val, content="42")
    }

    The body may also await freely: uncommitted writes stay private to the transaction, and the snapshot is re-validated after every suspension.

    #Ready-made structures

    Built from TVar and the same four primitives, usable from any transaction:

    • TMVar[T] — empty-or-full one-slot cell. take retries while empty, put retries while full: a transactional meeting point.
    • TQueue[T] — transactional FIFO queue. pop retries while empty; push, try_pop, peek, length, is_empty round it out.

    ///|
    test "queue hands work to a consumer in FIFO order" {
    let jobs = @transaction.TQueue::new()
    @transaction.atomically(txn => {
    jobs.push(txn, "first")
    jobs.push(txn, "second")
    })
    inspect(@transaction.atomically(t => jobs.pop(t)), content="first")
    inspect(
    @transaction.atomically(t => jobs.try_pop(t)) is Some("second"),
    content="true",
    )
    }

    #Semantics

    • Values must be immutable. For Array/Map values, build a fresh copy (items.copy() then mutate the copy) and write it back; never mutate a value obtained from txn.read.
    • A body may re-run. Retry, Conflict, and validation failures restart the body with a fresh transaction. Side effects are not rolled back.
    • Errors propagate. Any error raised by the body rolls back the writes and reaches the caller unchanged; is_retry distinguishes blocking from failure.
    • Transactions do not nest. An atomically call inside a transaction body runs and commits independently; if it invalidates the outer snapshot, the outer transaction restarts automatically.
    • Commit ordering. A global commit clock plus per-TVar versions detect stale snapshots in O(read set). Validation and commit happen in one synchronous section, so no interleaving can slip between them.

    #Concurrency model

    MoonBit tasks are cooperatively scheduled on a single-threaded event loop, so any number of tasks can share TVars with no data races — this library is designed for that world. True parallel threads are not exposed by the toolchain today; if a shared-memory backend arrives, the design (global version clock, read-set validation, private write sets — a TL2-style layout) ports by wrapping the validation/commit section in a lock.

    #Development

    moon test # wasm (default) — includes README and docstring tests moon test --target native # native backend moon bench --target native --release # benchmark suite (see doc/en.md) moon run examples/showcase # runnable example (see below) moon info && moon fmt # refresh .mbti interfaces and format

    Tests live in four layers: docstring/README doctests, unit tests (*_test.mbt, *_wbtest.mbt next to each package), randomized stress suites with deterministic seeds (stress_test.mbt, chaos_stress_test.mbt, and their runner counterparts), and integration scenarios (tests/). A benchmark suite (transaction_bench_test.mbt) measures the core transaction paths. CI runs check, build, both test targets, the example program, coverage, and benchmarks on every push and pull request.

    #Runnable example

    examples/showcase is a self-contained showcase (atomic transfer + a blocking producer/consumer pipeline):

    moon run examples/showcase # wasm moon run examples/showcase --target native

    Output:

    after transfer: alice=70, bob=50 consumer received: [10, 20, 30]

    #License

    Apache-2.0. The STM semantics (retry/orElse, blocking on the read set) follow the published research literature (see the manual); the implementation is original MoonBit code — nothing is ported from other projects.

    Conflict

    pub suberror Conflict {
    Conflict(Int)
    }

    Raised when a TVar read earlier by the transaction has been committed by another transaction, so the transaction can no longer return a consistent result.

    The payload is the global commit version at which the conflict was detected. atomically and atomically_async restart the body automatically; only custom runners see this error.

    Retry

    pub suberror Retry {
    Retry(Int)
    }

    Raised by Txn::retry to abort the current attempt because it cannot make progress yet. The payload is the global commit version observed at the moment of the retry.

    A blocking runner (such as @transaction/runner.atomically_async) parks until the commit version advances, then runs the body again from the beginning. Under atomically the error propagates to the caller.

    TMVar

    pub struct TMVar[T] {
    // private fields
    }

    A transactional variable that is either empty or holds exactly one value.

    take retries while the cell is empty, and put retries while it is full, which makes TMVar a one-slot meeting point: a take and a put pair up inside one atomic step. Use TQueue when any number of values may be pending.

    Example

    test {
    let box = @transaction.TMVar::full("gift")
    let got = @transaction.atomically(txn => box.take(txn))
    inspect(got, content="gift")
    assert_true(box.peek() is None)
    }

    TMVar::full

    fn[T] TMVar::full(value : T) -> TMVar[T]

    Create a TMVar that already holds value.

    Example

    test {
    let box = @transaction.TMVar::full(1)
    assert_true(box.peek() is Some(1))
    }

    TMVar::is_empty

    fn[T] TMVar::is_empty(self : TMVar[T], txn : Txn) -> Bool raise

    Return whether the TMVar is empty in this transaction.

    Example

    test {
    let box : @transaction.TMVar[Int] = @transaction.TMVar::new()
    let empty = @transaction.atomically(txn => box.is_empty(txn))
    inspect(empty, content="true")
    }

    TMVar::new

    fn[T] TMVar::new() -> TMVar[T]

    Create an empty TMVar.

    TMVar::peek

    fn[T] TMVar::peek(self : TMVar[T]) -> T?

    Read the current contents without removing them; None when empty.

    Example

    test {
    let box = @transaction.TMVar::full(9)
    assert_true(box.peek() is Some(9))
    }

    TMVar::put

    fn[T] TMVar::put(self : TMVar[T], txn : Txn, value : T) -> Unit raise

    Put value, retrying while the TMVar is full.

    Example

    test {
    let box = @transaction.TMVar::new()
    let txn = @transaction.Txn::begin()
    txn.run(_ => box.put(txn, "v"))
    assert_true(box.peek() is Some("v"))
    }

    TMVar::read

    fn[T] TMVar::read(self : TMVar[T], txn : Txn) -> T? raise

    Read the current contents without removing them.

    Example

    test {
    let box = @transaction.TMVar::full(9)
    let value = @transaction.atomically(txn => box.read(txn))
    assert_true(value is Some(9))
    }

    TMVar::take

    fn[T] TMVar::take(self : TMVar[T], txn : Txn) -> T raise

    Take the value, retrying while the TMVar is empty.

    Example

    test {
    let box = @transaction.TMVar::full("x")
    let value = @transaction.atomically(txn => box.take(txn))
    inspect(value, content="x")
    }

    TMVar::try_put

    fn[T] TMVar::try_put(self : TMVar[T], txn : Txn, value : T) -> Bool raise

    Put value if the TMVar is empty, without retrying.

    Returns whether the value was stored.

    Example

    test {
    let box = @transaction.TMVar::full("kept")
    let txn = @transaction.Txn::begin()
    inspect(txn.run(_ => box.try_put(txn, "dropped")), content="false")
    assert_true(box.peek() is Some("kept"))
    }

    TMVar::try_take

    fn[T] TMVar::try_take(self : TMVar[T], txn : Txn) -> T? raise

    Take the value if present, without retrying.

    Example

    test {
    let box = @transaction.TMVar::full(1)
    let txn = @transaction.Txn::begin()
    assert_true(txn.run(_ => box.try_take(txn)) is Some(1))
    assert_true(txn.run(_ => box.try_take(txn)) is None)
    }

    TQueue

    pub struct TQueue[T] {
    // private fields
    }

    A transactional FIFO queue backed by one TVar holding an immutable array.

    pop retries while the queue is empty, so a consumer parks (under @transaction/runner.atomically_async) until a producer pushes. Pushes and pops copy the array, so committed values are never mutated in place; this keeps the implementation simple, and a two-list or linked-node representation can replace it later without changing the API.

    Example

    test {
    let jobs = @transaction.TQueue::new()
    let txn = @transaction.Txn::begin()
    txn.run(_ => {
    jobs.push(txn, "a")
    jobs.push(txn, "b")
    })
    inspect(txn.run(t => jobs.pop(t)), content="a")
    }

    TQueue::is_empty

    fn[T] TQueue::is_empty(self : TQueue[T], txn : Txn) -> Bool raise

    Return whether the queue is empty.

    TQueue::length

    fn[T] TQueue::length(self : TQueue[T], txn : Txn) -> Int raise

    Return the number of elements in the queue.

    This is a transactional read: outside a transaction it reports the last committed length.

    TQueue::new

    fn[T] TQueue::new() -> TQueue[T]

    Create an empty queue.

    Example

    test {
    let queue : @transaction.TQueue[Int] = @transaction.TQueue::new()
    let txn = @transaction.Txn::begin()
    inspect(txn.run(t => queue.is_empty(t)), content="true")
    }

    TQueue::peek

    fn[T] TQueue::peek(self : TQueue[T], txn : Txn) -> T? raise

    Return the front element without removing it.

    Example

    test {
    let queue = @transaction.TQueue::new()
    let txn = @transaction.Txn::begin()
    txn.run(t => queue.push(t, "head"))
    assert_true(txn.run(t => queue.peek(t)) is Some("head"))
    }

    TQueue::pop

    fn[T] TQueue::pop(self : TQueue[T], txn : Txn) -> T raise

    Remove and return the front element, retrying while the queue is empty.

    Example

    test {
    let queue = @transaction.TQueue::new()
    let txn = @transaction.Txn::begin()
    txn.run(t => queue.push(t, 42))
    inspect(txn.run(t => queue.pop(t)), content="42")
    }

    TQueue::push

    fn[T] TQueue::push(self : TQueue[T], txn : Txn, value : T) -> Unit raise

    Append value to the queue.

    Example

    test {
    let queue = @transaction.TQueue::new()
    let txn = @transaction.Txn::begin()
    txn.run(t => queue.push(t, 1))
    inspect(@transaction.atomically(t => queue.length(t)), content="1")
    }

    TQueue::try_pop

    fn[T] TQueue::try_pop(self : TQueue[T], txn : Txn) -> T? raise

    Remove and return the front element, or None when the queue is empty.

    Example

    test {
    let queue : @transaction.TQueue[Int] = @transaction.TQueue::new()
    let txn = @transaction.Txn::begin()
    assert_true(txn.run(t => queue.try_pop(t)) is None)
    }

    TVar

    pub struct TVar[T] {
    // private fields
    }

    A shared transactional variable, the unit of state an STM program is built from.

    Inside a transaction, read it with Txn::read and update it with Txn::write or Txn::modify; the updates become visible to other transactions only when the transaction commits. Outside a transaction, peek returns the last committed value.

    Values stored in a TVar should be immutable: for Array or Map values, write a fresh copy instead of mutating the value returned by Txn::read. Two TVar handles are equal exactly when they refer to the same variable.

    Example

    test {
    let account = @transaction.TVar::new(100)
    @transaction.atomically(txn => {
    let balance = txn.read(account)
    txn.check(balance >= 10)
    txn.write(account, balance - 10)
    })
    inspect(account.peek(), content="90")
    }
    impl Eq for TVar[T]
    impl Hash for TVar[T]

    TVar::equal

    fn[T] TVar::equal(self : TVar[T], other : TVar[T]) -> Bool

    TVar::hash

    fn[T] TVar::hash(self : TVar[T]) -> Int

    TVar::hash_combine

    fn[T] TVar::hash_combine(self : TVar[T], hasher : Hasher) -> Unit

    TVar::new

    fn[T] TVar::new(value : T) -> TVar[T]

    Create a transactional variable with an initial committed value.

    Example

    test {
    let cell = @transaction.TVar::new([1, 2, 3])
    inspect(cell.peek().length(), content="3")
    }

    TVar::not_equal

    fn[T] TVar::not_equal(x : TVar[T], y : TVar[T]) -> Bool

    TVar::peek

    fn[T] TVar::peek(self : TVar[T]) -> T

    Read the last committed value without taking part in a transaction.

    This is a plain read: it never blocks, never conflicts, and may race with in-flight transactions by design. Use it for inspection and for code that runs outside transactions; inside a transaction, use Txn::read so the read joins the transaction's snapshot.

    Example

    test {
    let cell = @transaction.TVar::new(5)
    inspect(cell.peek(), content="5")
    }

    Txn

    pub struct Txn {
    // private fields
    }

    A handle to one running transaction.

    A handle records the reads used for validation, the writes to apply on commit, and the undo actions that remove uncommitted values on abort. Prefer atomically (or @transaction/runner.atomically_async for bodies that need to await) over creating and running a handle manually.

    Txn::begin

    fn Txn::begin() -> Txn

    Create a transaction handle.

    This is a low-level entry point used by runners. Most code should call atomically instead.

    Example

    test {
    let cell = @transaction.TVar::new(1)
    let txn = @transaction.Txn::begin()
    txn.write(cell, 2)
    txn.run(_ => ())
    inspect(cell.peek(), content="2")
    }

    Txn::check

    fn Txn::check(self : Txn, condition : Bool) -> Unit raise Retry

    Retry unless condition holds; a transactional assert that blocks instead of failing.

    Example

    test {
    let cell = @transaction.TVar::new(3)
    let value = @transaction.atomically(txn => {
    txn.check(txn.read(cell) > 0)
    txn.read(cell)
    })
    inspect(value, content="3")
    }

    Txn::commit

    fn Txn::commit(self : Txn) -> Unit raise Conflict

    Validate the read set and apply every buffered write as one atomic step.

    This is the low-level commit behind Txn::run, for custom runners: call it once the body has finished successfully. Raises Conflict — after rolling this handle back — when a variable the transaction read has been committed by another transaction in the meantime; dispatch with is_conflict and restart the body on a fresh handle.

    Example

    test {
    let cell = @transaction.TVar::new(1)
    let txn = @transaction.Txn::begin()
    txn.write(cell, 2)
    txn.commit()
    inspect(cell.peek(), content="2")
    }

    Txn::modify

    fn[T] Txn::modify(self : Txn, tv : TVar[T], f : (T) -> T raise) -> Unit raise

    Read tv, apply f, and write the result back.

    Example

    test {
    let counter = @transaction.TVar::new(7)
    @transaction.atomically(txn => txn.modify(counter, x => x + 1))
    inspect(counter.peek(), content="8")
    }

    Txn::or_else

    fn[T] Txn::or_else(self : Txn, first : (Txn) -> T raise, second : (Txn) -> T raise) -> T raise

    Run first; if it retries, discard its writes and reads, then run second.

    This composes blocking choices: take from either queue, answer from either cache, and so on, without races. A Retry raised by second propagates. Other errors, including Conflict, propagate without running second. The two branches must have the same result type.

    Example

    test {
    let inbox = @transaction.TMVar::new()
    let draft = @transaction.TMVar::full("draft")
    let picked = @transaction.atomically(txn => {
    txn.or_else(t => inbox.take(t), t => draft.take(t))
    })
    inspect(picked, content="draft")
    }

    Txn::read

    fn[T] Txn::read(self : Txn, tv : TVar[T]) -> T raise Conflict

    Read tv through this transaction.

    If this transaction wrote tv already, its own pending value is returned (read-your-own-writes); otherwise the last committed value is returned. Raises Conflict when a previously read variable has been committed by another transaction, so an asynchronous transaction stops as soon as its snapshot becomes stale. atomically and atomically_async restart the body automatically in that case.

    Example

    test {
    let cell = @transaction.TVar::new("hello")
    let value = @transaction.atomically(txn => txn.read(cell))
    inspect(value, content="hello")
    }

    Txn::retry

    fn[T] Txn::retry(_self : Txn) -> T raise Retry

    Abort the current attempt because it cannot make progress.

    The transaction is rolled back and Retry is raised. Under @transaction/runner.atomically_async the current task parks until some transaction commits, then the body runs again from the beginning. Under atomically the Retry error propagates to the caller, since synchronous code cannot block.

    Example

    test {
    let balance = @transaction.TVar::new(0)
    try
    @transaction.atomically(txn => {
    let funds = txn.read(balance)
    txn.check(funds >= 100)
    txn.write(balance, funds - 100)
    })
    catch {
    err => assert_true(@transaction.is_retry(err))
    } noraise {
    _ => fail("expected the transaction to retry")
    }
    inspect(balance.peek(), content="0")
    }

    Txn::rollback

    fn Txn::rollback(self : Txn) -> Unit

    Discard every buffered write of this transaction and remove its pending entries from the variables it touched.

    This is the low-level rollback behind Txn::run: prefer raising an error out of the body and letting the runner roll back. Manual control is meant for custom runners. The handle is reusable afterwards — logs are cleared, so a later run or commit starts from a clean slate.

    Example

    test {
    let cell = @transaction.TVar::new(1)
    let txn = @transaction.Txn::begin()
    txn.write(cell, 99)
    txn.rollback()
    inspect(cell.peek(), content="1")
    txn.run(t => t.write(cell, 2))
    inspect(cell.peek(), content="2")
    }

    Txn::run

    fn[T] Txn::run(self : Txn, f : (Txn) -> T raise) -> T raise

    Run f as one attempt of this transaction.

    On success the writes are validated and committed together. On any error the attempt is rolled back and the error is re-raised. f must not suspend; use run_async for an asynchronous body. Runners that want automatic restarts on Retry and Conflict build their loop around this method, like atomically does.

    Txn::run_async

    async fn[T] Txn::run_async(self : Txn, f : async (Txn) -> T) -> T

    Run an asynchronous transaction body.

    The body may suspend. Uncommitted writes stay private to this transaction (other transactions keep reading the last committed value), and the read set is validated whenever the body observes another value and again at commit time, so a body that awaited resumes on a checked snapshot.

    This is the low-level primitive behind @transaction/runner.atomically_async; prefer calling that instead.

    Txn::write

    fn[T] Txn::write(self : Txn, tv : TVar[T], value : T) -> Unit

    Write value to tv inside this transaction.

    The value becomes visible to other transactions only when this transaction commits. Writing the same variable twice keeps only the last value.

    Example

    test {
    let cell = @transaction.TVar::new(1)
    @transaction.atomically(txn => txn.write(cell, 41 + 1))
    inspect(cell.peek(), content="42")
    }

    atomically

    fn[T] atomically(f : (Txn) -> T raise) -> T raise

    Run f as one atomic transaction.

    All TVar operations performed through the supplied Txn are applied together, or not at all. The transaction body observes a consistent snapshot: if a variable it read is committed by another transaction while the body is still running, the body is discarded and run again from the beginning, so f must be prepared to run more than once. Keep side effects such as I/O and logging out of f, or make them idempotent.

    • If f raises Retry (via Txn::retry or Txn::check), the transaction rolls back and Retry is re-raised to the caller. Synchronous code cannot block; use @transaction/runner.atomically_async to park until the watched variables change.
    • If f raises any other error, every write is discarded and the error is re-raised as-is.
    • The body must not suspend. Use @transaction/runner.atomically_async when the body needs to await.

    Example

    test {
    let balance = @transaction.TVar::new(100)
    @transaction.atomically(txn => {
    let current = txn.read(balance)
    txn.write(balance, current - 30)
    })
    inspect(balance.peek(), content="70")
    }

    commit_version

    fn commit_version() -> Int

    The current global commit version.

    A transaction that retried at version v should run again after this value becomes greater than v. Blocking runners use it to decide when to wake a parked transaction.

    is_conflict

    fn is_conflict(err : Error) -> Bool

    Whether err is the Conflict raised when a transaction's snapshot went stale.

    atomically and atomically_async restart the body automatically on conflict, so only custom runners need this predicate.

    is_retry

    fn is_retry(err : Error) -> Bool

    Whether err is the Retry raised by Txn::retry or Txn::check.

    atomically declares an abstract raise (the body can raise anything), so a caller's catch binds an abstract Error that cannot be pattern matched with constructors. Use this predicate to detect the blocking condition instead:

    Example

    test {
    let cell : @transaction.TVar[Int] = @transaction.TVar::new(0)
    try
    @transaction.atomically(txn => {
    txn.check(txn.read(cell) > 0)
    txn.read(cell)
    })
    catch {
    err => assert_true(@transaction.is_retry(err))
    } noraise {
    _ => fail("expected the transaction to block")
    }
    }

    on_commit

    fn on_commit(listener : () -> Unit) -> Unit

    Register a callback that is invoked after every write transaction commits.

    This is the integration point used by blocking runners. A listener must not call back into atomically and must not block. Listeners are permanent for the lifetime of the program.