This is an automated email from the ASF dual-hosted git repository.

sruehl pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/plc4x.git


The following commit(s) were added to refs/heads/develop by this push:
     new ca4c256436 feat(plc4go): add WithMaxIdleTime to the connection cache
ca4c256436 is described below

commit ca4c25643618cb85a7aa21e7a5ce7ba6c92eb64e
Author: Sebastian Rühl <[email protected]>
AuthorDate: Thu Jul 2 17:36:21 2026 +0200

    feat(plc4go): add WithMaxIdleTime to the connection cache
    
    Remotes routinely reap idle TCP connections without a FIN/RST ever
    reaching the client (half-open TCP) - a death that no liveness flag can
    detect, as it only surfaces when the first write hits the dead socket.
    Field data against Modbus TCP gateways shows pooled connections dying
    this way between poll cycles, costing retry attempts on every affected
    poll.
    
    WithMaxIdleTime(d) discards cached connections that sat idle for longer
    than d and re-establishes them transparently on the next lease (0 = keep
    forever, the default - previous behavior is unchanged). The idle clock
    starts when a connection becomes idle (after connect and on lease
    return). As a side effect, connection slots on connection-limited
    remotes are freed between bursts instead of being parked indefinitely.
---
 plc4go/pkg/api/cache/PlcConnectionCache.go     |  17 +++
 plc4go/pkg/api/cache/connectionContainer.go    |  40 +++++-
 plc4go/pkg/api/cache/connectionIdleTTL_test.go | 192 +++++++++++++++++++++++++
 3 files changed, 244 insertions(+), 5 deletions(-)

diff --git a/plc4go/pkg/api/cache/PlcConnectionCache.go 
b/plc4go/pkg/api/cache/PlcConnectionCache.go
index 4f39de4547..d0aaf4d316 100644
--- a/plc4go/pkg/api/cache/PlcConnectionCache.go
+++ b/plc4go/pkg/api/cache/PlcConnectionCache.go
@@ -77,6 +77,19 @@ func WithMaxWaitTime(maxWaitTime time.Duration) 
WithConnectionCacheOption {
        }
 }
 
