513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
121 lines
5.5 KiB
C#
121 lines
5.5 KiB
C#
using System.Collections;
|
|
using System.Collections.Concurrent;
|
|
using System.Reflection;
|
|
using NSubstitute;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
|
|
|
/// <summary>
|
|
/// Rollback half of the batch-subscribe fix — a batch subscribe whose
|
|
/// <c>ApplyChanges</c> throws must leave NO residue behind.
|
|
///
|
|
/// <para>
|
|
/// <b>The gap this pins.</b> <c>CreateSubscriptionsCoreAsync</c> registers each monitored item as
|
|
/// it places it (<c>_callbacks</c>, <c>_itemShard</c>, the shard's item count and the SDK
|
|
/// subscription's own item list) and only issues <c>ApplyChanges</c> once the whole chunk is
|
|
/// placed. A throw there — a dead session being the common cause — propagated with every one of
|
|
/// those registrations still in place and nothing left holding a handle to release them. The actor
|
|
/// sees the throw as a whole-chunk fault and re-issues the SAME batch on its backoff timer, so
|
|
/// each probe grew the bookkeeping again: unbounded shard growth and phantom shard occupancy.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The seam is reached with a data shard whose SDK <see cref="Subscription"/> was never created,
|
|
/// which is precisely how the SDK reports "you have no usable subscription": placement and
|
|
/// <c>AddItem</c> succeed, <c>ApplyChangesAsync</c> throws. Private state is read by reflection
|
|
/// rather than widened for the test.
|
|
/// </para>
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public class RealOpcUaClientBatchRollbackTests
|
|
{
|
|
private static object Field(object target, string name) =>
|
|
target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(target)!;
|
|
|
|
/// <summary>
|
|
/// Gives the client a mocked session plus ONE pre-placed (never created) data shard, so a
|
|
/// batch subscribe places its items and then throws out of ApplyChanges.
|
|
/// </summary>
|
|
private static Subscription ArrangeUncreatedShard(RealOpcUaClient client)
|
|
{
|
|
var session = Substitute.For<ISession>();
|
|
session.NamespaceUris.Returns(new NamespaceTable());
|
|
typeof(RealOpcUaClient)
|
|
.GetField("_session", BindingFlags.Instance | BindingFlags.NonPublic)!
|
|
.SetValue(client, session);
|
|
|
|
// The parameterless ctor is obsolete in the current SDK in favour of a telemetry-context
|
|
// overload; nothing here publishes telemetry, and the alternative would be to hand-build a
|
|
// context purely to satisfy the signature.
|
|
#pragma warning disable CS0618
|
|
var subscription = new Subscription { DisplayName = "uncreated-shard" };
|
|
#pragma warning restore CS0618
|
|
|
|
var shardType = typeof(RealOpcUaClient).GetNestedType("DataShard", BindingFlags.NonPublic)!;
|
|
var shard = Activator.CreateInstance(
|
|
shardType,
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
|
|
null, [subscription], null)!;
|
|
((IList)Field(client, "_dataShards")).Add(shard);
|
|
|
|
return subscription;
|
|
}
|
|
|
|
private static int ShardItemCount(RealOpcUaClient client)
|
|
{
|
|
var shard = ((IList)Field(client, "_dataShards"))[0]!;
|
|
return (int)shard.GetType().GetProperty("ItemCount")!.GetValue(shard)!;
|
|
}
|
|
|
|
/// <summary>Verifies that a throw from ApplyChanges rolls back every partially created monitored item.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task ApplyChangesThrow_RollsBackEveryPartiallyCreatedItem()
|
|
{
|
|
var client = new RealOpcUaClient();
|
|
var subscription = ArrangeUncreatedShard(client);
|
|
|
|
var fault = await Record.ExceptionAsync(() => client.CreateSubscriptionsAsync(
|
|
["i=2258", "i=2259", "i=2260"],
|
|
(_, _, _, _) => { }));
|
|
|
|
// Positive control: the batch really did fail at ApplyChanges, not before placement.
|
|
Assert.NotNull(fault);
|
|
|
|
// Nothing registered survives the throw — pre-call state, exactly.
|
|
Assert.Empty((ConcurrentDictionary<string, Action<string, object?, DateTime, uint>>)
|
|
Field(client, "_callbacks"));
|
|
Assert.Empty((ConcurrentDictionary<string, Subscription>)Field(client, "_itemShard"));
|
|
Assert.Empty((ConcurrentDictionary<string, MonitoredItem>)Field(client, "_monitoredItems"));
|
|
Assert.Equal(0, ShardItemCount(client));
|
|
Assert.Empty(subscription.MonitoredItems);
|
|
}
|
|
|
|
/// <summary>Verifies that repeated failed batches do not grow the adapter's bookkeeping.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RepeatedFailedBatches_DoNotGrowTheAdapterBookkeeping()
|
|
{
|
|
// The actual operational damage: the actor re-issues the same chunk on its backoff timer,
|
|
// so an un-rolled-back batch compounded on every probe.
|
|
var client = new RealOpcUaClient();
|
|
ArrangeUncreatedShard(client);
|
|
|
|
for (var attempt = 0; attempt < 3; attempt++)
|
|
{
|
|
var fault = await Record.ExceptionAsync(() => client.CreateSubscriptionsAsync(
|
|
["i=2258", "i=2259"],
|
|
(_, _, _, _) => { }));
|
|
Assert.NotNull(fault);
|
|
}
|
|
|
|
Assert.Empty((ConcurrentDictionary<string, Action<string, object?, DateTime, uint>>)
|
|
Field(client, "_callbacks"));
|
|
Assert.Empty((ConcurrentDictionary<string, Subscription>)Field(client, "_itemShard"));
|
|
Assert.Equal(0, ShardItemCount(client));
|
|
}
|
|
}
|