Skip to content

Commit 03f1339

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 03f1339

7 files changed

Lines changed: 141 additions & 106 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: 46 additions & 40 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,53 @@ 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
35-
36-
held bool // true between Dequeue and Done
37-
err error // terminal error, set by Close
33+
// pool is shared across all streams on a connection and is owned by the
34+
// Manager, not the ring buffer. Its lifetime outlives this buffer, so a
35+
// consumer may safely return a buffer via Done even after Close.
36+
pool *BufferPool
37+
buf []*[]byte // ring of pooled buffer pointers
38+
head int // next write position (producer)
39+
tail int // next read position (consumer)
40+
count int // number of occupied slots
41+
42+
held *[]byte // buffer from the last Dequeue, released by Done
43+
err error // terminal error, set by Close
3844
}
3945

40-
func (rb *ringBuffer) init() {
46+
func (rb *ringBuffer) init(pool *BufferPool) {
4147
rb.cond.L = &rb.mu
42-
rb.buf = make([][]byte, defaultRingBufferCapacity)
48+
rb.pool = pool
49+
rb.buf = make([]*[]byte, defaultRingBufferCapacity)
4350
}
4451

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.
52+
// Enqueue copies data into a pooled buffer and places it in the next write
53+
// slot. If the buffer is full, it blocks until a slot is freed or the buffer
54+
// is closed. If the buffer is closed, Enqueue returns silently.
4855
func (rb *ringBuffer) Enqueue(data []byte) {
56+
b := rb.pool.Get()
57+
*b = append(*b, data...)
58+
4959
rb.mu.Lock()
5060
defer rb.mu.Unlock()
5161

5262
for rb.count == len(rb.buf) && rb.err == nil {
5363
rb.cond.Wait()
5464
}
5565
if rb.err != nil {
66+
rb.pool.Put(b)
5667
return
5768
}
5869

59-
rb.buf[rb.head] = append(rb.buf[rb.head][:0], data...)
70+
rb.buf[rb.head] = b
6071
rb.head = (rb.head + 1) % len(rb.buf)
6172
rb.count++
6273
rb.cond.Broadcast()
6374
}
6475

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.
76+
// Dequeue returns the data from the next buffered message and advances the
77+
// tail. The returned slice is valid until Done is called, which releases the
78+
// underlying buffer back to the pool. Done must be called exactly once after
79+
// each successful Dequeue.
6880
func (rb *ringBuffer) Dequeue() ([]byte, error) {
6981
rb.mu.Lock()
7082
defer rb.mu.Unlock()
@@ -76,37 +88,31 @@ func (rb *ringBuffer) Dequeue() ([]byte, error) {
7688
return nil, rb.err
7789
}
7890

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-
91+
b := rb.buf[rb.tail]
92+
rb.buf[rb.tail] = nil
9393
rb.tail = (rb.tail + 1) % len(rb.buf)
9494
rb.count--
95-
rb.held = false
95+
rb.held = b
9696
rb.cond.Broadcast()
97+
98+
return *b, nil
99+
}
100+
101+
// Done releases the buffer from the most recent Dequeue back to the pool,
102+
// invalidating the slice that Dequeue returned. It must be called exactly
103+
// once after each successful Dequeue. Because the queue is single-consumer,
104+
// Done is only ever called from the same goroutine as Dequeue.
105+
func (rb *ringBuffer) Done() {
106+
rb.pool.Put(rb.held)
107+
rb.held = nil
97108
}
98109

99110
// 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.
111+
// and Dequeue calls are woken and will return. Subsequent calls are no-ops.
103112
func (rb *ringBuffer) Close(err error) {
104113
rb.mu.Lock()
105114
defer rb.mu.Unlock()
106115

107-
for rb.held {
108-
rb.cond.Wait()
109-
}
110116
if rb.err != nil {
111117
return
112118
}

0 commit comments

Comments
 (0)