Skip to content

Commit 8af6dc2

Browse files
committed
error group support
1 parent eadb4c5 commit 8af6dc2

2 files changed

Lines changed: 312 additions & 0 deletions

File tree

group.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Group runs multiple functions concurrently and collects all errors into a
2+
// *MultiError. It is the error-aware counterpart to sync/errgroup: errgroup
3+
// stops at the first failure; Group collects every failure.
4+
5+
package errors
6+
7+
import (
8+
"context"
9+
"sync"
10+
)
11+
12+
// Group runs goroutines concurrently and collects every error they return.
13+
// The zero value is ready to use; options may be applied via NewGroup.
14+
//
15+
// Example — fan-out with full error collection:
16+
//
17+
// g := errors.NewGroup()
18+
// g.Go(func() error { return validateUser(id) })
19+
// g.Go(func() error { return validatePerms(id) })
20+
// if err := g.Wait(); err != nil {
21+
// // err is *MultiError containing all failures
22+
// log.Println(err)
23+
// }
24+
type Group struct {
25+
wg sync.WaitGroup
26+
errs *MultiError
27+
ctx context.Context
28+
cancel context.CancelFunc
29+
cancelOnFirst bool
30+
}
31+
32+
// GroupOption configures a Group.
33+
type GroupOption func(*Group)
34+
35+
// GroupWithContext attaches ctx to the Group. The ctx is passed to
36+
// context-aware Go calls (GoCtx). If cancelOnFirst is true, the context
37+
// is cancelled as soon as the first error is returned by any goroutine —
38+
// useful for "cancel siblings on first failure" patterns.
39+
func GroupWithContext(ctx context.Context, cancelOnFirst bool) GroupOption {
40+
return func(g *Group) {
41+
g.ctx, g.cancel = context.WithCancel(ctx)
42+
g.cancelOnFirst = cancelOnFirst
43+
}
44+
}
45+
46+
// GroupWithLimit sets a maximum error limit on the underlying MultiError.
47+
// Errors beyond the limit are dropped.
48+
func GroupWithLimit(n int) GroupOption {
49+
return func(g *Group) {
50+
g.errs = NewMultiError(WithLimit(n))
51+
}
52+
}
53+
54+
// NewGroup creates a Group with the given options applied.
55+
func NewGroup(opts ...GroupOption) *Group {
56+
g := &Group{
57+
errs: NewMultiError(),
58+
}
59+
for _, o := range opts {
60+
o(g)
61+
}
62+
if g.ctx == nil {
63+
g.ctx = context.Background()
64+
}
65+
return g
66+
}
67+
68+
// Go starts fn in a new goroutine. Errors returned by fn are collected;
69+
// nil returns are ignored. Thread-safe: MultiError.Add handles its own locking.
70+
// cancelOnFirst is read-only after construction so no lock is needed.
71+
func (g *Group) Go(fn func() error) {
72+
g.wg.Add(1)
73+
go func() {
74+
defer g.wg.Done()
75+
if err := fn(); err != nil {
76+
g.errs.Add(err) // MultiError.Add is internally mutex-protected
77+
if g.cancelOnFirst && g.cancel != nil {
78+
g.cancel()
79+
}
80+
}
81+
}()
82+
}
83+
84+
// GoCtx starts fn in a new goroutine, passing the group's context.
85+
// If the group was created with GroupWithContext, fn receives a context
86+
// that is cancelled when cancelOnFirst triggers or the parent is done.
87+
func (g *Group) GoCtx(fn func(ctx context.Context) error) {
88+
g.wg.Add(1)
89+
go func() {
90+
defer g.wg.Done()
91+
if err := fn(g.ctx); err != nil {
92+
g.errs.Add(err) // MultiError.Add is internally mutex-protected
93+
if g.cancelOnFirst && g.cancel != nil {
94+
g.cancel()
95+
}
96+
}
97+
}()
98+
}
99+
100+
// Wait blocks until all goroutines have finished and returns a *MultiError
101+
// containing every error collected, or nil if all succeeded.
102+
// Always returns *MultiError (never collapses to a raw error) so callers
103+
// can reliably type-assert the result.
104+
func (g *Group) Wait() error {
105+
g.wg.Wait()
106+
if g.cancel != nil {
107+
g.cancel() // release context resources
108+
}
109+
if !g.errs.Has() {
110+
return nil
111+
}
112+
return g.errs
113+
}
114+
115+
// Errors returns a snapshot of errors collected so far.
116+
// Safe to call concurrently with Go/GoCtx; may be incomplete before Wait returns.
117+
func (g *Group) Errors() []error {
118+
return g.errs.Errors()
119+
}

