Durable state graphs for MoonBit: nodes, merging, checkpoints, interrupts and resume. No model in sight.
#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"])
}[dependencies]
"moonbitstack/moonstate" = "0.1.0"import { "moonbitstack/moonstate" }#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)
}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"])
}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)
}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)
}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)
}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"])
}let fetch = @moonstate.task("fetch", async fn(url : String) -> String { ... })
let score = @moonstate.task("score", async fn(text : String) -> Int { ... })
let workflow = @moonstate.entrypoint("rank", async fn(url : String, ctx : @moonstate.Context) -> Int {
let text = fetch.call(ctx, url)
score.call(ctx, text)
}, saver=saver)| Item | Purpose |
|---|---|
| Builder::new(merge~, initial~) | The empty state, and how an update folds into it. |
| Builder::node(name, run, retry?) | A node: state and Run in, steps out. |
| Builder::edge(from, to) | Run to after from. |
| Builder::join(from, to) | Run to once every node in from ran in the same superstep. |
| Builder::branch(from, route) | route picks the next nodes from the merged state. |
| Builder::subgraph(name, graph, enter~, leave~) | A whole graph as one node. |
| Builder::routed(name) | name is entered by Send, Goto or a route. |
| Builder::entry(name) | The node a run starts at. |
| Builder::compile(name, saver?, store?, executor?, limit?) | Check the graph and freeze it. |
| Step | Write, Goto, Send, Interrupt: what a node returns. |
| Graph::run(input, thread?) | Run to the end, or continue a crashed thread. |
| Graph::stream(input, on, thread?) | The same, reporting events. |
| Graph::resume_with(thread, answer) | Continue a thread that is waiting. |
| Graph::resume_stream(thread, answer, on) | The same, reporting events. |
| Graph::state(thread) / Graph::history(thread) | The thread now, and every checkpoint it took. |
| Graph::update(thread, patch, as_node?) | Rewrite the latest state, then continue after a node. |
| Saver / MemorySaver | Where checkpoints live. |
| Store / MemoryStore | Long-term memory: namespaced JSON with a search. |
| Executor / inline | Where a superstep's tasks run. |
| Run | Thread, step, checkpoint, store, emit, cancel. |
| task / entrypoint / Task / Context | The functional form. |
| @sql.saver(driver, names?, dialect?) | Checkpoints in SQLite, PostgreSQL or MySQL. |
| @sql.store(driver, names?, dialect?) | Long-term memory on the same tables. |
| @sql.blocking(driver) | A blocking driver, as one the saver can await. |
| @otel.traced(executor, tracer~, now~) | An executor that reports spans. |
pub(open) trait Saver {
async fn get(Self, thread : String, id? : String) -> Checkpoint?
async fn put(Self, Checkpoint) -> Unit
async fn put_writes(Self, thread : String, id : String, task : String, values : Array[Json]) -> Unit
async fn list(Self, thread : String, limit? : Int, before? : String) -> Array[Checkpoint]
async fn delete(Self, thread : String) -> Unit
}pub(open) trait Store {
async fn get(Self, space : Array[String], key : String) -> Item?
async fn put(Self, space : Array[String], key : String, value : Json) -> Unit
async fn delete(Self, space : Array[String], key : String) -> Unit
async fn search(Self, space : Array[String], query? : String, filter? : Map[String, Json], limit? : Int) -> Array[Item]
}pub(all) suberror GraphError {
StepLimit(limit~ : Int)
NodeFailed(node~ : String, message~ : String)
Interrupted(thread~ : String, value~ : Json)
NotInterrupted(thread~ : String)
MissingThread(thread~ : String)
MissingNode(node~ : String)
Unreadable(reason~ : String)
Cancelled(thread~ : String)
Backend(reason~ : String)
}pub(all) suberror Refused {
NoNodes
NoEntry
UnknownEntry(node~ : String)
DuplicateNode(node~ : String)
DanglingEdge(from~ : String, to~ : String)
Unreachable(node~ : String)
NoExit
}async fn[I, O] Entrypoint::run(self : Entrypoint[I, O], input : I, thread? : String) -> O raise GraphErrorpub struct Graph[S, U] {
name : String
merge : (S, U) -> S
initial : S
entry : String
nodes : Map[String, NodeSpec[S, U]]
edges : Map[String, Array[String]]
joins : Array[(Array[String], String)]
branches : Map[String, Array[async (S) -> Array[String]]]
saver : &Saver?
store : &Store?
executor : &Executor
limit : Int
}async fn[S : FromJson, U] Graph::state(self : Graph[S, U], thread : String) -> Snapshot[S]? raise GraphErrorasync fn[S : ToJson + FromJson, U] Graph::update(self : Graph[S, U], thread : String, update : U, as_node? : String) -> Unit raise GraphErrorimpl Saver for MemorySaverasync fn list(self : MemorySaver, thread : String, limit? : Int, before? : String) -> Array[Checkpoint]async fn put_writes(self : MemorySaver, thread : String, id : String, task : String, values : Array[Json]) -> Unitasync fn MemorySaver::list(self : MemorySaver, thread : String, limit? : Int, before? : String) -> Array[Checkpoint]async fn MemorySaver::put_writes(self : MemorySaver, thread : String, id : String, task : String, values : Array[Json]) -> Unitimpl Store for MemoryStoreasync fn MemoryStore::put(self : MemoryStore, space : Array[String], key : String, value : Json) -> Unitasync fn MemoryStore::search(self : MemoryStore, space : Array[String], query? : String, filter? : Map[String, Json], limit? : Int) -> Array[Item]type NodeSpec[S, U]pub(all) struct Retry {
attempts : Int
delay : Int
}let default_limit : IntInstall
Download zipDurable state graphs for MoonBit: nodes, merging, checkpoints, interrupts and resume. No model in sight.