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 ea6686861d fix(plc4go): connection cache no longer bricks containers
or hands out dead connections
ea6686861d is described below
commit ea6686861d11c1de2c385e3dbdb6447c4a6dacb0
Author: Sebastian Rühl <[email protected]>
AuthorDate: Thu Jul 2 07:32:04 2026 +0200
fix(plc4go): connection cache no longer bricks containers or hands out dead
connections
The connection cache had four related defects that permanently broke a
connection string after transient endpoint failures (observed in the field
against connection-limited Modbus TCP gateways):
1. A failed initial connect left the container in StateInitialized forever.
connect() is only invoked at container creation and on lease return, so
subsequent lease requests queued with no reconnect ever attempted - every
caller timed out until process restart. connect() now marks the container
StateInvalid on failure.
2. lease() on a StateInvalid container sent nothing on either channel, so
callers silently timed out forever. It now queues the request and kicks
off a reconnect attempt, delivering either a fresh lease or the connect
error.
3. lease() handed out idle cached connections without any liveness check.
Remotes that drop idle connections (idle timeouts, connection-limited
gateways) caused the next client to fail on first use. Idle connections
are now checked with IsConnected() and replaced when dead.
4. Leases delivered to callers that had already given up (context cancel /
maxWaitTime) parked the connection in StateInUse with nobody left to
return it. Queued waiters with cancelled contexts are now skipped and
failed explicitly, and GetConnection drains abandoned lease channels on
the context-cancel path (previously only on the maxWaitTime path).
Also fixes a double-lease when returning an invalid connection with waiters
queued (both connect() and returnConnection() handed out the same fresh
connection) and a Debug log in onConnectionEvent that was missing its Msg()
call and never emitted.
---
plc4go/pkg/api/cache/PlcConnectionCache.go | 38 ++--
plc4go/pkg/api/cache/connectionContainer.go | 104 +++++++++--
plc4go/pkg/api/cache/connectionRecovery_test.go | 227 ++++++++++++++++++++++++
3 files changed, 347 insertions(+), 22 deletions(-)
diff --git a/plc4go/pkg/api/cache/PlcConnectionCache.go
b/plc4go/pkg/api/cache/PlcConnectionCache.go
index 85d0e3e7ec..4f39de4547 100644
--- a/plc4go/pkg/api/cache/PlcConnectionCache.go
+++ b/plc4go/pkg/api/cache/PlcConnectionCache.go
@@ -124,7 +124,10 @@ func (c *plcConnectionCache) onConnectionEvent(event
connectionEvent) {
if c.tracer != nil {
c.tracer.AddTrace("destroy-connection",
errorEvent.getError().Error())
}
- c.log.Debug().Str("connectionString",
connectionContainerInstance.connectionString)
+ c.log.Debug().
+ Str("connectionString",
connectionContainerInstance.connectionString).
+ Err(errorEvent.getError()).
+ Msg("Connection reported an error event")
}
}
@@ -180,10 +183,8 @@ func (c *plcConnectionCache) GetConnection(ctx
context.Context, connectionString
}
connChan, errChan := connection.lease(ctx)
maximumWaitTimeout := time.NewTimer(c.maxWaitTime)
+ defer maximumWaitTimeout.Stop()
select {
- case <-ctx.Done(): // abort on context cancel
- return nil, ctx.Err()
-
case conn := <-connChan: // Wait till we get a lease.
c.log.Debug().
Str("connectionString", connectionString).
@@ -201,18 +202,16 @@ func (c *plcConnectionCache) GetConnection(ctx
context.Context, connectionString
case err := <-errChan:
return nil, errors.Wrap(err, "error while trying to get lease
on connection")
- case <-ctx.Done():
+ case <-ctx.Done(): // abort on context cancel
+ // Drain the channels in the background: a lease may still be
delivered to
+ // the (buffered) channel after we've given up. Without
returning it, the
+ // connection would stay leased-to-nobody (StateInUse) forever.
+ c.drainAbandonedLease(connection, connChan, errChan)
return nil, ctx.Err()
case <-maximumWaitTimeout.C: // Timeout after the maximum waiting time.
// In this case we need to drain the chan and return it
immediate
- c.wg.Go(func() {
- select {
- case <-connChan:
- case <-errChan:
- }
- _ = connection.returnConnection(ctx, StateIdle)
- })
+ c.drainAbandonedLease(connection, connChan, errChan)
if c.tracer != nil {
c.tracer.AddTransactionalTrace(txId, "get-connection",
"timeout")
}
@@ -221,6 +220,21 @@ func (c *plcConnectionCache) GetConnection(ctx
context.Context, connectionString
}
}
+// drainAbandonedLease waits in the background for the outcome of a lease
request
+// whose caller has given up, and returns a delivered lease straight back to
the
+// container. Errors just get discarded. The container guarantees every queued
+// request eventually receives either a lease or an error (even on failed
+// connects), so this goroutine always terminates.
+func (c *plcConnectionCache) drainAbandonedLease(connection
*connectionContainer, connChan chan *plcConnectionLease, errChan chan error) {
+ c.wg.Go(func() {
+ select {
+ case <-connChan:
+ _ = connection.returnConnection(context.Background(),
StateIdle)
+ case <-errChan:
+ }
+ })
+}
+
func (c *plcConnectionCache) Close() error {
ctx := context.TODO()
c.log.Debug().Msg("Closing connection cache started.")
diff --git a/plc4go/pkg/api/cache/connectionContainer.go
b/plc4go/pkg/api/cache/connectionContainer.go
index 2a9009f328..99a3bef7a9 100644
--- a/plc4go/pkg/api/cache/connectionContainer.go
+++ b/plc4go/pkg/api/cache/connectionContainer.go
@@ -113,6 +113,12 @@ func (c *connectionContainer) connect(ctx context.Context)
{
} else {
c.log.Trace().Msg("no waiting clients")
}
+ // Mark the container as invalid so the next lease request
knows it has to
+ // re-attempt the connection. Previously the state was left
untouched
+ // (StateInitialized on the initial connect), which parked the
container in a
+ // state where lease() queued requests forever and no reconnect
was ever
+ // attempted - permanently breaking the connection string until
restart.
+ c.state = StateInvalid
return
}
@@ -127,13 +133,11 @@ func (c *connectionContainer) connect(ctx
context.Context) {
// Mark the connection as idle for now.
c.state = StateIdle
// If there is a request in the queue, hand out the connection to that.
- if waitingClientsLen := len(c.queue); waitingClientsLen > 0 {
- c.log.Trace().Int("waitingClientsLen",
waitingClientsLen).Msg("notifies waiting clients of connection")
- // Get the first in the queue.
- queueHead := c.queue[0]
- c.queue = c.queue[1:]
+ if queueHead := c.nextWaiter(); queueHead != nil {
+ c.log.Trace().Int("waitingClientsLen",
len(c.queue)).Msg("notifies waiting clients of connection")
// Mark the connection as being used.
c.state = StateInUse
+ c.leaseCounter++
// Return the lease to the caller.
connection := newPlcConnectionLease(c, c.leaseCounter,
c.connection)
// In this case we don'c need to check for blocks
@@ -145,6 +149,49 @@ func (c *connectionContainer) connect(ctx context.Context)
{
}
}
+// nextWaiter pops the next queued lease request whose context is still live.
+// Requests whose caller has already given up waiting are failed explicitly
(their
+// error channel is buffered) instead of being handed a lease: sending a lease
to
+// an abandoned request would park the connection in StateInUse forever, as
nobody
+// is left to return it. Must be called with c.lock held.
+func (c *connectionContainer) nextWaiter() *connectionRequest {
+ for len(c.queue) > 0 {
+ head := c.queue[0]
+ c.queue = c.queue[1:]
+ if head.ctx.Err() != nil {
+ c.log.Debug().Str("connectionString",
c.connectionString).
+ Msg("Skipping lease request with cancelled
context")
+ select {
+ case head.errChan <- head.ctx.Err():
+ default:
+ }
+ continue
+ }
+ return &head
+ }
+ return nil
+}
+
+// startReconnect discards any stale connection and re-runs connect
asynchronously.
+// State is set to StateInitialized so concurrent lease requests queue up
instead of
+// spawning additional connect attempts. Must be called with c.lock held.
+func (c *connectionContainer) startReconnect(ctx context.Context) {
+ stale := c.connection
+ c.connection = nil
+ c.state = StateInitialized
+ go func() {
+ if stale != nil {
+ // Close the stale connection so its message-codec
workers don't leak.
+ if err := stale.Close(); err != nil {
+ c.log.Debug().Err(err).
+ Str("connectionString",
c.connectionString).
+ Msg("Error closing stale connection
before reconnect")
+ }
+ }
+ c.connect(ctx)
+ }()
+}
+
func (c *connectionContainer) addListener(listener connectionListener) {
// Get the lock.
c.lock.Lock()
@@ -162,6 +209,22 @@ func (c *connectionContainer) lease(ctx context.Context)
(chan *plcConnectionLea
// Check if the connection is available.
switch c.state {
case StateIdle:
+ // Verify the cached connection is still alive before handing
it out. The
+ // 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() {
+ 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.")
+ c.queue = append(c.queue, connectionRequest{ctx: ctx,
connChan: connectionChan, errChan: errorChan})
+ c.startReconnect(ctx)
+ break
+ }
c.leaseCounter++
connection := newPlcConnectionLease(c, c.leaseCounter,
c.connection)
c.state = StateInUse
@@ -179,7 +242,18 @@ func (c *connectionContainer) lease(ctx context.Context)
(chan *plcConnectionLea
Int("waiting-queue-size", len(c.queue)).
Msg("Added lease-request to queue.")
case StateInvalid:
- c.log.Debug().Str("connectionString",
c.connectionString).Msg("No lease because invalid")
+ // A previous connect or reconnect failed. Previously the
request was simply
+ // ignored here (nothing was ever sent on either channel), so
every caller
+ // blocked until timeout and the container stayed broken
forever. Instead,
+ // queue the request and attempt to re-establish the connection.
+ if c.closed {
+ errorChan <- errors.New("connection container is
closed")
+ break
+ }
+ c.log.Debug().Str("connectionString", c.connectionString).
+ Msg("Connection is invalid - attempting reconnect.")
+ c.queue = append(c.queue, connectionRequest{ctx: ctx, connChan:
connectionChan, errChan: errorChan})
+ c.startReconnect(ctx)
}
return connectionChan, errorChan
}
@@ -214,6 +288,17 @@ func (c *connectionContainer) returnConnection(ctx
context.Context, newState cac
}
}
c.connect(ctx)
+
+ c.lock.Lock()
+ defer c.lock.Unlock()
+ if c.connection == nil {
+ c.state = StateInvalid
+ return errors.New("Can'c 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
+ // another lease here would lease the same connection twice.
+ return nil
default:
c.log.Debug().Str("connectionString",
c.connectionString).Msg("Client returned valid connection.")
}
@@ -225,11 +310,10 @@ func (c *connectionContainer) returnConnection(ctx
context.Context, newState cac
}
// Check how many others are waiting for this connection.
- if waitingClientsLen := len(c.queue); waitingClientsLen > 0 {
- c.log.Trace().Int("waitingClientsLen",
waitingClientsLen).Msg("notifies waiting clients of connection return")
+ if next := c.nextWaiter(); next != nil {
+ c.log.Trace().Int("waitingClientsLen",
len(c.queue)).Msg("notifies waiting clients of connection return")
// There are waiting clients, give the connection to the next
client in the line.
- next := c.queue[0]
- c.queue = c.queue[1:]
+ c.state = StateInUse
c.leaseCounter++
connection := newPlcConnectionLease(c, c.leaseCounter,
c.connection)
diff --git a/plc4go/pkg/api/cache/connectionRecovery_test.go
b/plc4go/pkg/api/cache/connectionRecovery_test.go
new file mode 100644
index 0000000000..c21c00e072
--- /dev/null
+++ b/plc4go/pkg/api/cache/connectionRecovery_test.go
@@ -0,0 +1,227 @@
+/*
+ * 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"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/apache/plc4x/plc4go/internal/simulated"
+ plc4go "github.com/apache/plc4x/plc4go/pkg/api"
+ "github.com/apache/plc4x/plc4go/pkg/api/config"
+ "github.com/apache/plc4x/plc4go/spi/options"
+ "github.com/apache/plc4x/plc4go/spi/testutils"
+)
+
+func testDriverManager(t *testing.T) plc4go.PlcDriverManager {
+ logger := testutils.ProduceTestingLogger(t)
+ driverManager :=
plc4go.NewPlcDriverManager(config.WithCustomLogger(logger))
+ t.Cleanup(func() {
+ assert.NoError(t, driverManager.Close())
+ })
+
driverManager.RegisterDriver(simulated.NewDriver(options.WithCustomLogger(logger)))
+ return driverManager
+}
+
+// A container whose initial connect failed must not ignore later lease
requests:
+// it has to re-attempt the connection (and deliver an error if that fails
again)
+// instead of leaving the caller to time out with nothing on either channel.
+func Test_connectionContainer_leaseAfterFailedConnectDeliversError(t
*testing.T) {
+ c := newConnectionContainer(testutils.ProduceTestingLogger(t),
testDriverManager(t), "simulated://1.2.3.4:42?connectionError=nope")
+
+ // Initial connect fails and must leave the container in StateInvalid.
+ c.connect(context.Background())
+ c.lock.RLock()
+ state := c.state
+ c.lock.RUnlock()
+ assert.Equal(t, StateInvalid, state)
+
+ // A subsequent lease request must trigger a reconnect attempt and get
the
+ // (failing) result delivered - not silence.
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ connChan, errChan := c.lease(ctx)
+ select {
+ case err := <-errChan:
+ assert.Error(t, err)
+ case conn := <-connChan:
+ t.Fatalf("expected an error, got a lease: %v", conn)
+ case <-ctx.Done():
+ t.Fatal("lease request on invalid container was silently
ignored (pre-fix behavior)")
+ }
+}
+
+// A container in StateInvalid whose endpoint has recovered must reconnect and
+// hand out a fresh lease on the next request.
+func Test_connectionContainer_leaseOnInvalidReconnects(t *testing.T) {
+ c := newConnectionContainer(testutils.ProduceTestingLogger(t),
testDriverManager(t), "simulated://1.2.3.4:42")
+ c.lock.Lock()
+ c.state = StateInvalid // simulate a previously failed connect/reconnect
+ c.lock.Unlock()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ connChan, errChan := c.lease(ctx)
+ select {
+ case conn := <-connChan:
+ require.NotNil(t, conn)
+ assert.True(t, conn.IsConnected())
+ assert.NoError(t, conn.Close())
+ case err := <-errChan:
+ t.Fatalf("expected a lease, got error: %v", err)
+ case <-ctx.Done():
+ t.Fatal("lease request on invalid container was silently
ignored (pre-fix behavior)")
+ }
+}
+
+// An idle cached connection that died while parked in the cache must not be
+// handed out; the container has to establish a replacement.
+func Test_connectionContainer_deadIdleConnectionReplaced(t *testing.T) {
+ c := newConnectionContainer(testutils.ProduceTestingLogger(t),
testDriverManager(t), "simulated://1.2.3.4:42")
+ c.connect(context.Background())
+ c.lock.RLock()
+ state, firstConnection := c.state, c.connection
+ c.lock.RUnlock()
+ require.Equal(t, StateIdle, state)
+ require.NotNil(t, firstConnection)
+
+ // Simulate the remote dropping the idle connection.
+ firstConnection.Invalidate()
+ require.False(t, firstConnection.IsConnected())
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ connChan, errChan := c.lease(ctx)
+ select {
+ case conn := <-connChan:
+ require.NotNil(t, conn)
+ assert.True(t, conn.IsConnected(), "handed-out lease must be
alive")
+ assert.NoError(t, conn.Close())
+ case err := <-errChan:
+ t.Fatalf("expected a lease, got error: %v", err)
+ case <-ctx.Done():
+ t.Fatal("timed out waiting for a replacement connection")
+ }
+
+ c.lock.RLock()
+ secondConnection := c.connection
+ c.lock.RUnlock()
+ assert.NotSame(t, firstConnection, secondConnection, "dead connection
must have been replaced")
+}
+
+// Queued lease requests whose caller already gave up must be skipped when a
+// connection is handed out - otherwise the connection is leased to nobody and
+// stays StateInUse forever.
+func Test_connectionContainer_cancelledWaiterSkipped(t *testing.T) {
+ c := newConnectionContainer(testutils.ProduceTestingLogger(t),
testDriverManager(t), "simulated://1.2.3.4:42")
+ c.connect(context.Background())
+
+ // Take the connection so follow-up requests queue.
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ connChan, _ := c.lease(ctx)
+ lease := <-connChan
+
+ // Queue a request and abandon it.
+ abandonedCtx, abandonedCancel :=
context.WithCancel(context.Background())
+ abandonedConnChan, abandonedErrChan := c.lease(abandonedCtx)
+ abandonedCancel()
+
+ // Queue a live request behind it.
+ liveConnChan, liveErrChan := c.lease(ctx)
+
+ // Returning the lease must skip the abandoned waiter (failing it
explicitly)
+ // and serve the live one.
+ require.NoError(t, lease.Close())
+
+ select {
+ case conn := <-liveConnChan:
+ require.NotNil(t, conn)
+ assert.NoError(t, conn.Close())
+ case err := <-liveErrChan:
+ t.Fatalf("live waiter got error: %v", err)
+ case <-time.After(5 * time.Second):
+ t.Fatal("live waiter was starved - connection was likely leased
to the abandoned waiter")
+ }
+
+ // The abandoned waiter must have received an explicit error, not a
lease.
+ select {
+ case err := <-abandonedErrChan:
+ assert.Error(t, err)
+ case conn := <-abandonedConnChan:
+ t.Fatalf("abandoned waiter got a lease: %v", conn)
+ case <-time.After(5 * time.Second):
+ t.Fatal("abandoned waiter was never notified")
+ }
+}
+
+// End-to-end through the cache: a connection string whose first connect fails
+// must keep yielding prompt errors on every attempt - not one error and then
+// silent timeouts (pre-fix behavior: the container stayed StateInitialized
with
+// requests queueing forever, or StateInvalid with requests ignored).
+func Test_plcConnectionCache_failedFirstConnectDoesNotBrick(t *testing.T) {
+ cache := NewPlcConnectionCache(testDriverManager(t))
+ t.Cleanup(func() {
+ assert.NoError(t, cache.Close())
+ })
+
+ for i := range 3 {
+ ctx, cancel := context.WithTimeout(context.Background(),
5*time.Second)
+ start := time.Now()
+ _, err := cache.GetConnection(ctx,
"simulated://1.2.3.4:42?connectionError=nope")
+ cancel()
+ assert.Error(t, err, "attempt %d", i+1)
+ assert.NotErrorIs(t, err, context.DeadlineExceeded, "attempt %d
must fail fast with the connect error, not time out", i+1)
+ assert.Less(t, time.Since(start), 3*time.Second, "attempt %d
must not run into the timeout", i+1)
+ }
+}
+
+// Concurrent lease requests against an invalid container must result in
exactly
+// one reconnect attempt with everyone served (leases handed out sequentially).
+func Test_connectionContainer_concurrentLeaseOnInvalid(t *testing.T) {
+ c := newConnectionContainer(testutils.ProduceTestingLogger(t),
testDriverManager(t), "simulated://1.2.3.4:42")
+ c.lock.Lock()
+ c.state = StateInvalid
+ c.lock.Unlock()
+
+ const clients = 5
+ var wg sync.WaitGroup
+ for range clients {
+ wg.Go(func() {
+ ctx, cancel :=
context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ connChan, errChan := c.lease(ctx)
+ select {
+ case conn := <-connChan:
+ assert.NoError(t, conn.Close())
+ case err := <-errChan:
+ t.Errorf("expected a lease, got error: %v", err)
+ case <-ctx.Done():
+ t.Error("timed out waiting for lease")
+ }
+ })
+ }
+ wg.Wait()
+}