/* ======================================================================== * 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.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Opc.Ua.Client.Subscriptions.MonitoredItems; namespace Opc.Ua.Client.Subscriptions { /// /// Manages all subscriptions of a session on the server side. This /// includes managing the publish requests and acknowledgements. /// The publish requests are managed by a controller that adjusts /// the number of workers based on the number of subscriptions. /// The workers are responsible for sending the publish requests /// to the server and dispatching the notifications to the /// subscriptions. The subscriptions queue acknowledgements for /// the completed notifications as soon as they are dispatched. /// The queued acknowledgements are then sent by the workers the /// next publish cycle. /// internal sealed class SubscriptionManager : ISubscriptionManager, IMessageAckQueue, IAsyncDisposable { /// /// Create subscription manager /// /// /// /// /// Optional /// used for elapsed-time and timer calculations. Defaults to /// when null. public SubscriptionManager(ISubscriptionManagerContext session, ILoggerFactory loggerFactory, DiagnosticsMasks returnDiagnostics, TimeProvider? timeProvider = null) { m_timeProvider = timeProvider ?? TimeProvider.System; m_session = session; m_loggerFactory = loggerFactory; m_logger = loggerFactory.CreateLogger(); ReturnDiagnostics = returnDiagnostics; m_publishController = PublishControllerAsync(m_cts.Token); m_acks = Channel.CreateUnboundedPrioritized( new UnboundedPrioritizedChannelOptions { Comparer = Comparer .Create((x, y) => x.SequenceNumber.CompareTo(y.SequenceNumber)) }); } /// /// If the subscriptions are transferred when a session is /// recreated. /// /// /// Default false, set to true if subscriptions /// should be transferred after recreating the session. /// Service must be supported by server. /// public bool TransferSubscriptionsOnRecreate { get; set; } /// public bool PoolNotifications { get; set; } /// public DiagnosticsMasks ReturnDiagnostics { get; set; } /// /// /// Setting this property signals the publish controller so the worker /// pool resizes promptly — without the signal, the controller stays /// parked on and the change does not /// take effect until some other event (subscription add/remove, /// pause/resume, worker exit) wakes it. /// public int MinPublishWorkerCount { get; set { if (field == value) { return; } field = value; m_publishControl.Set(); } } = 2; /// /// /// Setting this property signals the publish controller so the worker /// pool resizes promptly. See . /// public int MaxPublishWorkerCount { get; set { if (field == value) { return; } field = value; m_publishControl.Set(); } } = 15; /// public IEnumerable Items { get { lock (m_subscriptionLock) { // Expose logical wrappers — callers see one // ISubscription per logical subscription regardless // of how many partitions back it. return [.. m_logicals]; } } } /// public int Count { get { lock (m_subscriptionLock) { return m_logicals.Count; } } } /// public int GoodPublishRequestCount => m_goodPublishRequestCount; /// public int BadPublishRequestCount => m_badPublishRequestCount; /// public long MissingMessageCount { get { long total = 0; lock (m_subscriptionLock) { foreach (IManagedSubscription s in m_subscriptions) { total += s.MissingMessageCount; } } return total; } } /// public long RepublishMessageCount { get { long total = 0; lock (m_subscriptionLock) { foreach (IManagedSubscription s in m_subscriptions) { total += s.RepublishMessageCount; } } return total; } } /// public int PublishWorkerCount { get; private set; } /// internal int PublishControlCycles { get; set; } /// /// Created items /// internal List Created { get { lock (m_subscriptionLock) { return [.. m_subscriptions.Where(s => s.Created)]; } } } /// /// Number of created items /// public int CreatedCount { get { lock (m_subscriptionLock) { return m_subscriptions.Count(s => s.Created); } } } /// public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref m_disposed, 1) != 0) { return; } try { await m_cts.CancelAsync().ConfigureAwait(false); m_publishControl.Set(); await m_publishController.ConfigureAwait(false); List? logicals; List? orphans; lock (m_subscriptionLock) { logicals = [.. m_logicals]; m_logicals.Clear(); // Drop the dispatch registry entries owned by the // wrappers we are about to dispose so any partition // not currently bound to a wrapper (transient // pre-registration window) can still be cleaned up // below. var ownedByLogicals = new HashSet(); foreach (LogicalSubscription wrapper in logicals) { foreach (IManagedSubscription partition in wrapper.Partitions) { ownedByLogicals.Add(partition); } } orphans = []; foreach (IManagedSubscription partition in m_subscriptions) { if (!ownedByLogicals.Contains(partition)) { orphans.Add(partition); } } m_subscriptions.Clear(); } foreach (LogicalSubscription wrapper in logicals) { await wrapper.DisposeAsync().ConfigureAwait(false); } foreach (IManagedSubscription partition in orphans) { await partition.DisposeAsync().ConfigureAwait(false); } m_subscriptionHistory.Clear(); } catch (Exception ex) { m_logger.LogError(ex, "Exception during dispose"); } finally { m_cts.Dispose(); (m_acks as IDisposable)?.Dispose(); GC.SuppressFinalize(this); } } /// /// Trigger publish controller /// public void Update() { m_publishControl.Set(); } /// public ValueTask QueueAsync( SubscriptionAcknowledgement ack, CancellationToken ct) { return m_acks.Writer.WriteAsync(ack, ct); } /// public int DropPendingForSubscription(uint subscriptionId) { // Drain the prioritised channel into a local buffer, // keeping only the acks that do not target the dead // subscription id, then re-publish the kept items. // Concurrent workers may interleave reads or writes // during this loop; that is intentional — a worker // racing in is acceptable because (a) any ack it reads // would have been sent to the server anyway, (b) any // ack it writes is queued back into the channel. The // contract is "no targeted acks remain when this // method returns" — callers must invoke it BEFORE // recreate assigns a fresh subscription id so a new // generation cannot enter the queue. var keep = new List(); int dropped = 0; while (m_acks.Reader.TryRead(out SubscriptionAcknowledgement? ack)) { if (ack.SubscriptionId == subscriptionId) { dropped++; } else { keep.Add(ack); } } foreach (SubscriptionAcknowledgement ack in keep) { m_acks.Writer.TryWrite(ack); } return dropped; } /// public ValueTask CompleteAsync(uint subscriptionId, CancellationToken ct) { IManagedSubscription? subscription; LogicalSubscription? logical = null; lock (m_subscriptionLock) { // Drop the partition from the dispatch registry. subscription = m_subscriptions .FirstOrDefault(s => s.Id == subscriptionId); if (subscription == null || !m_subscriptions.Remove(subscription)) { return default; } m_subscriptionHistory.Enqueue(subscriptionId); // If the removed partition was the primary of any // logical wrapper, the wrapper has no usable // partitions left — drop it from the public registry // so ISubscriptionManager.Items / Count reflect the // deletion. Secondary partitions (added on demand by // the composite collection) do not remove the // wrapper; the wrapper keeps living as long as the // primary is registered. foreach (LogicalSubscription wrapper in m_logicals) { IReadOnlyList parts = wrapper.Partitions; if (parts.Count > 0 && ReferenceEquals(parts[0], subscription)) { logical = wrapper; break; } } if (logical != null) { m_logicals.Remove(logical); } } while (m_subscriptionHistory.Count > kMaxSubscriptionHistory) { m_subscriptionHistory.TryDequeue(out _); } m_logger.LogInformation("{Subscription} REMOVED.", subscription); m_publishControl.Set(); return default; } /// public ISubscription Add(ISubscriptionNotificationHandler handler, IOptionsMonitor options) { if (handler == null) { throw new ArgumentNullException(nameof(handler)); } if (options == null) { throw new ArgumentNullException(nameof(options)); } SubscriptionOptions snapshot = options.CurrentValue ?? new SubscriptionOptions(); // In multi-partition mode every partition fires its // notification callbacks on the same logical wrapper — // wrap the user handler in a forwarding shim that (a) // serialises calls across partitions and (b) re-targets // the subscription parameter to the wrapper instead of // the partition. ISubscriptionNotificationHandler effectiveHandler = handler; PartitionForwardingHandler? forwardingHandler = null; if (!snapshot.DisableUnboundedItemMode) { #pragma warning disable CA2000 // ownership transfers to wrapper.AttachForwardingHandler forwardingHandler = new PartitionForwardingHandler(handler); #pragma warning restore CA2000 effectiveHandler = forwardingHandler; } IManagedSubscription primary = m_session.CreateSubscription( effectiveHandler, options, this); // Build the placement policy + on-demand partition factory // unless the caller opted out via DisableUnboundedItemMode. // The factory is invoked by the composite collection inside // its lock when no existing partition has capacity; the // factory mints a sibling partition with the same handler // and options, registers it in the dispatch registry, and // wakes the publish controller so a worker can serve it. PartitionPlacementPolicy? policy = null; Func? partitionFactory = null; LogicalSubscription wrapper; if (snapshot.DisableUnboundedItemMode) { wrapper = new LogicalSubscription(primary, null, null); } else { policy = new PartitionPlacementPolicy( snapshot.MaxMonitoredItemsPerPartition ?? 0, snapshot.MaxPartitionCount); // Capture the wrapper into the factory closure so newly // minted secondary partitions can route their reactive- // fallback callbacks back to the same logical. LogicalSubscription? created = null; partitionFactory = () => { IManagedSubscription part = MintPartition(effectiveHandler, options); AttachReactiveFallback(part, created); created?.TryApplyDurableToNewPartition(part); return part; }; wrapper = new LogicalSubscription(primary, policy, partitionFactory, m_timeProvider, NormalizeIdleTimeout(snapshot.SecondaryPartitionIdleTimeout), RemoveSecondaryPartitionAsync); created = wrapper; AttachReactiveFallback(primary, wrapper); forwardingHandler!.BindLogical(wrapper); wrapper.AttachForwardingHandler(forwardingHandler); } lock (m_subscriptionLock) { if (!m_subscriptions.Add(primary)) { throw ServiceResultException.Create(StatusCodes.BadAlreadyExists, "Failed to register primary partition."); } if (!m_logicals.Add(wrapper)) { // Roll the primary back out so we do not leak // a partition with no logical wrapper. m_subscriptions.Remove(primary); throw ServiceResultException.Create(StatusCodes.BadAlreadyExists, "Failed to register logical subscription."); } m_logger.LogInformation("{Subscription} ADDED.", primary); } m_publishControl.Set(); return wrapper; } /// /// Wire a partition's /// hook /// back to the owning logical wrapper so a server /// Bad_TooManyMonitoredItems response marks the /// partition no-grow in the wrapper's placement policy. No-op /// when the partition is not a /// (e.g. a test fake) or when the wrapper has not yet been /// captured by the factory closure. /// private static void AttachReactiveFallback( IManagedSubscription partition, LogicalSubscription? wrapper) { if (wrapper == null) { return; } if (partition is Subscription concrete) { concrete.OnPartitionCapReached = _ => wrapper.OnPartitionCapReached(partition); } } /// /// Normalize a caller-supplied /// /// for the wrapper. Per the option docs, /// means immediate idle-delete /// and /// (the only valid negative value) disables idle-delete. Any /// other negative value is treated as "disabled" defensively /// because rejects them. /// private static TimeSpan NormalizeIdleTimeout(TimeSpan idleTimeout) { return idleTimeout < TimeSpan.Zero ? Timeout.InfiniteTimeSpan : idleTimeout; } /// /// Secondary-partition disposer handed to the wrapper's /// idle-delete timer. Drops the partition from this /// manager's dispatch registry, drains any pending /// acknowledgements addressed at it, and disposes the /// partition (which sends DeleteSubscriptions to the /// server). Logical wrapper book-keeping is performed by the /// composite collection under its own lock before this /// callback runs, so we only need to clean up manager-side /// state here. /// private async ValueTask RemoveSecondaryPartitionAsync(IManagedSubscription partition) { uint partitionId = partition.Id; lock (m_subscriptionLock) { m_subscriptions.Remove(partition); } if (partitionId != 0) { DropPendingForSubscription(partitionId); } try { await partition.DisposeAsync().ConfigureAwait(false); } catch (Exception ex) { m_logger.LogInformation(ex, "Idle-delete of secondary partition {Partition} threw on dispose.", partition); } m_publishControl.Set(); } /// /// Synchronous partition factory handed to the composite /// collection. Constructs a sibling partition with the same /// handler + options as the logical subscription's primary, /// registers it in the dispatch registry, and wakes the /// publish controller so a worker is scheduled for it. /// private IManagedSubscription MintPartition( ISubscriptionNotificationHandler handler, IOptionsMonitor options) { return MintPartitionCore(handler, options, loadState: null); } /// /// Variant of that accepts a /// pre-existing so the /// minted partition is construction-time bound to a /// previously-saved server-side identifier and items list. /// Used by to attach /// secondary partitions during multi-partition snapshot /// restore. /// private IManagedSubscription MintPreloadedPartition( ISubscriptionNotificationHandler handler, IOptionsMonitor options, SubscriptionLoadState loadState) { return MintPartitionCore(handler, options, loadState); } private IManagedSubscription MintPartitionCore( ISubscriptionNotificationHandler handler, IOptionsMonitor options, SubscriptionLoadState? loadState) { IManagedSubscription partition = m_session.CreateSubscription( handler, options, this, loadState); lock (m_subscriptionLock) { if (!m_subscriptions.Add(partition)) { throw ServiceResultException.Create(StatusCodes.BadAlreadyExists, "Failed to register partition."); } if (loadState == null) { m_logger.LogInformation( "{Subscription} ADDED (secondary partition).", partition); } else { m_logger.LogInformation( "{Subscription} ADDED (preloaded secondary partition).", partition); } } m_publishControl.Set(); return partition; } /// /// Restore a single subscription from a snapshot previously /// produced by . The /// returned subscription is registered with the manager via the /// same path as . /// /// Notification handler for the restored /// subscription. /// Snapshot captured earlier on the source /// session. /// /// When true the saved server-side subscription id and /// per-item server ids are preserved and an OPC UA /// TransferSubscriptions service call is issued so the new /// session takes over the existing server-side state. If /// transfer is unavailable (e.g. the server returns /// BadSubscriptionIdInvalid), the restore falls back to /// recreate. /// When false the V2 state machine mints fresh /// server-side ids — equivalent to a fresh /// with the saved configuration. /// /// Cancellation token. /// /// Not on : callers that want /// stream-based restore should use ; /// fluent helpers and the serializer cast to the concrete /// to reach this method. /// /// is null. internal ValueTask RestoreAsync( ISubscriptionNotificationHandler handler, SubscriptionStateSnapshot state, bool transferSubscriptions = false, CancellationToken ct = default) { if (handler == null) { throw new ArgumentNullException(nameof(handler)); } if (state == null) { throw new ArgumentNullException(nameof(state)); } if (transferSubscriptions && state.ServerId != 0) { return RestoreTransferAsync(handler, state, ct); } return RestoreRecreateAsync(handler, state, ct); } private ValueTask RestoreRecreateAsync( ISubscriptionNotificationHandler handler, SubscriptionStateSnapshot state, CancellationToken ct) { ct.ThrowIfCancellationRequested(); ISubscription subscription = Add( handler, new OptionsMonitor(state.ToOptions())); foreach (MonitoredItemStateSnapshot item in state.MonitoredItems) { subscription.MonitoredItems.TryAdd( item.Name, new OptionsMonitor(item.ToOptions()), out _); } return new ValueTask(subscription); } private async ValueTask RestoreTransferAsync( ISubscriptionNotificationHandler handler, SubscriptionStateSnapshot state, CancellationToken ct) { // Build the internal load-state record (decoupled from the // public DTO so the engine has no DTO coupling). The reverse // "items I trigger" set is reconstructed on demand from the // triggering relationships per item. var itemLoadStates = new List( state.MonitoredItems.Count); foreach (MonitoredItemStateSnapshot item in state.MonitoredItems) { IReadOnlyList triggeredBy = item.TriggeredByNames.IsNull ? [] : item.TriggeredByNames.ToArray() ?? []; itemLoadStates.Add(new MonitoredItemLoadState( item.Name, new OptionsMonitor(item.ToOptions()), item.ClientHandle, item.ServerId, triggeredBy)); } var loadState = new SubscriptionLoadState( state.ServerId, itemLoadStates); SubscriptionOptions options = state.ToOptions(); var optionsMonitor = new OptionsMonitor(options); // Match the Add() path: wrap the user handler in a // forwarding shim when running in multi-partition mode so // post-restore growth keeps the serialised-dispatch + // logical-subscription-parameter contract. ISubscriptionNotificationHandler effectiveHandler = handler; PartitionForwardingHandler? forwardingHandler = null; if (!options.DisableUnboundedItemMode) { #pragma warning disable CA2000 // ownership transfers to wrapper.AttachForwardingHandler forwardingHandler = new PartitionForwardingHandler(handler); #pragma warning restore CA2000 effectiveHandler = forwardingHandler; } IManagedSubscription subscription = m_session.CreateSubscription( effectiveHandler, optionsMonitor, this, loadState); // Wrap in a logical subscription so callers receive the // same surface as a freshly added subscription. Pick the // same placement policy + factory the standard Add path // would use so post-restore growth obeys the configured // limits. PartitionPlacementPolicy? policy = null; Func? partitionFactory = null; LogicalSubscription wrapper; if (options.DisableUnboundedItemMode) { wrapper = new LogicalSubscription(subscription, null, null); } else { policy = new PartitionPlacementPolicy( options.MaxMonitoredItemsPerPartition ?? 0, options.MaxPartitionCount); LogicalSubscription? created = null; partitionFactory = () => { IManagedSubscription part = MintPartition(effectiveHandler, optionsMonitor); AttachReactiveFallback(part, created); created?.TryApplyDurableToNewPartition(part); return part; }; wrapper = new LogicalSubscription(subscription, policy, partitionFactory, m_timeProvider, NormalizeIdleTimeout(options.SecondaryPartitionIdleTimeout), RemoveSecondaryPartitionAsync); created = wrapper; AttachReactiveFallback(subscription, wrapper); forwardingHandler!.BindLogical(wrapper); wrapper.AttachForwardingHandler(forwardingHandler); } lock (m_subscriptionLock) { if (!m_subscriptions.Add(subscription)) { throw ServiceResultException.Create(StatusCodes.BadAlreadyExists, "Failed to add restored subscription."); } if (!m_logicals.Add(wrapper)) { m_subscriptions.Remove(subscription); throw ServiceResultException.Create(StatusCodes.BadAlreadyExists, "Failed to register restored logical subscription."); } m_logger.LogInformation( "{Subscription} ADDED (transfer-pending, ServerId={ServerId}).", subscription, state.ServerId); } // Issue TransferSubscriptions for the saved server id. // sendInitialValues honors SubscriptionOptions.SendInitialValuesOnTransfer // (default false) — the snapshot captured the last server- // emitted values, so requesting initial values is only // useful when the caller wants the server to re-emit them // to a fresh notification handler. uint[] ids = [state.ServerId]; TransferSubscriptionsResponse response = await m_session .TransferSubscriptionsAsync( null, ids.ToArrayOf(), sendInitialValues: options.SendInitialValuesOnTransfer, ct) .ConfigureAwait(false); bool transferred = false; ResponseHeader responseHeader = response.ResponseHeader; if (StatusCode.IsGood(responseHeader.ServiceResult)) { ArrayOf results = response.Results; ClientBase.ValidateResponse(results, ids.ToArrayOf()); if (results.Count > 0 && StatusCode.IsGood(results[0].StatusCode)) { transferred = await subscription.TryCompleteTransferAsync( results[0].AvailableSequenceNumbers.IsNull ? [] : [.. results[0].AvailableSequenceNumbers], ct).ConfigureAwait(false); } else if (results.Count > 0) { m_logger.LogWarning( "{Subscription}: TransferSubscriptions per-item " + "result Bad ({Status}); falling back to recreate.", subscription, results[0].StatusCode); } } else if (responseHeader.ServiceResult == StatusCodes.BadServiceUnsupported) { m_logger.LogWarning( "{Subscription}: server does not support " + "TransferSubscriptions; falling back to recreate.", subscription); } else { m_logger.LogWarning( "{Subscription}: TransferSubscriptions service-level " + "result Bad ({Status}); falling back to recreate.", subscription, responseHeader.ServiceResult); } if (!transferred && subscription is Subscription loaded) { await loaded.ResetToRecreateAsync(ct).ConfigureAwait(false); // Await re-creation under a bounded timeout so LoadAsync // only returns after the server has assigned a fresh // SubscriptionId. Items still need ApplyChangesAsync to // round-trip but that runs in the same state-manager // iteration as CreateAsync. using CancellationTokenSource timeoutCts = m_timeProvider .CreateCancellationTokenSource(TimeSpan.FromSeconds(15)); using var linkedCts = CancellationTokenSource .CreateLinkedTokenSource(ct, timeoutCts.Token); try { await loaded.WaitForCreatedAsync(linkedCts.Token) .ConfigureAwait(false); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { m_logger.LogWarning( "{Subscription}: re-creation did not complete " + "within 15s after failed transfer; returning anyway.", subscription); } } m_publishControl.Set(); return wrapper; } /// /// Restore a logical subscription whose partitions span more /// than one snapshot record (multi-partition snapshot /// produced by ). /// The primary snapshot (PartitionIndex == 0) is /// restored via the existing single-snapshot path; each /// secondary snapshot is then attached to the same wrapper /// as an additional partition with its server-side /// identifier preserved (transfer) or recreated (recreate). /// /// /// The caller is expected to pre-sort /// by PartitionIndex and /// to have validated the grouping (contiguous indexes, /// exactly one primary, shared LogicalGroupId) — see /// /// which performs the validation before dispatching here. /// internal async ValueTask RestoreGroupAsync( ISubscriptionNotificationHandler handler, IReadOnlyList snapshots, bool transferSubscriptions, CancellationToken ct) { if (handler == null) { throw new ArgumentNullException(nameof(handler)); } if (snapshots == null || snapshots.Count == 0) { throw new ArgumentException( "Group must contain at least the primary snapshot.", nameof(snapshots)); } // Primary first — single-partition restore returns the // wrapper that owns all subsequent secondaries. ISubscription primaryWrapper = await RestoreAsync( handler, snapshots[0], transferSubscriptions, ct) .ConfigureAwait(false); if (snapshots.Count == 1) { return primaryWrapper; } if (primaryWrapper is not LogicalSubscription wrapper) { throw new InvalidOperationException( "Restore returned a subscription that does not " + "support multi-partition grouping."); } // Reconstruct the options the secondaries share with // the primary. The primary's wrapper was built from // snapshots[0] so any field accessible via ToOptions() // is valid for every partition in the group. SubscriptionOptions options = snapshots[0].ToOptions(); var optionsMonitor = new OptionsMonitor(options); for (int i = 1; i < snapshots.Count; i++) { ct.ThrowIfCancellationRequested(); await AttachSecondaryPartitionAsync( wrapper, handler, optionsMonitor, snapshots[i], transferSubscriptions, ct) .ConfigureAwait(false); } return wrapper; } private async ValueTask AttachSecondaryPartitionAsync( LogicalSubscription wrapper, ISubscriptionNotificationHandler handler, OptionsMonitor optionsMonitor, SubscriptionStateSnapshot snap, bool transferSubscriptions, CancellationToken ct) { // Build the per-partition load state from the snapshot. var itemLoadStates = new List( snap.MonitoredItems.Count); foreach (MonitoredItemStateSnapshot item in snap.MonitoredItems) { itemLoadStates.Add(new MonitoredItemLoadState( item.Name, new OptionsMonitor(item.ToOptions()), item.ClientHandle, item.ServerId, item.TriggeredByNames.IsNull ? [] : item.TriggeredByNames.ToArray() ?? [])); } var loadState = new SubscriptionLoadState(snap.ServerId, itemLoadStates); // Mint the partition via the preloaded-variant factory so // it is wired into the dispatch registry from the moment // it is constructed. Route the partition's notification // callbacks through the wrapper's forwarding handler so // restored secondaries observe the same serialization and // logical-subscription identity contract that fresh-Add // secondaries get; without this the raw user handler is // invoked concurrently from multiple partitions on the // restore path, breaking the single-threaded dispatch // guarantee callers rely on. ISubscriptionNotificationHandler effective = wrapper.ForwardingHandler ?? handler; IManagedSubscription partition = MintPreloadedPartition( effective, optionsMonitor, loadState); AttachReactiveFallback(partition, wrapper); // Append to the wrapper's partition list so the composite // collection's policy + index account for it. wrapper.AppendPreloadedPartition(partition); // Best-effort transfer: when the saved ServerId is // non-zero AND the caller asked for transfer, issue // TransferSubscriptions; on failure fall back to recreate // via the partition's own state machine. if (transferSubscriptions && snap.ServerId != 0 && partition is Subscription concrete) { bool transferred = false; try { TransferSubscriptionsResponse response = await m_session.TransferSubscriptionsAsync( null, new uint[] { snap.ServerId }.ToArrayOf(), sendInitialValues: optionsMonitor.CurrentValue.SendInitialValuesOnTransfer, ct).ConfigureAwait(false); if (StatusCode.IsGood(response.ResponseHeader.ServiceResult) && response.Results.Count > 0 && StatusCode.IsGood(response.Results[0].StatusCode)) { transferred = await concrete.TryCompleteTransferAsync( response.Results[0].AvailableSequenceNumbers.IsNull ? [] : [.. response.Results[0].AvailableSequenceNumbers], ct).ConfigureAwait(false); } } catch (Exception ex) { m_logger.LogWarning(ex, "{Subscription}: TransferSubscriptions threw on " + "secondary partition restore; falling back to recreate.", partition); } if (!transferred) { await concrete.ResetToRecreateAsync(ct).ConfigureAwait(false); } } } /// public ValueTask SaveAsync( System.IO.Stream stream, IServiceMessageContext messageContext, IEnumerable? subscriptions = null, CancellationToken ct = default) { return SubscriptionManagerSerializer.SaveAsync( this, stream, messageContext, subscriptions, ct); } /// public ValueTask> LoadAsync( System.IO.Stream stream, IServiceMessageContext messageContext, Func handlerFactory, bool transferSubscriptions = false, CancellationToken ct = default) { return SubscriptionManagerSerializer.LoadAsync( this, stream, messageContext, handlerFactory, transferSubscriptions, ct); } /// /// Resume subscriptions /// internal void Resume() { lock (m_subscriptionLock) { foreach (IManagedSubscription item in m_subscriptions) { item.NotifySubscriptionManagerPaused(false); } } m_running.Set(); } /// /// Pause subscriptions /// internal void Pause() { lock (m_subscriptionLock) { foreach (IManagedSubscription item in m_subscriptions) { item.NotifySubscriptionManagerPaused(true); } } m_running.Reset(); } /// /// Wait for all in-flight publish requests to complete or be /// cancelled. Used together with by callers /// that need a hard quiesce (e.g. a session re-create) before /// rebinding session state. alone is a /// soft signal — workers complete their current cycle before /// observing the pause. /// /// Cancellation token. internal Task DrainAsync(CancellationToken ct) { if (Volatile.Read(ref m_activePublishRequests) == 0) { return Task.CompletedTask; } return m_drainSignal.WaitAsync(ct); } /// /// Recreate subscriptions /// /// /// /// internal async Task RecreateSubscriptionsAsync( NodeId? previousSessionId, CancellationToken ct) { if (Count == 0) { // Nothing to do return; } IReadOnlyList subscriptions = [.. m_subscriptions]; if (TransferSubscriptionsOnRecreate && previousSessionId != null) { subscriptions = await TransferSubscriptionsAsync(subscriptions, false, ct).ConfigureAwait(false); } // Force creation of the subscriptions which were not transferred. foreach (IManagedSubscription subscription in subscriptions) { bool force = previousSessionId != null && subscription.Created; await subscription.RecreateAsync(ct).ConfigureAwait(false); } m_publishControl.Set(); // Helper to try and transfer the subscriptions async Task> TransferSubscriptionsAsync( IReadOnlyList subscriptions, bool sendInitialValues, CancellationToken ct) { var remaining = subscriptions.Where(s => !s.Created).ToList(); subscriptions = [.. subscriptions.Where(s => s.Created)]; if (subscriptions.Count == 0) { return remaining; } var subscriptionIds = subscriptions.Select(s => s.Id).ToArrayOf(); TransferSubscriptionsResponse response = await m_session.TransferSubscriptionsAsync( null, subscriptionIds, sendInitialValues, ct).ConfigureAwait(false); ResponseHeader responseHeader = response.ResponseHeader; if (!StatusCode.IsGood(responseHeader.ServiceResult)) { if (responseHeader.ServiceResult == StatusCodes.BadServiceUnsupported) { TransferSubscriptionsOnRecreate = false; m_logger.LogWarning("Transfer subscription unsupported, " + "TransferSubscriptionsOnReconnect set to false."); } else { m_logger.LogError( "Transfer subscriptions failed with error {Error}.", responseHeader.ServiceResult); } remaining.AddRange(subscriptions); return remaining; } ArrayOf transferResults = response.Results; ArrayOf diagnosticInfos = response.DiagnosticInfos; ClientBase.ValidateResponse(transferResults, subscriptionIds); ClientBase.ValidateDiagnosticInfos(diagnosticInfos, subscriptionIds); for (int index = 0; index < subscriptions.Count; index++) { if (transferResults[index].StatusCode == StatusCodes.BadNothingToDo) { m_logger.LogDebug( "Subscription {Id} is already member of the session.", subscriptionIds[index]); // Done } else if (!StatusCode.IsGood(transferResults[index].StatusCode)) { m_logger.LogError( "Subscription {Id} failed to transfer, StatusCode={Status}", subscriptionIds[index], transferResults[index].StatusCode); remaining.Add(subscriptions[index]); } else { bool success = await subscriptions[index].TryCompleteTransferAsync( transferResults[index].AvailableSequenceNumbers.ToList(), ct).ConfigureAwait(false); if (success) { continue; } // // Recreate as we cannot sync the subscription. This happens // when the GetMonitoredItems call fails and we cannot synchronize // the subscription state // remaining.Add(subscriptions[index]); } } return remaining; } } /// /// Get subscription with the specified id /// /// /// private IManagedSubscription? GetById(uint subscriptionId) { lock (m_subscriptionLock) { // find the subscription. foreach (IManagedSubscription subscription in m_subscriptions) { if (subscription.Id == subscriptionId) { return subscription; } } } return null; } /// /// Controls the publish workers. /// /// /// private async Task PublishControllerAsync(CancellationToken ct) { var publishWorkers = new List(); try { while (!ct.IsCancellationRequested) { int desiredWorkerCount = GetDesiredPublishWorkerCount(); if (publishWorkers.Count > desiredWorkerCount) { // Too many workers, reduce #if NET8_0_OR_GREATER foreach (PublishWorker worker in publishWorkers[desiredWorkerCount..]) #else foreach (PublishWorker worker in publishWorkers.Skip(desiredWorkerCount)) #endif { m_logger.LogInformation("Removing publish worker {Index}", worker.Index); await worker.DisposeAsync().ConfigureAwait(false); } #if NET8_0_OR_GREATER publishWorkers = publishWorkers[..desiredWorkerCount]; #else publishWorkers = [.. publishWorkers.Take(desiredWorkerCount)]; #endif } else if (desiredWorkerCount > publishWorkers.Count) { // Not enough workers increase publishWorkers.AddRange(Enumerable .Range(publishWorkers.Count, desiredWorkerCount - publishWorkers.Count) .Select(index => new PublishWorker(this, index))); } PublishWorkerCount = publishWorkers.Count; Task[] waiting = [.. publishWorkers .Select(w => w.Task) .Prepend(m_publishControl.WaitAsync(ct))]; await Task.WhenAny(waiting).ConfigureAwait(false); PublishControlCycles++; int index = 0; foreach (Task? item in waiting.Skip(1)) // Skip wait handle { if (item.IsCompleted) { PublishWorker worker = publishWorkers[index]; m_logger.LogInformation( "Publish worker {Index} exited", worker.Index); await worker.DisposeAsync().ConfigureAwait(false); publishWorkers.RemoveAt(index); continue; } index++; } // Now lower the max publish request if we got any too // many requests errors if (publishWorkers.Any(w => w.TooManyPublishRequests)) { if (MaxPublishWorkerCount > 1) { MaxPublishWorkerCount--; } } PublishWorkerCount = publishWorkers.Count; } } catch (OperationCanceledException) { } finally { // Controller exits, clean up all workers foreach (PublishWorker worker in publishWorkers) { await worker.DisposeAsync().ConfigureAwait(false); PublishWorkerCount--; } } int GetDesiredPublishWorkerCount() { int publishCount = CreatedCount; if (publishCount != 0) { // // Limit resulting to a number between min and max // request count. If max is below min, we honor the // min publish request count. // if (publishCount > MaxPublishWorkerCount) { publishCount = MaxPublishWorkerCount; } if (publishCount < MinPublishWorkerCount) { publishCount = MinPublishWorkerCount; } if (publishCount <= 0) { publishCount = 1; } } return publishCount; } } /// /// Worker object that manages the publish worker tasks inside /// the controller. /// private sealed class PublishWorker : IAsyncDisposable { /// /// Worker id /// public int Index { get; } /// /// Task to wait until worker exits /// public Task Task { get; } /// /// Signal too many publish requests running /// public bool TooManyPublishRequests { get; private set; } /// /// Create worker /// /// /// public PublishWorker(SubscriptionManager outer, int index) { Index = index; m_outer = outer; m_cts = CancellationTokenSource.CreateLinkedTokenSource(outer.m_cts.Token); m_logger = m_outer.m_loggerFactory.CreateLogger(); Task = PublishWorkerAsync(m_cts.Token); } /// public async ValueTask DisposeAsync() { try { await m_cts.CancelAsync().ConfigureAwait(false); if (!Task.IsCompleted) { try { await Task.ConfigureAwait(false); } catch { } // Ignore } } finally { m_cts.Dispose(); GC.SuppressFinalize(this); } } /// /// Represents a continously running publish forwarder that forwards /// publish responses to the subscriptions contained in this session. /// The publish worker tasks have a controller that reduces or /// increases the number of workers as new subscriptions are added /// or subscriptions are removed. Once the message has been delivered /// to the subscription, the subscription will queue acknowledges /// to the worker which it will send. /// /// private async Task PublishWorkerAsync(CancellationToken ct) { long publishLatencyStart = m_outer.m_timeProvider.GetTimestamp(); TimeSpan publishLatency = TimeSpan.Zero; bool publishLatencyRunning = false; uint timeoutHint = 0u; bool moreNotifications = true; // Dont wait first time we enter the loop. m_logger.LogInformation("PUBLISH Worker #{Handle} - STARTED.", Index); while (!ct.IsCancellationRequested) { if (!m_outer.m_running.IsSet) { m_logger.LogInformation("PUBLISH Worker #{Handle} - PAUSED.", Index); try { await m_outer.m_running.WaitAsync(ct).ConfigureAwait(false); m_logger.LogInformation( "PUBLISH Worker #{Handle} - RESUMED.", Index); } catch (OperationCanceledException) { continue; } catch (Exception ex) { m_logger.LogError( ex, "PUBLISH Worker #{Handle} - Unexpected exception while waiting to connect.", Index); break; } } long currentLatencyMs = publishLatencyRunning ? (long)m_outer.m_timeProvider.GetElapsedTime(publishLatencyStart).TotalMilliseconds : (long)publishLatency.TotalMilliseconds; int ackWaitTimeout = CalculateTimeouts( currentLatencyMs, ref timeoutHint); ArrayOf acks = GetAcksReadyToSend(); uint handle = Utils.IncrementIdentifier(ref m_outer.m_publishRequestCounter); try { if (acks.Count == 0 && !moreNotifications && ackWaitTimeout != 0) { // Throttle publishing as we wait for acks to arrive acks = await WaitForAcksAsync(ackWaitTimeout, ct).ConfigureAwait(false); } publishLatencyStart = m_outer.m_timeProvider.GetTimestamp(); publishLatencyRunning = true; if (Interlocked.Increment(ref m_outer.m_activePublishRequests) == 1) { m_outer.m_drainSignal.Reset(); } try { PublishResponse response = await m_outer.m_session.PublishAsync(new RequestHeader { TimeoutHint = timeoutHint, ReturnDiagnostics = (uint)(int)m_outer.ReturnDiagnostics, RequestHandle = handle }, acks, ct).ConfigureAwait(false); moreNotifications = response.MoreNotifications; uint subscriptionId = response.SubscriptionId; NotificationMessage notificationMessage = response.NotificationMessage; ArrayOf availableSequenceNumbers = response.AvailableSequenceNumbers; ArrayOf acknowledgeResults = response.Results; ArrayOf acknowledgeDiagnosticInfos = response.DiagnosticInfos; ClientBase.ValidateResponse(acknowledgeResults, acks); ClientBase.ValidateDiagnosticInfos(acknowledgeDiagnosticInfos, acks); TooManyPublishRequests = false; // Get the subscription with the provided identifier IManagedSubscription? subscription = m_outer.GetById(subscriptionId); publishLatency = m_outer.m_timeProvider.GetElapsedTime(publishLatencyStart); publishLatencyRunning = false; if (subscription != null) { // deliver to subscription await subscription.OnPublishReceivedAsync( notificationMessage, availableSequenceNumbers.ToList(), response.ResponseHeader.StringTable.ToList()).ConfigureAwait(false); Interlocked.Increment(ref m_outer.m_goodPublishRequestCount); } else if (!ct.IsCancellationRequested && !m_outer.m_subscriptionHistory.Contains(subscriptionId)) { // ignore messages with a subscription that was deleted // Do not delete publish requests of stale subscriptions m_logger.LogInformation( "PUBLISH Worker #{Handle}-{Id} - Received Publish Response " + "for Unknown SubscriptionId={SubscriptionId}. Deleting...", Index, handle, subscriptionId); Interlocked.Increment(ref m_outer.m_badPublishRequestCount); await m_outer.m_session.DeleteSubscriptionsAsync( null, [subscriptionId], ct).ConfigureAwait(false); moreNotifications = true; } } finally { int active = Interlocked.Decrement(ref m_outer.m_activePublishRequests); if (active == 0) { m_outer.m_drainSignal.Set(); } } } catch (OperationCanceledException) { break; } catch (Exception e) { // raise an error event. publishLatency = m_outer.m_timeProvider.GetElapsedTime(publishLatencyStart); publishLatencyRunning = false; var error = new ServiceResult(e); if (error.Code == StatusCodes.BadRequestInterrupted && ct.IsCancellationRequested) { break; } Interlocked.Increment(ref m_outer.m_badPublishRequestCount); // Rollback acks we collected acks.ForEach(ack => m_outer.m_acks.Writer.TryWrite(ack)); // ignore errors if paused. if (!m_outer.m_running.IsSet) { m_logger.LogWarning("PUBLISH Worker #{Handle}-{Id} - Publish " + "abandoned after error due to reconnect: {Message}", Index, handle, e.Message); continue; } // don't send another publish for these errors, // or throttle to avoid server overload. StatusCode statusCode = error.StatusCode; if (statusCode == StatusCodes.BadTooManyPublishRequests) { TooManyPublishRequests = true; } else if (statusCode == StatusCodes.BadNoSubscription || statusCode == StatusCodes.BadSessionClosed || statusCode == StatusCodes.BadSecurityChecksFailed || statusCode == StatusCodes.BadCertificateInvalid || statusCode == StatusCodes.BadServerHalted) { // ignore } // may require a reconnect or activate to recover else if (statusCode == StatusCodes.BadSessionIdInvalid || statusCode == StatusCodes.BadSecureChannelIdInvalid || statusCode == StatusCodes.BadSecureChannelClosed) { // TODO // OnKeepAliveError(error); } // Servers may return this error when overloaded else if (statusCode == StatusCodes.BadTooManyOperations || statusCode == StatusCodes.BadTcpServerTooBusy || statusCode == StatusCodes.BadServerTooBusy) { // throttle the next publish to reduce server load m_logger.LogDebug("PUBLISH Worker #{Handle}-{Id} - " + "Server busy, throttling worker.", Index, handle); moreNotifications = false; // throttle } else if (statusCode == StatusCodes.BadTimeout || statusCode == StatusCodes.BadRequestTimeout) { // Timed out - retry with larger timeout timeoutHint += 1000; // Increase by seconds m_logger.LogDebug("PUBLISH Worker #{Handle}-{Id} - " + "Timed out, increasing timeout to {Timeout}.", Index, handle, timeoutHint); moreNotifications = true; } else { m_logger.LogError(e, "PUBLISH Worker #{Handle}-{Id} - " + "Unhandled error {Status} during Publish.", Index, handle, error.StatusCode); } } } m_logger.LogInformation("PUBLISH Worker #{Handle} - STOPPED.", Index); } /// /// Wait until acks arrive and return them /// /// /// /// private async Task> WaitForAcksAsync( int maxWaitTime, CancellationToken ct) { Debug.Assert(maxWaitTime != 0, "Checked before entering"); long swStart = m_outer.m_timeProvider.GetTimestamp(); CancellationTokenSource? timeoutCts = null; CancellationTokenSource? linkedCts = null; CancellationToken waitToken; if (maxWaitTime != Timeout.Infinite) { #if FALSE var workers = m_outer.PublishWorkerCount; if (workers == 0) { Debug.Fail("Must have at least this worker here."); workers = 1; } maxWaitTime /= workers; #endif m_logger.LogDebug( "PUBLISH Worker #{Handle} - Waiting max {Time}ms for acks to arrive.", Index, maxWaitTime); timeoutCts = m_outer.m_timeProvider .CreateCancellationTokenSource(TimeSpan.FromMilliseconds(maxWaitTime)); linkedCts = CancellationTokenSource .CreateLinkedTokenSource(ct, timeoutCts.Token); waitToken = linkedCts.Token; } else { m_logger.LogDebug( "PUBLISH Worker #{Handle} - Waiting for acks to arrive.", Index); waitToken = ct; } try { SubscriptionAcknowledgement firstAck = await m_outer.m_acks.Reader.ReadAsync( waitToken).ConfigureAwait(false); ArrayOf restAcks = GetAcksReadyToSend(); var ackList = restAcks.ToList(); ackList.Insert(0, firstAck); ArrayOf acks = ackList; m_logger.LogDebug( "PUBLISH Worker #{Handle} - Publish {Count} acks after pausing {Duration}.", Index, acks.Count, m_outer.m_timeProvider.GetElapsedTime(swStart)); return acks; } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { m_logger.LogInformation( "PUBLISH Worker #{Handle} - Publish with no acks after waiting {Duration}.", Index, m_outer.m_timeProvider.GetElapsedTime(swStart)); return []; } finally { linkedCts?.Dispose(); timeoutCts?.Dispose(); } } /// /// Get acks that are ready to send /// /// private ArrayOf GetAcksReadyToSend() { var acks = new List(); // TODO: Is this something that we can get from ops limit? int available = m_outer.m_acks.Reader.Count; int maxAcks = available / Math.Max(m_outer.PublishWorkerCount, 1); for (int i = 0; i < maxAcks && m_outer.m_acks.Reader.TryRead(out SubscriptionAcknowledgement? ack); i++) { acks.Add(ack); } if (acks.Count != 0) { m_logger.LogDebug( "PUBLISH Worker #{Handle} - Acknoledging {Count} of {Total} messages.", Index, acks.Count, available); } return acks; } /// /// Calculate the publish timeout to use /// /// /// /// Max time to throttle the publish private int CalculateTimeouts(long latency, ref uint currentTimeout) { List created = m_outer.Created; if (created.Count == 0) { return 0; } TimeSpan timeout = TimeSpan.Zero; int minPublishInterval = Timeout.Infinite; foreach (IManagedSubscription s in created) { TimeSpan publishingInterval = s.CurrentPublishingInterval; TimeSpan keepAlive = publishingInterval.Multiply(s.CurrentKeepAliveCount); if (timeout < keepAlive) { timeout = keepAlive; } int pi = (int)publishingInterval.TotalMilliseconds; if (pi <= 0) { continue; } if (minPublishInterval > pi || minPublishInterval == Timeout.Infinite) { minPublishInterval = pi; } } // // The timeout while publishing should be twice the // value for PublishingInterval * KeepAliveCount // TODO: Validate this against spec // timeout = timeout.Multiply(2); if (timeout < s_minOperationTimeout) { timeout = s_minOperationTimeout; } if (timeout > s_maxOperationTimeout) { timeout = s_maxOperationTimeout; } uint newTimeout = (uint)timeout.TotalMilliseconds; if (newTimeout > currentTimeout) { currentTimeout = newTimeout; } if (minPublishInterval < latency) { return 0; } return minPublishInterval - (int)latency; } private readonly ILogger m_logger; private readonly SubscriptionManager m_outer; private readonly CancellationTokenSource m_cts; } private static readonly TimeSpan s_maxOperationTimeout = TimeSpan.FromMinutes(30); private static readonly TimeSpan s_minOperationTimeout = TimeSpan.FromSeconds(1); private const int kMaxSubscriptionHistory = 10; private uint m_publishRequestCounter; #pragma warning disable IDE0032 // Use auto property private int m_badPublishRequestCount; private int m_goodPublishRequestCount; #pragma warning restore IDE0032 // Use auto property private readonly Channel m_acks; private readonly AsyncManualResetEvent m_running = new(); private readonly AsyncAutoResetEvent m_publishControl = new(); private readonly AsyncManualResetEvent m_drainSignal = new(true); private int m_activePublishRequests; private int m_disposed; private readonly ConcurrentQueue m_subscriptionHistory = new(); private readonly Task m_publishController; private readonly Lock m_subscriptionLock = new(); // Dispatch registry: every partition subscription this manager // owns, including the primaries of logical wrappers. Publish // dispatch (GetById), acknowledgement routing, recreate, and // transfer iterate this set so they remain partition-aware. private readonly HashSet m_subscriptions = []; // Public registry: logical wrappers returned to callers from // ISubscriptionManager.Items / Add. One wrapper per logical // subscription regardless of how many partitions back it. private readonly HashSet m_logicals = []; private readonly CancellationTokenSource m_cts = new(); private readonly ISubscriptionManagerContext m_session; private readonly ILoggerFactory m_loggerFactory; private readonly ILogger m_logger; private readonly TimeProvider m_timeProvider; } }