goframework-io-www

2 lines · 20.7 KiB Permalink Raw
1// Recorded by goframework.io's tools/flows; do not edit.
2(window.GOFRAMEWORK_FLOWS = window.GOFRAMEWORK_FLOWS || []).push({"id":"flow","title":"A plan whose parallel step fails","group":"concurrency","summary":"flow runs a plan: load the configuration, then fetch users, orders and prices at once, then write a report, and clean up whatever happens. Fetching orders fails: the error cancels the other fetches, the report never starts, cleanup runs, and Run returns only when nothing is left running.","scenario":"err := flow.Step(loadConfig).\n\tParallel(fetchUsers, fetchOrders, fetchPrices).\n\tStep(writeReport).\n\tFinally(cleanup).\n\tRun(ctx)","recorded":{"date":"2026-10-17","go":"go1.27.2","clock":"testing/synctest's fake clock: the times are the program's own, without waiting for them","modules":{"crashproof":"9f8a7e5","flow":"56cc385"}},"width":1100,"height":610,"nodes":[{"id":"run","kind":"decision","title":"Plan.Run","module":"flow","x":20,"y":240,"w":170,"h":130},{"id":"load","kind":"effect","title":"Step: loadConfig","module":"flow","x":225,"y":240,"w":170,"h":130},{"id":"scope","kind":"decision","title":"Parallel: a scope","module":"flow","x":430,"y":240,"w":185,"h":130},{"id":"users","kind":"effect","title":"fetchUsers","x":665,"y":45,"w":195,"h":120},{"id":"orders","kind":"effect","title":"fetchOrders","x":665,"y":245,"w":195,"h":120},{"id":"prices","kind":"effect","title":"fetchPrices","x":665,"y":445,"w":195,"h":120},{"id":"report","kind":"effect","title":"Step: writeReport","x":900,"y":45,"w":180,"h":120},{"id":"result","kind":"state","title":"What Run returns","x":900,"y":245,"w":180,"h":120},{"id":"finally","kind":"effect","title":"Finally: cleanup","x":900,"y":445,"w":180,"h":120}],"edges":[{"id":1,"from":"run","to":"load","label":"step 1"},{"id":2,"from":"load","to":"scope","label":"returned nil: step 2"},{"id":3,"from":"scope","to":"users","label":"Go","bend":-10},{"id":4,"from":"scope","to":"orders","label":"Go","bend":-10},{"id":5,"from":"scope","to":"prices","label":"Go","bend":-10},{"id":6,"from":"users","to":"scope","label":"returned nil","bend":-10},{"id":7,"from":"orders","to":"scope","label":"an error: cancel the rest","bend":-10},{"id":8,"from":"scope","to":"prices","label":"its context ends","bend":14},{"id":9,"from":"scope","to":"report","label":"step 3, if step 2 succeeded","via":[[522,16],[990,16]]},{"id":10,"from":"scope","to":"finally","label":"the plan stops: Finally","via":[[522,594],[990,594]]},{"id":11,"from":"finally","to":"result","label":"Run returns"}],"events":[{"chapter":0,"at":0,"edge":1,"node":"run","kind":"run","text":"Run(ctx) starts the plan: three steps, then Finally. It returns only when everything has ended.","state":{"finally":{"lines":[["called with",""]]},"load":{"lines":[["state","waiting"]]},"orders":{"lines":[["state","waiting"]]},"prices":{"lines":[["state","waiting"]]},"report":{"lines":[["state","waiting"]]},"result":{"lines":[["Run returned",""]]},"run":{"lines":[["step",""]]},"scope":{"lines":[["blocks","3"],["running","0"]]},"users":{"lines":[["state","waiting"]]}}},{"chapter":0,"at":0,"edge":1,"node":"load","kind":"start","text":"loadConfig starts on its own goroutine.","state":{"finally":{"lines":[["called with",""]]},"load":{"status":"busy","lines":[["state","running"]]},"orders":{"lines":[["state","waiting"]]},"prices":{"lines":[["state","waiting"]]},"report":{"lines":[["state","waiting"]]},"result":{"lines":[["Run returned",""]]},"run":{"status":"busy","lines":[["step","1 of 3"]]},"scope":{"lines":[["blocks","3"],["running","0"]]},"users":{"lines":[["state","waiting"]]}}},{"chapter":0,"at":1,"edge":2,"node":"load","kind":"done","text":"loadConfig returns nil after 1 s.","state":{"finally":{"lines":[["called with",""]]},"load":{"status":"ok","lines":[["state","done in 1 s"]]},"orders":{"lines":[["state","waiting"]]},"prices":{"lines":[["state","waiting"]]},"report":{"lines":[["state","waiting"]]},"result":{"lines":[["Run returned",""]]},"run":{"status":"busy","lines":[["step","1 of 3"]]},"scope":{"lines":[["blocks","3"],["running","0"]]},"users":{"lines":[["state","waiting"]]}}},{"chapter":0,"at":1,"edge":5,"node":"prices","kind":"start","text":"fetchPrices starts on its own goroutine.","state":{"finally":{"lines":[["called with",""]]},"load":{"status":"ok","lines":[["state","done in 1 s"]]},"orders":{"lines":[["state","waiting"]]},"prices":{"status":"busy","lines":[["state","running"]]},"report":{"lines":[["state","waiting"]]},"result":{"lines":[["Run returned",""]]},"run":{"status":"busy","lines":[["step","2 of 3"]]},"scope":{"status":"busy","lines":[["blocks","3"],["running","1"]]},"users":{"lines":[["state","waiting"]]}}},{"chapter":0,"at":1,"edge":3,"node":"users","kind":"start","text":"fetchUsers starts on its own goroutine.","state":{"finally":{"lines":[["called with",""]]},"load":{"status":"ok","lines":[["state","done in 1 s"]]},"orders":{"lines":[["state","waiting"]]},"prices":{"status":"busy","lines":[["state","running"]]},"report":{"lines":[["state","waiting"]]},"result":{"lines":[["Run returned",""]]},"run":{"status":"busy","lines":[["step","2 of 3"]]},"scope":{"status":"busy","lines":[["blocks","3"],["running","2"]]},"users":{"status":"busy","lines":[["state","running"]]}}},{"chapter":0,"at":1,"edge":4,"node":"orders","kind":"start","text":"fetchOrders starts on its own goroutine.","state":{"finally":{"lines":[["called with",""]]},"load":{"status":"ok","lines":[["state","done in 1 s"]]},"orders":{"status":"busy","lines":[["state","running"]]},"prices":{"status":"busy","lines":[["state","running"]]},"report":{"lines":[["state","waiting"]]},"result":{"lines":[["Run returned",""]]},"run":{"status":"busy","lines":[["step","2 of 3"]]},"scope":{"status":"busy","lines":[["blocks","3"],["running","3"]]},"users":{"status":"busy","lines":[["state","running"]]}}},{"chapter":0,"at":1.5,"edge":6,"node":"users","kind":"done","text":"fetchUsers returns nil after 0.5 s.","state":{"finally":{"lines":[["called with",""]]},"load":{"status":"ok","lines":[["state","done in 1 s"]]},"orders":{"status":"busy","lines":[["state","running"]]},"prices":{"status":"busy","lines":[["state","running"]]},"report":{"lines":[["state","waiting"]]},"result":{"lines":[["Run returned",""]]},"run":{"status":"busy","lines":[["step","2 of 3"]]},"scope":{"status":"busy","lines":[["blocks","3"],["running","2"]]},"users":{"status":"ok","lines":[["state","done in 0.5 s"]]}}},{"chapter":1,"at":2,"edge":7,"node":"orders","kind":"failed","text":"fetchOrders returns an error after 1 s: \"orders service answered 503\". It is the first error: the scope cancels the other blocks with it.","state":{"finally":{"lines":[["called with",""]]},"load":{"status":"ok","lines":[["state","done in 1 s"]]},"orders":{"status":"error","lines":[["state","failed after 1 s"]]},"prices":{"status":"busy","lines":[["state","running"]]},"report":{"lines":[["state","waiting"]]},"result":{"lines":[["Run returned",""]]},"run":{"status":"busy","lines":[["step","2 of 3"]]},"scope":{"status":"busy","lines":[["blocks","3"],["running","1"]]},"users":{"status":"ok","lines":[["state","done in 0.5 s"]]}}},{"chapter":1,"at":2,"edge":8,"node":"prices","kind":"cancelled","text":"fetchPrices sees its context end after 1 s (cause: \"orders service answered 503\") and returns ctx.Err() at once; the scope leaves that out of Run's error, a consequence of the first.","state":{"finally":{"lines":[["called with",""]]},"load":{"status":"ok","lines":[["state","done in 1 s"]]},"orders":{"status":"error","lines":[["state","failed after 1 s"]]},"prices":{"status":"off","lines":[["state","cancelled after 1 s"]]},"report":{"lines":[["state","waiting"]]},"result":{"lines":[["Run returned",""]]},"run":{"status":"busy","lines":[["step","2 of 3"]]},"scope":{"lines":[["blocks","3"],["running","0"]]},"users":{"status":"ok","lines":[["state","done in 0.5 s"]]}}},{"chapter":2,"at":2,"node":"report","kind":"skipped","text":"writeReport never starts: step 2 failed, and a plan stops at the first failing step.","state":{"finally":{"lines":[["called with",""]]},"load":{"status":"ok","lines":[["state","done in 1 s"]]},"orders":{"status":"error","lines":[["state","failed after 1 s"]]},"prices":{"status":"off","lines":[["state","cancelled after 1 s"]]},"report":{"status":"off","lines":[["state","never started"]]},"result":{"lines":[["Run returned",""]]},"run":{"status":"busy","lines":[["step","2 of 3"]]},"scope":{"lines":[["blocks","3"],["running","0"]]},"users":{"status":"ok","lines":[["state","done in 0.5 s"]]}}},{"chapter":2,"at":2,"edge":10,"node":"finally","kind":"finally","text":"cleanup runs, called with the plan's error: \"orders service answered 503\".","state":{"finally":{"status":"ok","lines":[["called with","orders service answered 503"]]},"load":{"status":"ok","lines":[["state","done in 1 s"]]},"orders":{"status":"error","lines":[["state","failed after 1 s"]]},"prices":{"status":"off","lines":[["state","cancelled after 1 s"]]},"report":{"status":"off","lines":[["state","never started"]]},"result":{"lines":[["Run returned",""]]},"run":{"status":"busy","lines":[["step","2 of 3"]]},"scope":{"lines":[["blocks","3"],["running","0"]]},"users":{"status":"ok","lines":[["state","done in 0.5 s"]]}}},{"chapter":2,"at":2,"edge":11,"node":"result","kind":"returned","text":"Run returns \"orders service answered 503\", after 2 s: every block has ended, the cancelled ones included.","state":{"finally":{"status":"ok","lines":[["called with","orders service answered 503"]]},"load":{"status":"ok","lines":[["state","done in 1 s"]]},"orders":{"status":"error","lines":[["state","failed after 1 s"]]},"prices":{"status":"off","lines":[["state","cancelled after 1 s"]]},"report":{"status":"off","lines":[["state","never started"]]},"result":{"status":"error","lines":[["Run returned","orders service answered 503"]]},"run":{"lines":[["step","ended"]]},"scope":{"lines":[["blocks","3"],["running","0"]]},"users":{"status":"ok","lines":[["state","done in 0.5 s"]]}}}],"chapters":["Step 1, then three fetches at once","One fails: the others are cancelled, and the plan stops","Finally runs, and Run returns once nothing is left running"],"answers":{"cancelled":{"title":"A block is cancelled","why":"Cancellation in Go is cooperative: a block notices its context ending and returns. The cause says why, so a block can tell a sibling's failure from a deadline or the caller giving up. Its context error is left out of Run's result: it is a consequence of the first error, not news.","how":"The block waits on ctx.Done() alongside its work and returns ctx.Err(); the scope's fail drops context errors that come after its own cancellation.","code":[{"module":"tools/flows","file":"flow_test.go","symbol":"block","line":120,"source":"// block is one of the plan's blocks: it reports starting, then works for\n// took and returns nil or fail, or stops as soon as its context ends.\nfunc block(r *recorder, mutex *sync.Mutex, state map[string]string, name string, startEdge, doneEdge int, took time.Duration, fail error) func(ctx context.Context) error {\n\tlabel := map[string]string{\"load\": \"loadConfig\", \"users\": \"fetchUsers\", \"orders\": \"fetchOrders\", \"prices\": \"fetchPrices\", \"report\": \"writeReport\"}[name]\n\treturn func(ctx context.Context) error {\n\t\tstarted := time.Now()\n\t\tset := func(value string) {\n\t\t\tmutex.Lock()\n\t\t\tstate[name] = value\n\t\t\tmutex.Unlock()\n\t\t}\n\t\tset(\"running\")\n\t\tr.emit(name, startEdge, \"start\", \"%s starts on its own goroutine.\", label)\n\t\tselect {\n\t\tcase \u003c-time.After(took):\n\t\tcase \u003c-ctx.Done():\n\t\t\tset(\"cancelled after \" + seconds(time.Since(started)))\n\t\t\tr.emit(name, 8, \"cancelled\", \"%s sees its context end after %s (cause: %q) and returns ctx.Err() at once; the scope leaves that out of Run's error, a consequence of the first.\", label, seconds(time.Since(started)), context.Cause(ctx).Error())\n\t\t\treturn ctx.Err()\n\t\t}\n\t\tif fail != nil {\n\t\t\tr.next(\"One fails: the others are cancelled, and the plan stops\")\n\t\t\tset(\"failed after \" + seconds(time.Since(started)))\n\t\t\tr.emit(name, doneEdge, \"failed\", \"%s returns an error after %s: %q. It is the first error: the scope cancels the other blocks with it.\", label, seconds(time.Since(started)), fail.Error())\n\t\t\treturn fail\n\t\t}\n\t\tset(\"done in \" + seconds(time.Since(started)))\n\t\tr.emit(name, doneEdge, \"done\", \"%s returns nil after %s.\", label, seconds(time.Since(started)))\n\t\treturn nil\n\t}\n}"},{"module":"flow","file":"scope.go","symbol":"S.fail","line":192,"source":"// fail records a block's error; the first one cancels the scope.\nfunc (thisRef *S) fail(err error) {\n\tthisRef.lock.Lock()\n\tdefer thisRef.lock.Unlock()\n\tif thisRef.ctx.Err() != nil \u0026\u0026 (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) {\n\t\tthisRef.stopped++\n\t\treturn\n\t}\n\tthisRef.errs = append(thisRef.errs, err)\n\tif len(thisRef.errs) == 1 {\n\t\tthisRef.cancel(err)\n\t}\n}"}]},"done":{"title":"A block returns nil","why":"A step is done when all its blocks are; the next step starts only then, so each step can rely on what the steps before it did.","how":"The scope waits for its blocks; Run moves to the next step when the scope returned nil.","code":[{"module":"flow","file":"scope.go","symbol":"S.wait","line":174,"source":"// wait waits for every block, ends the scope and returns its error.\nfunc (thisRef *S) wait() error {\n\tthisRef.group.Wait()\n\tthisRef.ended.Store(true)\n\tcause := context.Cause(thisRef.ctx)\n\tthisRef.cancel(nil)\n\tthisRef.stop()\n\tthisRef.lock.Lock()\n\tdefer thisRef.lock.Unlock()\n\tswitch {\n\tcase len(thisRef.errs) == 0 \u0026\u0026 thisRef.stopped \u003e 0 \u0026\u0026 cause != nil:\n\t\treturn cause\n\tcase len(thisRef.errs) == 1:\n\t\treturn thisRef.errs[0]\n\t}\n\treturn errors.Join(thisRef.errs...)\n}"}]},"failed":{"title":"A block fails","why":"When one block fails, the others usually have no point going on: the report needs all three fetches. The first error cancels the scope's context, so the others stop early instead of wasting time and resources.","how":"The scope records the first error and cancels its context with that error as the cause.","code":[{"module":"flow","file":"scope.go","symbol":"S.fail","line":192,"source":"// fail records a block's error; the first one cancels the scope.\nfunc (thisRef *S) fail(err error) {\n\tthisRef.lock.Lock()\n\tdefer thisRef.lock.Unlock()\n\tif thisRef.ctx.Err() != nil \u0026\u0026 (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) {\n\t\tthisRef.stopped++\n\t\treturn\n\t}\n\tthisRef.errs = append(thisRef.errs, err)\n\tif len(thisRef.errs) == 1 {\n\t\tthisRef.cancel(err)\n\t}\n}"}]},"finally":{"title":"Finally runs","why":"Cleanup must happen whether the plan succeeded, failed or panicked, and it should know which. Finally gets the plan's error; a panic inside it becomes an error too.","how":"Run calls each Finally function after the steps, with their error, through finish, which recovers a panic.","code":[{"module":"flow","file":"plan.go","symbol":"finish","line":191,"source":"// finish calls a Finally function, its panic turned into a *PanicError.\nfunc finish(f func(error), err error) (panicked error) {\n\tdefer func() {\n\t\tif value := recover(); value != nil {\n\t\t\tpanicked = \u0026PanicError{Panic: crashproof.Recovered(value)}\n\t\t}\n\t}()\n\tf(err)\n\treturn nil\n}"}]},"returned":{"title":"Run returns","why":"Run returns only when every block has ended, cancelled ones included: when it returns, nothing the plan started is still running. Its error is the first one, the cause of everything else.","how":"Run returns the steps' error joined with any error of the Finally functions.","code":[{"module":"flow","file":"plan.go","symbol":"Plan.Run","line":144,"source":"// Run runs the plan's steps one after another, each in a scope of its own,\n// and stops at the first that fails; then the Finally functions, with the\n// plan's error. Misuse found while building the plan is returned without\n// running anything.\nfunc (thisRef Plan) Run(ctx context.Context) error {\n\tif thisRef.err != nil {\n\t\treturn thisRef.err\n\t}\n\terr := thisRef.runSteps(ctx)\n\tfor _, f := range thisRef.finally {\n\t\tif finallyErr := finish(f, err); finallyErr != nil {\n\t\t\terr = errors.Join(err, finallyErr)\n\t\t}\n\t}\n\treturn err\n}"}]},"run":{"title":"Run starts the plan","why":"A plan is work described, not started: building it starts nothing, so it can be passed around, reused, or checked for mistakes before it runs. Run blocks until everything in it has ended.","how":"Run returns the plan's building errors without running anything; otherwise it runs the steps in order, then the Finally functions.","code":[{"module":"flow","file":"plan.go","symbol":"Plan.Run","line":144,"source":"// Run runs the plan's steps one after another, each in a scope of its own,\n// and stops at the first that fails; then the Finally functions, with the\n// plan's error. Misuse found while building the plan is returned without\n// running anything.\nfunc (thisRef Plan) Run(ctx context.Context) error {\n\tif thisRef.err != nil {\n\t\treturn thisRef.err\n\t}\n\terr := thisRef.runSteps(ctx)\n\tfor _, f := range thisRef.finally {\n\t\tif finallyErr := finish(f, err); finallyErr != nil {\n\t\t\terr = errors.Join(err, finallyErr)\n\t\t}\n\t}\n\treturn err\n}"},{"module":"flow","file":"plan.go","symbol":"Plan.runSteps","line":158,"source":"// runSteps runs the steps, stopping at the first error.\nfunc (thisRef Plan) runSteps(ctx context.Context) error {\n\tfor _, current := range thisRef.steps {\n\t\tif err := current.run(ctx); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}"}]},"skipped":{"title":"A later step never starts","why":"Steps run in order and stop at the first that fails: writing a report from incomplete data would be worse than not writing it.","how":"runSteps returns the failing step's error without running the steps after it.","code":[{"module":"flow","file":"plan.go","symbol":"Plan.runSteps","line":158,"source":"// runSteps runs the steps, stopping at the first error.\nfunc (thisRef Plan) runSteps(ctx context.Context) error {\n\tfor _, current := range thisRef.steps {\n\t\tif err := current.run(ctx); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}"}]},"start":{"title":"A block starts","why":"Each block runs on its own goroutine inside a scope, and the scope does not return until every block has: no goroutine outlives the call that started it. A panic in a block becomes that block's error instead of ending the program.","how":"A step runs in a Scope; each block is started with S.Go, which counts it, runs it with the scope's context, and recovers its panic.","code":[{"module":"flow","file":"plan.go","symbol":"step.run","line":167,"source":"func (thisRef step) run(ctx context.Context) error {\n\tvar options []Option\n\tif thisRef.limit \u003e 0 {\n\t\toptions = append(options, Limit(thisRef.limit))\n\t}\n\tif thisRef.timeout \u003e 0 {\n\t\toptions = append(options, Timeout(thisRef.timeout))\n\t}\n\tif thisRef.name != \"\" {\n\t\toptions = append(options, Name(thisRef.name))\n\t}\n\treturn Scope(ctx, func(ctx context.Context, s *S) error {\n\t\tif thisRef.plan != nil {\n\t\t\ts.Go(thisRef.plan.Run)\n\t\t\treturn nil\n\t\t}\n\t\tfor _, block := range thisRef.blocks {\n\t\t\ts.Go(block)\n\t\t}\n\t\treturn nil\n\t}, options...)\n}"},{"module":"flow","file":"scope.go","symbol":"S.Go","line":214,"source":"// Go runs block in a new goroutine inside the scope; with a Limit, it first\n// waits for a free slot, and skips block if the scope ends meanwhile. A nil\n// block is the scope's error.\nfunc (thisRef *S) Go(block func(ctx context.Context) error) {\n\tthisRef.launch(block, true, true, nil)\n}"}]}},"questions":[{"q":"How is this different from errgroup?","a":"errgroup.Group returns the first error and needs a context set up by hand; flow also orders steps, runs Finally, turns panics into errors, limits concurrency per step, and the Scope it builds on returns every error, not only the first."},{"q":"Why not go func() and a WaitGroup?","a":"Nothing stops a forgotten Wait, a goroutine that outlives its caller, or a panic that ends the program. flow makes the structure impossible to get wrong: Run returns when everything is done."},{"q":"What if a block ignores its context?","a":"Then Run waits for it: flow never abandons a goroutine. That is the point: when Run returns, nothing it started is running. Blocks should watch ctx.Done()."},{"q":"Can a step have a timeout?","a":"Yes: Timeout(d) after a step ends its context after d, with ErrTimeout as the cause; Limit(n) runs at most n of its blocks at once."},{"q":"What does a panic in a block do?","a":"It becomes that block's error (a *PanicError with its stack), cancels the rest like any error, and Run returns it. The program keeps running."},{"q":"What does it cost?","a":"A block costs about 20 to 25% more than a bare WaitGroup.Go for tiny blocks (flow.md); for blocks that do I/O, nothing measurable."}]});