Repository: apache/pulsar-client-go
Pull request #1394 (2025-07-17 12:55:23)

Fix sending buffer race by using proper reference counting
Fixes https://github.com/apache/pulsar-client-go/issues/1371

### Motivation

The issue is that the buffer is sent to the connection when the data is flushed at this line: https://github.com/apache/pulsar-client-go/blob/0ab28c229e4dab2adb505a135325b6ede6e0e4f4/pulsar/producer_partition.go#L910

Because of this, both the data request in the connection and the pending item hold onto this buffer.

However, if the pending item fails due to a timeout or the producer closing, the buffer is returned to the buffer pool at this line: https://github.com/apache/pulsar-client-go/blob/0ab28c229e4dab2adb505a135325b6ede6e0e4f4/pulsar/producer_partition.go#L1747

Yet, the connection still has this buffer. When the connection tries to handle the data request, it will try to use a buffer that has already been returned. This causes a data race.

This fix implements proper reference counting to ensure buffers are only returned to the pool when all references are released, preventing the data race.

### Modifications

  - Added `Retain()` and `Release()` methods to the `Buffer` interface
  - Implemented atomic reference counting using `atomic.Int64`
  - Buffers are only returned to the pool when reference count reaches zero
  - Add new metric `pulsar_client_sending_buffers_count` to show the sending buffers count.
  - This PR also fixes serval minor issues like data/request cleanup when closing the connection 

### Does this pull request potentially affect one of the following parts:

