Skip to content

Commit 590aabe

Browse files
committed
Fix repeated "Pool connection error" and reconnections with small Session.Timeout
In GoCQL v1.x, the read deadline is set to 0 when reading a new response frame to prevent the connection from closing when it idles. In v2.0.0 a regression was introduced causing the read deadline to be set to clusterCfg.Timeout for every read operation which leads to connections constantly erroring and reconnecting if they idle. This patch fixes this regression by implementing the behavior of 1.x. A follow up ticket (CASSGO-127) will rework the timeout configuration so users can tune the read and write timeouts independently from the request timeout. Patch by João Reis; reviewed by Bohdan Siryk for CASSGO-125
1 parent e1d69bd commit 590aabe

5 files changed

Lines changed: 101 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Prevent panic when using a HostFilter and keyspace is not replicated to every DC (CASSGO-122)
1313
- system.peers fallback doesn't work in some scenarios (CASSGO-126)
14+
- Many "Pool connection error" with small Session.Timeout (CASSGO-125)
1415

1516
## [2.1.1]
1617

conn.go

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ type Conn struct {
171171
w contextWriter
172172

173173
writeTimeout time.Duration
174+
requestTimeout time.Duration // Request timeout, used for setting up request timers
174175
cfg *ConnConfig
175176
frameObserver FrameHeaderObserver
176177
streamObserver StreamObserver
@@ -279,6 +280,7 @@ func (s *Session) dialWithoutObserver(ctx context.Context, host *HostInfo, cfg *
279280
logger: logger,
280281
streamObserver: s.streamObserver,
281282
writeTimeout: writeTimeout,
283+
requestTimeout: cfg.ConnectTimeout,
282284
}
283285

284286
if err := c.init(ctx, dialedHost); err != nil {
@@ -312,6 +314,7 @@ func (c *Conn) init(ctx context.Context, dialedHost *DialedHost) error {
312314
}
313315

314316
c.r.SetTimeout(c.cfg.Timeout)
317+
c.requestTimeout = c.cfg.Timeout
315318

316319
// dont coalesce startup frames
317320
if c.session.cfg.WriteCoalesceWaitTime > 0 && !c.cfg.disableCoalesce && !dialedHost.DisableCoalesce {
@@ -335,8 +338,8 @@ type startupCoordinator struct {
335338

336339
func (s *startupCoordinator) setupConn(ctx context.Context) error {
337340
var cancel context.CancelFunc
338-
if s.conn.r.GetTimeout() > 0 {
339-
ctx, cancel = context.WithTimeout(ctx, s.conn.r.GetTimeout())
341+
if s.conn.requestTimeout > 0 {
342+
ctx, cancel = context.WithTimeout(ctx, s.conn.requestTimeout)
340343
} else {
341344
ctx, cancel = context.WithCancel(ctx)
342345
}
@@ -688,8 +691,9 @@ func (c *Conn) processFrame(ctx context.Context, r io.Reader) error {
688691

689692
// read a full header, ignore timeouts, as this is being ran in a loop
690693
// TODO: TCP level deadlines? or just query level deadlines?
691-
if c.r.GetTimeout() > 0 {
692-
c.r.SetReadDeadline(time.Time{})
694+
readTimeout := c.r.GetTimeout()
695+
if readTimeout > 0 {
696+
c.r.SetTimeout(0)
693697
}
694698

695699
headStartTime := time.Now()
@@ -700,6 +704,11 @@ func (c *Conn) processFrame(ctx context.Context, r io.Reader) error {
700704
return err
701705
}
702706

707+
// Set timeout back for reading frame body
708+
if readTimeout > 0 {
709+
c.r.SetTimeout(readTimeout)
710+
}
711+
703712
if c.frameObserver != nil {
704713
c.frameObserver.ObserveFrameHeader(context.Background(), ObservedFrameHeader{
705714
Version: protoVersion(head.version),
@@ -802,12 +811,24 @@ func (c *Conn) recvSegment(ctx context.Context) error {
802811
err error
803812
)
804813

814+
// Read segment without timeout, as this is being run in a loop waiting for the next segment
815+
readTimeout := c.r.GetTimeout()
816+
if readTimeout > 0 {
817+
c.r.SetTimeout(0)
818+
}
819+
805820
// Read frame based on compression
806821
if c.compressor != nil {
807822
frame, isSelfContained, err = readCompressedSegment(c.r, c.compressor)
808823
} else {
809824
frame, isSelfContained, err = readUncompressedSegment(c.r)
810825
}
826+
827+
// Restore timeout for subsequent segment reads in multi-segment frames
828+
if readTimeout > 0 {
829+
c.r.SetTimeout(readTimeout)
830+
}
831+
811832
if err != nil {
812833
return err
813834
}
@@ -908,6 +929,8 @@ func (c *connReader) Read(p []byte) (n int, err error) {
908929
var nn int
909930
if c.timeout > 0 {
910931
c.conn.SetReadDeadline(time.Now().Add(c.timeout))
932+
} else if c.timeout == 0 {
933+
c.conn.SetReadDeadline(time.Time{})
911934
}
912935

913936
nn, err = io.ReadFull(c.r, p[n:])
@@ -1309,7 +1332,7 @@ func (c *Conn) execInternal(ctx context.Context, req frameBuilder, tracer Tracer
13091332
}
13101333

13111334
var timeoutCh <-chan time.Time
1312-
if timeout := c.r.GetTimeout(); timeout > 0 {
1335+
if c.requestTimeout > 0 {
13131336
if call.timer == nil {
13141337
call.timer = time.NewTimer(0)
13151338
<-call.timer.C
@@ -1322,7 +1345,7 @@ func (c *Conn) execInternal(ctx context.Context, req frameBuilder, tracer Tracer
13221345
}
13231346
}
13241347

1325-
call.timer.Reset(timeout)
1348+
call.timer.Reset(c.requestTimeout)
13261349
timeoutCh = call.timer.C
13271350
}
13281351

conn_test.go

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,9 @@ func TestTimeout(t *testing.T) {
287287
srv := NewTestServer(t, defaultProto, ctx)
288288
defer srv.Stop()
289289

290-
db, err := newTestSession(defaultProto, srv.Address)
290+
cluster := testCluster(defaultProto, srv.Address)
291+
cluster.Timeout = 2 * time.Second
292+
db, err := cluster.CreateSession()
291293
if err != nil {
292294
t.Fatalf("NewCluster: %v", err)
293295
}
@@ -306,12 +308,18 @@ func TestTimeout(t *testing.T) {
306308
}
307309
}()
308310

309-
if err := db.Query("kill").WithContext(ctx).Exec(); err == nil {
311+
now := time.Now()
312+
err = db.Query("timeout").ExecContext(ctx)
313+
if err == nil {
310314
t.Fatal("expected error got nil")
311315
}
312316
cancel()
313-
314317
wg.Wait()
318+
319+
elapsed := time.Since(now)
320+
if elapsed < 1*time.Second || elapsed > 4*time.Second {
321+
t.Fatalf("timeout is not respected (took %v)", elapsed.String())
322+
}
315323
}
316324

317325
func TestCancel(t *testing.T) {
@@ -329,13 +337,11 @@ func TestCancel(t *testing.T) {
329337
}
330338
defer db.Close()
331339

332-
qry := db.Query("timeout").WithContext(ctx)
333-
334340
// Make sure we finish the query without leftovers
335341
var wg sync.WaitGroup
336342
wg.Add(1)
337343
go func() {
338-
err = qry.Exec()
344+
err = db.Query("timeout").ExecContext(ctx)
339345
wg.Done()
340346
}()
341347

@@ -720,9 +726,14 @@ func TestStream0(t *testing.T) {
720726
t.Fatal(err)
721727
}
722728

729+
clientConn, serverConn := net.Pipe()
730+
defer clientConn.Close()
731+
defer serverConn.Close()
732+
723733
conn := &Conn{
724734
r: &connReader{
725-
r: bufio.NewReader(&buf),
735+
r: bufio.NewReader(&buf),
736+
conn: clientConn,
726737
},
727738
streams: streams.New(protoVersion4),
728739
session: &Session{

control.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,8 @@ func (c *controlConn) setupConn(conn *Conn, sessionInit bool) error {
361361
c.conn.Store(ch)
362362

363363
c.session.logger.Info("Control connection connected to host.",
364-
NewLogFieldIP("host_addr", host.ConnectAddress()), NewLogFieldString("host_id", host.HostID()))
364+
NewLogFieldIP("host_addr", host.ConnectAddress()), NewLogFieldString("host_id", host.HostID()),
365+
NewLogFieldInt("protocol_version", c.session.cfg.ProtoVersion))
365366

366367
if c.session.initialized() {
367368
refreshErr := c.session.schemaDescriber.refreshSchemaMetadata()

integration_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -977,3 +977,54 @@ func TestSliceMapMapScanCollectionTypes(t *testing.T) {
977977
})
978978
}
979979
}
980+
981+
// TestSmallTimeoutNoPoolErrors verifies that small Session.Timeout values
982+
// don't cause connections to timeout and reconnect constantly. This is a
983+
// regression test for https://github.com/apache/cassandra-gocql-driver/issues/1919
984+
//
985+
// The issue was that the timeout was being applied to frame header reads,
986+
// causing connections to timeout while waiting for the next frame. The fix
987+
// ensures frame headers are read without timeout, while frame bodies are
988+
// read with timeout.
989+
func TestSmallTimeoutNoPoolErrors(t *testing.T) {
990+
// Create a test logger to capture log messages
991+
logger := newTestLogger(LogLevelDebug)
992+
defer func() {
993+
t.Log(logger.String())
994+
}()
995+
996+
cluster := createCluster()
997+
cluster.ConnectTimeout = 10 * time.Second
998+
cluster.Timeout = 750 * time.Millisecond
999+
cluster.NumConns = 1
1000+
cluster.Logger = logger
1001+
1002+
db, err := cluster.CreateSession()
1003+
if err != nil {
1004+
t.Fatalf("CreateSession: %v", err)
1005+
}
1006+
defer db.Close()
1007+
1008+
// Wait for connections to sit idle
1009+
// If the bug exists, connections will timeout while waiting for frame headers
1010+
// and "Pool connection error" messages will be logged repeatedly
1011+
time.Sleep(5 * time.Second)
1012+
1013+
// Get log output for analysis
1014+
logOutput := strings.ToLower(logger.String())
1015+
1016+
// Count successful connection messages - should be exactly NumConns * number of nodes
1017+
connectedCount := strings.Count(logOutput, "pool connected to node")
1018+
if connectedCount != *clusterSize*cluster.NumConns {
1019+
t.Fatalf("Expected exactly %d 'Pool connected to node' messages, got %d:\n%s",
1020+
*clusterSize*cluster.NumConns, connectedCount, logOutput)
1021+
}
1022+
1023+
// Count error messages - should be zero
1024+
// With the bug, we'd see many errors as connections constantly timeout and reconnect
1025+
errorCount := strings.Count(logOutput, "pool connection error")
1026+
if errorCount > 0 {
1027+
t.Fatalf("Found %d 'Pool connection error' messages - connections are timing out and reconnecting:\n%s",
1028+
errorCount, logOutput)
1029+
}
1030+
}

0 commit comments

Comments
 (0)