Files
Marc Schier dbc09b8e22 Adopt System.TimeProvider across the stack (fixes #2458) (#3829)
# Description

Adopt
[`System.TimeProvider`](https://learn.microsoft.com/dotnet/api/system.timeprovider)
across the stack so that every place that creates timers, runs duration
math, or schedules periodic work is mockable, deterministic in tests,
and immune to wall-clock changes.

The migration touches Core, Core.Types, Server, Client, PubSub,
Configuration, Gds (server + client), Lds, and the Quickstarts/Console
reference apps. Per-component changes follow the same pattern:

1. Add a `private readonly TimeProvider m_timeProvider;` field.
2. Accept a nullable `TimeProvider? timeProvider = null` as the **last**
constructor parameter (new overload; legacy constructor stays for binary
compatibility and delegates with `timeProvider: null`).
3. Initialise the field with `m_timeProvider = timeProvider ??=
TimeProvider.System;`.
4. Replace `new Timer(...)`, `Stopwatch`, `HiResClock.*`,
`DateTime.UtcNow`-based duration math, and `Task.Delay(delay, ct)` in
timing loops with the matching `m_timeProvider` API (`CreateTimer`,
`GetTimestamp` / `GetElapsedTime`, `GetUtcNow().UtcDateTime`,
`Task.Delay(delay, m_timeProvider, ct)`).
5. Wire the provider through DI: `AddOpcUaServerBuilder` /
`AddOpcUaClientBuilder` register `TimeProvider.System` via
`TryAddSingleton<TimeProvider>()` so tests can swap in
`Microsoft.Extensions.Time.Testing.FakeTimeProvider`.

`HiResClock` itself is kept (so the
`ApplicationConfiguration.DisableHiResClock` schema flag still
round-trips), but **every public member** is now `[Obsolete]` with a
single shared message pointing to the new migration-guide section. The
few internal call sites that still need to set/read `HiResClock` are
wrapped with `#pragma warning disable CS0618`.

### Highlights per component

- **Core / Stack** — `Opc.Ua.Core`, `Opc.Ua.Core.Types`, channel
pipeline (`UaSCBinaryClientChannel`, `ChannelToken`,
`UaSCBinaryChannel`), `ApplicationConfiguration`, certificate
validators, `JwksIssuerKeyResolver`, `TimeProviderExtensions` polyfills
(`GetTimestampMilliseconds`, `GetTickCount`,
`CancellationTokenSource(timeout, timeProvider)`, `Task.Delay(delay,
provider, ct)` polyfill for older TFMs).
- **Server** — session lockout, subscription pipeline
(publish/sample/keep-alive/lifetime), `MonitoredItemQueue`, alarm
sampling rate, `IntervalRunner`, server diagnostics, configuration state
machines, hosted services.
- **Client** — `Session`, managed/V2 subscription engines, reconnect
handlers, channel keep-alive, periodic publishing, complex-type
discovery polling.
- **PubSub** — `IntervalRunner` pipeline, message-receive timers,
MQTT/UDP keep-alive, publishers/readers.
- **Gds / Lds** — `LdsServer`, `LinqApplicationsDatabase` expiry
housekeeping, GDS clients (push, pull), `DirectoryCertificateStore`,
`ConnectionStateMachine`, classic subscription engine.
- **Quickstarts / Console samples** — `SampleNodeManager`,
`ReferenceNodeManager`, `AlarmNodeManager`, `BoilerState`,
`MemoryBufferState`, `PublishedValuesWrites`. Samples resolve via
`(Server as ITimeProviderProvider)?.TimeProvider ?? TimeProvider.System`
so existing ctor signatures stay binary-compatible.
- **Tests** — `Opc.Ua.PubSub.Tests` adds `IntervalRunnerTests`; existing
`SubscriptionTests`, `UaPublisherTests`, and `TestDataSystem` move off
`HiResClock`. Added `Microsoft.Extensions.TimeProvider.Testing` package
for `FakeTimeProvider` (project-load warning surfaces on net48/net472
only — runtime is .NET 8+).

### Migration guide

New section [**Time and Timer abstraction
(`TimeProvider`)**](Docs/MigrationGuide.md#time-and-timer-abstraction-timeprovider)
documents:

- The canonical replacement table (`HiResClock.UtcNow` →
`timeProvider.GetUtcNow().UtcDateTime`, `HiResClock.TickCount64` →
`timeProvider.GetTimestamp()`, `new Timer` → `timeProvider.CreateTimer`,
`Task.Delay(delay, ct)` → `Task.Delay(delay, timeProvider, ct)`, …).
- The `TimeProvider? timeProvider = null` + `??=` constructor pattern.
- DI wiring and how to swap in `FakeTimeProvider` in tests.
- Before/after snippets for `HiResClock.TickCount64`,
`HiResClock.UtcNow`, and `new Timer(...)`.

## Related Issues

- Fixes #2458

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-06-03 22:38:32 +02:00

2328 lines
89 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace Opc.Ua.Server
{
/// <summary>
/// A generic session manager object for a server.
/// </summary>
public class SubscriptionManager : ISubscriptionManager
{
/// <summary>
/// Initializes the manager with its configuration.
/// </summary>
public SubscriptionManager(IServerInternal server, ApplicationConfiguration configuration)
: this(server, configuration, timeProvider: null)
{
}
/// <summary>
/// Initializes the manager with its configuration.
/// </summary>
public SubscriptionManager(
IServerInternal server,
ApplicationConfiguration configuration,
TimeProvider? timeProvider)
{
if (configuration == null)
{
throw new ArgumentNullException(nameof(configuration));
}
m_server = server ?? throw new ArgumentNullException(nameof(server));
m_logger = server.Telemetry.CreateLogger<SubscriptionManager>();
m_timeProvider = timeProvider
?? (server as ITimeProviderProvider)?.TimeProvider
?? TimeProvider.System;
m_minPublishingInterval = configuration.ServerConfiguration!.MinPublishingInterval;
m_maxPublishingInterval = configuration.ServerConfiguration.MaxPublishingInterval;
m_publishingResolution = configuration.ServerConfiguration.PublishingResolution;
m_maxSubscriptionLifetime = (uint)configuration.ServerConfiguration
.MaxSubscriptionLifetime;
m_maxDurableSubscriptionLifetimeInHours = (uint)
configuration.ServerConfiguration.MaxDurableSubscriptionLifetimeInHours;
m_durableSubscriptionsEnabled = configuration.ServerConfiguration
.DurableSubscriptionsEnabled;
m_minSubscriptionLifetime = (uint)configuration.ServerConfiguration
.MinSubscriptionLifetime;
m_maxMessageCount = (uint)configuration.ServerConfiguration.MaxMessageQueueSize;
m_maxNotificationsPerPublish = (uint)configuration.ServerConfiguration
.MaxNotificationsPerPublish;
m_maxPublishRequestCount = configuration.ServerConfiguration.MaxPublishRequestCount;
m_maxSubscriptionCount = configuration.ServerConfiguration.MaxSubscriptionCount;
m_subscriptionStore = server.SubscriptionStore;
m_subscriptions = [];
m_abandonedSubscriptions = [];
m_publishQueues = [];
m_statusMessages = [];
m_lastSubscriptionId = BitConverter.ToUInt32(
Nonce.CreateRandomNonceData(sizeof(uint)),
0);
// create a event to signal shutdown.
m_shutdownEvent = new ManualResetEvent(true);
// create queue and event for condition refresh worker
m_conditionRefreshEvent = new ManualResetEvent(false);
m_conditionRefreshQueue = new Queue<ConditionRefreshTask>();
}
/// <summary>
/// Frees any unmanaged resources.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// An overrideable version of the Dispose.
/// </summary>
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
List<ISubscription>? subscriptions = null;
List<SessionPublishQueue>? publishQueues = null;
m_semaphoreSlim.Wait();
try
{
publishQueues = [.. m_publishQueues.Values];
m_publishQueues.Clear();
subscriptions = [.. m_subscriptions.Values];
m_subscriptions.Clear();
}
finally
{
m_semaphoreSlim.Release();
}
foreach (SessionPublishQueue publishQueue in publishQueues)
{
publishQueue?.Dispose();
}
foreach (ISubscription subscription in subscriptions)
{
subscription?.Dispose();
}
m_shutdownEvent.Dispose();
m_conditionRefreshEvent.Dispose();
m_semaphoreSlim.Dispose();
}
}
/// <summary>
/// Raised after a new subscription is created.
/// </summary>
public event SubscriptionEventHandler SubscriptionCreated
{
add
{
lock (m_eventLock)
{
m_SubscriptionCreated += value;
}
}
remove
{
lock (m_eventLock)
{
m_SubscriptionCreated -= value;
}
}
}
/// <summary>
/// Raised before a subscription is deleted.
/// </summary>
public event SubscriptionEventHandler SubscriptionDeleted
{
add
{
lock (m_eventLock)
{
m_SubscriptionDeleted += value;
}
}
remove
{
lock (m_eventLock)
{
m_SubscriptionDeleted -= value;
}
}
}
/// <inheritdoc/>
public IList<ISubscription> GetSubscriptions()
{
return [.. m_subscriptions.Values];
}
/// <inheritdoc/>
public bool TryGetSubscription(uint id, [NotNullWhen(true)] out ISubscription? subscription)
{
return m_subscriptions.TryGetValue(id, out subscription);
}
/// <summary>
/// Raises an event related to a subscription.
/// </summary>
protected virtual void RaiseSubscriptionEvent(ISubscription subscription, bool deleted)
{
SubscriptionEventHandler? handler;
lock (m_eventLock)
{
handler = m_SubscriptionCreated;
if (deleted)
{
handler = m_SubscriptionDeleted;
}
}
if (handler != null)
{
try
{
handler(subscription, deleted);
}
catch (Exception e)
{
m_logger.LogError(e, "Subscription event handler raised an exception.");
}
}
}
/// <summary>
/// Starts up the manager makes it ready to create subscriptions.
/// </summary>
public virtual async ValueTask StartupAsync(CancellationToken cancellationToken = default)
{
await m_semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// restore subscriptions on startup
await RestoreSubscriptionsAsync(cancellationToken)
.ConfigureAwait(false);
m_shutdownEvent.Reset();
// TODO: Ensure shutdown awaits completion and a cancellation token is passed
_ = Task.Factory.StartNew(
() => PublishSubscriptionsAsync(m_publishingResolution),
default,
TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach,
TaskScheduler.Default);
m_conditionRefreshEvent.Reset();
// TODO: Ensure shutdown awaits completion and a cancellation token is passed
_ = Task.Factory.StartNew(
ConditionRefreshWorkerAsync,
default,
TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach,
TaskScheduler.Default);
}
finally
{
m_semaphoreSlim.Release();
}
}
/// <summary>
/// Closes all subscriptions and rejects any new requests.
/// </summary>
public virtual async ValueTask ShutdownAsync(CancellationToken cancellationToken = default)
{
await m_semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// stop the publishing thread.
m_shutdownEvent.Set();
// trigger the condition refresh thread.
m_conditionRefreshEvent.Set();
// dispose of publish queues.
foreach (SessionPublishQueue queue in m_publishQueues.Values)
{
queue.Dispose();
}
m_publishQueues.Clear();
// store subscriptions to be able to restore them after a restart
await StoreSubscriptionsAsync(cancellationToken)
.ConfigureAwait(false);
// dispose of subscriptions objects.
foreach (ISubscription subscription in m_subscriptions.Values)
{
subscription.Dispose();
}
m_subscriptions.Clear();
}
finally
{
m_semaphoreSlim.Release();
}
}
/// <summary>
/// Stores durable subscriptions to be able to restore them after a restart
/// </summary>
public virtual ValueTask StoreSubscriptionsAsync(CancellationToken cancellationToken = default)
{
// only store subscriptions if durable subscriptions are enabled
if (!m_durableSubscriptionsEnabled || m_subscriptionStore == null)
{
return default;
}
var subscriptionsToStore = new List<IStoredSubscription>();
foreach (ISubscription subscription in m_subscriptions.Values)
{
// only store durable subscriptions
if (!subscription.IsDurable)
{
continue;
}
subscriptionsToStore.Add(subscription.ToStorableSubscription());
}
if (subscriptionsToStore.Count == 0)
{
return default;
}
try
{
if (m_subscriptionStore.StoreSubscriptions(subscriptionsToStore))
{
m_logger.LogInformation("{Count} Subscriptions stored", subscriptionsToStore.Count);
}
}
catch (Exception ex)
{
m_logger.LogError(ex, "Failed to store {Count} subscriptions", subscriptionsToStore.Count);
}
return default;
}
/// <summary>
/// Restore durable subscriptions after a server restart
/// </summary>
/// <exception cref="InvalidOperationException"></exception>
public virtual async ValueTask RestoreSubscriptionsAsync(CancellationToken cancellationToken = default)
{
if (m_server.IsRunning)
{
throw new InvalidOperationException(
"Subscription restore can only occur on startup");
}
// only restore subscriptions if durable subscriptions are enabeld
if (!m_durableSubscriptionsEnabled || m_subscriptionStore == null)
{
return;
}
RestoreSubscriptionResult restoreResult;
try
{
restoreResult = m_subscriptionStore.RestoreSubscriptions();
}
catch (Exception ex)
{
m_logger.LogError(ex, "Failed to restore subscriptions");
return;
}
if (!restoreResult.Success ||
restoreResult.Subscriptions == null ||
!restoreResult.Subscriptions.Any())
{
return;
}
var createdSubscriptions = new Dictionary<uint, ArrayOf<uint>>();
foreach (IStoredSubscription storedSubscription in restoreResult.Subscriptions)
{
ISubscription subscription;
try
{
subscription = await RestoreSubscriptionAsync(storedSubscription, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex)
{
m_logger.LogError(
ex,
"Failed to restore subscritption with id {SubscriptionId}",
storedSubscription.Id);
continue;
}
subscription.GetMonitoredItems(out ArrayOf<uint> monitoredItemsIds, out _);
createdSubscriptions.Add(subscription.Id, monitoredItemsIds);
}
m_lastSubscriptionId = restoreResult.Subscriptions.Max(s => s.Id);
m_subscriptionStore.OnSubscriptionRestoreComplete(createdSubscriptions);
}
/// <summary>
/// Restore a subscription after a restart
/// </summary>
/// <exception cref="ServiceResultException"></exception>
protected virtual async ValueTask<ISubscription> RestoreSubscriptionAsync(
IStoredSubscription storedSubscription,
CancellationToken cancellationToken = default)
{
if (m_subscriptions.Count >= m_maxSubscriptionCount)
{
throw new ServiceResultException(StatusCodes.BadTooManySubscriptions);
}
// calculate publishing interval.
storedSubscription.PublishingInterval = CalculatePublishingInterval(
storedSubscription.PublishingInterval);
// calculate the keep alive count.
storedSubscription.MaxKeepaliveCount = CalculateKeepAliveCount(
storedSubscription.PublishingInterval,
storedSubscription.MaxKeepaliveCount,
storedSubscription.IsDurable);
// calculate the lifetime count.
storedSubscription.MaxLifetimeCount = CalculateLifetimeCount(
storedSubscription.PublishingInterval,
storedSubscription.MaxKeepaliveCount,
storedSubscription.MaxLifetimeCount,
storedSubscription.IsDurable);
// calculate the max notification count.
storedSubscription.MaxNotificationsPerPublish = CalculateMaxNotificationsPerPublish(
storedSubscription.MaxNotificationsPerPublish);
// create the subscription.
Subscription subscription = await Subscription.RestoreAsync(m_server, storedSubscription, cancellationToken)
.ConfigureAwait(false);
uint publishingIntervalCount;
// save subscription.
if (!m_subscriptions.TryAdd(subscription.Id, subscription))
{
throw new ServiceResultException(StatusCodes.BadInternalError, "Failed to create subscription in Server");
}
// get the count for the diagnostics.
publishingIntervalCount = GetPublishingIntervalCount();
lock (m_server.DiagnosticsWriteLock)
{
ServerDiagnosticsSummaryDataType diagnostics = m_server.ServerDiagnostics;
diagnostics.CurrentSubscriptionCount++;
diagnostics.CumulatedSubscriptionCount++;
diagnostics.PublishingIntervalCount = publishingIntervalCount;
}
// raise subscription event.
RaiseSubscriptionEvent(subscription, false);
return subscription;
}
/// <summary>
/// Signals that a session is closing.
/// </summary>
public virtual async ValueTask SessionClosingAsync(
OperationContext context,
NodeId sessionId,
bool deleteSubscriptions,
CancellationToken cancellationToken)
{
IList<ISubscription>? subscriptionsToDelete = null;
// close the publish queue for the session.
if (m_publishQueues.TryRemove(sessionId, out SessionPublishQueue? queue))
{
try
{
subscriptionsToDelete = queue.Close();
// remove the subscriptions.
if (deleteSubscriptions && subscriptionsToDelete != null)
{
for (int ii = 0; ii < subscriptionsToDelete.Count; ii++)
{
m_subscriptions.TryRemove(subscriptionsToDelete[ii].Id, out _);
}
}
}
finally
{
queue.Dispose();
}
}
// remove the expired subscription status change notifications for this session
lock (m_statusMessagesLock)
{
if (m_statusMessages.TryGetValue(sessionId, out _))
{
m_statusMessages.Remove(sessionId);
}
}
// process all subscriptions in the queue.
if (subscriptionsToDelete != null)
{
for (int ii = 0; ii < subscriptionsToDelete.Count; ii++)
{
ISubscription subscription = subscriptionsToDelete[ii];
// delete the subscription.
if (deleteSubscriptions)
{
// raise subscription event.
RaiseSubscriptionEvent(subscription, true);
// delete subscription.
await subscription.DeleteAsync(context, cancellationToken).ConfigureAwait(false);
// get the count for the diagnostics.
uint publishingIntervalCount = GetPublishingIntervalCount();
lock (m_server.DiagnosticsWriteLock)
{
ServerDiagnosticsSummaryDataType diagnostics = m_server
.ServerDiagnostics;
diagnostics.CurrentSubscriptionCount--;
diagnostics.PublishingIntervalCount = publishingIntervalCount;
}
}
// mark the subscriptions as abandoned.
else if (m_abandonedSubscriptions.TryAdd(subscription.Id, subscription))
{
m_logger.LogWarning(
"Subscription ABANDONED, Id={SubscriptionId}.",
subscription.Id);
}
}
}
}
/// <summary>
/// Refreshes the conditions for the specified subscription.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
public void ConditionRefresh(OperationContext context, uint subscriptionId)
{
if (!m_subscriptions.TryGetValue(subscriptionId, out ISubscription? subscription))
{
throw ServiceResultException.Create(
StatusCodes.BadSubscriptionIdInvalid,
"Cannot refresh conditions for a subscription that does not exist.");
}
// ensure a condition refresh is allowed.
subscription.ValidateConditionRefresh(context);
var conditionRefreshTask = new ConditionRefreshTask(subscription, 0);
ServiceResultException? serviceResultException = null;
lock (m_conditionRefreshLock)
{
if (!m_conditionRefreshQueue.Contains(conditionRefreshTask))
{
m_conditionRefreshQueue.Enqueue(conditionRefreshTask);
}
else
{
serviceResultException = new ServiceResultException(
StatusCodes.BadRefreshInProgress);
}
// trigger the refresh worker.
m_conditionRefreshEvent.Set();
}
if (serviceResultException != null)
{
throw serviceResultException;
}
}
/// <summary>
/// Refreshes the conditions for the specified subscription and monitored item.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
public void ConditionRefresh2(
OperationContext context,
uint subscriptionId,
uint monitoredItemId)
{
if (!m_subscriptions.TryGetValue(subscriptionId, out ISubscription? subscription))
{
throw ServiceResultException.Create(
StatusCodes.BadSubscriptionIdInvalid,
"Cannot refresh conditions for a subscription that does not exist.");
}
// ensure a condition refresh is allowed.
subscription.ValidateConditionRefresh2(context, monitoredItemId);
var conditionRefreshTask = new ConditionRefreshTask(subscription, monitoredItemId);
lock (m_conditionRefreshLock)
{
if (!m_conditionRefreshQueue.Contains(conditionRefreshTask))
{
m_conditionRefreshQueue.Enqueue(conditionRefreshTask);
}
else
{
throw new ServiceResultException(StatusCodes.BadRefreshInProgress);
}
// trigger the refresh worker.
m_conditionRefreshEvent.Set();
}
}
/// <summary>
/// Completes a refresh conditions request.
/// </summary>
private async ValueTask DoConditionRefreshAsync(ISubscription subscription, CancellationToken cancellationToken = default)
{
try
{
if (m_logger.IsEnabled(LogLevel.Trace))
{
m_logger.LogTrace("Subscription ConditionRefresh started, Id={SubscriptionId}.", subscription.Id);
}
await subscription.ConditionRefreshAsync(cancellationToken)
.ConfigureAwait(false);
}
catch (Exception e)
{
m_logger.LogError(e, "Subscription - DoConditionRefresh Exited Unexpectedly");
}
}
/// <summary>
/// Completes a refresh conditions request.
/// </summary>
private async ValueTask DoConditionRefresh2Async(ISubscription subscription, uint monitoredItemId, CancellationToken cancellationToken = default)
{
try
{
if (m_logger.IsEnabled(LogLevel.Trace))
{
m_logger.LogTrace(
"Subscription ConditionRefresh2 started, Id={SubscriptionId}, MonitoredItemId={MonitoredItemId}.",
subscription.Id,
monitoredItemId);
}
await subscription.ConditionRefresh2Async(monitoredItemId, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception e)
{
m_logger.LogError(e, "Subscription - DoConditionRefresh2 Exited Unexpectedly");
}
}
/// <summary>
/// Deletes the specified subscription.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
public async ValueTask<StatusCode> DeleteSubscriptionAsync(OperationContext context, uint subscriptionId, CancellationToken cancellationToken = default)
{
ISubscription? subscription = null;
await m_semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// remove from publish queue.
if (m_subscriptions.TryGetValue(subscriptionId, out subscription))
{
NodeId sessionId = subscription.SessionId;
if (!sessionId.IsNull)
{
// check that the subscription is the owner.
if (context != null &&
!ReferenceEquals(context.Session, subscription.Session))
{
throw new ServiceResultException(StatusCodes.BadSubscriptionIdInvalid);
}
if (m_publishQueues.TryGetValue(sessionId, out SessionPublishQueue? queue))
{
queue.Remove(subscription, true);
}
}
}
// check for abandoned subscription.
if (m_abandonedSubscriptions.TryRemove(subscriptionId, out _))
{
m_logger.LogWarning(
"Subscription DELETED(ABANDONED), Id={SubscriptionId}.",
subscriptionId);
}
// remove subscription.
m_subscriptions.TryRemove(subscriptionId, out _);
}
finally
{
m_semaphoreSlim.Release();
}
if (subscription != null)
{
int monitoredItemCount = subscription.MonitoredItemCount;
// raise subscription event.
RaiseSubscriptionEvent(subscription, true);
// delete subscription.
await subscription.DeleteAsync(context, cancellationToken).ConfigureAwait(false);
// get the count for the diagnostics.
uint publishingIntervalCount = GetPublishingIntervalCount();
lock (m_server.DiagnosticsWriteLock)
{
ServerDiagnosticsSummaryDataType diagnostics = m_server.ServerDiagnostics;
diagnostics.CurrentSubscriptionCount--;
diagnostics.PublishingIntervalCount = publishingIntervalCount;
}
if (context != null && context.Session != null)
{
lock (context.Session.DiagnosticsLock)
{
SessionDiagnosticsDataType diagnostics = context.Session.SessionDiagnostics;
diagnostics.CurrentSubscriptionsCount--;
UpdateCurrentMonitoredItemsCount(diagnostics, -monitoredItemCount);
}
}
return StatusCodes.Good;
}
return StatusCodes.BadSubscriptionIdInvalid;
}
/// <summary>
/// Updates the current monitored item count for the session.
/// </summary>
private static void UpdateCurrentMonitoredItemsCount(
SessionDiagnosticsDataType diagnostics,
int change)
{
long monitoredItemsCount = diagnostics.CurrentMonitoredItemsCount;
monitoredItemsCount += change;
if (monitoredItemsCount > 0)
{
diagnostics.CurrentMonitoredItemsCount = (uint)monitoredItemsCount;
}
else
{
diagnostics.CurrentMonitoredItemsCount = 0;
}
}
/// <summary>
/// Gets the total number of publishing intervals in use.
/// </summary>
private uint GetPublishingIntervalCount()
{
var publishingDiagnostics = new Dictionary<double, uint>();
foreach (KeyValuePair<uint, ISubscription> kvp in m_subscriptions)
{
double publishingInterval = kvp.Value.PublishingInterval;
if (!publishingDiagnostics.TryGetValue(publishingInterval, out uint total))
{
total = 0;
}
publishingDiagnostics[publishingInterval] = total + 1;
}
return (uint)publishingDiagnostics.Count;
}
/// <summary>
/// Creates a new subscription.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
public virtual async ValueTask<CreateSubscriptionResponse> CreateSubscriptionAsync(
OperationContext context,
double requestedPublishingInterval,
uint requestedLifetimeCount,
uint requestedMaxKeepAliveCount,
uint maxNotificationsPerPublish,
bool publishingEnabled,
byte priority,
CancellationToken cancellationToken = default)
{
if (m_subscriptions.Count >= m_maxSubscriptionCount)
{
throw new ServiceResultException(StatusCodes.BadTooManySubscriptions);
}
uint subscriptionId;
double revisedPublishingInterval;
uint revisedLifetimeCount;
uint revisedMaxKeepAliveCount;
uint publishingIntervalCount = 0;
// get session from context.
ISession session = context.Session;
// assign new identifier.
subscriptionId = Utils.IncrementIdentifier(ref m_lastSubscriptionId);
// calculate publishing interval.
revisedPublishingInterval = CalculatePublishingInterval(requestedPublishingInterval);
// calculate the keep alive count.
revisedMaxKeepAliveCount = CalculateKeepAliveCount(
revisedPublishingInterval,
requestedMaxKeepAliveCount);
// calculate the lifetime count.
revisedLifetimeCount = CalculateLifetimeCount(
revisedPublishingInterval,
revisedMaxKeepAliveCount,
requestedLifetimeCount);
// calculate the max notification count.
maxNotificationsPerPublish = CalculateMaxNotificationsPerPublish(
maxNotificationsPerPublish);
// create the subscription.
ISubscription subscription = CreateSubscription(
context,
subscriptionId,
revisedPublishingInterval,
revisedLifetimeCount,
revisedMaxKeepAliveCount,
maxNotificationsPerPublish,
priority,
publishingEnabled);
await m_semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// save subscription.
if (!m_subscriptions.TryAdd(subscriptionId, subscription))
{
throw new ServiceResultException(StatusCodes.BadInternalError, "Failed to create subscription in Server");
}
// create/update publish queue.
m_publishQueues.AddOrUpdate(
session.Id,
(key) =>
{
var queue = new SessionPublishQueue(
m_server,
session,
m_maxPublishRequestCount,
m_timeProvider);
queue.Add(subscription);
return queue;
},
(key, queue) =>
{
queue.Add(subscription);
return queue;
}
);
}
finally
{
m_semaphoreSlim.Release();
}
// get the count for the diagnostics.
publishingIntervalCount = GetPublishingIntervalCount();
lock (m_statusMessagesLock)
{
if (!m_statusMessages.TryGetValue(
session.Id,
out Queue<StatusMessage>? messagesQueue))
{
m_statusMessages[session.Id] = new Queue<StatusMessage>();
}
}
lock (m_server.DiagnosticsWriteLock)
{
ServerDiagnosticsSummaryDataType diagnostics = m_server.ServerDiagnostics;
diagnostics.CurrentSubscriptionCount++;
diagnostics.CumulatedSubscriptionCount++;
diagnostics.PublishingIntervalCount = publishingIntervalCount;
}
if (context.Session != null)
{
lock (context.Session.DiagnosticsLock)
{
SessionDiagnosticsDataType diagnostics = context.Session.SessionDiagnostics;
diagnostics.CurrentSubscriptionsCount++;
}
}
// raise subscription event.
RaiseSubscriptionEvent(subscription, false);
return new CreateSubscriptionResponse
{
SubscriptionId = subscriptionId,
RevisedPublishingInterval = revisedPublishingInterval,
RevisedLifetimeCount = revisedLifetimeCount,
RevisedMaxKeepAliveCount = revisedMaxKeepAliveCount
};
}
/// <summary>
/// Deletes group of subscriptions.
/// </summary>
public async ValueTask<DeleteSubscriptionsResponse> DeleteSubscriptionsAsync(
OperationContext context,
ArrayOf<uint> subscriptionIds,
CancellationToken cancellationToken = default)
{
bool diagnosticsExist = false;
var results = new List<StatusCode>(subscriptionIds.Count);
var diagnosticInfos = new List<DiagnosticInfo>(subscriptionIds.Count);
foreach (uint subscriptionId in subscriptionIds.ToList())
{
try
{
StatusCode result = await DeleteSubscriptionAsync(context, subscriptionId, cancellationToken).ConfigureAwait(false);
results.Add(result);
if ((context.DiagnosticsMask & DiagnosticsMasks.OperationAll) != 0)
{
diagnosticInfos.Add(null!);
}
}
catch (Exception e)
{
m_logger.LogError(e, "Error occurred in DeleteSubscriptions");
var result = ServiceResult.Create(
e,
StatusCodes.BadUnexpectedError,
string.Empty);
results.Add(result.Code);
if ((context.DiagnosticsMask & DiagnosticsMasks.OperationAll) != 0)
{
DiagnosticInfo? diagnosticInfo = ServerUtils.CreateDiagnosticInfo(
m_server,
context,
result,
m_logger);
diagnosticInfos.Add(diagnosticInfo!);
diagnosticsExist = true;
}
}
}
if (!diagnosticsExist)
{
diagnosticInfos.Clear();
}
return new DeleteSubscriptionsResponse
{
Results = results,
DiagnosticInfos = diagnosticInfos
};
}
/// <summary>
/// Called when a subscription expires.
/// </summary>
/// <param name="subscription">The subscription.</param>
internal void SubscriptionExpired(ISubscription subscription)
{
lock (m_statusMessagesLock)
{
var message = new StatusMessage
{
SubscriptionId = subscription.Id,
Message = subscription.PublishTimeout()
};
if (!subscription.SessionId.IsNull &&
m_statusMessages.TryGetValue(
subscription.SessionId,
out Queue<StatusMessage>? queue))
{
queue.Enqueue(message);
}
}
}
/// <summary>
/// Publishes a subscription.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
public async Task<PublishResponse> PublishAsync(
OperationContext context,
ArrayOf<SubscriptionAcknowledgement> subscriptionAcknowledgements,
CancellationToken cancellationToken = default)
{
// get publish queue for session.
if (!m_publishQueues.TryGetValue(context.Session.Id, out SessionPublishQueue? queue))
{
if (m_subscriptions.IsEmpty)
{
throw new ServiceResultException(StatusCodes.BadNoSubscription);
}
throw new ServiceResultException(StatusCodes.BadSessionClosed);
}
// acknowledge previous messages.
queue.Acknowledge(
context,
subscriptionAcknowledgements,
out ArrayOf<StatusCode> acknowledgeResults,
out ArrayOf<DiagnosticInfo> acknowledgeDiagnosticInfos);
// update diagnostics.
if (context.Session != null)
{
lock (context.Session.DiagnosticsLock)
{
SessionDiagnosticsDataType diagnostics = context.Session.SessionDiagnostics;
diagnostics.CurrentPublishRequestsInQueue++;
}
}
try
{
if (m_logger.IsEnabled(LogLevel.Trace))
{
m_logger.LogTrace("Publish #{ClientHandle} ReceivedFromClient", context.ClientHandle);
}
// check for any pending status messages that need to be sent.
if (ReturnPendingStatusMessage(context, out NotificationMessage statusMessage, out uint statusSubscriptionId))
{
return new PublishResponse
{
SubscriptionId = statusSubscriptionId,
MoreNotifications = false,
NotificationMessage = statusMessage,
Results = acknowledgeResults,
DiagnosticInfos = acknowledgeDiagnosticInfos
};
}
bool requeue = false;
do
{
// blocks until a subscription is available or timeout expires.
// Publish requests always carry a channel context.
ISubscription subscription = await queue.PublishAsync(
context.ChannelContext!.SecureChannelId,
context.OperationDeadline,
requeue,
cancellationToken).ConfigureAwait(false);
// check for pending status message that may have arrived while waiting.
if (ReturnPendingStatusMessage(context, out statusMessage, out statusSubscriptionId))
{
if (subscription != null)
{
// requeue the subscription that was ready to publish.
queue.Requeue(subscription);
}
return new PublishResponse
{
SubscriptionId = statusSubscriptionId,
MoreNotifications = false,
NotificationMessage = statusMessage,
Results = acknowledgeResults,
DiagnosticInfos = acknowledgeDiagnosticInfos
};
}
// false alarm or race condition, requeue the request.
if (subscription == null)
{
requeue = true;
continue;
}
bool moreNotifications = false;
// publish notifications.
try
{
NotificationMessage? message = subscription.Publish(
context,
out ArrayOf<uint> availableSequenceNumbers,
out moreNotifications);
// a null message indicates a false alarm; requeue and wait for the next one.
if (message != null)
{
return new PublishResponse
{
SubscriptionId = subscription.Id,
AvailableSequenceNumbers = availableSequenceNumbers,
MoreNotifications = moreNotifications,
NotificationMessage = message,
Results = acknowledgeResults,
DiagnosticInfos = acknowledgeDiagnosticInfos
};
}
requeue = true;
if (m_logger.IsEnabled(LogLevel.Trace))
{
m_logger.LogTrace(
"Publish False Alarm - Request #{ClientHandle} Requeued.",
context.ClientHandle);
}
}
finally
{
queue.PublishCompleted(subscription, moreNotifications);
}
} while (requeue);
throw new ServiceResultException(StatusCodes.BadTimeout);
}
finally
{
// update diagnostics.
if (context.Session != null)
{
lock (context.Session.DiagnosticsLock)
{
SessionDiagnosticsDataType diagnostics = context.Session.SessionDiagnostics;
diagnostics.CurrentPublishRequestsInQueue--;
}
}
}
}
/// <summary>
/// Modifies an existing subscription.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
public void ModifySubscription(
OperationContext context,
uint subscriptionId,
double requestedPublishingInterval,
uint requestedLifetimeCount,
uint requestedMaxKeepAliveCount,
uint maxNotificationsPerPublish,
byte priority,
out double revisedPublishingInterval,
out uint revisedLifetimeCount,
out uint revisedMaxKeepAliveCount)
{
revisedPublishingInterval = requestedPublishingInterval;
revisedLifetimeCount = requestedLifetimeCount;
revisedMaxKeepAliveCount = requestedMaxKeepAliveCount;
// find subscription.
if (!m_subscriptions.TryGetValue(subscriptionId, out ISubscription? subscription))
{
throw new ServiceResultException(StatusCodes.BadSubscriptionIdInvalid);
}
_ = subscription.PublishingInterval;
// calculate publishing interval.
revisedPublishingInterval = CalculatePublishingInterval(requestedPublishingInterval);
// calculate the keep alive count.
revisedMaxKeepAliveCount = CalculateKeepAliveCount(
revisedPublishingInterval,
requestedMaxKeepAliveCount,
subscription.IsDurable);
// calculate the lifetime count.
revisedLifetimeCount = CalculateLifetimeCount(
revisedPublishingInterval,
revisedMaxKeepAliveCount,
requestedLifetimeCount,
subscription.IsDurable);
// calculate the max notification count.
maxNotificationsPerPublish = CalculateMaxNotificationsPerPublish(
maxNotificationsPerPublish);
// update the subscription.
subscription.Modify(
context,
revisedPublishingInterval,
revisedLifetimeCount,
revisedMaxKeepAliveCount,
maxNotificationsPerPublish,
priority);
// get the count for the diagnostics.
uint publishingIntervalCount = GetPublishingIntervalCount();
lock (m_server.DiagnosticsWriteLock)
{
ServerDiagnosticsSummaryDataType diagnostics = m_server.ServerDiagnostics;
diagnostics.PublishingIntervalCount = publishingIntervalCount;
}
}
/// <summary>
/// Sets a subscription into durable mode
/// </summary>
/// <param name="context">the system context.</param>
/// <param name="subscriptionId">Identifier of the Subscription.</param>
/// <param name="lifetimeInHours">The requested lifetime in hours for the durable Subscription.</param>
/// <param name="revisedLifetimeInHours">The revised lifetime in hours the Server applied to the durable Subscription.</param>
/// <exception cref="ServiceResultException"></exception>
public ServiceResult SetSubscriptionDurable(
ISystemContext context,
uint subscriptionId,
uint lifetimeInHours,
out uint revisedLifetimeInHours)
{
revisedLifetimeInHours = 0;
if (!m_subscriptions.TryGetValue(subscriptionId, out ISubscription? subscription))
{
throw new ServiceResultException(StatusCodes.BadSubscriptionIdInvalid);
}
NodeId curSession = (context as ISessionSystemContext)?.SessionId ?? default;
if (subscription.SessionId != curSession)
{
// user tries to access subscription of different session
return StatusCodes.BadUserAccessDenied;
}
if (subscription.MonitoredItemCount > 0)
{
// durable subscription can only be created before monitored items are created
return StatusCodes.BadInvalidState;
}
revisedLifetimeInHours = lifetimeInHours;
if (revisedLifetimeInHours == 0 ||
revisedLifetimeInHours > m_maxDurableSubscriptionLifetimeInHours)
{
revisedLifetimeInHours = m_maxDurableSubscriptionLifetimeInHours;
}
const uint hoursInSeconds = 3_600_000;
long lifetimeInSeconds = revisedLifetimeInHours * hoursInSeconds;
uint requestedLifeTimeCount = (uint)(lifetimeInSeconds /
subscription.PublishingInterval);
return subscription.SetSubscriptionDurable(requestedLifeTimeCount);
}
/// <summary>
/// Sets the publishing mode for a set of subscriptions.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
public void SetPublishingMode(
OperationContext context,
bool publishingEnabled,
ArrayOf<uint> subscriptionIds,
out ArrayOf<StatusCode> results,
out ArrayOf<DiagnosticInfo> diagnosticInfos)
{
bool diagnosticsExist = false;
var resultList = new List<StatusCode>(subscriptionIds.Count);
var diagnosticInfoList = new List<DiagnosticInfo>(subscriptionIds.Count);
for (int ii = 0; ii < subscriptionIds.Count; ii++)
{
try
{
// find subscription.
if (!m_subscriptions.TryGetValue(subscriptionIds[ii], out ISubscription? subscription))
{
throw new ServiceResultException(StatusCodes.BadSubscriptionIdInvalid);
}
// update the subscription.
subscription.SetPublishingMode(context, publishingEnabled);
// save results.
resultList.Add(StatusCodes.Good);
if ((context.DiagnosticsMask & DiagnosticsMasks.OperationAll) != 0)
{
diagnosticInfoList.Add(null!);
}
}
catch (Exception e)
{
if (e is not ServiceResultException)
{
m_logger.LogError(e, "Error occurred in SetPublishingMode");
}
var result = ServiceResult.Create(
e,
StatusCodes.BadUnexpectedError,
string.Empty);
resultList.Add(result.Code);
if ((context.DiagnosticsMask & DiagnosticsMasks.OperationAll) != 0)
{
DiagnosticInfo? diagnosticInfo = ServerUtils.CreateDiagnosticInfo(
m_server,
context,
result,
m_logger);
diagnosticInfoList.Add(diagnosticInfo!);
diagnosticsExist = true;
}
}
if (!diagnosticsExist)
{
diagnosticInfoList.Clear();
}
}
results = resultList;
diagnosticInfos = diagnosticInfoList;
}
/// <summary>
/// Attaches a groups of subscriptions to a different session.
/// </summary>
public async ValueTask<TransferSubscriptionsResponse> TransferSubscriptionsAsync(
OperationContext context,
ArrayOf<uint> subscriptionIds,
bool sendInitialValues,
CancellationToken cancellationToken = default)
{
var results = new List<TransferResult>();
var diagnosticInfos = new List<DiagnosticInfo>();
m_logger.LogInformation(
"TransferSubscriptions to SessionId={SessionId}, Count={Count}, sendInitialValues={SendInitialValues}",
context.Session.Id,
subscriptionIds.Count,
sendInitialValues);
for (int ii = 0; ii < subscriptionIds.Count; ii++)
{
var result = new TransferResult();
try
{
// find subscription.
if (!m_subscriptions.TryGetValue(subscriptionIds[ii], out ISubscription? subscription))
{
result.StatusCode = StatusCodes.BadSubscriptionIdInvalid;
results.Add(result);
if ((context.DiagnosticsMask & DiagnosticsMasks.OperationAll) != 0)
{
diagnosticInfos.Add(null!);
}
continue;
}
lock (subscription.DiagnosticsLock)
{
SubscriptionDiagnosticsDataType diagnostics = subscription.Diagnostics;
diagnostics.TransferRequestCount++;
}
// check if new and old sessions are different
ISession ownerSession = subscription.Session;
if (ownerSession != null &&
!ownerSession.Id.IsNull &&
ownerSession.Id == context.Session.Id)
{
result.StatusCode = StatusCodes.BadNothingToDo;
results.Add(result);
if ((context.DiagnosticsMask & DiagnosticsMasks.OperationAll) != 0)
{
diagnosticInfos.Add(null!);
}
continue;
}
// Validate the identity of the user who owns/owned the subscription
// is the same as the new owner.
bool validIdentity = subscription.EffectiveIdentity.TokenHandler.Equals(
context.Session.EffectiveIdentity.TokenHandler);
// Test if anonymous user is using a secure session using Sign or SignAndEncrypt
if (validIdentity &&
subscription.EffectiveIdentity.TokenType == UserTokenType.Anonymous)
{
MessageSecurityMode securityMode = context!.ChannelContext!
.EndpointDescription!
.SecurityMode;
validIdentity = securityMode
is MessageSecurityMode.Sign
or MessageSecurityMode.SignAndEncrypt;
}
// continue if identity check failed
if (!validIdentity)
{
result.StatusCode = StatusCodes.BadUserAccessDenied;
results.Add(result);
if ((context.DiagnosticsMask & DiagnosticsMasks.OperationAll) != 0)
{
diagnosticInfos.Add(null!);
}
continue;
}
// transfer session, add subscription to publish queue
await m_semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await subscription.TransferSessionAsync(context, sendInitialValues, cancellationToken).ConfigureAwait(false);
// remove from queue in old session
if (ownerSession != null &&
m_publishQueues.TryGetValue(
ownerSession.Id,
out SessionPublishQueue? ownerPublishQueue) &&
ownerPublishQueue != null)
{
// keep the queued requests for the status message
ownerPublishQueue.Remove(subscription, false);
}
// add to queue in new session, create queue if necessary
if (!m_publishQueues.TryGetValue(
context.SessionId,
out SessionPublishQueue? publishQueue) ||
publishQueue == null)
{
m_publishQueues[context.SessionId]
= publishQueue = new SessionPublishQueue(
m_server,
context.Session,
m_maxPublishRequestCount,
m_timeProvider);
}
publishQueue.Add(subscription);
}
finally
{
m_semaphoreSlim.Release();
}
lock (m_statusMessagesLock)
{
var processedQueue = new Queue<StatusMessage>();
if (m_statusMessages.TryGetValue(
context.SessionId,
out Queue<StatusMessage>? messagesQueue) &&
messagesQueue != null)
{
// There must not be any messages left from
// the transferred subscription
foreach (StatusMessage statusMessage in messagesQueue)
{
if (statusMessage.SubscriptionId == subscription.Id)
{
continue;
}
processedQueue.Enqueue(statusMessage);
}
}
m_statusMessages[context.SessionId] = processedQueue;
}
if (context.Session != null)
{
lock (context.Session.DiagnosticsLock)
{
SessionDiagnosticsDataType diagnostics = context.Session
.SessionDiagnostics;
diagnostics.CurrentSubscriptionsCount++;
}
}
// raise subscription event.
RaiseSubscriptionEvent(subscription, false);
result.StatusCode = StatusCodes.Good;
// Notify old session with Good_SubscriptionTransferred.
if (ownerSession != null)
{
lock (ownerSession.DiagnosticsLock)
{
SessionDiagnosticsDataType diagnostics = ownerSession
.SessionDiagnostics;
diagnostics.CurrentSubscriptionsCount--;
}
// queue the Good_SubscriptionTransferred message
bool statusQueued = false;
lock (m_statusMessagesLock)
{
if (!ownerSession.Id.IsNull &&
m_statusMessages.TryGetValue(
ownerSession.Id,
out Queue<StatusMessage>? queue))
{
var message = new StatusMessage
{
SubscriptionId = subscription.Id,
Message = subscription.SubscriptionTransferred()
};
queue.Enqueue(message);
statusQueued = true;
}
}
await m_semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// trigger publish response to return status immediately
if (m_publishQueues.TryGetValue(
ownerSession.Id,
out SessionPublishQueue? ownerPublishQueue) &&
ownerPublishQueue != null)
{
if (statusQueued)
{
// queue the status message
bool success = ownerPublishQueue.TryPublishCustomStatus(
StatusCodes.GoodSubscriptionTransferred);
if (!success)
{
m_logger.LogWarning(
"Failed to queue Good_SubscriptionTransferred for SessionId {SessionId}, SubscriptionId {SubscriptionId} due to an empty request queue.",
ownerSession.Id,
subscription.Id);
}
}
// check to remove queued requests if no subscriptions are active
ownerPublishQueue.RemoveQueuedRequests();
}
}
finally
{
m_semaphoreSlim.Release();
}
}
// Return the sequence numbers that are available for retransmission.
result.AvailableSequenceNumbers = subscription
.AvailableSequenceNumbersForRetransmission();
lock (subscription.DiagnosticsLock)
{
SubscriptionDiagnosticsDataType diagnostics = subscription.Diagnostics;
diagnostics.TransferredToSameClientCount++;
}
// save results.
results.Add(result);
if ((context.DiagnosticsMask & DiagnosticsMasks.OperationAll) != 0)
{
diagnosticInfos.Add(null!);
}
m_logger.LogInformation(
"Transferred subscription Id {SubscriptionId} to SessionId {SessionId}",
subscription.Id,
context.Session!.Id);
}
catch (Exception e)
{
result.StatusCode = StatusCodes.Bad;
if ((context.DiagnosticsMask & DiagnosticsMasks.OperationAll) != 0)
{
diagnosticInfos.Add(
new DiagnosticInfo(e, context.DiagnosticsMask, false, null!, m_logger));
}
}
for (int i = 0; i < results.Count; i++)
{
m_server.ReportAuditTransferSubscriptionEvent(
context.AuditEntryId,
context.Session,
results[i].StatusCode,
m_logger);
}
}
return new TransferSubscriptionsResponse
{
Results = results,
DiagnosticInfos = diagnosticInfos
};
}
/// <summary>
/// Republishes a previously published notification message.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
public NotificationMessage Republish(
OperationContext context,
uint subscriptionId,
uint retransmitSequenceNumber)
{
// find subscription.
if (!m_subscriptions.TryGetValue(subscriptionId, out ISubscription? subscription))
{
throw new ServiceResultException(StatusCodes.BadSubscriptionIdInvalid);
}
// fetch the message.
return subscription.Republish(context, retransmitSequenceNumber);
}
/// <summary>
/// Updates the triggers for the monitored item.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
public void SetTriggering(
OperationContext context,
uint subscriptionId,
uint triggeringItemId,
ArrayOf<uint> linksToAdd,
ArrayOf<uint> linksToRemove,
out ArrayOf<StatusCode> addResults,
out ArrayOf<DiagnosticInfo> addDiagnosticInfos,
out ArrayOf<StatusCode> removeResults,
out ArrayOf<DiagnosticInfo> removeDiagnosticInfos)
{
// find subscription.
if (!m_subscriptions.TryGetValue(subscriptionId, out ISubscription? subscription))
{
throw new ServiceResultException(StatusCodes.BadSubscriptionIdInvalid);
}
// update the triggers.
subscription.SetTriggering(
context,
triggeringItemId,
linksToAdd,
linksToRemove,
out addResults,
out addDiagnosticInfos,
out removeResults,
out removeDiagnosticInfos);
}
/// <summary>
/// Adds monitored items to a subscription.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
public async ValueTask<CreateMonitoredItemsResponse> CreateMonitoredItemsAsync(
OperationContext context,
uint subscriptionId,
TimestampsToReturn timestampsToReturn,
ArrayOf<MonitoredItemCreateRequest> itemsToCreate,
CancellationToken cancellationToken = default)
{
// find subscription.
if (!m_subscriptions.TryGetValue(subscriptionId, out ISubscription? subscription))
{
throw new ServiceResultException(StatusCodes.BadSubscriptionIdInvalid);
}
int currentMonitoredItemCount = subscription.MonitoredItemCount;
// create the items.
CreateMonitoredItemsResponse response = await subscription.CreateMonitoredItemsAsync(
context,
timestampsToReturn,
itemsToCreate,
cancellationToken).ConfigureAwait(false);
int monitoredItemCountIncrement = subscription.MonitoredItemCount -
currentMonitoredItemCount;
// update diagnostics.
if (context.Session != null)
{
lock (context.Session.DiagnosticsLock)
{
SessionDiagnosticsDataType diagnostics = context.Session.SessionDiagnostics;
UpdateCurrentMonitoredItemsCount(diagnostics, monitoredItemCountIncrement);
}
}
return response;
}
/// <summary>
/// Modifies monitored items in a subscription.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
public ValueTask<ModifyMonitoredItemsResponse> ModifyMonitoredItemsAsync(
OperationContext context,
uint subscriptionId,
TimestampsToReturn timestampsToReturn,
ArrayOf<MonitoredItemModifyRequest> itemsToModify,
CancellationToken cancellationToken = default)
{
// find subscription.
if (!m_subscriptions.TryGetValue(subscriptionId, out ISubscription? subscription))
{
throw new ServiceResultException(StatusCodes.BadSubscriptionIdInvalid);
}
// modify the items.
return subscription.ModifyMonitoredItemsAsync(
context,
timestampsToReturn,
itemsToModify,
cancellationToken);
}
/// <summary>
/// Deletes the monitored items in a subscription.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
public async ValueTask<DeleteMonitoredItemsResponse> DeleteMonitoredItemsAsync(
OperationContext context,
uint subscriptionId,
ArrayOf<uint> monitoredItemIds,
CancellationToken cancellationToken = default)
{
// find subscription.
if (!m_subscriptions.TryGetValue(subscriptionId, out ISubscription? subscription))
{
throw new ServiceResultException(StatusCodes.BadSubscriptionIdInvalid);
}
int currentMonitoredItemCount = subscription.MonitoredItemCount;
// create the items.
DeleteMonitoredItemsResponse response = await subscription.DeleteMonitoredItemsAsync(
context,
monitoredItemIds,
cancellationToken).ConfigureAwait(false);
int monitoredItemCountIncrement = subscription.MonitoredItemCount -
currentMonitoredItemCount;
// update diagnostics.
if (context.Session != null)
{
lock (context.Session.DiagnosticsLock)
{
SessionDiagnosticsDataType diagnostics = context.Session.SessionDiagnostics;
UpdateCurrentMonitoredItemsCount(diagnostics, monitoredItemCountIncrement);
}
}
return response;
}
/// <summary>
/// Changes the monitoring mode for a set of items.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
public ValueTask<(ArrayOf<StatusCode> results, ArrayOf<DiagnosticInfo> diagnosticInfos)> SetMonitoringModeAsync(
OperationContext context,
uint subscriptionId,
MonitoringMode monitoringMode,
ArrayOf<uint> monitoredItemIds,
CancellationToken cancellationToken = default)
{
// find subscription.
if (!m_subscriptions.TryGetValue(subscriptionId, out ISubscription? subscription))
{
throw new ServiceResultException(StatusCodes.BadSubscriptionIdInvalid);
}
// create the items.
return subscription.SetMonitoringModeAsync(
context,
monitoringMode,
monitoredItemIds,
cancellationToken);
}
/// <summary>
/// Calculate a revised queue size for a monitored item based on the provided maximum allowed queue sizes.
/// depending if an item is durable
/// </summary>
/// <param name="isDurable">the item to create is a part of a durable subscription</param>
/// <param name="queueSize">the queue size to revise</param>
/// <param name="maxQueueSize">the maximum queue size for regular subscriptions</param>
/// <param name="maxDurableQueueSize">the maxmimum queue size for durable subscriptions</param>
/// <returns>the revised queue size</returns>
public static uint CalculateRevisedQueueSize(
bool isDurable,
uint queueSize,
uint maxQueueSize,
uint maxDurableQueueSize)
{
//reqular limit
if (queueSize > maxQueueSize && !isDurable)
{
return maxQueueSize;
}
//durable subscription limit
if (queueSize > maxDurableQueueSize && isDurable)
{
return maxDurableQueueSize;
}
//no revision needed as size within limits
return queueSize;
}
/// <summary>
/// Calculates the publishing interval.
/// </summary>
protected virtual double CalculatePublishingInterval(double publishingInterval)
{
if (double.IsNaN(publishingInterval) || publishingInterval < m_minPublishingInterval)
{
publishingInterval = m_minPublishingInterval;
}
if (publishingInterval > m_maxPublishingInterval)
{
publishingInterval = m_maxPublishingInterval;
}
if (publishingInterval < m_publishingResolution)
{
publishingInterval = m_publishingResolution;
}
if (publishingInterval % m_publishingResolution != 0)
{
publishingInterval =
((((int)publishingInterval) / m_publishingResolution) + 1) *
m_publishingResolution;
}
return publishingInterval;
}
/// <summary>
/// Calculates the keep alive count.
/// </summary>
protected virtual uint CalculateKeepAliveCount(
double publishingInterval,
uint keepAliveCount,
bool isDurableSubscription = false)
{
// set default.
if (keepAliveCount == 0)
{
keepAliveCount = 3;
}
ulong maxSubscriptionLifetime = isDurableSubscription
? m_maxDurableSubscriptionLifetimeInHours
: m_maxSubscriptionLifetime;
double keepAliveInterval = keepAliveCount * publishingInterval;
// keep alive interval cannot be longer than the max subscription lifetime.
if (keepAliveInterval > maxSubscriptionLifetime)
{
keepAliveCount = (uint)(maxSubscriptionLifetime / publishingInterval);
if (keepAliveCount < uint.MaxValue &&
maxSubscriptionLifetime % publishingInterval != 0)
{
keepAliveCount++;
}
keepAliveInterval = keepAliveCount * publishingInterval;
}
// the time between publishes cannot exceed the max publishing interval.
if (keepAliveInterval > m_maxPublishingInterval)
{
keepAliveCount = (uint)(m_maxPublishingInterval / publishingInterval);
if (keepAliveCount < uint.MaxValue &&
m_maxPublishingInterval % publishingInterval != 0)
{
keepAliveCount++;
}
}
return keepAliveCount;
}
/// <summary>
/// Calculates the lifetime count.
/// </summary>
protected virtual uint CalculateLifetimeCount(
double publishingInterval,
uint keepAliveCount,
uint lifetimeCount,
bool isDurableSubscription = false)
{
const int kMillisecondsToHours = 3_600_000;
ulong maxSubscriptionLifetime = isDurableSubscription
? m_maxDurableSubscriptionLifetimeInHours * kMillisecondsToHours
: m_maxSubscriptionLifetime;
double lifetimeInterval = lifetimeCount * publishingInterval;
// lifetime cannot be longer than the max subscription lifetime.
if (lifetimeInterval > maxSubscriptionLifetime)
{
lifetimeCount = (uint)(maxSubscriptionLifetime / publishingInterval);
if (lifetimeCount < uint.MaxValue &&
maxSubscriptionLifetime % publishingInterval != 0)
{
lifetimeCount++;
}
}
// the lifetime must be greater than the keepalive.
if (keepAliveCount < uint.MaxValue / 3)
{
if (keepAliveCount * 3 > lifetimeCount)
{
lifetimeCount = keepAliveCount * 3;
}
lifetimeInterval = lifetimeCount * publishingInterval;
}
else
{
lifetimeCount = uint.MaxValue;
lifetimeInterval = double.MaxValue;
}
// apply the minimum.
if (m_minSubscriptionLifetime > publishingInterval &&
m_minSubscriptionLifetime > lifetimeInterval)
{
lifetimeCount = (uint)(m_minSubscriptionLifetime / publishingInterval);
if (lifetimeCount < uint.MaxValue &&
m_minSubscriptionLifetime % publishingInterval != 0)
{
lifetimeCount++;
}
}
return lifetimeCount;
}
/// <summary>
/// Calculates the maximum number of notifications per publish.
/// </summary>
protected virtual uint CalculateMaxNotificationsPerPublish(uint maxNotificationsPerPublish)
{
if (maxNotificationsPerPublish == 0 ||
maxNotificationsPerPublish > m_maxNotificationsPerPublish)
{
return m_maxNotificationsPerPublish;
}
return maxNotificationsPerPublish;
}
/// <summary>
/// Creates a new instance of a subscription.
/// </summary>
protected virtual ISubscription CreateSubscription(
OperationContext context,
uint subscriptionId,
double publishingInterval,
uint lifetimeCount,
uint keepAliveCount,
uint maxNotificationsPerPublish,
byte priority,
bool publishingEnabled)
{
return new Subscription(
m_server,
context.Session,
subscriptionId,
publishingInterval,
lifetimeCount,
keepAliveCount,
maxNotificationsPerPublish,
priority,
publishingEnabled,
m_maxMessageCount);
}
/// <summary>
/// Checks if there is a status message to return.
/// </summary>
private bool ReturnPendingStatusMessage(
OperationContext context,
out NotificationMessage message,
out uint subscriptionId)
{
message = null!;
subscriptionId = 0;
// check for status messages.
lock (m_statusMessagesLock)
{
if (m_statusMessages.TryGetValue(
context.SessionId,
out Queue<StatusMessage>? statusQueue) &&
statusQueue.Count > 0)
{
StatusMessage status = statusQueue.Dequeue();
subscriptionId = status.SubscriptionId;
message = status.Message!;
return true;
}
}
return false;
}
/// <summary>
/// Periodically checks if the sessions have timed out.
/// </summary>
private async ValueTask PublishSubscriptionsAsync(int sleepCycle, CancellationToken cancellationToken = default)
{
try
{
m_logger.LogInformation(
"Subscription - Publish Task {TaskId:X8} Started.",
Task.CurrentId);
int timeToWait = sleepCycle;
while (true)
{
// ConcurrentDictionary enumeration is thread-safe and provides a stable
// snapshot for the current pass without taking the manager semaphore.
SessionPublishQueue[] queues = [.. m_publishQueues.Values];
ISubscription[] abandonedSubscriptions = [.. m_abandonedSubscriptions.Values];
// check the publish timer for each subscription.
for (int ii = 0; ii < queues.Length; ii++)
{
queues[ii].PublishTimerExpired();
}
// check the publish timer for each abandoned subscription.
if (abandonedSubscriptions.Length > 0)
{
var subscriptionsToDelete = new List<ISubscription>();
for (int ii = 0; ii < abandonedSubscriptions.Length; ii++)
{
ISubscription subscription = abandonedSubscriptions[ii];
if (subscription.PublishTimerExpired() != PublishingState.Expired)
{
continue;
}
subscriptionsToDelete.Add(subscription);
SubscriptionExpired(subscription);
m_logger.LogInformation(
"Subscription - Abandoned Subscription Id={SubscriptionId} Delete Scheduled.",
subscription.Id);
}
// schedule cleanup on a background thread.
if (subscriptionsToDelete.Count > 0)
{
for (int ii = 0; ii < subscriptionsToDelete.Count; ii++)
{
m_abandonedSubscriptions.TryRemove(subscriptionsToDelete[ii].Id, out _);
}
CleanupSubscriptions(m_server, subscriptionsToDelete, m_logger);
}
}
if (m_shutdownEvent.WaitOne(0))
{
m_logger.LogInformation(
"Subscription - Publish Task {TaskId:X8} Exited Normally.",
Task.CurrentId);
break;
}
await m_timeProvider.Delay(TimeSpan.FromMilliseconds(timeToWait), cancellationToken).ConfigureAwait(false);
}
}
catch (ObjectDisposedException)
{
m_logger.LogInformation(
"Subscription - Publish Task {TaskId:X8} Exited Normally (disposed during shutdown).",
Task.CurrentId);
}
catch (Exception e)
{
m_logger.LogError(
e,
"Subscription - Publish Task {TaskId:X8} Exited Unexpectedly.",
Task.CurrentId);
}
}
/// <summary>
/// A single thread to execute the condition refresh.
/// </summary>
private async Task ConditionRefreshWorkerAsync()
{
try
{
m_logger.LogInformation(
"Subscription - ConditionRefresh Task {TaskId:X8} Started.",
Task.CurrentId);
while (true)
{
ConditionRefreshTask? conditionRefreshTask = null;
lock (m_conditionRefreshLock)
{
if (m_conditionRefreshQueue.Count > 0)
{
conditionRefreshTask = m_conditionRefreshQueue.Dequeue();
}
else
{
m_conditionRefreshEvent.Reset();
}
}
if (conditionRefreshTask == null)
{
m_conditionRefreshEvent.WaitOne();
}
else if (conditionRefreshTask.MonitoredItemId == 0)
{
await DoConditionRefreshAsync(conditionRefreshTask.Subscription)
.ConfigureAwait(false);
}
else
{
await DoConditionRefresh2Async(
conditionRefreshTask.Subscription,
conditionRefreshTask.MonitoredItemId)
.ConfigureAwait(false);
}
// use shutdown event to end loop
if (m_shutdownEvent.WaitOne(0))
{
m_logger.LogInformation(
"Subscription - ConditionRefresh Task {TaskId:X8} Exited Normally.",
Task.CurrentId);
break;
}
}
}
catch (ObjectDisposedException)
{
m_logger.LogInformation(
"Subscription - ConditionRefresh Task {TaskId:X8} Exited Normally (disposed during shutdown).",
Task.CurrentId);
}
catch (Exception e)
{
m_logger.LogError(
e,
"Subscription - ConditionRefresh Task {TaskId:X8} Exited Unexpectedly.",
Task.CurrentId);
}
}
/// <summary>
/// Cleanups the subscriptions.
/// </summary>
/// <param name="server">The server.</param>
/// <param name="subscriptionsToDelete">The subscriptions to delete.</param>
/// <param name="logger">A contextual logger to log to</param>
internal static void CleanupSubscriptions(
IServerInternal server,
IList<ISubscription> subscriptionsToDelete,
ILogger logger)
{
if (subscriptionsToDelete != null && subscriptionsToDelete.Count > 0)
{
logger.LogInformation(
"Server - {Count} Subscriptions scheduled for delete.",
subscriptionsToDelete.Count);
_ = Task.Run(
() => CleanupSubscriptionsCoreAsync(server, subscriptionsToDelete, logger));
}
}
/// <summary>
/// Deletes any expired subscriptions.
/// </summary>
private static async ValueTask CleanupSubscriptionsCoreAsync(
IServerInternal server,
IList<ISubscription> subscriptionsToDelete,
ILogger logger,
CancellationToken cancellationToken = default)
{
try
{
logger.LogInformation("Server - CleanupSubscriptions Task Started");
foreach (ISubscription subscription in subscriptionsToDelete)
{
await server.DeleteSubscriptionAsync(subscription.Id, cancellationToken).ConfigureAwait(false);
}
logger.LogInformation("Server - CleanupSubscriptions Task Completed");
}
catch (Exception e)
{
logger.LogError(e, "Server - CleanupSubscriptions Task Halted Unexpectedly");
}
}
private class StatusMessage
{
public uint SubscriptionId;
public NotificationMessage? Message;
}
private class ConditionRefreshTask
{
public ConditionRefreshTask(ISubscription subscription, uint monitoredItemId)
{
Subscription = subscription;
MonitoredItemId = monitoredItemId;
}
public ISubscription Subscription { get; }
public uint MonitoredItemId { get; }
public override bool Equals(object? obj)
{
return obj is ConditionRefreshTask crt &&
Subscription?.Id == crt.Subscription?.Id &&
MonitoredItemId == crt.MonitoredItemId;
}
public override int GetHashCode()
{
return HashCode.Combine(Subscription.Id, MonitoredItemId);
}
}
private readonly SemaphoreSlim m_semaphoreSlim = new(1, 1);
private uint m_lastSubscriptionId;
private readonly ILogger m_logger;
private readonly IServerInternal m_server;
private readonly TimeProvider m_timeProvider;
private readonly double m_minPublishingInterval;
private readonly double m_maxPublishingInterval;
private readonly int m_publishingResolution;
private readonly uint m_maxSubscriptionLifetime;
private readonly uint m_maxDurableSubscriptionLifetimeInHours;
private readonly uint m_minSubscriptionLifetime;
private readonly uint m_maxMessageCount;
private readonly uint m_maxNotificationsPerPublish;
private readonly int m_maxPublishRequestCount;
private readonly int m_maxSubscriptionCount;
private readonly bool m_durableSubscriptionsEnabled;
private readonly ConcurrentDictionary<uint, ISubscription> m_subscriptions;
private readonly ConcurrentDictionary<uint, ISubscription> m_abandonedSubscriptions;
private readonly NodeIdDictionary<Queue<StatusMessage>> m_statusMessages;
private readonly NodeIdDictionary<SessionPublishQueue> m_publishQueues;
private readonly ManualResetEvent m_shutdownEvent;
private readonly Queue<ConditionRefreshTask> m_conditionRefreshQueue;
private readonly ManualResetEvent m_conditionRefreshEvent;
private readonly ISubscriptionStore m_subscriptionStore;
private readonly Lock m_statusMessagesLock = new();
private readonly Lock m_eventLock = new();
private readonly Lock m_conditionRefreshLock = new();
private event SubscriptionEventHandler? m_SubscriptionCreated;
private event SubscriptionEventHandler? m_SubscriptionDeleted;
}
}