moonstate

A run, step by step

The library is a graph, a state, and a fold. Everything else — checkpoints, questions, resume, fan-out, time travel — is one of those three being careful about something. This page walks the same path the code does, and every block on it is compiled and run by CI against the source you are reading.

01Install

The engine depends on moonbitlang/async and nothing else. The two subpackages are separate imports, so a reader who wants the graph never downloads a database driver or a tracing stack.

moon add moonbitstack/moonstate
import { "moonbitstack/moonstate" }

02State and fold

Two types: the state S, and the update U a node writes. One function, merge : (S, U) -> S. Both go through JSON, because a checkpoint stores the state, and an executor that ships a task elsewhere sends it as JSON.

A node returns steps, not a new state. Write folds an update, Goto leaves the declared edges behind, Send queues a node with its own input, Interrupt stops the run and waits.

#derive(@json.ToJson, @json.FromJson)
struct Counter {
  n : Int32
  log : Array[String]
}

#derive(@json.ToJson, @json.FromJson)
struct Patch {
  n : Int32?
  log : Array[String]?
}

fn merge(state : Counter, patch : Patch) -> Counter {
  { n: patch.n.unwrap_or(state.n), log: state.log + patch.log.unwrap_or([]) }
}

async fn hello() -> Counter raise {
  let graph = @moonstate.Builder::new(merge~, initial={ n: 0, log: [] })
    .entry("seed")
    .node("seed", (_state, _run) => {
      [@moonstate.Write({ n: Some(1), log: Some(["seed"]) })]
    })
    .node("double", (state, _run) => {
      [@moonstate.Write({ n: Some(state.n * 2), log: None })]
    })
    .edge("seed", "double")
    .compile("hello")
  graph.run({ n: None, log: None })
}

async test "the first graph" {
  let out = hello() catch { error => abort((error : Error).to_string()) }
  assert_eq(out.n, 2)
  assert_eq(out.log, ["seed"])
}

out n = 2 · log = ["seed"]

compile checks the structure before a run can happen: an edge to nowhere, a node nothing reaches, a graph with no end, a node entered at run time that nobody declared with routed. Those arrive as Refused, not as a failure halfway through a run.

03Checkpoints

Compile with a Saver and every superstep is written down before it runs. The next run on the same thread opens the last checkpoint instead of starting over, which is what makes a crash survivable and a finished thread restartable.

async fn durable() -> Array[String] raise {
  let saver = @moonstate.MemorySaver::new()
  let graph = @moonstate.Builder::new(merge~, initial={ n: 0, log: [] })
    .entry("seed")
    .node("seed", (_state, _run) => {
      [@moonstate.Write({ n: Some(1), log: Some(["seed"]) })]
    })
    .node("double", (state, _run) => {
      [@moonstate.Write({ n: Some(state.n * 2), log: None })]
    })
    .edge("seed", "double")
    .compile("durable", saver=saver)
  let _ = graph.run({ n: None, log: None }, thread="job")
  let second = graph.run({ n: None, log: None }, thread="job")
  let history = graph.history("job")
  [second.n.to_string(), history.length().to_string(), history[2].id]
}

async test "a finished thread starts a new turn" {
  let out = durable() catch { error => abort((error : Error).to_string()) }
  assert_eq(out, ["2", "6", "cp-000002"])
}

out ["2", "6", "cp-000002"] — the value, six checkpoints, the third one by id

put_writes records a task's result the moment it finishes, so a task that completed is not run twice after a crash even when the rest of its superstep did not survive. state is the thread now; history is every checkpoint it went through, each naming the one it was written on top of.

A saver is an interface, not a table: MemorySaver is for tests, moonstate/sql is for a database, and src/sql/saver.mbt is a hundred lines to read if you want your own.

04Questions

A question is not a callback and not a thread that blocks. The node returns Interrupt(payload), the run ends with GraphError::Interrupted, and the payload is in the checkpoint. The answer arrives later, from anywhere, and the node that asked runs again and reads it.

async fn approve() -> Int32 raise {
  let graph = @moonstate.Builder::new(merge~, initial={ n: 0, log: [] })
    .entry("approve")
    .node("approve", (_state, run) => match run.answer() {
      Some(answer) => {
        let decided : Int32 = @json.from_json(answer)
        [@moonstate.Write({ n: Some(decided), log: None })]
      }
      None => [@moonstate.Interrupt({ "ask": "approve?" })]
    })
    .compile("approval", saver=@moonstate.MemorySaver::new())
  let stopped = try {
    let _ = graph.run({ n: None, log: None }, thread="review")
    "finished"
  } catch {
    @moonstate.GraphError::Interrupted(value~, ..) => value.stringify()
    _ => abort("the run failed for another reason")
  }
  assert_eq(stopped, "{\"ask\":\"approve?\"}")
  let out = graph.resume_with("review", Json(7))
  out.n
}

async test "a question stops the run and the answer continues it" {
  let out = approve() catch { error => abort((error : Error).to_string()) }
  assert_eq(out, 7)
}

stopped with {"ask":"approve?"} then answered n = 7

05Supersteps and the fan-out

A superstep runs the nodes that are queued, merges what they wrote in task order, then decides what runs next. Send queues one node once per input; a join holds a node until every upstream node has run in the same superstep.

#derive(@json.ToJson, @json.FromJson)
struct Report {
  parts : Array[Array[Int32]]
  total : Int32
}

#derive(@json.ToJson, @json.FromJson)
struct Part {
  value : Int32
}