*If `yes` was chosen, please highlight the changes*

  - Dependencies (does it add or upgrade a dependency): (yes / no)
  - The public API: (yes / no)
  - The schema: (yes / no / don't know)
  - The default values of configurations: (yes / no)
  - The wire protocol: (yes / no)

### Documentation

  - Does this pull request introduce a new feature? (yes / no)
  - If yes, how is the feature documented? (not applicable / docs / GoDocs / not documented)
  - If a feature is not applicable for documentation, explain why?
  - If a feature is not documented yet in this PR, please create a followup issue for adding the documentation



Patch:
diff --git a/pulsar/internal/batch_builder.go b/pulsar/internal/batch_builder.go
index 1074be8..3328569 100644
--- a/pulsar/internal/batch_builder.go
+++ b/pulsar/internal/batch_builder.go
@@ -29,10 +29,6 @@ import (
 	"github.com/apache/pulsar-client-go/pulsar/log"
 )
 
-type BuffersPool interface {
-	GetBuffer() Buffer
-}
-
 // BatcherBuilderProvider defines func which returns the BatchBuilder.
 type BatcherBuilderProvider func(
 	maxMessages uint, maxBatchSize uint, maxMessageSize uint32, producerName string, producerID uint64,
@@ -269,10 +265,7 @@ func (bc *batchContainer) Flush() *FlushBatch {
 	uncompressedSize := bc.buffer.ReadableBytes()
 	bc.msgMetadata.UncompressedSize = &uncompressedSize
 
-	buffer := bc.buffersPool.GetBuffer()
-	if buffer == nil {
-		buffer = NewBuffer(int(uncompressedSize * 3 / 2))
-	}
+	buffer := bc.buffersPool.GetBuffer(int(uncompressedSize * 3 / 2))
 
 	sequenceID := uint64(0)
 	var err error
diff --git a/pulsar/internal/buffer.go b/pulsar/internal/buffer.go
index b3e23fb..af6676d 100644
--- a/pulsar/internal/buffer.go
+++ b/pulsar/internal/buffer.go
@@ -19,10 +19,17 @@ package internal
 
 import (
 	"encoding/binary"
+	"sync"
+	"sync/atomic"
 
 	log "github.com/sirupsen/logrus"
 )
 
+type BuffersPool interface {
+	GetBuffer(initSize int) Buffer
+	Put(buf Buffer)
+}
+
 // Buffer is a variable-sized buffer of bytes with Read and Write methods.
 // The zero value for Buffer is an empty buffer ready to use.
 type Buffer interface {
@@ -69,15 +76,56 @@ type Buffer interface {
 	Resize(newSize uint32)
 	ResizeIfNeeded(spaceNeeded uint32)
 
+	Retain()
+	Release()
+	RefCnt() int64
+
 	// Clear will clear the current buffer data.
 	Clear()
 }
 
+type bufferPoolImpl struct {
+	sync.Pool
+}
+
+func NewBufferPool() BuffersPool {
+	return &bufferPoolImpl{
+		Pool: sync.Pool{},
+	}
+}
+
+func (p *bufferPoolImpl) GetBuffer(initSize int) Buffer {
+	sendingBuffersCount.Inc()
+	b, ok := p.Get().(*buffer)
+	if ok {
+		b.Clear()
+	} else {
+		b = &buffer{
+			data:      make([]byte, initSize),
+			readerIdx: 0,
+			writerIdx: 0,
+		}
+	}
+	b.pool = p
+	b.Retain()
+	return b
+}
+
+func (p *bufferPoolImpl) Put(buf Buffer) {
+	sendingBuffersCount.Dec()
+	if b, ok := buf.(*buffer); ok {
+		p.Pool.Put(b)
+	}
+}
+
 type buffer struct {
 	data []byte
 
 	readerIdx uint32
 	writerIdx uint32
+
+	refCnt atomic.Int64
+	pool   BuffersPool
 }
 
 // NewBuffer creates and initializes a new Buffer using buf as its initial contents.
@@ -213,7 +261,24 @@ func (b *buffer) Put(writerIdx uint32, s []byte) {
 	copy(b.data[writerIdx:], s)
 }
 
+func (b *buffer) Retain() {
+	b.refCnt.Add(1)
+}
+
+func (b *buffer) Release() {
+	if b.refCnt.Add(-1) == 0 {
+		if b.pool != nil {
+			b.pool.Put(b)
+		}
+	}
+}
+
+func (b *buffer) RefCnt() int64 {
+	return b.refCnt.Load()
+}
+
 func (b *buffer) Clear() {
 	b.readerIdx = 0
 	b.writerIdx = 0
+	b.refCnt.Store(0)
 }
diff --git a/pulsar/internal/connection.go b/pulsar/internal/connection.go
index 2ad1acb..cbb21b6 100644
--- a/pulsar/internal/connection.go
+++ b/pulsar/internal/connection.go
@@ -385,6 +385,7 @@ func (c *connection) failLeftRequestsWhenClose() {
 		// if other requests come in after the nil message
 		// then the RPC client will time out
 		ch <- nil
+		c.writeRequestsCh <- nil
 	}()
 	for req := range ch {
 		if nil == req {
@@ -392,6 +393,12 @@ func (c *connection) failLeftRequestsWhenClose() {
 		}
 		c.internalSendRequest(req)
 	}
+	for req := range c.writeRequestsCh {
+		if nil == req {
+			break
+		}
+		req.data.Release()
+	}
 }
 
 func (c *connection) run() {
@@ -432,6 +439,7 @@ func (c *connection) run() {
 				return
 			}
 			c.internalWriteData(req.ctx, req.data)
+			req.data.Release()
 
 		case <-pingSendTicker.C:
 			c.sendPing()
@@ -457,13 +465,23 @@ func (c *connection) runPingCheck(pingCheckTicker *time.Ticker) {
 }
 
 func (c *connection) WriteData(ctx context.Context, data Buffer) {
+	writeToQueue := false
+	defer func() {
+		if !writeToQueue {
+			data.Release()
+		}
+	}()
 	select {
 	case c.writeRequestsCh <- &dataRequest{ctx: ctx, data: data}:
 		// Channel is not full
+		writeToQueue = true
 		return
 	case <-ctx.Done():
 		c.log.Debug("Write data context cancelled")
 		return
+	case <-c.closeCh:
+		c.log.Debug("Write data connection closed")
+		return
 	default:
 		// Channel full, fallback to probe if connection is closed
 	}
@@ -472,10 +490,14 @@ func (c *connection) WriteData(ctx context.Context, data Buffer) {
 		select {
 		case c.writeRequestsCh <- &dataRequest{ctx: ctx, data: data}:
 			// Successfully wrote on the channel
+			writeToQueue = true
 			return
 		case <-ctx.Done():
 			c.log.Debug("Write data context cancelled")
 			return
+		case <-c.closeCh:
+			c.log.Debug("Write data connection closed")
+			return
 		case <-time.After(100 * time.Millisecond):
 			// The channel is either:
 			// 1. blocked, in which case we need to wait until we have space
@@ -497,6 +519,8 @@ func (c *connection) internalWriteData(ctx context.Context, data Buffer) {
 	select {
 	case <-ctx.Done():
 		return
+	case <-c.closeCh:
+		return
 	default:
 		if _, err := c.cnx.Write(data.ReadableSlice()); err != nil {
 			c.log.WithError(err).Warn("Failed to write on connection")
diff --git a/pulsar/internal/metrics.go b/pulsar/internal/metrics.go
index e9bc815..f737495 100644
--- a/pulsar/internal/metrics.go
+++ b/pulsar/internal/metrics.go
@@ -21,6 +21,18 @@ import (
 	"github.com/prometheus/client_golang/prometheus"
 )
 
+var (
+	defaultConstLabels = map[string]string{
+		"client": "go",
+	}
+
+	sendingBuffersCount = prometheus.NewGauge(prometheus.GaugeOpts{
+		Name:        "pulsar_client_sending_buffers_count",
+		Help:        "Number of sending buffers",
+		ConstLabels: defaultConstLabels,
+	})
+)
+
 type Metrics struct {
 	metricsLevel      int
 	messagesPublished *prometheus.CounterVec
@@ -99,8 +111,9 @@ type LeveledMetrics struct {
 // NewMetricsProvider returns metrics registered to registerer.
 func NewMetricsProvider(metricsCardinality int, userDefinedLabels map[string]string,
 	registerer prometheus.Registerer) *Metrics {
-	constLabels := map[string]string{
-		"client": "go",
+	constLabels := make(map[string]string)
+	for k, v := range defaultConstLabels {
+		constLabels[k] = v
 	}
 	for k, v := range userDefinedLabels {
 		constLabels[k] = v
@@ -535,6 +548,7 @@ func NewMetricsProvider(metricsCardinality int, userDefinedLabels map[string]str
 			metrics.RPCRequestCount = are.ExistingCollector.(prometheus.Counter)
 		}
 	}
+	_ = registerer.Register(sendingBuffersCount)
 	return metrics
 }
 
diff --git a/pulsar/producer_partition.go b/pulsar/producer_partition.go
index a4c2e3f..a322cfa 100755
--- a/pulsar/producer_partition.go
+++ b/pulsar/producer_partition.go
@@ -69,7 +69,7 @@ var (
 	ErrProducerBlockedQuotaExceeded = newError(ProducerBlockedQuotaExceededException, "producer blocked")
 	ErrProducerFenced               = newError(ProducerFenced, "producer fenced")
 
-	buffersPool     sync.Pool
+	buffersPool     internal.BuffersPool
 	sendRequestPool *sync.Pool
 )
 
@@ -86,6 +86,7 @@ func init() {
 			return &sendRequest{}
 		},
 	}
+	buffersPool = internal.NewBufferPool()
 }
 
 type partitionProducer struct {
@@ -96,7 +97,7 @@ type partitionProducer struct {
 	topic  string
 	log    log.Logger
 
-	conn         uAtomic.Value
+	conn         atomic.Pointer[internal.Connection]
 	cnxKeySuffix int32
 
 	options                  *ProducerOptions
@@ -363,7 +364,7 @@ func (p *partitionProducer) grabCnx(assignedBrokerURL string) error {
 		p.batchBuilder, err = provider(p.options.BatchingMaxMessages, p.options.BatchingMaxSize,
 			maxMessageSize, p.producerName, p.producerID, pb.CompressionType(p.options.CompressionType),
 			compression.Level(p.options.CompressionLevel),
-			p,
+			buffersPool,
 			p.log,
 			p.encryptor)
 		if err != nil {
@@ -393,6 +394,7 @@ func (p *partitionProducer) grabCnx(assignedBrokerURL string) error {
 			pi.Lock()
 			pi.sentAt = time.Now()
 			pi.Unlock()
+			pi.buffer.Retain() // Retain for writing to the connection
 			p.pendingQueue.Put(pi)
 			p._getConn().WriteData(pi.ctx, pi.buffer)
 
@@ -412,14 +414,6 @@ func (cc *connectionClosed) HasURL() bool {
 	return len(cc.assignedBrokerURL) > 0
 }
 
-func (p *partitionProducer) GetBuffer() internal.Buffer {
-	b, ok := buffersPool.Get().(internal.Buffer)
-	if ok {
-		b.Clear()
-	}
-	return b
-}
-
 func (p *partitionProducer) ConnectionClosed(closeProducer *pb.CommandCloseProducer) {
 	// Trigger reconnection in the produce goroutine
 	p.log.WithField("cnx", p._getConn().ID()).Warn("Connection was closed")
@@ -796,10 +790,7 @@ func (p *partitionProducer) internalSingleSend(
 	payloadBuf := internal.NewBuffer(len(compressedPayload))
 	payloadBuf.Write(compressedPayload)
 
-	buffer := p.GetBuffer()
-	if buffer == nil {
-		buffer = internal.NewBuffer(int(payloadBuf.ReadableBytes() * 3 / 2))
-	}
+	buffer := buffersPool.GetBuffer(int(payloadBuf.ReadableBytes() * 3 / 2))
 
 	sid := *mm.SequenceId
 	var useTxn bool
@@ -889,6 +880,7 @@ func (p *partitionProducer) internalFlushCurrentBatch() {
 func (p *partitionProducer) writeData(buffer internal.Buffer, sequenceID uint64, callbacks []interface{}) {
 	select {
 	case <-p.ctx.Done():
+		buffer.Release()
 		for _, cb := range callbacks {
 			if sr, ok := cb.(*sendRequest); ok {
 				sr.done(nil, ErrProducerClosed)
@@ -898,6 +890,7 @@ func (p *partitionProducer) writeData(buffer internal.Buffer, sequenceID uint64,
 	default:
 		now := time.Now()
 		ctx, cancel := context.WithCancel(context.Background())
+		buffer.Retain()
 		p.pendingQueue.Put(&pendingItem{
 			ctx:          ctx,
 			cancel:       cancel,
@@ -1744,7 +1737,7 @@ func (i *pendingItem) done(err error) {
 
 	i.isDone = true
 	// return the buffer to the pool after all callbacks have been called.
-	defer buffersPool.Put(i.buffer)
+	defer i.buffer.Release()
 	if i.flushCallback != nil {
 		i.flushCallback(err)
 	}
@@ -1757,16 +1750,16 @@ func (i *pendingItem) done(err error) {
 // _setConn sets the internal connection field of this partition producer atomically.
 // Note: should only be called by this partition producer when a new connection is available.
 func (p *partitionProducer) _setConn(conn internal.Connection) {
-	p.conn.Store(conn)
+	p.conn.Store(&conn)
 }
 
 // _getConn returns internal connection field of this partition producer atomically.
 // Note: should only be called by this partition producer before attempting to use the connection
 func (p *partitionProducer) _getConn() internal.Connection {
-	// Invariant: p.conn must be non-nil for the lifetime of the partitionProducer.
+	// Invariant: The conn must be non-nil for the lifetime of the partitionProducer.
 	//            For this reason we leave this cast unchecked and panic() if the
 	//            invariant is broken
-	return p.conn.Load().(internal.Connection)
+	return *p.conn.Load()
 }
 
 type chunkRecorder struct {


Test patch:
diff --git a/pulsar/consumer_multitopic_test.go b/pulsar/consumer_multitopic_test.go
index 30ae5cc..46e6f22 100644
--- a/pulsar/consumer_multitopic_test.go
+++ b/pulsar/consumer_multitopic_test.go
@@ -354,7 +354,7 @@ func (dummyConnection) ID() string {
 }
 
 func (dummyConnection) GetMaxMessageSize() int32 {
-	return 0
+	return 5 * 1024 * 1024 // 5MB
 }
 
 func (dummyConnection) Close() {
diff --git a/pulsar/internal/key_based_batch_builder_test.go b/pulsar/internal/key_based_batch_builder_test.go
index 9df33b0..864f477 100644
--- a/pulsar/internal/key_based_batch_builder_test.go
+++ b/pulsar/internal/key_based_batch_builder_test.go
@@ -30,13 +30,6 @@ import (
 	"google.golang.org/protobuf/proto"
 )
 
-type mockBufferPool struct {
-}
-
-func (m *mockBufferPool) GetBuffer() Buffer {
-	return nil
-}
-
 type mockEncryptor struct {
 }
 
@@ -53,7 +46,7 @@ func TestKeyBasedBatcherOrdering(t *testing.T) {
 		1,
 		pb.CompressionType_NONE,
 		compression.Level(0),
-		&mockBufferPool{},
+		&bufferPoolImpl{},
 		log.NewLoggerWithLogrus(logrus.StandardLogger()),
 		&mockEncryptor{},
 	)
diff --git a/pulsar/producer_test.go b/pulsar/producer_test.go
index 00cb6db..71c4269 100644
--- a/pulsar/producer_test.go
+++ b/pulsar/producer_test.go
@@ -2378,7 +2378,9 @@ func TestFailPendingMessageWithClose(t *testing.T) {
 		})
 	}
 	partitionProducerImp := testProducer.(*producer).producers[0].(*partitionProducer)
-	partitionProducerImp.pendingQueue.Put(&pendingItem{})
+	partitionProducerImp.pendingQueue.Put(&pendingItem{
+		buffer: buffersPool.GetBuffer(0),
+	})
 	testProducer.Close()
 	assert.Equal(t, 0, partitionProducerImp.pendingQueue.Size())
 }
@@ -2607,3 +2609,62 @@ func TestSelectConnectionForSameProducer(t *testing.T) {
 
 	client.Close()
 }
+
+type mockConn struct {
+	*dummyConnection
+	realConn internal.Connection
+
+	l       sync.Mutex
+	buffers []internal.Buffer
+}
+
+func (m *mockConn) WriteData(_ context.Context, buffer internal.Buffer) {
+	m.l.Lock()
+	m.buffers = append(m.buffers, buffer)
+	m.l.Unlock()
+}
+
+func (m *mockConn) SendRequest(requestID uint64, req *pb.BaseCommand, callback func(*pb.BaseCommand, error)) {
+	m.realConn.SendRequest(requestID, req, callback)
+}
+
+func TestSendBufferRetainWhenConnectionStuck(t *testing.T) {
+	topicName := newTopicName()
+
+	client, err := NewClient(ClientOptions{
+		URL: serviceURL,
+	})
+	assert.NoError(t, err)
+	defer client.Close()
+
+	p, err := client.CreateProducer(ProducerOptions{
+		Topic: topicName,
+	})
+	assert.NoError(t, err)
+	pp := p.(*producer).producers[0].(*partitionProducer)
+
+	// Create a mock connection that tracks written buffers
+	conn := &mockConn{
+		dummyConnection: &dummyConnection{},
+		buffers:         make([]internal.Buffer, 0),
+		realConn:        pp._getConn(),
+	}
+
+	pp._setConn(conn)
+
+	pp.SendAsync(context.Background(), &ProducerMessage{
+		Payload: []byte("test"),
+	}, nil)
+
+	// Wait for the buffer to be written to the connection
+	assert.Eventually(t, func() bool {
+		return len(conn.buffers) != 0
+	}, 5*time.Second, 100*time.Millisecond)
+
+	// Simulate connection failure and verify buffer retention
+	pp.failPendingMessages(errors.New("expected error"))
+
+	assert.Equal(t, 1, len(conn.buffers), "Expected one buffer to be sent")
+	b := conn.buffers[0]
+	assert.Equal(t, int64(1), b.RefCnt(), "Expected buffer to have a reference count of 1 after sending")
+}