455 lines
18 KiB
C#
455 lines
18 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
using System.Runtime.CompilerServices;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
|
using ZB.MOM.WW.MxGateway.Server.Workers;
|
|
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
|
|
|
public sealed class DashboardLiveDataServiceTests
|
|
{
|
|
// Mirrors DashboardLiveDataService.MaxSubscribedTags — the cap is private, so the
|
|
// tests drive it through the public read surface at exactly its documented size.
|
|
private const int MaxSubscribedTags = 256;
|
|
|
|
/// <summary>
|
|
/// Verifies a tag already in the advise set is not subscribed again on a later read.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ReadAsync_WhenTagAlreadySubscribed_DoesNotResubscribe()
|
|
{
|
|
RecordingWorkerClient worker = new();
|
|
await using FakeSessionManager sessionManager = new(worker);
|
|
await using DashboardLiveDataService service = CreateService(sessionManager);
|
|
|
|
await service.ReadAsync(["Tank_001.PV", "Tank_002.PV"], CancellationToken.None);
|
|
DashboardLiveReadResult second = await service.ReadAsync(
|
|
["Tank_001.PV", "Tank_002.PV"],
|
|
CancellationToken.None);
|
|
|
|
Assert.Null(second.Error);
|
|
Assert.Equal(["Tank_001.PV", "Tank_002.PV"], worker.SubscribedTags);
|
|
Assert.Empty(worker.UnsubscribedHandles);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies the advise set is capped: subscribing past the cap unadvises the
|
|
/// least-recently-read tag on the worker and leaves the re-read tag advised.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ReadAsync_PastCap_EvictsLeastRecentlyReadTag()
|
|
{
|
|
RecordingWorkerClient worker = new();
|
|
await using FakeSessionManager sessionManager = new(worker);
|
|
await using DashboardLiveDataService service = CreateService(sessionManager);
|
|
|
|
// Within one read the last tag counts as most recently read, so filler[0] is
|
|
// the tail of the recency list.
|
|
string[] filler = CreateTagAddresses(MaxSubscribedTags);
|
|
await service.ReadAsync(filler, CancellationToken.None);
|
|
|
|
// Re-read the tail, making it most recent: the tag read before it is now the
|
|
// eviction candidate.
|
|
await service.ReadAsync([filler[0]], CancellationToken.None);
|
|
Assert.Equal(MaxSubscribedTags, worker.SubscribedTags.Count);
|
|
|
|
DashboardLiveReadResult overflow = await service.ReadAsync(
|
|
["Overflow.PV"],
|
|
CancellationToken.None);
|
|
|
|
Assert.Null(overflow.Error);
|
|
Assert.Equal([worker.HandleFor(filler[1])], worker.UnsubscribedHandles);
|
|
Assert.Equal("Overflow.PV", worker.SubscribedTags[^1]);
|
|
Assert.Equal(MaxSubscribedTags + 1, worker.SubscribedTags.Count);
|
|
|
|
// The evicted tag is no longer tracked and re-subscribes; the re-read one does not.
|
|
await service.ReadAsync([filler[0], filler[1]], CancellationToken.None);
|
|
Assert.Equal(filler[1], worker.SubscribedTags[^1]);
|
|
Assert.Equal(MaxSubscribedTags + 2, worker.SubscribedTags.Count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies the cap is per-read, not absolute: a single read of more distinct tags
|
|
/// than the cap keeps them all (a read never evicts a tag it is about to return),
|
|
/// and the next read that subscribes anything squeezes the overshoot back out.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ReadAsync_WithMoreDistinctTagsThanCap_KeepsThemAllThenSelfCorrects()
|
|
{
|
|
RecordingWorkerClient worker = new();
|
|
await using FakeSessionManager sessionManager = new(worker);
|
|
await using DashboardLiveDataService service = CreateService(sessionManager);
|
|
|
|
string[] oversize = CreateTagAddresses(300);
|
|
DashboardLiveReadResult oversizeResult = await service.ReadAsync(oversize, CancellationToken.None);
|
|
|
|
Assert.Null(oversizeResult.Error);
|
|
Assert.Equal(300, oversizeResult.Values.Count);
|
|
Assert.Equal(300, worker.SubscribedTags.Count);
|
|
Assert.Empty(worker.UnsubscribedHandles);
|
|
|
|
// 300 + 1 - 256 = 45 evicted in one pass, landing the set back on the cap.
|
|
await service.ReadAsync(["Overflow.PV"], CancellationToken.None);
|
|
Assert.Equal(oversize[..45].Select(worker.HandleFor), worker.UnsubscribedHandles);
|
|
|
|
// Exactly at the cap now: one more new tag evicts exactly one.
|
|
worker.UnsubscribedHandles.Clear();
|
|
await service.ReadAsync(["Overflow2.PV"], CancellationToken.None);
|
|
Assert.Equal([worker.HandleFor(oversize[45])], worker.UnsubscribedHandles);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies tags read in the same call are never evicted for each other: a read that
|
|
/// touches nearly the whole advise set evicts only the untouched remainder, ends over
|
|
/// the cap, and the following read trims it back.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ReadAsync_WhenTouchedTagsFillTheCap_EvictsOnlyUntouchedTags()
|
|
{
|
|
RecordingWorkerClient worker = new();
|
|
await using FakeSessionManager sessionManager = new(worker);
|
|
await using DashboardLiveDataService service = CreateService(sessionManager);
|
|
|
|
string[] filler = CreateTagAddresses(MaxSubscribedTags);
|
|
await service.ReadAsync(filler, CancellationToken.None);
|
|
|
|
// 250 already-advised tags + 10 new ones: only the 6 untouched tags are
|
|
// evictable, so the set ends at 260.
|
|
string[] fresh = CreateTagAddresses(10, "Fresh");
|
|
await service.ReadAsync([.. filler[..250], .. fresh], CancellationToken.None);
|
|
|
|
Assert.Equal(filler[250..].Select(worker.HandleFor), worker.UnsubscribedHandles);
|
|
|
|
// 260 + 1 - 256 = 5 evicted on the next read that subscribes anything.
|
|
worker.UnsubscribedHandles.Clear();
|
|
await service.ReadAsync(["Overflow.PV"], CancellationToken.None);
|
|
Assert.Equal(5, worker.UnsubscribedHandles.Count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies a tag the worker failed to advise still occupies a slot but is evicted
|
|
/// without any unsubscribe command — there is no item handle to unadvise.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ReadAsync_WhenAdviseFailed_EvictsTagWithoutUnsubscribing()
|
|
{
|
|
RecordingWorkerClient worker = new();
|
|
worker.FailSubscribeFor.Add("Bad.PV");
|
|
await using FakeSessionManager sessionManager = new(worker);
|
|
await using DashboardLiveDataService service = CreateService(sessionManager);
|
|
|
|
// Bad.PV is read first, so it is the least recently read of the batch and the
|
|
// first tag evicted.
|
|
string[] filler = CreateTagAddresses(MaxSubscribedTags - 1);
|
|
await service.ReadAsync(["Bad.PV", .. filler], CancellationToken.None);
|
|
|
|
DashboardLiveReadResult overflow = await service.ReadAsync(
|
|
["Overflow.PV"],
|
|
CancellationToken.None);
|
|
|
|
Assert.Null(overflow.Error);
|
|
Assert.Empty(worker.UnsubscribedHandles);
|
|
Assert.Equal(0, worker.UnsubscribeCommandCount);
|
|
|
|
// It was dropped from tracking all the same, so reading it again re-advises it.
|
|
await service.ReadAsync(["Bad.PV"], CancellationToken.None);
|
|
Assert.Equal("Bad.PV", worker.SubscribedTags[^1]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies a failed unadvise of an evicted tag does not fail the read, and the
|
|
/// evicted tag is dropped from tracking anyway.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ReadAsync_WhenEvictionUnsubscribeFails_StillCompletesRead()
|
|
{
|
|
RecordingWorkerClient worker = new() { FailUnsubscribe = true };
|
|
await using FakeSessionManager sessionManager = new(worker);
|
|
await using DashboardLiveDataService service = CreateService(sessionManager);
|
|
|
|
string[] filler = CreateTagAddresses(MaxSubscribedTags);
|
|
await service.ReadAsync(filler, CancellationToken.None);
|
|
|
|
DashboardLiveReadResult overflow = await service.ReadAsync(
|
|
["Overflow.PV"],
|
|
CancellationToken.None);
|
|
|
|
Assert.Null(overflow.Error);
|
|
Assert.Equal("Overflow.PV", Assert.Single(overflow.Values).TagAddress);
|
|
Assert.Equal(1, sessionManager.OpenCount);
|
|
|
|
// The evicted tag was dropped from tracking despite the failed unadvise.
|
|
await service.ReadAsync([filler[0]], CancellationToken.None);
|
|
Assert.Equal(filler[0], worker.SubscribedTags[^1]);
|
|
}
|
|
|
|
private static DashboardLiveDataService CreateService(ISessionManager sessionManager)
|
|
{
|
|
return new DashboardLiveDataService(
|
|
sessionManager,
|
|
new FakeGatewayAlarmService(),
|
|
NullLogger<DashboardLiveDataService>.Instance);
|
|
}
|
|
|
|
private static string[] CreateTagAddresses(int count, string prefix = "Tank")
|
|
{
|
|
string[] addresses = new string[count];
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
addresses[i] = $"{prefix}_{i:D4}.PV";
|
|
}
|
|
|
|
return addresses;
|
|
}
|
|
|
|
// Serves the dashboard service a single Ready session backed by the recording
|
|
// worker, so reads exercise the real GatewaySession bulk command path.
|
|
private sealed class FakeSessionManager(RecordingWorkerClient workerClient) : ISessionManager, IAsyncDisposable
|
|
{
|
|
private readonly List<GatewaySession> _sessions = [];
|
|
|
|
/// <summary>Gets the number of sessions the dashboard service opened.</summary>
|
|
public int OpenCount { get; private set; }
|
|
|
|
/// <inheritdoc />
|
|
public Task<GatewaySession> OpenSessionAsync(
|
|
SessionOpenRequest request,
|
|
string? clientIdentity,
|
|
string? ownerKeyId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
OpenCount++;
|
|
string sessionId = $"dashboard-session-{OpenCount}";
|
|
GatewaySession session = new(
|
|
sessionId,
|
|
"Galaxy",
|
|
$"mxgw-1-{sessionId}",
|
|
"nonce",
|
|
clientIdentity,
|
|
request.ClientSessionName,
|
|
request.ClientCorrelationId,
|
|
TimeSpan.FromSeconds(30),
|
|
TimeSpan.FromSeconds(5),
|
|
TimeSpan.FromSeconds(5),
|
|
DateTimeOffset.UnixEpoch);
|
|
session.AttachWorkerClient(workerClient);
|
|
session.MarkReady();
|
|
_sessions.Add(session);
|
|
return Task.FromResult(session);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
|
|
{
|
|
session = _sessions.Find(candidate => candidate.SessionId == sessionId);
|
|
return session is not null;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<SessionCloseResult> CloseSessionAsync(string sessionId, CancellationToken cancellationToken)
|
|
{
|
|
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<WorkerCommandReply> InvokeAsync(
|
|
string sessionId,
|
|
WorkerCommand command,
|
|
CancellationToken cancellationToken) =>
|
|
throw new NotSupportedException();
|
|
|
|
/// <inheritdoc />
|
|
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(string sessionId, CancellationToken cancellationToken) =>
|
|
throw new NotSupportedException();
|
|
|
|
/// <inheritdoc />
|
|
public Task<SessionCloseResult> KillWorkerAsync(
|
|
string sessionId,
|
|
string reason,
|
|
CancellationToken cancellationToken) =>
|
|
throw new NotSupportedException();
|
|
|
|
/// <inheritdoc />
|
|
public Task<int> CloseExpiredLeasesAsync(DateTimeOffset now, CancellationToken cancellationToken) =>
|
|
throw new NotSupportedException();
|
|
|
|
/// <inheritdoc />
|
|
public Task ShutdownAsync(CancellationToken cancellationToken) => throw new NotSupportedException();
|
|
|
|
/// <summary>Disposes every session handed to the dashboard service.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
foreach (GatewaySession session in _sessions)
|
|
{
|
|
await session.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Answers Register / SubscribeBulk / UnsubscribeBulk / ReadBulk with successful
|
|
// replies and records what the dashboard advised and unadvised.
|
|
private sealed class RecordingWorkerClient : IWorkerClient
|
|
{
|
|
private const int RegisteredServerHandle = 77;
|
|
|
|
private readonly Dictionary<string, int> _itemHandles = new(StringComparer.OrdinalIgnoreCase);
|
|
private int _nextItemHandle = 1000;
|
|
|
|
/// <inheritdoc />
|
|
public string SessionId => "dashboard-session-1";
|
|
|
|
/// <inheritdoc />
|
|
public int? ProcessId => 4242;
|
|
|
|
/// <inheritdoc />
|
|
public WorkerClientState State => WorkerClientState.Ready;
|
|
|
|
/// <inheritdoc />
|
|
public DateTimeOffset LastHeartbeatAt => DateTimeOffset.UnixEpoch;
|
|
|
|
/// <summary>Gets the tag addresses subscribed, in the order the dashboard asked for them.</summary>
|
|
public List<string> SubscribedTags { get; } = [];
|
|
|
|
/// <summary>Gets the item handles the dashboard unsubscribed, in order.</summary>
|
|
public List<int> UnsubscribedHandles { get; } = [];
|
|
|
|
/// <summary>Gets the number of unsubscribe commands the dashboard sent.</summary>
|
|
public int UnsubscribeCommandCount { get; private set; }
|
|
|
|
/// <summary>Gets the tag addresses the worker refuses to advise.</summary>
|
|
public HashSet<string> FailSubscribeFor { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
/// <summary>Gets or sets a value indicating whether unsubscribe commands throw.</summary>
|
|
public bool FailUnsubscribe { get; set; }
|
|
|
|
/// <summary>Gets the item handle bound for a previously subscribed tag.</summary>
|
|
/// <param name="tagAddress">Tag address to look up.</param>
|
|
/// <returns>The bound item handle.</returns>
|
|
public int HandleFor(string tagAddress) => _itemHandles[tagAddress];
|
|
|
|
/// <inheritdoc />
|
|
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
|
|
/// <inheritdoc />
|
|
public Task<WorkerCommandReply> InvokeAsync(
|
|
WorkerCommand command,
|
|
TimeSpan timeout,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
MxCommand mxCommand = command.Command
|
|
?? throw new InvalidOperationException("The dashboard sent a command with no payload.");
|
|
|
|
MxCommandReply reply = new()
|
|
{
|
|
Kind = mxCommand.Kind,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
};
|
|
|
|
switch (mxCommand.Kind)
|
|
{
|
|
case MxCommandKind.Register:
|
|
reply.Register = new RegisterReply { ServerHandle = RegisteredServerHandle };
|
|
break;
|
|
case MxCommandKind.SubscribeBulk:
|
|
reply.SubscribeBulk = Subscribe(mxCommand.SubscribeBulk.TagAddresses);
|
|
break;
|
|
case MxCommandKind.UnsubscribeBulk:
|
|
UnsubscribeCommandCount++;
|
|
if (FailUnsubscribe)
|
|
{
|
|
throw new InvalidOperationException("Simulated worker unsubscribe failure.");
|
|
}
|
|
|
|
UnsubscribedHandles.AddRange(mxCommand.UnsubscribeBulk.ItemHandles);
|
|
reply.UnsubscribeBulk = new BulkSubscribeReply();
|
|
break;
|
|
case MxCommandKind.ReadBulk:
|
|
reply.ReadBulk = Read(mxCommand.ReadBulk.TagAddresses);
|
|
break;
|
|
default:
|
|
throw new NotSupportedException($"Unexpected dashboard command {mxCommand.Kind}.");
|
|
}
|
|
|
|
return Task.FromResult(new WorkerCommandReply { Reply = reply });
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
await Task.CompletedTask.ConfigureAwait(false);
|
|
yield break;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
|
|
|
|
/// <inheritdoc />
|
|
public void Kill(string reason)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
|
|
private BulkSubscribeReply Subscribe(IEnumerable<string> tagAddresses)
|
|
{
|
|
BulkSubscribeReply subscribeReply = new();
|
|
foreach (string tagAddress in tagAddresses)
|
|
{
|
|
SubscribedTags.Add(tagAddress);
|
|
if (FailSubscribeFor.Contains(tagAddress))
|
|
{
|
|
subscribeReply.Results.Add(new SubscribeResult
|
|
{
|
|
ServerHandle = RegisteredServerHandle,
|
|
TagAddress = tagAddress,
|
|
ItemHandle = 0,
|
|
WasSuccessful = false,
|
|
ErrorMessage = "Simulated advise failure.",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (!_itemHandles.TryGetValue(tagAddress, out int itemHandle))
|
|
{
|
|
itemHandle = _nextItemHandle++;
|
|
_itemHandles[tagAddress] = itemHandle;
|
|
}
|
|
|
|
subscribeReply.Results.Add(new SubscribeResult
|
|
{
|
|
ServerHandle = RegisteredServerHandle,
|
|
TagAddress = tagAddress,
|
|
ItemHandle = itemHandle,
|
|
WasSuccessful = true,
|
|
});
|
|
}
|
|
|
|
return subscribeReply;
|
|
}
|
|
|
|
private BulkReadReply Read(IEnumerable<string> tagAddresses)
|
|
{
|
|
BulkReadReply readReply = new();
|
|
foreach (string tagAddress in tagAddresses)
|
|
{
|
|
readReply.Results.Add(new BulkReadResult
|
|
{
|
|
ServerHandle = RegisteredServerHandle,
|
|
TagAddress = tagAddress,
|
|
ItemHandle = _itemHandles.TryGetValue(tagAddress, out int itemHandle) ? itemHandle : 0,
|
|
WasSuccessful = true,
|
|
Quality = 192,
|
|
});
|
|
}
|
|
|
|
return readReply;
|
|
}
|
|
}
|
|
}
|