每一轮调度,你的函数都从头再跑一遍
整个设计就是这一句话。API 为什么长这样、约束为什么是那几条、出错时会怎么错,都是从它推出来的。
一轮是这样的
- 1Trigger 投一个事件
Trigger 往 Redis 上的队列写一个事件,然后把 callId 交给你。此刻你的流程一行都还没跑。
- 2某个 worker 重放你的函数
某个 worker 把事件取走,从你函数的第一行开始调用。取到事件的不一定是触发它的那个进程。注意它不是从中间接着跑,是整个重跑一遍。
- 3做完的步骤是读出来的,不是跑出来的
每一步先查自己的状态。已经完成的 Task 立刻返回,你的闭包不会被调用。Memo 直接把上次存下的值递回来,不会重新算一遍。
- 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 里查 |
| 随机数、UUID | id := 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) 就是干这个用的。