mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-22 01:26:20 +08:00
87f253bb47
# Description
Adds **unbounded monitored items per subscription** to the V2
`ManagedSession` subscription engine via automatic, transparent
partitioning. Top-level public API and runtime semantics for callers
that stay under the server's `MaxMonitoredItemsPerSubscription` cap are
unchanged; callers that exceed the cap continue to succeed instead of
failing with `BadTooManyMonitoredItems`.
## Approach (OPC UA Part 4 1.05.07 conformant)
- The public `ISubscription` is now a `LogicalSubscription` that owns
one **primary** and zero-or-more **secondary** `Subscription`
partitions, each a real server-side subscription.
- `CompositeMonitoredItemCollection` aggregates the per-partition
`MonitoredItemCollection` views so `Items`, indexer access, and
enumeration look unsharded to the caller.
- `PartitionPlacementPolicy` decides the target partition at
`TryAddMonitoredItem` time:
- **`StrictAffinity`** — keep items tagged with the same `Affinity`
co-located until the partition fills, then split with a one-time
warning.
- **`BestEffortAffinity`** (default) — prefer co-location but spill
silently when the partition is full.
- No affinity → fall back to the least-loaded partition (or the
primary).
- `PartitionForwardingHandler` proxies notifications from secondary
partitions to the caller's handler with the **primary partition's**
`ISubscription` identity, so handler code never observes the split.
- **Durable subscriptions (Part 4 §5.13.9):** when `SetAsDurableAsync`
runs before any items are added, the wrapper records the intent and
installs an `OnAfterCreateAsync` hook on every existing partition. Each
newly-minted secondary inherits the hook so `SetSubscriptionDurable` is
invoked between `CreateSubscription` and `CreateMonitoredItems` — the
only order the standard allows.
- **Snapshot/restore** preserves the partition layout so reconnect and
failover restore items into the same partition they were created in,
with affinity tags intact.
- Secondary partitions that go idle (no items, no pending
`CreateMonitoredItems`) are deleted to release server resources.
## Performance
Hot-path dispatch goes through the primary partition unchanged for the
unsharded case (single-partition fast path); the forwarding handler only
allocates on the multi-partition path. The performance smoke test in
`Tests/Opc.Ua.Stress.Tests` shows no regression.
## New configuration
```csharp
session.AddSubscription(handler, new SubscriptionOptions
{
MaxMonitoredItemsPerPartition = 5000, // optional cap per server-side subscription
PartitionPlacementPolicy = // optional placement policy
PartitionPlacementPolicy.StrictAffinity
});
sub.TryAddMonitoredItem(
name,
nodeId,
o => o with { Affinity = "machine-A" }, // optional co-location hint
out _);
```
Defaults preserve pre-change behaviour (`MaxMonitoredItemsPerPartition =
uint.MaxValue`, `BestEffortAffinity`).
## New types (Libraries/Opc.Ua.Client/Subscription)
| File | Purpose |
| --- | --- |
| `CompositeMonitoredItemCollection.cs` | Aggregate collection view |
| `ILogicalSubscription.cs` / `IPartitionedSubscription.cs` | Internal
contracts used by tests and infrastructure |
| `LogicalSubscription.cs` | Wrapper that owns the partitions |
| `PartitionForwardingHandler.cs` | Notification proxy |
| `PartitionPlacementPolicy.cs` | Affinity-aware item placement |
Modified: `ISubscriptionNotificationHandler`, `MonitoredItemManager`,
`MonitoredItemOptions` (adds `Affinity`), `Subscription`,
`SubscriptionManager`, `SubscriptionOptions` (adds
`MaxMonitoredItemsPerPartition`, `PartitionPlacementPolicy`),
`SubscriptionManagerSerializer`, `MonitoredItemStateSnapshot`,
`SubscriptionStateSnapshot`, `ManagedSessionExtensions`.
## Documentation
- New: `Docs/UnboundedSubscriptions.md` — feature overview, affinity
semantics, snapshot/durable interaction.
- Cross-links added in `Docs/Sessions.md`, `Docs/MigrationGuide.md`,
`Docs/README.md`.
## Validation
| Suite | Result |
| --- | --- |
| Unit tests for new types — `Opc.Ua.Client.Tests.Subscription.*`
(net10.0 + net48) | **39 / 39** |
| All `Opc.Ua.Client.Subscriptions.*` unit tests (net48) | **152 / 152**
|
| Integration — `Opc.Ua.Subscriptions.Tests.UnboundedSubscriptionTests`
(net10.0 + net48) | **3 / 3** (1 durable-only skipped) |
| Integration — `Opc.Ua.Subscriptions.Durable.Tests.*V2Async` (net10.0)
| **6 / 6** (incl. new `DurableLifetimeAppliesToAllPartitionsV2Async`) |
| Adjacent — `Opc.Ua.Sessions.Tests` (net10.0) | **690 / 690** |
| Adjacent — `Opc.Ua.Subscriptions.Classic.Tests` (net10.0) | **32 /
32** |
| Adjacent — `Opc.Ua.Features.Tests` (net10.0) | **128 / 128** |
| Adjacent — `Opc.Ua.Stress.Tests` (net10.0) | **159 / 159** |
| NativeAOT smoke — `Opc.Ua.Aot.Tests.SubscriptionAotTests` (win-x64) |
**5 / 5** (incl. new `UnboundedSubscriptionWithCapAotAsync`) |
## Compatibility
- **No public type removed**, **no obsoletions added**.
- Public surface on `ISubscription` / `ManagedSession` unchanged.
- New options non-breaking by default (`MaxMonitoredItemsPerPartition =
uint.MaxValue` preserves prior behaviour; `BestEffortAffinity` matches
the closest analogue to the prior single-partition semantics).
- Classic V1 subscription engine unchanged.
## Related Issues
- _No tracking issue filed — please reference the appropriate one if any
exists._
## Security review
Scoped against Microsoft Service Tree
``59eec07a-6f20-42b9-b41b-d20e0a6322da`` (Azure Industrial IoT) using a
combination of the security-agent MCP (code-integrity, secrets-usage,
tls, policy-governance, ai-chat) and the ``security-review`` SAST
sub-agent on the PR diff. Bar: SDL secure-coding for a client library +
OPC UA Part 2 / Part 4 §5.5 / §5.13 spec.
Fixed in commit ``c6997689``:
| # | Sev | Title | Fix |
| --- | --- | --- | --- |
| 1 | CRITICAL | Unbounded partition fan-out under hostile server
(CWE-400/770) | Added ``SubscriptionOptions.MaxPartitionCount`` (default
``32``), enforced in ``PartitionPlacementPolicy``, surfaced as
``PlacementDecision.RejectMaxPartitionCountReached`` |
| 2 | HIGH | Snapshot ``LogicalGroupId`` option-consistency not
validated (CWE-502) | ``ValidateAndSortGroup`` now asserts all snapshots
in a group agree on every subscription-wide option |
| 3 | MEDIUM | Restore-path secondaries bypassed
``PartitionForwardingHandler`` (CWE-362) |
``AttachSecondaryPartitionAsync`` now reuses the wrapper's forwarding
handler when present |
Findings explicitly assessed and dismissed (sub-agent confirmed):
* Affinity-index growth — uses ``StringComparer.Ordinal`` (no
CVE-2022-29117 class); growth is application-controlled, not
attacker-controlled.
* Idle-timer race vs ``DisposeAsync`` — race exists; worst case is a
swallowed ``ObjectDisposedException``. No attacker trigger.
* Cross-wrapper notification routing — each ``Add`` allocates its own
forwarding handler; ``BindLogical`` rejects re-binding; no structural
path for crosstalk.
* OPC UA Part 4 §5.13.6 (``SetTriggering`` cross-partition) — enforced
by ``LogicalSubscription.SetTriggeringAsync``.
* OPC UA Part 4 §5.13.9 (durable hook ordering) — installed uniformly on
every minted partition.
Validation: ``Opc.Ua.Client`` builds clean on net10.0; 43 V2 unit tests
+ 3 unbounded integration tests + 6 V2 durable integration tests all
pass.
## Checklist
- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
218 lines
9.2 KiB
C#
218 lines
9.2 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System;
|
|
using System.Threading;
|
|
|
|
namespace Opc.Ua.Client.Subscriptions
|
|
{
|
|
/// <summary>
|
|
/// Subscription options
|
|
/// </summary>
|
|
public record class SubscriptionOptions
|
|
{
|
|
/// <summary>
|
|
/// The subscription is disabled
|
|
/// </summary>
|
|
public bool Disabled { get; init; }
|
|
|
|
/// <summary>
|
|
/// Set keep alive count
|
|
/// </summary>
|
|
public uint KeepAliveCount { get; init; }
|
|
|
|
/// <summary>
|
|
/// The life time of the subscription in counts of
|
|
/// publish interval.
|
|
/// LifetimeCount shall be at least 3*KeepAliveCount.
|
|
/// </summary>
|
|
public uint LifetimeCount { get; init; }
|
|
|
|
/// <summary>
|
|
/// Set desired priority of the subscription
|
|
/// </summary>
|
|
public byte Priority { get; init; }
|
|
|
|
/// <summary>
|
|
/// Set desired publishing interval
|
|
/// </summary>
|
|
public TimeSpan PublishingInterval { get; init; }
|
|
|
|
/// <summary>
|
|
/// Set desired publishing enabled
|
|
/// </summary>
|
|
public bool PublishingEnabled { get; init; }
|
|
|
|
/// <summary>
|
|
/// Set max notifications per publish
|
|
/// </summary>
|
|
public uint MaxNotificationsPerPublish { get; init; }
|
|
|
|
/// <summary>
|
|
/// Set min lifetime interval
|
|
/// </summary>
|
|
public TimeSpan MinLifetimeInterval { get; init; }
|
|
|
|
/// <summary>
|
|
/// When the V2 manager restores this subscription via
|
|
/// <see cref="ISubscriptionManager.LoadAsync"/> with
|
|
/// <c>transferSubscriptions: true</c>, request the server to
|
|
/// send the latest cached value of every monitored item as
|
|
/// part of the take-over (OPC UA Part 4 §5.13.7
|
|
/// <c>TransferSubscriptions</c>'s <c>sendInitialValues</c>
|
|
/// argument). Defaults to <c>false</c>: the server only sends
|
|
/// values that arrived after the subscription was suspended,
|
|
/// matching the post-disconnect semantics that the V2 manager
|
|
/// expects for failover-on-recreate.
|
|
/// </summary>
|
|
public bool SendInitialValuesOnTransfer { get; init; }
|
|
|
|
/// <summary>
|
|
/// Controls how the client reacts to an unsolicited
|
|
/// <c>Good_SubscriptionTransferred</c> StatusChangeNotification
|
|
/// from the server. Defaults to
|
|
/// <see cref="SubscriptionRecoveryPolicy.ReportOnly"/> which
|
|
/// matches the spec-strict behaviour (OPC UA Part 4 §5.14.7).
|
|
/// Set to
|
|
/// <see cref="SubscriptionRecoveryPolicy.RecreateOnUnsolicitedTransfer"/>
|
|
/// to automatically recreate the subscription on the same
|
|
/// session when the notification arrives without the client
|
|
/// having initiated a TransferSubscriptions request — this
|
|
/// addresses server quirks that leave subscriptions stuck.
|
|
/// </summary>
|
|
public SubscriptionRecoveryPolicy RecoveryPolicy { get; init; }
|
|
|
|
/// <summary>
|
|
/// <para>
|
|
/// Opts the logical subscription out of the V2 unbounded-item
|
|
/// behaviour. When <c>false</c> (the default), the engine
|
|
/// transparently splits monitored items across multiple
|
|
/// server-side partition subscriptions as needed so callers
|
|
/// can add an effectively unlimited number of items. When
|
|
/// <c>true</c>, the subscription is bound to a single
|
|
/// server-side subscription and
|
|
/// <see cref="MonitoredItems.IMonitoredItemCollection.TryAdd"/>
|
|
/// calls that would exceed the server's
|
|
/// <see cref="ServerCapabilities.MaxMonitoredItemsPerSubscription"/>
|
|
/// cap surface the server's
|
|
/// <c>Bad_TooManyMonitoredItems</c> per-item status (the
|
|
/// pre-V2 behaviour).
|
|
/// </para>
|
|
/// </summary>
|
|
public bool DisableUnboundedItemMode { get; init; }
|
|
|
|
/// <summary>
|
|
/// <para>
|
|
/// Per-partition upper bound on monitored items. When the
|
|
/// logical subscription decides to split (see
|
|
/// <see cref="DisableUnboundedItemMode"/>) the wrapper uses
|
|
/// this value as the proactive per-partition cap; new items
|
|
/// land on a fresh partition once the current one reaches
|
|
/// the cap.
|
|
/// </para>
|
|
/// <para>
|
|
/// The reactive fallback further pins the effective cap of
|
|
/// any individual partition if the server returns
|
|
/// <c>Bad_TooManyMonitoredItems</c> for a
|
|
/// <c>CreateMonitoredItems</c> request — the partition is
|
|
/// marked no-grow and the next add mints a new partition.
|
|
/// This lets the wrapper handle servers that enforce a
|
|
/// stricter limit than they advertise (or that advertise
|
|
/// none at all).
|
|
/// </para>
|
|
/// <para>
|
|
/// <c>null</c> (the default) means "no proactive cap" — the
|
|
/// wrapper relies entirely on the reactive fallback to
|
|
/// discover the server's actual limit. Set this to a smaller
|
|
/// value to keep each partition smaller for snapshot /
|
|
/// transfer scaling, or for predictable partitioning at a
|
|
/// specific threshold; the wrapper does not consult
|
|
/// <see cref="ServerCapabilities.MaxMonitoredItemsPerSubscription"/>
|
|
/// to seed this value automatically.
|
|
/// </para>
|
|
/// <para>
|
|
/// Has no effect when <see cref="DisableUnboundedItemMode"/>
|
|
/// is <c>true</c>.
|
|
/// </para>
|
|
/// </summary>
|
|
public uint? MaxMonitoredItemsPerPartition { get; init; }
|
|
|
|
/// <summary>
|
|
/// <para>
|
|
/// Hard upper bound on the number of partitions a single
|
|
/// logical subscription is allowed to grow to. The wrapper
|
|
/// refuses to mint a new partition once the count reaches
|
|
/// this value; the failed <c>TryAdd</c> returns <c>false</c>
|
|
/// and the caller observes the standard add-failed path.
|
|
/// </para>
|
|
/// <para>
|
|
/// This cap exists primarily as a denial-of-service
|
|
/// safeguard: a hostile or buggy server that rejects every
|
|
/// <c>CreateMonitoredItems</c> request with
|
|
/// <c>Bad_TooManyMonitoredItems</c> would otherwise cause
|
|
/// the wrapper's reactive fallback to mint a fresh server-
|
|
/// side subscription per add attempt, growing client memory
|
|
/// and server handle space without bound. The cap defaults
|
|
/// to <c>32</c>, which comfortably covers legitimate
|
|
/// fan-out scenarios (a server allowing 1000 items per
|
|
/// subscription supports 32k items per logical subscription)
|
|
/// but stops runaway growth.
|
|
/// </para>
|
|
/// <para>
|
|
/// Has no effect when <see cref="DisableUnboundedItemMode"/>
|
|
/// is <c>true</c>.
|
|
/// </para>
|
|
/// </summary>
|
|
public uint MaxPartitionCount { get; init; } = 32;
|
|
|
|
/// <summary>
|
|
/// <para>
|
|
/// Idle timeout before a secondary (non-primary) partition
|
|
/// subscription is deleted on the server after its last
|
|
/// monitored item is removed. The primary partition is never
|
|
/// deleted while the logical subscription is alive so the
|
|
/// logical subscription's server-side identifier remains
|
|
/// stable for callers that log or correlate by id.
|
|
/// </para>
|
|
/// <para>
|
|
/// Defaults to <c>30s</c>. Set to <see cref="TimeSpan.Zero"/>
|
|
/// to delete empty secondary partitions immediately; set to
|
|
/// <see cref="Timeout.InfiniteTimeSpan"/> to never delete
|
|
/// empty secondary partitions.
|
|
/// </para>
|
|
/// <para>
|
|
/// Has no effect when <see cref="DisableUnboundedItemMode"/>
|
|
/// is <c>true</c>.
|
|
/// </para>
|
|
/// </summary>
|
|
public TimeSpan SecondaryPartitionIdleTimeout { get; init; }
|
|
= TimeSpan.FromSeconds(30);
|
|
}
|
|
}
|