English · 简体中文 · Documentation
Durable workflows for Go, on the Redis you already run.
Close the order in thirty minutes. Email the user three days after signup. These flows live longer than the process that started them. gotick lets you write them down as they read — and a restart, a crash or a deploy won't send them back to the beginning.
There is no server to deploy. It uses the Redis you already have.
go get github.com/zbysir/gotickpackage main
import (
"context"
"log"
"time"
"github.com/zbysir/gotick"
)
func main() {
tick, err := gotick.NewServerFromConfig(gotick.Config{
RedisURL: "redis://localhost:6379/0",
})
if err != nil {
log.Fatal(err)
}
// A flow is an ordinary Go function. You don't call it — you trigger it.
tick.Flow("order/close", func(ctx *gotick.Context) {
gotick.Task(ctx, "create-order", func(c *gotick.TaskContext) error {
log.Println("order created")
return nil
})
// Not time.Sleep. Nothing is running while it waits, and you can
// restart the process here — the flow still wakes up on time.
gotick.Sleep(ctx, "wait-payment", 30*time.Second)
gotick.Task(ctx, "close-order", func(c *gotick.TaskContext) error {
log.Println("order closed")
return nil
})
})
ctx := context.Background()
if _, err := tick.Trigger(ctx, "order/close", nil); err != nil {
log.Fatal(err)
}
log.Fatal(tick.StartServer(ctx))
}gotick does not compile your flow into a state machine. Every scheduling round runs your function again from the top, and the steps that already finished are skipped using state in Redis. A flow with four steps runs its function at least five times.
So the code inside Task runs once, but the code around it runs many times —
and it has to reach the same decisions every time:
tick.Flow("demo", func(ctx *gotick.Context) {
// ✗ each run sees a different time, so runs can take different branches
if time.Now().Hour() > 12 { ... }
// ✗ hits the database on every run, and the answer may change
user, _ := db.GetUser(id)
// ✓ Memo runs it once and replays the stored result
user := gotick.Memo(ctx, "user", func() (User, error) {
return db.GetUser(id)
})
})The rule: anything with a side effect, or whose answer can change, goes inside
Task / Memo / Array. The function body around them only decides the order.
| In a flow | |
|---|---|
Task |
Run a step. Retried on failure. |
Memo |
Run a step once and remember its result. |
Sleep |
Wait — seconds or days — holding no process. |
WaitForSignal |
Wait until something outside sends a signal. |
Async + Wait |
Run steps in parallel, with a concurrency limit. |
Array |
Remember a list, then fan out over it. |
| From outside | |
|---|---|
Trigger |
Start a flow. |
SendSignal |
Hand a waiting flow the thing it's waiting for. |
Cancel |
Stop a flow, wherever it currently is. |
Steer the flow with ordinary if and for. There is nothing to register.
"Close the order unless it's paid within 30 minutes" is a race between an event
and a timeout. WaitForSignal settles that race in one atomic operation, so a
payment landing at 29:59.9 can't be both accepted and timed out:
paid, ok := gotick.WaitForSignal[Payment](ctx, "paid",
gotick.WithSignalTimeout(30*time.Minute))
if !ok {
gotick.Task(ctx, "close-order", closeOrder)
return
}
gotick.Task(ctx, "ship", shipOrder)Your payment webhook calls tick.SendSignal(ctx, callId, "paid", payment).
Pass an identifier you already have when you trigger, and address the flow by it afterwards — no extra column on your orders table just for gotick:
tick.Trigger(ctx, "order/close", meta, gotick.WithKey(orderId))
tick.SendSignalByKey(ctx, "order/close", orderId, "paid", payment)
tick.CancelByKey(ctx, "order/close", orderId, "user canceled")Trigger the same key again and the previous unfinished run is canceled automatically — only the latest one carries on.
A built-in inspector shows which flows are running, which step each one is on,
how long it took, what failed, and how long a sleeping one has left. The list
carries the metadata each run was triggered with, and narrows by flow, status,
call id, or the business key from WithKey — so "what happened to order
ORD-88231" is one lookup instead of a scroll. It's a plain http.Handler that
reads Redis directly, so it never has to reach a running worker:
h, _ := ui.NewHandler(ui.Options{
RootPath: "/_gotick",
Store: store.NewRedisStore(rdb),
Auth: ui.BasicAuth("admin", "s3cret"), // it can see every metadata value
})
mux.Handle("/_gotick/", h)Or look without changing any code:
go run github.com/zbysir/gotick/cmd/gotick@latest ui -redis redis://localhost:6379/0The inspector lives in its own sub-package, so if you never import it your binary doesn't grow by a single byte.
The rest — how the replay engine actually works, parallel steps, loops, retries and timeouts, queue namespaces, what is stored and for how long, and complete examples — is at gotick.bysir.top/en/docs.
Runnable examples live in example/.
gotick is young. It is Go-only, it has no workflow versioning yet, and it hasn't been proven at the scale Temporal has. The honest tradeoffs are written down — read them before you bet money on it.
MIT licensed.