Durable workflows,
as plain programs.
Write the process from top to bottom: wait three days, ask a manager, charge a card, retry, give up. Qoqi Flow keeps it alive through crashes, restarts and new versions of its own code, and never runs a finished step twice.
curl -fsSL https://qoqi.dev/install.sh | sh
impl Flow<Void> for Trial { fn run() { welcome(self.user); wait time.after(3.days()); if !hasCreatedAProject(self.user) { sendTips(self.user); } wait time.after(10.days()); // a deploy meanwhile? it goes on if !isSubscribed(self.user) { offerDiscount(self.user, 20); wait time.after(1.days()); endTrial(self.user); } }}
- step
welcome("ada")sent - wait
3 daysfrozen in the store - step
hasCreatedAProject("ada")false - step
sendTips("ada")sent - wait
10 daysfrozen in the store - deploy
trial.qi, version 2it stays at its line - step
isSubscribed("ada")true - end
subscribeddone
Two weeks, written as two weeks. A new version was deployed while it waited: this trial went on from its line, on the code it started with; the trials started after run the new one.
impl Flow<Result<String, Problem>> for Checkout { fn run() -> Result<String, Problem> { let hold = reserve(self.cart); // undone if it fails let payment = charge(self.cart)?; // once, crash or not let parcel = ship(self.cart, hold)?; // fails? refund, release receipt(self.cart, payment, parcel); return Ok(parcel); }}// Run again after a crash, with the same key: charged oncestep fn charge(cart: Cart) -> Result<String, Problem> { let payment = bank.charge(cart.card, cart.cents, flow.key()); flow.undo(refund, payment); return Ok(payment);}
- step
reserve(cart)hold-1, its undo noted - crash
during bank.charge(...)did the bank charge? - step
charge(cart)run again, same key: charged once - step
ship(cart, "hold-1")"parcel-7781" - step
receipt(cart, …)sent - return
Ok("parcel-7781")done
The process died in the middle of the charge. The step ran again under the same key, so the bank charged once; had the parcel failed, the payment would be refunded and the goods released, the last first.
impl Flow<String> for Agent { fn run() -> String { let messages = [Message.user(self.question)]; for _ in 0..20 { let reply = complete(messages); // kept: never asked twice if reply.tool == null { return reply.text; } if reply.tool == "refund" { // money: a person says yes askReviewer(self.reviewer, reply.text); let approved = wait self.approvals.receive(); if !approved { return "refused by ${self.reviewer}"; } } messages.push(callTool(reply)); // each tool call, once } return "gave up after 20 turns"; }}
- step
complete(messages)wants refund(#4412, 89 €) - step
askReviewer("sam", …)sent - wait
approvals.receive()frozen in the store - deploy
every worker restarts - message
approvals: truethawed, at this line - step
callTool(refund)refunded 89 € - step
complete(messages)"Your refund is on its way." - return
"Your refund is on its way."done
Each answer of the model and each tool call is kept: after the restart, nothing was asked twice and nothing was paid twice. The agent waited three hours for a person, costing nothing.
impl Flow<Decision> for Expense { fn run() -> Decision { let approver = managerOf(self.author); for _ in 0..3 { ask(approver, self.id); // It may stop here for days, and pick up where it was let answer = wait match { case let a = self.answers.receive(): a; case time.after(2.days()): null; }; if answer != null { if answer.approved { return Decision.Approved(approver); } return Decision.Refused(answer.why); } approver = managerOf(approver); // no answer: one level up } return Decision.Unanswered; }}
- step
managerOf("lin")"grace" - step
ask("grace", "1")sent - wait
an answer, or 2 daysfrozen in the store - timer
2 daysno answer - step
managerOf("grace")"ada" - step
ask("ada", "1")sent - message
answers: approvedthawed on node-2 - return
Approved("ada")done
Nobody answered in two days, so it asked one level up. For three days it was a row in the store, not a process: the calls in progress and their variables, taken up at the line they were at.
The console
Every instance, at the line it is at.
Started from a form made from its types, waiting at its line with what its steps returned, answered from its inbox. Built into qoqi serve.
Why
A process that waits is usually taken apart by hand.
For a payment, a person, a webhook, a date. Nobody can read it from top to bottom any more, and the bugs live in the gaps. Qoqi Flow keeps it a program.
Taken apart
- the line it is ata
statuscolumn - its variablesa JSON field
- its sleepa delayed job
- its retriesa counter, and a cron
- its crash recoverya sweeper
With Qoqi Flow
- the line it is atthe line it is at
- its variablesits variables
- its sleep
wait time.after(3.days()) - its retriesa
forloop - its crash recoverynothing to write
What it does
A language and a runtime, made for flows.
Checked when it compiles
A flow reaches the world only through a step fn. The compiler points at the call that reaches the network, a file or a database outside a step, with the path of calls that leads there.
Frozen, not replayed
An instance is taken up from what was saved, not by running its history again. The code need not be deterministic, and a new version never re-runs an old history.
exactly onceSteps run once
What a step returned is written before the flow goes on. One that may have half run is run again under the same key; one declared Settled<T> is never run twice.
New code under running instances
stage marks where an instance may move to new code, and flow.lock records its shape. A change without a migrate fn is flagged, and refused by the daemon.
Tests that crash on purpose
A trial runs on a clock of its own, so three days pass in a moment. everyCrash stops the process after each write, and requires the same ending every time.
Steps in your application
A step can be carried out by your own service, through a queue. qoqi gen writes the typed interfaces from the flow.
Testing
Three days pass in a moment. Every crash, tried.
A trial runs a flow on a clock of its own. trial.replace(step, fn) stands a function in for a step, so a test reaches no real service. Then qoqi test.
// The same scenario again, with the process stopped after// each of its writes: it has to end the same every timefn testAnAnswerSurvivesCrashes() { flow.test(Expense { id: "3", author: "lin" }).everyCrash(fn(trial: flow.Trial<Decision>) { trial.go(); trial.advance(2.days()); // two days, in a moment trial.send("answers", Answer { approved: true, why: "" }); });}
Your application
Steps your service carries out, typed from the flow.
A step declared by a queue has no body in the flow: the daemon hands it to your application, signed with the queue's secret, and keeps what it answers. The declaration says how long it may take, and which step undoes it.
step fn charge(customer: String, cents: Int) -> Result<String, Decline> within 30.seconds() by payments undo refund;
$ qoqi gen go shop.qi --package steps > steps/steps_gen.go$ qoqi gen php shop.qi --namespace 'App\Flow' --out src/Flow$ qoqi gen ts shop.qi > steps.ts
One binary
Served, started, answered. In a minute.
The compiler, the daemon, an HTTP API, a web console, a test runner, a formatter and a language server, all in qoqi. The store is SQLite, PostgreSQL or MySQL; daemons on several machines share one.
$ qoqi serve &$ qoqi deploy approvals.qi$ qoqi start Expense expense:1 '{"id": "1", "author": "lin"}'$ qoqi send expense:1 answers '{"approved": true, "why": ""}'$ qoqi result expense:1Approved("grace")$ qoqi ui
- compiler
- daemon
- HTTP API
- web console
- test runner
- formatter
- language server
- SQLite · PostgreSQL · MySQL
How it compares
Not a library added to a language.
| Temporal, Restate, Inngest, DBOS, Workflow SDK and others | Qoqi Flow | |
|---|---|---|
| What it is | A library or service added to an existing language | A language and runtime made for flows |
| Recovery after a crash | A history played again; the code must be deterministic | The state frozen and thawed |
| Reaching the world outside a step | Found when it runs, or not at all | Refused when it compiles |
| A new version of the code | A run is pinned to the version that started it, or patched by hand | Pinned until the instance reaches a stage, where it moves, checked against flow.lock |
| Testing crashes | Left to you | everyCrash |
What they have and Qoqi Flow does not yet: years in production, managed hosting, and SDKs for writing the flows themselves in many languages.
Write the process. Let it wait.
curl -fsSL https://qoqi.dev/install.sh | sh