mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-29 15:11:53 +08:00
# Description Fixes client subscription acknowledgement defects and replaces the mutable event-based acknowledgement policy with an injectable, asynchronous provider model. The latest changes in `7be912614` add opt-in **whole-message delivery**, harden lifecycle/concurrency behavior, and simplify acknowledgement state and wire-array construction. Existing features and all deprecations remain in this PR. The acknowledgement unit is one OPC UA `NotificationMessage`, not one data change, event, downstream message, or group of Publish responses indicated by `MoreNotifications`. ### Protocol and lifecycle fixes - Ensure acknowledgements make progress when fewer acknowledgements are queued than there are Publish workers. - Honor legacy deferral on V2 and preserve deferred/in-flight acknowledgements across retries. A throwing legacy callback restores the original lists; a throwing provider defers the batch. - Validate ownership before sending and after provider decisions, including subscription removal, recreation, changed IDs and server ID reuse. Reject detected responses from the previous owner before they advance the replacement subscription's sequence cursor. - Prune acknowledgements no longer retained by the server, including servers without a retransmission queue. - Retire one subscription's acknowledgement state without quiescing publishing for the whole session. - Reserve the classic engine's provider-decision slot before invoking application code. Concurrent Publish calls cannot overwrite pending decisions; asynchronous failures are observed and logged. - Preserve the original outstanding lifecycle when a cancelled queue attempt can still be queued. Retries do not create an additional terminal outcome. - Retire internal confirmation state synchronously, independently of application observer progress. Observer callbacks remain asynchronous and outside SDK locks. ### Public API and whole-message delivery | API | Purpose | |---|---| | `AcknowledgementHandle` | Process-local acknowledgement identity scoped to a subscription lifetime; avoids correlating messages solely by subscription ID and sequence number. | | `ISubscriptionAcknowledgementProvider` | Asynchronous, fail-closed send/defer decisions over `ArrayOf<AcknowledgementHandle>`. | | `ISubscriptionAcknowledgementObserver` | Terminal outcomes: `Acknowledged`, `NotRetained`, `OwnerRetired`, or `Abandoned`, subject to the cross-session limitation below. | | `AcknowledgementMode.AfterConfirmation` / `ISessionAcknowledgements.Confirm` | Do not acknowledge until the application explicitly confirms processing of the complete message. | | `ISubscriptionNotificationMessageHandler` / `SubscriptionNotificationMessage` | Opt-in delivery of all typed payloads in one envelope, preserving payload order, physical-partition identity, publish time and diagnostic string table. | - Provider configuration is available through `ManagedSessionOptions`, fluent builder methods and DI registration. - Whole-message handlers are registered through the V2 subscription APIs or `Subscription.NotificationMessageHandler` on the classic engine. Existing load/restore factories can return handlers implementing both notification interfaces. - Whole-message handling replaces data/event callbacks only for the opted-in subscription; keep-alive and lifecycle handling remain available. Returning from the handler does not implicitly confirm delivery. - Projection arrays do not borrow pooled wire notification containers. Monitored-item references remain live SDK objects. - Whole-message handlers receive shutdown cancellation before subscription retirement waits for their dispatch to finish. - Managed model-change tracking owns a dedicated whole-message subscription instead of sharing application streaming acknowledgement ownership. It confirms only after all events succeed; namespace-refresh, cache-invalidation and subscriber failures remain logged but do not produce a success confirmation. ### Compatibility and deprecations The following deprecations remain, with compatibility behavior preserved: - `ISession.PublishSequenceNumbersToAcknowledge`: honored when no provider is configured; not raised when a provider owns the policy. - Classic `FastDataChangeCallback`, `FastEventCallback`, `FastKeepAliveCallback` and their delegate types. - `ISession.DefaultSubscription` and its implementations. The lifetime proposal from the closed PR #4901 is incorporated through the provider/handle model rather than additional lifecycle events. ### Simplification and performance scope - Pending acknowledgements derive owner and generation from their scope rather than storing redundant copies. - Wire acknowledgements are projected directly into the final array, eliminating the intermediate converted list and its extra copy. - Whole-message envelopes are constructed only for opted-in handlers. The existing payload-callback path remains available. - Handles reuse cached subscription-lifetime scopes. Provider callbacks execute outside SDK locks. This is not a benchmarked throughput or allocation-parity claim. Broader lock/contention work, provider-path allocations and benchmark evidence remain tracked by #4912. ### Known limitations and non-goals - **#4929 remains open:** classic subscriptions served by the default V2 compatibility bridge do not yet participate in normal acknowledgement tracking. Supporting the classic engine is not the same as fixing that bridge. - **#4930 remains open:** transferring a classic subscription between different `Session` objects does not yet move tracked acknowledgements/confirmations between engines. Duplicate terminal reports or lost confirmations remain possible on that path. In-place managed-session recreation is a different, supported path. - Delaying protocol acknowledgement is not itself downstream retry or durable storage. Applications still own downstream retry/buffering; server retransmission queues are finite. - External observer delivery is buffered. Slow observers can grow their notification backlog, although they no longer retain internal confirmation state. Disposal waits up to five seconds for callbacks; an unresponsive observer can outlive disposal. - HA takeover/session-continuation redesign and server-side acknowledgement defects are not addressed here. Documentation: [Subscriptions](docs/Subscriptions.md), [Migration guide](docs/MigrationGuide.md), and [Model-change tracking](docs/ModelChangeTracking.md). ### Validation Local validation of the changes included in `7be912614`: | Check | Result | |---|---| | Expanded net10 client regression selection | **389 passed, 0 failed, 0 skipped** | | Final API-registration and whole-message-routing selection | **3 passed**; two overlap the regression selection, one is additional (**390 distinct client cases across the runs**) | | Acknowledgement integration tests | **4 passed**: classic/V2, each with legacy payload callbacks and whole-message handling | | `Opc.Ua.Client` net48 build | **0 warnings, 0 errors** | | Scoped whitespace, style and analyzer verification | Passed, including informational severity for style/analyzers | | Negative/revert controls | Tests reproduced the provider race, missing whole-message routing, observer-dependent cleanup, cancelled-queue lifecycle loss, shutdown deadlock and false model-change confirmation; corrections were restored and positive cases passed | <details> <summary>Exact local test/build commands and results</summary> Run from the repository root. Framework-specific assets must be restored before commands using `--no-restore`; perform the net48 build last because changing `CustomTestTarget` replaces restored assets. ```powershell dotnet test tests\Opc.Ua.Client.Tests\Opc.Ua.Client.Tests.csproj -p:CustomTestTarget=net10.0 --framework net10.0 --no-restore --filter "FullyQualifiedName~AcknowledgementProviderTests|FullyQualifiedName~SubscriptionManagerTests|FullyQualifiedName~ClassicSubscriptionEngineTests|FullyQualifiedName~ManagedSessionAcknowledgementForwardingTests|FullyQualifiedName~AcknowledgementOptionsTests|FullyQualifiedName~SubscriptionTests|FullyQualifiedName~ModelChangeTrackerTests|FullyQualifiedName~PartitionForwardingHandlerTests|FullyQualifiedName~SessionEngineContextTests|FullyQualifiedName~DefaultSubscriptionEngineTests|FullyQualifiedName~MonitoredItemManagerTests|FullyQualifiedName~MessageProcessorTests" --logger "console;verbosity=minimal" -v quiet -nologo # Passed: 389; failed: 0; skipped: 0. dotnet test tests\Opc.Ua.Client.Tests\Opc.Ua.Client.Tests.csproj -p:CustomTestTarget=net10.0 --framework net10.0 --no-restore --filter "FullyQualifiedName~WholeMessageRegistrationRejectsMissingInputs|FullyQualifiedName~WholeMessageDeliveryPreservesMixedPayloadsAsync" --logger "console;verbosity=minimal" -v quiet -nologo # Passed: 3; failed: 0; skipped: 0. Two cases overlap the previous selection. dotnet test tests\Opc.Ua.Subscriptions.Tests\Opc.Ua.Subscriptions.Tests.csproj -p:CustomTestTarget=net10.0 --framework net10.0 --no-restore --filter "FullyQualifiedName~AcknowledgementProviderIntegrationTests" --logger "console;verbosity=minimal" -v quiet -nologo # Passed: 4; failed: 0; skipped: 0. dotnet build src\Opc.Ua.Client\Opc.Ua.Client.csproj -p:CustomTestTarget=net48 -p:RestoreSources=https://www.nuget.org/api/v2/ -p:NuGetAudit=false -v quiet -nologo # Build succeeded: 0 warnings, 0 errors. ``` The v2 feed and disabled NuGet audit above were local restore workarounds only; repository settings were not changed. CI dependency auditing remains required. </details> The full `UA.slnx` net48/net10 test matrix, NativeAOT publishing/runtime validation, fresh coverage measurements and performance benchmarks were **not run locally for this revision**. CI and maintainer API review remain outstanding. Historical results on `a28c12252` are not presented as validation of the latest head. ## Related Issues _If this is a relatively large or complex change, a design must have been discussed in the related tracking issue and signed off (which becomes the Architectural Decision Record (ADR))._ - Fixes #4894: acknowledgement progress, deferral/retry behavior and ownership validation. - Fixes #4898: injectable fail-closed decision API, with legacy precedence preserved. - Fixes #4913: admission of detected old-owner responses after subscription ID reuse. Non-closing references: | Issue | Title / scope | Disposition | |---|---|---| | [#4897](https://github.com/OPCFoundation/UA-.NETStandard/issues/4897) | Stable notification identity and terminal acknowledgement state | Core functionality is implemented via handles/providers, with whole-message delivery added. Keep this link non-closing until the cross-session exception in #4930 is resolved or the issue's accepted scope is explicitly narrowed. | | [#4912](https://github.com/OPCFoundation/UA-.NETStandard/issues/4912) | Reduce acknowledgement-path lock contention and allocations | Partially advanced by state/projection simplification; broader locking changes and benchmarks remain. | | [#4929](https://github.com/OPCFoundation/UA-.NETStandard/issues/4929) | Classic subscriptions on the V2 compatibility bridge | Not fixed; separate follow-up. | | [#4930](https://github.com/OPCFoundation/UA-.NETStandard/issues/4930) | Cross-session classic acknowledgement handover | Not fixed; separate follow-up and explicit limitation on terminal-outcome guarantees. | Design rationale: [maintainer request for injectable providers and session facets](https://github.com/OPCFoundation/UA-.NETStandard/pull/4899#discussion_r4366515418). The new whole-message API is submitted for maintainer review; this description does not claim that its final API shape or an ADR has been signed off. ## 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. Regression tests were added and passed; the combined tests/coverage checkbox is left open because a fresh coverage increase has not yet been verified. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Marc Schier <marcschier@users.noreply.github.com> Co-authored-by: Marc <marcschier@hotmail.com>
1182 lines
56 KiB
C#
1182 lines
56 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.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using Moq;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Client.Subscriptions;
|
|
using Opc.Ua.Client.TestFramework;
|
|
using Opc.Ua.Tests;
|
|
|
|
#pragma warning disable CS0618 // tests exercise the obsolete classic API
|
|
|
|
namespace Opc.Ua.Client.Tests
|
|
{
|
|
[TestFixture]
|
|
[Parallelizable]
|
|
public class SubscriptionUnitTests
|
|
{
|
|
private const PublishStateChangedMask kSessionNotConnected = PublishStateChangedMask.Stopped | PublishStateChangedMask.SessionNotConnected;
|
|
|
|
public record KeepAliveTestDataProvider(PublishStateChangedMask ExpectedPublishState) : IFormattable
|
|
{
|
|
public bool SessionConnected { get; init; }
|
|
public bool SessionReconnecting { get; init; }
|
|
public bool SessionKeepAliveStopped { get; init; }
|
|
|
|
public string ToString(string? format, IFormatProvider? formatProvider)
|
|
{
|
|
return $"Connected={SessionConnected}, " +
|
|
$"reconnecting={SessionReconnecting}, " +
|
|
$"keepAlive={SessionKeepAliveStopped}. " +
|
|
$"Expected status:{ExpectedPublishState}";
|
|
}
|
|
}
|
|
|
|
private sealed class SubscriptionContainer : IDisposable
|
|
{
|
|
private readonly CancellationTokenRegistration m_tokedCancellation;
|
|
public Subscription Subscription { get; }
|
|
public Task[] ProcessedMessages { get; }
|
|
|
|
public SubscriptionContainer(Subscription subscription, TaskCompletionSource<bool>[] awaiters, CancellationToken token)
|
|
{
|
|
Subscription = subscription;
|
|
ProcessedMessages = [.. awaiters.Select(x => x.Task)];
|
|
m_tokedCancellation = token.Register(() =>
|
|
{
|
|
foreach (TaskCompletionSource<bool> awaiter in awaiters)
|
|
{
|
|
awaiter.TrySetCanceled();
|
|
}
|
|
});
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
m_tokedCancellation.Dispose();
|
|
Subscription.Dispose();
|
|
}
|
|
}
|
|
|
|
private static Task AwaitForRepublishTimeout(CancellationToken ct)
|
|
{
|
|
return Task.Delay(Subscription.RepublishMessageTimeout + 100, ct);
|
|
}
|
|
|
|
private static ISession BuildSessionMock(Func<uint, uint, bool>? republishHandler = null, Action<Mock<ISession>>? setup = null)
|
|
{
|
|
uint subscriptionIdSeed = 0u;
|
|
|
|
var session = new Mock<ISession>();
|
|
if (republishHandler is not null)
|
|
{
|
|
session
|
|
.Setup(x => x.RepublishAsync(It.IsAny<uint>(), It.IsAny<uint>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync<uint, uint, CancellationToken, ISession, (bool, ServiceResult)>((
|
|
subscriptionId,
|
|
sequenceNumber,
|
|
ct) =>
|
|
{
|
|
if (subscriptionId > subscriptionIdSeed)
|
|
{
|
|
return (true, StatusCodes.BadSubscriptionIdInvalid);
|
|
}
|
|
if (republishHandler(subscriptionId, sequenceNumber))
|
|
{
|
|
return (true, ServiceResult.Good);
|
|
}
|
|
return (true, StatusCodes.BadMessageNotAvailable);
|
|
});
|
|
}
|
|
session
|
|
.Setup(x => x
|
|
.CreateSubscriptionAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<double>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<byte>(),
|
|
It.IsAny<CancellationToken>()
|
|
)
|
|
)
|
|
.ReturnsAsync<
|
|
RequestHeader,
|
|
double,
|
|
uint,
|
|
uint,
|
|
uint,
|
|
bool,
|
|
byte,
|
|
CancellationToken,
|
|
ISession,
|
|
CreateSubscriptionResponse
|
|
>((
|
|
requestHeader,
|
|
requestedPublishingInterval,
|
|
requestedLifetimeCount,
|
|
requestedMaxKeepAliveCount,
|
|
maxNotificationsPerPublish,
|
|
publishingEnabled,
|
|
priority,
|
|
ct
|
|
) => new() { SubscriptionId = ++subscriptionIdSeed });
|
|
session
|
|
.Setup(x => x.SetPublishingModeAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<ArrayOf<uint>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync<
|
|
RequestHeader,
|
|
bool,
|
|
ArrayOf<uint>,
|
|
CancellationToken,
|
|
ISession,
|
|
SetPublishingModeResponse
|
|
>((requestHeader, publishingEnabled, subscriptionIds, ct) => new()
|
|
{
|
|
Results = [.. subscriptionIds.ConvertAll(id => id > subscriptionIdSeed ?
|
|
StatusCodes.BadSubscriptionIdInvalid :
|
|
StatusCodes.Good)],
|
|
DiagnosticInfos = [.. subscriptionIds.ConvertAll(_ => new DiagnosticInfo())]
|
|
});
|
|
setup?.Invoke(session);
|
|
return session.Object;
|
|
}
|
|
|
|
private static NotificationMessage[] BuildMessages(int count)
|
|
{
|
|
return [.. Enumerable
|
|
.Range(1, count)
|
|
.Select(sequenceNumber => new NotificationMessage
|
|
{
|
|
SequenceNumber = (uint)sequenceNumber,
|
|
NotificationData = [new(new DataChangeNotification { SequenceNumber = (uint)sequenceNumber })]
|
|
})
|
|
.Prepend(new())];
|
|
}
|
|
|
|
private static async Task<SubscriptionContainer> BuildSubscriptionAsync(
|
|
NotificationMessage[] messagesToProcess,
|
|
bool sequentialPublishing,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
TaskCompletionSource<bool>[] messageAwaiters =
|
|
[.. messagesToProcess.Select(_ => new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously))];
|
|
messageAwaiters[0].SetResult(true);
|
|
List<uint> availableSequenceNumbers = [.. messagesToProcess.Skip(1).Select(x => x.SequenceNumber)];
|
|
|
|
var subscription = new Subscription(
|
|
NUnitTelemetryContext.Create(),
|
|
new()
|
|
{
|
|
PublishingEnabled = true,
|
|
SequentialPublishing = sequentialPublishing,
|
|
MaxMessageCount = messagesToProcess.Length
|
|
})
|
|
{
|
|
FastDataChangeCallback = (_, message, _) => messageAwaiters[message.SequenceNumber].TrySetResult(true)
|
|
};
|
|
subscription.Session = BuildSessionMock((subscriptionId, sequenceNumber) =>
|
|
{
|
|
//simplified republish emulation
|
|
if (subscription.Id == subscriptionId && availableSequenceNumbers.Remove(sequenceNumber))
|
|
{
|
|
subscription.SaveMessageInCache(default, messagesToProcess[sequenceNumber]);
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
await subscription.CreateAsync(cancellationToken).ConfigureAwait(false);
|
|
return new(subscription, messageAwaiters, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set up a Server and a Client instance.
|
|
/// </summary>
|
|
[OneTimeSetUp]
|
|
public void OneTimeSetUp()
|
|
{
|
|
TestContext.AddFormatter<NotificationMessage>((obj) =>
|
|
{
|
|
if (obj is NotificationMessage other)
|
|
{
|
|
return $"{nameof(NotificationMessage)}: {other.SequenceNumber}";
|
|
}
|
|
return null!;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression test for the documented sequential-publishing trade-off:
|
|
/// when <c>sequentialPublishing == true</c> the subscription tolerates
|
|
/// at most one message of look-ahead before abandoning the next
|
|
/// expected sequence number. A late-arriving message whose republish
|
|
/// timeout has already elapsed is dropped instead of being re-inserted
|
|
/// out of order, in exchange for the strict in-order delivery
|
|
/// guarantee on the remaining sequence.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Marked <see cref="ExplicitAttribute"/> because the current
|
|
/// subscription implementation delivers the late <c>messages[1]</c>
|
|
/// out-of-order (5 notifications observed where 4 are expected)
|
|
/// instead of abandoning it. The test is retained as a manual probe
|
|
/// until the underlying sequential-publishing abandonment logic is
|
|
/// fixed; see follow-up tracking issue.
|
|
/// </remarks>
|
|
[Test]
|
|
[Explicit("Tracks the sequential-publishing late-message abandonment bug; un-mark once the product code drops messages past the republish timeout.")]
|
|
[CancelAfter(Subscription.RepublishMessageTimeout * 6)]
|
|
public async Task OldMessagesAbandonedAfterRepublishTimeoutInSequentialModeAsync(CancellationToken ct)
|
|
{
|
|
NotificationMessage[] messages = BuildMessages(5);
|
|
using SubscriptionContainer container = await BuildSubscriptionAsync(messages, sequentialPublishing: true, ct).ConfigureAwait(false);
|
|
Subscription subscription = container.Subscription;
|
|
|
|
subscription.SaveMessageInCache([], messages[3]);
|
|
await AwaitForRepublishTimeout(ct).ConfigureAwait(false);
|
|
subscription.SaveMessageInCache([], messages[2]);//this one will be processed, because one message out of order is always awaited
|
|
await AwaitForRepublishTimeout(ct).ConfigureAwait(false);
|
|
subscription.SaveMessageInCache([], messages[4]);
|
|
await AwaitForRepublishTimeout(ct).ConfigureAwait(false);
|
|
subscription.SaveMessageInCache([], messages[1]);//this one should be lost due to expiration timout
|
|
await AwaitForRepublishTimeout(ct).ConfigureAwait(false);
|
|
subscription.SaveMessageInCache([], messages[5]);
|
|
|
|
await Task.WhenAll(
|
|
container.ProcessedMessages[2],
|
|
container.ProcessedMessages[3],
|
|
container.ProcessedMessages[4],
|
|
container.ProcessedMessages[5]
|
|
).ConfigureAwait(false);
|
|
|
|
Assert.That(subscription.Notifications, Is.EqualTo([messages[2], messages[3], messages[4], messages[5]]));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whole-message diagnostics come from the wire array even when caching is disabled
|
|
/// or the message contains client handles that are not registered locally.
|
|
/// </summary>
|
|
[TestCase(true, false)]
|
|
[TestCase(false, false)]
|
|
[TestCase(true, true)]
|
|
[TestCase(false, true)]
|
|
[CancelAfter(10_000)]
|
|
public async Task WholeMessageDeliveryPreservesWireDiagnosticsAsync(
|
|
bool disableCache, bool registeredItems, CancellationToken ct)
|
|
{
|
|
var delivered = new TaskCompletionSource<SubscriptionNotificationMessage>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var handler = new Mock<ISubscriptionNotificationMessageHandler>();
|
|
handler.Setup(value => value.OnNotificationMessageAsync(
|
|
It.IsAny<SubscriptionNotificationMessage>(), It.IsAny<CancellationToken>()))
|
|
.Callback((SubscriptionNotificationMessage message, CancellationToken _) =>
|
|
delivered.TrySetResult(message))
|
|
.Returns(default(ValueTask));
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
using var subscription = new Subscription(telemetry, new() { PublishingEnabled = true })
|
|
{
|
|
DisableMonitoredItemCache = disableCache,
|
|
NotificationMessageHandler = handler.Object,
|
|
Session = BuildSessionMock()
|
|
};
|
|
await subscription.CreateAsync(ct).ConfigureAwait(false);
|
|
uint firstHandle = 11;
|
|
uint secondHandle = 12;
|
|
if (registeredItems)
|
|
{
|
|
var firstItem = new MonitoredItem(telemetry);
|
|
var secondItem = new MonitoredItem(telemetry);
|
|
subscription.AddItems([firstItem, secondItem]);
|
|
firstHandle = firstItem.ClientHandle;
|
|
secondHandle = secondItem.ClientHandle;
|
|
}
|
|
var first = new DiagnosticInfo { SymbolicId = 0, AdditionalInfo = "first wire diagnostic" };
|
|
var second = new DiagnosticInfo { SymbolicId = 1, AdditionalInfo = "second wire diagnostic" };
|
|
var data = new DataChangeNotification
|
|
{
|
|
MonitoredItems =
|
|
[
|
|
new MonitoredItemNotification { ClientHandle = firstHandle },
|
|
new MonitoredItemNotification { ClientHandle = secondHandle }
|
|
],
|
|
DiagnosticInfos = [first, second]
|
|
};
|
|
subscription.SaveMessageInCache([1u], new NotificationMessage
|
|
{
|
|
SequenceNumber = 1,
|
|
StringTable = ["first", "second"],
|
|
NotificationData = [new(data)]
|
|
}, subscription.GetAcknowledgementScope());
|
|
|
|
SubscriptionNotificationMessage message = await delivered.Task.WaitAsync(ct).ConfigureAwait(false);
|
|
|
|
Assert.That(data.MonitoredItems[0].DiagnosticInfo,
|
|
Is.SameAs(registeredItems && !disableCache ? first : null));
|
|
Assert.That(message.Notifications[0].DataChanges[0].DiagnosticInfo, Is.SameAs(first));
|
|
Assert.That(message.Notifications[0].DataChanges[1].DiagnosticInfo, Is.SameAs(second));
|
|
Assert.That(message.StringTable[1], Is.EqualTo("second"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A notification carries the acknowledgement handle captured when its message was
|
|
/// received, even if the subscription lifetime changes before the message is
|
|
/// dispatched, so it matches the acknowledgement the engine tracks.
|
|
/// </summary>
|
|
[Test]
|
|
[CancelAfter(10_000)]
|
|
public async Task DispatchedNotificationsCarryTheHandleCapturedAtReceiptAsync(CancellationToken ct)
|
|
{
|
|
using var session = SessionMock.Create();
|
|
session.Acknowledgements = new SessionAcknowledgements(AcknowledgementMode.AfterConfirmation, null, null);
|
|
var delivered = new TaskCompletionSource<DataChangeNotification>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
using var subscription = new Subscription(
|
|
NUnitTelemetryContext.Create(),
|
|
new() { PublishingEnabled = true })
|
|
{
|
|
FastDataChangeCallback = (_, notification, _) => delivered.TrySetResult(notification),
|
|
Session = BuildSessionMock()
|
|
};
|
|
// Created through the mocked services, which starts the dispatch worker, then bound to a
|
|
// session with an acknowledgement configuration.
|
|
await subscription.CreateAsync(ct).ConfigureAwait(false);
|
|
subscription.Session = session;
|
|
var received = new AcknowledgementScope(subscription, 5, subscription.Id);
|
|
Assert.That(subscription.GetAcknowledgementScope(), Is.Not.SameAs(received),
|
|
"The lifetime changed after the message was received.");
|
|
|
|
subscription.SaveMessageInCache([1u], new NotificationMessage
|
|
{
|
|
SequenceNumber = 1,
|
|
NotificationData = [new(new DataChangeNotification { SequenceNumber = 1 })]
|
|
}, received);
|
|
DataChangeNotification notification = await delivered.Task.WaitAsync(ct).ConfigureAwait(false);
|
|
|
|
Assert.That(subscription.TryGetAcknowledgementHandle(notification, out AcknowledgementHandle handle), Is.True);
|
|
Assert.That(handle, Is.EqualTo(new AcknowledgementHandle(received, 1)));
|
|
|
|
// A message cached without a tracked acknowledgement (the default-engine
|
|
// compatibility path) gets no handle.
|
|
delivered = new TaskCompletionSource<DataChangeNotification>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
subscription.SaveMessageInCache([2u], new NotificationMessage
|
|
{
|
|
SequenceNumber = 2,
|
|
NotificationData = [new(new DataChangeNotification { SequenceNumber = 2 })]
|
|
});
|
|
DataChangeNotification untracked = await delivered.Task.WaitAsync(ct).ConfigureAwait(false);
|
|
Assert.That(subscription.TryGetAcknowledgementHandle(untracked, out _), Is.False);
|
|
}
|
|
|
|
[Test]
|
|
[CancelAfter(Subscription.RepublishMessageTimeout * 3)]
|
|
public async Task WillRestoreOrderOfTwoMessagesForSequentialPublishingAsync(CancellationToken ct)
|
|
{
|
|
NotificationMessage[] messages = BuildMessages(3);
|
|
using SubscriptionContainer container = await BuildSubscriptionAsync(messages, sequentialPublishing: true, ct).ConfigureAwait(false);
|
|
Subscription subscription = container.Subscription;
|
|
|
|
subscription.SaveMessageInCache([], messages[2]);
|
|
await AwaitForRepublishTimeout(ct).ConfigureAwait(false);
|
|
subscription.SaveMessageInCache([], messages[1]);
|
|
await AwaitForRepublishTimeout(ct).ConfigureAwait(false);
|
|
subscription.SaveMessageInCache([], messages[3]);
|
|
|
|
await Task.WhenAll(container.ProcessedMessages).ConfigureAwait(false);
|
|
Assert.That(subscription.Notifications, Is.EqualTo(messages.Skip(1)));
|
|
}
|
|
|
|
[Theory]
|
|
[CancelAfter(Subscription.RepublishMessageTimeout * 7)]
|
|
public async Task WillAbandonMissedMessagesIfThereAreNoAvailableSequenceNumbersAsync(bool sequentialPublishing, CancellationToken ct)
|
|
{
|
|
int[] sequenceNumbersToPublish = [/*1..2 gap*/ 3, 4, /*5..7 gap*/ 8, 9, 10];
|
|
NotificationMessage[] messages = BuildMessages(sequenceNumbersToPublish[^1]);
|
|
NotificationMessage[] messagesToPublish = [.. sequenceNumbersToPublish.Select(x => messages[x])];
|
|
using SubscriptionContainer container = await BuildSubscriptionAsync(messages, sequentialPublishing, ct).ConfigureAwait(false);
|
|
Subscription subscription = container.Subscription;
|
|
|
|
foreach (NotificationMessage message in messagesToPublish)
|
|
{
|
|
subscription.SaveMessageInCache([], message);
|
|
await AwaitForRepublishTimeout(ct).ConfigureAwait(false);
|
|
}
|
|
await Task.WhenAll(sequenceNumbersToPublish.Select(x => container.ProcessedMessages[x])).ConfigureAwait(false);
|
|
|
|
if (sequentialPublishing)
|
|
{
|
|
Assert.That(subscription.Notifications, Is.EqualTo(messagesToPublish));
|
|
}
|
|
else
|
|
{
|
|
Assert.That(subscription.Notifications, Is.EquivalentTo(messagesToPublish));
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[CancelAfter(Subscription.RepublishMessageTimeout * 3)]
|
|
public async Task WillRepublishIfMissedMessagesOnFirstPublishAsync(
|
|
bool sequentialPublishing,
|
|
[Values(2, 5, 8, 11)] int gapEnd,
|
|
CancellationToken ct)
|
|
{
|
|
int finalMessage = gapEnd + 1;
|
|
NotificationMessage[] messages = BuildMessages(finalMessage);
|
|
uint[] sequenceNumbersOfGap = [.. messages.Skip(1).Take(gapEnd).Select(x => x.SequenceNumber)];
|
|
using SubscriptionContainer container = await BuildSubscriptionAsync(messages, sequentialPublishing, ct).ConfigureAwait(false);
|
|
Subscription subscription = container.Subscription;
|
|
|
|
subscription.SaveMessageInCache(sequenceNumbersOfGap, messages[gapEnd]);
|
|
await AwaitForRepublishTimeout(ct).ConfigureAwait(false);
|
|
subscription.SaveMessageInCache(sequenceNumbersOfGap, messages[finalMessage]);
|
|
await Task.WhenAll(container.ProcessedMessages).ConfigureAwait(false);
|
|
|
|
if (sequentialPublishing)
|
|
{
|
|
Assert.That(subscription.Notifications, Is.EqualTo(messages.Skip(1)));
|
|
}
|
|
else
|
|
{
|
|
Assert.That(subscription.Notifications, Is.EquivalentTo(messages.Skip(1)));
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[CancelAfter(Subscription.RepublishMessageTimeout * 3)]
|
|
public async Task WillRepublishIfMissedMessagesInBetweenOfPublishesAsync(
|
|
bool sequentialPublishing,
|
|
[Values(1, 6, 9)] int gapStart,
|
|
[Values(1, 6, 9)] int gapSize,
|
|
CancellationToken ct)
|
|
{
|
|
int gapEnd = gapStart + gapSize + 1;
|
|
int finalMessage = gapEnd + 1;
|
|
NotificationMessage[] messages = BuildMessages(finalMessage);
|
|
uint[] sequenceNumbersOfGap = [.. messages.Skip(1).Skip(gapStart).Take(gapSize).Select(x => x.SequenceNumber)];
|
|
using SubscriptionContainer container = await BuildSubscriptionAsync(messages, sequentialPublishing, ct).ConfigureAwait(false);
|
|
Subscription subscription = container.Subscription;
|
|
|
|
for (int i = 1; i <= gapStart; i++)
|
|
{
|
|
subscription.SaveMessageInCache([], messages[i]);
|
|
}
|
|
subscription.SaveMessageInCache(sequenceNumbersOfGap, messages[gapEnd]);
|
|
await AwaitForRepublishTimeout(ct).ConfigureAwait(false);
|
|
subscription.SaveMessageInCache(sequenceNumbersOfGap, messages[finalMessage]);
|
|
await Task.WhenAll(container.ProcessedMessages).ConfigureAwait(false);
|
|
|
|
if (sequentialPublishing)
|
|
{
|
|
Assert.That(subscription.Notifications, Is.EqualTo(messages.Skip(1)));
|
|
}
|
|
else
|
|
{
|
|
Assert.That(subscription.Notifications, Is.EquivalentTo(messages.Skip(1)));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Steady delivery and sequence rollover do not resynchronize the cursor backwards.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentOutOfRangeException"></exception>
|
|
[TestCase("steady", false)]
|
|
[TestCase("steady", true)]
|
|
[TestCase("wrap", false)]
|
|
[TestCase("wrap", true)]
|
|
[TestCase("pending", false)]
|
|
public async Task LeadingGapDoesNotRewindOrLogDuringSteadyDeliveryAsync(
|
|
string scenario, bool sequentialPublishing)
|
|
{
|
|
ArrayOf<uint> sequenceNumbers = scenario switch
|
|
{
|
|
"steady" => [1, 2, 3],
|
|
"wrap" => [uint.MaxValue, 1, 2],
|
|
"pending" => [1, 4, 5],
|
|
_ => throw new ArgumentOutOfRangeException(nameof(scenario))
|
|
};
|
|
var logger = new Mock<ILogger>();
|
|
logger.Setup(value => value.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
|
|
var loggerFactory = new Mock<ILoggerFactory>();
|
|
loggerFactory.Setup(value => value.CreateLogger(It.IsAny<string>())).Returns(logger.Object);
|
|
var telemetry = new Mock<ITelemetryContext>();
|
|
telemetry.SetupGet(value => value.LoggerFactory).Returns(loggerFactory.Object);
|
|
var received = new ConcurrentQueue<uint>();
|
|
var completions = new Dictionary<uint, TaskCompletionSource<bool>>();
|
|
foreach (uint sequenceNumber in sequenceNumbers)
|
|
{
|
|
completions.Add(sequenceNumber,
|
|
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously));
|
|
}
|
|
using var subscription = new Subscription(telemetry.Object)
|
|
{
|
|
Session = BuildSessionMock(),
|
|
SequentialPublishing = sequentialPublishing,
|
|
FastDataChangeCallback = (_, notification, _) =>
|
|
{
|
|
received.Enqueue(notification.SequenceNumber);
|
|
completions[notification.SequenceNumber].TrySetResult(true);
|
|
}
|
|
};
|
|
await subscription.CreateAsync(CancellationToken.None).ConfigureAwait(false);
|
|
for (int index = 0; index < sequenceNumbers.Count; index++)
|
|
{
|
|
uint sequenceNumber = sequenceNumbers[index];
|
|
subscription.SaveMessageInCache([], new NotificationMessage
|
|
{
|
|
SequenceNumber = sequenceNumber,
|
|
NotificationData = [new ExtensionObject(new DataChangeNotification())]
|
|
});
|
|
await completions[sequenceNumber].Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
|
if (index == 0)
|
|
{
|
|
logger.Invocations.Clear();
|
|
}
|
|
}
|
|
|
|
Assert.That(received, Is.EqualTo(sequenceNumbers.ToArray()));
|
|
Assert.That(logger.Invocations.Where(invocation =>
|
|
invocation.Method.Name == nameof(ILogger.Log) &&
|
|
invocation.Arguments[1] is EventId id &&
|
|
id.Name == "SubscriptionIdSubscriptionIdResyncedLastSequenceNumber"), Is.Empty);
|
|
Mock.Get(subscription.Session).Verify(value => value.RepublishAsync(
|
|
It.IsAny<uint>(), 0, It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Transferred messages are recovered without waiting for new data changes.
|
|
/// </summary>
|
|
[Test]
|
|
[Combinatorial]
|
|
[CancelAfter(5000)]
|
|
public async Task TransferRepublishesWithoutNewNotificationsAsync(
|
|
[Values] bool sendKeepAlive,
|
|
[Values] bool sequentialPublishing,
|
|
[Values(1, 3)] int messageCount,
|
|
CancellationToken ct)
|
|
{
|
|
var delivered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var received = new List<uint>();
|
|
using var subscription = new Subscription(NUnitTelemetryContext.Create())
|
|
{
|
|
RepublishAfterTransfer = true,
|
|
SequentialPublishing = sequentialPublishing,
|
|
FastDataChangeCallback = (_, message, _) =>
|
|
{
|
|
received.Add(message.SequenceNumber);
|
|
if (received.Count == messageCount)
|
|
{
|
|
delivered.TrySetResult(true);
|
|
}
|
|
}
|
|
};
|
|
var available = Enumerable.Range(11, messageCount).Select(value => (uint)value).ToArrayOf();
|
|
ISession session = BuildSessionMock(
|
|
(_, sequenceNumber) =>
|
|
{
|
|
subscription.SaveMessageInCache(available, new NotificationMessage
|
|
{
|
|
SequenceNumber = sequenceNumber,
|
|
NotificationData = [new(new DataChangeNotification())]
|
|
});
|
|
return true;
|
|
},
|
|
mock =>
|
|
{
|
|
mock.Setup(value => value.Subscriptions).Returns([]);
|
|
mock.Setup(value => value.RemoveTransferredSubscription(subscription)).Returns(true);
|
|
mock.Setup(value => value.AddSubscription(subscription)).Returns(true);
|
|
});
|
|
subscription.Session = session;
|
|
await subscription.CreateAsync(ct).ConfigureAwait(false);
|
|
|
|
Assert.That(await subscription.TransferAsync(session, subscription.Id, available, ct).ConfigureAwait(false),
|
|
Is.True);
|
|
if (sendKeepAlive)
|
|
{
|
|
subscription.SaveMessageInCache(available,
|
|
new NotificationMessage { SequenceNumber = (uint)(11 + messageCount) });
|
|
}
|
|
|
|
await Task.WhenAny(delivered.Task, Task.Delay(Timeout.Infinite, ct)).ConfigureAwait(false);
|
|
Assert.That(delivered.Task.IsCompleted, Is.True, "The transferred notification was not delivered.");
|
|
await delivered.Task.ConfigureAwait(false);
|
|
Assert.That(received, Is.EquivalentTo(available.ToArray()!));
|
|
foreach (uint sequenceNumber in available)
|
|
{
|
|
Mock.Get(session).Verify(value =>
|
|
value.RepublishAsync(subscription.Id, sequenceNumber, It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
Mock.Get(session).Verify(value =>
|
|
value.RepublishAsync(subscription.Id, It.IsAny<uint>(), It.IsAny<CancellationToken>()),
|
|
Times.Exactly(messageCount));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Keep-alives do not turn disabled transfer recovery into Republish requests.
|
|
/// </summary>
|
|
[Test]
|
|
[CancelAfter(5000)]
|
|
public async Task TransferWithoutRepublishPreservesKeepAliveAsync(CancellationToken ct)
|
|
{
|
|
var keepAlive = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
using var subscription = new Subscription(NUnitTelemetryContext.Create())
|
|
{
|
|
RepublishAfterTransfer = false,
|
|
FastKeepAliveCallback = (_, _) => keepAlive.TrySetResult(true)
|
|
};
|
|
ISession session = BuildSessionMock(setup: mock =>
|
|
{
|
|
mock.Setup(value => value.Subscriptions).Returns([]);
|
|
mock.Setup(value => value.RemoveTransferredSubscription(subscription)).Returns(true);
|
|
mock.Setup(value => value.AddSubscription(subscription)).Returns(true);
|
|
});
|
|
subscription.Session = session;
|
|
await subscription.CreateAsync(ct).ConfigureAwait(false);
|
|
ArrayOf<uint> available = new uint[] { 11 }.ToArrayOf();
|
|
|
|
Assert.That(await subscription.TransferAsync(session, subscription.Id, available, ct).ConfigureAwait(false),
|
|
Is.True);
|
|
subscription.SaveMessageInCache(available, new NotificationMessage { SequenceNumber = 12 });
|
|
await Task.WhenAny(keepAlive.Task, Task.Delay(Timeout.Infinite, ct)).ConfigureAwait(false);
|
|
|
|
Assert.That(keepAlive.Task.IsCompleted, Is.True);
|
|
Mock.Get(session).Verify(value =>
|
|
value.RepublishAsync(It.IsAny<uint>(), It.IsAny<uint>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
[DatapointSource]
|
|
public IEnumerable<KeepAliveTestDataProvider> SubscriptionKeepAliveValues()
|
|
{
|
|
yield return new(PublishStateChangedMask.Stopped) { SessionConnected = true, SessionReconnecting = false, SessionKeepAliveStopped = false };
|
|
|
|
yield return new(kSessionNotConnected) { SessionConnected = false, SessionReconnecting = false, SessionKeepAliveStopped = false };
|
|
yield return new(kSessionNotConnected) { SessionConnected = false, SessionReconnecting = true, SessionKeepAliveStopped = false };
|
|
yield return new(kSessionNotConnected) { SessionConnected = false, SessionReconnecting = false, SessionKeepAliveStopped = true };
|
|
yield return new(kSessionNotConnected) { SessionConnected = false, SessionReconnecting = true, SessionKeepAliveStopped = true };
|
|
|
|
yield return new(kSessionNotConnected) { SessionConnected = true, SessionReconnecting = true, SessionKeepAliveStopped = false };
|
|
yield return new(kSessionNotConnected) { SessionConnected = true, SessionReconnecting = true, SessionKeepAliveStopped = true };
|
|
|
|
yield return new(kSessionNotConnected) { SessionConnected = true, SessionReconnecting = false, SessionKeepAliveStopped = true };
|
|
}
|
|
|
|
[Theory]
|
|
[CancelAfter(Subscription.MinKeepAliveTimerInterval * 10)]
|
|
public async Task RespectsStateOfSessionDuringKeepAliveCalls(KeepAliveTestDataProvider testData, CancellationToken ct)
|
|
{
|
|
var keepAliveCompleted = new TaskCompletionSource<PublishStateChangedMask>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
void KeepAliveHasTriggered(Subscription x, PublishStateChangedEventArgs y) => keepAliveCompleted.TrySetResult(y.Status);
|
|
ISession session = BuildSessionMock(
|
|
setup: mock =>
|
|
{
|
|
mock.Setup(x => x.Connected).Returns(testData.SessionConnected);
|
|
mock.Setup(x => x.Reconnecting).Returns(testData.SessionReconnecting);
|
|
mock.Setup(x => x.KeepAliveStopped).Returns(testData.SessionKeepAliveStopped);
|
|
});
|
|
|
|
using var subscription = new Subscription(
|
|
NUnitTelemetryContext.Create(),
|
|
new() { PublishingEnabled = true })
|
|
{
|
|
Session = session
|
|
};
|
|
subscription.PublishStatusChanged += KeepAliveHasTriggered;
|
|
await subscription.CreateAsync(ct).ConfigureAwait(false);
|
|
await Task.WhenAny(keepAliveCompleted.Task, Task.Delay(-1, ct)).ConfigureAwait(false);
|
|
|
|
Assert.That(await keepAliveCompleted.Task.ConfigureAwait(false), Is.EqualTo(testData.ExpectedPublishState));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression coverage for OPCFoundation/UA-.NETStandard#3540. When
|
|
/// <see cref="SubscriptionRecoveryPolicy.ReportOnly"/> is in effect
|
|
/// (default) and an unsolicited <c>Good_SubscriptionTransferred</c>
|
|
/// arrives, the subscription must remain on its original server-side
|
|
/// identifier — no recreate is attempted — preserving the
|
|
/// spec-strict, backwards-compatible behaviour. The
|
|
/// <c>Transferred</c> publish-state flag is raised so listeners
|
|
/// can still react.
|
|
/// </summary>
|
|
[Test]
|
|
[CancelAfter(5000)]
|
|
public async Task ReportOnlyPolicyDoesNotRecreateOnUnsolicitedTransferAsync(CancellationToken ct)
|
|
{
|
|
NotificationMessage[] messages = BuildMessages(2);
|
|
using SubscriptionContainer container = await BuildSubscriptionAsync(
|
|
messages, sequentialPublishing: false, ct).ConfigureAwait(false);
|
|
Subscription subscription = container.Subscription;
|
|
uint originalId = subscription.Id;
|
|
|
|
Assert.That(originalId, Is.GreaterThan(0u));
|
|
Assert.That(subscription.RecoveryPolicy,
|
|
Is.EqualTo(SubscriptionRecoveryPolicy.ReportOnly),
|
|
"ReportOnly must be the spec-strict default to preserve backwards compatibility.");
|
|
|
|
PublishStateChangedMask observedMask = PublishStateChangedMask.None;
|
|
var observed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
subscription.PublishStatusChanged += (_, args) =>
|
|
{
|
|
if ((args.Status & PublishStateChangedMask.Transferred) != 0)
|
|
{
|
|
observedMask = args.Status;
|
|
observed.TrySetResult(true);
|
|
}
|
|
};
|
|
|
|
subscription.SaveMessageInCache(default, BuildStatusChangeMessage(
|
|
sequenceNumber: 1, StatusCodes.GoodSubscriptionTransferred));
|
|
|
|
await Task.WhenAny(observed.Task, Task.Delay(2000, ct)).ConfigureAwait(false);
|
|
|
|
Assert.That(observed.Task.Status, Is.EqualTo(TaskStatus.RanToCompletion),
|
|
"Transferred publish-state flag was never raised.");
|
|
Assert.That(observedMask & PublishStateChangedMask.Transferred,
|
|
Is.EqualTo(PublishStateChangedMask.Transferred));
|
|
Assert.That(subscription.Id, Is.EqualTo(originalId),
|
|
"ReportOnly must not recreate the subscription on the server.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression coverage for OPCFoundation/UA-.NETStandard#3540. With
|
|
/// <see cref="SubscriptionRecoveryPolicy.RecreateOnUnsolicitedTransfer"/>
|
|
/// enabled, an unsolicited <c>Good_SubscriptionTransferred</c> must
|
|
/// drive an in-place recreate against the same session and the
|
|
/// subscription must end up with a fresh server-side identifier.
|
|
/// This is the "I just want my data back" path that fixes the
|
|
/// Kepware-after-reinitialise symptom reported in the issue.
|
|
/// </summary>
|
|
[Test]
|
|
[CancelAfter(5000)]
|
|
public async Task RecreatePolicyRecreatesSubscriptionOnUnsolicitedTransferAsync(CancellationToken ct)
|
|
{
|
|
NotificationMessage[] messages = BuildMessages(2);
|
|
using SubscriptionContainer container = await BuildSubscriptionAsync(
|
|
messages, sequentialPublishing: false, ct).ConfigureAwait(false);
|
|
Subscription subscription = container.Subscription;
|
|
uint originalId = subscription.Id;
|
|
|
|
Assert.That(originalId, Is.GreaterThan(0u));
|
|
|
|
subscription.RecoveryPolicy = SubscriptionRecoveryPolicy.RecreateOnUnsolicitedTransfer;
|
|
|
|
subscription.SaveMessageInCache(default, BuildStatusChangeMessage(
|
|
sequenceNumber: 1, StatusCodes.GoodSubscriptionTransferred));
|
|
|
|
// Recovery runs on a background Task — poll for Id change.
|
|
await WaitForAsync(
|
|
() => subscription.Id != 0 && subscription.Id != originalId,
|
|
TimeSpan.FromSeconds(3),
|
|
ct).ConfigureAwait(false);
|
|
|
|
Assert.That(subscription.Id, Is.Not.EqualTo(originalId),
|
|
"Recovery must assign a fresh server-side subscription id.");
|
|
Assert.That(subscription.Id, Is.Not.Zero,
|
|
"Subscription must end up Created after recovery.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Multiple unsolicited Good_SubscriptionTransferred dispatches must
|
|
/// collapse into a single recovery attempt — guarded by the
|
|
/// <c>m_recreateAfterTransferInProgress</c> flag.
|
|
/// </summary>
|
|
[Test]
|
|
[CancelAfter(5000)]
|
|
public async Task RecreatePolicyCollapsesConcurrentNotificationsAsync(CancellationToken ct)
|
|
{
|
|
NotificationMessage[] messages = BuildMessages(2);
|
|
using SubscriptionContainer container = await BuildSubscriptionAsync(
|
|
messages, sequentialPublishing: false, ct).ConfigureAwait(false);
|
|
Subscription subscription = container.Subscription;
|
|
uint originalId = subscription.Id;
|
|
|
|
subscription.RecoveryPolicy = SubscriptionRecoveryPolicy.RecreateOnUnsolicitedTransfer;
|
|
|
|
// Stack multiple status notifications back-to-back. Only the
|
|
// first should drive a recreate; the rest hit the idempotency
|
|
// guard and become no-ops.
|
|
for (uint i = 1; i <= 3; i++)
|
|
{
|
|
subscription.SaveMessageInCache(default, BuildStatusChangeMessage(
|
|
sequenceNumber: i, StatusCodes.GoodSubscriptionTransferred));
|
|
}
|
|
|
|
await WaitForAsync(
|
|
() => subscription.Id != 0 && subscription.Id != originalId,
|
|
TimeSpan.FromSeconds(3),
|
|
ct).ConfigureAwait(false);
|
|
|
|
uint afterFirstRecovery = subscription.Id;
|
|
|
|
// Give late recovery tasks a chance to run; assert nothing
|
|
// else triggered.
|
|
await Task.Delay(300, ct).ConfigureAwait(false);
|
|
|
|
Assert.That(subscription.Id, Is.EqualTo(afterFirstRecovery),
|
|
"Idempotency guard must prevent duplicate recreates from the burst.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// When the owning session reports itself as reconnecting the
|
|
/// discriminator must classify the notification as solicited and
|
|
/// fall back to the spec-strict ReportOnly path even if the
|
|
/// caller asked for auto-recreate. Avoids fighting the reconnect
|
|
/// pipeline that may itself be running TransferSubscriptions.
|
|
/// </summary>
|
|
[Test]
|
|
[CancelAfter(5000)]
|
|
public async Task RecreatePolicyDoesNotRecreateWhileSessionReconnectingAsync(CancellationToken ct)
|
|
{
|
|
uint subscriptionIdSeed = 0;
|
|
var session = new Mock<ISession>();
|
|
session.Setup(x => x.Connected).Returns(true);
|
|
session.Setup(x => x.Reconnecting).Returns(true);
|
|
session
|
|
.Setup(x => x.CreateSubscriptionAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<double>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<byte>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(() => new CreateSubscriptionResponse
|
|
{
|
|
SubscriptionId = ++subscriptionIdSeed
|
|
});
|
|
|
|
using var subscription = new Subscription(
|
|
NUnitTelemetryContext.Create(),
|
|
new() { PublishingEnabled = false, MaxMessageCount = 4 })
|
|
{
|
|
Session = session.Object,
|
|
RecoveryPolicy = SubscriptionRecoveryPolicy.RecreateOnUnsolicitedTransfer
|
|
};
|
|
await subscription.CreateAsync(ct).ConfigureAwait(false);
|
|
uint originalId = subscription.Id;
|
|
Assert.That(originalId, Is.GreaterThan(0u));
|
|
|
|
subscription.SaveMessageInCache(default, BuildStatusChangeMessage(
|
|
sequenceNumber: 1, StatusCodes.GoodSubscriptionTransferred));
|
|
|
|
// Allow the dispatch loop to observe the notification.
|
|
await Task.Delay(500, ct).ConfigureAwait(false);
|
|
|
|
Assert.That(subscription.Id, Is.EqualTo(originalId),
|
|
"Reconnecting session must keep the spec-strict path.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The Good_SubscriptionTransferred a server sends to the old session
|
|
/// when this client transfers the subscription itself must neither
|
|
/// stop the publish worker nor recreate the subscription (L7-7).
|
|
/// </summary>
|
|
[TestCase(SubscriptionRecoveryPolicy.ReportOnly)]
|
|
[TestCase(SubscriptionRecoveryPolicy.RecreateOnUnsolicitedTransfer)]
|
|
[CancelAfter(5000)]
|
|
public async Task OwnTransferDoesNotStopOrRecreateTheSubscriptionAsync(
|
|
SubscriptionRecoveryPolicy policy,
|
|
CancellationToken ct)
|
|
{
|
|
NotificationMessage[] messages = BuildMessages(2);
|
|
using SubscriptionContainer container = await BuildSubscriptionAsync(
|
|
messages, sequentialPublishing: false, ct).ConfigureAwait(false);
|
|
Subscription subscription = container.Subscription;
|
|
subscription.RecoveryPolicy = policy;
|
|
uint originalId = subscription.Id;
|
|
var transferredReported = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
subscription.PublishStatusChanged += (_, args) =>
|
|
{
|
|
if ((args.Status & PublishStateChangedMask.Transferred) != 0)
|
|
{
|
|
transferredReported.TrySetResult(true);
|
|
}
|
|
};
|
|
|
|
subscription.OnTransferStarting();
|
|
subscription.SaveMessageInCache(default, BuildStatusChangeMessage(
|
|
sequenceNumber: 1, StatusCodes.GoodSubscriptionTransferred));
|
|
await transferredReported.Task.WaitAsync(ct).ConfigureAwait(false);
|
|
subscription.OnTransferFinished();
|
|
|
|
// the publish worker must still be running
|
|
subscription.SaveMessageInCache(default, messages[2]);
|
|
await Task.WhenAny(container.ProcessedMessages[2], Task.Delay(1000, ct)).ConfigureAwait(false);
|
|
|
|
Assert.That(container.ProcessedMessages[2].IsCompleted, Is.True,
|
|
"The publish worker was stopped by the notification of the own transfer.");
|
|
Assert.That(subscription.Id, Is.EqualTo(originalId),
|
|
"The subscription must not be recreated after the own transfer.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A late copy of a message that was already processed (the original
|
|
/// Publish response racing its Republish) must not be delivered again
|
|
/// (L7-2).
|
|
/// </summary>
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
[CancelAfter(5000)]
|
|
public async Task LateCopyOfProcessedMessageIsNotDeliveredAgainAsync(
|
|
bool sequentialPublishing,
|
|
CancellationToken ct)
|
|
{
|
|
var received = new ConcurrentQueue<uint>();
|
|
var completions = new ConcurrentDictionary<uint, TaskCompletionSource<bool>>();
|
|
TaskCompletionSource<bool> Completion(uint sequenceNumber) => completions.GetOrAdd(
|
|
sequenceNumber,
|
|
_ => new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously));
|
|
ISession session = BuildSessionMock((_, _) => false);
|
|
using var subscription = new Subscription(NUnitTelemetryContext.Create())
|
|
{
|
|
Session = session,
|
|
SequentialPublishing = sequentialPublishing,
|
|
FastDataChangeCallback = (_, notification, _) =>
|
|
{
|
|
received.Enqueue(notification.SequenceNumber);
|
|
Completion(notification.SequenceNumber).TrySetResult(true);
|
|
}
|
|
};
|
|
await subscription.CreateAsync(ct).ConfigureAwait(false);
|
|
NotificationMessage[] messages = BuildMessages(4);
|
|
|
|
for (uint sequenceNumber = 1; sequenceNumber <= 3; sequenceNumber++)
|
|
{
|
|
subscription.SaveMessageInCache([], messages[sequenceNumber]);
|
|
await Completion(sequenceNumber).Task.WaitAsync(ct).ConfigureAwait(false);
|
|
}
|
|
|
|
// late copies: 3 still has a processed entry, 2 was cleaned up
|
|
subscription.SaveMessageInCache([], messages[3]);
|
|
subscription.SaveMessageInCache([], messages[2]);
|
|
subscription.SaveMessageInCache([], messages[4]);
|
|
await Completion(4).Task.WaitAsync(ct).ConfigureAwait(false);
|
|
|
|
Assert.That(received, Is.EqualTo(new uint[] { 1, 2, 3, 4 }));
|
|
Mock.Get(session).Verify(value => value.RepublishAsync(
|
|
It.IsAny<uint>(), It.IsAny<uint>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Disposing the subscription from a PublishStatusChanged handler that
|
|
/// the keep-alive timer raised must not wait for the running timer
|
|
/// callback, which is the caller itself (L7-3).
|
|
/// </summary>
|
|
[Test]
|
|
[CancelAfter(Subscription.MinKeepAliveTimerInterval * 10)]
|
|
public async Task DisposeFromKeepAliveStoppedHandlerDoesNotDeadlockAsync(CancellationToken ct)
|
|
{
|
|
var disposed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
using var subscription = new Subscription(
|
|
NUnitTelemetryContext.Create(),
|
|
new() { PublishingEnabled = true })
|
|
{
|
|
Session = BuildSessionMock(setup: mock => mock.Setup(x => x.Connected).Returns(false))
|
|
};
|
|
subscription.PublishStatusChanged += (s, e) =>
|
|
{
|
|
if ((e.Status & PublishStateChangedMask.Stopped) != 0)
|
|
{
|
|
s.Dispose();
|
|
disposed.TrySetResult(true);
|
|
}
|
|
};
|
|
await subscription.CreateAsync(ct).ConfigureAwait(false);
|
|
|
|
await Task.WhenAny(disposed.Task, Task.Delay(Timeout.Infinite, ct)).ConfigureAwait(false);
|
|
|
|
Assert.That(disposed.Task.IsCompleted, Is.True,
|
|
"Dispose from the keep-alive handler did not return.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// G6 (review of L7-3): a PublishStatusChanged handler raised by the
|
|
/// keep-alive timer that disposes the subscription on another flow and
|
|
/// waits for it (managed session Dispose: its state machine worker
|
|
/// disposes the inner session and its subscriptions) deadlocked,
|
|
/// because the dispose waited for the running timer callback.
|
|
/// </summary>
|
|
[Test]
|
|
[CancelAfter(Subscription.MinKeepAliveTimerInterval * 10)]
|
|
public async Task DisposeOnOtherFlowFromKeepAliveStoppedHandlerDoesNotDeadlockAsync(
|
|
CancellationToken ct)
|
|
{
|
|
var disposed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
using var subscription = new Subscription(
|
|
NUnitTelemetryContext.Create(),
|
|
new() { PublishingEnabled = true })
|
|
{
|
|
Session = BuildSessionMock(setup: mock => mock.Setup(x => x.Connected).Returns(false))
|
|
};
|
|
subscription.PublishStatusChanged += (s, e) =>
|
|
{
|
|
if ((e.Status & PublishStateChangedMask.Stopped) != 0 && !disposed.Task.IsCompleted)
|
|
{
|
|
// The dispose runs without the handler's execution context.
|
|
Task disposal;
|
|
using (ExecutionContext.SuppressFlow())
|
|
{
|
|
disposal = Task.Run(s.Dispose, CancellationToken.None);
|
|
}
|
|
disposed.TrySetResult(disposal.Wait((int)TimeSpan.FromSeconds(5).TotalMilliseconds, CancellationToken.None));
|
|
}
|
|
};
|
|
await subscription.CreateAsync(ct).ConfigureAwait(false);
|
|
|
|
await Task.WhenAny(disposed.Task, Task.Delay(Timeout.Infinite, ct)).ConfigureAwait(false);
|
|
|
|
Assert.That(disposed.Task.IsCompleted, Is.True, "The keep-alive handler was not raised.");
|
|
Assert.That(disposed.Task.Result, Is.True,
|
|
"Dispose on another flow did not complete while the keep-alive handler waited for it.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A negative MaxMessageCount must not make every worker pass throw
|
|
/// (L7-10): the notification is still delivered and cached.
|
|
/// </summary>
|
|
[Test]
|
|
[CancelAfter(5000)]
|
|
public async Task NegativeMaxMessageCountStillDeliversNotificationsAsync(CancellationToken ct)
|
|
{
|
|
var delivered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var received = new ConcurrentQueue<uint>();
|
|
using var subscription = new Subscription(
|
|
NUnitTelemetryContext.Create(),
|
|
new() { MaxMessageCount = -1 })
|
|
{
|
|
Session = BuildSessionMock(),
|
|
FastDataChangeCallback = (_, notification, _) =>
|
|
{
|
|
received.Enqueue(notification.SequenceNumber);
|
|
if (received.Count == 2)
|
|
{
|
|
delivered.TrySetResult(true);
|
|
}
|
|
}
|
|
};
|
|
await subscription.CreateAsync(ct).ConfigureAwait(false);
|
|
|
|
NotificationMessage[] messages = BuildMessages(2);
|
|
subscription.SaveMessageInCache([], messages[1]);
|
|
subscription.SaveMessageInCache([], messages[2]);
|
|
await Task.WhenAny(delivered.Task, Task.Delay(Timeout.Infinite, ct)).ConfigureAwait(false);
|
|
|
|
Assert.That(delivered.Task.IsCompleted, Is.True, "No notification was delivered.");
|
|
Assert.That(received, Is.EqualTo(new uint[] { 1, 2 }));
|
|
Assert.That(subscription.Notifications, Is.EqualTo(new[] { messages[2] }));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Very long keep-alive intervals must not overflow the publish
|
|
/// timeout or the publishing-stopped check (L7-9).
|
|
/// </summary>
|
|
[TestCase(3_600_000d, 200u)]
|
|
[TestCase((double)int.MaxValue, 10u)]
|
|
[CancelAfter(5000)]
|
|
public async Task LongKeepAliveIntervalDoesNotOverflowAsync(
|
|
double revisedPublishingInterval,
|
|
uint revisedKeepAliveCount,
|
|
CancellationToken ct)
|
|
{
|
|
var publishTimeouts = new ConcurrentQueue<int>();
|
|
ISession session = BuildSessionMock(setup: mock =>
|
|
{
|
|
mock
|
|
.Setup(x => x.CreateSubscriptionAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<double>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<byte>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new CreateSubscriptionResponse
|
|
{
|
|
SubscriptionId = 1,
|
|
RevisedPublishingInterval = revisedPublishingInterval,
|
|
RevisedMaxKeepAliveCount = revisedKeepAliveCount,
|
|
RevisedLifetimeCount = revisedKeepAliveCount * 3
|
|
});
|
|
mock
|
|
.Setup(x => x.StartPublishing(It.IsAny<int>(), It.IsAny<bool>()))
|
|
.Callback<int, bool>((timeout, _) => publishTimeouts.Enqueue(timeout));
|
|
});
|
|
using var subscription = new Subscription(NUnitTelemetryContext.Create())
|
|
{
|
|
Session = session
|
|
};
|
|
|
|
await subscription.CreateAsync(ct).ConfigureAwait(false);
|
|
|
|
Assert.That(publishTimeouts, Is.Not.Empty);
|
|
Assert.That(publishTimeouts, Has.All.EqualTo(int.MaxValue),
|
|
"Three keep-alive intervals exceed int.MaxValue, so the timeout must saturate.");
|
|
Assert.That(subscription.PublishingStopped, Is.False,
|
|
"A subscription that was just created has not stopped publishing.");
|
|
}
|
|
|
|
private static NotificationMessage BuildStatusChangeMessage(
|
|
uint sequenceNumber, StatusCode status)
|
|
{
|
|
return new NotificationMessage
|
|
{
|
|
SequenceNumber = sequenceNumber,
|
|
NotificationData =
|
|
[
|
|
new ExtensionObject(new StatusChangeNotification
|
|
{
|
|
Status = status
|
|
})
|
|
]
|
|
};
|
|
}
|
|
|
|
private static async Task WaitForAsync(
|
|
Func<bool> predicate, TimeSpan timeout, CancellationToken ct)
|
|
{
|
|
DateTime deadline = DateTime.UtcNow + timeout;
|
|
while (!predicate() && DateTime.UtcNow < deadline)
|
|
{
|
|
await Task.Delay(25, ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
}
|