本日程式碼:repo tag day-08
昨天帳本定義完之後,鏈下的核心三件事都齊了:狀態機、雙鍵、ledger。但到目前為止,Payment Intent 從 authorized 往後的每一步都是在 Example 裡用 Advance 手動推的,還沒有任何一個 actor 真的在「等 intent 變成 authorized,然後再把它送上鏈」。今天開始我們要實作這個 actor,也就是 relayer。同時我們會圍繞在「intent 到了 authorized 之後,誰負責處理它、怎麼排隊、系統掛了怎麼辦」這些問題上。
relayer 這個詞在業界的用法(比如 OpenZeppelin Defender 的 Relayers)大致是「替你保管送交易的錢包、簽名、送出、管 nonce、一路 babysit 到上鏈」的服務,而我們的 relayer 當然也是這個意思。
我們要做一個 queue 驅動的 relayer,也就是一個 worker。queue 的形狀我沿用 SQS standard queue 的三個機制:至少一次(at-least-once)交付、visibility timeout(別人在某期限內看不到領走的訊息,我們叫這個期限 lease)、receipt handle(每次領到的憑證都不一樣、帶著它才能刪訊息)。這三個機制在任何一個 at-least-once 的 queue 上都找得到對應的機制。如果你夠用心,遙想 Day 5 的 lease 與 attempt 其實也是同一個技巧。
repo 中 internal/relayer 的 Example_settleThroughQueue 會把三筆 authorized 的 intent 排進 queue,讓一個 worker 一份一份領走。輸出大概長這樣:
enqueue pi_0001/settle queued
enqueue pi_0001/settle no-op (already queued)
enqueue pi_0002/settle queued
enqueue pi_0003/settle queued
worker pi_0001/settle #1 sent tx 0xaa
worker pi_0003/settle #1 retry (send: rpc: connection refused)
worker pi_0002/settle #2 sent tx 0xab
worker pi_0003/settle #2 retry (settling for 30s without tx hash, waiting)
worker pi_0003/settle #3 needs_review (settling for 5m30s without tx hash; broadcast outcome unknown)
enqueue pi_0001/settle queued
worker pi_0001/settle #1 no-op (already confirming)
queue: 0 job(s) left
#1 hold 0xb02f8d29… by relayer payer:0x7099…79C8 -100000000 merchant:0x3C44…93BC +100000000
#2 hold 0x27ec4d4d… by relayer payer:0x7099…79C8 -100000000 merchant:0x3C44…93BC +100000000
#3 hold 0xfce5e296… by relayer payer:0x7099…79C8 -100000000 merchant:0x3C44…93BC +100000000
pi_0001 confirming v4 tx=0xaa
pi_0002 confirming v4 tx=0xab
pi_0003 needs_review v4 tx=
pi_0001 正常走完;之後同一份 job 又被排了一次(上游重送),worker 看到 intent 已經在 confirming 就不會做事。pi_0002 被一個 worker 領走之後那個 worker 就死了,然後 lease 過期後變成另一個 worker 領到同一份(#2),照常走完。pi_0003 送出時 RPC 掛了,worker 只回 retry;RPC 好了之後它也不重送,因為它不知道上一筆到底有沒有出門,卡在 settling 超過五分鐘之後就推到 needs_review 讓人類介入了。intent 變成 authorized 之後,為了要傳給下一個人負責,有三種常見的傳遞方法:
authorized 的SKIP LOCKED 文件直接說它是給「queue-like table」用的;但輪詢要自己發明 lease、attempt、延後重試這些語意,做完就是一條 queue。Enqueue(進 queue)、Lease(領走並對其他人隱藏)、Ack(完成並刪除 job)、Nack(做不完或不知道,晚點再來)等等 SQS 的形狀。queue 的缺點是一個事件要寫兩個地方:intent 寫進 store、job 丟進 queue,而且兩個寫入不在同一個 transaction 裡。如果今天先寫 intent 再丟 job,那掉了 job 的 intent 要靠定期重掃 authorized 來補回,Enqueue 對同 ID 冪等所以重複發 job 並不會有影響(這個重掃我們之後有機會再補齊)。這個問題 Brandur 有一篇 Transactionally Staged Job Drains 講得很完整,之後我們也會照那條路走。
// Job 是一份工作。ID 的慣例是 <intent id>/settle:同一筆 intent 的同一種工作,queue 裡最多排一份。
// 只有 IntentID 與 Ref,沒有金額與地址:intent store 才是唯一的真相,job 只是一張「去看看它」的便條。
type Job struct {
ID string
Kind Kind
IntentID string
Ref paymentref.Ref
}
job 是 at-least-once 交付的,同一份可能在 30 秒後又被領一次;如果它身上帶著金額,worker 拿到的就是 30 秒前的金額,重讀 intent store 永遠拿到最新的那份。ref 帶著走純粹是為了 log 與追蹤:從 API 到鏈上,每一層印出來的都是同一個 ref。
去重的範圍只有「還在 queue 裡」:同 ID 的 job 在排隊或被領走時再 Enqueue 是 no-op,Ack 之後同 ID 再進來就是新的一份工作(reorg 之後要 relayer 重送,就是這樣再排一次)。所以安全不靠 queue 去重,靠 worker 每一步冪等。
順序是固定的:
settling(成功才算領到這筆 intent,輸了就是別人先動了它)Sender 廣播,回來把 tx hash 寫進 confirming
Ack 永遠是最後一步,因為 worker 如果在中間任何一步死掉了,至少 lease 過期後 job 會再回來;但如果領到 job 就先 Ack,這筆 intent 就從 at-least-once 變成 at-most-once,當 worker 死掉時那筆 intent 就永遠停在 authorized 而且沒有人會知道。而重來的 worker 不能假設「job 來了就代表 intent 在 authorized」,它每次都重讀 intent、照現在的狀態決定要做什麼:
created 代表 job 比簽名還早送到,等待就好confirming 後 relayer 都沒有事可做,直接 Ack 就好settling 卻沒有 tx hash,會分不出前手 worker 是死在廣播前還是廣播後(tx hash 要進 confirming 才會有),如果貿然重送就可能付兩次錢。所以它只會做兩件事:
StuckAfter 就推到 needs_review,出動人工審核缺點很明顯:RPC 抽筋、worker 延遲,就會有一批 intent 送審,Example 裡的 pi_0003 就是這個狀況。要讓 relayer 敢在 settling 重送,得先能證明上一次的交易到底有沒有真的被廣播出去,而那需要 nonce 與每一次嘗試的紀錄。聰明的你一定想到了更多例外情形:多個 worker 同時跑怎麼分工、送出失敗怎麼分類(哪些該重試、哪些該直接 failed)、重試幾次要放棄、放棄的 job 去哪。這些之後再討論。今天的 Sender 也只是一個介面,Example 與測試都還沒接上鏈。
今天都在定義 relayer 怎麼領取工作:API 與 relayer 之間只隔一條 at-least-once 的 queue,job 只帶 intent id 與 ref,worker 每一步冪等、順序固定(先 settling、再 hold、再廣播、最後 Ack),重來的 worker 照 intent 現在的狀態決定要做什麼,下落不明的一律送審不重送。exactly-once 從來不是 queue 給的,Tyler Treat 那篇 You Cannot Have Exactly-Once Delivery 的結論是靠冪等來 fake(「the way we achieve exactly-once delivery in practice is by faking it」),前幾天的 idpk、狀態機、ledger 也都是這樣 fake 的。
明天把這一個 worker 變成多個:多個 worker 同時對同一條 queue 領工作,怎麼分配、怎麼收工、怎麼不把 RPC 打爆。
明天見。