Files
UA-.NETStandard/Libraries/Opc.Ua.Client/SubscriptionAsync.cs
T
Elijah Burshtein 92ae1d324e Enable Subscription to invoke callbacks sequentially and synchronously (#1493)
Pertaining to the discussion started in Issue #957, this Pull Request seeks to add capabilities for Subscription to sequentially invoke its MonitoredItem and FastDataChange callbacks to maintain order properly. The key goal is to avoid out-of-order monitored items. To this effect, the Sequential Publishing capability is added to Subscription
Resolves #957 

### SequentialPublishing
A toggle which forces Subscription to raise callbacks only in a sequential order by the SequenceNumber of the incoming message. It limits the number of tasks that can process publish responses to 1, and enforces a strict +1 Sequence Number requirement before releasing a message.

#### Backwards compatibility
The new property has been added as DataMember at the end to maintain backwards compatibility with the DataContract, where such may be used. The relevant copy constructor has also been updated.

The feature is disabled by default, so the existing behavior is the default. Users must explicitly set the property on the Subscription object to enable these new features. 

#### Leveraging existing "time machine" in SaveMessageInCache
There is already a mechanism for re-ordering messages that entered in the wrong order. This change makes use of that existing "time machine" which sorts messages into chronological order (by the sequence number) to only pull off messages with a proper +1 sequence number each time.

#### KeepAlive messages advancing sequence number
KeepAlive messages do not contain notifications, and do not enter the proper branch in the code to be pulled out. They will not interrupt sequential flow. Since the next message in the sequence with data will "re-use" that sequence number, it can be expected to maintain sequence. 

#### Delayed messages
When sequential publishing is enabled, if a message is genuinely missing from the sequence, it will "hold up" the messages until it either arrives or is pulled out of the incoming messages list by being too old. In either case, it is considered "processed" for the sequence purposes at that point and the rest of the messages may proceed. 

The automatic republish request mechanism is also leveraged here for that purpose as well.

#### SequenceNumber roll-over after max value
As specified in the OPC UA spec, at 1ms publishing, this would take almost 50 days of uptime. Naturally at any "reasonable" publishing interval this time is even longer. The matter itself is also not addressed throughout the whole existing code that handled it before this change. (Though some consideration can be seen in some places, such as placing messages at the end of the incoming message linked list if no better place was found) 

#### Locking on m_cache to get the semaphore
I've considered making a separate locking object for the semaphore management, but decided it is not needed. The message worker would need to obtain m_cache lock later anyway, and momentarily obtaining it is not harmful for the overall flow.
Only in cases of serious contention between new PublishResponses coming in and workers attempting to work would this cause any problems.

#### Callbacks that "take a long time"
This change will naturally suffer from callbacks that take a long time to invoke. In our use case, each callback message is passed into an ActionBlock for handling, so our callbacks are fast. But Other clients may invoke much more code on these callbacks.
Sequential publishing means this capacity is possibly bottlenecked. Sequential publishing should be used for its intended purpose and with callbacks that do not hold up their calling thread for a long time. To ensure proper sequential callbacks, they cannot just be started in order, they have to be processed fully in order. 

#### Changing this property while the Subscription is active
There is limited support to on-the-fly changes to this property, and this is not the intended use case. It is meant to be set once and ideally never changed while messages are being processed.

If users need certain items properly sequenced and some items they can accept out-of-order or ignore outdated messages, they can do this by defining two separate Subscription objects, same as they would if they needed different Publishing Intervals. 

#### Why semaphore?
At first I had considered using ActionBlock from TPL DataFlow and limiting its concurrency to the number of desired message workers, but I had opted not to add a dependency on DataFlow only for this purpose. (Referencing all of DataFlow just for ActionBlock's automatic concurrency limiting is wasteful.

I had also considered using a Limited Concurrency Task Scheduler implementation and queueing the message workers on that instead of the normal ThreadPool via Task.Run, but that is similarly more complex than it needs to be.

#### Why async Task? What was wrong with Void?
While being on a new Task from the ThreadPool anyway, the worker would still occupy a task from the ThreadPool if it was using the synchronous Wait() method, which is a blocking call. By using WaitAsync, the ThreadPool task goes "back" to the ThreadPool until the semaphore is released. 

### Disclaimer - I am not an OPC UA expert
Please do not assume I have "done all my homework" with regard to these changes. I have attempted to learn as much as I can in the time I was working on this change, but I fully expect to have missed a few spots and specifics of the OPC UA protocol.

I have done some testing in our application to verify the out-of-order problem is solved, and that no significant delay is introduced by the additional work done to facilitate this.
2021-09-16 18:58:51 +02:00

475 lines
16 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2019 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/
* ======================================================================*/
#if CLIENT_ASYNC
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Opc.Ua.Bindings;
namespace Opc.Ua.Client
{
/// <summary>
/// The async interface for a subscription.
/// </summary>
public partial class Subscription
{
#region Public Async Methods (TPL)
/// <summary>
/// Creates a subscription on the server and adds all monitored items.
/// </summary>
public async Task CreateAsync(CancellationToken ct = default)
{
VerifySubscriptionState(false);
// create the subscription.
uint revisedMaxKeepAliveCount = m_keepAliveCount;
uint revisedLifetimeCount = m_lifetimeCount;
AdjustCounts(ref revisedMaxKeepAliveCount, ref revisedLifetimeCount);
ManageMessageWorkerSemaphore();
var response = await m_session.CreateSubscriptionAsync(
null,
m_publishingInterval,
revisedLifetimeCount,
revisedMaxKeepAliveCount,
m_maxNotificationsPerPublish,
m_publishingEnabled,
m_priority,
ct).ConfigureAwait(false);
CreateSubscription(
response.SubscriptionId,
response.RevisedPublishingInterval,
response.RevisedMaxKeepAliveCount,
response.RevisedLifetimeCount);
await CreateItemsAsync(ct).ConfigureAwait(false);
ChangesCompleted();
}
/// <summary>
/// Deletes a subscription on the server.
/// </summary>
public async Task DeleteAsync(bool silent, CancellationToken ct = default)
{
if (!silent)
{
VerifySubscriptionState(true);
}
// nothing to do if not created.
if (!this.Created)
{
return;
}
try
{
// stop the publish timer.
if (m_publishTimer != null)
{
m_publishTimer.Dispose();
m_publishTimer = null;
}
// delete the subscription.
UInt32Collection subscriptionIds = new uint[] { m_id };
var response = await m_session.DeleteSubscriptionsAsync(
null,
subscriptionIds,
ct).ConfigureAwait(false);
// validate response.
ClientBase.ValidateResponse(response.Results, subscriptionIds);
ClientBase.ValidateDiagnosticInfos(response.DiagnosticInfos, subscriptionIds);
if (StatusCode.IsBad(response.Results[0]))
{
throw new ServiceResultException(
ClientBase.GetResult(response.Results[0], 0, response.DiagnosticInfos, response.ResponseHeader));
}
}
// supress exception if silent flag is set.
catch (Exception e)
{
if (!silent)
{
throw new ServiceResultException(e, StatusCodes.BadUnexpectedError);
}
}
// always put object in disconnected state even if an error occurs.
finally
{
DeleteSubscription();
}
ChangesCompleted();
}
/// <summary>
/// Modifies a subscription on the server.
/// </summary>
public async Task ModifyAsync(CancellationToken ct = default)
{
VerifySubscriptionState(true);
// modify the subscription.
uint revisedKeepAliveCount = m_keepAliveCount;
uint revisedLifetimeCounter = m_lifetimeCount;
AdjustCounts(ref revisedKeepAliveCount, ref revisedLifetimeCounter);
var response = await m_session.ModifySubscriptionAsync(
null,
m_id,
m_publishingInterval,
revisedLifetimeCounter,
revisedKeepAliveCount,
m_maxNotificationsPerPublish,
m_priority,
ct).ConfigureAwait(false);
// update current state.
ModifySubscription(
response.RevisedPublishingInterval,
response.RevisedMaxKeepAliveCount,
response.RevisedLifetimeCount);
ChangesCompleted();
}
/// <summary>
/// Changes the publishing enabled state for the subscription.
/// </summary>
public async Task SetPublishingModeAsync(bool enabled, CancellationToken ct = default)
{
VerifySubscriptionState(true);
// modify the subscription.
UInt32Collection subscriptionIds = new uint[] { m_id };
var response = await m_session.SetPublishingModeAsync(
null,
enabled,
new uint[] { m_id },
ct).ConfigureAwait(false);
// validate response.
ClientBase.ValidateResponse(response.Results, subscriptionIds);
ClientBase.ValidateDiagnosticInfos(response.DiagnosticInfos, subscriptionIds);
if (StatusCode.IsBad(response.Results[0]))
{
throw new ServiceResultException(
ClientBase.GetResult(response.Results[0], 0, response.DiagnosticInfos, response.ResponseHeader));
}
// update current state.
m_currentPublishingEnabled = m_publishingEnabled = enabled;
m_changeMask |= SubscriptionChangeMask.Modified;
ChangesCompleted();
}
/// <summary>
/// Republishes the specified notification message.
/// </summary>
public async Task<NotificationMessage> RepublishAsync(uint sequenceNumber, CancellationToken ct = default)
{
VerifySubscriptionState(true);
var response = await m_session.RepublishAsync(
null,
m_id,
sequenceNumber,
ct).ConfigureAwait(false);
return response.NotificationMessage;
}
/// <summary>
/// Applies any changes to the subscription items.
/// </summary>
public async Task ApplyChangesAsync(CancellationToken ct = default)
{
await DeleteItemsAsync(ct).ConfigureAwait(false);
await ModifyItemsAsync(ct).ConfigureAwait(false);
await CreateItemsAsync(ct).ConfigureAwait(false);
}
/// <summary>
/// Resolves all relative paths to nodes on the server.
/// </summary>
public async Task ResolveItemNodeIdsAsync(CancellationToken ct)
{
VerifySubscriptionState(true);
// collect list of browse paths.
BrowsePathCollection browsePaths = new BrowsePathCollection();
List<MonitoredItem> itemsToBrowse = new List<MonitoredItem>();
PrepareResolveItemNodeIds(browsePaths, itemsToBrowse);
// nothing to do.
if (browsePaths.Count == 0)
{
return;
}
// translate browse paths.
var response = await m_session.TranslateBrowsePathsToNodeIdsAsync(
null,
browsePaths,
ct).ConfigureAwait(false);
BrowsePathResultCollection results = response.Results;
ClientBase.ValidateResponse(results, browsePaths);
ClientBase.ValidateDiagnosticInfos(response.DiagnosticInfos, browsePaths);
// update results.
for (int ii = 0; ii < results.Count; ii++)
{
itemsToBrowse[ii].SetResolvePathResult(results[ii], ii, response.DiagnosticInfos, response.ResponseHeader);
}
m_changeMask |= SubscriptionChangeMask.ItemsModified;
}
/// <summary>
/// Creates all items on the server that have not already been created.
/// </summary>
public async Task<IList<MonitoredItem>> CreateItemsAsync(CancellationToken ct = default)
{
List<MonitoredItem> itemsToCreate;
MonitoredItemCreateRequestCollection requestItems = PrepareItemsToCreate(out itemsToCreate);
if (requestItems.Count == 0)
{
return itemsToCreate;
}
// create monitored items.
var response = await m_session.CreateMonitoredItemsAsync(
null,
m_id,
m_timestampsToReturn,
requestItems,
ct).ConfigureAwait(false);
MonitoredItemCreateResultCollection results = response.Results;
ClientBase.ValidateResponse(results, itemsToCreate);
ClientBase.ValidateDiagnosticInfos(response.DiagnosticInfos, itemsToCreate);
// update results.
for (int ii = 0; ii < results.Count; ii++)
{
itemsToCreate[ii].SetCreateResult(requestItems[ii], results[ii], ii, response.DiagnosticInfos, response.ResponseHeader);
}
m_changeMask |= SubscriptionChangeMask.ItemsCreated;
ChangesCompleted();
// return the list of items affected by the change.
return itemsToCreate;
}
/// <summary>
/// Modies all items that have been changed.
/// </summary>
public async Task<IList<MonitoredItem>> ModifyItemsAsync(CancellationToken ct = default)
{
VerifySubscriptionState(true);
MonitoredItemModifyRequestCollection requestItems = new MonitoredItemModifyRequestCollection();
List<MonitoredItem> itemsToModify = new List<MonitoredItem>();
PrepareItemsToModify(requestItems, itemsToModify);
if (requestItems.Count == 0)
{
return itemsToModify;
}
// modify the subscription.
var response = await m_session.ModifyMonitoredItemsAsync(
null,
m_id,
m_timestampsToReturn,
requestItems,
ct).ConfigureAwait(false);
MonitoredItemModifyResultCollection results = response.Results;
ClientBase.ValidateResponse(results, itemsToModify);
ClientBase.ValidateDiagnosticInfos(response.DiagnosticInfos, itemsToModify);
// update results.
for (int ii = 0; ii < results.Count; ii++)
{
itemsToModify[ii].SetModifyResult(requestItems[ii], results[ii], ii, response.DiagnosticInfos, response.ResponseHeader);
}
m_changeMask |= SubscriptionChangeMask.ItemsModified;
ChangesCompleted();
// return the list of items affected by the change.
return itemsToModify;
}
/// <summary>
/// Deletes all items that have been marked for deletion.
/// </summary>
public async Task<IList<MonitoredItem>> DeleteItemsAsync(CancellationToken ct)
{
VerifySubscriptionState(true);
if (m_deletedItems.Count == 0)
{
return new List<MonitoredItem>();
}
List<MonitoredItem> itemsToDelete = m_deletedItems;
m_deletedItems = new List<MonitoredItem>();
UInt32Collection monitoredItemIds = new UInt32Collection();
foreach (MonitoredItem monitoredItem in itemsToDelete)
{
monitoredItemIds.Add(monitoredItem.Status.Id);
}
var response = await m_session.DeleteMonitoredItemsAsync(
null,
m_id,
monitoredItemIds,
ct).ConfigureAwait(false);
StatusCodeCollection results = response.Results;
ClientBase.ValidateResponse(results, monitoredItemIds);
ClientBase.ValidateDiagnosticInfos(response.DiagnosticInfos, monitoredItemIds);
// update results.
for (int ii = 0; ii < results.Count; ii++)
{
itemsToDelete[ii].SetDeleteResult(results[ii], ii, response.DiagnosticInfos, response.ResponseHeader);
}
m_changeMask |= SubscriptionChangeMask.ItemsDeleted;
ChangesCompleted();
// return the list of items affected by the change.
return itemsToDelete;
}
/// <summary>
/// Set monitoring mode of items.
/// </summary>
public async Task<List<ServiceResult>> SetMonitoringModeAsync(
MonitoringMode monitoringMode,
IList<MonitoredItem> monitoredItems,
CancellationToken ct = default)
{
if (monitoredItems == null) throw new ArgumentNullException(nameof(monitoredItems));
VerifySubscriptionState(true);
if (monitoredItems.Count == 0)
{
return null;
}
// get list of items to update.
UInt32Collection monitoredItemIds = new UInt32Collection();
foreach (MonitoredItem monitoredItem in monitoredItems)
{
monitoredItemIds.Add(monitoredItem.Status.Id);
}
var response = await m_session.SetMonitoringModeAsync(
null,
m_id,
monitoringMode,
monitoredItemIds,
ct).ConfigureAwait(false);
StatusCodeCollection results = response.Results;
ClientBase.ValidateResponse(results, monitoredItemIds);
ClientBase.ValidateDiagnosticInfos(response.DiagnosticInfos, monitoredItemIds);
// update results.
List<ServiceResult> errors = new List<ServiceResult>();
bool noErrors = UpdateMonitoringMode(
monitoredItems, errors, results,
response.DiagnosticInfos, response.ResponseHeader,
monitoringMode);
// raise state changed event.
m_changeMask |= SubscriptionChangeMask.ItemsModified;
ChangesCompleted();
// return null list if no errors occurred.
if (noErrors)
{
return null;
}
return errors;
}
/// <summary>
/// Tells the server to refresh all conditions being monitored by the subscription.
/// </summary>
public async Task ConditionRefreshAsync(CancellationToken ct = default(CancellationToken))
{
VerifySubscriptionState(true);
var methodsToCall = new CallMethodRequestCollection();
methodsToCall.Add(new CallMethodRequest() {
MethodId = MethodIds.ConditionType_ConditionRefresh,
InputArguments = new VariantCollection() { new Variant(m_id) }
});
var response = await m_session.CallAsync(
null,
methodsToCall,
ct).ConfigureAwait(false);
}
#endregion
}
}
#endif