No determinism rules
In Temporal, time.Now, rand, goroutines and ranging over a map are bugs waiting for a replay. A Qoqi flow never runs twice: it is just code.
Temporal's durability, without the determinism rules.
Temporal replays your workflow from its event history, so your code has to play back identically, forever. Qoqi Flow freezes the state of execution and thaws it at the line it was at. Nothing replays, so nothing has to be deterministic.
In Temporal, time.Now, rand, goroutines and ranging over a map are bugs waiting for a replay. A Qoqi flow never runs twice: it is just code.
A network call outside a step does not compile, and the error shows the path of calls that leads to it. Temporal finds out on replay, in production, or never.
No Temporal Service, no Cassandra, no Elasticsearch, no worker fleet. qoqi serve and a SQLite file; PostgreSQL or MySQL to share the work.
The same wait, in Temporal's Go SDK and in Qoqi.
var answer Answeranswered := falsetimerCtx, cancelTimer := workflow.WithCancel(ctx)sel := workflow.NewSelector(ctx)sel.AddReceive(workflow.GetSignalChannel(ctx, "answers"), func(c workflow.ReceiveChannel, more bool) { c.Receive(ctx, &answer) answered = true cancelTimer() })sel.AddFuture(workflow.NewTimer(timerCtx, 7*24*time.Hour), func(f workflow.Future) {})sel.Select(ctx)if !answered { return "expired", nil}
let answer = wait match { case let a = self.answers.receive(): a; case time.after(7.days()): null;};if answer == null { return Outcome.Expired;}
Temporal has a replacement for the clock, for sleep, for randomness, for goroutines. Qoqi needs none.
func Renewal(ctx workflow.Context, user User) error { // Replayed from its history: no time.Now(), it's workflow.Now(ctx) if workflow.Now(ctx).Before(user.TrialEnds) { // no time.Sleep: workflow.Sleep _ = workflow.Sleep(ctx, 48*time.Hour) } // no rand: wrap it in a SideEffect, or it changes on replay var pick int _ = workflow.SideEffect(ctx, func(ctx workflow.Context) interface{} { return rand.Intn(len(user.Tips)) }).Get(&pick) // no goroutines, channels, select, or ranging over a map return workflow.ExecuteActivity(ctx, Charge, user.ID, user.Tips[pick]).Get(ctx, nil)}
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); }}
Temporal branches on a version marker, kept for as long as old executions live. A Qoqi instance moves to new code at a stage, its state checked against flow.lock.
func Expense(ctx workflow.Context, exp Expense) (string, error) { // ... notify, wait for the answer ... v := workflow.GetVersion(ctx, "notify-accounting", workflow.DefaultVersion, 1) if v != workflow.DefaultVersion { err := workflow.ExecuteActivity(ctx, NotifyAccounting, exp.ID).Get(ctx, nil) if err != nil { return "", err } } // Keep this branch until every execution started before it // has left retention. One GetVersion per change, for good. var payout string err := workflow.ExecuteActivity(ctx, Pay, exp).Get(ctx, &payout) return payout, err}
impl Flow<Outcome> for Expense { fn run() -> Outcome { notifyManager(self.id); let answer = wait self.answers.receive(); // Running instances move to new code here, and only here stage paying; notifyAccounting(self.id); // new in version 2 return pay(self.employee, self.cents); }}// $ qoqi deploy expense.qi// expense deployed, version 7b02e1c4 in place of 43381189:// 12 instances move to it at their next stage
| Temporal | Qoqi Flow | |
|---|---|---|
| Workflow code | Must replay identically: no time.Now, rand, goroutines, map ranges | Plain code: the clock, chance, any order |
| A call outside a step | Allowed; runs again on every replay | Refused at compile time (F0001) |
| Waiting for a message or a deadline | Selector, signal channel, timer, cancel | wait match |
| New code under running workflows | GetVersion branches kept for good, or old workers kept until runs end | Instances move at a stage, checked against flow.lock |
| A step that must never run twice | Activities run at least once | Settled<T>: never run again; the flow is told Unknown and decides |
| Long-lived workflows | Event history capped at 51,200 events or 50 MB: Continue-As-New | No event history to replay |
| Crash testing | Write it yourself | everyCrash: the test again, stopped after every write, same ending required |
| Steps in your own services | An SDK in the worker's language | Any server that speaks the protocol: a signed HTTP POST. SDKs for Go, PHP, TypeScript |
| To run it | Temporal Service, a database, Elasticsearch, your workers | One binary |
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. Activity: step fn. Signal: an Inbox field. Timer: wait time.after(...). Selector: wait match. Patching: stage, and migrate fn when the state changes shape. Worker: qoqi serve.
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