fix(host): run monitored-item revalidation on a low-frequency timer (archreview 03/S19)
This commit is contained in:
+144
@@ -0,0 +1,144 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the ACL revalidation timer's lifecycle on the hosted service (arch-review 03/S19) — that it is
|
||||
/// armed at the configured interval, that <c>0</c> arms nothing, and that stopping disposes it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The timer is armed through <see cref="OtOpcUaServerHostedService.StartRevalidationTimer"/> rather
|
||||
/// than by running <c>StartAsync</c>, because <c>StartAsync</c> boots the real OPC UA SDK (certificate
|
||||
/// store, listening socket) and none of that is what this is about. Everything the timer's lifecycle
|
||||
/// touches — the options, the clock, the disposal — is exercised exactly as production reaches it.
|
||||
/// </remarks>
|
||||
public sealed class OtOpcUaServerHostedServiceRevalidationTests
|
||||
{
|
||||
/// <summary>The configured interval is what the timer is armed at.</summary>
|
||||
[Fact]
|
||||
public void A_configured_interval_arms_one_timer_at_that_period()
|
||||
{
|
||||
var clock = new RecordingTimeProvider();
|
||||
var sut = Service(clock, intervalSeconds: 30);
|
||||
|
||||
sut.StartRevalidationTimer();
|
||||
|
||||
clock.Periods.ShouldBe([TimeSpan.FromSeconds(30)]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>RevalidationIntervalSeconds = 0</c> arms nothing — the documented rollback to
|
||||
/// deploy-triggered-only revalidation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_zero_interval_arms_nothing()
|
||||
{
|
||||
var clock = new RecordingTimeProvider();
|
||||
var sut = Service(clock, intervalSeconds: 0);
|
||||
|
||||
sut.StartRevalidationTimer();
|
||||
|
||||
clock.Periods.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>StopAsync</c> disposes the timer, so no tick can land on a node manager that is being torn
|
||||
/// down.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The disposal is the FIRST thing <c>StopAsync</c> does, ahead of
|
||||
/// <c>SetDataPlaneAuthority(null)</c> / <c>SetDataPlaneAuthorizer(null)</c> — a stopping node sheds
|
||||
/// the trigger before it sheds the thing the trigger drives.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task StopAsync_disposes_the_timer()
|
||||
{
|
||||
var clock = new RecordingTimeProvider();
|
||||
var sut = Service(clock, intervalSeconds: 30);
|
||||
sut.StartRevalidationTimer();
|
||||
|
||||
clock.Timers[0].Disposed.ShouldBeFalse("non-vacuity: the timer is live before the stop");
|
||||
|
||||
await sut.StopAsync(CancellationToken.None);
|
||||
|
||||
clock.Timers[0].Disposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Disposing the service disposes the timer too, for a host that never called StopAsync.</summary>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_disposes_the_timer()
|
||||
{
|
||||
var clock = new RecordingTimeProvider();
|
||||
var sut = Service(clock, intervalSeconds: 30);
|
||||
sut.StartRevalidationTimer();
|
||||
|
||||
await sut.DisposeAsync();
|
||||
|
||||
clock.Timers[0].Disposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private static OtOpcUaServerHostedService Service(TimeProvider clock, int intervalSeconds) => new(
|
||||
Options.Create(new OpcUaApplicationHostOptions()),
|
||||
new DeferredAddressSpaceSink(),
|
||||
new DeferredServiceLevelPublisher(),
|
||||
NullOpcUaUserAuthenticator.Instance,
|
||||
NullHistorianDataSource.Instance,
|
||||
EmptyDataPlaneAuthoritySource.Instance,
|
||||
new ConfigurationBuilder().Build(),
|
||||
() => throw new NotSupportedException("no ActorSystem is needed for the timer lifecycle"),
|
||||
new ActorRegistry(),
|
||||
dataPlaneAuthorizer: null,
|
||||
Options.Create(new AclOptions { RevalidationIntervalSeconds = intervalSeconds }),
|
||||
clock,
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
/// <summary>Records the timers asked of it instead of arming real ones.</summary>
|
||||
private sealed class RecordingTimeProvider : TimeProvider
|
||||
{
|
||||
/// <summary>Every timer this provider was asked to create, in order.</summary>
|
||||
public List<FakeTimer> Timers { get; } = [];
|
||||
|
||||
/// <summary>The period each of those timers was armed at.</summary>
|
||||
public IReadOnlyList<TimeSpan> Periods => [.. Timers.Select(t => t.Period)];
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
|
||||
{
|
||||
var timer = new FakeTimer(period);
|
||||
Timers.Add(timer);
|
||||
return timer;
|
||||
}
|
||||
|
||||
/// <summary>A timer that remembers its period and whether it was disposed.</summary>
|
||||
internal sealed class FakeTimer(TimeSpan period) : ITimer
|
||||
{
|
||||
public TimeSpan Period { get; } = period;
|
||||
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public bool Change(TimeSpan dueTime, TimeSpan p) => true;
|
||||
|
||||
public void Dispose() => Disposed = true;
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Disposed = true;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user