Skip to content

Commit 0b5e824

Browse files
authored
Merge pull request #11 from keybase/joshblum/bugfix
bug fixes
2 parents 4fbc48a + 9b6ccac commit 0b5e824

2 files changed

Lines changed: 195 additions & 43 deletions

File tree

pipeliner.go

Lines changed: 59 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,19 @@ import (
77

88
// Pipeliner coordinates a flow of parallel requests, rate-limiting so that
99
// only a fixed number are outstanding at any one given time.
10+
//
11+
// Once an error has been recorded (via CompleteOne, or via context
12+
// cancellation), it is sticky: every subsequent call to WaitForRoom or
13+
// Flush on the same instance returns that error. A Pipeliner is therefore
14+
// single-use per batch — construct a new one for each independent batch of
15+
// work rather than reusing an instance after an error or cancellation.
1016
type Pipeliner struct {
11-
sync.RWMutex
12-
window int
13-
numOut int
14-
ch chan struct{}
15-
err error
17+
mu sync.RWMutex
18+
window int
19+
numOut int
20+
ch chan struct{}
21+
err error
22+
pending sync.WaitGroup // counts reservations awaiting a CompleteOne call
1623
}
1724

1825
// NewPipeliner makes a pipeliner with window size `w`.
@@ -24,21 +31,24 @@ func NewPipeliner(w int) *Pipeliner {
2431
}
2532

2633
func (p *Pipeliner) getError() error {
27-
p.RLock()
28-
defer p.RUnlock()
34+
p.mu.RLock()
35+
defer p.mu.RUnlock()
2936
return p.err
3037
}
3138

32-
func (p *Pipeliner) hasRoom() bool {
33-
p.RLock()
34-
defer p.RUnlock()
35-
return p.numOut < p.window
36-
}
37-
38-
func (p *Pipeliner) launchOne() {
39-
p.Lock()
40-
defer p.Unlock()
41-
p.numOut++
39+
// tryReserve atomically checks for room in the window and, if available,
40+
// reserves a slot. Checking and reserving under a single lock acquisition
41+
// prevents concurrent callers from both observing room and over-committing
42+
// past the window.
43+
func (p *Pipeliner) tryReserve() bool {
44+
p.mu.Lock()
45+
defer p.mu.Unlock()
46+
if p.numOut < p.window {
47+
p.numOut++
48+
p.pending.Add(1)
49+
return true
50+
}
51+
return false
4252
}
4353

4454
// WaitForRoom will block until there is room in the window to fire
@@ -50,42 +60,42 @@ func (p *Pipeliner) WaitForRoom(ctx context.Context) error {
5060
for {
5161
p.checkContextDone(ctx)
5262
if err := p.getError(); err != nil {
63+
p.drain()
5364
return err
5465
}
55-
if p.hasRoom() {
56-
break
66+
if p.tryReserve() {
67+
return nil
5768
}
5869
p.wait(ctx)
5970
}
60-
p.launchOne()
61-
return nil
6271
}
6372

6473
// CompleteOne should be called when a request is completed, to make
6574
// room for subsequent requests. Call it with an error if you want the
6675
// rest of the pipeline to be short-circuited. This is the error that
6776
// is returned from WaitForRoom.
6877
func (p *Pipeliner) CompleteOne(e error) {
78+
defer p.pending.Done()
6979
p.setError(e)
7080
p.landOne()
7181
p.ch <- struct{}{}
7282
}
7383

7484
func (p *Pipeliner) landOne() {
75-
p.Lock()
76-
defer p.Unlock()
85+
p.mu.Lock()
86+
defer p.mu.Unlock()
7787
p.numOut--
7888
}
7989

8090
func (p *Pipeliner) hasOutstanding() bool {
81-
p.RLock()
82-
defer p.RUnlock()
91+
p.mu.RLock()
92+
defer p.mu.RUnlock()
8393
return p.numOut > 0
8494
}
8595

8696
func (p *Pipeliner) setError(e error) {
87-
p.Lock()
88-
defer p.Unlock()
97+
p.mu.Lock()
98+
defer p.mu.Unlock()
8999
if e != nil && p.err == nil {
90100
p.err = e
91101
}
@@ -107,13 +117,35 @@ func (p *Pipeliner) wait(ctx context.Context) {
107117
}
108118
}
109119

120+
// drain blocks until every reserved-but-not-yet-completed request has called
121+
// CompleteOne, so WaitForRoom/Flush can never return an error while leaving
122+
// a goroutine blocked sending on ch. A "receive while numOut > 0" loop won't
123+
// do: numOut can hit zero before the last CompleteOne's send actually lands.
124+
// So instead we race a reader against pending.Wait, which only unblocks once
125+
// every reservation's CompleteOne call has truly returned.
126+
func (p *Pipeliner) drain() {
127+
done := make(chan struct{})
128+
go func() {
129+
p.pending.Wait()
130+
close(done)
131+
}()
132+
for {
133+
select {
134+
case <-p.ch:
135+
case <-done:
136+
return
137+
}
138+
}
139+
}
140+
110141
// Flush any outstanding requests, blocking until the last completes.
111142
// Returns an error set by CompleteOne, or a context-based error
112143
// if any request was canceled mid-flight.
113144
func (p *Pipeliner) Flush(ctx context.Context) error {
114145
for {
115146
p.checkContextDone(ctx)
116147
if err := p.getError(); err != nil {
148+
p.drain()
117149
return err
118150
}
119151
if !p.hasOutstanding() {

pipeliner_test.go

Lines changed: 136 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,20 @@ import (
44
"context"
55
"errors"
66
"math/rand"
7+
"reflect"
8+
"runtime"
79
"sync"
10+
"sync/atomic"
811
"testing"
912
"time"
1013

1114
"github.com/stretchr/testify/require"
1215
)
1316

1417
func TestPipeliner(t *testing.T) {
15-
v, err := testPipeliner(false, false)
18+
v, launched, err := testPipeliner(false, false)
1619
require.NoError(t, err)
20+
require.Equal(t, len(v), launched)
1721
for i, e := range v {
1822
require.Equal(t, i, e)
1923
}
@@ -24,21 +28,25 @@ func TestPipelinerError(t *testing.T) {
2428
}
2529

2630
func testPipelinerError(t *testing.T) {
27-
v, err := testPipeliner(true, false)
31+
v, launched, err := testPipeliner(true, false)
2832
require.Error(t, err)
2933
if err != nil {
3034
require.Equal(t, err.Error(), "errored out")
3135
}
32-
for _, e := range v[28:] {
36+
// Indices below `launched` were handed to a goroutine and may have
37+
// written a value; how many of those race ahead of the error being
38+
// noticed varies run to run. Only indices at or past `launched` were
39+
// never launched at all, so they must be untouched.
40+
for _, e := range v[launched:] {
3341
require.Equal(t, 0, e)
3442
}
3543
}
3644

3745
func TestPipelinerCancel(t *testing.T) {
38-
v, err := testPipeliner(false, true)
46+
v, launched, err := testPipeliner(false, true)
3947
require.Error(t, err)
4048
require.Equal(t, err.Error(), "context canceled")
41-
for _, e := range v[28:] {
49+
for _, e := range v[launched:] {
4250
require.Equal(t, 0, e)
4351
}
4452
}
@@ -49,8 +57,121 @@ func TestPipelinerErrorStress(t *testing.T) {
4957
}
5058
}
5159

52-
func testPipeliner(doError bool, doCancel bool) ([]int, error) {
53-
v := make([]int, 100)
60+
// TestPipelinerNoGoroutineLeakOnError is a regression test: callers that
61+
// follow the documented pattern of returning immediately when WaitForRoom
62+
// errors out must not leak the goroutines that were still in flight.
63+
func TestPipelinerNoGoroutineLeakOnError(t *testing.T) {
64+
runtime.GC()
65+
before := runtime.NumGoroutine()
66+
67+
for range 200 {
68+
testPipelinerError(t)
69+
}
70+
71+
// Give any leaked goroutines a chance to be scheduled and block, so
72+
// they show up in the goroutine count rather than racing this check.
73+
time.Sleep(50 * time.Millisecond)
74+
runtime.GC()
75+
after := runtime.NumGoroutine()
76+
77+
require.LessOrEqual(t, after, before+2,
78+
"goroutine count grew from %d to %d, suggesting a leak", before, after)
79+
}
80+
81+
// TestTryReserveConcurrentNeverExceedsWindow hammers tryReserve from many
82+
// goroutines to guard the check-and-reserve race. It calls tryReserve
83+
// directly rather than through WaitForRoom: concurrent WaitForRoom callers
84+
// can race over CompleteOne's wakeups, a separate hazard this library
85+
// doesn't promise to handle.
86+
func TestTryReserveConcurrentNeverExceedsWindow(t *testing.T) {
87+
const window = 4
88+
const attempts = 5000
89+
90+
p := NewPipeliner(window)
91+
92+
var wg sync.WaitGroup
93+
var active atomic.Int32
94+
var maxActive atomic.Int32
95+
96+
for range attempts {
97+
wg.Add(1)
98+
go func() {
99+
defer wg.Done()
100+
if !p.tryReserve() {
101+
return
102+
}
103+
defer p.pending.Done()
104+
105+
n := active.Add(1)
106+
for {
107+
old := maxActive.Load()
108+
if n <= old || maxActive.CompareAndSwap(old, n) {
109+
break
110+
}
111+
}
112+
active.Add(-1)
113+
p.landOne()
114+
}()
115+
}
116+
117+
wg.Wait()
118+
require.LessOrEqual(t, int(maxActive.Load()), window,
119+
"observed %d reservations active at once, exceeding window %d", maxActive.Load(), window)
120+
}
121+
122+
// TestPublicAPIDoesNotExposeLockMethods guards against re-embedding
123+
// sync.RWMutex (rather than holding it as an unexported field), which would
124+
// silently promote Lock/Unlock/RLock/RUnlock onto the public API again.
125+
func TestPublicAPIDoesNotExposeLockMethods(t *testing.T) {
126+
typ := reflect.TypeFor[*Pipeliner]()
127+
for _, name := range []string{"Lock", "Unlock", "RLock", "RUnlock"} {
128+
_, ok := typ.MethodByName(name)
129+
require.False(t, ok, "Pipeliner must not export %s", name)
130+
}
131+
}
132+
133+
// TestSetErrorFirstWins pins down the sticky-error invariant directly: once
134+
// an error is recorded, later errors must never overwrite it.
135+
func TestSetErrorFirstWins(t *testing.T) {
136+
p := NewPipeliner(4)
137+
first := errors.New("first")
138+
second := errors.New("second")
139+
140+
p.setError(first)
141+
p.setError(second)
142+
143+
require.Equal(t, first, p.getError())
144+
}
145+
146+
// TestStickyErrorReturnedByWaitForRoomAndFlush verifies the sticky error
147+
// surfaces consistently through the public API: once set, WaitForRoom and
148+
// Flush keep returning it, even across further calls on the same instance.
149+
func TestStickyErrorReturnedByWaitForRoomAndFlush(t *testing.T) {
150+
p := NewPipeliner(1)
151+
ctx := context.Background()
152+
153+
require.NoError(t, p.WaitForRoom(ctx))
154+
first := errors.New("first")
155+
go p.CompleteOne(first)
156+
157+
err := p.Flush(ctx)
158+
require.Equal(t, first, err)
159+
160+
// Reusing the instance after an error keeps returning the same error
161+
// rather than doing further work or surfacing a different one.
162+
err = p.WaitForRoom(ctx)
163+
require.Equal(t, first, err)
164+
165+
err = p.Flush(ctx)
166+
require.Equal(t, first, err)
167+
}
168+
169+
// testPipeliner runs a batch of 100 simulated requests through a Pipeliner.
170+
// It returns the results, the number of requests actually handed to a
171+
// goroutine before WaitForRoom stopped returning nil, and any resulting
172+
// error.
173+
func testPipeliner(doError bool, doCancel bool) (v []int, launched int, err error) {
174+
v = make([]int, 100)
54175
var vlock sync.Mutex
55176
pipeliner := NewPipeliner(4)
56177
ctx := context.Background()
@@ -74,17 +195,16 @@ func testPipeliner(doError bool, doCancel bool) ([]int, error) {
74195
}
75196

76197
for i := range v {
77-
err := pipeliner.WaitForRoom(ctx)
78-
if err != nil {
79-
// Ensure any in-flight goroutines are complete before returning. Note that Flush does not actually wait if there is an error.
80-
for pipeliner.hasOutstanding() {
81-
<-pipeliner.ch
82-
}
83-
return v, err
198+
werr := pipeliner.WaitForRoom(ctx)
199+
if werr != nil {
200+
// WaitForRoom drains any in-flight goroutines before returning
201+
// an error, so it's safe to return immediately here.
202+
return v, launched, werr
84203
}
204+
launched++
85205
go func(i int) { pipeliner.CompleteOne(f(ctx, i)) }(i)
86206
}
87207

88-
err := pipeliner.Flush(ctx)
89-
return v, err
208+
err = pipeliner.Flush(ctx)
209+
return v, launched, err
90210
}

0 commit comments

Comments
 (0)