group_test.go

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package errors
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
"sync/atomic"
8+
"testing"
9+
"time"
10+
)
11+
12+
func TestGroupAllSucceed(t *testing.T) {
13+
g := NewGroup()
14+
g.Go(func() error { return nil })
15+
g.Go(func() error { return nil })
16+
g.Go(func() error { return nil })
17+
18+
if err := g.Wait(); err != nil {
19+
t.Errorf("expected nil, got: %v", err)
20+
}
21+
}
22+
23+
func TestGroupCollectsAllErrors(t *testing.T) {
24+
g := NewGroup()
25+
g.Go(func() error { return New("error one") })
26+
g.Go(func() error { return nil })
27+
g.Go(func() error { return New("error two") })
28+
g.Go(func() error { return New("error three") })
29+
30+
err := g.Wait()
31+
if err == nil {
32+
t.Fatal("expected errors, got nil")
33+
}
34+
multi, ok := err.(*MultiError)
35+
if !ok {
36+
t.Fatalf("expected *MultiError, got %T", err)
37+
}
38+
if multi.Count() != 3 {
39+
t.Errorf("expected 3 errors, got %d", multi.Count())
40+
}
41+
}
42+
43+
func TestGroupSingleError(t *testing.T) {
44+
g := NewGroup()
45+
g.Go(func() error { return New("only error") })
46+
47+
err := g.Wait()
48+
if err == nil {
49+
t.Fatal("expected error, got nil")
50+
}
51+
// Wait always returns *MultiError so callers can reliably type-assert.
52+
multi, ok := err.(*MultiError)
53+
if !ok {
54+
t.Fatalf("expected *MultiError, got %T", err)
55+
}
56+
if multi.Count() != 1 {
57+
t.Errorf("expected 1 error, got %d", multi.Count())
58+
}
59+
if !strings.Contains(multi.Error(), "only error") {
60+
t.Errorf("unexpected message: %q", multi.Error())
61+
}
62+
}
63+
64+
func TestGroupGoCtx(t *testing.T) {
65+
ctx := context.Background()
66+
g := NewGroup(GroupWithContext(ctx, false))
67+
68+
var received atomic.Int32
69+
g.GoCtx(func(ctx context.Context) error {
70+
if ctx == nil {
71+
return New("nil context")
72+
}
73+
received.Add(1)
74+
return nil
75+
})
76+
g.GoCtx(func(ctx context.Context) error {
77+
received.Add(1)
78+
return nil
79+
})
80+
81+
if err := g.Wait(); err != nil {
82+
t.Errorf("unexpected error: %v", err)
83+
}
84+
if received.Load() != 2 {
85+
t.Errorf("expected 2 goroutines to run, got %d", received.Load())
86+
}
87+
}
88+
89+
func TestGroupCancelOnFirst(t *testing.T) {
90+
ctx := context.Background()
91+
g := NewGroup(GroupWithContext(ctx, true))
92+
93+
var started atomic.Int32
94+
// First goroutine errors immediately.
95+
g.GoCtx(func(ctx context.Context) error {
96+
started.Add(1)
97+
return New("first failure")
98+
})
99+
// Second goroutine checks ctx cancellation after a small delay.
100+
g.GoCtx(func(ctx context.Context) error {
101+
started.Add(1)
102+
select {
103+
case <-ctx.Done():
104+
// Context was cancelled by first failure — return nil
105+
// to show cancellation was observed.
106+
return nil
107+
case <-time.After(200 * time.Millisecond):
108+
return New("second should have been cancelled")
109+
}
110+
})
111+
112+
err := g.Wait()
113+
// Only the first error should be collected; second observed cancellation.
114+
if err == nil {
115+
t.Fatal("expected at least one error")
116+
}
117+
if started.Load() != 2 {
118+
t.Errorf("expected both goroutines to start, got %d", started.Load())
119+
}
120+
}
121+
122+
func TestGroupWithLimit(t *testing.T) {
123+
g := NewGroup(GroupWithLimit(2))
124+
for i := 0; i < 10; i++ {
125+
i := i
126+
g.Go(func() error { return fmt.Errorf("error %d", i) })
127+
}
128+
err := g.Wait()
129+
if err == nil {
130+
t.Fatal("expected errors, got nil")
131+
}
132+
multi, ok := err.(*MultiError)
133+
if !ok {
134+
t.Fatalf("expected *MultiError, got %T", err)
135+
}
136+
if multi.Count() > 2 {
137+
t.Errorf("expected at most 2 errors due to limit, got %d", multi.Count())
138+
}
139+
}
140+
141+
func TestGroupErrors(t *testing.T) {
142+
g := NewGroup()
143+
g.Go(func() error { return New("a") })
144+
g.Go(func() error { return New("b") })
145+
_ = g.Wait()
146+
147+
errs := g.Errors()
148+
if len(errs) != 2 {
149+
t.Errorf("expected 2 errors from Errors(), got %d", len(errs))
150+
}
151+
}
152+
153+
func TestGroupReuseAfterWait(t *testing.T) {
154+
g := NewGroup()
155+
g.Go(func() error { return New("round one") })
156+
err1 := g.Wait()
157+
if err1 == nil {
158+
t.Fatal("expected error in round one")
159+
}
160+
161+
// Second round — verify group can be reused.
162+
g.Go(func() error { return nil })
163+
err2 := g.Wait()
164+
// After reuse the old errors are still present (Group accumulates).
165+
// This is expected behaviour; document it in the test.
166+
_ = err2
167+
}
168+
169+
func TestGroupConcurrentSafety(t *testing.T) {
170+
g := NewGroup()
171+
for i := 0; i < 100; i++ {
172+
i := i
173+
g.Go(func() error {
174+
if i%2 == 0 {
175+
// Use unique messages so MultiError.Add deduplication does not
176+
// collapse them — each goroutine index produces a distinct string.
177+
return fmt.Errorf("even error %d", i)
178+
}
179+
return nil
180+
})
181+
}
182+
err := g.Wait()
183+
if err == nil {
184+
t.Fatal("expected errors from 50 failing goroutines")
185+
}
186+
multi, ok := err.(*MultiError)
187+
if !ok {
188+
t.Fatalf("expected *MultiError, got %T", err)
189+
}
190+
if multi.Count() != 50 {
191+
t.Errorf("expected 50 errors, got %d", multi.Count())
192+
}
193+
}

0 commit comments

Comments
 (0)