雲端 Mac 上的 iOS 相依圖驗收:穩定拓撲排序與循環偵測
同一批資源處理工作,在本機執行時順序正確,搬到雲端 Mac 的流水線後,卻偶爾先產生索引,再產生資源。問題不一定出在機器:如果工作順序取決於字典的走訪順序,或把「已排入佇列」誤認為「相依工作已完成」,只要換一個程序執行,就可能暴露錯誤。以下使用獨立的 Swift 指令稿驗收相依圖,不必先啟動模擬器。
先定義邊的方向與失敗語意
手動指定順序,適合固定且很短的流程;相依圖則適合工作會增減,或多個步驟共用前置工作的流程。如果所有工作本來就是循序執行,引入圖只會增加維護成本。
本文以「工作名稱 → 前置工作集合」表示輸入。例如,index 相依於 assets,輸出順序就必須讓後者先出現。工作名稱是穩定的識別碼,不隨顯示語言改變;互不相依的工作,則依識別碼的字典序選取。
輸入還必須約定三件事:重複的邊合併處理、遇到未知相依立即失敗、自我相依視為循環。空圖是合法輸入,應回傳空陣列。不要默默建立未知節點,否則名稱拼錯也會被當成成功。
排序只能證明相依關係是否能排成合法序列,不能證明工作已完成,更不能證明產出正確。執行狀態與圖結構應分開驗收。
實作可執行的穩定排序器
在節點的工作目錄中,將以下內容儲存為 graph-check.swift。程式碼只依賴 Swift 標準函式庫,使用目前選定的工具鏈執行即可,不需要安裝額外套件。演算法會記錄每個工作尚未滿足的相依數量,每一輪都選取名稱排序最前的就緒工作。
enum GraphError: Error, Equatable {
case unknown(task: String, dependency: String)
case blocked([String])
}
func ordered(_ graph: [String: [String]]) throws -> [String] {
let names = graph.keys.sorted()
let known = Set(names)
var remaining: [String: Int] = [:]
var successors: [String: [String]] = [:]
for task in names {
let dependencies = Set(graph[task]!)
for dependency in dependencies.sorted() {
guard known.contains(dependency) else {
throw GraphError.unknown(
task: task, dependency: dependency
)
}
successors[dependency, default: []].append(task)
}
remaining[task] = dependencies.count
}
var ready = names.filter { remaining[$0] == 0 }
var result: [String] = []
while !ready.isEmpty {
let task = ready.removeFirst()
result.append(task)
for next in successors[task, default: []] {
remaining[next]! -= 1
if remaining[next] == 0 {
ready.append(next)
}
}
ready.sort()
}
guard result.count == names.count else {
throw GraphError.blocked(
names.filter { remaining[$0]! > 0 }
)
}
return result
}
func check() throws {
let graph = [
"assets": ["fetch", "fetch"],
"fetch": [],
"index": ["assets"],
"lint": []
]
let result = try ordered(graph)
precondition(result == ["fetch", "assets", "index", "lint"])
let position = Dictionary(
uniqueKeysWithValues: result.enumerated().map {
($0.element, $0.offset)
}
)
precondition(result.count == graph.count)
precondition(Set(result) == Set(graph.keys))
for (task, dependencies) in graph {
for dependency in dependencies {
precondition(position[dependency]! < position[task]!)
}
}
let empty = try ordered([:])
precondition(empty.isEmpty)
do {
_ = try ordered(["export": ["missing"]])
preconditionFailure("Unknown dependency was accepted")
} catch let error as GraphError {
precondition(error == .unknown(
task: "export", dependency: "missing"
))
}
do {
_ = try ordered([
"a": ["b"], "b": ["a"], "report": ["b"]
])
preconditionFailure("Cycle was accepted")
} catch let error as GraphError {
precondition(error == .blocked(["a", "b", "report"]))
}
}
try check()
xcrun swift graph-check.swift
驗收通過時不會輸出任何內容;失敗時,程序會回傳非零結束代碼。執行這份驗收指令稿時,不要使用會停用前置條件檢查的最佳化模式。整合到專案後,可以將相同的斷言移至正式測試目標,避免業務程式的建置設定影響檢查行為。
用性質斷言補足範例的盲點
固定的預期結果能找出順序漂移,卻也可能連錯誤實作一起固定下來。因此,程式碼同時驗證輸出數量、節點集合,以及每條邊的先後關係。這三類檢查分別用來防止遺漏、重複與相依順序顛倒。
接著加入菱形圖:兩個工作相依於同一個根節點,最後一個工作再相依於這兩個工作。根節點只能輸出一次,匯合工作必須最後出現。再加入多個獨立子圖、單一節點、自我相依,以及所有節點都位於循環中的輸入。
對同一張圖,以不同順序將項目插入字典,再反轉各個相依陣列,結果都應完全一致。這裡的確定性來自明確排序,而不是反覆執行後「看起來都一樣」。不要把一次碰巧正確的字典走訪順序當成介面保證。
不要把阻塞集合誤報為循環成員
範例中的 a 與 b 互相相依,report 只是相依於 b,卻同樣無法解除阻塞。因此,錯誤名稱使用 blocked,而不是宣稱所有殘留工作都位於循環中。
排查問題時應保留哪些證據
失敗報告只需保留原始圖、經穩定排序的阻塞集合、提交識別碼與工具鏈版本。若需要在介面上精確標紅循環成員,再加入強連通分量分析;不要只憑入度仍大於零,就畫出「循環路徑」。
存在循環時,即使部分獨立工作已排出順序,整體規劃仍應失敗。除非產品明確支援部分執行,否則不能把前半段結果當成完整計畫交給執行器。
整合流水線時,守住排程邊界
先在雲端 Mac 上執行圖檢查,再進入耗時的建置步驟。保留非零結束代碼,讓相依設定錯誤在耗時數分鐘的工作啟動前就被發現,而不是等執行器卡住後才察覺。
這份實作每一輪都會重新排序就緒陣列,適合規模有限的應用程式工作圖;節點數量增加後,可以改用最小堆積,但必須維持相同的字典序契約,並沿用現有測試比對結果。
真正平行執行時,只有在前置工作成功完成後,才能減少後續工作的未完成相依計數。失敗、取消與重試都需要獨立的狀態,不能從拓撲順序的索引推導。最終驗收應同時滿足:異常輸入有明確說明、合法圖的順序穩定、執行器不會提早將相依條件視為已滿足。這樣即使換到另一個建置節點,也不會把偶然的順序帶進發布流程。
常見問題
排序失敗後,殘留節點都是循環成員嗎?
不是。依賴循環的下游工作也可能殘留,應先標為無法排程。若要精確找出循環成員,需再做強連通分量分析。
穩定排序會限制工作只能循序執行嗎?
不會。它只固定輸出順序;並行排程仍須維護就緒集合,等前置工作真正完成後才釋放後續工作。