對於一個大型複雜的系統來說,通常包含多個模塊或多個組件構成,模擬各個子系統的故障是測試中必不可少的環節,並且這些故障模擬必須做到無侵入地集成到自動化測試系統中,通過在自動化測試中自動激活這些故障點來模擬故障,並觀測最終結果是否符合預期結果來判斷系統的正確性和穩定性。如果在一個分散式系統中需要專門請一位同事來插拔網線來模擬網路異常,一個存儲系統中需要通過破壞硬碟來模擬磁碟損壞,昂貴的測試成本會讓測試成為一場災難,並且難以模擬一些需要精細化控制的的測試。所以我們需要一些自動化的方式來進行確定性的故障測試。
Etcd 團隊在 2016 年開發了 gofail 極大地簡化了錯誤注入,為 Golang 生態做出了巨大貢獻。我們在 2018 年已經引入了 gofail 進行錯誤注入測試,但是我們在使用中發現了一些功能性以及便利性的問題,所以我們決定造一個更好的「輪子」。
if vFailIfStatusBecomes, __fpErr := __fp_FailIfStatusBecomes.Acquire(); __fpErr == nil { defer __fp_FailIfStatusBecomes.Release(); FailIfStatusBecomes, __fpTypeOK := vFailIfStatusBecomes.(int); if !__fpTypeOK { goto __badTypeFailIfStatusBecomes}
if merger, ok := scp.merger.(*StatusCheckpointMerger); ok && merger.EngineID >= 0 && int(merger.Status) == FailIfStatusBecomes {
rc.checkpointsWg.Done()
rc.checkpointsWg.Wait()
panic("forcing failure due to FailIfStatusBecomes")
}
goto RETURN2; __badTypeFailIfStatusBecomes: __fp_FailIfStatusBecomes.BadType(vFailIfStatusBecomes, "int"); };
對於任何一個 Golang 代碼的源文件,可以通過解析出這個文件的語法樹,遍歷整個語法樹,找出所有 failpoint 注入點,然後對語法樹重寫,轉換成想要的邏輯。
相關概念
Failpoint
Failpoint 是一個代碼片段,並且僅在對應的 failpoint name 激活的情況下才會執行,如果通過 failpoint.Disable("failpoint-name-for-demo")
禁用後, 那麼對應的的 failpoint 永遠不會觸發。所有 failpoiint 代碼片段不會編譯到最終的二進位文件中,比如我們模擬文件系統許可權控制:
func saveTo(path string) error {
failpoint.Inject("mock-permission-deny", func() error {
// Its OK to access outer scope variable
return fmt.Errorf("mock permission deny: %s", path)
})
}
Marker 函數
AST 重寫階段標記需要被重寫的部分,主要有以下功能:
提示 Rewriter 重寫為一個相等的 IF 語句。
標記函數的參數是重寫過程中需要用到的參數。
標記函數是一個空函數,編譯過程會被 inline,進一步被消除。
標記函數中注入的 failpoint 是一個閉包,如果閉包訪問外部作用於變數,閉包語法允許捕獲外部作用域變數,不會出現編譯錯誤, 同時轉換後的的代碼是一個 IF 語句,IF 語句訪問外部作用域變數不會產生任何問題,所以閉包捕獲只是為了語法合法,最終不會有任何額外開銷。
簡單、易讀、易寫。
引入編譯器檢測,如果 Marker 函數的參數不正確,程序不能通過編譯的,進而保證轉換後的代碼正確性。
目前支持的 Marker 函數列表:
func Inject(fpname string
, fpblock func(val Value)) {}
func InjectContext(fpname string
, ctx context.Context
, fpblock func(val Value)) {}
func Break(label ...string) {}
func Goto(label string) {}
func Continue(label ...string) {}
func Fallthrough() {}
func Return(results ...interface{}) {}
func Label(label string) {}
如何在你的程序中使用 failpoint 進行注入?
最簡單的方式是使用 failpoint.Inject
在調用的地方注入一個 failpoint,最終 failpoint.Inject
調用會重寫為一個 IF 語句, 其中 mock-io-error
用來判斷是否觸發,failpoint-closure
中的邏輯會在觸發後執行。 比如我們在一個讀取文件的函數中注入一個 IO 錯誤:
failpoint.Inject("mock-io-error", func(val failpoint.Value) error {
return fmt.Errorf("mock error: %v", val.(string))
})
最終轉換後的代碼如下:
if ok, val := failpoint.Eval(_curpkg_("mock-io-error")); ok {
return fmt.Errorf("mock error: %v", val.(string))
}
通過 failpoint.Enable("mock-io-error", "return("disk error")")
激活程序中的 failpoint,如果需要給 failpoint.Value
賦一個自定義的值,則需要傳入一個 failpoint expression,比如這裡 return("disk error")
,更多語法可以參考 failpoint語法。
閉包可以為 nil
,比如 failpoint.Enable("mock-delay", "sleep(1000)")
,目的是在注入點休眠一秒,不需要執行額外的邏輯。
failpoint.Inject("mock-delay", nil)
failpoint.Inject("mock-delay", func(){})
最終會產生以下代碼:
failpoint.Eval(_curpkg_("mock-delay"))
failpoint.Eval(_curpkg_("mock-delay"))
如果我們只想在 failpoint 中執行一個 panic,不需要接收 failpoint.Value
,則我們可以在閉包的參數中忽略這個值。 例如:
failpoint.Inject("mock-panic", func(_ failpoint.Value) error {
panic("mock panic")
})
// OR
failpoint.Inject("mock-panic", func() error {
panic("mock panic")
})
最佳實踐是以下這樣:
failpoint.Enable("mock-panic", "panic")
failpoint.Inject("mock-panic", nil)
// GENERATED CODE
failpoint.Eval(_curpkg_("mock-panic"))
為了可以在並行測試中防止不同的測試任務之間的干擾,可以在 context.Context
中包含一個回調函數,用於精細化控制 failpoint 的激活與關閉 :
failpoint.InjectContext(ctx, "failpoint-name", func(val failpoint.Value) {
fmt.Println("unit-test", val)
})
轉換後的代碼:
if ok, val := failpoint.EvalContext(ctx, _curpkg_("failpoint-name")); ok {
fmt.Println("unit-test", val)
}
使用 failpoint.WithHook
的示例 :
func (s *dmlSuite) TestCRUDParallel() {
sctx := failpoint.WithHook(context.Backgroud(), func(ctx context.Context, fpname string) bool {
return ctx.Value(fpname) != nil // Determine by ctx key
})
insertFailpoints = map[string]struct{} {
"insert-record-fp": {},
"insert-index-fp": {},
"on-duplicate-fp": {},
}
ictx := failpoint.WithHook(context.Backgroud(), func(ctx context.Context, fpname string) bool {
_, found := insertFailpoints[fpname] // Only enables some failpoints.
return found
})
deleteFailpoints = map[string]struct{} {
"tikv-is-busy-fp": {},
"fetch-tso-timeout": {},
}
dctx := failpoint.WithHook(context.Backgroud(), func(ctx context.Context, fpname string) bool {
_, found := deleteFailpoints[fpname] // Only disables failpoints.
return !found
})
// other DML parallel test cases.
s.RunParallel(buildSelectTests(sctx))
s.RunParallel(buildInsertTests(ictx))
s.RunParallel(buildDeleteTests(dctx))
}
如果我們在循環中使用 failpoint,可能我們會使用到其他的 Marker 函數 :
failpoint.Label("outer")
for i := 0; i < 100; i++ {
inner:
for j := 0; j < 1000; j++ {
switch rand.Intn(j) + i {
case j / 5:
failpoint.Break()
case j / 7:
failpoint.Continue("outer")
case j / 9:
failpoint.Fallthrough()
case j / 10:
failpoint.Goto("outer")
default:
failpoint.Inject("failpoint-name", func(val failpoint.Value) {
fmt.Println("unit-test", val.(int))
if val == j/11 {
failpoint.Break("inner")
} else {
failpoint.Goto("outer")
}
})
}
}
}
以上代碼最終會重寫為如下代碼:
outer:
for i := 0; i < 100; i++ {
inner:
for j := 0; j < 1000; j++ {
switch rand.Intn(j) + i {
case j / 5:
break
case j / 7:
continue outer
case j / 9:
fallthrough
case j / 10:
goto outer
default:
if ok, val := failpoint.Eval(_curpkg_("failpoint-name")); ok {
fmt.Println("unit-test", val.(int))
if val == j/11 {
break inner
} else {
goto outer
}
}
}
}
}
對於為什麼會有 label, break, continue 和 fallthrough 相關 Marker 函數保持疑問,為什麼不直接使用關鍵字?
Golang 中如果某個變數或則標籤未使用,是不能通過編譯的。
label1: // compiler error: unused label1
failpoint.Inject("failpoint-name", func(val failpoint.Value) {
if val.(int) == 1000 {
goto label1 // illegal to use goto here
}
fmt.Println("unit-test", val)
})
break 和 continue 只能在循環上下文中使用,在閉包中使用。
一些複雜的注入示例
示例一:在 IF 語句的 INITIAL 和 CONDITIONAL 中注入 failpoint
if a, b := func() {
failpoint.Inject("failpoint-name", func(val failpoint.Value) {
fmt.Println("unit-test", val)
})
}, func() int { return rand.Intn(200) }(); b > func() int {
failpoint.Inject("failpoint-name", func(val failpoint.Value) int {
return val.(int)
})
return rand.Intn(3000)
}() && b < func() int {
failpoint.Inject("failpoint-name-2", func(val failpoint.Value) {
return rand.Intn(val.(int))
})
return rand.Intn(6000)
}() {
a()
failpoint.Inject("failpoint-name-3", func(val failpoint.Value) {
fmt.Println("unit-test", val)
})
}
上面的代碼最終會被重寫為:
if a, b := func() {
if ok, val := failpoint.Eval(_curpkg_("failpoint-name")); ok {
fmt.Println("unit-test", val)
}
}, func() int { return rand.Intn(200) }(); b > func() int {
if ok, val := failpoint.Eval(_curpkg_("failpoint-name")); ok {
return val.(int)
}
return rand.Intn(3000)
}() && b < func() int {
if ok, val := failpoint.Eval(_curpkg_("failpoint-name-2")); ok {
return rand.Intn(val.(int))
}
return rand.Intn(6000)
}() {
a()
if ok, val := failpoint.Eval(_curpkg_("failpoint-name-3")); ok {
fmt.Println("unit-test", val)
}
}
示例二:在 SELECT 語句的 CASE 中注入 failpoint 來動態控制某個 case 是否被阻塞
func (s *StoreService) ExecuteStoreTask() {
select {
case <-func() chan *StoreTask {
failpoint.Inject("priority-fp", func(_ failpoint.Value) {
return make(chan *StoreTask)
})
return s.priorityHighCh
}():
fmt.Println("execute high priority task")
case <- s.priorityNormalCh:
fmt.Println("execute normal priority task")
case <- s.priorityLowCh:
fmt.Println("execute normal low task")
}
}
上面的代碼最終會被重寫為:
func (s *StoreService) ExecuteStoreTask() {
select {
case <-func() chan *StoreTask {
if ok, _ := failpoint.Eval(_curpkg_("priority-fp")); ok {
return make(chan *StoreTask)
})
return s.priorityHighCh
}():
fmt.Println("execute high priority task")
case <- s.priorityNormalCh:
fmt.Println("execute normal priority task")
case <- s.priorityLowCh:
fmt.Println("execute normal low task")
}
}
示例三:動態注入 SWITCH CASE
switch opType := operator.Type(); {
case opType == "balance-leader":
fmt.Println("create balance leader steps")
case opType == "balance-region":
fmt.Println("create balance region steps")
case opType == "scatter-region":
fmt.Println("create scatter region steps")
case func() bool {
failpoint.Inject("dynamic-op-type", func(val failpoint.Value) bool {
return strings.Contains(val.(string), opType)
})
return false
}():
fmt.Println("do something")
default:
panic("unsupported operator type")
}
以上代碼最終會重寫為如下代碼:
switch opType := operator.Type(); {
case opType == "balance-leader":
fmt.Println("create balance leader steps")
case opType == "balance-region":
fmt.Println("create balance region steps")
case opType == "scatter-region":
fmt.Println("create scatter region steps")
case func() bool {
if ok, val := failpoint.Eval(_curpkg_("dynamic-op-type")); ok {
return strings.Contains(val.(string), opType)
}
return false
}():
fmt.Println("do something")
default:
panic("unsupported operator type")
}
除了上面的例子之外,還可以寫的更加複雜的情況:
循環的 INITIAL 語句, CONDITIONAL 表達式,以及 POST 語句
FOR RANGE 語句
SWITCH INITIAL 語句
Slice 的構造和索引
結構體動態初始化
……
實際上,任何你可以調用函數的地方都可以注入 failpoint,所以請發揮你的想像力。
Failpoint 命名最佳實踐
上面生成的代碼中會自動添加一個 _curpkg_
調用在 failpoint-name
上,是因為名字是全局的,為了避免命名衝突,所以會在最終的名字包包名,_curpkg_
相當一個宏,在運行的時候自動使用包名進行展開。你並不需要在自己的應用程序中實現 _curpkg_
,它在 failpoint-ctl enable
的自動生成以及自動添加,並在 failpoint-ctl disable
的時候被刪除。
package ddl // ddl』s parent package is `github.com/pingcap/tidb`
func demo() {
// _curpkg_("the-original-failpoint-name") will be expanded as `github.com/pingcap/tidb/ddl/the-original-failpoint-name`
if ok, val := failpoint.Eval(_curpkg_("the-original-failpoint-name")); ok {...}
}
因為同一個包下面的所有 failpoint 都在同一個命名空間,所以需要小心命名來避免命名衝突,這裡有一些推薦的規則來改善這種情況:
GO_FAILPOINTS="github.com/pingcap/tidb/ddl/renameTableErr=return(100);github.com/pingcap/tidb/planner/core/illegalPushDown=return(true);github.com/pingcap/pd/server/schedulers/balanceLeaderFailed=return(true)"
致謝
感謝 gofail 提供最初實現,給我們提供了靈感,讓我們能站在巨人的肩膀上對 failpoint 進行迭代。
感謝 FreeBSD 定義 語法規範。
最後,歡迎大家和我們交流討論,一起完善 Failpoint 項目。
推薦閱讀: