/* ======================================================================== * 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.Collections.Immutable; using System.Linq; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using Microsoft.Extensions.Logging; namespace Opc.Ua.Client.Subscriptions { /// /// Processes messages inside a subscription, it is the base class for all /// subscription objects but basis to support better testing in isolation. /// internal abstract class MessageProcessor : IMessageProcessor, IAsyncDisposable { /// public uint Id { get; internal set; } /// /// Number of notification messages detected as missing during /// gap-walking of the SequenceNumber on this subscription. /// public long MissingMessageCount => Volatile.Read(ref m_missingCount); /// /// Number of republish requests issued for this subscription /// (counts every attempt regardless of outcome). /// public long RepublishMessageCount => Volatile.Read(ref m_republishCount); /// /// Observability context /// protected ITelemetryContext Observability { get; } /// /// Whether the message processor should release pooled notification /// payload instances back to their activator pools after the handler /// dispatch completes. Reflects the current /// setting on the /// subscription manager; toggling the manager-level setting takes /// effect on the next dispatch. /// protected bool PoolNotifications => AckQueue.PoolNotifications; /// /// Create subscription /// /// /// /// /// Optional /// for elapsed-time and timestamp calculations. Defaults to /// when null. protected MessageProcessor( ISubscriptionServiceSetClientMethods services, IMessageAckQueue completion, ITelemetryContext telemetry, TimeProvider? timeProvider = null) { TimeProvider = timeProvider ?? TimeProvider.System; Observability = telemetry; AvailableInRetransmissionQueue = []; Logger = Observability.LoggerFactory.CreateLogger(); m_services = services; AckQueue = completion; m_messages = Channel.CreateUnboundedPrioritized( new UnboundedPrioritizedChannelOptions { SingleReader = true, Comparer = Comparer.Create( IncomingMessage.Compare) }); m_messageWorkerTask = ProcessReceivedMessagesAsync(m_cts.Token); } /// public ValueTask DisposeAsync() { GC.SuppressFinalize(this); return DisposeAsync(true); } /// public virtual async ValueTask OnPublishReceivedAsync( NotificationMessage message, IReadOnlyList? availableSequenceNumbers, IReadOnlyList stringTable) { if (availableSequenceNumbers != null) { AvailableInRetransmissionQueue = availableSequenceNumbers; } LastNotificationTimestamp = TimeProvider.GetTimestamp(); await m_messages.Writer.WriteAsync(new IncomingMessage(message, stringTable, TimeProvider.GetUtcNow())).ConfigureAwait(false); } /// /// Dispose subscription /// /// protected virtual async ValueTask DisposeAsync(bool disposing) { if (disposing && !Disposed) { try { m_messages.Writer.TryComplete(); await m_cts.CancelAsync().ConfigureAwait(false); await m_messageWorkerTask.ConfigureAwait(false); } finally { m_cts.Dispose(); (m_messages as IDisposable)?.Dispose(); Disposed = true; } } } /// /// Process status change notification /// /// /// /// /// /// /// protected abstract ValueTask OnStatusChangeNotificationAsync( uint sequenceNumber, DateTime publishTime, StatusChangeNotification notification, PublishState publishStateMask, IReadOnlyList stringTable); /// /// Process keep alive notification /// /// /// /// /// protected abstract ValueTask OnKeepAliveNotificationAsync( uint sequenceNumber, DateTime publishTime, PublishState publishStateMask); /// /// Process data change notification /// /// /// /// /// /// /// protected abstract ValueTask OnDataChangeNotificationAsync( uint sequenceNumber, DateTime publishTime, DataChangeNotification notification, PublishState publishStateMask, IReadOnlyList stringTable); /// /// Process event notification /// /// /// /// /// /// /// protected abstract ValueTask OnEventDataNotificationAsync( uint sequenceNumber, DateTime publishTime, EventNotificationList notification, PublishState publishStateMask, IReadOnlyList stringTable); /// /// On publish state changed /// /// /// protected virtual void OnPublishStateChanged(PublishState stateMask) { if (stateMask.HasFlag(PublishState.Stopped)) { Logger.LogInformation("{Subscription} STOPPED!", this); } if (stateMask.HasFlag(PublishState.Recovered)) { Logger.LogInformation("{Subscription} RECOVERED!", this); } if (stateMask.HasFlag(PublishState.Completed)) { Logger.LogInformation("{Subscription} CLOSED!", this); } } /// /// Processes all incoming messages and dispatch them. /// /// private async Task ProcessReceivedMessagesAsync(CancellationToken ct) { try { // // This can be optimized to peek when missing sequence number // and not in available sequence number als to support batching. // TODO This also needs to guard against overruns using some // form of semaphore to block the publisher. // Unless we get https://github.com/dotnet/runtime/issues/101292 // IAsyncEnumerable reader = m_messages.Reader.ReadAllAsync(ct); await foreach (IncomingMessage incoming in reader.ConfigureAwait(false)) { await ProcessMessageAsync(incoming, ct).ConfigureAwait(false); } } catch (OperationCanceledException) { } catch (Exception ex) { Logger.LogCritical(ex, "{Subscription}: Error processing messages. Processor is exiting!!!", this); // Do not call complete here as we do not want the subscription // to be removed throw; } await AckQueue.CompleteAsync(Id, default).ConfigureAwait(false); OnPublishStateChanged(PublishState.Completed); } /// /// Notify the owning manager/queue that the subscription's /// state has changed (e.g. it has just been created on the /// server). The manager uses this signal to re-evaluate the /// publish worker pool. /// protected void NotifyManagerOfCreation() { AckQueue.Update(); } /// /// Process message. The logic checks whether any message was missed /// and try to republish. Any new messages received that already were /// handled are discarded. The message is then dispatched to the appropriate /// callbacks that must be overridden by the derived class. /// /// /// Sequence numbers are circular per OPC UA Part 4 §7.30.5 — they /// wrap from to 1 (skipping 0). The /// implementation uses unsigned-difference arithmetic to detect /// "older vs newer" across a wraparound, and walks the /// missing-message range explicitly wrapping past /// to 1. /// /// /// /// private async Task ProcessMessageAsync(IncomingMessage incoming, CancellationToken ct) { uint curSeqNum = incoming.Message.SequenceNumber; bool isKeepAlive = incoming.Message.NotificationData.Count == 0; const uint kBackwardThreshold = 1u << 31; if (isKeepAlive) { // Per OPC UA Part 4 §7.30, a keep-alive NotificationMessage // carries the SequenceNumber of the next NotificationMessage // to be sent. Multiple consecutive keep-alives therefore // reuse the same SequenceNumber, and the next real data // message reuses it too (the slot is consumed only when data // is emitted). Bypass the duplicate-message filter, surface // the keep-alive, and DO NOT advance the data-dedup gate // (LastDataSequenceNumberProcessed) — otherwise the next real // data message would be silently dropped as a duplicate. LastSequenceNumberProcessed = curSeqNum; await OnNotificationReceivedAsync( incoming.Message, PublishState.KeepAlive, ct).ConfigureAwait(false); return; } // Data / event message — apply the dedup / gap-walk logic // against the highest-consumed data SequenceNumber. uint prevDataSeq = LastDataSequenceNumberProcessed; if (prevDataSeq != 0) { uint delta = unchecked(curSeqNum - prevDataSeq); if (delta is 0 or >= kBackwardThreshold) { if (!Logger.IsEnabled(LogLevel.Debug)) { return; } if (curSeqNum == prevDataSeq) { Logger.LogDebug( "{Subscription}: Received duplicate message " + "with sequence number #{SeqNumber}.", this, curSeqNum); } else { Logger.LogDebug( "{Subscription}: Received older message with " + "sequence number #{SeqNumber} but already processed message with " + "sequence number #{Old}.", this, curSeqNum, prevDataSeq); } return; } // Walk the gap (prevDataSeq, curSeqNum) wrapping past // uint.MaxValue to 1. uint missing = prevDataSeq; while (true) { missing = missing == uint.MaxValue ? 1u : missing + 1u; if (missing == curSeqNum) { break; } Interlocked.Increment(ref m_missingCount); await TryRepublishAsync(missing, curSeqNum, ct).ConfigureAwait(false); } } else { // First data message after subscription create / recreate / // transfer. We don't know what came before, but the // server may still hold older retransmissible messages // (e.g. on a transferred subscription where the first // received SequenceNumber is greater than 1). Walk only // the published AvailableInRetransmissionQueue so the // cost is bounded by the server's queue size, not the // raw gap. Mirrors the V1 fix in PR #3565. IReadOnlyList available = AvailableInRetransmissionQueue; for (int i = 0; i < available.Count; i++) { uint seq = available[i]; uint delta = unchecked(curSeqNum - seq); if (delta is not 0 and < kBackwardThreshold) { await TryRepublishAsync(seq, curSeqNum, ct).ConfigureAwait(false); } } } LastDataSequenceNumberProcessed = curSeqNum; LastSequenceNumberProcessed = curSeqNum; await OnNotificationReceivedAsync( incoming.Message, PublishState.None, ct).ConfigureAwait(false); } /// /// Try republish a missing message /// /// /// /// /// private async ValueTask TryRepublishAsync(uint missing, uint curSeqNum, CancellationToken ct) { Interlocked.Increment(ref m_republishCount); if (!AvailableInRetransmissionQueue.Contains(missing)) { Logger.LogWarning( "{Subscription}: Message with sequence number " + "#{SeqNumber} is not in server retransmission queue and was dropped.", this, missing); return; } try { Logger.LogInformation( "{Subscription}: Republishing missing message " + "with sequence number #{Missing} to catch up to message " + "with sequence number #{SeqNumber}...", this, missing, curSeqNum); RepublishResponse republish = await m_services.RepublishAsync( null, Id, missing, ct).ConfigureAwait(false); if (ServiceResult.IsGood(republish.ResponseHeader.ServiceResult)) { await OnNotificationReceivedAsync( republish.NotificationMessage, PublishState.Republish, ct).ConfigureAwait(false); } else { Logger.LogWarning( "{Subscription}: Republishing message with " + "sequence number #{SeqNumber} failed.", this, missing); } } catch (Exception ex) { Logger.LogError( ex, "{Subscription}: Error republishing message with " + "sequence number #{SeqNumber}.", this, missing); } } /// /// Dispatch notification message /// /// /// /// /// private async ValueTask OnNotificationReceivedAsync( NotificationMessage message, PublishState publishStateMask, CancellationToken ct) { try { if (message.NotificationData.Count == 0) { publishStateMask |= PublishState.KeepAlive; await OnKeepAliveNotificationAsync( message.SequenceNumber, (DateTime)message.PublishTime, publishStateMask).ConfigureAwait(false); } else { for (int i = 0; i < message.NotificationData.Count; i++) { await DispatchAsync( message, publishStateMask, message.NotificationData[i]).ConfigureAwait(false); } } await AckQueue.QueueAsync(new SubscriptionAcknowledgement { SequenceNumber = message.SequenceNumber, SubscriptionId = Id }, ct).ConfigureAwait(false); } catch (Exception ex) { Logger.LogError(ex, "{Subscription}: Error dispatching notification data.", this); } } /// /// Dispatch notification /// /// /// /// /// private async ValueTask DispatchAsync(NotificationMessage message, PublishState publishStateMask, ExtensionObject? notificationData) { if (notificationData == null) { return; } if (notificationData.Value.TryGetValue( out DataChangeNotification? datachange)) { await OnDataChangeNotificationAsync( message.SequenceNumber, (DateTime)message.PublishTime, datachange, publishStateMask, message.StringTable.ToArray() ?? []).ConfigureAwait(false); } else if (notificationData.Value.TryGetValue( out EventNotificationList? events)) { await OnEventDataNotificationAsync( message.SequenceNumber, (DateTime)message.PublishTime, events, publishStateMask, message.StringTable.ToArray() ?? []).ConfigureAwait(false); } else if (notificationData.Value.TryGetValue( out StatusChangeNotification? statusChanged)) { PublishState mask = publishStateMask; if (statusChanged.Status == StatusCodes.GoodSubscriptionTransferred) { // Only happens if we did not initiate the transfer. // TODO: Complete this subscription. mask |= PublishState.Transferred; } else if (statusChanged.Status == StatusCodes.BadTimeout) { // Timeout on the server. // TODO: Also complete this subscription mask |= PublishState.Timeout; } await OnStatusChangeNotificationAsync( message.SequenceNumber, (DateTime)message.PublishTime, statusChanged, mask, message.StringTable.ToArray() ?? []).ConfigureAwait(false); } } /// /// A message received from the server cached until is processed or discarded. /// /// /// /// private readonly record struct IncomingMessage( NotificationMessage Message, IReadOnlyList StringTable, DateTimeOffset Enqueued) { public static int Compare(IncomingMessage message, IncomingMessage other) { // Greater than zero � message follows the // other message in sort order. return (int)message.Message.SequenceNumber - (int)other.Message.SequenceNumber; } } internal bool Disposed; internal long LastNotificationTimestamp; internal uint LastSequenceNumberProcessed; internal uint LastDataSequenceNumberProcessed; internal long m_missingCount; internal long m_republishCount; internal IReadOnlyList AvailableInRetransmissionQueue; internal readonly ILogger Logger; protected TimeProvider TimeProvider { get; } /// /// Exposes the ack queue / completion sink supplied to the /// processor so derived classes can interact with it without /// reaching for a private field — used today by the recovery /// path to drop stale acknowledgements before recreating a /// subscription that the server has invalidated under us. /// protected IMessageAckQueue AckQueue { get; } private readonly ISubscriptionServiceSetClientMethods m_services; // CA2213: m_cts is disposed in DisposeAsync(bool) — suppressed because // the analyzer does not track IAsyncDisposable disposal paths. #pragma warning disable CA2213 private readonly CancellationTokenSource m_cts = new(); #pragma warning restore CA2213 private readonly Task m_messageWorkerTask; private readonly Channel m_messages; } }