fn report_merge(state : Report, part : Part) -> Report {
  { parts: state.parts + [[part.value]], total: state.total + part.value }
}

async fn fan_out() -> Report raise {
  let graph = @moonstate.Builder::new(merge=report_merge, initial={ parts: [], total: 0 })
    .entry("fan")
    .node("fan", (state, _run) => {
      let steps : Array[@moonstate.Step[Report, Part]] = []
      for index = 0; index < 3; index = index + 1 {
        steps.push(@moonstate.Send("shard", { parts: state.parts, total: index }))
      }
      steps
    })
    .node("shard", (state, _run) => {
      let steps : Array[@moonstate.Step[Report, Part]] = []
      steps.push(@moonstate.Write({ value: state.total * 10 }))
      steps
    })
    .routed("shard")
    .compile("fan")
  graph.run({ value: 0 })
}

async test "one node, three inputs" {
  let out = fan_out() catch { error => abort((error : Error).to_string()) }
  assert_eq(out.total, 30)
}

out total = 30, four shards folded in the order they were queued

The result does not depend on which task finished first: the engine folds the writes in the order the tasks were queued. That is the whole reason merging is a function rather than a set of field annotations.

06The executor

An Executor decides where a superstep's tasks run. The default puts them all in this process at the same time; an executor that ships them elsewhere finds the node by name on the far side and returns the steps as JSON. A policy of your own is one method.

struct OneAtATime {}

pub impl @moonstate.Executor for OneAtATime with fn each(_self, works) {
  let done : Array[@moonstate.Done] = []
  for work in works {
    let outcome = try {
      Ok((work.run)())
    } catch {
      error => Err(error)
    }
    done.push(match outcome {
      Ok(values) => @moonstate.Done::{ values: Some(values), error: None }
      Err(error) =>
        @moonstate.Done::{ values: None, error: Some((error : Error).to_string()) }
    })
  }
  done
}

// The trait's method is not promoted to a plain method of the type: call it
// through the trait, which is what the engine does.
pub extend OneAtATime with @moonstate.Executor::{each}

async fn one_at_a_time() -> Int32 raise {
  let graph = @moonstate.Builder::new(merge~, initial={ n: 0, log: [] })
    .entry("seed")
    .node("seed", (_state, _run) => [@moonstate.Write({ n: Some(5), log: None })])
    .compile("serial", executor=OneAtATime::{})
  let out = graph.run({ n: None, log: None })
  out.n
}

async test "an executor of one's own" {
  let out = one_at_a_time() catch { error => abort((error : Error).to_string()) }
  assert_eq(out, 5)
}

out n = 5, one task at a time

07A database

moonbitstack/moonstate/sql puts the saver and the store on any backend the database seam speaks. SQLite, PostgreSQL and MySQL run the same code: the placeholders, the upsert spelling and the indexes are written once against the dialect the driver reports.

async fn [T : @moondb.AsyncDriver] on_a_database(
  connection : T,
) -> @moonstate.Graph[Counter, Patch] raise {
  let saver = @sql.saver(connection)
  // Creates the tables and their indexes when they are not there; safe to run on
  // every start.
  saver.setup()
  @moonstate.Builder::new(merge~, initial={ n: 0, log: [] })
    .entry("seed")
    .node("seed", (_state, _run) => {
      [@moonstate.Write({ n: Some(1), log: Some(["seed"]) })]
    })
    .compile("on-a-database", saver=saver)
}
// once, at startup
@sql.saver(connection).setup()
@sql.store(connection).setup()

One connection carries one saver and one store: a driver's connection carries one exchange at a time. A blocking driver — SQLite's calls into C — goes in through @blocking.Blocking::new(connection). A driver that does not report its dialect is told which one to use: @sql.saver(connection, dialect=@moondb.Dialect::Postgres).

08Traces

moonbitstack/moonstate/otel wraps an executor so every superstep is a span and every node a child of it, named with the GenAI semantic conventions. A trace follows a thread from superstep to superstep, so a run is one trace however long it takes, and a node span sits under the superstep that ran it.

fn clock() -> Int64 {
  @env.now().reinterpret_as_int64() * 1_000_000L
}

fn random_bytes(width : Int32) -> Bytes {
  match @env.rand(width) {
    Some(bytes) => bytes
    None => abort("the platform has no entropy source")
  }
}

async fn traced_run() -> Array[String] raise {
  let spans = @trace.Recorder::new()
  let tracer = @trace.Tracer::new(
    rand=random_bytes,
    sink=spans,
    sampler=@trace.parent_based(root=@trace.always),
  )
  let executor = @otel.traced(@moonstate.inline, tracer~, now=clock)
  let graph = @moonstate.Builder::new(merge~, initial={ n: 0, log: [] })
    .entry("seed")
    .node("seed", (_state, _run) => [@moonstate.Write({ n: Some(2), log: None })])
    .compile("traced", executor=executor)
  let _ = graph.run({ n: None, log: None })
  spans.spans().map(span => span.name)
}

async test "a run is a tree of spans" {
  let names = traced_run() catch { error => abort((error : Error).to_string()) }
  assert_eq(names, ["seed", "superstep 0"])
}

span names ["seed", "superstep 0"]

now is required, the way moontel's own layer requires it: a library that reads a clock cannot be replayed. The live check in CI sends these spans to weaver registry live-check, and a name this library invented would fail the job.

→What is not on this page

join, branch, subgraphs, retries, cancellation and the functional form are in the README, with the same promise: its blocks compile and its asserted values were produced by running them. The reference lists every public item of the three packages.