每一轮调度,你的函数都从头再跑一遍

整个设计就是这一句话。API 为什么长这样、约束为什么是那几条、出错时会怎么错,都是从它推出来的。

本页目录

一轮是这样的

  1. 1
    Trigger 投一个事件

    Trigger 往 Redis 上的队列写一个事件,然后把 callId 交给你。此刻你的流程一行都还没跑。

  2. 2
    某个 worker 重放你的函数

    某个 worker 把事件取走,从你函数的第一行开始调用。取到事件的不一定是触发它的那个进程。注意它不是从中间接着跑,是整个重跑一遍。

  3. 3
    做完的步骤是读出来的,不是跑出来的

    每一步先查自己的状态。已经完成的 Task 立刻返回,你的闭包不会被调用。Memo 直接把上次存下的值递回来,不会重新算一遍。

  4. 4
    第一个没做完的步骤执行,然后这一轮就结束

    gotick 记下新状态,中断这一轮,再安排下一个事件。下一个事件可能是马上,也可能是你要求的三十分钟以后。下一轮做同样的事,往前挪一步。

一轮怎么中断:panic

这里没有协程可以挂起,也没有代码生成。需要等的那一步会抛一个断点出来,也就是一个带着 gotick.Breakpoint 值的 panic,调度器在这一轮的最外层把它 recover 掉。靠这个手法,流程才能是一个普通闭包:不需要 await,也没有任何特殊语法。

由此带来一个坑:在流程函数里、Task 之外裸写一个 recover(),会把断点一起吞掉,流程从此不再往前走,而且不报错。如果那里确实要 recover,先判断拿到的值是不是 gotick.Breakpoint,是就重新 panic 出去。Task 里面可以随便 recover,那段代码跑在正常的调用栈上。

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)})

一共要跑多少轮

每个要写状态的步骤都占一轮,成功的也算:成功必须先落盘,下一步才允许开始。函数正常返回再占一轮。所以四个顺序 task 的流程,函数体至少执行五次。只有一处例外:重试退避只剩不到一秒时,gotick 就地等完,省掉一轮什么都没推进的重放。

唯一的规矩:Task 之外必须确定

同一次调用的两遍执行必须做出同样的判断。否则这一遍走 A 分支、下一遍走 B 分支,同一次调用就做了两件互相矛盾的事。这种错是静默的:不报错,只是偶尔有个订单既发了货又关了单。

你想做的事别这么写这么写
看时间if time.Now().Hour() > 12先用 Memo 把时刻固化,再拿它判断
查库user, _ := db.GetUser(id)gotick.Memo(ctx, "user", ...),或者放进 Task 里查
随机数、UUIDid := uuid.NewString()生成一次,用 Memo 记住
遍历一个列表items, _ := db.ListItems()gotick.Array(ctx, "items", ...) 会记住列表和它的顺序
任何有副作用的动作mailer.Send(to, body)包进 gotick.Task 里

目前没有任何东西强制这一点,既没有静态检查,也没有运行期拦截。这是 gotick 最锋利的一道边,现在只写在文档里,还没写进代码里。

key 就是步骤的身份

每个原语的第二个参数都是 key。gotick 靠 key 在两遍执行之间认出同一个步骤,不看行号,也不看调用顺序。所以同一个流程里两个步骤不能重名,撞了 key 会直接 panic,不会让后一个去读前一个的状态。key 还必须稳定:有实例在飞的时候改名,那一步会被当成新步骤从头再做。循环里的 key 每轮都要不一样,ArrayWrap.Key(prefix) 和 SequenceWrap.TaskKey(prefix) 就是干这个用的。

Render diagnostics