Bengal STM

Build Status Maven Central Scala License

Software Transactional Memory for Cats Effect with intelligent scheduling.

Bengal STM is a library for writing composable concurrency operations based on in-memory transactions. The library handles all aspects of concurrency management including locking, retries, semantic blocking, and optimised transaction scheduling. STM provides a higher-level concurrency abstraction that offers a safe, efficient, and composable alternative to locks, mutexes, and other low-level primitives.

Key Features

  • Intelligent Runtime Scheduler: Unlike blindly optimistic STM implementations, Bengal's runtime uses a custom scheduler that performs fast static analysis of transaction variable domains to reduce retry likelihood. This ensures consistent performance even for highly-contentious transactional variables.

  • First-Class Transactional Maps: In addition to transactional variables (TxnVar), Bengal includes performant transactional maps (TxnVarMap) as a core API data structure, providing performance benefits over wrapping an entire map in a transactional variable.

  • Cats Effect Integration: Built on Cats Effect for seamless integration with the Typelevel ecosystem.

Requirements

  • Java: 21 or later
  • Scala: 2.13.x

Installation

Add the following dependency to your build.sbt:

libraryDependencies += "ai.entrolution" %% "bengal-stm" % "<version>"

See the Maven Central badge above for the latest version.

Quick Start

import bengal.stm.STM
import bengal.stm.model._
import bengal.stm.syntax.all._
import cats.effect.{IO, IOApp}

object QuickStart extends IOApp.Simple {
  def run: IO[Unit] =
    for {
      stm     <- STM.runtime[IO]
      counter <- stm.TxnVar.of(0)
      _       <- counter.modify(_ + 1).commit(stm)
      value   <- counter.get.commit(stm)
      _       <- IO.println(s"Counter: $value")
    } yield ()
}

API Reference

Example Description Type Signature Notes
STM.runtime[F] Creates a runtime in an F[_] container whose transaction results can be lifted into a container F[_] via commit def runtime[F[_]: Async]: F[STM[F]]
txnVar.get.commit Commits a transaction and lifts the result into F[_] def commit: F[V]
TxnVar.of[List[Int]](List()) Creates a transactional variable def of[T](value: T): F[TxnVar[T]]
TxnVarMap.of[String, Int](Map()) Creates a transactional map of[K, V](valueMap: Map[K, V]): F[TxnVarMap[K, V]]
txnVar.get Retrieves value of transactional variable def get: Txn[V]
txnVarMap.get Retrieves an immutable map (i.e. a view) representing transactional map state def get: Txn[Map[K, V]] Performance-wise it is better to retrieve individual keys instead of acquiring the entire map
txnVarMap.get("David") Retrieves optional value depending on whether key exists in the map def get(key: K): Txn[Option[V]] Will raise an error if the key is never created (previously or current transaction). A None is returned if the value has been deleted in the current transaction.
txnVar.set(100) Sets the value of transactional variable def set(newValue: V): Txn[Unit]
txnVar.setF(Async[F].pure(100)) Sets the value of transactional variable via an abstract effect wrapped in F def setF[F[_]: Async](newValue: V): Txn[Unit] Ensure F[V] does not encapsulate side-effects
txnVarMap.set(Map("David" -> 100)) Uses an immutable map to set the transactional map state def set(newValueMap: Map[K, V]): Txn[Unit] Performance-wise it is better to set individual keys instead of setting the entire map. This operation will create/delete key-values as needed.
txnVarMap.set("David", 100) Upserts the key-value into the transactional map def set(key: K, newValue: V): Txn[Unit] Will create the key-value in the transactional map if the key was not present
txnVar.modify(_ + 5) Modifies the value of a transactional variable def modify(f: V => V): Txn[Unit]
txnVarMap.modify("David", _ + 20) Modifies the value in a transactional map for a given key def modify(key: K, f: V => V): Txn[Unit] Will throw an error if the key is not present in the map
txnVarMap.remove("David") Removes a key-value from the transactional map def remove(key: K): Txn[Unit] Will throw an error if the key doesn't exist in the map
pure(10) Lifts a value into a transactional monad def pure[V](value: V): Txn[V]
delay(10+2) Lifts a computation into a transactional monad (by-name value) def delay[V](value: => V): Txn[V] Argument will be evaluated every time a transaction is attempted. Not advised for side effects.
abort(new RuntimeException("foo")) Aborts the current transaction def abort(ex: Throwable): Txn[Unit] Variables/Maps changes will not be persisted if the transaction is aborted
txn.handleErrorWith(_ => pure("bar")) Absorbs an error/abort and remaps to another transaction def handleErrorWith(f: Throwable => Txn[V]): Txn[V]
waitFor(value > 10) Semantically blocks a transaction until a condition is met def waitFor(predicate: => Boolean): Txn[Unit] Blocking is semantic (no thread locking). Implemented via retries initiated by variable/map updates.

