graphrun 0.1.0

Durable workflow engine
Documentation

Graphrun

Graphrun is a durable workflow engine. You write a graph in YAML or with a typed Rust builder. Both compile to the same IR. Every command goes through Raft, then a redb apply. Kill the process. Open the same data directory again. The run is still there.

Local mode is one Raft voter in-process. A cluster uses the same write path with more voters and optional remote workers. There is no in-memory shortcut.

Install

Library:

[dependencies]
graphrun = "0.1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

CLI (installs the graphrun binary):

cargo install graphrun-cli --locked

Rust 1.90 or newer. Linux and macOS.

Run a workflow

Engine::local owns a data directory. This graph reserves inventory, then charges payment. Built-in handlers in the library implement those two activity names.

dsl: graphrun/v1
id: sequence
version: 1
input_schema: order/v1
output_schema: receipt/v1
start: reserve
nodes:
  reserve:
    kind: activity
    activity: {name: inventory.reserve, version: 1}
    input: {from: workflow.input}
    retry:
      errors: [inventory.unavailable]
      max_attempts: 5
    next: charge
  charge:
    kind: activity
    activity: {name: payment.charge, version: 1}
    input: {from: nodes.reserve.output}
    next: finish
  finish:
    kind: complete
    output: {from: nodes.charge.output}
use graphrun::{Catalog, Engine, Value};
use std::collections::BTreeMap;
use std::time::Duration;

#[tokio::main]
async fn main() -> graphrun::Result<()> {
	let catalog = Catalog::from_json(include_bytes!("catalog.json"))?;
	let engine = Engine::local("/tmp/graphrun-demo").await?;
	let input = Value::Object(BTreeMap::from([
		("order_id".into(), Value::String("o1".into())),
		("amount".into(), Value::Int(1000)),
	]));
	let run = engine.start_yaml(include_str!("sequence.yaml"), &catalog, input).await?;
	let output = engine.wait_terminal(run, Duration::from_secs(10)).await?;
	println!("{output:?}");
	engine.shutdown().await?;
	Ok(())
}

The output is payment_id: pay-1.

A complete, runnable copy (catalog JSON included) is graphrun/examples/order.rs:

cargo run -p graphrun --example order

The catalog is contracts: schema, activity name/version, execution kind, effect kind, recovery, and error codes. YAML and the builder both require it. The example catalog and more graphs live in docs/specs/v1/examples.

Build the same graph in Rust

activity_ref binds input and output types. A mismatch fails at compile time.

use graphrun::builder::{RegionBuilder, WorkflowBuilder};
use graphrun::schema::{DurablePayload, SchemaRef};
use graphrun::Catalog;
use serde::{Deserialize, Serialize};

#[derive(Clone, Serialize, Deserialize)]
struct Order { order_id: String, amount: i64 }
impl DurablePayload for Order {
	fn schema_ref() -> SchemaRef { SchemaRef::named("order", 1).unwrap() }
}

#[derive(Clone, Serialize, Deserialize)]
struct ReservedOrder { order_id: String, amount: i64, reservation_id: String }
impl DurablePayload for ReservedOrder {
	fn schema_ref() -> SchemaRef { SchemaRef::named("reserved_order", 1).unwrap() }
}

#[derive(Clone, Serialize, Deserialize)]
struct Receipt { order_id: String, amount: i64, payment_id: String }
impl DurablePayload for Receipt {
	fn schema_ref() -> SchemaRef { SchemaRef::named("receipt", 1).unwrap() }
}

let catalog = Catalog::from_json(include_bytes!("catalog.json"))?;
let reserve = catalog.activity_ref::<Order, ReservedOrder>("inventory.reserve", 1)?;
let charge = catalog.activity_ref::<ReservedOrder, Receipt>("payment.charge", 1)?;
let mut root = RegionBuilder::<Order>::new();
let reserved = root.activity("reserve", &reserve, root.input())?;
let charged = root.activity("charge", &charge, reserved.output())?;
let root = root.complete("finish", charged.output())?;
let definition = WorkflowBuilder::new("sequence", 1, root).build(&catalog)?;

Then engine.start(definition, catalog, input).await. YAML and builder produce one IR. cargo test --test api yaml_and_builder_while_match checks that parity.

Add serde = { version = "1", features = ["derive"] } for the payload types.

CLI

graphrun serve --local-dir /tmp/graphrun-demo &

graphrun validate \
	--definition sequence.yaml \
	--catalog catalog.json

graphrun start \
	--definition sequence.yaml \
	--catalog catalog.json \
	--input order.json \
	--local-dir /tmp/graphrun-demo

graphrun inspect --run <run-id> --local-dir /tmp/graphrun-demo

start waits for a terminal status unless you pass --no-wait. Inspect prints status, output, blocked_reason, pending waits, and open scopes.

To deliver an external event:

graphrun signal \
	--run <run-id> \
	--name approval \
	--key k1 \
	--event-id aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
	--payload approval.json \
	--local-dir /tmp/graphrun-demo

--key is the wait correlation key. Certificate private keys use --tls-key.

Other commands: cancel, list, history, replay, resolve (blocked compensation, abandon), backup, restore, cluster join|promote|remove.

A cluster member is reached with --endpoint, --ca, --cert, --tls-key, and --server-name instead of --local-dir.

What the engine does

  • One write path. Command, Raft quorum, decide, events, evolve, redb apply. Local and clustered runs share that path.
  • Activities. Catalog contracts are data. Engine::local runs the library handlers for the names in the example catalog (inventory.reserve, payment.charge, counter.increment, and the other fixture names). Those handlers are how the examples complete without a separate worker process.
  • Waits and signals. A run can block on a named signal and a correlation key. EventId is a 32-character hex identity. Duplicate ids are idempotent.
  • Sagas. A failed run compensates open obligations in reverse order. If compensation input cannot be built, the obligation is blocked until resolve supplies input or --abandon --confirm.
  • History. inspect and history are read-only. Replay reconstructs state from recorded events. Apply does not call activities, clocks, or RNG.

Cluster

Three members plus workers is the production shape. Members speak Tonic gRPC over mTLS. Workers claim ready activities from a member. See Run a three-member cluster.

Limits

A definition may have 512 nodes. YAML, payloads, and the normalized IR are each capped at 256 KiB. Unapplied log is capped at 4,096 entries or 64 MiB. The full table is docs/limits.md.

How-tos

The v1 spec is docs/specs/v1.

Development

cargo fmt --all -- --check
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked -- --skip snapshot_controller_fires
cargo build --release --locked -p graphrun-cli
cargo run --locked -p graphrun-e2e -- verify \
	--cli target/release/graphrun \
	--matrix docs/specs/v1/verification-matrix.tsv \
	--artifacts target/e2e-artifacts