A single-threaded software-transactional-memory-style API for Standard ML.
Provides transactional variables (tvar) and atomically blocks with genuine
all-or-nothing rollback: if a transaction raises (including Retry), every
write it performed is undone via an undo log. Inspired by Haskell's STM, but
without concurrency — see the limitations.
Breaking change: the
transferhelper has been removed from the public API. It was a banking-specific example, not a primitive. Inline it at the call site withatomically+readTVar/writeTVar(see the example below).
(* Create transactional variables *)
val acc1 : int Stm.tvar = Stm.newTVar 1000
val acc2 : int Stm.tvar = Stm.newTVar 500
(* Atomic transfer — either both updates happen or neither.
(transfer is no longer built in; inline it like this.) *)
val () = Stm.atomically (fn () =>
let val bal = Stm.readTVar acc1
in if bal < 200 then raise Stm.Retry
else (Stm.writeTVar acc1 (bal - 200);
Stm.writeTVar acc2 (Stm.readTVar acc2 + 200))
end)
(* Read outside a transaction (snapshot) *)
val bal = Stm.readTVar acc1 (* 800 *)
(* orElse: retry first branch, fall back to second *)
val result = Stm.orElse
(fn () => if Stm.readTVar acc1 < 0 then raise Stm.Retry else "ok")
(fn () => "fallback")
(* check b is `if b then () else retry ()` — drives the orElse fallback *)
val ok = Stm.orElse
(fn () => (Stm.check (Stm.readTVar acc1 >= 1000); "rich"))
(fn () => "poor")
(* read-modify-write helpers *)
val () = Stm.atomically (fn () => Stm.modifyTVar acc1 (fn n => n + 1))
val prev = Stm.atomically (fn () => Stm.swap acc2 0) (* set 0, return old *)
(* stateTVar: read, transform, write back the new state, return a result *)
val popped = Stm.atomically (fn () =>
Stm.stateTVar acc1 (fn n => (n, n - 1))) (* returns old, decrements *)
(* atomicallyOpt: NONE instead of a Retry exception *)
val r = Stm.atomicallyOpt (fn () => (Stm.check false; 1)) (* NONE *)
(* runOrRetry: re-run up to N times until it stops retrying *)
val r2 = Stm.runOrRetry 5 (fn () => (Stm.check (cond ()); compute ()))
(* commit-time invariant: roll back and raise Stm.Invariant if violated *)
val () = Stm.invariant (fn n => n >= 0) acc1 (fn () =>
Stm.modifyTVar acc1 (fn n => n - 50))atomically frunsf; on success its writes commit. Iffraises any exception (includingRetry), all tvar writes made inside the transaction are rolled back to their pre-transaction values and the exception propagates.atomicallyOpt fisatomically fbut returnsNONEonRetry(other exceptions still propagate after rollback).runOrRetry n fre-runsfas a fresh transaction up tontimes, stopping at the first non-retrying run.atomicallydoes not block or automatically re-run. It is not an "optimistic-commit-and-validate" loop and there is no wait-for-change: aRetrysimply rolls back and re-raises, leaving the caller to decide.orElse t1 t2runst1as its own transaction; ift1raisesRetryit is rolled back fully (so none of its writes leak) andt2is run.retry ()raisesRetry;check bisif b then () else retry (). Together they drive theorElsefallback: a failingcheckin the first branch rolls that branch back and runs the second.modifyTVar tv fis a transactional read-modify-write;modifyTVar'forces the new value first;swap tv xsetsxand returns the previous value;stateTVar tv fappliesf, writes the new state, and returns a result. All participate in rollback.always inv body/invariant pred tv bodyrunbodyand then check the invariant once at commit; a violation rolls the whole transaction back and raisesInvariant. This is a commit-time assertion, not the continuously revalidatedalwaysof a concurrent STM (there are no interleavings here).- Nested transactions: an inner
atomicallythat commits hands its undo entries to the enclosing transaction, so if the outer transaction later aborts, the inner writes are rolled back too.
- Single-threaded, no concurrency. This is not thread-safe and provides no parallelism or contention handling — it gives transactional rollback semantics for sequential code, not multi-threaded isolation.
- No read-set / version validation. With no other thread mutating tvars mid-transaction, optimistic conflict detection would never fire, so it is deliberately absent; this is honest rollback, not an optimistic-commit loop.
- No automatic retry/blocking.
Retrydoes not suspend and wake on a tvar change; it aborts the transaction (and, insideorElse, selects the fallback). UserunOrRetryor build any wait-and-retry loop yourself. always/invariantare commit-time only, not continuous revalidation.
make example builds and runs examples/demo.sml, which
runs a transaction that increments a counter tvar and appends to a log tvar,
then exercises swap, stateTVar, an orElse/check-driven retry fallback,
and a commit-time invariant (output is byte-identical under MLton and
Poly/ML):
Transaction 1: increment counter, append to log
counter = 1
log = [incremented]
swap counter to 100, old value was 1
stateTVar: returned 100, counter now 99
orElse: took the fallback branch
invariant (counter >= 0) held; counter = 104
smlpkg add github.com/sjqtentacles/sml-stm
smlpkg syncReference from your .mlb:
lib/github.com/sjqtentacles/sml-stm/stm.mlb
make test # MLton
make test-poly # Poly/ML
make all-tests # both
make cleansml.pkg
Makefile
lib/github.com/sjqtentacles/sml-stm/
stm.sig STM signature
stm.sml tvar + atomically/atomicallyOpt/runOrRetry + orElse + retry/check
+ modify/swap/stateTVar + always/invariant
stm.mlb
test/
test.sml transfer (inlined), retry, modify/swap/stateTVar, check/orElse,
atomicallyOpt/runOrRetry, invariant, rollback tests
MIT. See LICENSE.