Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/GracefulStopWithOpenSyncStreamTests.cs
T
Joseph Doherty 47f0b98c6e
v2-ci / unit-tests (push) Has been cancelled
chore(deps): pin ZB.MOM.WW.LocalDb 0.3.8 (scadaproj#57)
LocalDb 0.3.8 ends open sync streams on ApplicationStopping and refuses
new ones Unavailable while stopping (lmxopcua#906, library side). The
host-side ShutdownStreamCancellationInterceptor stays: it also covers
the mesh telemetry/command streams.

GracefulStopWithOpenSyncStreamTests' positive control started passing
the stop in ~950 ms under 0.3.8, because the library alone now ends
the stream. The control registers LocalDbSyncService without the host
lifetime (the 0.3.7 behaviour), so it still reproduces the 8 s drain.

Release build 0 warnings. Host.Tests 288/288, Host.IntegrationTests
350 passed / 2 skipped, Runtime.Tests 1063 passed / 3 skipped,
Core.AlarmHistorian.Tests 53/53.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-13 06:55:27 -05:00

299 lines
13 KiB
C#

using System.Diagnostics;
using System.Net;
using Akka.Actor;
using Akka.Cluster;
using Akka.Configuration;
using Akka.Hosting;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.LocalDb.Replication;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using AkkaCluster = Akka.Cluster.Cluster;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.LocalDb;
/// <summary>
/// lmxopcua#906 — a graceful stop of a node whose peer holds an open LocalDb sync stream must leave
/// the Akka cluster FIRST and finish promptly, not wait out Kestrel's graceful-drain timeout.
/// </summary>
/// <remarks>
/// <para>
/// The passive node is a real <see cref="WebApplication"/> — the hosting model whose hosted-service
/// order caused the bug (the web server stops before Akka) — with a real Kestrel h2c listener, the
/// real <c>MapZbLocalDbSync</c> behind the real auth interceptor, a real single-member Akka cluster
/// and the production <see cref="GracefulClusterLeaveRegistration.AddOtOpcUaGracefulClusterLeave"/>
/// + <see cref="ShutdownStreamCancellationInterceptor"/>. The peer is a real
/// <c>SyncBackgroundService</c> dialling it over loopback.
/// </para>
/// <para>
/// The web server's <see cref="IServer"/> is wrapped to timestamp the moment its
/// <c>StopAsync</c> (the drain) begins, so "left the cluster before the drain" is asserted as an
/// ORDER, not inferred from a total duration.
/// </para>
/// <para>
/// The second test is the positive control: the same stack WITHOUT the fix reproduces #906 — the
/// stop waits out the whole shutdown timeout and the cluster is left only after the drain. Without
/// it, a fast stop in the first test could be a harness that never held a stream open at all.
/// </para>
/// </remarks>
[Collection("LocalDbPairConvergence")]
public sealed class GracefulStopWithOpenSyncStreamTests
{
private const string ApiKey = "otopcua-906-graceful-stop-key";
/// <summary>The host's ShutdownTimeout in these tests — Kestrel's drain budget (30 s in production).</summary>
private static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(8);
static GracefulStopWithOpenSyncStreamTests() =>
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
[Fact]
public async Task GracefulStop_WithOpenSyncStream_LeavesClusterBeforeDrain_AndFinishesPromptly()
{
var result = await RunStopScenarioAsync(withFix: true);
// Left the cluster, and did so before the web server began draining.
result.MemberRemovedAt.ShouldNotBeNull("the stopping node never left the Akka cluster");
result.MemberRemovedAt.Value.ShouldBeLessThan(result.ServerStopBeganAt!.Value,
"the node must leave the cluster BEFORE Kestrel drains, so the peer promotes promptly");
// Finished well inside the drain budget: the open sync stream did not hold the stop.
result.StopElapsed.ShouldBeLessThan(TimeSpan.FromSeconds(4),
$"stop took {result.StopElapsed.TotalMilliseconds:0} ms against a {ShutdownTimeout.TotalSeconds:0} s drain budget");
result.MemberRemovedAt.Value.ShouldBeLessThan(TimeSpan.FromSeconds(4));
}
[Fact]
public async Task PositiveControl_WithoutTheFix_StopWaitsOutTheDrain_AndLeavesTheClusterLast()
{
var result = await RunStopScenarioAsync(withFix: false);
// The #906 shape: Kestrel waits for the open stream until the shutdown timeout aborts it,
// and only then does Akka's own hosted service leave the cluster.
result.StopElapsed.ShouldBeGreaterThanOrEqualTo(ShutdownTimeout - TimeSpan.FromSeconds(1));
result.MemberRemovedAt.ShouldNotBeNull();
result.MemberRemovedAt.Value.ShouldBeGreaterThan(result.ServerStopBeganAt!.Value);
result.MemberRemovedAt.Value.ShouldBeGreaterThanOrEqualTo(ShutdownTimeout - TimeSpan.FromSeconds(1));
}
private sealed record StopResult(TimeSpan StopElapsed, TimeSpan? MemberRemovedAt, TimeSpan? ServerStopBeganAt);
private static async Task<StopResult> RunStopScenarioAsync(bool withFix)
{
var ct = TestContext.Current.CancellationToken;
var pathA = Path.Combine(Path.GetTempPath(), $"otopcua-906-A-{Guid.NewGuid():N}.db");
var pathB = Path.Combine(Path.GetTempPath(), $"otopcua-906-B-{Guid.NewGuid():N}.db");
await using var dbA = BuildDatabaseProvider(pathA);
await using var dbB = BuildDatabaseProvider(pathB);
var clock = new Stopwatch();
var serverStopBegan = new ServerStopProbe(clock);
IHost? initiator = null;
WebApplication? passive = null;
try
{
passive = BuildPassiveNode(dbB.GetRequiredService<ILocalDb>(), serverStopBegan, withFix);
await passive.StartAsync(ct);
var system = passive.Services.GetRequiredService<ActorSystem>();
var cluster = AkkaCluster.Get(system);
cluster.Join(cluster.SelfAddress);
await WaitUntilAsync(() => cluster.State.Members.Any(m => m.Status == MemberStatus.Up),
"the passive node to come Up as its own seed");
TimeSpan? memberRemovedAt = null;
cluster.RegisterOnMemberRemoved(() => memberRemovedAt ??= clock.Elapsed);
var address = passive.Services.GetRequiredService<IServer>()
.Features.Get<IServerAddressesFeature>()!.Addresses.Single();
initiator = await StartInitiatorAsync(dbA.GetRequiredService<ILocalDb>(), address, ct);
// The peer's stream is open and holding a session on the passive node.
var passiveStatus = passive.Services.GetRequiredService<ISyncStatus>();
await WaitUntilAsync(() => passiveStatus.Connected, "the peer's sync stream to be open on the passive node");
clock.Start();
await passive.StopAsync(CancellationToken.None);
var elapsed = clock.Elapsed;
TestContext.Current.TestOutputHelper?.WriteLine(
$"withFix={withFix}: stop {elapsed.TotalMilliseconds:0} ms; member removed at " +
$"{memberRemovedAt?.TotalMilliseconds:0} ms; web-server drain began at " +
$"{serverStopBegan.BeganAt?.TotalMilliseconds:0} ms (drain budget {ShutdownTimeout.TotalSeconds:0} s)");
return new StopResult(elapsed, memberRemovedAt, serverStopBegan.BeganAt);
}
finally
{
if (initiator is not null)
{
try { await initiator.StopAsync(TimeSpan.FromSeconds(5)); } catch { /* teardown */ }
initiator.Dispose();
}
if (passive is not null)
await passive.DisposeAsync();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
foreach (var path in new[] { pathA, pathB })
foreach (var suffix in new[] { "", "-wal", "-shm" })
try { File.Delete(path + suffix); } catch { /* best effort */ }
}
}
private static WebApplication BuildPassiveNode(ILocalDb db, ServerStopProbe probe, bool withFix)
{
var builder = WebApplication.CreateBuilder();
builder.Environment.ApplicationName = typeof(GracefulStopWithOpenSyncStreamTests).Assembly.GetName().Name!;
builder.Logging.ClearProviders();
builder.WebHost.ConfigureKestrel(k =>
k.Listen(IPAddress.Loopback, 0, o => o.Protocols = HttpProtocols.Http2));
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = ShutdownTimeout);
builder.Services.AddGrpc(o =>
{
o.Interceptors.Add<LocalDbSyncAuthInterceptor>();
if (withFix)
o.Interceptors.Add<ShutdownStreamCancellationInterceptor>();
});
builder.Services.AddSingleton(db);
builder.Services.AddZbLocalDbReplication(ReplicationConfig(peerAddress: null));
if (!withFix)
{
// From LocalDb 0.3.8 the library itself ends open sync streams on ApplicationStopping
// (scadaproj#57), which would hide the #906 drain in this control. Re-register the
// service WITHOUT the host lifetime, i.e. the 0.3.7 behaviour, so the control still
// reproduces the bug the host-side fix guards against.
builder.Services.Replace(ServiceDescriptor.Singleton(sp => new LocalDbSyncService(
sp.GetRequiredService<ILocalDb>(),
sp.GetRequiredService<IOptions<ReplicationOptions>>(),
sp.GetRequiredService<ILoggerFactory>(),
sp.GetService<LocalDbMetrics>(),
sp.GetService<ISyncStatus>(),
lifetime: null)));
}
builder.Services.AddAkka("otopcua906", (ab, _) => ab.AddHocon(
ConfigurationFactory.ParseString(
"akka.remote.dot-netty.tcp.hostname = \"127.0.0.1\"\n"
+ "akka.remote.dot-netty.tcp.public-hostname = \"127.0.0.1\"\n"
+ "akka.remote.dot-netty.tcp.port = 0\n"
+ "akka.cluster.seed-nodes = []\n"
+ "akka.loglevel = \"WARNING\"\n")
.WithFallback(HoconLoader.LoadBaseConfig()),
HoconAddMode.Prepend));
if (withFix)
builder.Services.AddOtOpcUaGracefulClusterLeave();
// Wrap the web server so the start of its drain is timestamped.
var serverDescriptor = builder.Services.Last(d => d.ServiceType == typeof(IServer));
builder.Services.Remove(serverDescriptor);
builder.Services.AddSingleton<IServer>(sp =>
{
var inner = serverDescriptor.ImplementationFactory is { } factory
? (IServer)factory(sp)
: (IServer)ActivatorUtilities.CreateInstance(sp, serverDescriptor.ImplementationType!);
return probe.Wrap(inner);
});
var app = builder.Build();
app.MapZbLocalDbSync();
return app;
}
private static async Task<IHost> StartInitiatorAsync(ILocalDb db, string peerAddress, CancellationToken ct)
{
var config = ReplicationConfig(peerAddress);
var host = new HostBuilder()
.ConfigureServices(services =>
{
services.AddLogging();
services.AddSingleton(db);
services.AddZbLocalDbReplication(config);
})
.Build();
await host.StartAsync(ct);
return host;
}
private static IConfiguration ReplicationConfig(string? peerAddress)
{
var values = new Dictionary<string, string?>
{
["LocalDb:Replication:FlushInterval"] = "00:00:00.050",
["LocalDb:Replication:ReconnectBackoffMax"] = "00:00:02",
["LocalDb:Replication:ApiKey"] = ApiKey,
};
if (peerAddress is not null)
values["LocalDb:Replication:PeerAddress"] = peerAddress;
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
}
private static ServiceProvider BuildDatabaseProvider(string path)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["LocalDb:Path"] = path,
["LocalDb:Replication:ApiKey"] = ApiKey,
})
.Build();
return new ServiceCollection()
.AddZbLocalDb(config, db => LocalDbSetup.OnReady(db, config))
.BuildServiceProvider();
}
private static async Task WaitUntilAsync(Func<bool> condition, string because)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
while (DateTime.UtcNow < deadline)
{
if (condition())
return;
await Task.Delay(50, TestContext.Current.CancellationToken);
}
Assert.Fail($"Timed out waiting for: {because}");
}
/// <summary>Records when the wrapped web server's <c>StopAsync</c> (its graceful drain) begins.</summary>
private sealed class ServerStopProbe(Stopwatch clock)
{
public TimeSpan? BeganAt { get; private set; }
private void MarkBegan() => BeganAt ??= clock.Elapsed;
public IServer Wrap(IServer inner) => new RecordingServer(inner, this);
private sealed class RecordingServer(IServer inner, ServerStopProbe probe) : IServer
{
public IFeatureCollection Features => inner.Features;
public Task StartAsync<TContext>(IHttpApplication<TContext> application, CancellationToken cancellationToken)
where TContext : notnull
=> inner.StartAsync(application, cancellationToken);
public Task StopAsync(CancellationToken cancellationToken)
{
probe.MarkBegan();
return inner.StopAsync(cancellationToken);
}
public void Dispose() => inner.Dispose();
}
}
}