mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 22:55:08 +08:00
a81d981755
# Description
Pure code-style sweep across the repo — no behavioral changes. Drives
`dotnet format` info-level diagnostics down on a large slice of the
codebase (Stack, Libraries, Tests, Tools).
Six commits, applied bottom-up in safety order so each batch leaves the
build green:
1. **`style: fix whitespace and final-newline formatting (WHITESPACE,
FINALNEWLINE)`**
197 hits across ~30 files; pure mechanical cleanup.
2. **`style: apply dotnet format style fixes (IDE rules)`**
IDE-rule batch via `dotnet format style --severity info` —
`var`/explicit type, simplify names, collection expressions, expression
bodies, unused usings, etc.
3. **`style: apply dotnet format analyzers fixes (RCS blank-line
formatting)`**
Roslynator formatting (RCS0xxx) blank-line and punctuation rules.
4. **`style: convert comments to doc comments and inline local vars
(RCS1181, RCS1124)`**
24 files. RCS1181 promotes `// summary`-style comments above
public/internal members to `/// <summary>` doc comments; RCS1124 inlines
locals used exactly once. Generated `Tools/` files were reverted to keep
the source-generator outputs deterministic.
5. **`docs(agent): correct --diagnostics flag form and verify Roslynator
4.x code fixers`**
Updates `.github/agents/opc-ua-codestyle-enforcer.agent.md` with two
corrections discovered while running this sweep:
- `dotnet format --diagnostics "RULE1 RULE2"` (single quoted,
space-separated) silently fixes nothing on `dotnet format` 10.x — it
parses as one unknown ID and exits 0. The correct form is `--diagnostics
RULE1 --diagnostics RULE2 …`, repeated per rule.
- Roslynator 4.15.0 **does** ship code fixers for
RCS1078/1118/1124/1140/1142/1181/1221/1260 (the previous "no auto-fixer"
list was outdated). RCS1140/1142 default to `Hidden` so need `--severity
info` to surface and fire; RCS1078 requires
`roslynator_empty_string_style = field` (already set in
`.editorconfig`). Only NUnit4002/2046/2010 genuinely lack auto-fixers in
this stack.
6. **`style: format new code from master merge (dotnet format sweep)`**
After merging master into the branch (commit `e3ebc8742`), re-ran the
same three-phase format sweep on the 151 .cs files brought in by the
merge, scoped per project via repeated `--include`. 81 files changed;
same rule families as commits 1–4.
- Hand-fixed `Tests/Opc.Ua.MigrationAnalyzer.Tests/AnalyzerHarness.cs`
for `RCS1174` (redundant `async`/`await` removal) because the auto-fixer
didn't apply for that specific case.
- Reverted files that the formatter would break:
- `Libraries/Opc.Ua.Server/Server/ServerInternalData.cs` — multi-TFM
analysis disagreement injected `<<<<<<<` / `=======` / `>>>>>>>`
markers.
-
`Tools/Opc.Ua.MigrationAnalyzer.Core/{Core/Stack/Client/ChannelBase.cs,
Core/Stack/Types/IUserIdentityTokenHandler.cs,
Client/Session/Session.cs}` — IDE0005 removed `using
Opc.Ua.Security.Certificates` that IS used (the files are 1.5.378
reference fixtures for the analyzer).
- `Tests/Opc.Ua.Gds.Tests/PushTest.cs` — IDE0002 over-simplified
`OpcUa.ObjectTypeIds` to `ObjectTypeIds`, which then resolves to a
conflicting type.
### What this PR does **not** touch
- No behavioral or semantic logic changes.
- No `.editorconfig` rule promotions (rules stay at their current
severities; this is a backlog-burndown pass, not a rule tightening).
- Generated source under `Tools/` is intentionally excluded.
- Higher-risk rule families (`RCS1090`/`CA2007` `ConfigureAwait`,
`RCS1249` null-forgiving cleanup, `IDE0270`/`IDE0059`) are left for
follow-up sweeps because they need per-TFM build verification or
semantic review.
## Related Issues
_No tracking issue — this is a non-functional style sweep and the
agent-doc correction is a follow-up to the same work. Happy to open one
if the maintainers prefer._
## Checklist
- [ ] 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. _(N/A — style-only, no
behavioral change)_
- [x] I have added all necessary documentation. _(agent doc updated with
the two corrections)_
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings. _(verified clean on `net10.0` and `net48`; only
pre-existing `NETSDK1005`/`NETSDK1207` failures on Tools/AOT projects)_
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. _(build
verified on net10 and net48 per batch; full test suite not yet run
locally — relying on CI)_
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
---------
Co-authored-by: agent <agent@local>
1301 lines
53 KiB
C#
1301 lines
53 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.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using Opc.Ua.Client.Subscriptions.MonitoredItems;
|
|
|
|
namespace Opc.Ua.Client.Subscriptions
|
|
{
|
|
/// <summary>
|
|
/// A managed subscription inside a subscription manager. Can be
|
|
/// extended to provide custom subscription implementations on
|
|
/// top to route the received data appropriately per application.
|
|
/// The subscription itself is based on top of the message processor
|
|
/// implementation that routes messages to subscribers, but adds
|
|
/// state management of the subscription on the server using the
|
|
/// subscription and monitored item service set provided as context.
|
|
/// </summary>
|
|
internal abstract class Subscription : MessageProcessor, IManagedSubscription,
|
|
IMonitoredItemManagerContext
|
|
{
|
|
/// <inheritdoc/>
|
|
public byte CurrentPriority { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Server-assigned subscription id. <c>0</c> when the
|
|
/// subscription has not been created on the server yet.
|
|
/// </summary>
|
|
public uint ServerId => Id;
|
|
|
|
/// <inheritdoc/>
|
|
public TimeSpan CurrentPublishingInterval { get; private set; }
|
|
|
|
/// <inheritdoc/>
|
|
public uint CurrentKeepAliveCount { get; private set; }
|
|
|
|
/// <inheritdoc/>
|
|
public uint CurrentLifetimeCount { get; private set; }
|
|
|
|
/// <inheritdoc/>
|
|
public bool CurrentPublishingEnabled { get; private set; }
|
|
|
|
/// <inheritdoc/>
|
|
public uint CurrentMaxNotificationsPerPublish { get; private set; }
|
|
|
|
/// <inheritdoc/>
|
|
public IMonitoredItemCollection MonitoredItems => m_monitoredItems;
|
|
|
|
/// <inheritdoc/>
|
|
public bool Created => Id != 0;
|
|
|
|
/// <inheritdoc/>
|
|
public IMonitoredItemServiceSetClientMethods MonitoredItemServiceSet
|
|
=> m_context.MonitoredItemServiceSet;
|
|
|
|
/// <inheritdoc/>
|
|
public IMethodServiceSetClientMethods MethodServiceSet
|
|
=> m_context.MethodServiceSet;
|
|
|
|
/// <summary>
|
|
/// Returns true if the subscription is not receiving publishes.
|
|
/// </summary>
|
|
internal bool PublishingStopped
|
|
{
|
|
get
|
|
{
|
|
long lastNotificationTimestamp = LastNotificationTimestamp;
|
|
if (lastNotificationTimestamp == 0)
|
|
{
|
|
return false;
|
|
}
|
|
TimeSpan timeSinceLastNotification = TimeProvider
|
|
.GetElapsedTime(lastNotificationTimestamp);
|
|
return timeSinceLastNotification >
|
|
m_keepAliveInterval + s_keepAliveTimerMargin;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Current subscription options
|
|
/// </summary>
|
|
protected internal SubscriptionOptions Options { get; protected set; } = new();
|
|
|
|
/// <summary>
|
|
/// Create subscription
|
|
/// </summary>
|
|
/// <param name="context"></param>
|
|
/// <param name="handler"></param>
|
|
/// <param name="completion"></param>
|
|
/// <param name="options"></param>
|
|
/// <param name="telemetry"></param>
|
|
/// <param name="loadState">Optional snapshot of a previously
|
|
/// active subscription. When supplied, the V2 state machine is
|
|
/// constructed in "loaded" mode: <see cref="MessageProcessor.Id"/>
|
|
/// is pre-set to the saved server-side subscription id, the
|
|
/// supplied monitored items are pre-bound to their saved
|
|
/// server/client handles, and the initial signal that would
|
|
/// otherwise trigger <c>CreateSubscription</c> is suppressed.
|
|
/// The owning <see cref="SubscriptionManager.RestoreAsync"/>
|
|
/// flow then issues TransferSubscriptions and binds runtime
|
|
/// state via <see cref="TryCompleteTransferAsync"/>.</param>
|
|
/// <param name="timeProvider">Optional <see cref="TimeProvider"/>
|
|
/// for elapsed-time and timer calculations. Defaults to
|
|
/// <see cref="TimeProvider.System"/> when <c>null</c>.</param>
|
|
protected Subscription(
|
|
ISubscriptionContext context,
|
|
ISubscriptionNotificationHandler handler,
|
|
IMessageAckQueue completion,
|
|
IOptionsMonitor<SubscriptionOptions> options,
|
|
ITelemetryContext telemetry,
|
|
SubscriptionLoadState? loadState = null,
|
|
TimeProvider? timeProvider = null)
|
|
: base(context.SubscriptionServiceSet, completion, telemetry, timeProvider)
|
|
{
|
|
m_handler = handler;
|
|
m_context = context;
|
|
m_monitoredItems = new MonitoredItemManager(this, telemetry);
|
|
m_publishTimer = TimeProvider.CreateTimer(OnKeepAlive,
|
|
null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
|
if (loadState != null)
|
|
{
|
|
// Pre-install server-assigned identifiers + items
|
|
// before hooking change tracking, so the V2 state
|
|
// machine sees a coherent "loaded" snapshot when it
|
|
// first wakes.
|
|
Id = loadState.ServerId;
|
|
m_createdEvent.Set();
|
|
Options = options.CurrentValue;
|
|
foreach (MonitoredItemLoadState item in loadState.MonitoredItems)
|
|
{
|
|
m_monitoredItems.AddLoaded(item);
|
|
}
|
|
// Hook options change tracking but do NOT signal
|
|
// m_stateControl — the manager's RestoreAsync drives
|
|
// transfer + completion explicitly.
|
|
m_changeTracking = options.OnChange((o, _) => OnOptionsChanged(o));
|
|
}
|
|
else
|
|
{
|
|
OnOptionsChanged(options.CurrentValue);
|
|
m_changeTracking = options.OnChange((o, _) => OnOptionsChanged(o));
|
|
}
|
|
m_stateManagement = StateManagerAsync(m_cts.Token);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override string ToString()
|
|
{
|
|
return $"{m_context}:{Id}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Capture an immutable snapshot of this subscription's
|
|
/// configuration + identifiers + the per-item state.
|
|
/// </summary>
|
|
public SubscriptionStateSnapshot Snapshot()
|
|
{
|
|
var items = new List<MonitoredItemStateSnapshot>();
|
|
foreach (IMonitoredItem item in m_monitoredItems.Items)
|
|
{
|
|
if (item is MonitoredItems.MonitoredItem concrete)
|
|
{
|
|
items.Add(concrete.Snapshot());
|
|
}
|
|
}
|
|
uint[] available = AvailableInRetransmissionQueue == null
|
|
? []
|
|
: [.. AvailableInRetransmissionQueue];
|
|
return SubscriptionStateSnapshot.AsOptions(
|
|
Options,
|
|
Id,
|
|
available.ToArrayOf(),
|
|
items.ToArrayOf());
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask ConditionRefreshAsync(CancellationToken ct)
|
|
{
|
|
if (!Created)
|
|
{
|
|
throw ServiceResultException.Create(StatusCodes.BadSubscriptionIdInvalid,
|
|
"Subscription has not been created.");
|
|
}
|
|
var methodsToCall = new CallMethodRequest[]
|
|
{
|
|
new()
|
|
{
|
|
MethodId = MethodIds.ConditionType_ConditionRefresh,
|
|
InputArguments = [new Variant(Id)]
|
|
}
|
|
};
|
|
await m_context.MethodServiceSet.CallAsync(null, methodsToCall,
|
|
ct).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configure triggering relationships between monitored items
|
|
/// in this subscription. Per OPC UA Part 4 §5.13.5, the service
|
|
/// call reports per-link status; this implementation updates
|
|
/// the triggered items' <see cref="IMonitoredItem.TriggeringItem"/>
|
|
/// link only for results whose status is Good. Partial failures
|
|
/// do not corrupt local state; callers inspect the returned
|
|
/// <see cref="SetTriggeringResponse"/> for per-link results.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Kept on the concrete <see cref="Subscription"/> class (not on
|
|
/// <see cref="ISubscription"/>) — the API surface is too
|
|
/// low-level for the SDK contract; per-item link/unlink methods
|
|
/// are tracked as a follow-up.
|
|
/// </remarks>
|
|
/// <exception cref="ArgumentNullException"><paramref name="linksToAdd"/> is <c>null</c>.</exception>
|
|
/// <exception cref="ServiceResultException"></exception>
|
|
/// <exception cref="ArgumentException"></exception>
|
|
public async ValueTask<SetTriggeringResponse> SetTriggeringAsync(
|
|
uint triggeringItemClientHandle,
|
|
IReadOnlyList<uint> linksToAdd,
|
|
IReadOnlyList<uint> linksToRemove,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (linksToAdd == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(linksToAdd));
|
|
}
|
|
if (linksToRemove == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(linksToRemove));
|
|
}
|
|
if (!Created)
|
|
{
|
|
throw ServiceResultException.Create(StatusCodes.BadSubscriptionIdInvalid,
|
|
"Subscription has not been created.");
|
|
}
|
|
if (!m_monitoredItems.TryGetMonitoredItemByClientHandle(
|
|
triggeringItemClientHandle, out IMonitoredItem? triggeringItem) ||
|
|
triggeringItem is null)
|
|
{
|
|
throw new ArgumentException(
|
|
$"Triggering item with client handle {triggeringItemClientHandle} " +
|
|
"is not part of this subscription.",
|
|
nameof(triggeringItemClientHandle));
|
|
}
|
|
if (!triggeringItem.Created)
|
|
{
|
|
throw ServiceResultException.Create(StatusCodes.BadMonitoredItemIdInvalid,
|
|
"Triggering item has not been created on the server yet.");
|
|
}
|
|
|
|
// Resolve client handles → server monitored item ids while
|
|
// keeping a parallel list of the client handles for the
|
|
// post-call local update. Skipping items not in the
|
|
// subscription would silently lose links — instead we throw
|
|
// so the caller can fix the call site.
|
|
uint[] addServerIds = new uint[linksToAdd.Count];
|
|
for (int i = 0; i < linksToAdd.Count; i++)
|
|
{
|
|
addServerIds[i] = ResolveServerId(linksToAdd[i], nameof(linksToAdd));
|
|
}
|
|
uint[] removeServerIds = new uint[linksToRemove.Count];
|
|
for (int i = 0; i < linksToRemove.Count; i++)
|
|
{
|
|
removeServerIds[i] = ResolveServerId(linksToRemove[i], nameof(linksToRemove));
|
|
}
|
|
|
|
SetTriggeringResponse response = await m_context.MonitoredItemServiceSet
|
|
.SetTriggeringAsync(
|
|
null,
|
|
Id,
|
|
triggeringItem.ServerId,
|
|
addServerIds.ToArrayOf(),
|
|
removeServerIds.ToArrayOf(),
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
|
|
// Update local state only for results with a Good status —
|
|
// partial failure must not corrupt the in-process tracking.
|
|
// The triggered items remember who triggers them; the
|
|
// reverse list (triggered-by-this) is resolved on demand via
|
|
// the manager when callers ask for "what does this item
|
|
// trigger".
|
|
ArrayOf<StatusCode> addResults = response.AddResults;
|
|
for (int i = 0; i < linksToAdd.Count; i++)
|
|
{
|
|
StatusCode status = addResults.Count > i ? addResults[i] : StatusCodes.Bad;
|
|
if (!StatusCode.IsGood(status))
|
|
{
|
|
continue;
|
|
}
|
|
if (m_monitoredItems.TryGetMonitoredItemByClientHandle(
|
|
linksToAdd[i], out IMonitoredItem? triggered) &&
|
|
triggered is MonitoredItems.MonitoredItem triggeredInternal)
|
|
{
|
|
triggeredInternal.TriggeringItemClientHandle =
|
|
triggeringItemClientHandle;
|
|
}
|
|
}
|
|
ArrayOf<StatusCode> removeResults = response.RemoveResults;
|
|
for (int i = 0; i < linksToRemove.Count; i++)
|
|
{
|
|
StatusCode status = removeResults.Count > i ? removeResults[i] : StatusCodes.Bad;
|
|
if (!StatusCode.IsGood(status))
|
|
{
|
|
continue;
|
|
}
|
|
if (m_monitoredItems.TryGetMonitoredItemByClientHandle(
|
|
linksToRemove[i], out IMonitoredItem? triggered) &&
|
|
triggered is MonitoredItems.MonitoredItem triggeredInternal &&
|
|
triggeredInternal.TriggeringItemClientHandle ==
|
|
triggeringItemClientHandle)
|
|
{
|
|
triggeredInternal.TriggeringItemClientHandle = 0;
|
|
}
|
|
}
|
|
|
|
return response;
|
|
|
|
uint ResolveServerId(uint clientHandle, string paramName)
|
|
{
|
|
if (!m_monitoredItems.TryGetMonitoredItemByClientHandle(
|
|
clientHandle, out IMonitoredItem? item) ||
|
|
item == null)
|
|
{
|
|
throw new ArgumentException(
|
|
$"Monitored item with client handle {clientHandle} " +
|
|
"is not part of this subscription.",
|
|
paramName);
|
|
}
|
|
if (!item.Created)
|
|
{
|
|
throw ServiceResultException.Create(
|
|
StatusCodes.BadMonitoredItemIdInvalid,
|
|
$"Monitored item {clientHandle} has not been created on the server yet.");
|
|
}
|
|
return item.ServerId;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask<TimeSpan> SetAsDurableAsync(
|
|
TimeSpan lifetime,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (!Created)
|
|
{
|
|
throw ServiceResultException.Create(
|
|
StatusCodes.BadSubscriptionIdInvalid,
|
|
"Subscription has not been created on the server.");
|
|
}
|
|
// SetSubscriptionDurable uses whole-hour granularity (UInt32);
|
|
// round up so requesting 90 minutes asks the server for 2 hours.
|
|
uint lifetimeInHours = (uint)Math.Max(
|
|
1,
|
|
Math.Ceiling(lifetime.TotalHours));
|
|
ArrayOf<CallMethodRequest> methodsToCall =
|
|
[
|
|
new CallMethodRequest
|
|
{
|
|
ObjectId = ObjectIds.Server,
|
|
MethodId = MethodIds.Server_SetSubscriptionDurable,
|
|
InputArguments =
|
|
[
|
|
new Variant(Id),
|
|
new Variant(lifetimeInHours)
|
|
]
|
|
}
|
|
];
|
|
CallResponse response = await m_context.MethodServiceSet
|
|
.CallAsync(null, methodsToCall, ct).ConfigureAwait(false);
|
|
ArrayOf<CallMethodResult> results = response.Results;
|
|
ArrayOf<DiagnosticInfo> diagnosticInfos = response.DiagnosticInfos;
|
|
ClientBase.ValidateResponse(results, methodsToCall);
|
|
ClientBase.ValidateDiagnosticInfos(diagnosticInfos, methodsToCall);
|
|
if (StatusCode.IsBad(results[0].StatusCode))
|
|
{
|
|
throw new ServiceResultException(ClientBase.GetResult(
|
|
results[0].StatusCode, 0, diagnosticInfos,
|
|
response.ResponseHeader));
|
|
}
|
|
ArrayOf<Variant> outputs = results[0].OutputArguments;
|
|
if (outputs.Count == 0 ||
|
|
!outputs[0].TryGetValue(out uint revised))
|
|
{
|
|
throw ServiceResultException.Create(StatusCodes.BadUnexpectedError,
|
|
"Server.SetSubscriptionDurable returned no revised lifetime.");
|
|
}
|
|
return TimeSpan.FromHours(revised);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask RecreateAsync(CancellationToken ct)
|
|
{
|
|
await m_stateLock.WaitAsync(ct).ConfigureAwait(false);
|
|
try
|
|
{
|
|
LastSequenceNumberProcessed = 0;
|
|
LastNotificationTimestamp = 0;
|
|
|
|
Id = 0;
|
|
CurrentPublishingInterval = TimeSpan.Zero;
|
|
CurrentKeepAliveCount = 0;
|
|
CurrentPublishingEnabled = false;
|
|
CurrentPriority = 0;
|
|
|
|
// Recreate subscription
|
|
await CreateAsync(Options, ct).ConfigureAwait(false);
|
|
|
|
await m_monitoredItems.ApplyChangesAsync(true, true,
|
|
ct).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
m_stateLock.Release();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void NotifySubscriptionManagerPaused(bool paused)
|
|
{
|
|
m_monitoredItems.NotifySubscriptionManagerPaused(paused);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask<bool> TryCompleteTransferAsync(
|
|
IReadOnlyList<uint> availableSequenceNumbers, CancellationToken ct)
|
|
{
|
|
await m_stateLock.WaitAsync(ct).ConfigureAwait(false);
|
|
try
|
|
{
|
|
StopKeepAliveTimer();
|
|
bool success = await m_monitoredItems.TrySynchronizeHandlesAsync(
|
|
ct).ConfigureAwait(false);
|
|
if (!success)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// save available sequence numbers
|
|
AvailableInRetransmissionQueue = availableSequenceNumbers;
|
|
|
|
await m_monitoredItems.ApplyChangesAsync(true, false,
|
|
ct).ConfigureAwait(false);
|
|
StartKeepAliveTimer();
|
|
return true;
|
|
}
|
|
finally
|
|
{
|
|
m_stateLock.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reset this loaded subscription back into the create-fresh
|
|
/// pipeline. Called by
|
|
/// <see cref="SubscriptionManager.RestoreAsync"/> when
|
|
/// <c>TransferSubscriptions</c> rejects the saved server-side
|
|
/// id (typically <c>BadSubscriptionIdInvalid</c>): the V2
|
|
/// engine then drops the loaded identifiers and creates a fresh
|
|
/// server-side subscription via the normal
|
|
/// <see cref="StateManagerAsync"/> path.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Triggering links captured in the load state are intentionally
|
|
/// not replayed on recreate — the saved server-side triggering
|
|
/// relationships are tied to the (now-stale) server item ids.
|
|
/// Callers that want triggering preserved across a fallback
|
|
/// recreate must re-issue
|
|
/// <see cref="SetTriggeringAsync"/> after the items finish
|
|
/// re-creating.
|
|
/// </remarks>
|
|
internal async ValueTask ResetToRecreateAsync(CancellationToken ct)
|
|
{
|
|
await m_stateLock.WaitAsync(ct).ConfigureAwait(false);
|
|
try
|
|
{
|
|
Id = 0;
|
|
m_createdEvent.Reset();
|
|
CurrentPublishingInterval = TimeSpan.Zero;
|
|
CurrentKeepAliveCount = 0;
|
|
CurrentLifetimeCount = 0;
|
|
CurrentPublishingEnabled = false;
|
|
CurrentPriority = 0;
|
|
CurrentMaxNotificationsPerPublish = 0;
|
|
LastSequenceNumberProcessed = 0;
|
|
LastNotificationTimestamp = 0;
|
|
AvailableInRetransmissionQueue = [];
|
|
|
|
// Reset every loaded item so it queues a fresh
|
|
// CreateMonitoredItem on the next ApplyChanges pass.
|
|
foreach (IMonitoredItem item in m_monitoredItems.Items)
|
|
{
|
|
if (item is MonitoredItems.MonitoredItem mi)
|
|
{
|
|
mi.Reset();
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
m_stateLock.Release();
|
|
}
|
|
// Wake the state manager so the next pass observes
|
|
// !Created and runs CreateAsync.
|
|
m_stateControl.Set();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wait until the subscription becomes <see cref="Created"/>
|
|
/// (the server has assigned an Id and
|
|
/// <see cref="OnSubscriptionUpdateComplete"/> has run).
|
|
/// Used by <see cref="SubscriptionManager.RestoreTransferAsync"/>
|
|
/// to await the recreate-fallback before returning to the
|
|
/// caller of <see cref="SubscriptionManager.LoadAsync"/>.
|
|
/// </summary>
|
|
/// <param name="ct"></param>
|
|
internal Task WaitForCreatedAsync(CancellationToken ct)
|
|
{
|
|
return m_createdEvent.WaitAsync(ct);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override ValueTask OnPublishReceivedAsync(NotificationMessage message,
|
|
IReadOnlyList<uint>? availableSequenceNumbers,
|
|
IReadOnlyList<string> stringTable)
|
|
{
|
|
// Reset the keep alive timer
|
|
m_publishTimer.Change(m_keepAliveInterval, m_keepAliveInterval);
|
|
|
|
// send notification that publishing received a keep alive
|
|
// or has to republish.
|
|
if (PublishingStopped)
|
|
{
|
|
OnPublishStateChanged(PublishState.Recovered);
|
|
}
|
|
return base.OnPublishReceivedAsync(message, availableSequenceNumbers,
|
|
stringTable);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
protected override async ValueTask DisposeAsync(bool disposing)
|
|
{
|
|
if (disposing && !Disposed)
|
|
{
|
|
try
|
|
{
|
|
await m_cts.CancelAsync().ConfigureAwait(false);
|
|
await m_stateManagement.ConfigureAwait(false);
|
|
|
|
await m_monitoredItems.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
m_publishTimer.Dispose();
|
|
m_changeTracking?.Dispose();
|
|
m_cts.Dispose();
|
|
m_stateLock.Dispose();
|
|
}
|
|
}
|
|
await base.DisposeAsync(disposing).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void Update()
|
|
{
|
|
m_stateControl.Set();
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public MonitoredItems.MonitoredItem CreateMonitoredItem(string name,
|
|
IOptionsMonitor<MonitoredItems.MonitoredItemOptions> options, IMonitoredItemContext context)
|
|
{
|
|
return CreateMonitoredItem(name, options, context, Observability);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create monitored item
|
|
/// </summary>
|
|
/// <param name="name"></param>
|
|
/// <param name="options"></param>
|
|
/// <param name="context"></param>
|
|
/// <param name="telemetry"></param>
|
|
/// <returns></returns>
|
|
protected abstract MonitoredItems.MonitoredItem CreateMonitoredItem(string name,
|
|
IOptionsMonitor<MonitoredItems.MonitoredItemOptions> options, IMonitoredItemContext context,
|
|
ITelemetryContext telemetry);
|
|
|
|
/// <summary>
|
|
/// Called when the options changed
|
|
/// </summary>
|
|
/// <param name="options"></param>
|
|
protected virtual void OnOptionsChanged(SubscriptionOptions options)
|
|
{
|
|
SubscriptionOptions currentOptions = Options;
|
|
if (currentOptions == options)
|
|
{
|
|
return;
|
|
}
|
|
Options = options;
|
|
m_stateControl.Set();
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
protected override ValueTask OnKeepAliveNotificationAsync(uint sequenceNumber,
|
|
DateTime publishTime, PublishState publishStateMask)
|
|
{
|
|
return m_handler.OnKeepAliveNotificationAsync(this, sequenceNumber,
|
|
publishTime, publishStateMask);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
protected override async ValueTask OnDataChangeNotificationAsync(uint sequenceNumber,
|
|
DateTime publishTime, DataChangeNotification notification,
|
|
PublishState publishStateMask, IReadOnlyList<string> stringTable)
|
|
{
|
|
try
|
|
{
|
|
await m_handler.OnDataChangeNotificationAsync(this, sequenceNumber,
|
|
publishTime, m_monitoredItems.CreateNotification(notification),
|
|
publishStateMask, stringTable).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
if (PoolNotifications)
|
|
{
|
|
ReuseDataChangeNotification(notification);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
protected override async ValueTask OnEventDataNotificationAsync(uint sequenceNumber,
|
|
DateTime publishTime, EventNotificationList notification,
|
|
PublishState publishStateMask, IReadOnlyList<string> stringTable)
|
|
{
|
|
try
|
|
{
|
|
await m_handler.OnEventDataNotificationAsync(this, sequenceNumber,
|
|
publishTime, m_monitoredItems.CreateNotification(notification),
|
|
publishStateMask, stringTable).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
if (PoolNotifications)
|
|
{
|
|
ReuseEventNotificationList(notification);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Walk a dispatched <see cref="DataChangeNotification"/> after the
|
|
/// handler returns and release pooled payload instances back to
|
|
/// their activators. Items that do not implement
|
|
/// <see cref="IPooledEncodeable"/> are skipped silently — this
|
|
/// allows the walk to be safe before source-gen learns to emit
|
|
/// the interface for tagged types, and serves as the universal
|
|
/// dispatch path for any future pooled payload variant.
|
|
/// </summary>
|
|
private static void ReuseDataChangeNotification(DataChangeNotification notification)
|
|
{
|
|
ReadOnlySpan<MonitoredItemNotification> items =
|
|
notification.MonitoredItems.Span;
|
|
for (int i = 0; i < items.Length; i++)
|
|
{
|
|
if (items[i] is IPooledEncodeable item)
|
|
{
|
|
item.Reuse();
|
|
}
|
|
}
|
|
if (notification is IPooledEncodeable container)
|
|
{
|
|
container.Reuse();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Event-side companion to <see cref="ReuseDataChangeNotification"/>.
|
|
/// </summary>
|
|
private static void ReuseEventNotificationList(EventNotificationList notification)
|
|
{
|
|
ReadOnlySpan<EventFieldList> events = notification.Events.Span;
|
|
for (int i = 0; i < events.Length; i++)
|
|
{
|
|
if (events[i] is IPooledEncodeable evt)
|
|
{
|
|
evt.Reuse();
|
|
}
|
|
}
|
|
if (notification is IPooledEncodeable container)
|
|
{
|
|
container.Reuse();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
protected override ValueTask OnStatusChangeNotificationAsync(uint sequenceNumber,
|
|
DateTime publishTime, StatusChangeNotification notification,
|
|
PublishState publishStateMask, IReadOnlyList<string> stringTable)
|
|
{
|
|
// The base MessageProcessor does not automatically raise
|
|
// OnPublishStateChanged for status notifications, so do it
|
|
// here. This is what surfaces the Transferred / Timeout
|
|
// flags to user-visible publish-state handlers.
|
|
OnPublishStateChanged(publishStateMask);
|
|
|
|
// Recovery on unsolicited Good_SubscriptionTransferred.
|
|
// Per OPC UA Part 4 §5.14.7 this notification is sent to
|
|
// the *old* session when its subscription was transferred
|
|
// away — receiving it on a freshly created subscription
|
|
// here is a server quirk (e.g. Kepware leaking
|
|
// pre-restart state). Opt-in policy lets the caller ask
|
|
// for an in-place recreate instead of leaving the
|
|
// subscription dark.
|
|
if (notification.Status == StatusCodes.GoodSubscriptionTransferred &&
|
|
Options.RecoveryPolicy
|
|
.HasFlag(SubscriptionRecoveryPolicy.RecreateOnUnsolicitedTransfer) &&
|
|
Created &&
|
|
!Disposed && Interlocked.CompareExchange(
|
|
ref m_recreateAfterTransferInProgress, 1, 0) == 0)
|
|
{
|
|
_ = Task.Run(RecoverAfterUnsolicitedTransferAsync);
|
|
}
|
|
return default;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Run an in-place recreate on the same session after an
|
|
/// unsolicited <c>Good_SubscriptionTransferred</c>. Drops
|
|
/// queued acknowledgements for the dead subscription id
|
|
/// before invoking <see cref="ResetToRecreateAsync"/> so the
|
|
/// state-manager loop sees a coherent reset and the ack
|
|
/// queue cannot leak <c>BadSubscriptionIdInvalid</c>s on
|
|
/// servers that re-use subscription identifiers. Idempotent:
|
|
/// concurrent dispatches collapse through
|
|
/// <see cref="m_recreateAfterTransferInProgress"/>.
|
|
/// </summary>
|
|
private async Task RecoverAfterUnsolicitedTransferAsync()
|
|
{
|
|
uint deadId = Id;
|
|
try
|
|
{
|
|
Logger.LogWarning(
|
|
"{Subscription}: unsolicited Good_SubscriptionTransferred received — " +
|
|
"auto-recreating on the same session " +
|
|
"(RecoveryPolicy=RecreateOnUnsolicitedTransfer).",
|
|
this);
|
|
|
|
if (deadId != 0)
|
|
{
|
|
int dropped = AckQueue.DropPendingForSubscription(deadId);
|
|
if (dropped > 0)
|
|
{
|
|
Logger.LogInformation(
|
|
"{Subscription}: dropped {Count} stale acknowledgement(s) " +
|
|
"before recovery recreate.",
|
|
this,
|
|
dropped);
|
|
}
|
|
}
|
|
|
|
if (Disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await ResetToRecreateAsync(CancellationToken.None)
|
|
.ConfigureAwait(false);
|
|
|
|
Logger.LogInformation(
|
|
"{Subscription}: recreate signalled after unsolicited " +
|
|
"Good_SubscriptionTransferred (old SubscriptionId={OldId}).",
|
|
this,
|
|
deadId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogWarning(
|
|
ex,
|
|
"{Subscription}: recovery after unsolicited " +
|
|
"Good_SubscriptionTransferred failed (old SubscriptionId={OldId}); " +
|
|
"the subscription stays dark until the next reconnect or " +
|
|
"manual recreate.",
|
|
this,
|
|
deadId);
|
|
}
|
|
finally
|
|
{
|
|
Interlocked.Exchange(ref m_recreateAfterTransferInProgress, 0);
|
|
}
|
|
}
|
|
|
|
private int m_recreateAfterTransferInProgress;
|
|
|
|
/// <summary>
|
|
/// Called when the subscription state changed
|
|
/// </summary>
|
|
/// <param name="state"></param>
|
|
protected virtual void OnSubscriptionStateChanged(SubscriptionState state)
|
|
{
|
|
Logger.LogInformation("{Subscription}: {State}.", this, state);
|
|
FireStateChangedToHandler(state, default);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
protected override void OnPublishStateChanged(PublishState stateMask)
|
|
{
|
|
base.OnPublishStateChanged(stateMask);
|
|
FireStateChangedToHandler(default, stateMask);
|
|
}
|
|
|
|
private void FireStateChangedToHandler(SubscriptionState state,
|
|
PublishState publishStateMask)
|
|
{
|
|
try
|
|
{
|
|
// Fire-and-forget: handler is allowed to block, but we
|
|
// intentionally don't await here because OnSubscriptionStateChanged
|
|
// is invoked from inside StateManagerAsync (under m_stateLock)
|
|
// and OnPublishStateChanged is invoked from the publish dispatch
|
|
// path. Either await would risk a deadlock if the handler
|
|
// re-enters the engine. Handlers that need backpressure
|
|
// should buffer in OnSubscriptionStateChangedAsync and
|
|
// process on a worker.
|
|
_ = m_handler.OnSubscriptionStateChangedAsync(this, state,
|
|
publishStateMask).AsTask();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogWarning(ex,
|
|
"{Subscription}: OnSubscriptionStateChangedAsync handler threw.",
|
|
this);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Controls the state changes of the subscriptions and the contained monitored
|
|
/// items.
|
|
/// </summary>
|
|
/// <param name="ct"></param>
|
|
/// <returns></returns>
|
|
private async Task StateManagerAsync(CancellationToken ct)
|
|
{
|
|
OnSubscriptionStateChanged(SubscriptionState.Opened);
|
|
try
|
|
{
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
await m_stateControl.WaitAsync(ct).ConfigureAwait(false);
|
|
await m_stateLock.WaitAsync(ct).ConfigureAwait(false);
|
|
SubscriptionOptions options = Options;
|
|
try
|
|
{
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
if (options.Disabled)
|
|
{
|
|
await DeleteAsync(ct).ConfigureAwait(false);
|
|
break; // Wait for changes while disabled
|
|
}
|
|
|
|
if (!Created)
|
|
{
|
|
await CreateAsync(options, ct).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
await ModifyAsync(options, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
bool modified = await m_monitoredItems.ApplyChangesAsync(
|
|
false, false, ct).ConfigureAwait(false);
|
|
if (modified)
|
|
{
|
|
OnSubscriptionStateChanged(SubscriptionState.Modified);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
|
{
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Failed to apply subscription changes.");
|
|
}
|
|
finally
|
|
{
|
|
m_stateLock.Release();
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
}
|
|
|
|
// Delete subscription on server on dispose
|
|
await DeleteAsync(default).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a subscription on the server, but keeps the subscription in the session.
|
|
/// </summary>
|
|
/// <param name="ct"></param>
|
|
///
|
|
/// <exception cref="ServiceResultException"></exception>
|
|
internal async ValueTask DeleteAsync(CancellationToken ct)
|
|
{
|
|
// nothing to do if not created.
|
|
if (!Created)
|
|
{
|
|
return;
|
|
}
|
|
try
|
|
{
|
|
// delete the subscription.
|
|
ArrayOf<uint> subscriptionIds = new uint[] { Id };
|
|
DeleteSubscriptionsResponse response = await m_context.SubscriptionServiceSet.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));
|
|
}
|
|
}
|
|
// suppress exception if silent flag is set.
|
|
catch (Exception e)
|
|
{
|
|
Logger.LogInformation(e, "Deleting subscription on server failed.");
|
|
}
|
|
OnSubscriptionDeleteCompleted();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a subscription on the server and adds all monitored items.
|
|
/// </summary>
|
|
/// <param name="options"></param>
|
|
/// <param name="ct"></param>
|
|
internal async ValueTask CreateAsync(SubscriptionOptions options, CancellationToken ct)
|
|
{
|
|
// create the subscription.
|
|
AdjustCounts(options, out uint revisedMaxKeepAliveCount, out uint revisedLifetimeCount);
|
|
|
|
CreateSubscriptionResponse response = await m_context.SubscriptionServiceSet.CreateSubscriptionAsync(null,
|
|
options.PublishingInterval.TotalMilliseconds, revisedLifetimeCount,
|
|
revisedMaxKeepAliveCount, options.MaxNotificationsPerPublish,
|
|
options.PublishingEnabled, options.Priority, ct).ConfigureAwait(false);
|
|
|
|
OnSubscriptionUpdateComplete(true, response.SubscriptionId,
|
|
TimeSpan.FromMilliseconds(response.RevisedPublishingInterval),
|
|
response.RevisedMaxKeepAliveCount, response.RevisedLifetimeCount,
|
|
options.Priority, options.MaxNotificationsPerPublish,
|
|
options.PublishingEnabled);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Modifies a subscription on the server.
|
|
/// </summary>
|
|
/// <param name="options"></param>
|
|
/// <param name="ct"></param>
|
|
/// <exception cref="ServiceResultException"></exception>
|
|
internal async ValueTask ModifyAsync(SubscriptionOptions options, CancellationToken ct)
|
|
{
|
|
// modify the subscription.
|
|
AdjustCounts(options, out uint revisedMaxKeepAliveCount, out uint revisedLifetimeCount);
|
|
|
|
if (revisedMaxKeepAliveCount != CurrentKeepAliveCount ||
|
|
revisedLifetimeCount != CurrentLifetimeCount ||
|
|
options.Priority != CurrentPriority ||
|
|
options.MaxNotificationsPerPublish != CurrentMaxNotificationsPerPublish ||
|
|
options.PublishingInterval != CurrentPublishingInterval)
|
|
{
|
|
ModifySubscriptionResponse response = await m_context.SubscriptionServiceSet.ModifySubscriptionAsync(null, Id,
|
|
options.PublishingInterval.TotalMilliseconds, revisedLifetimeCount,
|
|
revisedMaxKeepAliveCount, options.MaxNotificationsPerPublish, options.Priority,
|
|
ct).ConfigureAwait(false);
|
|
|
|
if (options.PublishingEnabled != CurrentPublishingEnabled)
|
|
{
|
|
await SetPublishingModeAsync(options, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
OnSubscriptionUpdateComplete(false, 0,
|
|
TimeSpan.FromMilliseconds(response.RevisedPublishingInterval),
|
|
response.RevisedMaxKeepAliveCount, response.RevisedLifetimeCount,
|
|
options.Priority, options.MaxNotificationsPerPublish,
|
|
options.PublishingEnabled);
|
|
}
|
|
else if (options.PublishingEnabled != CurrentPublishingEnabled)
|
|
{
|
|
await SetPublishingModeAsync(options, ct).ConfigureAwait(false);
|
|
|
|
// update current state.
|
|
CurrentPublishingEnabled = options.PublishingEnabled;
|
|
OnSubscriptionStateChanged(SubscriptionState.Modified);
|
|
}
|
|
|
|
async Task SetPublishingModeAsync(SubscriptionOptions options, CancellationToken ct)
|
|
{
|
|
// modify the subscription.
|
|
ArrayOf<uint> subscriptionIds = new uint[] { Id };
|
|
SetPublishingModeResponse response = await m_context.SubscriptionServiceSet.SetPublishingModeAsync(
|
|
null, options.PublishingEnabled, 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));
|
|
}
|
|
|
|
Logger.LogInformation(
|
|
"{Subscription}: Modified - Publishing is now {New}.",
|
|
this, options.PublishingEnabled ? "Enabled" : "Disabled");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update the subscription with the given revised settings.
|
|
/// </summary>
|
|
/// <param name="created"></param>
|
|
/// <param name="subscriptionId"></param>
|
|
/// <param name="revisedPublishingInterval"></param>
|
|
/// <param name="revisedKeepAliveCount"></param>
|
|
/// <param name="revisedLifetimeCount"></param>
|
|
/// <param name="priority"></param>
|
|
/// <param name="maxNotificationsPerPublish"></param>
|
|
/// <param name="publishingEnabled"></param>
|
|
internal void OnSubscriptionUpdateComplete(bool created,
|
|
uint subscriptionId, TimeSpan revisedPublishingInterval,
|
|
uint revisedKeepAliveCount, uint revisedLifetimeCount,
|
|
byte priority, uint maxNotificationsPerPublish,
|
|
bool publishingEnabled)
|
|
{
|
|
if (CurrentPublishingEnabled != publishingEnabled)
|
|
{
|
|
Logger.LogInformation(
|
|
"{Subscription}: Created - Publishing is {New}.",
|
|
this, publishingEnabled ? "Enabled" : "Disabled");
|
|
CurrentPublishingEnabled = publishingEnabled;
|
|
}
|
|
|
|
if (CurrentKeepAliveCount != revisedKeepAliveCount)
|
|
{
|
|
Logger.LogInformation(
|
|
"{Subscription}: Changed KeepAliveCount to {New}.",
|
|
this, revisedKeepAliveCount);
|
|
|
|
CurrentKeepAliveCount = revisedKeepAliveCount;
|
|
}
|
|
|
|
if (CurrentPublishingInterval != revisedPublishingInterval)
|
|
{
|
|
Logger.LogInformation(
|
|
"{Subscription}: Changed PublishingInterval to {New}.",
|
|
this, revisedPublishingInterval);
|
|
CurrentPublishingInterval = revisedPublishingInterval;
|
|
}
|
|
|
|
if (CurrentMaxNotificationsPerPublish != maxNotificationsPerPublish)
|
|
{
|
|
Logger.LogInformation(
|
|
"{Subscription}: Change MaxNotificationsPerPublish to {New}.",
|
|
this, maxNotificationsPerPublish);
|
|
CurrentMaxNotificationsPerPublish = maxNotificationsPerPublish;
|
|
}
|
|
|
|
if (CurrentLifetimeCount != revisedLifetimeCount)
|
|
{
|
|
Logger.LogInformation(
|
|
"{Subscription}: Changed LifetimeCount to {New}.",
|
|
this, revisedLifetimeCount);
|
|
CurrentLifetimeCount = revisedLifetimeCount;
|
|
}
|
|
|
|
if (CurrentPriority != priority)
|
|
{
|
|
Logger.LogInformation(
|
|
"{Subscription}: Changed Priority to {New}.",
|
|
this, priority);
|
|
CurrentPriority = priority;
|
|
}
|
|
|
|
if (created)
|
|
{
|
|
Id = subscriptionId;
|
|
StartKeepAliveTimer();
|
|
NotifyManagerOfCreation();
|
|
m_createdEvent.Set();
|
|
}
|
|
|
|
// Notify all monitored items of the changes
|
|
SubscriptionState state = created ?
|
|
SubscriptionState.Created : SubscriptionState.Modified;
|
|
m_monitoredItems.OnSubscriptionStateChange(state, CurrentPublishingInterval);
|
|
OnSubscriptionStateChanged(state);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delete the subscription.
|
|
/// Ignore errors, always reset all parameter.
|
|
/// </summary>
|
|
internal void OnSubscriptionDeleteCompleted()
|
|
{
|
|
LastSequenceNumberProcessed = 0;
|
|
LastNotificationTimestamp = 0;
|
|
|
|
Id = 0;
|
|
m_createdEvent.Reset();
|
|
CurrentPublishingInterval = TimeSpan.Zero;
|
|
CurrentKeepAliveCount = 0;
|
|
CurrentPublishingEnabled = false;
|
|
CurrentPriority = 0;
|
|
|
|
m_deletedItems.Clear();
|
|
|
|
// Notify all monitored items of the changes
|
|
m_monitoredItems.OnSubscriptionStateChange(SubscriptionState.Deleted,
|
|
CurrentPublishingInterval);
|
|
OnSubscriptionStateChanged(SubscriptionState.Deleted);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stop the keep alive timer for the subscription.
|
|
/// </summary>
|
|
private void StopKeepAliveTimer()
|
|
{
|
|
m_publishTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Starts a timer to ensure publish requests are sent frequently
|
|
/// enough to detect network interruptions.
|
|
/// </summary>
|
|
private void StartKeepAliveTimer()
|
|
{
|
|
SubscriptionOptions options = Options;
|
|
LastNotificationTimestamp = TimeProvider.GetTimestamp();
|
|
m_keepAliveInterval = CurrentPublishingInterval.Multiply(CurrentKeepAliveCount + 1);
|
|
if (m_keepAliveInterval < s_minKeepAliveTimerInterval)
|
|
{
|
|
m_keepAliveInterval = options.PublishingInterval.Multiply(options.KeepAliveCount + 1);
|
|
}
|
|
if (m_keepAliveInterval > Timeout.InfiniteTimeSpan)
|
|
{
|
|
m_keepAliveInterval = Timeout.InfiniteTimeSpan;
|
|
}
|
|
if (m_keepAliveInterval < s_minKeepAliveTimerInterval)
|
|
{
|
|
m_keepAliveInterval = s_minKeepAliveTimerInterval;
|
|
}
|
|
m_publishTimer.Change(m_keepAliveInterval, m_keepAliveInterval);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if a notification has arrived in time.
|
|
/// </summary>
|
|
/// <param name="state"></param>
|
|
private void OnKeepAlive(object? state)
|
|
{
|
|
// check if a publish has arrived.
|
|
if (PublishingStopped)
|
|
{
|
|
Interlocked.Increment(ref m_publishLateCount);
|
|
OnPublishStateChanged(PublishState.Stopped);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ensures sensible values for the counts.
|
|
/// </summary>
|
|
/// <param name="options"></param>
|
|
/// <param name="keepAliveCount"></param>
|
|
/// <param name="lifetimeCount"></param>
|
|
private void AdjustCounts(SubscriptionOptions options, out uint keepAliveCount,
|
|
out uint lifetimeCount)
|
|
{
|
|
const uint kDefaultKeepAlive = 10;
|
|
const uint kDefaultLifeTime = 1000;
|
|
|
|
keepAliveCount = options.KeepAliveCount;
|
|
lifetimeCount = options.LifetimeCount;
|
|
// keep alive count must be at least 1, 10 is a good default.
|
|
if (keepAliveCount == 0)
|
|
{
|
|
Logger.LogInformation("{Subscription}: Adjusted KeepAliveCount " +
|
|
"from {Old} to {New}.", this, keepAliveCount, kDefaultKeepAlive);
|
|
keepAliveCount = kDefaultKeepAlive;
|
|
}
|
|
|
|
// ensure the lifetime is sensible given the sampling interval.
|
|
if (options.PublishingInterval > TimeSpan.Zero)
|
|
{
|
|
if (options.MinLifetimeInterval > TimeSpan.Zero &&
|
|
options.MinLifetimeInterval < m_context.SessionTimeout)
|
|
{
|
|
Logger.LogWarning(
|
|
"{Subscription}: A smaller minimum LifetimeInterval " +
|
|
"{Counter}ms than session timeout {Timeout}ms configured.",
|
|
this, options.MinLifetimeInterval, m_context.SessionTimeout);
|
|
}
|
|
|
|
uint minLifetimeInterval = (uint)options.MinLifetimeInterval.TotalMilliseconds;
|
|
uint publishingInterval = (uint)options.PublishingInterval.TotalMilliseconds;
|
|
uint minLifetimeCount = minLifetimeInterval / publishingInterval;
|
|
if (lifetimeCount < minLifetimeCount)
|
|
{
|
|
lifetimeCount = minLifetimeCount;
|
|
|
|
if (minLifetimeInterval % publishingInterval != 0)
|
|
{
|
|
lifetimeCount++;
|
|
}
|
|
Logger.LogInformation(
|
|
"{Subscription}: Adjusted LifetimeCount to value={New}.",
|
|
this, lifetimeCount);
|
|
}
|
|
|
|
if (options.PublishingInterval.Multiply(lifetimeCount) < m_context.SessionTimeout)
|
|
{
|
|
Logger.LogWarning(
|
|
"{Subscription}: Lifetime {LifeTime}ms configured is less " +
|
|
"than session timeout {Timeout}ms.", this,
|
|
options.PublishingInterval.Multiply(lifetimeCount), m_context.SessionTimeout);
|
|
}
|
|
}
|
|
else if (lifetimeCount == 0)
|
|
{
|
|
// don't know what the sampling interval will be - use something large
|
|
// enough to ensure the user does not experience unexpected drop outs.
|
|
Logger.LogInformation(
|
|
"{Subscription}: Adjusted LifetimeCount from {Old} to {New}. ",
|
|
this, lifetimeCount, kDefaultLifeTime);
|
|
lifetimeCount = kDefaultLifeTime;
|
|
}
|
|
|
|
// validate spec: lifetimecount shall be at least 3*keepAliveCount
|
|
uint minLifeTimeCount = 3 * keepAliveCount;
|
|
if (lifetimeCount < minLifeTimeCount)
|
|
{
|
|
Logger.LogInformation(
|
|
"{Subscription}: Adjusted LifetimeCount from {Old} to {New}.",
|
|
this, lifetimeCount, minLifeTimeCount);
|
|
lifetimeCount = minLifeTimeCount;
|
|
}
|
|
}
|
|
|
|
private static readonly TimeSpan s_minKeepAliveTimerInterval = TimeSpan.FromSeconds(1);
|
|
private static readonly TimeSpan s_keepAliveTimerMargin = TimeSpan.FromSeconds(1);
|
|
private TimeSpan m_keepAliveInterval;
|
|
private int m_publishLateCount;
|
|
private readonly AsyncAutoResetEvent m_stateControl = new();
|
|
private readonly AsyncManualResetEvent m_createdEvent = new();
|
|
private readonly CancellationTokenSource m_cts = new();
|
|
private readonly Task m_stateManagement;
|
|
private readonly SemaphoreSlim m_stateLock = new(1, 1);
|
|
private readonly List<uint> m_deletedItems = [];
|
|
private readonly ITimer m_publishTimer;
|
|
private readonly IDisposable? m_changeTracking;
|
|
private readonly ISubscriptionNotificationHandler m_handler;
|
|
private readonly ISubscriptionContext m_context;
|
|
private readonly MonitoredItemManager m_monitoredItems;
|
|
}
|
|
}
|