本日程式碼:repo tag day-13
如果我們現在有一些付款請求,它們的 job 已經被移除了,我們會發現那些請求依然存在,但「實際上是哪些 job 被移除了」這件事沒有存下來:queue 上是空的,Stats.Poison 能記的是這一輪放棄了幾份,沒有紀錄是哪幾份、為什麼被放棄,也沒有辦法通知對應的角色來檢查或處理它們。
我們今天要做一個 DLQ(dead-letter queue),讓停止重試的 job 停在裡面等待人工介入。這邊可以參考的公開設計有三個:
repo 中 internal/relayer 的 Example_redriveJob 用跟昨天同樣的三份 job:先讓它們停進 DLQ,再一份一份放回去。輸出長這樣:
dlq 3 parked
pi_0001/settle #1 parked failed - retrying will not help; nothing was sent, so the intent failed
pi_0002/settle #3 parked needs_review - no luck after 3 deliveries; last broadcast unknown, needs review
pi_0003/settle #3 parked created - no luck after 3 deliveries; job dropped, intent still created
sign pi_0003 signed at last, back to authorized
redrive pi_0001/settle by ops
worker pi_0001/settle #1 no-op (already failed)
redrive pi_0002/settle by ops
worker pi_0002/settle #1 no-op (already needs_review)
redrive pi_0003/settle by ops
worker pi_0003/settle #1 sent tx 0x0003
redrive pi_0003/settle refused (dlq: record is not parked: pi_0003/settle is redriven)
dlq 0 parked, 3 redriven
pi_0001 與 pi_0002 放回去只換到一次 no-op,因為那兩筆付款早就不在等 relayer 了pi_0003 是 redrive 真正救得回來的那一種:付款人補簽之後,同一份便條放回去就走完了其實很合理吧,都判成 poison 了,除了人工介入之外理論上也沒輒。
圖上 Redrive 那條線是繞回原本那條 queue 的,所以兩邊都會遇到「同一份 job 進來兩次」,只是去重的範圍不一樣:queue 只對「還在排隊的同 ID job」不重複收,DLQ 只要同一份 job 還停著就不重複收。至於已經被處理掉的同 ID 的 job 如果再進來一次,就算新的一趟、Cycles 加一。
redrive 之後會發生什麼,全部由那筆 intent 現在停在哪一狀態決定:
可以從圖得知,redrive 只讓那份 job 再被交付一次,沒有額外做任何事情;而 worker 每一次都重讀 intent、照它現在的狀態決定要做什麼。所以放回一筆已經走掉的 intent,只會換到一次 no-op 加一次 Ack,帳本不會多任何紀錄。
這也解釋了為什麼 SQS 乾脆不給改內容。一份 job 只帶 intent id 與 ref,改它沒有意義,因為決定接下來會發生什麼的本來就是 intent store 現在的內容。反過來說,如果 job 身上帶著金額與地址,redrive 就會變成一件危險的事:relayer 重新收到的會是一份幾天前的 snapshot,而我們根本看不出那份 snapshot 跟 intent store 現在是不是有落差。
開始 redrive 後有兩種進行方式可以選擇:
我們過去處理一個 intent 的順序都是「帳先記錄、狀態再開始變化」(因為比較安全),但今天 DLQ redrive 反過來比較適合:先把 job 放回 queue,再標記那筆紀錄。就像圖中說的那樣,「先放回、再標記」出事的話了不起再 redrive 一次,但「先標記、再放回」就要重新人工介入,而且流程會更複雜。
// 先問一次「它還停著嗎」,才不會把一份已經被 Drop 掉的 job 放回 queue。真正的把關是下面的 Resolve,
// 這裡只是不要白做一次 Enqueue。
if r.Status != StatusParked {
return Record{}, fmt.Errorf("%w: %s is %s", ErrNotParked, jobID, r.Status)
}
if _, err := q.Enqueue(ctx, r.Job, now); err != nil {
return Record{}, err
}
return s.Resolve(ctx, jobID, StatusRedriven, by, now)
人工介入過程也可能發生「兩個人同時 redrive」的問題,但這也有解法:兩邊都 Enqueue,然後只有一個人的 Resolve 會成功,另一個拿到 ErrNotParked。萬一那份 job 剛好已經做完被 Ack 掉了,第二次 Enqueue 就會變成真的新的一份工作,而那也不危險,因為它一樣只會換到一次 no-op。
前面都在講「人按下去之後會發生什麼」,這裡把邊界寫清楚:哪些事 redrive 做得到、哪些事它從頭到尾都碰不到。
DLQ 上的資訊全是過去式:一筆紀錄上的理由與 intent 狀態,講的是「這份 job 當初為什麼被放棄、放棄的那一刻那筆付款停在哪一格」,也就是那一刻拍下來的一張照片。人打開來看的時候那張照片多半已經過期了——付款人可能剛補完簽名,也可能早就被 operator 結案。所以 dlq 這個 package 不 import intent,那一欄就只是個字串,用途是讓人一眼分出「已經結案」與「還在等」,要判斷還是得回 intent store 重讀。
「有沒有救」只有 intent 現在的狀態說得準:放回去的那份 job 只帶 intent id 與 ref,worker 領到之後一定重讀 intent。所以 DLQ 上的理由再怎麼像「再試一次就會好」,只要那筆 intent 已經不在等 relayer,換到的就是一次 no-op。
redrive 救不動停在 needs_review 的付款:轉移表上它的出口只有 settled 與 failed,而且只有 operator 走得動。便條放回去,worker 一樣重讀 intent、一樣 no-op、一樣 Ack 掉;真正要被推一把的是那筆 intent,不是那份 job。Drop 也一樣——丟掉的是便條,不是付款,一筆停在 needs_review 的付款不會因為便條被丟掉就結案。
每一次處置都要簽名:Resolve 除了目標狀態還要收下按按鈕的人是誰,沒填就不收。理由很簡單:一筆付款被人推了一把、跟它自己走完,在帳本上長得一模一樣,差別只留在這一欄。
放回去又回來的要算一趟:Cycles 數的就是這個。一份 job 進出兩次以上,多半不是再放一次就會好,它該讓人停下來去看鏈上發生了什麼。
(題外話:DLQ 本身還缺一個保留上限,Sidekiq 那邊的預設是留 10000 筆或六個月;我們目前沒寫入資料庫,所以可以先不管這件事。)
我們今天討論了被移除的 job 去了哪裡:被丟進一個沒有 consumer 的 DLQ,連著 Reason 與「intent 當下狀態」一起寫下來,能讓它動起來的只有人工介入並且按下 redrive 或 drop。redrive 把原本那份 job 原封不動放回原本那條 queue,順序是先放回、再標記,因為 Enqueue 重放是 no-op 而 Resolve 是 CAS。至於「放回去不會多付一次錢」這件事由 worker 保證,它每一次都重讀 intent;redrive 真正救得回來的,只有那筆 intent 還在等 relayer 動它的那一種。
明天我打算讓 listener 上場,先把四條鏈各自的「不可逆」是什麼意思弄清楚,更之後再來想想鏈上鏈下對齊的實作。
明天見。