Long waits are what flows do
An approval, a trial, a renewal: weeks, months. Restate advises splitting such handlers. In Qoqi, wait time.after(90.days()) is one line.
Waiting for months is the point, not a smell.
Restate replays a journal, so every side effect goes through ctx.run and the clock through ctx.date, by discipline. Running invocations stay on their deployment, and its docs advise against handlers that run for days. A Qoqi flow waits as long as the process takes, moves to new code at a stage, and the compiler checks the rules.
An approval, a trial, a renewal: weeks, months. Restate advises splitting such handlers. In Qoqi, wait time.after(90.days()) is one line.
Forget a ctx.run in Restate and the call runs again on every replay, unrecorded. In Qoqi, a call outside a step does not compile.
Restate's server is under the BSL 1.1. Qoqi Flow is Apache 2.0, one binary on SQLite, PostgreSQL or MySQL.
A durable promise, a timeout that throws, and a second handler to resolve it; or one wait match.
run: async (ctx: restate.WorkflowContext, expense: Expense) => { let answer: Answer; try { answer = await ctx.promise<Answer>("answer").get().orTimeout({ days: 7 }); } catch (e) { if (e instanceof restate.TimeoutError) return { status: "expired" }; throw e; } // ...},// and a handler of its own to deliver the answeranswer: async (ctx: restate.WorkflowSharedContext, answer: Answer) => { await ctx.promise<Answer>("answer").resolve(answer);},
let answer = wait match { case let a = self.answers.receive(): a; case time.after(7.days()): null;};if answer == null { return Outcome.Expired;}
ctx.date, ctx.rand, ctx.run: Restate needs its own version of everything that changes between runs.
run: async (ctx: restate.WorkflowContext, user: User) => { // Replayed from its journal: not Date.now(), ctx.date.now() if ((await ctx.date.now()) < user.trialEnds) { await ctx.sleep({ days: 2 }); } // not Math.random(): ctx.rand const tip = user.tips[Math.floor(ctx.rand.random() * user.tips.length)]; // Forgot ctx.run? It runs again on every replay, unrecorded await fetch("https://hooks.example.com/renewals", { method: "POST", body: user.id }); await ctx.run("charge", () => charge(user.id, tip));},
impl Flow<Void> for Renewal { fn run() { // The clock and chance are plain code: read once, never again if time.now() < self.user.trialEnds { wait time.after(2.days()); } let tip = self.user.tips[random.below(self.user.tips.len())]; charge(self.user.id, tip); }}
| Restate | Qoqi Flow | |
|---|---|---|
| How it comes back | Replays its journal | Its state thawed, at the line it was at |
| Code between steps | Deterministic: ctx.date, ctx.rand, ctx.run | Anything |
| A call outside a step | Not journaled; runs again on replay | Refused at compile time (F0001) |
| Waiting for weeks or months | Advised against: split into delayed calls | What flows are for |
| New code under running work | Stays on its deployment, kept up until it ends | Instances move at a stage, checked against flow.lock |
| A step that must never run twice | ctx.run runs again if it was in progress | Settled<T>: never run again; the flow is told Unknown and decides |
| Crash testing | alwaysReplay, to find non-determinism | everyCrash: the test again, stopped after every write, same ending required |
| Steps in your own services | An SDK in each service's language | Any server that speaks the protocol: a signed HTTP POST. SDKs for Go, PHP, TypeScript |
| License | BSL 1.1 server | Apache 2.0 |
It is at v0.1.0. The language, the runtime and the daemon work end to end, and they are tested by crashing them on purpose after every write. What it does not have yet is years in production. Start with a flow that matters, not the one that holds the company up.
Workflow: impl Flow. ctx.run: step fn. Durable promise or awakeable: an Inbox field. ctx.sleep: wait time.after(...). orTimeout and RestatePromise.race: wait match. Deployments: stage.
No. Only the flow is Qoqi. A step that needs your code stays in your service: any server that answers Qoqi's protocol, a signed HTTP POST. qoqi gen writes the typed interfaces for Go, PHP and TypeScript; any other language implements the protocol in a page of code.
Anywhere one binary runs. SQLite on one machine; PostgreSQL or MySQL when several daemons share the work. There is no managed cloud yet.
Also compared
curl -fsSL https://qoqi.dev/install.sh | sh