Skip to content

Commit f5edd92

Browse files
shubhamdhamaclaude
andcommitted
drpcstream: introduce shared BufferPool for ring buffer
Add a BufferPool backed by sync.Pool that is shared across all streams within a Manager. The ring buffer now obtains a pooled buffer on Enqueue and copies the message into it, instead of growing a fixed slice per slot. Dequeue hands the buffer's data to the consumer and advances the tail immediately, and Done releases the buffer back to the pool once the consumer is finished with it. Keeping the Dequeue/Done contract means the consumer works with a plain []byte and never has to know whether the queue is backed by a pool or by fixed buffers. Because Dequeue advances the tail right away rather than waiting for Done, Close no longer has to block until in-flight buffers are released. The pool is a required parameter in the Stream constructor, created once per Manager and passed to all streams it creates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3579a5f commit f5edd92

7 files changed

Lines changed: 137 additions & 105 deletions

File tree

drpcmanager/active_streams_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func testMuxWriter(t *testing.T) *drpcwire.MuxWriter {
2222
}
2323

2424
func testStream(t *testing.T, id uint64) *drpcstream.Stream {
25-
return drpcstream.New(context.Background(), id, testMuxWriter(t))
25+
return drpcstream.New(context.Background(), id, testMuxWriter(t), drpcstream.NewBufferPool())
2626
}
2727

