/* ======================================================================== * 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.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Opc.Ua.Client.Subscriptions.MonitoredItems; namespace Opc.Ua.Client.Subscriptions { /// /// /// V2 logical-subscription wrapper. Represents one logical /// subscription to the consumer while transparently owning one /// or more underlying server-side partition subscriptions (each /// an ) when the per-partition /// monitored-item cap would be exceeded. /// /// /// In the foundational state landed by the first milestone the /// wrapper supports exactly one primary partition; subsequent /// milestones extend it with on-demand partition creation, a /// composite monitored-item collection, partition-affinity /// placement, dispatch serialisation, and the idle-delete / /// recreate / transfer / save-load fan-out paths. /// /// internal sealed class LogicalSubscription : ILogicalSubscription { /// /// Construct a logical subscription that wraps a single /// pre-created primary partition. All /// members delegate to the /// primary unchanged so the single-partition fast path is /// indistinguishable from the pre-wrapper behaviour. This /// constructor is intended for callers (and tests) that do /// not need on-demand partition creation. /// /// The pre-created server-side /// subscription that serves as this logical subscription's /// primary partition. Must not be null. public LogicalSubscription(IManagedSubscription primary) : this(primary, placementPolicy: null, partitionFactory: null) { } /// /// Construct a logical subscription that wraps a primary /// partition and is permitted to grow secondary partitions /// on demand. When and /// are both supplied, /// returns a composite /// collection that uses the policy to place items and the /// factory to mint new partitions when no existing partition /// has capacity. When either is null the wrapper /// degrades to single-partition mode so callers can opt out. /// /// Primary partition (required). /// Optional placement policy /// driving the composite collection. Required for /// multi-partition operation. /// Optional synchronous /// factory invoked when a new partition is required. The /// factory is responsible for any registration with the /// owning 's dispatch /// registry. Required for multi-partition operation. /// Optional time provider used to /// schedule the secondary-partition idle-delete timer. /// Defaults to . /// Optional idle timeout /// applied to secondary partitions; secondary partitions /// that have had no monitored items for this long are removed /// via . Pass /// /// (the default) to disable idle-delete. /// Async callback to dispose /// a secondary partition once its idle timeout elapses. The /// callback is responsible for removing the partition from /// the owning manager's dispatch registry. null /// disables idle-delete. public LogicalSubscription( IManagedSubscription primary, PartitionPlacementPolicy? placementPolicy, Func? partitionFactory, TimeProvider? timeProvider = null, TimeSpan? secondaryIdleTimeout = null, Func? secondaryDisposer = null) { if (primary == null) { throw new ArgumentNullException(nameof(primary)); } m_partitions = [primary]; m_partitionLock = new object(); m_monitoredItems = new CompositeMonitoredItemCollection( m_partitions, m_partitionLock, placementPolicy, partitionFactory == null ? null : () => AppendPartition(partitionFactory), timeProvider, secondaryIdleTimeout, secondaryDisposer); } /// /// Attach a forwarding handler whose lifetime is tied to /// this logical subscription. Disposed on /// so the semaphore-based serial /// dispatch primitive is released along with the wrapper. /// Called by when /// running in multi-partition mode. /// internal void AttachForwardingHandler(PartitionForwardingHandler handler) { m_forwardingHandler = handler ?? throw new ArgumentNullException(nameof(handler)); } /// /// Forwarding handler installed by /// ; null when /// the wrapper was constructed in single-partition fast-path /// mode (i.e. /// is true). Exposed so the manager can route /// late-attached partitions (snapshot-restore secondaries) /// through the same wrapper that serializes notifications /// across the primary partition. /// internal PartitionForwardingHandler? ForwardingHandler => m_forwardingHandler; /// public bool Created { get { // A logical subscription is considered Created only // when every partition has been created on the // server. With one partition this collapses to the // partition's own Created flag. The primary // partition is installed at construction time and // never removed, so the snapshot is guaranteed to // contain at least the primary — no post-loop count // check is needed (and reading m_partitions.Count // outside the lock would race against TryAdd). foreach (IManagedSubscription partition in SnapshotPartitions()) { if (!partition.Created) { return false; } } return true; } } /// public TimeSpan CurrentPublishingInterval => Primary.CurrentPublishingInterval; /// public byte CurrentPriority => Primary.CurrentPriority; /// public uint CurrentLifetimeCount => Primary.CurrentLifetimeCount; /// public uint CurrentKeepAliveCount => Primary.CurrentKeepAliveCount; /// public bool CurrentPublishingEnabled => Primary.CurrentPublishingEnabled; /// public uint CurrentMaxNotificationsPerPublish => Primary.CurrentMaxNotificationsPerPublish; /// public IMonitoredItemCollection MonitoredItems => m_monitoredItems; /// public long MissingMessageCount { get { long total = 0; foreach (IManagedSubscription partition in SnapshotPartitions()) { total += partition.MissingMessageCount; } return total; } } /// public long RepublishMessageCount { get { long total = 0; foreach (IManagedSubscription partition in SnapshotPartitions()) { total += partition.RepublishMessageCount; } return total; } } /// /// Server-assigned subscription id of the primary partition. /// Mirrors on the primary; /// remains stable for the lifetime of the logical /// subscription so callers that log or correlate by id see a /// consistent value even if secondary partitions are added /// or removed. /// public uint ServerId => Primary.Id; /// public int PartitionCount { get { lock (m_partitionLock) { return m_partitions.Count; } } } /// public IReadOnlyList PartitionIds { get { lock (m_partitionLock) { // Honour the IPartitionedSubscription contract: // the list is empty until the primary partition // has been created on the server. We use the // primary's Created flag rather than calling // this.Created to keep this getter // self-contained and to avoid the snapshot copy // the Created-aggregate path makes. if (!m_partitions[0].Created) { return []; } if (m_partitions.Count == 1) { return [m_partitions[0].Id]; } var ids = new uint[m_partitions.Count]; for (int i = 0; i < m_partitions.Count; i++) { ids[i] = m_partitions[i].Id; } return ids; } } } /// public IReadOnlyList Partitions => // Defensive snapshot — never hand out the live list so // callers cannot observe a torn read while a new // partition is being added under m_partitionLock. SnapshotPartitions(); /// public async ValueTask ConditionRefreshAsync(CancellationToken ct = default) { // Fan ConditionRefresh out to every partition so the // logical subscription's view of conditions is fully // refreshed even when monitored items are split across // partitions. Per OPC UA Part 9 §5.5.7 the call is // per-subscription, so each partition must issue its own // ConditionRefresh request. foreach (IManagedSubscription partition in SnapshotPartitions()) { await partition.ConditionRefreshAsync(ct).ConfigureAwait(false); } } /// public async ValueTask SetAsDurableAsync( TimeSpan lifetime, CancellationToken ct = default) { // Persist the intent so on-demand secondary partitions // created later inherit the same durable lifetime via the // partition state-machine's OnAfterCreateAsync hook, // which guarantees the OPC UA Part 4 §5.13.9 ordering // requirement (SetSubscriptionDurable must precede any // CreateMonitoredItems). m_durableLifetime = lifetime; TimeSpan minRevised = TimeSpan.MaxValue; foreach (IManagedSubscription partition in SnapshotPartitions()) { // For partitions that are already Created, apply the // durable call inline so the caller's await returns // only once the server has accepted (or rejected) it. // For partitions that are still racing through // Create (or that get re-created on reconnect), wire // the OnAfterCreateAsync hook so the state machine // applies SetSubscriptionDurable between the // CreateSubscription response and the first // CreateMonitoredItems request. InstallDurableHook(partition, lifetime); if (partition.Created) { TimeSpan revised = await partition.SetAsDurableAsync( lifetime, ct).ConfigureAwait(false); if (revised < minRevised) { minRevised = revised; } } } return minRevised == TimeSpan.MaxValue ? lifetime : minRevised; } /// public async ValueTask RecreateAsync(CancellationToken ct = default) { foreach (IManagedSubscription partition in SnapshotPartitions()) { await partition.RecreateAsync(ct).ConfigureAwait(false); } } /// public void NotifySubscriptionManagerPaused(bool paused) { foreach (IManagedSubscription partition in SnapshotPartitions()) { partition.NotifySubscriptionManagerPaused(paused); } } /// /// Capture an immutable snapshot of every partition that /// backs this logical subscription. The returned list is /// ordered primary-first (index 0) followed by /// secondary partitions in mint order; every snapshot is /// stamped with the wrapper's stable LogicalGroupId /// and its PartitionIndex so a later /// LoadAsync can regroup them into one /// via /// SubscriptionManager.RestoreGroupAsync. /// /// /// Single-partition wrappers return a one-element list whose /// element matches . /// public IReadOnlyList SnapshotAllPartitions() { IReadOnlyList partitions = SnapshotPartitions(); string groupId = GetOrCreateLogicalGroupId(); var result = new List(partitions.Count); for (int i = 0; i < partitions.Count; i++) { if (partitions[i] is Subscription concrete) { SubscriptionStateSnapshot snap = concrete.Snapshot(); result.Add(snap with { LogicalGroupId = groupId, PartitionIndex = i }); } } return result; } /// /// Capture an immutable snapshot of this logical subscription's /// primary partition, including its configuration, server-side /// identifier, and the per-item state. The returned snapshot /// is stamped with the wrapper's LogicalGroupId and /// PartitionIndex=0 so it round-trips through a /// group-aware LoadAsync as a single-partition logical /// subscription. Use when /// you need every partition's state. /// public SubscriptionStateSnapshot Snapshot() { if (Primary is Subscription concrete) { SubscriptionStateSnapshot snap = concrete.Snapshot(); return snap with { LogicalGroupId = GetOrCreateLogicalGroupId(), PartitionIndex = 0 }; } throw new InvalidOperationException( "Primary partition does not support snapshots."); } /// /// Stable LogicalGroupId assigned on first request and /// cached for the wrapper's lifetime. The id is a fresh GUID /// so a snapshot taken from one logical subscription cannot /// collide with a snapshot taken from another wrapper inside /// the same session (or any other session). Lazy generation /// avoids handing out an id when the caller never asks for /// snapshots. /// private string GetOrCreateLogicalGroupId() { string? existing = m_logicalGroupId; if (existing != null) { return existing; } string fresh = Guid.NewGuid().ToString("N"); string? prior = Interlocked.CompareExchange( ref m_logicalGroupId, fresh, null); return prior ?? fresh; } /// /// Configure triggering relationships between monitored items /// in this logical subscription. Per OPC UA Part 4 §5.13.5 /// the OPC UA SetTriggering service is scoped to a single /// server-side subscription. When the logical subscription /// spans multiple partitions, this method rejects calls that /// would cross a partition boundary — every linked item must /// share the same partition as the triggering item. Callers /// can guarantee co-location by pinning items via /// . /// /// When /// or any entry of /// / /// is null. /// When the triggering /// item or any of the linked items is not part of this /// subscription, or when items resolve to different /// partitions. public ValueTask SetTriggeringAsync( IMonitoredItem triggeringItem, IReadOnlyCollection? linksToAdd = null, IReadOnlyCollection? linksToRemove = null, CancellationToken ct = default) { if (triggeringItem == null) { throw new ArgumentNullException(nameof(triggeringItem)); } // Resolve the triggering item's owning partition. If we // only have one partition (the common case) the answer is // trivially the primary and we can skip the per-link // boundary check. IReadOnlyList snapshot = SnapshotPartitions(); IManagedSubscription owner = ResolveOwningPartition( triggeringItem, snapshot) ?? throw new ArgumentException( "Triggering item is not part of this subscription.", nameof(triggeringItem)); if (snapshot.Count > 1) { EnsureSamePartition(owner, linksToAdd, snapshot, nameof(linksToAdd)); EnsureSamePartition(owner, linksToRemove, snapshot, nameof(linksToRemove)); } // Delegate to the owning partition's SetTriggeringAsync. // The partition's MonitoredItemManager owns the item // identity (reference) and enforces the spec-correct // Part 4 §5.13.5 semantics (batching, retry while // triggering item not Created, per-link result mapping). return owner.SetTriggeringAsync( triggeringItem, linksToAdd, linksToRemove, ct); } private static IManagedSubscription? ResolveOwningPartition( IMonitoredItem item, IReadOnlyList partitions) { foreach (IManagedSubscription partition in partitions) { if (partition.MonitoredItems .TryGetMonitoredItemByClientHandle( item.ClientHandle, out IMonitoredItem? candidate) && ReferenceEquals(candidate, item)) { return partition; } } return null; } private static void EnsureSamePartition( IManagedSubscription owner, IReadOnlyCollection? items, IReadOnlyList partitions, string paramName) { if (items == null) { return; } foreach (IMonitoredItem item in items) { if (item == null) { throw new ArgumentNullException(paramName, "Triggered item entries must not be null."); } IManagedSubscription? itemPartition = ResolveOwningPartition( item, partitions) ?? throw new ArgumentException( "Monitored item is not part of this subscription.", paramName); if (!ReferenceEquals(itemPartition, owner)) { throw new ArgumentException( "Monitored item is in a different partition than the " + "triggering item. Use MonitoredItemOptions.Affinity to " + "pin triggering and triggered items into the same partition.", paramName); } } } /// /// Append a preloaded partition to this wrapper. Used by /// when /// restoring a multi-partition snapshot group: the primary /// is loaded via the standard restore path and each /// secondary is built externally (with its /// ), then absorbed here /// so the wrapper's partition list, composite-collection /// policy, and durable-hook intent all see the new partition. /// /// Fully-constructed secondary /// partition. Must not already be a member of the wrapper. /// When /// is null. /// When the /// wrapper was constructed in single-partition mode (no /// placement policy / factory). internal void AppendPreloadedPartition(IManagedSubscription partition) { if (partition == null) { throw new ArgumentNullException(nameof(partition)); } lock (m_partitionLock) { if (m_partitions.Contains(partition)) { return; } m_partitions.Add(partition); m_monitoredItems.NotifyPreloadedPartitionAdded(partition); } // Propagate durable intent to the new partition just // like the on-demand factory path would; preloaded // partitions go through the same hook so the // SetSubscriptionDurable ordering rule is satisfied on // recreate. TryApplyDurableToNewPartition(partition); } /// public ValueTask DisposeAsync() { return DisposeCoreAsync(); } private async ValueTask DisposeCoreAsync() { // Stop any armed secondary-partition idle timers first so // they cannot fire against partitions we are tearing // down. m_monitoredItems.DisposeIdleTimers(); // Dispose partitions in reverse-add order; secondary partitions // (added on demand) hold references back to the primary's // notification handler in subsequent milestones, so removing // them first avoids the secondary's dispatch worker observing // a disposed primary handler. IReadOnlyList snapshot = SnapshotPartitions(); for (int i = snapshot.Count - 1; i >= 0; i--) { await snapshot[i].DisposeAsync().ConfigureAwait(false); } // Forwarding handler (if any) outlives the partitions so // their in-flight notification callbacks can run their // semaphore release before the primitive is freed. m_forwardingHandler?.Dispose(); } /// /// Reactive-fallback entry point. Called by a partition's /// hook when /// the server rejects a CreateMonitoredItems request with /// . Marks /// the offending partition no-grow so subsequent placements /// land in a different (or freshly minted) partition. The /// already-failed item itself is surfaced to the caller via /// the standard per-item error path. /// internal void OnPartitionCapReached(IManagedSubscription partition) { if (partition == null) { throw new ArgumentNullException(nameof(partition)); } m_monitoredItems.OnPartitionCapReached(partition); } /// /// Synchronously install the durable-apply hook on a /// freshly-minted secondary partition so its /// callback /// invokes /// between the partition's CreateSubscription /// response and its first CreateMonitoredItems /// request — satisfying OPC UA Part 4 §5.13.9 ordering. When /// no durable intent has been recorded the call is a no-op. /// internal void TryApplyDurableToNewPartition(IManagedSubscription partition) { if (partition == null) { throw new ArgumentNullException(nameof(partition)); } TimeSpan? lifetime = m_durableLifetime; if (lifetime == null) { return; } InstallDurableHook(partition, lifetime.Value); } /// /// Wire the partition's /// hook to /// invoke . The /// hook fires once per partition lifetime — the state /// machine clears it after the first Create — so the wrapper /// reinstalls it on every /// call to cover reconnect / recreate cycles. /// private static void InstallDurableHook( IManagedSubscription partition, TimeSpan lifetime) { if (partition is Subscription concrete) { concrete.OnAfterCreateAsync = async ct => // Discard the server-revised lifetime; the // wrapper caches its own intent and reports the // min revised lifetime from the synchronous Apply // path. Hook failures are caught by the state // machine and logged. _ = await partition.SetAsDurableAsync(lifetime, ct) .ConfigureAwait(false); } } /// /// Stable snapshot of the partition list under /// . Used by every iterator on /// the wrapper so an in-flight partition append from a /// concurrent /// cannot tear the read. /// private IReadOnlyList SnapshotPartitions() { lock (m_partitionLock) { if (m_partitions.Count == 1) { return [m_partitions[0]]; } return [.. m_partitions]; } } /// /// Append-partition hook handed to the composite collection's /// factory. Wraps the caller-supplied factory so the wrapper's /// partition lock guards the actual List<T>.Add — without /// the wrap a concurrent /// reader could see a half-mutated list. /// private IManagedSubscription AppendPartition( Func innerFactory) { IManagedSubscription added = innerFactory(); // The actual List.Add is performed by the composite under // the same m_partitionLock; this helper exists so future // milestones can hook side effects (notification handler // attach, durable apply, etc.) on append without changing // the composite contract. return added; } /// /// Primary partition that backs this logical subscription. /// Exposed to test assemblies (via InternalsVisibleTo) /// so partition-internal API such as /// LastSequenceNumberProcessed remains reachable /// through the wrapper for legacy assertions; production /// callers should use the public /// or surface. /// internal IManagedSubscription Primary => m_partitions[0]; private readonly List m_partitions; private readonly object m_partitionLock; private readonly CompositeMonitoredItemCollection m_monitoredItems; private PartitionForwardingHandler? m_forwardingHandler; private TimeSpan? m_durableLifetime; private string? m_logicalGroupId; } }