Skip to content

Commit f191251

Browse files
committed
fix: stop accepting pubsub messages without closing the queue
1 parent 7313e0b commit f191251

2 files changed

Lines changed: 144 additions & 3 deletions

File tree

internal/db/p2p/close_race_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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+
batcher: newPubsubBatcher("peerID", func(string, []byte) error { return nil }),
35+
processQueue: newProcessQueue(),
36+
}
37+
}
38+
39+
// The pubsub dispatcher keeps delivering until libp2p tears the subscription down, which Close
40+
// does not do, so the handler runs after Close has begun. A send on a closed channel is a ready
41+
// case in a select rather than a fallthrough to the default arm, so closing the queue to stop
42+
// the workers panics whichever goroutine is mid-enqueue.
43+
func TestEnqueueDuringCloseDoesNotPanic(t *testing.T) {
44+
p := newClosableP2P(4)
45+
46+
msg, err := cbor.Marshal(protocol.PushLogRequest{DocID: "docID"})
47+
require.NoError(t, err)
48+
49+
// Drains so the queue keeps accepting and the handler stays on the enqueue arm, which is
50+
// the arm that panics. Stopped by its own signal rather than by closing the queue, so the
51+
// only thing that can close it is the code under test.
52+
drained := make(chan struct{})
53+
stopDrain := make(chan struct{})
54+
go func() {
55+
defer close(drained)
56+
for {
57+
select {
58+
case <-stopDrain:
59+
return
60+
case <-p.msgQueue:
61+
}
62+
}
63+
}()
64+
65+
var handlers sync.WaitGroup
66+
panicked := make(chan any, 1)
67+
stop := make(chan struct{})
68+
for range 4 {
69+
handlers.Add(1)
70+
go func() {
71+
defer handlers.Done()
72+
defer func() {
73+
if r := recover(); r != nil {
74+
select {
75+
case panicked <- r:
76+
default:
77+
}
78+
}
79+
}()
80+
for {
81+
select {
82+
case <-stop:
83+
return
84+
default:
85+
}
86+
_, _ = p.pubSubMessageHandler("sender", "topic", msg)
87+
}
88+
}()
89+
}
90+
91+
time.Sleep(20 * time.Millisecond)
92+
p.Close()
93+
close(stop)
94+
handlers.Wait()
95+
close(stopDrain)
96+
<-drained
97+
98+
select {
99+
case r := <-panicked:
100+
t.Fatalf("a dispatcher enqueueing during Close panicked: %v", r)
101+
default:
102+
}
103+
}
104+
105+
// Nothing closes the queue any more, so the stop signal is the only thing that ends a worker.
106+
func TestWorkersExitOnStopAccepting(t *testing.T) {
107+
p := newClosableP2P(1)
108+
109+
done := make(chan struct{})
110+
p.msgWorkers.Add(1)
111+
go func() {
112+
defer close(done)
113+
p.processMessageWorker()
114+
}()
115+
116+
close(p.stopAccepting)
117+
select {
118+
case <-done:
119+
case <-time.After(5 * time.Second):
120+
t.Fatal("worker did not exit on stopAccepting")
121+
}
122+
}

internal/db/p2p/p2p.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,12 @@ type P2P struct {
206206
msgQueueBytes atomic.Int64
207207
// msgQueueMaxBytes caps msgQueueBytes. Zero or less disables the byte bound.
208208
msgQueueMaxBytes int64
209+
210+
// stopAccepting ends the worker pool. The queue itself is never closed: the pubsub
211+
// dispatcher keeps calling the handler until libp2p tears the subscription down, and a
212+
// send on a closed channel is a ready case in a select, so closing it would panic the
213+
// dispatcher rather than fall through to the default arm.
214+
stopAccepting chan struct{}
209215
}
210216

211217
// pushLogCommProcessor implements CommProcessor for push log functionality
@@ -263,6 +269,7 @@ func New(
263269
topicPeerCounts: make(map[string]int),
264270
msgQueue: make(chan queuedMessage, msgQueueSize),
265271
msgQueueMaxBytes: queueByteBudget(),
272+
stopAccepting: make(chan struct{}),
266273
}
267274

268275
for i := 0; i < dagSyncWorkers; i++ {
@@ -677,6 +684,8 @@ func (p *P2P) pubSubMessageHandler(from string, topic string, msg []byte) ([]byt
677684

678685
select {
679686
case p.msgQueue <- queuedMessage{req: req, size: size}:
687+
case <-p.stopAccepting:
688+
p.releaseQueueBytes(size)
680689
case <-p.ctx.Done():
681690
p.releaseQueueBytes(size)
682691
default:
@@ -712,7 +721,17 @@ func (p *P2P) releaseQueueBytes(size int64) {
712721
// concurrently, bounding the goroutine count regardless of inbound message rate.
713722
func (p *P2P) processMessageWorker() {
714723
defer p.msgWorkers.Done()
715-
for m := range p.msgQueue {
724+
for {
725+
// A worker finishes the message in hand and then stops, abandoning whatever is still
726+
// queued. Nothing has claimed those messages, and working a full queue off would
727+
// outlast any shutdown budget by minutes.
728+
var m queuedMessage
729+
select {
730+
case <-p.stopAccepting:
731+
return
732+
case m = <-p.msgQueue:
733+
}
734+
716735
err := p.processPushlogRequest(p.ctx, m.req, false)
717736
// Released here rather than deferred so the budget frees up per message, not
718737
// when the worker exits.
@@ -1144,7 +1163,7 @@ func (pq *processQueue) close() {
11441163
// and waits for all worker goroutines to exit.
11451164
// It should be called once when the P2P subsystem is shutting down.
11461165
func (p *P2P) Close() {
1147-
close(p.msgQueue)
1166+
close(p.stopAccepting)
11481167
done := make(chan struct{})
11491168
go func() {
11501169
p.msgWorkers.Wait()
@@ -1153,7 +1172,7 @@ func (p *P2P) Close() {
11531172
select {
11541173
case <-done:
11551174
case <-time.After(10 * time.Second):
1156-
log.Info("timed out waiting for pubsub workers to drain")
1175+
log.Info("timed out waiting for pubsub workers to finish")
11571176
}
11581177
p.batcher.Close()
11591178
p.processQueue.close()

0 commit comments

Comments
 (0)