Every round, your function runs again from the top
That one sentence is the whole design. The API, the constraints and the failure modes all follow from it, so it is worth five minutes.
On this page
The loop
- 1Trigger publishes an event
Trigger writes one event to a Redis-backed queue and hands you back a callId. Nothing of your flow has executed yet.
- 2A worker replays your function
Some worker — not necessarily the one that triggered it — picks the event up and calls your flow function from its very first line. It does not resume in the middle. It starts over.
- 3Finished steps are read, not run
Each step looks up its own state first. A Task that is already done returns immediately without calling your closure; a Memo hands back the value it stored instead of building it again.
- 4The first unfinished step runs, then the round ends
gotick writes the new state down, stops the run, and schedules the next event — for right now, or for thirty minutes from now if you asked to sleep. Next round: same thing, one step further.
How a round stops: panic
There are no coroutines to park here and no code generation step. A step that has to wait raises a breakpoint — a panic carrying a gotick.Breakpoint value — which the scheduler recovers at the top of the run. That is the trick that lets a flow be an ordinary closure, with no await keyword and no special syntax anywhere in it.
One consequence is worth knowing before it bites you: a bare recover() in the flow function, outside a Task, swallows the breakpoint, and the flow quietly stops making progress. If you need to recover there, re-panic when the recovered value implements gotick.Breakpoint. Inside a Task, recover freely — that code runs on a normal call stack.
tick.Flow("demo", func(ctx *gotick.Context) { defer func() { if r := recover(); r != nil { if _, isBreak := r.(gotick.Breakpoint); isBreak { panic(r) // let the breakpoint through, or the flow stops here } log.Printf("flow panicked: %v", r) } }() gotick.Task(ctx, "step", doWork)})How many rounds
Every step that writes state costs a round, including one that succeeded — success has to be persisted before the next step is allowed to run. The function returning normally costs one more. So a flow with four sequential tasks runs from the top at least five times. The one shortcut: when a retry backoff has under a second left, gotick waits in place rather than paying for another round.
The rule: outside a Task, be deterministic
Two runs of the same call have to reach the same decisions. When they don't, one run takes a branch the next one doesn't — and the same call ends up having done two contradictory things. That failure is silent: no error, just an order that shipped and closed.
| What you're doing | Don't | Do |
|---|---|---|
| Reading the clock | if time.Now().Hour() > 12 | Memo the timestamp once, then branch on that |
| Reading a database | user, _ := db.GetUser(id) | gotick.Memo(ctx, "user", ...), or read it inside a Task |
| Random values, UUIDs | id := uuid.NewString() | Memo the generated value |
| Looping over a list | items, _ := db.ListItems() | gotick.Array(ctx, "items", ...) remembers the list and its order |
| Anything with a side effect | mailer.Send(to, body) | wrap it in gotick.Task |
Nothing enforces this today — no static check, no runtime guard. It is the sharpest edge in gotick, and right now it lives in the docs rather than in the code.
The key is the step's identity
Every primitive takes a key as its second argument, and that key — not the line number, not the call order — is how gotick recognises a step across runs. Two steps in one flow must not share a key: gotick panics on a duplicate rather than letting the second one read the first one's state. Keys have to be stable as well, so renaming one while instances are in flight makes that step start over as a brand-new step. Inside a loop the key has to vary per iteration, which is what ArrayWrap.Key(prefix) and SequenceWrap.TaskKey(prefix) are for.