昨天我把「這一輪要處理哪些新資料」講完了。一輪有自己的識別碼,每次進入有自己的編號,選了什麼、刷掉什麼都留了原因。
然後就會遇到今天這一題:那一輪做到一半停了,下一次進來,程式怎麼知道哪些已經做完、哪些要重做、哪些應該停在原地?
我先去翻自己那支輪詢腳本,想看看它現在遇到中斷會怎麼辦。
查核當下看到的是:程式用原子的 mkdir 建一把單實例目錄鎖,鎖已經存在就不進來;重試次數上限預設 3,狀態是失敗、而且累積次數已達上限的項目不再被撿取。
這兩樣都在做事,但它們回答的是別的問題。鎖回答「現在有沒有別人在跑」,次數上限回答「這件事失敗幾次之後就別再撿」。沒有任何一個欄位記得「上一次停在第幾步、哪些產物已經可以信」。
而且我還看到兩個容易被自己騙過去的細節。
第一,缺狀態檔時寫入空物件的初始化,發生在取鎖之前。所以這把鎖連現行程式自己的狀態變更都沒有完整包住。
第二,「上限 3」不等於「這件事最多真的啟動三次」。計數是在子程序回來之後才寫進狀態檔的,所以真的被啟動過幾次和被記下來幾次,本來就可能不一樣。
還有一段歷史值得提,但要講清楚時態。SOP、提交紀錄與程式註解都記載:過去第二個 trap ... EXIT 曾經取代掉第一個,導致鎖的清理動作被弄丟。Bash 手冊本來就是這樣定義的,同一個規格再設定一次,新的動作就取代舊的。我在本機用最小腳本試過,依序設兩個 EXIT 動作,結束時只看到第二個輸出。現行版本已經把兩個暫存檔和鎖合併進同一個清理動作,所以這是修過的舊帳,不是現在還在漏。
至於狀態怎麼寫進去,目前是先寫一個暫存 JSON,再用 mv 換掉狀態檔。這只能描述寫入順序。暫存檔沒有指定跟目的檔同一個目錄,也看不到同步步驟,所以我不會把它叫做已經驗證過的原子寫入或斷電安全。
把四樣東西擺在一起,就看得出今天缺的是哪一塊。
| 機制 | 它能回答 | 它單獨答不了 |
|---|---|---|
| 目錄鎖 | 現在是否已有一個遵守同一把鎖的程序拿到執行權 | 上一輪停在哪裡、鎖是不是還屬於活著的程序 |
| 次數上限 | 已記錄的失敗累積到幾次之後不再撿取 | 這個錯誤能不能安全重試、硬中斷有沒有被計進去 |
| 冪等 | 同一個有副作用的要求重送時,怎麼避免多做出一份外部結果 | 多階段的流程已經完成到哪一步 |
| 檢查點 | 哪些階段、版本、等待狀態與產物已經被記錄下來 | 成品品質,以及整條執行路徑合不合格 |
今天要補的是最後一列,加上一份把中斷證據換成處置的紀錄。最後那個「答不了」的欄位不是今天的事,留給明天。
這是我覺得最容易寫錯的地方。
同一個 run_id 底下可以有很多個 attempt_id。程式掛掉再進來,是同一輪的第二次進入,不是一個新的排程觸發。如果把恢復偽裝成新一輪,昨天辛苦分出來的「這一輪該處理哪些資料」就白做了:時間窗會重算,選料會重跑,本來已經完成的階段可能被當成沒發生過。
所以恢復要換編號、不換輪次。三個出口也都在同一輪裡面:
retry 是重做目前這個階段resume 是採用已經核對過的產物,往後接stop 是證據不足、或錯誤不會自己好,停在原地出口由固定規則決定,不交給模型臨場判斷。這句話我想特別強調:這一層是最不該讓模型自由發揮的地方。 它要的不是聰明,是每次都一樣。
決定走哪個出口,得分開讀三種資料。
| 判斷軸 | 它問什麼 | 例子 |
|---|---|---|
| 中斷位置 | 停在工作開始前、產物提交後,還是人工等待中? | before_artifact_commit |
| 錯誤類別 | 同樣輸入再試一次可能會好,還是一定再失敗? | transient、permanent |
| 效果觀察 | 看的是本機產物、人工請求,還是外部副作用?現在是不存在、已重驗,還是不明? | local_stage_artifact / verified、external_side_effect / unknown |
第三軸有個坑我踩過一次才想清楚:範圍和狀態必須成對讀。 「外部副作用/不明」跟「本機產物/已驗」是完全不同的處境,不能因為都寫著同一個階段就當成同一件事。
另外兩件也不會自動成立。「產物還沒寫入」推不出「可以重試」,還要看錯誤類別跟次數還夠不夠。「我看到檔案了」也推不出「可以接續」,還要重算指紋、重跑驗證器。
任何一軸拿不到證據,預設就是停。這個預設值是刻意選的:停下來我還能人工介入,猜錯了可能是重複處理,也可能是永遠漏掉。
檢查點不是一個「做完了」的旗標。它至少要記得這一輪是誰、這次進入是誰、流程版本與流程定義的指紋、目前在哪個階段、哪些階段已完成、有沒有在等人,以及往前一份檢查點的連結。
這裡面我最喜歡的是 pending_stage。在開始一個階段之前,程式先把「我準備要做什麼」寫下來:預期的產物參照、輸入的指紋、流程版本、驗證器版本與驗證器定義的指紋、還有這一階段的次數預算。
差別在這:如果只在做完之後才記錄,中斷後我手上只有一個半成品和一堆猜測。事前先寫一份基準,中斷後就有東西可以對。
順帶說指紋是什麼。SHA-256 會把任意長度的輸入壓成固定長度的一段值,同樣的輸入永遠得到同樣的結果,輸入改一個字元結果就完全不同。恢復的時候重算一次拿來比對,就知道內容有沒有被換過。
不過它只是一致性檢查。沒有金鑰、沒有簽章、儲存也不受保護的話,有人可以把內容和指紋一起換掉,我就看不出來。這篇不把它當防竄改。
還有一件事讓我意外:算指紋之前,得先固定 JSON 長什麼樣。 同一份資料,屬性順序不同、數字寫法不同,算出來就是不同的值。RFC 8785 就是為這件事定義了一套固定序列化方式。我用的是專案自己的固定規則,不是符合那份規範,所以我只能說「有版本的自訂規則」,不能說「符合 RFC 8785」。
階段產物我沒有直接存一份輸出就算了,而是包了一層外殼,裡面有這一輪、這個階段、輸入指紋、流程版本與定義指紋、內容本身、內容指紋,還有一張完成收據,收據上記著驗證器版本、驗證器定義指紋、狀態與驗證時間。
恢復的時候是這樣核的:先拿前一版檢查點的 pending_stage 去對這一輪、這個階段、輸入指紋和流程定義指紋,再重算一次內容指紋,最後照本機受信任規則指定的驗證器,重跑一次驗證。
產物自己寫著「我通過了」不算數,它自述的版本和定義指紋也不能直接相信。 這聽起來有點苛刻,但道理很簡單:那份產物就是中斷現場留下來的東西,它的可信度正是我現在要判斷的事,不能拿它自己的說法當答案。
缺欄位、指紋不合、或驗證沒過,程式就停下來保留現場,不用新的輸出覆蓋舊檔。中斷之後最不該做的事,就是急著把唯一的證據蓋掉。
次數預算裡有個欄位叫 attempts_reserved,記的是「已經保留掉的階段執行名額」。關鍵在時機:新一次工作要在呼叫模型之前,就先用新的檢查點把名額保留下來。
真正開始執行時還有兩道:執行器先從磁碟把該序號的檢查點讀回來,確認完整指紋跟手上傳進來的一致;再用排他寫入,替這個 attempt_id 建一份持久的執行占用紀錄。沒存好的檢查點、或同一個身分再進來一次,都會在呼叫模型之前就被擋掉。
合成測試確認過名額會從 1 被寫成 2,也確認過未保存的新檢查點和相同的嘗試身分不能執行。但這只攔得住同一個嘗試身分的重播,不是恰好一次的保證,也還沒經過正式程序崩潰、並行競爭或斷電。
決定要停的時候,after_reserved 不能增加。停止不該吃掉名額,這是留給人的餘裕。
固定合成資料注入三個中斷點,看它們各自走去哪。表格裡的數字都是實測計數。
| 中斷點 | 假模型函式 | 執行占用紀錄 | 階段產物 | 新複核請求 | 下游呼叫 | 決定 |
|---|---|---|---|---|---|---|
| 階段開始前,暫時錯誤 | 1 | 2 | 1 | 0 | 0 | 先保留第二個名額,再 retry |
| 產物提交後、檢查點前進前 | 1 | 1 | 1 | 0 | 0 | resume |
| 人工等待中 | 0 | 0 | 0 | 0 | 0 | resume 後仍然在等 |
三列都保留同一個 run_id,需要恢復時換一個可以重算的 attempt_id。稽核檔另外存了名額、執行占用紀錄、檢查點寫入數、恢復紀錄數、產物指紋與原因碼。
第一列還有兩個變體,同一個中斷位置卻走去不同出口,我覺得這組對照最有說明力:
| 同樣停在階段開始前 | 出口 | 實測 |
|---|---|---|
transient、次數還沒滿 |
名額從 1 寫成 2,換新 attempt_id 重做這個階段 |
假模型 1 次、執行占用 2 份、產物 1 份、檢查點 3 份、名額保留寫入 1 次、恢復紀錄 1 份、下游 0 |
permanent |
stop |
假模型 0 次、執行占用 1 份、產物 0 份、檢查點 2 份、名額保留寫入 0 次 |
transient、名額已滿 |
stop |
名額維持 1/上限 1、執行占用 1 份,不再保留新名額 |
中斷位置一樣,錯誤類別和剩餘名額不一樣,出口就不一樣。這就是為什麼三軸要分開讀。
第二列那個「假模型 1 次」值得說一下:那 1 次發生在中斷之前,產物就是它做出來的。恢復階段核對完就直接接續,沒有再呼叫一次。這正是接續比重做值錢的地方。
第三列是我最不想寫錯的一條。
中斷前的狀態是「等人」,而且待處理的請求已經連請求種類、請求識別碼、版本、參照、完整請求指紋與來源指紋都存好了。恢復之後,這些前後完全相同,狀態還是「等人」,沒有新增任何複核請求。
恢復器可以把等待狀態重新載入,但不能替我做決定,也不能把「重新載入成功」當成「已經批准」。 一個會自己把等待狀態解掉的恢復機制,比沒有恢復機制更危險。
該說清楚的邊界我放在這裡,不放在文末當免責清單。
上面所有數字都來自 2026-09-09 的固定合成資料、單一程序、單一寫入者。沒有呼叫任何外部模型,也沒有另外開一個程序去測中斷。測試會從磁碟重新載入檢查點,但這跟作業系統強制終止一個真正在跑的程序是兩件事,我不會把它叫做程序崩潰測試。
單一寫入者也不是小字免責。Node.js 的檔案介面本來就沒有替多個寫入者自動同步,多個並行操作改同一個檔案是可能弄壞資料的。所以「單機、一個寫入者」是這次實驗的有效範圍。
寫入後中斷這一段,我只談可以核對的本機產物。外部發布已經成功但回應遺失、多步驟只成功一半、要去查外部狀態再補償,那些是對帳的題目,不是恢復的題目,留到 Day 26。
還有一整類問題我今天連邊都沒碰:等多久再重試、要不要加抖動、由哪一層負責重試。雲端服務的文件把這些講得很細,重試政策從來不只是一個次數;多層各自重試會把呼叫次數放大,指數退避如果所有人同時醒來還是會再撞一次。我這次的次數預算只先管總數,這些是正式化的時候要補的政策,不是固定測試已經幫我調好的參數。
今天交出去的是三份東西:一份記得做到哪裡的檢查點、一份能獨立重驗的階段產物、一份寫著決定與理由的恢復紀錄。
它們的用途不只是讓程式接得回去。更重要的是,明天可以拿著結果和執行紀錄逐項回頭檢查:這個成品是誰做的、用了哪份輸入、照哪個版本的規則、被哪個驗證器驗過。
沒有這一層,「它跑完了」跟「它做對了」就永遠是同一句話。