Docs
What it’s for
restate gives Mojo durable execution through Restate: handlers whose state, timers, calls and side effects are journaled, so a retry replays the finished steps instead of running them again. A Rust shim embeds the official Restate SDK, which owns the HTTP/2 endpoint and the journal; your handlers are plain Mojo functions served on a pool of threads. It stands apart from the data path: no other tin depends on it.
Install
A new project needs Modular’s channel, pixi’s build preview and the compiler before the tin:
pixi init app -c https://conda.modular.com/max -c conda-forge && cd app
sed -i.bak 's/^\[workspace\]/[workspace]\npreview = ["pixi-build"]/' pixi.toml
pixi add "mojo-compiler==1.2.0"
pixi shelf add restate-mojo # brings threads-mojo, and builds the Rust shim
The first install compiles the shim (restate-sdk and tokio) with conda-forge’s Rust, which takes a few minutes. Your program needs a Restate server to register with: npx @restatedev/restate-server runs one, and npx @restatedev/restate deployments register http://localhost:9080 points it at your endpoint.
Quick start
A virtual object with a counter and a journaled step. Run it with a Restate server; the program compiles as is.
from restate import App, Ctx, Invocation, Unit
def handle_add(app: App, inv: Invocation, worker: Int, ctx: Ctx[Unit]) raises:
var n = app.get_state_int(inv, "count", 0) + 1 # this key's durable state
app.set_state_int(inv, "count", n)
app.complete(inv, String(n))
def handle_tag(app: App, inv: Invocation, worker: Int, ctx: Ctx[Unit]) raises:
var key = inv.key
def make_tag() raises {imm key} -> String:
return "tag-for-" + key # runs once, then replays
var tag = app.step(inv, make_tag)
app.sleep_ms(inv, 1000) # a journaled timer
app.complete(inv, tag)
def main() raises:
var nothing = Unit()
# Registers `add` and `tag` on the virtual object `Counter`, listens on
# :9080 and serves on one thread per core until `app.stop()`.
_ = App.run[Unit, __functions_in_module()]("Counter", nothing)
Then curl -X POST localhost:8080/Counter/alice/add -d '""' answers 1, then 2.
API
Everything is exported from restate.
Serving. App.run[T: AnyType, funcs: Tuple, /](service: String, ref state: T, object: Bool = True, workflow: Bool = False, host: String = "0.0.0.0", port: Int = 9080, num_workers: Int = 0) raises -> Int finds every handle_* function in the module you pass __functions_in_module() from, registers each under the name after the prefix, serves until stop() and returns how many invocations completed. A handler is def(App, Invocation, Int, Ctx[T]) thin raises -> None: the app, the invocation, the worker index and your shared state. object=True is a Virtual Object (keyed, with state), object=False a stateless Service, and workflow=True a Workflow whose run handler executes once per key.
App(service: String, handlers: List[String], object: Bool = True, workflow: Bool = False, host: String = "0.0.0.0", port: Int = 9080) raises with serve[handler: HandlerFn](self, num_workers: Int = 0, ctx: OpaquePtr = opaque_ptr(0)) raises -> Int or serve_with[T, handler](self, ref state: T, num_workers: Int = 0) raises -> Int is the same loop when you list the handlers and dispatch on inv.handler yourself.
Invocation: handler, key (empty for a Service), id, input (raw bytes) and input_string().
App operations, each taking the invocation first:
get_state(self, inv: Invocation, key: String) raises -> Optional[String],get_state_int(self, inv: Invocation, key: String, default: Int) raises -> Int,set_state(self, inv: Invocation, key: String, value: String) raises,set_state_int,clear_state, and_bytesvariants for binary values.sleep_ms(self, inv: Invocation, millis: Int) raises: a durable timer; a long one suspends the invocation.step[F: def () raises -> String](self, inv: Invocation, f: F, initial_delay_ms: Int = 0, factor: Float64 = 0.0, max_delay_ms: Int = 0, max_attempts: Int = 0, max_duration_ms: Int = 0) raises -> String: replays a journaled result or runsfand journals it. Iffraises, the step is retried; the retry arguments bound that, and with none Restate retries forever.run_enter(self, inv: Invocation) raises -> Optional[String],run_exit(self, inv: Invocation, value: String) raises -> Stringandrun_fail(self, inv: Invocation, message: String, terminal: Bool = True) raises -> Optional[String]: the same protocol by hand, when a step must fail terminally so the handler can compensate.call(self, inv: Invocation, service: String, handler: String, payload: String, key: String = "") raises -> Stringandsend(self, inv: Invocation, service: String, handler: String, payload: String, key: String = "", delay_ms: Int = 0) raises: durable request/response and one-way messages.awakeable_create(inv) -> Stringandawakeable_await(inv, id) -> String: a promise resolved from outside, through another handler’sawakeable_resolve/awakeable_rejector the ingress API.promise_await,promise_peek,promise_resolveandpromise_rejectare a Workflow’s named promises.complete(self, inv: Invocation, output: String) raisesfinishes an invocation;fail(self, inv: Invocation, message: String) raisesends it with a terminal error that isn’t retried.cancel_invocation(inv, invocation_id)cancels another one.stop(self) raisesreleases every worker, from any thread, including from inside a handler;is_stopping()reports it.
Ctx[T]: a typed pointer to the state you passed to run; read it as ctx[].field. Unit is the state for a service that keeps none in process.
Rules and limits
- A handler must end its invocation with
complete,failor a raise. A raise is caught: the invocation is abandoned, Restate delivers it again, and the error is printed unless it’s a suspension (is_suspended(e)). - Code between durable operations has to be deterministic, because a retry replays the journal and re-runs your handler. Put time, randomness and I/O inside
step. Everyrun_enterthat returns None must be closed by exactly onerun_exitorrun_fail, or every later attempt raises a protocol error. - Use at least 2 workers if a handler
calls another handler in the same process: withnum_workers=1that deadlocks. The default, 0, means one per core. A Virtual Object already runs one invocation per key at a time; workers add concurrency across keys. - Payloads are raw bytes: a JSON
"x"arrives with its quotes, so parse and serialize in the handler. ctx[]is shared by every worker at once; fields that several handlers change have to be atomics.- The shim is loaded from
$CONDA_PREFIX/lib, so run your program withpixi run. Errors from the shim raise asrestate <operation> failed: <message>orrestate terminal error in <operation>: <message>. - Workflow mode works but has no end-to-end test yet.
Tests
pixi run it and pixi run it-served each start a throwaway restate-server through npx, register an example endpoint and check it over HTTP. it covers state, sleep, journaled steps and awakeables. it-served covers self-calls with 4 workers, invocations overlapping, and stop() from inside a handler; it also shows that the self-call still deadlocks with 1 worker. Ports can be changed with RESTATE_IT_INGRESS, RESTATE_IT_ADMIN, RESTATE_IT_NODE and RESTATE_IT_ENDPOINT. CI runs both on macOS and Linux with Mojo 1.2.0 and Node 22.
The full reference is the README on GitHub.