+// WithMaxIdleTime discards cached connections that sat idle for longer than
+// the given duration and re-establishes them on the next lease (0 = keep
+// forever, the default). Use this against remotes that silently reap idle
+// connections (half-open TCP): such a death is undetectable until the first
+// write fails, so connections past this age are replaced proactively. As a
+// side effect, connection slots on connection-limited remotes are freed
+// between bursts instead of being parked indefinitely.
+func WithMaxIdleTime(maxIdleTime time.Duration) WithConnectionCacheOption {
+       return func(plcConnectionCache *plcConnectionCache) {
+               plcConnectionCache.maxIdleTime = maxIdleTime
+       }
+}
+
 func WithTracer() WithConnectionCacheOption {
        return func(plcConnectionCache *plcConnectionCache) {
                plcConnectionCache.EnableTracer()
@@ -107,6 +120,9 @@ type plcConnectionCache struct {
        // If the connection is used for a longer time, it is forcefully 
removed from the client.
        maxLeaseTime time.Duration
        maxWaitTime  time.Duration
+       // Maximum duration a connection may sit idle before being replaced on 
the
+       // next lease (0 = keep forever). See WithMaxIdleTime.
+       maxIdleTime time.Duration
 
        cacheLock   *sync.RWMutex
        connections map[string]*connectionContainer
@@ -160,6 +176,7 @@ func (c *plcConnectionCache) GetConnection(ctx 
context.Context, connectionString
                c.log.Debug().Str("connectionString", 
connectionString).Msg("Create new cached connection")
                // Create a new connection container.
                cc := newConnectionContainer(c.log, c.driverManager, 
connectionString)
+               cc.maxIdleTime = c.maxIdleTime
                // Register for connection events (Like connection closed or 
error).
                cc.addListener(c)
                // Store the new connection container in the cache of 
connections.
diff --git a/plc4go/pkg/api/cache/connectionContainer.go 
b/plc4go/pkg/api/cache/connectionContainer.go
index 99a3bef7a9..4938a0c531 100644
--- a/plc4go/pkg/api/cache/connectionContainer.go
+++ b/plc4go/pkg/api/cache/connectionContainer.go
@@ -23,6 +23,7 @@ import (
        "context"
        "fmt"
        "sync"
+       "time"
 
        "github.com/rs/zerolog"
 
@@ -44,6 +45,16 @@ type connectionContainer struct {
        queue []connectionRequest
        // Listeners for connection events.
        listeners []connectionListener
+       // Maximum duration a connection may sit idle in the cache before it is
+       // discarded and re-established on the next lease (0 = keep forever).
+       // Remotes routinely reap idle connections without a FIN/RST reaching us
+       // (half-open TCP), which no liveness flag can detect - the death only
+       // surfaces on the first write. Refusing to hand out connections that
+       // exceeded this age avoids that failure mode and frees connection slots
+       // on connection-limited remotes between bursts.
+       maxIdleTime time.Duration
+       // idleSince records when the connection last became idle.
+       idleSince time.Time
 
        log zerolog.Logger
 }
@@ -132,6 +143,7 @@ func (c *connectionContainer) connect(ctx context.Context) {
        c.tracerEnabled = c.connection.IsTraceEnabled()
        // Mark the connection as idle for now.
        c.state = StateIdle
+       c.idleSince = time.Now()
        // If there is a request in the queue, hand out the connection to that.
        if queueHead := c.nextWaiter(); queueHead != nil {
                c.log.Trace().Int("waitingClientsLen", 
len(c.queue)).Msg("notifies waiting clients of connection")
@@ -213,14 +225,25 @@ func (c *connectionContainer) lease(ctx context.Context) 
(chan *plcConnectionLea
                // remote may have dropped it while it sat idle in the cache 
(idle timeouts
                // and connection-limited gateways do this routinely); without 
this check
                // the client receives a dead connection and fails on first use.
-               if c.connection == nil || !c.connection.IsConnected() {
+               // A connection that exceeded the configured max idle time is 
treated the
+               // same way: remotes that silently reap idle connections leave 
a half-open
+               // socket that IsConnected() cannot detect, so past that age 
the connection
+               // is not to be trusted and gets replaced proactively.
+               if c.connection == nil || !c.connection.IsConnected() || 
c.idleExpired() {
                        if c.closed {
                                // The cache is shutting down - don't 
reconnect, just report.
                                errorChan <- errors.New("connection container 
is closed")
                                break
                        }
-                       c.log.Debug().Str("connectionString", 
c.connectionString).
-                               Msg("Cached idle connection is no longer alive 
- reconnecting.")
+                       if c.idleExpired() {
+                               c.log.Debug().Str("connectionString", 
c.connectionString).
+                                       Dur("maxIdleTime", c.maxIdleTime).
+                                       Time("idleSince", c.idleSince).
+                                       Msg("Cached idle connection exceeded 
max idle time - reconnecting.")
+                       } else {
+                               c.log.Debug().Str("connectionString", 
c.connectionString).
+                                       Msg("Cached idle connection is no 
longer alive - reconnecting.")
+                       }
                        c.queue = append(c.queue, connectionRequest{ctx: ctx, 
connChan: connectionChan, errChan: errorChan})
                        c.startReconnect(ctx)
                        break
@@ -293,7 +316,7 @@ func (c *connectionContainer) returnConnection(ctx 
context.Context, newState cac
                defer c.lock.Unlock()
                if c.connection == nil {
                        c.state = StateInvalid
-                       return errors.New("Can'c return a broken connection")
+                       return errors.New("Can't return a broken connection")
                }
                // connect() already dealt with the queue: it either handed the 
fresh
                // connection to the next waiter (StateInUse) or marked it 
idle. Handing out
@@ -306,7 +329,7 @@ func (c *connectionContainer) returnConnection(ctx 
context.Context, newState cac
        defer c.lock.Unlock()
        if c.connection == nil {
                c.state = StateInvalid
-               return errors.New("Can'c return a broken connection")
+               return errors.New("Can't return a broken connection")
        }
 
        // Check how many others are waiting for this connection.
@@ -329,10 +352,17 @@ func (c *connectionContainer) returnConnection(ctx 
context.Context, newState cac
                c.log.Debug().Str("connectionString", c.connectionString).
                        Msg("Connection set to 'idle'.")
                c.state = StateIdle
+               c.idleSince = time.Now()
        }
        return nil
 }
 
+// idleExpired reports whether the connection outstayed the configured max idle
+// time. Must be called with c.lock held.
+func (c *connectionContainer) idleExpired() bool {
+       return c.maxIdleTime > 0 && time.Since(c.idleSince) > c.maxIdleTime
+}
+
 func (c *connectionContainer) String() string {
        return fmt.Sprintf("connectionContainer{%s:%s, leaseCounter: %d, 
closed: %t, state: %s}", c.connectionString, c.connection, c.leaseCounter, 
c.closed, c.state)
 }
diff --git a/plc4go/pkg/api/cache/connectionIdleTTL_test.go 
b/plc4go/pkg/api/cache/connectionIdleTTL_test.go
new file mode 100644
index 0000000000..47b30e130b
--- /dev/null
+++ b/plc4go/pkg/api/cache/connectionIdleTTL_test.go
@@ -0,0 +1,192 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package cache
+
+import (
+       "context"
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+
+       "github.com/apache/plc4x/plc4go/spi/testutils"
+)
+
+// leaseWithTimeout requests a lease and waits for the outcome.
+func leaseWithTimeout(t *testing.T, c *connectionContainer) 
*plcConnectionLease {
+       t.Helper()
+       ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+       defer cancel()
+       connChan, errChan := c.lease(ctx)
+       select {
+       case conn := <-connChan:
+               return conn
+       case err := <-errChan:
+               t.Fatalf("expected a lease, got error: %v", err)
+       case <-ctx.Done():
+               t.Fatal("timed out waiting for lease")
+       }
+       return nil
+}
+
+// An idle connection older than maxIdleTime must not be handed out - the
+// container has to discard it and establish a replacement. Remotes that
+// silently reap idle connections leave a half-open socket that IsConnected()
+// cannot detect; past the configured age the connection is not to be trusted.
+func Test_connectionContainer_idleExpiredConnectionReplaced(t *testing.T) {
+       c := newConnectionContainer(testutils.ProduceTestingLogger(t), 
testDriverManager(t), "simulated://1.2.3.4:42")
+       c.maxIdleTime = time.Minute
+       c.connect(context.Background())
+       c.lock.RLock()
+       firstConnection := c.connection
+       c.lock.RUnlock()
+       require.NotNil(t, firstConnection)
+
+       // Age the idle connection past the TTL.
+       c.lock.Lock()
+       c.idleSince = time.Now().Add(-2 * time.Minute)
+       c.lock.Unlock()
+
+       lease := leaseWithTimeout(t, c)
+       require.NotNil(t, lease)
+       assert.True(t, lease.IsConnected(), "handed-out lease must be alive")
+       assert.NoError(t, lease.Close())
+
+       c.lock.RLock()
+       secondConnection := c.connection
+       c.lock.RUnlock()
+       assert.NotSame(t, firstConnection, secondConnection, "expired idle 
connection must have been replaced")
+}
+
+// A connection younger than maxIdleTime is reused as-is.
+func Test_connectionContainer_idleNotExpiredKeepsConnection(t *testing.T) {
+       c := newConnectionContainer(testutils.ProduceTestingLogger(t), 
testDriverManager(t), "simulated://1.2.3.4:42")
+       c.maxIdleTime = time.Hour
+       c.connect(context.Background())
+       c.lock.RLock()
+       firstConnection := c.connection
+       c.lock.RUnlock()
+       require.NotNil(t, firstConnection)
+
+       lease := leaseWithTimeout(t, c)
+       require.NotNil(t, lease)
+       assert.NoError(t, lease.Close())
+
+       c.lock.RLock()
+       secondConnection := c.connection
+       c.lock.RUnlock()
+       assert.Same(t, firstConnection, secondConnection, "young idle 
connection must be reused")
+}
+
+// maxIdleTime = 0 (the default) disables the TTL entirely - connections are
+// kept forever, preserving the previous behavior.
+func Test_connectionContainer_maxIdleTimeDisabledByDefault(t *testing.T) {
+       c := newConnectionContainer(testutils.ProduceTestingLogger(t), 
testDriverManager(t), "simulated://1.2.3.4:42")
+       c.connect(context.Background())
+       c.lock.RLock()
+       firstConnection := c.connection
+       c.lock.RUnlock()
+       require.NotNil(t, firstConnection)
+
+       // Even an ancient idle timestamp must not trigger a replacement.
+       c.lock.Lock()
+       c.idleSince = time.Now().Add(-24 * time.Hour)
+       c.lock.Unlock()
+
+       lease := leaseWithTimeout(t, c)
+       require.NotNil(t, lease)
+       assert.NoError(t, lease.Close())
+
+       c.lock.RLock()
+       secondConnection := c.connection
+       c.lock.RUnlock()
+       assert.Same(t, firstConnection, secondConnection, "TTL disabled: 
connection must be kept")
+}
+
+// Returning a lease must restart the idle clock.
+func Test_connectionContainer_returnResetsIdleClock(t *testing.T) {
+       c := newConnectionContainer(testutils.ProduceTestingLogger(t), 
testDriverManager(t), "simulated://1.2.3.4:42")
+       c.maxIdleTime = time.Minute
+       c.connect(context.Background())
+
+       // Age the container, then take and return a lease - the return must
+       // refresh idleSince so the connection is trusted again.
+       c.lock.Lock()
+       c.idleSince = time.Now().Add(-2 * time.Minute)
+       c.lock.Unlock()
+
+       lease := leaseWithTimeout(t, c) // triggers replacement (expired)
+       require.NotNil(t, lease)
+       require.NoError(t, lease.Close())
+
+       c.lock.RLock()
+       idleSince := c.idleSince
+       connection := c.connection
+       c.lock.RUnlock()
+       assert.WithinDuration(t, time.Now(), idleSince, 10*time.Second, "idle 
clock must restart on return")
+
+       // An immediate follow-up lease reuses the same connection.
+       lease2 := leaseWithTimeout(t, c)
+       require.NotNil(t, lease2)
+       assert.NoError(t, lease2.Close())
+       c.lock.RLock()
+       assert.Same(t, connection, c.connection)
+       c.lock.RUnlock()
+}
+
+// End-to-end through the cache: WithMaxIdleTime propagates to containers and
+// an aged connection is transparently replaced on the next GetConnection.
+func Test_plcConnectionCache_withMaxIdleTime(t *testing.T) {
+       cache := NewPlcConnectionCache(testDriverManager(t), 
WithMaxIdleTime(time.Minute)).(*plcConnectionCache)
+       t.Cleanup(func() {
+               assert.NoError(t, cache.Close())
+       })
+
+       ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+       defer cancel()
+       conn, err := cache.GetConnection(ctx, "simulated://1.2.3.4:42")
+       require.NoError(t, err)
+       require.NoError(t, conn.Close())
+
+       cache.cacheLock.RLock()
+       container := cache.connections["simulated://1.2.3.4:42"]
+       cache.cacheLock.RUnlock()
+       require.NotNil(t, container)
+       assert.Equal(t, time.Minute, container.maxIdleTime, "option must 
propagate to containers")
+
+       // Age the pooled connection and lease again - must succeed on a fresh 
one.
+       container.lock.Lock()
+       firstConnection := container.connection
+       container.idleSince = time.Now().Add(-2 * time.Minute)
+       container.lock.Unlock()
+
+       ctx2, cancel2 := context.WithTimeout(context.Background(), 
5*time.Second)
+       defer cancel2()
+       conn2, err := cache.GetConnection(ctx2, "simulated://1.2.3.4:42")
+       require.NoError(t, err)
+       assert.True(t, conn2.IsConnected())
+       require.NoError(t, conn2.Close())
+
+       container.lock.RLock()
+       secondConnection := container.connection
+       container.lock.RUnlock()
+       assert.NotSame(t, firstConnection, secondConnection, "aged connection 
must have been replaced")
+}

Reply via email to