Skip to content

Commit 0550de8

Browse files
committed
fix: stop accepting pubsub messages without closing the queue
1 parent 3567a2f commit 0550de8

2 files changed

Lines changed: 145 additions & 3 deletions

File tree

internal/db/p2p/close_race_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright 2025 Democratized Data Foundation
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the file licenses/BSL.txt.
5+
//
6+
// As of the Change Date specified in that file, in accordance with
7+
// the Business Source License, use of this software will be governed
8+
// by the Apache License, Version 2.0, included in the file
9+
// licenses/APL.txt.
10+
11+
package p2p
12+
13+
import (
14+
"context"
15+
"sync"
16+
"testing"
17+
"time"
18+
19+
"github.com/fxamacker/cbor/v2"
20+
"github.com/stretchr/testify/require"
21+
22+
"github.com/sourcenetwork/defradb/internal/db/p2p/protocol"
23+
)
24+
25+
// newClosableP2P builds the smallest P2P that Close can run against: the queue and its stop
26+
// signal, plus the two subsystems Close shuts down after the workers.
27+
func newClosableP2P(queueSize int) *P2P {
28+
return &P2P{
29+
ctx: context.Background(),
30+
host: &SimpleMockHost{},
31+
msgQueue: make(chan queuedMessage, queueSize),
32+
msgQueueMaxBytes: 1 << 20,
33+
stopAccepting: make(chan struct{}),
34+
stopStats: make(chan struct{}),
35+
batcher: newPubsubBatcher("peerID", func(string, []byte) error { return nil }),
36+
processQueue: newProcessQueue(),
37+
}
38+
}
39+
40+
// The pubsub dispatcher keeps delivering until libp2p tears the subscription down, which Close
41+
// does not do, so the handler runs after Close has begun. A send on a closed channel is a ready
42+
// case in a select rather than a fallthrough to the default arm, so closing the queue to stop
43+
// the workers panics whichever goroutine is mid-enqueue.
44+
func TestEnqueueDuringCloseDoesNotPanic(t *testing.T) {
45+
p := newClosableP2P(4)
46+
47+
msg, err := cbor.Marshal(protocol.PushLogRequest{DocID: "docID"})
48+
require.NoError(t, err)
49+
50+
// Drains so the queue keeps accepting and the handler stays on the enqueue arm, which is
51+
// the arm that panics. Stopped by its own signal rather than by closing the queue, so the
52+
// only thing that can close it is the code under test.
53+
drained := make(chan struct{})
54+
stopDrain := make(chan struct{})
55+
go func() {
56+
defer close(drained)
57+
for {
58+
select {
59+
case <-stopDrain:
60+
return
61+
case <-p.msgQueue:
62+
}
63+
}
64+
}()
65+
66+
var handlers sync.WaitGroup
67+
panicked := make(chan any, 1)
68+
stop := make(chan struct{})
69+
for range 4 {
70+
handlers.Add(1)
71+
go func() {
72+
defer handlers.Done()
73+
defer func() {
74+
if r := recover(); r != nil {
75+
select {
76+
case panicked <- r:
77+
default:
78+
}
79+
}
80+
}()
81+
for {
82+
select {
83+
case <-stop:
84+
return
85+
default:
86+
}
87+
_, _ = p.pubSubMessageHandler("sender", "topic", msg)
88+
}
89+
}()
90+
}
91+
92+
time.Sleep(20 * time.Millisecond)
93+
p.Close()
94+
close(stop)
95+
handlers.Wait()
96+
close(stopDrain)
97+
<-drained
98+
99+
select {
100+
case r := <-panicked:
101+
t.Fatalf("a dispatcher enqueueing during Close panicked: %v", r)
102+
default:
103+
}
104+
}
105+
106+
// Nothing closes the queue any more, so the stop signal is the only thing that ends a worker.
107+
func TestWorkersExitOnStopAccepting(t *testing.T) {
108+
p := newClosableP2P(1)
109+
110+
done := make(chan struct{})
111+
p.msgWorkers.Add(1)
112+
go func() {
113+
defer close(done)
114+
p.processMessageWorker()
115+
}()
116+
117+
close(p.stopAccepting)
118+
select {
119+
case <-done:
120+
case <-time.After(5 * time.Second):
121+
t.Fatal("worker did not exit on stopAccepting")
122+
}
123+
}

internal/db/p2p/p2p.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,12 @@ type P2P struct {
212212
// msgQueueMaxBytes caps msgQueueBytes. Zero or less disables the byte bound.
213213
msgQueueMaxBytes int64
214214

215+
// stopAccepting ends the worker pool. The queue itself is never closed: the pubsub
216+
// dispatcher keeps calling the handler until libp2p tears the subscription down, and a
217+
// send on a closed channel is a ready case in a select, so closing it would panic the
218+
// dispatcher rather than fall through to the default arm.
219+
stopAccepting chan struct{}
220+
215221
// stopStats ends the stats reporter. The context outlives Close, so the reporter
216222
// needs a signal of its own.
217223
stopStats chan struct{}
@@ -328,6 +334,7 @@ func New(
328334
topicPeerCounts: make(map[string]int),
329335
msgQueue: make(chan queuedMessage, msgQueueSize),
330336
msgQueueMaxBytes: queueByteBudget(),
337+
stopAccepting: make(chan struct{}),
331338
stopStats: make(chan struct{}),
332339
dedup: newWireDedup(),
333340
}
@@ -746,6 +753,8 @@ func (p *P2P) pubSubMessageHandler(from string, topic string, msg []byte) ([]byt
746753

747754
select {
748755
case p.msgQueue <- queuedMessage{req: req, size: size}:
756+
case <-p.stopAccepting:
757+
p.releaseQueueBytes(size)
749758
case <-p.ctx.Done():
750759
p.releaseQueueBytes(size)
751760
default:
@@ -861,7 +870,17 @@ func reportFailureReasons(msg string, counts []reasonCount) {
861870
// concurrently, bounding the goroutine count regardless of inbound message rate.
862871
func (p *P2P) processMessageWorker() {
863872
defer p.msgWorkers.Done()
864-
for m := range p.msgQueue {
873+
for {
874+
// A worker finishes the message in hand and then stops, abandoning whatever is still
875+
// queued. Nothing has claimed those messages, and working a full queue off would
876+
// outlast any shutdown budget by minutes.
877+
var m queuedMessage
878+
select {
879+
case <-p.stopAccepting:
880+
return
881+
case m = <-p.msgQueue:
882+
}
883+
865884
err := p.processPushlogRequest(p.ctx, m.req, false)
866885
// Released here rather than deferred so the budget frees up per message, not
867886
// when the worker exits.
@@ -1322,7 +1341,7 @@ func (pq *processQueue) close() {
13221341
// It should be called once when the P2P subsystem is shutting down.
13231342
func (p *P2P) Close() {
13241343
close(p.stopStats)
1325-
close(p.msgQueue)
1344+
close(p.stopAccepting)
13261345
done := make(chan struct{})
13271346
go func() {
13281347
p.msgWorkers.Wait()
@@ -1331,7 +1350,7 @@ func (p *P2P) Close() {
13311350
select {
13321351
case <-done:
13331352
case <-time.After(10 * time.Second):
1334-
log.Info("timed out waiting for pubsub workers to drain")
1353+
log.Info("timed out waiting for pubsub workers to finish")
13351354
}
13361355
p.batcher.Close()
13371356
p.processQueue.close()

0 commit comments

Comments
 (0)