2828
func TestActiveStreams_AddAndGet(t *testing.T) {

drpcmanager/manager.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ type Manager struct {
6161
wg sync.WaitGroup // tracks active manageStream goroutines
6262

6363
// streams tracks active streams.
64-
streams *activeStreams
64+
streams *activeStreams
65+
recvPool *drpcstream.BufferPool
6566

6667
pdone drpcsignal.Chan // signals when NewServerStream has registered the new stream
6768
invokes chan invokeInfo // completed invoke info from manageReader to NewServerStream
@@ -130,6 +131,7 @@ func NewWithOptions(tr drpc.Transport, kind ManagerKind, opts Options) *Manager
130131
m.pendingStreams = make(map[uint64]*pendingStream)
131132

132133
m.streams = newActiveStreams()
134+
m.recvPool = drpcstream.NewBufferPool()
133135

134136
// set the internal stream options
135137
drpcopts.SetStreamTransport(&m.opts.Stream.Internal, m.tr)
@@ -268,7 +270,7 @@ func (m *Manager) newStream(ctx context.Context, sid uint64, kind drpc.StreamKin
268270
drpcopts.SetStreamStats(&opts.Internal, cb(rpc))
269271
}
270272

271-
stream := drpcstream.NewWithOptions(ctx, sid, m.wr, opts)
273+
stream := drpcstream.NewWithOptions(ctx, sid, m.wr, m.recvPool, opts)
272274

273275
if err := m.streams.Add(sid, stream); err != nil {
274276
return nil, err

drpcstream/buffer_pool.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright (C) 2026 Cockroach Labs.
2+
// See LICENSE for copying information.
3+
4+
package drpcstream
5+
6+
import "sync"
7+
8+
// BufferPool wraps sync.Pool to provide reusable byte slices for the
9+
// stream receive path. Buffers obtained via Get should be returned via
10+
// Put when no longer needed. Forgetting to Put is safe (GC reclaims)
11+
// but reduces reuse.
12+
type BufferPool struct {
13+
pool sync.Pool
14+
}
15+
16+
// NewBufferPool returns a new buffer pool.
17+
func NewBufferPool() *BufferPool {
18+
return &BufferPool{
19+
pool: sync.Pool{
20+
New: func() interface{} {
21+
b := make([]byte, 0, 4096)
22+
return &b
23+
},
24+
},
25+
}
26+
}
27+
28+
// Get returns a zero-length byte slice from the pool, retaining its
29+
// backing array for reuse.
30+
func (bp *BufferPool) Get() *[]byte {
31+
p := bp.pool.Get().(*[]byte)
32+
*p = (*p)[:0]
33+
return p
34+
}
35+
36+
// Put returns a buffer to the pool. Nil is safe to pass.
37+
func (bp *BufferPool) Put(b *[]byte) {
38+
if b == nil {
39+
return
40+
}
41+
bp.pool.Put(b)
42+
}

drpcstream/ring_buffer.go

Lines changed: 42 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ const defaultRingBufferCapacity = 256
1717
// assembled packet data. It sits between manageReader (producer, calls
1818
// Enqueue) and the application goroutine (consumer, calls Dequeue/Done).
1919
//
20-
// Slots are pre-allocated and reused: each slot's backing array grows via
21-
// append to fit incoming data, then stays at its high-water mark, avoiding
22-
// per-message allocation in steady state.
20+
// Buffers are obtained from a shared BufferPool. Enqueue copies data into a
21+
// pooled buffer; Dequeue returns that buffer's data and advances the tail
22+
// immediately, and Done releases the buffer back to the pool. Keeping the
23+
// pool behind Dequeue/Done means the consumer does not need to know whether
24+
// the queue is backed by a pool or by fixed buffers.
2325
//
2426
// After Close, Dequeue drains any queued messages before returning the close
2527
// error. This ensures graceful shutdown (KindClose/KindCloseSend) delivers
@@ -28,43 +30,50 @@ type ringBuffer struct {
2830
mu sync.Mutex
2931
cond sync.Cond
3032

31-
buf [][]byte // ring of byte slices
32-
head int // next write position (producer)
33-
tail int // next read position (consumer)
34-
count int // number of occupied slots
33+
pool *BufferPool // shared pool buffers are obtained from
34+
buf []*[]byte // ring of pooled buffer pointers
35+
head int // next write position (producer)
36+
tail int // next read position (consumer)
37+
count int // number of occupied slots
3538

36-
held bool // true between Dequeue and Done
37-
err error // terminal error, set by Close
39+
held *[]byte // buffer from the last Dequeue, released by Done
40+
err error // terminal error, set by Close
3841
}
3942

40-
func (rb *ringBuffer) init() {
43+
func (rb *ringBuffer) init(pool *BufferPool) {
4144
rb.cond.L = &rb.mu
42-
rb.buf = make([][]byte, defaultRingBufferCapacity)
45+
rb.pool = pool
46+
rb.buf = make([]*[]byte, defaultRingBufferCapacity)
4347
}
4448

45-
// Enqueue copies data into the next write slot. If the buffer is full, it
46-
// blocks until a slot is freed or the buffer is closed. If the buffer is
47-
// closed, Enqueue returns silently without enqueuing.
49+
// Enqueue copies data into a pooled buffer and places it in the next write
50+
// slot. If the buffer is full, it blocks until a slot is freed or the buffer
51+
// is closed. If the buffer is closed, Enqueue returns silently.
4852
func (rb *ringBuffer) Enqueue(data []byte) {
53+
b := rb.pool.Get()
54+
*b = append(*b, data...)
55+
4956
rb.mu.Lock()
5057
defer rb.mu.Unlock()
5158

5259
for rb.count == len(rb.buf) && rb.err == nil {
5360
rb.cond.Wait()
5461
}
5562
if rb.err != nil {
63+
rb.pool.Put(b)
5664
return
5765
}
5866

59-
rb.buf[rb.head] = append(rb.buf[rb.head][:0], data...)
67+
rb.buf[rb.head] = b
6068
rb.head = (rb.head + 1) % len(rb.buf)
6169
rb.count++
6270
rb.cond.Broadcast()
6371
}
6472

65-
// Dequeue returns the data from the next read slot. If the buffer is empty,
66-
// it blocks until data is available or the buffer is closed. The returned
67-
// slice is valid until Done is called.
73+
// Dequeue returns the data from the next buffered message and advances the
74+
// tail. The returned slice is valid until Done is called, which releases the
75+
// underlying buffer back to the pool. Done must be called exactly once after
76+
// each successful Dequeue.
6877
func (rb *ringBuffer) Dequeue() ([]byte, error) {
6978
rb.mu.Lock()
7079
defer rb.mu.Unlock()
@@ -76,37 +85,31 @@ func (rb *ringBuffer) Dequeue() ([]byte, error) {
7685
return nil, rb.err
7786
}
7887

79-
rb.held = true
80-
return rb.buf[rb.tail], nil
81-
}
82-
83-
// Done advances the read pointer, making the slot available for reuse.
84-
// It must be called exactly once after each successful Dequeue.
85-
//
86-
// TODO(shubham): remove this method once a shared buffer pool is introduced.
87-
// With a pool, Dequeue will advance the tail immediately and the caller will
88-
// return the buffer to the pool directly.
89-
func (rb *ringBuffer) Done() {
90-
rb.mu.Lock()
91-
defer rb.mu.Unlock()
92-
88+
b := rb.buf[rb.tail]
89+
rb.buf[rb.tail] = nil
9390
rb.tail = (rb.tail + 1) % len(rb.buf)
9491
rb.count--
95-
rb.held = false
92+
rb.held = b
9693
rb.cond.Broadcast()
94+
95+
return *b, nil
96+
}
97+
98+
// Done releases the buffer from the most recent Dequeue back to the pool,
99+
// invalidating the slice that Dequeue returned. It must be called exactly
100+
// once after each successful Dequeue. Because the queue is single-consumer,
101+
// Done is only ever called from the same goroutine as Dequeue.
102+
func (rb *ringBuffer) Done() {
103+
rb.pool.Put(rb.held)
104+
rb.held = nil
97105
}
98106

99107
// Close marks the buffer as closed with the given error. All blocked Enqueue
100-
// and Dequeue calls are woken and will return. Close waits for any in-progress
101-
// Dequeue/Done pair to complete before setting the error. Subsequent calls are
102-
// no-ops.
108+
// and Dequeue calls are woken and will return. Subsequent calls are no-ops.
103109
func (rb *ringBuffer) Close(err error) {
104110
rb.mu.Lock()
105111
defer rb.mu.Unlock()
106112

107-
for rb.held {
108-
rb.cond.Wait()
109-
}
110113
if rb.err != nil {
111114
return
112115
}

0 commit comments

Comments
 (0)