本日程式碼:repo tag day-12
昨天 relayer 已經有辦法救一筆卡住的交易,但它對失敗的補救依然是不斷重複「Nack 回 queue、五秒後再交付一次」;RPC 抽筋就算了,但如果「這條鏈根本沒有設定簽名金鑰」的話那重試到死都不會成功。今天要處理的就是這件事:一次失敗到底值不值得再交付一次。
我們今天要做一個判失敗的元件。假設有一次失敗進來的話,它會決定「這份 job 還要不要再交付」以及「要的話隔多久」。沿用的公開設計有三個:
maxReceiveCount:「the number of times a consumer can receive a message from a source queue」,我們實作上可以統計交付次數(delivery)而非失敗次數repo 中 internal/relayer 的 Example_poisonJob 用三筆付款示範三種失敗(Example 把 max attempts 縮到 3 次、關掉 jitter,輸出才短又固定)。輸出長這樣:
policy at most 3 deliveries; back off 5s doubling, capped at 2m0s
worker pi_0001/settle #1 poison (retrying will not help; nothing was sent, so the intent failed)
worker pi_0002/settle #1 retry (send: rpc: connection refused)
worker pi_0002/settle #2 retry (settling for 5s without tx hash, waiting)
worker pi_0002/settle #3 poison (no luck after 3 deliveries; last broadcast unknown, needs review)
worker pi_0003/settle #1 retry (not authorized yet)
worker pi_0003/settle #2 retry (not authorized yet)
worker pi_0003/settle #3 poison (no luck after 3 deliveries; job dropped, intent still created)
pi_0001 failed hold voided
pi_0002 needs_review hold pending
pi_0003 created hold -
pi_0001 的 Sender 宣告了兩件事:這筆確定沒發送出去,而且重試不會好pi_0002 只是一直逾時,沒有人宣告任何事,只能 backoff 到 attempts 用完為止pi_0003 從頭到尾還沒 authorized,relayer 一個 byte 都還沒寫過三份 job 最後都停止重試,但最下面那三行的結果完全不一樣。
poison 是訊息佇列世界的既有術語「poison message(毒訊息)」:一則 consumer 永遠無法處理成功的訊息。它會一再地被重新交付,卻永遠不會成功,所以也永遠不會消失。
有兩種情形會進 poison,但各自都有點瑕疵:
左邊要求呼叫端事先想得到,可是寫 Sender 的人不可能列得完鏈上會回什麼;右邊不需要任何人事先分類,缺點是一個一眼就沒救的錯誤也要陪跑完整輪,中間每一次都吃掉一份 lease 與一個 worker 的一圈。
所以最正確的判定是兩種條件要依照順序進行判定:
宣告擺在最前面,是因為它是唯一「知道為什麼」的一條,知道的時候就不該再浪費一次交付去確認。ErrPoison 的用法跟昨天的 ErrNotSent 一樣,包在錯誤裡讓 errors.Is 拆得開,但預設的方向剛好相反:昨天預設偏保守,沒宣告的一律當成「不知道有沒有發送出去」;今天預設偏樂觀,沒宣告的一律當成重試會好。因為猜錯的代價不一樣,昨天猜錯是拿同一個 nonce 去撞自己那筆躺在 mempool 的交易,今天猜錯只是多試幾次,而且 max attempts 能 cover。
固定五秒改成 exponential backoff:5、10、20、40 秒,每次加倍,封頂在兩分鐘。加倍是為了讓一個真的壞掉的節點有時間被修好,封頂是為了不要讓一份 job 隔幾個小時才被看一次。這裡有一個很容易寫錯的地方:加倍要用迴圈,不能用位移,因為 attempt 是 queue 給的數字,一份 job 被交付幾百次完全有可能,而位移 64 次就繞回零,backoff 會安靜地變成不 backoff。
真正需要挑的是 jitter。N 個 worker 同時撞上同一個壞掉的節點時,它們的重試會排在同一秒,醒來又一起撞一次,所以算出來的時間還要打散。AWS 那篇把三種 jitter 量過一遍,結論是 full jitter(在 0 到 d 之間隨機)最省事,而我這裡選的是 equal jitter:
func EqualJitter(d time.Duration) time.Duration {
if d <= 0 {
return 0
}
half := d / 2
return half + time.Duration(rand.N(int64(d-half)+1))
}
一半固定、一半隨機,下限就守住了。full jitter 可以退到接近零,而我們每一次重新交付都要吃掉一份 lease 與一個 worker 的一圈,退到零就是白跑一圈。
還有一個數字要對一下:最糟情形的總時長必須撐得過 StuckAfter。repo 裡預設交付 10 次、第一階 5 秒、封頂兩分鐘,加起來大約十分鐘,比 StuckAfter 的五分鐘長。反過來的話,一筆卡在 settling 的付款會在救援發生之前就先被判死,那一整套替換等於沒有接上。
這非常重要:一份 job 只是一張「去看看它」的便條,intent store 才是唯一的真相。所以一份 job 被判 poison,意思是「這張便條再交付幾次都一樣」,不是「這筆付款失敗了」。判完之後要重讀一次 intent,照它現在停在哪一格決定要不要動它:
created 與 authorized 那條路是 pi_0003 走的:relayer 在這兩格一個 byte 都還沒寫過,而且轉移表上它在這裡只有一條出口,就是往前推到 settling。宣告一筆付款失敗不是它的權力,所以它只丟便條。工作也沒有因此消失,那筆 intent 還在,之後誰再丟一份新的 job 進來,照樣被處理。settling 那條路才是今天新開的:settling → failed 這一列從 Day 4 定案到現在沒有人走過,今天它第一次有主人。條件很嚴,要上一次廣播「確定沒發送出去」才算,也就是三種發送結果裡唯一敢說鏈上乾淨的那一種;其他兩種一律推 needs_review,因為錢可能已經動了。今天我們討論的是 job 失敗怎麼判斷:分成 retryable 和 poison 兩類,進 poison 的原因是自己宣告錯誤或重試次數用完;backoff 的策略是每次加倍、封頂、加 equal jitter,下限比上限重要,而重試最糟情形的總時長要撐得過 StuckAfter,不然救援永遠輪不到。最後是判決的範圍:分類判的是 job,relayer 只有在確定什麼都沒發送出去的時候才敢宣告 failed,其他一律交出去。
明天我打算給那些停止重試的 job 一個收容的地方,不會讓它們直接從 queue 上消失。
明天見。