# Silent Failure Hunting

> 一份不會產生錯誤訊息的資料流水線失敗清單——什麼都沒配對到的 JOIN、其實是真寫入的試跑、把列數放大的 fan-out、因編碼錯誤而死掉的 guard、寫好卻沒合併的工具。當流水線回報成功但資料是錯的、要檢討匯入或同步腳本、要決定批次寫入後該驗什麼、或要設計一個「runtime 不會替你抓」的檢查時使用。

- Skill: `jerryliu0103/silent-failure-hunting-2` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jerryliu0103/silent-failure-hunting-2`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jerryliu0103/silent-failure-hunting-2/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: jerryliu0103 (https://skillmd.com/u/jerryliu0103)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jerryliu0103/silent-failure-hunting-2

---


# 會成功的失敗

多數關於流水線的建議都在講怎麼處理錯誤。這一支講的是另一類：**跑完了、離開碼是 0、
日誌看起來很正常，而資料是錯的。**

這種在每一個面向上都比當掉更糟。當掉會告訴你在哪裡、什麼時候；一個什麼都沒配對到的
JOIN 什麼都不會說，而等你發現的時候，壞資料已經跟好資料無法區分，你還在上面蓋了東西。

**底下每一條都是真的在一個策展資料集上發生過的事**，連代價與現在靠什麼抓一起寫。

## 清單

### 1. 什麼都沒配對到的 JOIN 不會拋出任何東西

你用顯示名當鍵匯入一批資料。有些列在正本裡是用另一個顯示名存在的，但底層鍵相同。
每個敘述都跑了。每個沒配對到的 `INSERT ... SELECT ... JOIN` 只是**少插了幾列**。

**當時的樣子**：同一個真實事物有「根芹菜」與「芹菜根」兩種寫法，底層鍵相同。新的插入
被 UNIQUE 約束擋下了——**那一部分是吵鬧的**。但那些用顯示名做 JOIN 的**後續敘述**
什麼都沒配對到、什麼都沒寫、也什麼都沒回報。分類成員與交叉引用就這樣直接不見。

**怎麼發現的**：試跑時比對每個分類的成員數。**不是靠錯誤訊息。**

**檢查方式**：任何批次之後，**拿實際數字跟你預期的數字比對**。「沒有出錯」不是一個
結果，「影響了 n 列」才是——而且你應該在跑之前就先預測那個數字。

### 2. 失敗的 `BEGIN` 會把試跑變成真寫入

一個產生出來的 SQL 檔本來要開交易、跑 22 個敘述、然後回捲。檔案在經過幾層 shell 與
腳本轉換時掉了一個反斜線，abort-on-error 的指示詞變成一個**沒有分號的裸字**，
剖析器把它跟下一行的 `BEGIN;` 併成同一個語法錯誤的敘述。

連鎖後果，依序：

1. abort-on-error 從來沒設成功，所以出錯不會中止
2. 沒有交易，所以 22 個敘述**逐句自動提交**
3. 結尾的 `ROLLBACK` 沒有東西可以回捲

**唯一的警訊**是一行「目前沒有進行中的交易」，夾在結尾一堆看起來很正常的驗收輸出裡。

那一次剛好無害——寫進去的正是已核准的內容。**但過程是失控的**：中途要是撞到約束，
會留下半批資料，而執行的人以為什麼都沒發生。

**三道防線：**

- abort-on-error **下在命令列上**，不要用寫在檔案裡的指示詞。跳脫字元在傳遞過程中會被
  吃掉，**而被吃掉的指示詞是安靜的**。
- **事後回頭查正本，確認什麼都沒寫進去。** 不要相信輸出。
- 把「沒有進行中的交易」當成錯誤，不是警告。那是流水線在告訴你這不是試跑。

### 3. 印出 `ROLLBACK` 不代表回捲了什麼

上一條的推論，單獨列出來是因為這正是人們會相信的那件事。**不管有沒有交易，那個字都會
印出來。它是輸出，不是證據。**

### 4. 少一個過濾條件會讓列數放大，而且沒有東西會抱怨

某個正本從「一個項目一列」改成「多個來源各一列」，好讓多個來源並存。原本用
`LEFT JOIN` 預期取一列的同步腳本，現在會取到多列。列數從 528 變成 550。

**沒有任何錯誤。** 下游系統盡責地建出了重複頁面。這個數字小到可以矇混過一眼掃過，
又大到會造成問題。

正本上有一個 partial unique index，保證每個項目最多一列是主要的——**那是一個真的保證，
而且完全不相干，因為它擋不住一個忘了加條件的查詢。**

**檢查方式**：任何從一對一改成一對多的 schema 變更之後，把所有 JOIN 到那張表的查詢
grep 出來。比對變更前後的列數。**約束保護資料，它不保護查詢。**

### 5. 你的 guard 可能死於編碼錯誤，然後把防線一起帶走

一支 `PreToolUse` hook 寫來擋危險指令。在一台主控台編碼不是 UTF-8 的機器上，印出
非 ASCII 字元時丟例外。hook 以非 0 離開、**且沒有輸出任何決定**——於是那個工具呼叫
通過了。

**防線已經 fail open，而且它看起來跟一支正常運作的防線一模一樣。**

是靠把測資直接 pipe 進 hook、檢查它的輸出才發現的：三個該被擋的指令全部沒被擋。

**檢查方式：**

- 任何會印出非 ASCII 的 hook，都要明確強制 UTF-8 輸出。
- **用直接餵輸入的方式測 hook，並對輸出做斷言。** 一支你沒測過的 hook，是一支你在
  猜的 hook。
- 知道你的平台的失敗模式：hook 出錯時可能 fail open 也可能 fail closed，**而 fail open
  是危險的那個**。

### 6. 沒有合併的工具等於不存在的工具

一支解析器是專門為了防止某類重複而寫的。它在一個沒合併的分支上躺了三週。那三週裡，
「一律要用這支解析器」這條規則在實務上不是規則——**沒有任何東西在用它**。

更糟的是：終於合併時，**它已經不能跑了**。schema 在底下往前走了，它引用的欄位早就被
移除。**第一次執行就會炸。**

**檢查方式：**

- 一條規則如果指名了某支工具，那支工具必須在主分支上。
- 一個開了很久的分支，要問的不只是「合得起來嗎」，還有「它還符合現在的 schema 嗎」。
- **你寫了 guard 就要把它 land 掉。** 一支沒合併的 guard，有寫它的全部成本、沒有任何
  好處。

### 7. 單向同步會永遠留著孤兒

在正本刪掉或改鍵一個項目，單向同步永遠不會移除它的鏡像。**不會有錯誤**，因為從同步的
角度看沒有任何事不對：它發布存在的東西，而且發布得很正確。

**檢查方式**：定期跑一個雙向對帳，它唯一的工作就是列出「在鏡像裡有、在正本裡沒有」的
東西。清單非空時以非 0 離開。

### 8. 靠主表時間戳決定要同步什麼，會漏掉只改了子表的變更

如果你的同步是用 `parent.updated_at` 挑工作，那麼一批只寫了子表的資料不會改變任何同步
看得到的東西。同步跑了、回報成功、什麼都沒發布，**而鏡像安靜地落後一版**。

**檢查方式**：任何只動到子表的批次之後，去 touch 主表，或跑一次明確的過期稽核。

### 9. 未知旗標可能被忽略而不是被拒絕

對一支沒有實作 `--dry-run` 的腳本下這個旗標，等於**它照常做實際的事，而操作者以為
自己在測試**。

**檢查方式**：每支腳本啟動時都斷言自己認得的旗標，遇到不認得的就離開。這是五行程式，
擋掉一整類問題。

### 10. 自動化可能安靜地把你的稽核軌跡切碎

一個「閒置就 commit 工作區」的 hook，會在工作進行到一半時，把做到一半的東西掃進一個
訊息很籠統的 commit。**東西沒有掉**——但你事後寫的那封很仔細的 commit 訊息，描述的
內容有一部分躺在前面那個什麼都沒說的 commit 裡。

**不是資料安全問題，是稽核問題**，而且特別是對其他地方都很在意來源軌跡的專案而言。
以後追「這條規則哪來的」，會追到一個什麼都沒寫的 commit。

**檢查方式**：那一輪結束前自己先 commit 掉；把自動化的門檻調高，讓它只在你真的離開時
才觸發。

## 共同的形狀

看一下這十條的共通模式。每一次都是：

> **系統完全照著被告知的做了，而它被告知的內容錯在一個不會產生輸出的地方。**

由此得到通用的防法，這比任何單獨一條都更值錢：

1. **跑之前先預測數字。** 然後比對。「沒有出錯」不是結果，「n 列」才是。
2. **驗狀態，不要驗輸出。** 去查正本。**不要相信日誌——日誌是你正在檢查的那個東西
   自己產生的。**
3. **用對抗的方式測你的 guard。** 餵它該擋的東西，斷言它擋了。**沒測過的 guard 會
   fail open。**
4. **schema 改完之後把查詢 grep 一遍。** 約束保護資料，不保護忘了它的查詢。
5. **任何安靜的東西都要配一個明確的稽核。** 單向同步、用顯示名做的 JOIN、旗標、編碼
   ——只要它沒辦法回報自己的失敗，就要寫一個東西去問它。

## 相關

- `curation-pipeline`——這些檢查在保護的那個 staging 閘門
- `provenance-and-dedup`——去重專屬的那些靜默失敗

