/* ========================================================================
* 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 Microsoft.Extensions.Logging;
using Opc.Ua.Client.Subscriptions;
using Opc.Ua.Client.Subscriptions.Streaming;
using MonitoringOptions = Opc.Ua.Client.Subscriptions.MonitoredItems.MonitoredItemOptions;
namespace Opc.Ua.Client.ModelChange
{
///
/// Default backed by a
/// streaming subscription on the Server object's
/// GeneralModelChangeEventType notifier.
///
public sealed class ModelChangeTracker : IModelChangeTracker
{
private readonly IStreamingSubscription m_streaming;
private readonly INodeCache? m_nodeCache;
private readonly ILogger m_logger;
private CancellationTokenSource? m_cts;
private Task? m_pumpTask;
private bool m_disposed;
///
public event EventHandler? ModelChanged;
///
public bool IsTracking { get; private set; }
///
/// Initializes a new model change tracker.
///
public ModelChangeTracker(
IStreamingSubscription streaming,
INodeCache? nodeCache = null,
ILogger? logger = null)
{
m_streaming = streaming ?? throw new ArgumentNullException(nameof(streaming));
m_nodeCache = nodeCache;
m_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
}
///
public ValueTask StartTrackingAsync(CancellationToken ct = default)
{
if (IsTracking)
{
return default;
}
m_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
// Capture the token before launching the pump task so a racing
// StopTrackingAsync (which nulls m_cts) cannot NRE the lambda.
CancellationToken pumpToken = m_cts.Token;
m_pumpTask = Task.Run(() => PumpAsync(pumpToken), pumpToken);
IsTracking = true;
return default;
}
///
public async ValueTask StopTrackingAsync(CancellationToken ct = default)
{
if (!IsTracking)
{
return;
}
IsTracking = false;
CancellationTokenSource? cts = m_cts;
m_cts = null;
if (cts != null)
{
try
{
cts.Cancel();
}
catch (ObjectDisposedException)
{
// already disposed
}
}
if (m_pumpTask != null)
{
try
{
await m_pumpTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// expected
}
m_pumpTask = null;
}
cts?.Dispose();
}
///
public async ValueTask DisposeAsync()
{
if (m_disposed)
{
return;
}
m_disposed = true;
await StopTrackingAsync().ConfigureAwait(false);
}
private async Task PumpAsync(CancellationToken ct)
{
try
{
EventFilter filter = BuildModelChangeFilter();
var options = new MonitoringOptions
{
StartNodeId = ObjectIds.Server,
AttributeId = Attributes.EventNotifier,
QueueSize = 50
};
IAsyncEnumerable source =
m_streaming.SubscribeEventsAsync(ObjectIds.Server, filter, options, ct);
await foreach (EventNotification notification in source.ConfigureAwait(false))
{
HandleNotification(notification);
}
}
catch (OperationCanceledException)
{
// graceful shutdown
}
catch (Exception ex)
{
m_logger.LogError(ex, "ModelChangeTracker pump failed");
}
}
private static EventFilter BuildModelChangeFilter()
{
var filter = new EventFilter();
filter.AddSelectClause(ObjectTypeIds.BaseEventType,
QualifiedName.From(BrowseNames.EventId));
filter.AddSelectClause(ObjectTypeIds.BaseEventType,
QualifiedName.From(BrowseNames.EventType));
filter.AddSelectClause(ObjectTypeIds.GeneralModelChangeEventType,
QualifiedName.From(BrowseNames.Changes));
filter.WhereClause.Push(FilterOperator.OfType,
Variant.From(ObjectTypeIds.BaseModelChangeEventType));
return filter;
}
private void HandleNotification(EventNotification notification)
{
Variant[] fields = notification.Fields.ToArray() ?? [];
if (fields.Length < 3)
{
return;
}
Variant changesVariant = fields[2];
var changes = new List();
bool requiresFullInvalidation = false;
if (changesVariant.TryGetValue(out ArrayOf extObjs))
{
foreach (ExtensionObject ext in extObjs)
{
if (ext.TryGetValue(out ModelChangeStructureDataType? change) &&
change != null)
{
changes.Add(new ModelChange(
(ModelChangeVerb)change.Verb,
change.Affected,
change.AffectedType));
}
}
}
else
{
requiresFullInvalidation = true;
}
try
{
if (requiresFullInvalidation)
{
m_nodeCache?.Clear();
}
else
{
foreach (ModelChange change in changes)
{
InvalidateCache(change);
}
}
}
catch (Exception ex)
{
m_logger.LogWarning(ex, "ModelChangeTracker failed to invalidate cache");
}
try
{
ModelChanged?.Invoke(this,
new ModelChangedEventArgs(changes, requiresFullInvalidation));
}
catch (Exception ex)
{
m_logger.LogError(ex, "ModelChangeTracker subscriber threw");
}
}
private void InvalidateCache(ModelChange change)
{
if (m_nodeCache == null || change.Verb == ModelChangeVerb.None)
{
return;
}
// Targeted per-node invalidation. Falls back to Clear()
// automatically through the INodeCache default impl when
// an implementation doesn't override per-node eviction.
m_nodeCache.InvalidateNode(change.AffectedNode);
}
}
}