213 lines
10 KiB
C#
213 lines
10 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using Google.Protobuf;
|
|
using Grpc.Core;
|
|
using Grpc.Net.Client;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
|
|
|
/// <summary>
|
|
/// Command-plane failback (review 2026-10-21 03/P-2): the probe must test READINESS with a real
|
|
/// RPC (a node that is Up but still warming refuses commands with a not-dispatched Unavailable),
|
|
/// and the backoff must only reset after a real call on the preferred node succeeds — not after
|
|
/// the probe — so a node that keeps failing real calls is retried ever less often.
|
|
/// </summary>
|
|
public class SitePairChannelProviderFailbackTests
|
|
{
|
|
private const string Site = "site-1";
|
|
|
|
private static SitePairChannelProvider Build(Func<GrpcChannel, CancellationToken, Task<bool>> probe) => new(
|
|
new NoKeyProvider(),
|
|
Options.Create(new CommunicationOptions()),
|
|
NullLogger<SitePairChannelProvider>.Instance,
|
|
handlerFactory: null,
|
|
reachabilityProbe: probe);
|
|
|
|
/// <summary>Runs one call that node A refuses as not-ready (then succeeds on B), as a warming node does.</summary>
|
|
private static async Task CallRejectedByA(SitePairChannelProvider provider)
|
|
{
|
|
GrpcChannel? first = null;
|
|
await provider.ExecuteAsync(Site, (channel, _) =>
|
|
{
|
|
if (first is null)
|
|
{
|
|
first = channel;
|
|
throw GrpcRetrySafety.NotDispatched("Site command plane not ready.");
|
|
}
|
|
return Task.FromResult(1);
|
|
}, CancellationToken.None);
|
|
}
|
|
|
|
private static Task CallSucceeds(SitePairChannelProvider provider) =>
|
|
provider.ExecuteAsync(Site, (_, _) => Task.FromResult(1), CancellationToken.None);
|
|
|
|
/// <summary>Verifies that a probe success alone does not reset the backoff; repeated rejections after failback grow it.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task ProbeSuccess_ThenRealCallRejected_GrowsTheBackoffInsteadOfResettingIt()
|
|
{
|
|
using var provider = Build((_, _) => Task.FromResult(true));
|
|
provider.UpdateSite(Site, "http://localhost:15301", "http://localhost:15302");
|
|
|
|
await CallRejectedByA(provider);
|
|
Assert.False(provider.IsOnPreferredNode(Site));
|
|
Assert.Equal(TimeSpan.FromSeconds(1), provider.GetFailbackBackoff(Site));
|
|
|
|
Assert.True(await provider.TryFailbackAsync(Site, CancellationToken.None));
|
|
await CallRejectedByA(provider);
|
|
Assert.Equal(TimeSpan.FromSeconds(2), provider.GetFailbackBackoff(Site));
|
|
|
|
Assert.True(await provider.TryFailbackAsync(Site, CancellationToken.None));
|
|
await CallRejectedByA(provider);
|
|
Assert.Equal(TimeSpan.FromSeconds(4), provider.GetFailbackBackoff(Site));
|
|
}
|
|
|
|
/// <summary>Verifies that a successful real call on the preferred node after failback resets the backoff.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task RealCallSucceedsAfterFailback_ResetsTheBackoff()
|
|
{
|
|
using var provider = Build((_, _) => Task.FromResult(true));
|
|
provider.UpdateSite(Site, "http://localhost:15311", "http://localhost:15312");
|
|
|
|
await CallRejectedByA(provider);
|
|
Assert.True(await provider.TryFailbackAsync(Site, CancellationToken.None));
|
|
await CallRejectedByA(provider);
|
|
Assert.Equal(TimeSpan.FromSeconds(2), provider.GetFailbackBackoff(Site));
|
|
|
|
Assert.True(await provider.TryFailbackAsync(Site, CancellationToken.None));
|
|
await CallSucceeds(provider); // A really is ready now
|
|
|
|
// A later, unrelated failure starts again from the initial backoff.
|
|
await CallRejectedByA(provider);
|
|
Assert.Equal(TimeSpan.FromSeconds(1), provider.GetFailbackBackoff(Site));
|
|
}
|
|
|
|
/// <summary>Verifies that probation expires: a quiet site whose NodeA dies long after the failback starts again at the initial backoff.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task ProbationOlderThanTheWindow_IsTreatedAsConfirmed_FirstProbeAtInitialBackoff()
|
|
{
|
|
using var provider = Build((_, _) => Task.FromResult(true));
|
|
provider.FailbackProbationWindow = TimeSpan.FromMilliseconds(100);
|
|
provider.UpdateSite(Site, "http://localhost:15321", "http://localhost:15322");
|
|
|
|
await CallRejectedByA(provider);
|
|
Assert.True(await provider.TryFailbackAsync(Site, CancellationToken.None));
|
|
await CallRejectedByA(provider);
|
|
Assert.Equal(TimeSpan.FromSeconds(2), provider.GetFailbackBackoff(Site));
|
|
|
|
// Fail back on probation, then the site goes quiet (no real call) past the window.
|
|
Assert.True(await provider.TryFailbackAsync(Site, CancellationToken.None));
|
|
await Task.Delay(300);
|
|
|
|
await CallRejectedByA(provider);
|
|
Assert.Equal(TimeSpan.FromSeconds(1), provider.GetFailbackBackoff(Site));
|
|
}
|
|
|
|
/// <summary>Verifies that an address change clears probation.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddressChange_ClearsProbation()
|
|
{
|
|
using var provider = Build((_, _) => Task.FromResult(true));
|
|
provider.UpdateSite(Site, "http://localhost:15331", "http://localhost:15332");
|
|
|
|
await CallRejectedByA(provider);
|
|
Assert.True(await provider.TryFailbackAsync(Site, CancellationToken.None));
|
|
await CallRejectedByA(provider);
|
|
Assert.True(await provider.TryFailbackAsync(Site, CancellationToken.None));
|
|
|
|
provider.UpdateSite(Site, "http://localhost:15333", "http://localhost:15332");
|
|
await CallRejectedByA(provider);
|
|
Assert.Equal(TimeSpan.FromSeconds(1), provider.GetFailbackBackoff(Site));
|
|
}
|
|
|
|
/// <summary>Verifies that the production probe treats a not-ready (not-dispatched Unavailable) site as not ready.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task ReadinessProbe_SiteRefusingAsNotReady_IsNotReady()
|
|
{
|
|
var handler = new GrpcStubHandler(ready: false);
|
|
using var channel = GrpcChannel.ForAddress("http://node-a", new GrpcChannelOptions { HttpHandler = handler });
|
|
|
|
Assert.False(await SitePairChannelProvider.ReadinessProbeAsync(channel, CancellationToken.None));
|
|
Assert.EndsWith(".SiteCommandService/ExecuteLifecycle", handler.LastPath);
|
|
}
|
|
|
|
/// <summary>Verifies that the production probe treats a site answering a real command as ready.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task ReadinessProbe_SiteAnsweringARealCommand_IsReady()
|
|
{
|
|
var handler = new GrpcStubHandler(ready: true);
|
|
using var channel = GrpcChannel.ForAddress("http://node-a", new GrpcChannelOptions { HttpHandler = handler });
|
|
|
|
Assert.True(await SitePairChannelProvider.ReadinessProbeAsync(channel, CancellationToken.None));
|
|
Assert.Contains(SitePairChannelProvider.ReadinessProbeInstance, handler.LastRequestBody);
|
|
}
|
|
|
|
/// <summary>Verifies that the probe's sentinel instance name can never be a real instance (it is not a legal actor name).</summary>
|
|
[Fact]
|
|
public void ReadinessProbeSentinel_IsNotALegalInstanceName()
|
|
{
|
|
Assert.False(ZB.MOM.WW.ScadaBridge.Commons.Validation.ActorNameRules.IsValid(
|
|
SitePairChannelProvider.ReadinessProbeInstance));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Minimal in-memory gRPC server stand-in: a trailers-only Unavailable (+ the not-dispatched
|
|
/// trailer) while not ready, or a framed <see cref="LifecycleReply"/> with OK status when ready.
|
|
/// </summary>
|
|
private sealed class GrpcStubHandler(bool ready) : HttpMessageHandler
|
|
{
|
|
public string LastPath { get; private set; } = "";
|
|
public string LastRequestBody { get; private set; } = "";
|
|
|
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
|
{
|
|
LastPath = request.RequestUri!.AbsolutePath;
|
|
var body = await request.Content!.ReadAsByteArrayAsync(ct);
|
|
var parsed = LifecycleRequest.Parser.ParseFrom(body.AsSpan(5).ToArray());
|
|
LastRequestBody = parsed.ToString();
|
|
|
|
var response = new HttpResponseMessage(HttpStatusCode.OK) { Version = HttpVersion.Version20 };
|
|
if (!ready)
|
|
{
|
|
var notDispatched = GrpcRetrySafety.NotDispatched("Site command plane not ready.");
|
|
response.Content = new ByteArrayContent(Array.Empty<byte>());
|
|
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/grpc");
|
|
response.Headers.Add("grpc-status", ((int)StatusCode.Unavailable).ToString());
|
|
foreach (var entry in notDispatched.Trailers)
|
|
{
|
|
response.Headers.Add(entry.Key, entry.Value);
|
|
}
|
|
return response;
|
|
}
|
|
|
|
var reply = new LifecycleReply { DeploymentStateQuery = new DeploymentStateQueryResponseDto() }.ToByteArray();
|
|
var framed = new byte[5 + reply.Length];
|
|
framed[1] = (byte)(reply.Length >> 24);
|
|
framed[2] = (byte)(reply.Length >> 16);
|
|
framed[3] = (byte)(reply.Length >> 8);
|
|
framed[4] = (byte)reply.Length;
|
|
reply.CopyTo(framed, 5);
|
|
response.Content = new ByteArrayContent(framed);
|
|
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/grpc");
|
|
response.TrailingHeaders.Add("grpc-status", "0");
|
|
return response;
|
|
}
|
|
}
|
|
|
|
private sealed class NoKeyProvider : ISitePskProvider
|
|
{
|
|
/// <inheritdoc />
|
|
public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new("k");
|
|
/// <inheritdoc />
|
|
public void Invalidate(string siteId) { }
|
|
}
|
|
}
|