Chooses between Pool, Stage, FanOut and errgroup for concurrent Go backend work, and applies the eight concurrency rules. Use when backend code starts goroutines, uses channels, or fans work out over providers, wallets or cabals.
Install
npx skillscat add munyon-canyon/monaco/go-concurrency Install via the SkillsCat registry.
SKILL.md
Go concurrency
Business code never starts a goroutine by hand. It calls one of three helpers in apps/backend/internal/platform/concurrency/concurrency.go, or errgroup. The rules come from Concurrency rules.
Pick the helper
| Job shape | Use | Behavior |
|---|---|---|
| A known slice of items, every result needed, first error stops the rest | concurrency.FanOut(ctx, limit, items, fn) |
Results come back in input order, or not at all. The first error cancels the derived context with that error as context.Cause. Items not yet started never run. The returned error wraps the cause, so errors.Is matches. |
| A stream of work from a channel, handled by N workers | concurrency.Pool(ctx, workers, in, fn) |
Returns out and errs, both unbuffered, both closed after the last worker exits. Read them in one select loop. Ranging over out alone stalls at the first error, because that worker blocks on errs. |
| One step of a pipeline that must keep input order | concurrency.Stage(ctx, in, buf, fn) |
One goroutine, so its Result stream keeps input order. buf is its only slack. |
| A few different calls that run side by side and share a lifetime | errgroup.Group from golang.org/x/sync/errgroup |
Wait returns the first error. Use errgroup.WithContext when one failure must cancel the others. |
workers, buf and limit must be positive. Zero or negative panics at the call.
Real call sites:
FanOutbatches price calls inapps/backend/internal/platform/chain/jupiter/prices.go.errgroup.Groupruns the request and the event stream side by side inapps/backend/cmd/monacoctl/verify/drive.goandapps/backend/internal/testkit/scenario/stream.go.
Pool and Stage have no production callers yet. Their tests in apps/backend/internal/platform/concurrency/ show the calling pattern.
The eight rules
- No bare
go f()in business code. Thenogoanalyzer inapps/backend/internal/platform/lint/nogo/nogo.gofails anygostatement outsidecmd/andinternal/platform/. CI runs it withgo run ./internal/platform/lint/nogo/cmd/nogo ./.... - Every goroutine has an owner that waits for it. Every package's
TestMainistestkit.Main(m, ...)inmain_test.go, andtestkit.Mainruns goleak. A leaked goroutine fails the package. - The sender closes the channel. Receivers never close.
- Every channel is bounded. Use unbuffered or an explicit, justified size. No "big enough" buffers.
- Backpressure over dropping. Drop only live SSE hints, and count the drops in a metric.
- Shared mutable state lives behind one goroutine or a mutex in the same struct, never both. Prefer ownership over locks.
- The race detector runs in CI. Stage 1's
backendjob runsgo test -raceover the backend throughscripts/test-backend.sh. - JetStream
MaxAckPendingis the cross-process bound. It ismaxAckPending(64) inapps/backend/internal/platform/bus/registry.go. An in-process pool inside a consumer must not exceed it.
Context in concurrent code
- Pass
ctxfirst. Everyselectthat sends or receives also selects<-ctx.Done(). - Cancel with
context.WithCancelCauseso logs say why the work stopped.FanOutalready does this. - Never start work on
context.Background()outsidemain, tests and the bus consumer roots.
Tests
- Time-dependent code takes the injected
clock.Clockfromapps/backend/internal/platform/clock. Tests usetesting/synctestortestkit.Eventually.time.Sleepis banned in tests, and thetestwaitanalyzer inapps/backend/internal/platform/lint/nogo/testwait.gobans its disguises. - Test the failure path. Assert that the first error cancels the rest, that results keep their order, and that a cancelled parent context ends the work.
- Stage 0 (
go run ./cmd/monacoctl agents check) runs the short tests without-race. Stage 1 runs them with-race. Owners do not run-racelocally.
Checklist
- No
gostatement outsidecmd/orinternal/platform/. - Each goroutine's owner waits for it, and the package's
TestMainusestestkit.Main. - Each channel has a sender that closes it and a bounded size.
- Each blocking
selectincludes<-ctx.Done(). - Any in-process pool in a consumer stays at or under
MaxAckPending. - A test covers the first-error path and the cancellation path.