做者:龙恒git
对于一个大型复杂的系统来讲,一般包含多个模块或多个组件构成,模拟各个子系统的故障是测试中必不可少的环节,而且这些故障模拟必须作到无侵入地集成到自动化测试系统中,经过在自动化测试中自动激活这些故障点来模拟故障,并观测最终结果是否符合预期结果来判断系统的正确性和稳定性。若是在一个分布式系统中须要专门请一位同事来插拔网线来模拟网络异常,一个存储系统中须要经过破坏硬盘来模拟磁盘损坏,昂贵的测试成本会让测试成为一场灾难,而且难以模拟一些须要精细化控制的的测试。因此咱们须要一些自动化的方式来进行肯定性的故障测试。github
Failpoint 项目 就是为此而生,它是 FreeBSD failpoints 的 Golang 实现,容许在代码中注入错误或异常行为, 并由环境变量或代码动态激活来触发这些异常行为。Failpoint 能用于各类复杂系统中模拟错误处理来提升系统的容错性、正确性和稳定性,好比:express
Etcd 团队在 2016 年开发了 gofail 极大地简化了错误注入,为 Golang 生态作出了巨大贡献。咱们在 2018 年已经引入了 gofail 进行错误注入测试,可是咱们在使用中发现了一些功能性以及便利性的问题,因此咱们决定造一个更好的「轮子」。网络
使用注释在程序中注入一个 failpoint:闭包
// gofail: var FailIfImportedChunk int // if merger, ok := scp.merger.(*ChunkCheckpointMerger); ok && merger.Checksum.SumKVS() >= uint64(FailIfImportedChunk) { // rc.checkpointsWg.Done() // rc.checkpointsWg.Wait() // panic("forcing failure due to FailIfImportedChunk") // } // goto RETURN1 // gofail: RETURN1: // gofail: var FailIfStatusBecomes int // 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 // gofail: RETURN2:
使用 gofail enable 转换后的代码:分布式
if vFailIfImportedChunk, __fpErr := __fp_FailIfImportedChunk.Acquire(); __fpErr == nil { defer __fp_FailIfImportedChunk.Release(); FailIfImportedChunk, __fpTypeOK := vFailIfImportedChunk.(int); if !__fpTypeOK { goto __badTypeFailIfImportedChunk} if merger, ok := scp.merger.(*ChunkCheckpointMerger); ok && merger.Checksum.SumKVS() >= uint64(FailIfImportedChunk) { rc.checkpointsWg.Done() rc.checkpointsWg.Wait() panic("forcing failure due to FailIfImportedChunk") } goto RETURN1; __badTypeFailIfImportedChunk: __fp_FailIfImportedChunk.BadType(vFailIfImportedChunk, "int"); }; /* gofail-label */ RETURN1: 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"); }; /* gofail-label */ RETURN2:
// goto RETURN2
和 // gofail: RETURN2:
,而且中间必须添加一个空行,至于缘由能够看 generated code 逻辑。理想中的 failpoint 应该是使用代码定义而且对业务逻辑无侵入,若是在一个支持宏的语言中 (好比 Rust),咱们能够定义一个 fail_point
宏来定义 failpoint:函数
fail_point!("transport_on_send_store", |sid| if let Some(sid) = sid { let sid: u64 = sid.parse().unwrap(); if sid == store_id { self.raft_client.wl().addrs.remove(&store_id); } })
可是咱们遇到了一些问题:微服务
go build --tag="enable-failpoint-a"
)。Failpoint 代码不该该有任何额外开销:性能
context.Context
控制一个某个具体的 failpoint 是否激活。宏的本质是什么?若是追本溯源,发现其实能够经过 AST 重写在 Golang 中实现知足以上条件的 failpoint,原理以下图所示:测试
对于任何一个 Golang 代码的源文件,能够经过解析出这个文件的语法树,遍历整个语法树,找出全部 failpoint 注入点,而后对语法树重写,转换成想要的逻辑。
Failpoint 是一个代码片断,而且仅在对应的 failpoint name 激活的状况下才会执行,若是经过 failpoint.Disable("failpoint-name-for-demo")
禁用后, 那么对应的的 failpoint 永远不会触发。全部 failpoiint 代码片断不会编译到最终的二进制文件中,好比咱们模拟文件系统权限控制:
func saveTo(path string) error { failpoint.Inject("mock-permission-deny", func() error { // It's OK to access outer scope variable return fmt.Errorf("mock permission deny: %s", path) }) }
AST 重写阶段标记须要被重写的部分,主要有如下功能:
提示 Rewriter 重写为一个相等的 IF 语句。
目前支持的 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.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) })
示例一:在 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") }
除了上面的例子以外,还能够写的更加复杂的状况:
实际上,任何你能够调用函数的地方均可以注入 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)"
最后,欢迎你们和咱们交流讨论,一块儿完善 Failpoint 项目。