Skip to content

Commit cfaf76b

Browse files
authored
streaming: prevent Close from deadlocking on a stalled subscriber (#77)
The Reader/Sink read loop fans events out to subscriber channels with a blocking send while holding the lock. Close acquired that same lock as its first action before closing donechan, so when a subscriber stopped draining its channel the read loop parked on the send while holding the lock, Close deadlocked acquiring it, and the read goroutine plus its Redis connection leaked forever. Close now closes donechan first, without holding the lock, so the signal reaches a parked read loop; the fan-out send selects on the done channel and abandons delivery during teardown. Close is wrapped in a sync.Once so the full shutdown runs exactly once and concurrent callers block until it completes. Adds regression tests covering a stalled subscriber for both Reader and Sink.
1 parent 969e794 commit cfaf76b

4 files changed

Lines changed: 157 additions & 40 deletions

File tree

streaming/reader.go

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ type (
4444
chans []chan *Event
4545
// startOnce is used to ensure the reader is started only once.
4646
startOnce sync.Once
47+
// closeOnce is used to ensure the reader is closed only once.
48+
closeOnce sync.Once
4749
// donechan is the reader donechan channel.
4850
donechan chan struct{}
4951
// streamschan notifies the reader when streams are added or
@@ -185,21 +187,26 @@ func (r *Reader) RemoveStream(ctx context.Context, stream *Stream) error {
185187
}
186188

187189
// Close stops event polling and closes the reader channel. It is safe to call
188-
// Close multiple times.
190+
// Close multiple times; concurrent callers block until the first Close
191+
// completes. Close returns only once the read goroutine has stopped and its
192+
// resources are released, which may take up to one block duration.
189193
func (r *Reader) Close() {
190-
r.lock.Lock()
191-
if r.closing {
192-
return
193-
}
194-
r.closing = true
195-
close(r.donechan)
196-
close(r.streamschan)
197-
r.lock.Unlock()
198-
r.wait.Wait()
199-
r.lock.Lock()
200-
defer r.lock.Unlock()
201-
r.closed = true
202-
r.logger.Info("stopped")
194+
r.closeOnce.Do(func() {
195+
// Close donechan first, without holding the lock, so the signal
196+
// reaches the read loop even when it is parked on a fan-out send
197+
// to a stalled subscriber (which holds the lock). Otherwise Close
198+
// would deadlock acquiring the lock the read loop never releases.
199+
close(r.donechan)
200+
r.lock.Lock()
201+
r.closing = true
202+
close(r.streamschan)
203+
r.lock.Unlock()
204+
r.wait.Wait()
205+
r.lock.Lock()
206+
defer r.lock.Unlock()
207+
r.closed = true
208+
r.logger.Info("stopped")
209+
})
203210
}
204211

205212
// IsClosed returns true if the reader is stopped.
@@ -238,7 +245,7 @@ func (r *Reader) read() {
238245
r.lock.Lock()
239246
for _, events := range streamsEvents {
240247
streamName := events.Stream[len(streamKeyPrefix):]
241-
streamEvents(streamName, events.Stream, "", events.Messages, r.eventFilter, r.chans, r.rdb, r.logger)
248+
streamEvents(streamName, events.Stream, "", events.Messages, r.eventFilter, r.chans, r.donechan, r.rdb, r.logger)
242249
for i := range r.streamKeys {
243250
if r.streamKeys[i] == events.Stream {
244251
r.streamCursors[i] = events.Messages[len(events.Messages)-1].ID
@@ -312,6 +319,7 @@ func streamEvents(
312319
msgs []redis.XMessage,
313320
eventFilter eventFilterFunc,
314321
chans []chan *Event,
322+
done <-chan struct{},
315323
rdb *redis.Client,
316324
logger pulse.Logger,
317325
) {
@@ -339,7 +347,17 @@ func streamEvents(
339347
}
340348
logger.Debug("event", "stream", streamName, "event", ev.EventName, "id", ev.ID, "channels", len(chans))
341349
for _, c := range chans {
342-
c <- ev
350+
select {
351+
case c <- ev:
352+
case <-done:
353+
// The reader/sink is closing; stop fanning out so the
354+
// read loop can return and release its resources instead
355+
// of blocking forever on a stalled subscriber. Any
356+
// remaining subscribers and messages in this batch are
357+
// abandoned: delivery is at-most-once and the reader/sink
358+
// is being torn down, so partial fan-out is acceptable.
359+
return
360+
}
343361
}
344362
}
345363
}

streaming/reader_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,53 @@ func TestRemoveReaderStream(t *testing.T) {
193193
assert.Equal(t, []byte("payload3"), read.Payload)
194194
}
195195

196+
func TestReaderCloseWithStalledSubscriber(t *testing.T) {
197+
testName := strings.Replace(t.Name(), "/", "_", -1)
198+
rdb := ptesting.NewRedisClient(t)
199+
defer ptesting.CleanupRedis(t, rdb, true, testName)
200+
ctx := ptesting.NewTestContext(t)
201+
s, err := NewStream(testName, rdb, options.WithStreamLogger(pulse.ClueLogger(ctx)))
202+
require.NoError(t, err)
203+
204+
// Tiny buffer so the read loop's fan-out send blocks after a couple of
205+
// events when the subscriber never drains its channel.
206+
reader, err := s.NewReader(ctx,
207+
options.WithReaderStartAtOldest(),
208+
options.WithReaderBlockDuration(testBlockDuration),
209+
options.WithReaderBufferSize(1))
210+
require.NoError(t, err)
211+
212+
// Subscribe but deliberately never read from the channel so the read
213+
// loop fills the buffer and then parks on the next fan-out send.
214+
c := reader.Subscribe()
215+
216+
// Add more events than the buffer can hold so the read loop parks on
217+
// `c <- ev` inside streamEvents.
218+
for range 5 {
219+
_, err = s.Add(ctx, "event", []byte("payload"))
220+
require.NoError(t, err)
221+
}
222+
223+
// Wait until the buffer is full, which means the read loop has consumed
224+
// events and is now parked on the fan-out send to the stalled subscriber.
225+
require.Eventually(t, func() bool { return len(c) == cap(c) }, max, delay)
226+
227+
// Close must return even though the subscriber stalled the read loop.
228+
done := make(chan struct{})
229+
go func() {
230+
reader.Close()
231+
close(done)
232+
}()
233+
select {
234+
case <-done:
235+
case <-time.After(2 * time.Second):
236+
t.Fatal("reader.Close() hung with a stalled subscriber")
237+
}
238+
assert.True(t, reader.IsClosed())
239+
240+
require.NoError(t, s.Destroy(ctx))
241+
}
242+
196243
func TestEventCreatedAt(t *testing.T) {
197244
rdb := ptesting.NewRedisClient(t)
198245
defer ptesting.CleanupRedis(t, rdb, false, "")

streaming/sink.go

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ type (
7575
donechan chan struct{}
7676
// wait is the sink cleanup wait group.
7777
wait sync.WaitGroup
78+
// closeOnce is used to ensure the sink is closed only once.
79+
closeOnce sync.Once
7880
// closing is true if Close was called.
7981
closing bool
8082
// eventFilter is the event filter if any.
@@ -291,30 +293,33 @@ func (s *Sink) RemoveStream(ctx context.Context, stream *Stream) error {
291293
}
292294

293295
// Close stops event polling, waits for all events to be processed, and closes the sink channel.
294-
// It is safe to call Close multiple times.
296+
// It is safe to call Close multiple times; concurrent callers block until the
297+
// first Close completes.
295298
func (s *Sink) Close(ctx context.Context) {
296-
s.lock.Lock()
297-
if s.closing {
299+
s.closeOnce.Do(func() {
300+
// Close donechan first, without holding the lock, so the signal
301+
// reaches the read loop even when it is parked on a fan-out send
302+
// to a stalled subscriber (which holds the lock). Otherwise Close
303+
// would deadlock acquiring the lock the read loop never releases.
304+
close(s.donechan)
305+
s.lock.Lock()
306+
s.closing = true
298307
s.lock.Unlock()
299-
return
300-
}
301-
s.closing = true
302-
close(s.donechan)
303-
s.lock.Unlock()
304-
s.wait.Wait()
305-
s.lock.Lock()
306-
defer s.lock.Unlock()
307-
for _, c := range s.chans {
308-
close(c)
309-
}
310-
// Note: we do not delete the consumer from the keep-alive and consumer maps
311-
// so that another instance may claim any pending messages.
312-
s.consumersKeepAliveMap.Close()
313-
for _, cm := range s.consumersMap {
314-
cm.Close()
315-
}
316-
s.closed = true
317-
s.logger.Info("closed")
308+
s.wait.Wait()
309+
s.lock.Lock()
310+
defer s.lock.Unlock()
311+
for _, c := range s.chans {
312+
close(c)
313+
}
314+
// Note: we do not delete the consumer from the keep-alive and consumer maps
315+
// so that another instance may claim any pending messages.
316+
s.consumersKeepAliveMap.Close()
317+
for _, cm := range s.consumersMap {
318+
cm.Close()
319+
}
320+
s.closed = true
321+
s.logger.Info("closed")
322+
})
318323
}
319324

320325
// IsClosed returns true if the sink was closed.
@@ -457,7 +462,7 @@ func (s *Sink) read(ctx context.Context) {
457462
}
458463
for _, events := range streams {
459464
streamName := events.Stream[len(streamKeyPrefix):]
460-
streamEvents(streamName, events.Stream, s.Name, events.Messages, s.eventFilter, s.chans, s.rdb, s.logger)
465+
streamEvents(streamName, events.Stream, s.Name, events.Messages, s.eventFilter, s.chans, s.donechan, s.rdb, s.logger)
461466
}
462467
s.lock.Unlock()
463468
}
@@ -574,7 +579,7 @@ func (s *Sink) claim(ctx context.Context, streamName string, args redis.XAutoCla
574579
messages, start, err := s.rdb.XAutoClaim(ctx, &args).Result()
575580
if len(messages) > 0 {
576581
s.logger.Info("claimed", "stream", streamName, "messages", len(messages))
577-
streamEvents(streamName, args.Stream, s.Name, messages, s.eventFilter, s.chans, s.rdb, s.logger)
582+
streamEvents(streamName, args.Stream, s.Name, messages, s.eventFilter, s.chans, s.donechan, s.rdb, s.logger)
578583
}
579584
return start, err
580585
}

streaming/sink_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,53 @@ var (
1919
testAckDuration = 50 * time.Millisecond
2020
)
2121

22+
func TestSinkCloseWithStalledSubscriber(t *testing.T) {
23+
testName := strings.Replace(t.Name(), "/", "_", -1)
24+
rdb := ptesting.NewRedisClient(t)
25+
defer ptesting.CleanupRedis(t, rdb, true, testName)
26+
ctx := ptesting.NewTestContext(t)
27+
s, err := NewStream(testName, rdb, options.WithStreamLogger(pulse.ClueLogger(ctx)))
28+
require.NoError(t, err)
29+
30+
// Tiny buffer so the read loop's fan-out send blocks after a couple of
31+
// events when the subscriber never drains its channel.
32+
sink, err := s.NewSink(ctx, "sink",
33+
options.WithSinkStartAtOldest(),
34+
options.WithSinkBlockDuration(testBlockDuration),
35+
options.WithSinkBufferSize(1))
36+
require.NoError(t, err)
37+
38+
// Subscribe but deliberately never read from the channel so the read
39+
// loop fills the buffer and then parks on the next fan-out send.
40+
c := sink.Subscribe()
41+
42+
// Add more events than the buffer can hold so the read loop parks on
43+
// `c <- ev` inside streamEvents.
44+
for range 5 {
45+
_, err = s.Add(ctx, "event", []byte("payload"))
46+
require.NoError(t, err)
47+
}
48+
49+
// Wait until the buffer is full, which means the read loop has consumed
50+
// events and is now parked on the fan-out send to the stalled subscriber.
51+
require.Eventually(t, func() bool { return len(c) == cap(c) }, max, delay)
52+
53+
// Close must return even though the subscriber stalled the read loop.
54+
done := make(chan struct{})
55+
go func() {
56+
sink.Close(ctx)
57+
close(done)
58+
}()
59+
select {
60+
case <-done:
61+
case <-time.After(2 * time.Second):
62+
t.Fatal("sink.Close() hung with a stalled subscriber")
63+
}
64+
assert.True(t, sink.IsClosed())
65+
66+
require.NoError(t, s.Destroy(ctx))
67+
}
68+
2269
func TestNewSink(t *testing.T) {
2370
testName := strings.Replace(t.Name(), "/", "_", -1)
2471
rdb := ptesting.NewRedisClient(t)

0 commit comments

Comments
 (0)