Example: Bank Transfer

This example demonstrates transactional transfers between accounts with semantic blocking until the bank opens:

import bengal.stm.STM
import bengal.stm.model._
import bengal.stm.syntax.all._
import cats.effect.{IO, IOApp}
import scala.concurrent.duration._

object BankTransfer extends IOApp.Simple {

  def run: IO[Unit] = {
    def createAccount(
        name: String,
        initialBalance: Int,
        accounts: TxnVarMap[IO, String, Int]
    )(implicit stm: STM[IO]): IO[Unit] =
      accounts.set(name, initialBalance).commit

    def transferFunds(
        accounts: TxnVarMap[IO, String, Int],
        bankOpen: TxnVar[IO, Boolean],
        to: String,
        from: String,
        amount: Int
    )(implicit stm: STM[IO]): IO[Unit] =
      (for {
        balance    <- accounts.get(from)
        isBankOpen <- bankOpen.get
        _          <- STM[IO].waitFor(isBankOpen)
        _          <- STM[IO].waitFor(balance.exists(_ >= amount))
        _          <- accounts.modify(from, _ - amount)
        _          <- accounts.modify(to, _ + amount)
      } yield ()).commit

    def openBank(
        bankOpen: TxnVar[IO, Boolean]
    )(implicit stm: STM[IO]): IO[Unit] =
      for {
        _ <- IO.sleep(1000.millis)
        _ <- IO.println("Bank Open!")
        _ <- bankOpen.set(true).commit
      } yield ()

    def printAccounts(
        accounts: TxnVarMap[IO, String, Int]
    )(implicit stm: STM[IO]): IO[Unit] =
      for {
        accounts <- accounts.get.commit
        _ <- IO.println(accounts.toList.map { case (k, v) => s"$k: $v" }.mkString(", "))
      } yield ()

    for {
      implicit0(stm: STM[IO]) <- STM.runtime[IO]
      bankOpen                <- TxnVar.of(false)
      accounts                <- TxnVarMap.of[IO, String, Int](Map())
      _                       <- createAccount("David", 100, accounts)
      _                       <- createAccount("Sasha", 0, accounts)
      _                       <- printAccounts(accounts)
      _                       <- openBank(bankOpen).start
      _                       <- transferFunds(accounts, bankOpen, "Sasha", "David", 100)
      _                       <- printAccounts(accounts)
    } yield ()
  }
}

Note: The example uses the better-monadic-for compiler plugin to expose the STM runtime as an implicit in the for-comprehension.

Background

For an introduction to STM concepts, see Beautiful Concurrency by Simon Peyton Jones.

FAQ

Why another STM implementation?

Blindly optimistic execution strategies can lead to poor performance in high-contention scenarios. In production, this sometimes required falling back to sequential transaction execution, negating the benefits of STM. Bengal addresses this with a scheduler that performs static analysis to reduce contention, enabling genuine concurrency even in high-contention scenarios.

Additionally, Bengal treats Map as a fundamental transactional data structure (analogous to a database index), which presents interesting scheduling challenges around structural updates but proves very useful in practice.

How does Bengal differ from cats-stm?

cats-stm is an excellent STM implementation for Cats Effect. Bengal differs in:

  • Implementation: Bengal uses Free Monads with different interpreters for static analysis and building transactional logs
  • API design: cats-stm has orElse for bypassing retries; Bengal intentionally omits this for clearer waitFor semantics
  • Initialization: TxnVar and TxnVarMap initialization occurs outside the Txn[_] monad

Why is there no way to bypass waitFor?

waitFor is designed to have clear semantic delineation from conditional if statements. Bengal short-circuits monadic evaluation on failed waitFor predicates as a performance optimization, which wouldn't be possible if bypass mechanisms needed to be checked.

Why 'Bengal'?

Bengals are a very playful and active cat breed. The name fits a library built on Cats.

Contributing

Contributions are welcome! Please see CONTRIBUTING.md for guidelines.

License

Bengal STM is licensed under the Apache License 2.0.

Copyright 2023 Greg von Nessi