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 162260187a feat(plc4go): exempt subscription-carrying connections from
cache idle TTL
162260187a is described below
commit 162260187a33a319fc6123fd46581bf0b6dccaa7
Author: Sebastian Rühl <[email protected]>
AuthorDate: Wed Jul 15 13:57:43 2026 +0200
feat(plc4go): exempt subscription-carrying connections from cache idle TTL
WithMaxIdleTime reaps connections that sat idle in the cache past the
configured age. For connections carrying stateful subscriptions (BACnet
COV: server-side registrations plus client-side lifetime/2 refresh
timers) a reap silently destroys that state - passive updates stop until
the client happens to re-subscribe, which may be hours away.
The cache now consults an optional capability interface
(ActiveSubscriptionCount) before declaring an idle connection expired:
connections reporting active subscription handles are kept regardless of
age. The BACnet connection implements the capability by summing live
handles across its subscribers (newly mutex-guarded, as the cache calls
in from outside the connect path). Drivers without the capability keep
the existing TTL behavior unchanged.
---
plc4go/internal/bacnetip/Connection.go | 19 ++++
.../bacnetip/ConnectionSubscriptionCount_test.go | 48 ++++++++++
plc4go/internal/bacnetip/Subscriber.go | 8 ++
plc4go/pkg/api/cache/PlcConnectionCache.go | 5 +
plc4go/pkg/api/cache/connectionContainer.go | 24 ++++-
.../cache/connectionIdleTTLSubscription_test.go | 101 +++++++++++++++++++++
6 files changed, 204 insertions(+), 1 deletion(-)
diff --git a/plc4go/internal/bacnetip/Connection.go
b/plc4go/internal/bacnetip/Connection.go
index 2500ae2f61..227e30ae6c 100644
--- a/plc4go/internal/bacnetip/Connection.go
+++ b/plc4go/internal/bacnetip/Connection.go
@@ -49,6 +49,7 @@ type Connection struct {
configuration Configuration
driverContext DriverContext
subscribers []*Subscriber
+ subscribersMu sync.Mutex
tm transactions.RequestTransactionManager
connectionId string
@@ -252,6 +253,8 @@ func (c *Connection) UnsubscriptionRequestBuilder()
apiModel.PlcUnsubscriptionRe
}
func (c *Connection) addSubscriber(subscriber *Subscriber) {
+ c.subscribersMu.Lock()
+ defer c.subscribersMu.Unlock()
if slices.Contains(c.subscribers, subscriber) {
c.log.Debug().Interface("subscriber",
subscriber).Msg("Subscriber already added")
return
@@ -259,6 +262,22 @@ func (c *Connection) addSubscriber(subscriber *Subscriber)
{
c.subscribers = append(c.subscribers, subscriber)
}
+// ActiveSubscriptionCount reports how many COV subscription handles are
+// currently registered across this connection's subscribers. The connection
+// cache consults it (as an optional capability) to exempt subscription-
+// carrying connections from idle reaping: their server-side COV
+// registrations and refresh timers live on this connection and would be
+// destroyed by a reap.
+func (c *Connection) ActiveSubscriptionCount() int {
+ c.subscribersMu.Lock()
+ defer c.subscribersMu.Unlock()
+ count := 0
+ for _, s := range c.subscribers {
+ count += s.activeHandleCount()
+ }
+ return count
+}
+
func (c *Connection) String() string {
return fmt.Sprintf("bacnetip.Connection")
}
diff --git a/plc4go/internal/bacnetip/ConnectionSubscriptionCount_test.go
b/plc4go/internal/bacnetip/ConnectionSubscriptionCount_test.go
new file mode 100644
index 0000000000..92620d827e
--- /dev/null
+++ b/plc4go/internal/bacnetip/ConnectionSubscriptionCount_test.go
@@ -0,0 +1,48 @@
+/*
+ * 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 bacnetip
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// ActiveSubscriptionCount feeds the connection cache's subscription-aware
+// idle reaping: it must reflect the live COV handle count across all
+// subscribers of the connection.
+func TestConnection_ActiveSubscriptionCount(t *testing.T) {
+ conn := &Connection{}
+ assert.Zero(t, conn.ActiveSubscriptionCount(), "fresh connection has no
subscriptions")
+
+ s1 := NewSubscriber(conn)
+ s2 := NewSubscriber(conn)
+ conn.addSubscriber(s1)
+ conn.addSubscriber(s2)
+ assert.Zero(t, conn.ActiveSubscriptionCount(), "subscribers without
handles don't count")
+
+ s1.storeHandle(&SubscriptionHandle{subscriberProcessId: 1})
+ s1.storeHandle(&SubscriptionHandle{subscriberProcessId: 2})
+ s2.storeHandle(&SubscriptionHandle{subscriberProcessId: 7})
+ assert.Equal(t, 3, conn.ActiveSubscriptionCount(), "handles sum across
subscribers")
+
+ s1.removeHandle(2)
+ assert.Equal(t, 2, conn.ActiveSubscriptionCount(), "removed handles
drop out of the count")
+}
diff --git a/plc4go/internal/bacnetip/Subscriber.go
b/plc4go/internal/bacnetip/Subscriber.go
index 1bf90f11ba..ee7ff04c47 100644
--- a/plc4go/internal/bacnetip/Subscriber.go
+++ b/plc4go/internal/bacnetip/Subscriber.go
@@ -354,6 +354,14 @@ func (m *Subscriber) lookupHandle(processId uint32)
*SubscriptionHandle {
return m.handles[processId]
}
+// activeHandleCount reports the number of currently registered subscription
+// handles (feeds Connection.ActiveSubscriptionCount).
+func (m *Subscriber) activeHandleCount() int {
+ m.handlesMu.RLock()
+ defer m.handlesMu.RUnlock()
+ return len(m.handles)
+}
+
// findHandle locates the SubscriptionHandle that owns the given
// apiModel.PlcSubscriptionHandle (returned earlier in the Subscribe response).
// Used by Unsubscribe which only has the api-level handle in hand.
diff --git a/plc4go/pkg/api/cache/PlcConnectionCache.go
b/plc4go/pkg/api/cache/PlcConnectionCache.go
index d0aaf4d316..3f19b54b2f 100644
--- a/plc4go/pkg/api/cache/PlcConnectionCache.go
+++ b/plc4go/pkg/api/cache/PlcConnectionCache.go
@@ -84,6 +84,11 @@ func WithMaxWaitTime(maxWaitTime time.Duration)
WithConnectionCacheOption {
// 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.
+//
+// Connections that report active subscription handles (see the drivers'
+// subscription support, e.g. BACnet COV) are exempt from the TTL: their
+// subscription state lives on the connection and would be silently destroyed
+// by a reap, cutting off passive updates until the client re-subscribes.
func WithMaxIdleTime(maxIdleTime time.Duration) WithConnectionCacheOption {
return func(plcConnectionCache *plcConnectionCache) {
plcConnectionCache.maxIdleTime = maxIdleTime
diff --git a/plc4go/pkg/api/cache/connectionContainer.go
b/plc4go/pkg/api/cache/connectionContainer.go
index 4938a0c531..5162be623d 100644
--- a/plc4go/pkg/api/cache/connectionContainer.go
+++ b/plc4go/pkg/api/cache/connectionContainer.go
@@ -357,10 +357,32 @@ func (c *connectionContainer) returnConnection(ctx
context.Context, newState cac
return nil
}
+// subscriptionAware is an optional capability of cached connections: drivers
+// whose connections carry stateful subscriptions (e.g. BACnet COV, with its
+// server-side registrations and client-side refresh timers) implement it so
+// the cache can tell "parked but working" apart from "abandoned".
+type subscriptionAware interface {
+ // ActiveSubscriptionCount reports the number of currently active
+ // subscription handles on the connection.
+ ActiveSubscriptionCount() int
+}
+
// idleExpired reports whether the connection outstayed the configured max idle
// time. Must be called with c.lock held.
+//
+// A connection with active subscription handles never expires: its
+// subscription state lives on the connection, so reaping it would silently
+// destroy server-side registrations and refresh timers, cutting off passive
+// updates until the client happens to re-subscribe. Such a connection is not
+// "idle" in any meaningful sense even when no lease touched it for a while.
func (c *connectionContainer) idleExpired() bool {
- return c.maxIdleTime > 0 && time.Since(c.idleSince) > c.maxIdleTime
+ if c.maxIdleTime <= 0 || time.Since(c.idleSince) <= c.maxIdleTime {
+ return false
+ }
+ if sa, ok := c.connection.(subscriptionAware); ok &&
sa.ActiveSubscriptionCount() > 0 {
+ return false
+ }
+ return true
}
func (c *connectionContainer) String() string {
diff --git a/plc4go/pkg/api/cache/connectionIdleTTLSubscription_test.go
b/plc4go/pkg/api/cache/connectionIdleTTLSubscription_test.go
new file mode 100644
index 0000000000..971f9d1eb6
--- /dev/null
+++ b/plc4go/pkg/api/cache/connectionIdleTTLSubscription_test.go
@@ -0,0 +1,101 @@
+/*
+ * 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"
+)
+
+// stubSubscribedConnection implements tracedPlcConnection via interface
+// embedding (unimplemented methods panic if called) and reports a fixed
+// number of active subscription handles.
+type stubSubscribedConnection struct {
+ tracedPlcConnection
+ activeSubscriptions int
+}
+
+func (s *stubSubscribedConnection) ActiveSubscriptionCount() int { return
s.activeSubscriptions }
+func (s *stubSubscribedConnection) IsConnected() bool { return true
}
+func (s *stubSubscribedConnection) Close() error { return nil }
+func (s *stubSubscribedConnection) IsTraceEnabled() bool { return
false }
+
+// A connection carrying active subscription handles must never be reaped by
+// the idle TTL: its subscription state (e.g. BACnet COV refresh timers) lives
+// on the connection and would be silently destroyed, cutting off passive
+// updates until the client happens to re-subscribe.
+func Test_connectionContainer_idleExpiredSkipsSubscribedConnection(t
*testing.T) {
+ c := newConnectionContainer(testutils.ProduceTestingLogger(t),
testDriverManager(t), "simulated://1.2.3.4:42")
+ c.maxIdleTime = time.Minute
+
+ stub := &stubSubscribedConnection{activeSubscriptions: 1}
+ c.lock.Lock()
+ c.connection = stub
+ c.state = StateIdle
+ c.idleSince = time.Now().Add(-2 * time.Minute) // aged past the TTL
+ c.lock.Unlock()
+
+ c.lock.RLock()
+ expired := c.idleExpired()
+ c.lock.RUnlock()
+ assert.False(t, expired, "aged connection with active subscriptions
must not be considered expired")
+
+ // The same aged connection without subscriptions expires as usual.
+ stub.activeSubscriptions = 0
+ c.lock.RLock()
+ expired = c.idleExpired()
+ c.lock.RUnlock()
+ assert.True(t, expired, "aged connection without subscriptions must
expire")
+}
+
+// Lease-level behavior: an aged-but-subscribed connection is handed out as-is
+// instead of being replaced.
+func Test_connectionContainer_leaseKeepsAgedSubscribedConnection(t *testing.T)
{
+ c := newConnectionContainer(testutils.ProduceTestingLogger(t),
testDriverManager(t), "simulated://1.2.3.4:42")
+ c.maxIdleTime = time.Minute
+
+ stub := &stubSubscribedConnection{activeSubscriptions: 3}
+ c.lock.Lock()
+ c.connection = stub
+ c.state = StateIdle
+ c.idleSince = time.Now().Add(-2 * time.Minute)
+ c.lock.Unlock()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ connChan, errChan := c.lease(ctx)
+ select {
+ case lease := <-connChan:
+ require.NotNil(t, lease)
+ c.lock.RLock()
+ assert.Same(t, stub, c.connection, "subscribed connection must
be kept across the lease")
+ c.lock.RUnlock()
+ case err := <-errChan:
+ t.Fatalf("expected a lease, got error: %v", err)
+ case <-ctx.Done():
+ t.Fatal("timed out waiting for lease")
+ }
+}