4d2dfb5b50
MeshGrpcBoundaryTests replaces the MeshClusterClientBoundaryTests deleted in step 1. Three
self-seeded single-member clusters sharing the ActorSystem name, joined ONLY by gRPC, driving
the REAL CentralCommunicationActor -> MeshGrpcCommandDialer -> Kestrel h2c ->
MeshCommandAuthInterceptor -> NodeCommandGrpcService -> NodeCommunicationActor -> EventStream:
1. No_cross_mesh_Akka_association_forms_between_central_and_either_site — the program's own
point, kept as a test rather than a live-gate note: each cluster stays single-member and
none ever holds another's address as a member.
2. One_dispatch_through_central_reaches_a_node_in_every_cluster_mesh — the fan, with each
site holding its OWN per-cluster PSK, so a dial that selected the wrong cluster's key
would be refused at the far side.
3. Falsifiability_control_a_dial_carrying_the_wrong_cluster_key_delivers_nothing — without
it, (2) cannot distinguish "the gRPC boundary carried it" from "something else in the
process did". The same node then accepts the right key, so the silence is the auth gate
and not a dead endpoint.
All three run fully offline — no Docker fixture — and pass.
Comment sweep across every remaining clusterclient|receptionist hit in src/ and tests/:
the program label becomes "mesh gRPC command plane", and every comment that described the
deleted transport as live now describes what actually runs (Dps = pair-local default,
Grpc = cross-mesh). mesh_transport.proto's header/service comments are reworded; no message,
field or enum is touched — the contract is locked. MeshCommActorPathTests is reshaped rather
than retired: the two paths still exist and still matter, but what a mismatch costs is now a
misleading Unavailable from the gRPC ingress rather than a silently dropped receptionist send.
The only surviving mentions of the old names are the ones that must survive: the fail-closed
rules that name "ClusterClient" as a retired value refused at boot, their tests, and this
test class's own account of what it replaces.
924 lines
42 KiB
C#
924 lines
42 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
using Akka.Actor;
|
|
using Akka.Cluster.Tools.PublishSubscribe;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Cluster;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Communication;
|
|
|
|
public class CentralCommunicationActorTests : ControlPlaneActorTestBase
|
|
{
|
|
private const string DeploymentsTopic = "deployments";
|
|
|
|
private static MeshTransportOptions GrpcMode(int refreshSeconds = 60) => new()
|
|
{
|
|
Mode = MeshTransportOptions.ModeGrpc,
|
|
ContactRefreshSeconds = refreshSeconds,
|
|
DispatchTimeoutSeconds = 10,
|
|
};
|
|
|
|
/// <summary>Seeds a cluster whose nodes carry a <c>GrpcPort</c> — the command plane's shape.</summary>
|
|
private static void SeedGrpcCluster(
|
|
IDbContextFactory<OtOpcUaConfigDbContext> factory,
|
|
string clusterId,
|
|
params (string NodeId, string Host, int? GrpcPort, bool Enabled, bool Maintenance)[] nodes)
|
|
{
|
|
using var db = factory.CreateDbContext();
|
|
db.ServerClusters.Add(new ServerCluster
|
|
{
|
|
ClusterId = clusterId,
|
|
Name = clusterId,
|
|
Enterprise = "ZB",
|
|
Site = "Site",
|
|
RedundancyMode = RedundancyMode.None,
|
|
NodeCount = 1,
|
|
CreatedBy = "test",
|
|
});
|
|
foreach (var n in nodes)
|
|
{
|
|
db.ClusterNodes.Add(new ClusterNode
|
|
{
|
|
NodeId = n.NodeId,
|
|
ClusterId = clusterId,
|
|
Host = n.Host,
|
|
AkkaPort = 4053,
|
|
GrpcPort = n.GrpcPort,
|
|
ApplicationUri = $"urn:{n.NodeId}",
|
|
Enabled = n.Enabled,
|
|
MaintenanceMode = n.Maintenance,
|
|
CreatedBy = "test",
|
|
});
|
|
}
|
|
|
|
db.SaveChanges();
|
|
}
|
|
|
|
private IActorRef Spawn(
|
|
IDbContextFactory<OtOpcUaConfigDbContext> factory,
|
|
MeshTransportOptions options,
|
|
IActorRef? coordinator = null) =>
|
|
Sys.ActorOf(CentralCommunicationActor.Props(factory, options, () => coordinator));
|
|
|
|
[Fact]
|
|
public void Dps_mode_publishes_on_the_carried_topic()
|
|
{
|
|
// Dps is the pair-local default and stays in the tree: it is the correct transport for a
|
|
// single-mesh topology, which every integration test and every profile that leaves
|
|
// MeshTransport:Mode unset still runs.
|
|
var factory = NewInMemoryDbFactory();
|
|
var mediatorProbe = CreateTestProbe();
|
|
// Subscribe on the probe's behalf: SubscribeAck goes to the SENDER, so telling the mediator
|
|
// without an explicit sender would ack to TestActor and this wait would hang.
|
|
DistributedPubSub.Get(Sys).Mediator.Tell(
|
|
new Subscribe(DeploymentsTopic, mediatorProbe.Ref), mediatorProbe.Ref);
|
|
mediatorProbe.ExpectMsg<SubscribeAck>(TimeSpan.FromSeconds(5));
|
|
|
|
var actor = Spawn(factory, new MeshTransportOptions());
|
|
var dispatch = new DispatchDeployment(
|
|
new DeploymentId(Guid.NewGuid()), new RevisionHash("abc"), new CorrelationId(Guid.NewGuid()));
|
|
|
|
actor.Tell(new MeshCommand(DeploymentsTopic, dispatch));
|
|
|
|
mediatorProbe.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(5)).ShouldBe(dispatch);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyAck_is_forwarded_to_the_coordinator_preserving_the_sender()
|
|
{
|
|
var factory = NewInMemoryDbFactory();
|
|
var coordinator = CreateTestProbe();
|
|
var node = CreateTestProbe();
|
|
var actor = Spawn(factory, GrpcMode(), coordinator.Ref);
|
|
|
|
var ack = new ApplyAck(
|
|
new DeploymentId(Guid.NewGuid()),
|
|
new NodeId("host-a:4053"),
|
|
ApplyAckOutcome.Applied,
|
|
null,
|
|
new CorrelationId(Guid.NewGuid()));
|
|
|
|
actor.Tell(ack, node.Ref);
|
|
|
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).ShouldBe(ack);
|
|
// Forward, not Tell: the coordinator must see the node, not this relay.
|
|
coordinator.LastSender.ShouldBe(node.Ref);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_unresolvable_coordinator_logs_rather_than_losing_the_ack_silently()
|
|
{
|
|
var factory = NewInMemoryDbFactory();
|
|
var actor = Spawn(factory, GrpcMode(), coordinator: null);
|
|
|
|
var ack = new ApplyAck(
|
|
new DeploymentId(Guid.NewGuid()),
|
|
new NodeId("host-a:4053"),
|
|
ApplyAckOutcome.Applied,
|
|
null,
|
|
new CorrelationId(Guid.NewGuid()));
|
|
|
|
EventFilter.Warning(contains: "not resolvable").ExpectOne(TimeSpan.FromSeconds(5), () =>
|
|
actor.Tell(ack));
|
|
}
|
|
|
|
[Fact]
|
|
public void A_faulted_contact_load_warns_and_leaves_the_actor_alive()
|
|
{
|
|
var factory = NewInMemoryDbFactory();
|
|
var actor = Spawn(factory, GrpcMode());
|
|
|
|
// Filter on a phrase unique to the DB-failure warning. EventFilter matches case-INSENSITIVELY,
|
|
// so a looser "was NOT" would also catch this actor's other warnings and the assertion would
|
|
// fail with two matches instead of one.
|
|
EventFilter.Warning(contains: "contact set was NOT refreshed").ExpectOne(
|
|
TimeSpan.FromSeconds(5),
|
|
() => actor.Tell(new Status.Failure(new InvalidOperationException("db down"))));
|
|
|
|
// Still serving: a transient DB outage must not take the command boundary down with it.
|
|
actor.Tell(new MeshCommand(DeploymentsTopic, "anything"));
|
|
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
|
}
|
|
|
|
// ------------------------------------------------------------------ Mode = Grpc
|
|
//
|
|
// The cross-mesh transport. The fan-out must reach EVERY enabled, non-maintenance node in every
|
|
// cluster, because the node side has no ClusterId or node filter at all; scoping happens later,
|
|
// inside the artifact. A narrower fan would leave nodes silently on their old configuration
|
|
// while the deployment sealed green.
|
|
|
|
[Fact]
|
|
public void Grpc_mode_fans_one_command_to_every_enabled_node_across_all_clusters()
|
|
{
|
|
// THE assertion of this transport: one command, one call per node, across every cluster.
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGrpcCluster(
|
|
factory,
|
|
"C1",
|
|
("host-a:4053", "host-a", 4056, true, false),
|
|
("host-b:4053", "host-b", 4056, true, false));
|
|
SeedGrpcCluster(factory, "C2", ("host-c:4053", "host-c", 4056, true, false));
|
|
var rig = SpawnGrpcRig(factory);
|
|
AwaitContactsLoaded(rig);
|
|
|
|
rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch()));
|
|
|
|
AwaitCondition(() => rig.Dialer.Calls.Count == 3, TimeSpan.FromSeconds(5));
|
|
rig.Dialer.Calls.Select(c => c.Endpoint).ShouldBe(
|
|
new[] { "http://host-a:4056", "http://host-b:4056", "http://host-c:4056" }, ignoreOrder: true);
|
|
|
|
// Each node is dialled with ITS OWN cluster's id — that is what selects the pre-shared key,
|
|
// so getting it wrong turns every command into a PermissionDenied at the node.
|
|
rig.Dialer.Calls.Single(c => c.Endpoint.Contains("host-c")).ClusterId.ShouldBe("C2");
|
|
rig.Dialer.Calls.Where(c => !c.Endpoint.Contains("host-c")).ShouldAllBe(c => c.ClusterId == "C1");
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_mode_publishes_nothing_on_pubsub()
|
|
{
|
|
// DPS is mesh-wide and cannot cross an application-Cluster boundary, so a command that also
|
|
// went out on the topic would be delivered twice inside central's own mesh and not at all
|
|
// anywhere else.
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false));
|
|
var mediatorProbe = CreateTestProbe();
|
|
DistributedPubSub.Get(Sys).Mediator.Tell(
|
|
new Subscribe(DeploymentsTopic, mediatorProbe.Ref), mediatorProbe.Ref);
|
|
mediatorProbe.ExpectMsg<SubscribeAck>(TimeSpan.FromSeconds(5));
|
|
|
|
var rig = SpawnGrpcRig(factory);
|
|
AwaitContactsLoaded(rig);
|
|
|
|
rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch()));
|
|
|
|
AwaitCondition(() => rig.Dialer.Calls.Count == 1, TimeSpan.FromSeconds(5));
|
|
mediatorProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_mode_does_not_dial_disabled_or_maintenance_nodes()
|
|
{
|
|
// Disabled and maintenance-mode rows are not nodes central talks to; the same filter the
|
|
// contact load applies, asserted here so the fan cannot drift from it.
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGrpcCluster(
|
|
factory,
|
|
"C1",
|
|
("host-a:4053", "host-a", 4056, true, false),
|
|
("host-b:4053", "host-b", 4056, false, false),
|
|
("host-c:4053", "host-c", 4056, true, true));
|
|
var rig = SpawnGrpcRig(factory);
|
|
AwaitContactsLoaded(rig);
|
|
|
|
rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch()));
|
|
|
|
AwaitCondition(() => rig.Dialer.Calls.Count == 1, TimeSpan.FromSeconds(5));
|
|
rig.Dialer.Calls[0].Endpoint.ShouldContain("host-a");
|
|
|
|
// And no late arrival from the two excluded rows.
|
|
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
rig.Dialer.Calls.Count.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_node_with_no_GrpcPort_is_skipped_with_a_warning_and_the_others_are_still_dialled()
|
|
{
|
|
// Mirrors TelemetryNodeSource: a row with no port exposes no endpoint yet. It must cost that
|
|
// node its commands, never the whole fan.
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGrpcCluster(
|
|
factory,
|
|
"C1",
|
|
("host-a:4053", "host-a", null, true, false),
|
|
("host-b:4053", "host-b", 4056, true, false));
|
|
|
|
GrpcRig? rig = null;
|
|
EventFilter.Warning(contains: "has no GrpcPort").ExpectOne(
|
|
TimeSpan.FromSeconds(5), () => rig = SpawnGrpcRig(factory));
|
|
|
|
rig.ShouldNotBeNull();
|
|
AwaitContactsLoaded(rig);
|
|
rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch()));
|
|
|
|
AwaitCondition(() => rig.Dialer.Calls.Count == 1, TimeSpan.FromSeconds(5));
|
|
rig.Dialer.Calls[0].Endpoint.ShouldBe("http://host-b:4056");
|
|
}
|
|
|
|
[Fact]
|
|
public void A_node_that_regains_then_loses_its_GrpcPort_warns_again()
|
|
{
|
|
// Warn-once is per continuous skip run, not per process. The suppression exists so a
|
|
// permanently portless row does not repeat every minute; carried across a repair it would
|
|
// silence the SECOND time a node loses its endpoint — the occurrence an operator most needs
|
|
// to see, because it is a regression rather than a known gap.
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", null, true, false));
|
|
var rig = SpawnGrpcRig(factory);
|
|
AwaitContactsLoaded(rig);
|
|
|
|
SetGrpcPort(factory, "host-a:4053", 4056);
|
|
rig.Actor.Tell(new CentralCommunicationActor.RefreshContacts());
|
|
AwaitCondition(
|
|
() => rig.Psk.KnownClusterPublications.Count >= 2, TimeSpan.FromSeconds(5));
|
|
|
|
SetGrpcPort(factory, "host-a:4053", null);
|
|
EventFilter.Warning(contains: "has no GrpcPort").ExpectOne(TimeSpan.FromSeconds(5), () =>
|
|
rig.Actor.Tell(new CentralCommunicationActor.RefreshContacts()));
|
|
}
|
|
|
|
[Fact]
|
|
public void One_failing_node_does_not_stop_the_fan_and_is_logged_by_node_id()
|
|
{
|
|
// Fire-and-forget: a failure is logged and DROPPED. No retry, no queue, and above all no
|
|
// abort of the remaining nodes — the deploy path's apply deadline is the backstop.
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGrpcCluster(
|
|
factory,
|
|
"C1",
|
|
("host-a:4053", "host-a", 4056, true, false),
|
|
("host-b:4053", "host-b", 4056, true, false));
|
|
var dialer = new RecordingDialer
|
|
{
|
|
FailEndpointsContaining = "host-a",
|
|
Fault = new Grpc.Core.RpcException(
|
|
new Grpc.Core.Status(Grpc.Core.StatusCode.Unavailable, "node down")),
|
|
};
|
|
var rig = SpawnGrpcRig(factory, dialer);
|
|
AwaitContactsLoaded(rig);
|
|
|
|
// The node id, not the endpoint: an operator reading the log is looking at ClusterNode rows.
|
|
EventFilter.Warning(contains: "host-a:4053").ExpectOne(TimeSpan.FromSeconds(5), () =>
|
|
rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch())));
|
|
|
|
// Both were attempted; the healthy one is unaffected by its neighbour's failure.
|
|
AwaitCondition(() => rig.Dialer.Calls.Count == 2, TimeSpan.FromSeconds(5));
|
|
|
|
// Still serving after a per-node failure — no supervision restart, no state loss.
|
|
rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch()));
|
|
AwaitCondition(() => rig.Dialer.Calls.Count == 4, TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public void A_psk_miss_costs_only_that_node_and_never_becomes_a_fan_level_abort()
|
|
{
|
|
// The dialer refuses a keyless cluster locally, so it arrives here as an ordinary per-node
|
|
// failure. The fan-level assertion is that the other cluster is still dialled.
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false));
|
|
SeedGrpcCluster(factory, "C2", ("host-b:4053", "host-b", 4056, true, false));
|
|
var dialer = new RecordingDialer
|
|
{
|
|
FailClusters = ["C2"],
|
|
Fault = new Grpc.Core.RpcException(
|
|
new Grpc.Core.Status(Grpc.Core.StatusCode.Unauthenticated, "no key")),
|
|
};
|
|
var rig = SpawnGrpcRig(factory, dialer);
|
|
AwaitContactsLoaded(rig);
|
|
|
|
EventFilter.Warning(contains: "Unauthenticated").ExpectOne(TimeSpan.FromSeconds(5), () =>
|
|
rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch())));
|
|
|
|
AwaitCondition(() => rig.Dialer.Calls.Count == 2, TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public void A_command_with_no_grpc_target_is_dropped_with_a_warning()
|
|
{
|
|
var factory = NewInMemoryDbFactory();
|
|
var rig = SpawnGrpcRig(factory);
|
|
AwaitContactsLoaded(rig);
|
|
|
|
EventFilter.Warning(contains: "dropping").ExpectOne(TimeSpan.FromSeconds(5), () =>
|
|
rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch())));
|
|
|
|
rig.Dialer.Calls.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void A_payload_that_is_not_a_mesh_command_record_is_dropped_with_an_error()
|
|
{
|
|
// The mapper refuses to encode an unknown payload rather than shipping something the node
|
|
// cannot decode. That refusal must cost the command, not the actor.
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false));
|
|
var rig = SpawnGrpcRig(factory);
|
|
AwaitContactsLoaded(rig);
|
|
|
|
EventFilter.Error(contains: "Cannot encode").ExpectOne(TimeSpan.FromSeconds(5), () =>
|
|
rig.Actor.Tell(new MeshCommand(DeploymentsTopic, "not a command record")));
|
|
|
|
rig.Dialer.Calls.ShouldBeEmpty();
|
|
|
|
// Still serving.
|
|
rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch()));
|
|
AwaitCondition(() => rig.Dialer.Calls.Count == 1, TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public void The_envelope_carries_the_commands_own_correlation_and_the_whole_fan_shares_it()
|
|
{
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGrpcCluster(
|
|
factory,
|
|
"C1",
|
|
("host-a:4053", "host-a", 4056, true, false),
|
|
("host-b:4053", "host-b", 4056, true, false));
|
|
var rig = SpawnGrpcRig(factory);
|
|
AwaitContactsLoaded(rig);
|
|
|
|
var correlationId = new CorrelationId(Guid.NewGuid());
|
|
rig.Actor.Tell(new MeshCommand(DeploymentsTopic, new DispatchDeployment(
|
|
new DeploymentId(Guid.NewGuid()), new RevisionHash("abc"), correlationId)));
|
|
|
|
AwaitCondition(() => rig.Dialer.Calls.Count == 2, TimeSpan.FromSeconds(5));
|
|
rig.Dialer.Calls.ShouldAllBe(c => c.Envelope.CorrelationId == correlationId.ToString());
|
|
// One envelope object, encoded once and fanned — not re-encoded per node.
|
|
rig.Dialer.Calls.Select(c => c.Envelope).Distinct().Count().ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_alarm_command_gets_one_minted_transport_correlation_shared_by_the_whole_fan()
|
|
{
|
|
// AlarmCommand is the one record with no correlation member of its own, so the envelope's is
|
|
// the only handle an operator has on it. Minting per fan rather than per node is what makes
|
|
// "this alarm command" one traceable event instead of N unrelated ones.
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGrpcCluster(
|
|
factory,
|
|
"C1",
|
|
("host-a:4053", "host-a", 4056, true, false),
|
|
("host-b:4053", "host-b", 4056, true, false));
|
|
var rig = SpawnGrpcRig(factory);
|
|
AwaitContactsLoaded(rig);
|
|
|
|
rig.Actor.Tell(new MeshCommand(
|
|
"alarms", new AlarmCommand("alarm-1", "Acknowledge", "operator", null, null)));
|
|
|
|
AwaitCondition(() => rig.Dialer.Calls.Count == 2, TimeSpan.FromSeconds(5));
|
|
rig.Dialer.Calls.Select(c => c.Envelope.CorrelationId).Distinct().Count().ShouldBe(1);
|
|
rig.Dialer.Calls[0].Envelope.CorrelationId.ShouldNotBeNullOrWhiteSpace();
|
|
rig.Dialer.Calls[0].Envelope.AlarmCommand.AlarmId.ShouldBe("alarm-1");
|
|
}
|
|
|
|
[Fact]
|
|
public void The_contact_refresh_publishes_the_known_cluster_set_and_drops_cached_psks()
|
|
{
|
|
// Phase 1A security register item 1, wired: the gate is only a control if something keeps it
|
|
// current, and the refresh is the moment central's picture of the fleet is up to date. The
|
|
// InvalidateAll on the same tick is what makes a rotated key take effect within one refresh
|
|
// instead of requiring a central restart.
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false));
|
|
SeedGrpcCluster(factory, "C2", ("host-b:4053", "host-b", null, true, false));
|
|
var rig = SpawnGrpcRig(factory);
|
|
|
|
AwaitContactsLoaded(rig);
|
|
|
|
// C2 is included even though its only node has no GrpcPort: the gate is about which clusters
|
|
// central KNOWS, not which it can currently dial. Excluding it would deny that cluster's acks.
|
|
rig.Psk.KnownClusterPublications[0].ShouldBe(new[] { "C1", "C2" }, ignoreOrder: true);
|
|
rig.Psk.InvalidateAllCount.ShouldBeGreaterThanOrEqualTo(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_slow_dial_does_not_park_the_actors_mailbox()
|
|
{
|
|
// The dialer's first act is a PSK lookup, which blocks on the secret store — bounded, but
|
|
// seconds, and repeated after every refresh because the refresh drops the cache. An async
|
|
// method runs synchronously to its first await, so dialing straight from the receive would
|
|
// stall this actor for that long PER NODE, and deployment acks queue behind that mailbox.
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false));
|
|
var coordinator = CreateTestProbe();
|
|
var dialer = new RecordingDialer { BlockBeforeFirstAwait = true };
|
|
var rig = SpawnGrpcRig(factory, dialer, coordinator: coordinator.Ref);
|
|
AwaitContactsLoaded(rig);
|
|
|
|
try
|
|
{
|
|
rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch()));
|
|
|
|
// The ack must be handled while the dial is still parked.
|
|
var ack = new ApplyAck(
|
|
new DeploymentId(Guid.NewGuid()),
|
|
new NodeId("host-a:4053"),
|
|
ApplyAckOutcome.Applied,
|
|
null,
|
|
new CorrelationId(Guid.NewGuid()));
|
|
rig.Actor.Tell(ack);
|
|
|
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).ShouldBe(ack);
|
|
}
|
|
finally
|
|
{
|
|
dialer.Release();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void A_node_added_after_start_joins_the_fan_on_the_next_refresh()
|
|
{
|
|
// The refresh is not decoration: operators add and retire nodes, and central must pick them
|
|
// up without a restart. Under Grpc that is the ONLY thing keeping the dispatch set current —
|
|
// nothing on the node side announces itself; the DB row is the only source.
|
|
//
|
|
// Driven by telling the actor its own RefreshContacts tick rather than by shortening
|
|
// ContactRefreshSeconds: a one-second timer would run for the whole fact, adding DB load to
|
|
// every test sharing the runner, and would make this assertion depend on wall-clock timing.
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false));
|
|
var rig = SpawnGrpcRig(factory);
|
|
AwaitContactsLoaded(rig);
|
|
|
|
using (var db = factory.CreateDbContext())
|
|
{
|
|
db.ClusterNodes.Add(new ClusterNode
|
|
{
|
|
NodeId = "host-b:4053",
|
|
ClusterId = "C1",
|
|
Host = "host-b",
|
|
AkkaPort = 4053,
|
|
GrpcPort = 4056,
|
|
ApplicationUri = "urn:host-b",
|
|
Enabled = true,
|
|
CreatedBy = "test",
|
|
});
|
|
db.SaveChanges();
|
|
}
|
|
|
|
rig.Actor.Tell(new CentralCommunicationActor.RefreshContacts());
|
|
AwaitCondition(
|
|
() => rig.Psk.KnownClusterPublications.Count >= 2, TimeSpan.FromSeconds(5));
|
|
|
|
rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch()));
|
|
|
|
AwaitCondition(() => rig.Dialer.Calls.Count == 2, TimeSpan.FromSeconds(5));
|
|
rig.Dialer.Calls.Select(c => c.Endpoint).ShouldBe(
|
|
new[] { "http://host-a:4056", "http://host-b:4056" }, ignoreOrder: true);
|
|
|
|
// Once PER REFRESH, not once at startup. A key can rotate without any membership change, so
|
|
// an invalidation that only fired when the cluster set moved would serve the pre-rotation
|
|
// key forever and a rotation would need a central restart.
|
|
rig.Psk.InvalidateAllCount.ShouldBeGreaterThanOrEqualTo(2);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(MeshTransportOptions.ModeDps)]
|
|
[InlineData(MeshTransportOptions.ModeGrpc)]
|
|
public void The_known_cluster_gate_is_published_in_every_transport_mode(string mode)
|
|
{
|
|
// Replaces an earlier fact asserting the OPPOSITE, whose premise did not survive Phase 1B: the
|
|
// gate feeds CentralCommandAuthInterceptor, which is registered on every admin node, and the
|
|
// ack service it protects is mapped by LISTENER PORT — neither consults MeshTransport:Mode.
|
|
// Gating the publication on Grpc meant a rollback to Dps left that endpoint serving with a
|
|
// permanently inactive gate, silently restoring both the timing oracle and the
|
|
// pre-authentication store/audit flood. The set is central's cluster inventory, not
|
|
// transport state.
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false));
|
|
var psk = new RecordingPskProvider();
|
|
|
|
Sys.ActorOf(CentralCommunicationActor.Props(
|
|
factory,
|
|
new MeshTransportOptions { Mode = mode, ContactRefreshSeconds = 60 },
|
|
() => null,
|
|
() => new RecordingDialer(),
|
|
() => psk));
|
|
|
|
AwaitCondition(
|
|
() => psk.KnownClusterPublications.Count >= 1, TimeSpan.FromSeconds(5));
|
|
psk.KnownClusterPublications[0].ShouldBe(new[] { "C1" });
|
|
psk.InvalidateAllCount.ShouldBeGreaterThanOrEqualTo(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void The_gate_set_includes_a_cluster_the_fan_set_excludes()
|
|
{
|
|
// The two sets answer different questions. The FAN asks "who does central talk to" — enabled,
|
|
// non-maintenance, ported. The GATE asks "is this a cluster central has ever heard of", and
|
|
// must say yes for a cluster whose every node is disabled or in maintenance: such a cluster
|
|
// can still send a deployment ack, and refusing it would quietly turn maintenance mode into
|
|
// an authentication switch.
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false));
|
|
SeedGrpcCluster(factory, "C-DISABLED", ("host-b:4053", "host-b", 4056, false, false));
|
|
SeedGrpcCluster(factory, "C-MAINT", ("host-c:4053", "host-c", 4056, true, true));
|
|
var rig = SpawnGrpcRig(factory);
|
|
AwaitContactsLoaded(rig);
|
|
|
|
rig.Psk.KnownClusterPublications[0].ShouldBe(
|
|
new[] { "C1", "C-DISABLED", "C-MAINT" }, ignoreOrder: true);
|
|
|
|
rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch()));
|
|
|
|
AwaitCondition(() => rig.Dialer.Calls.Count == 1, TimeSpan.FromSeconds(5));
|
|
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
rig.Dialer.Calls.Count.ShouldBe(1);
|
|
rig.Dialer.Calls[0].ClusterId.ShouldBe("C1");
|
|
}
|
|
|
|
[Fact]
|
|
public void The_gate_is_published_after_the_dispatch_set_is_adopted_and_before_the_cache_is_dropped()
|
|
{
|
|
// Two orderings in one handler, both load-bearing. SetKnownClusters must precede
|
|
// InvalidateAll, or the first lookup after a refresh re-reads the store under the PREVIOUS
|
|
// membership — briefly resolving a cluster central has just retired. And the dispatch set is
|
|
// adopted first so a command processed immediately after the publication already fans over
|
|
// the current targets.
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false));
|
|
var rig = SpawnGrpcRig(factory);
|
|
AwaitContactsLoaded(rig);
|
|
|
|
rig.Psk.Sequence.ShouldBe(new[] { "SetKnownClusters", "InvalidateAll" });
|
|
|
|
// Targets-before-gate, observed the only way it can be from outside the actor: the very next
|
|
// message finds the dispatch set already populated.
|
|
rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch()));
|
|
AwaitCondition(() => rig.Dialer.Calls.Count == 1, TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_mode_with_no_dialer_wired_drops_the_command_with_an_error()
|
|
{
|
|
// Mis-wiring, not misconfiguration: Mode=Grpc validates fine but the DI graph did not supply
|
|
// a dialer. Silence here would be a fleet-wide command outage with no symptom until a deploy
|
|
// stalled at its apply deadline two minutes later.
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false));
|
|
var actor = Sys.ActorOf(CentralCommunicationActor.Props(
|
|
factory,
|
|
GrpcMode(),
|
|
() => null,
|
|
dialer: null,
|
|
pskProvider: null));
|
|
|
|
// A never-supplied factory (dialer: null here) is silent at PreStart and on every retry —
|
|
// see Resolve's remarks — so this dispatch-time Error is the ONLY log line the mis-wiring
|
|
// produces. That is what makes the plain "no command dialer is wired" phrase safe to match
|
|
// without a second source racing it, unlike before this actor stopped double-reporting the
|
|
// same absence at two severities.
|
|
EventFilter.Error(contains: "no command dialer is wired").ExpectOne(
|
|
TimeSpan.FromSeconds(5),
|
|
() => actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch())));
|
|
}
|
|
|
|
// ------------------------------------------------------------------ PreStart resolve-race logging
|
|
//
|
|
// PreStart's eager resolve of IMeshCommandDialer/IMeshPskProvider loses a millisecond-wide race
|
|
// against the Akka builder's remaining starters on essentially every admin boot (live gates: 1A
|
|
// 2/3 boots, 1B 4/4 boots) and self-heals at the memoized properties' first use. The two facts
|
|
// below are the headline pin: a healthy boot must produce ZERO Warning/Error from this actor, and
|
|
// a GENUINELY broken factory must still be reported loudly, exactly once.
|
|
|
|
[Fact]
|
|
public void A_healthy_boot_produces_no_warning_or_error_even_though_PreStart_loses_the_resolve_race()
|
|
{
|
|
// Models the ordinary boot: both factories throw on their FIRST invocation (PreStart, which
|
|
// loses the race) and succeed on every subsequent one (the memoized properties' first-use
|
|
// retry, long after every starter has finished). This must be silent — no Warning, no Error
|
|
// — from PreStart all the way through the first contact load and the first dispatch, or a
|
|
// soak error-grep on a healthy rig is impossible.
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false));
|
|
var dialer = new RecordingDialer();
|
|
var psk = new RecordingPskProvider();
|
|
var dialerCalls = 0;
|
|
var pskCalls = 0;
|
|
|
|
IMeshCommandDialer? DialerFactory()
|
|
{
|
|
dialerCalls++;
|
|
return dialerCalls == 1 ? throw new InvalidOperationException("starter-ordering race") : dialer;
|
|
}
|
|
|
|
IMeshPskProvider? PskFactory()
|
|
{
|
|
pskCalls++;
|
|
return pskCalls == 1 ? throw new InvalidOperationException("starter-ordering race") : psk;
|
|
}
|
|
|
|
IActorRef? actor = null;
|
|
EventFilter.Warning().And.Error().Expect(0, TimeSpan.FromSeconds(5), () =>
|
|
{
|
|
actor = Sys.ActorOf(CentralCommunicationActor.Props(
|
|
factory,
|
|
GrpcMode(),
|
|
() => null,
|
|
DialerFactory,
|
|
PskFactory));
|
|
|
|
// First use of the PSK provider: the contact-refresh timer's initial (TimeSpan.Zero)
|
|
// tick, observed via the gate publish.
|
|
AwaitCondition(() => psk.KnownClusterPublications.Count >= 1, TimeSpan.FromSeconds(5));
|
|
|
|
// First use of the dialer: an ordinary dispatch, retried until the contact load (which
|
|
// races this on the same timer tick) has adopted the gRPC target set.
|
|
AwaitCondition(() =>
|
|
{
|
|
actor!.Tell(new MeshCommand(DeploymentsTopic, NewDispatch()));
|
|
return dialer.Calls.Count >= 1;
|
|
}, TimeSpan.FromSeconds(5));
|
|
});
|
|
|
|
// Both retries actually happened — this fact is not vacuously true because the factories
|
|
// never got a second chance to run.
|
|
dialerCalls.ShouldBeGreaterThanOrEqualTo(2);
|
|
pskCalls.ShouldBeGreaterThanOrEqualTo(2);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_permanently_unresolvable_pskProvider_is_reported_once_per_incarnation_not_once_per_refresh()
|
|
{
|
|
// The counterpart pin: a factory that NEVER succeeds is the genuinely permanent absence and
|
|
// must still be loud — but PublishKnownClusters runs on the contact-refresh timer for the
|
|
// actor's whole life, so an unguarded Error would repeat every ContactRefreshSeconds forever.
|
|
// Guarded exactly like _warnedPortlessNodes: once per incarnation, not once per refresh.
|
|
//
|
|
// The factory counts its own invocations rather than relying on a dialer/dispatch side
|
|
// effect: LoadContactsFromDb is fire-and-forget, so a signal that isn't the resolve itself
|
|
// (e.g. a dispatched command landing) can close an AwaitCondition window before the SECOND
|
|
// contact load has actually re-invoked PublishKnownClusters, making the "does not repeat"
|
|
// half true for the wrong reason.
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false));
|
|
var pskCalls = 0;
|
|
|
|
IMeshPskProvider? PskFactory()
|
|
{
|
|
pskCalls++;
|
|
throw new InvalidOperationException("permanently broken");
|
|
}
|
|
|
|
IActorRef actor = null!;
|
|
EventFilter.Error(contains: "will NEVER be published").ExpectOne(TimeSpan.FromSeconds(5), () =>
|
|
{
|
|
actor = Sys.ActorOf(CentralCommunicationActor.Props(
|
|
factory,
|
|
GrpcMode(),
|
|
() => null,
|
|
() => new RecordingDialer(),
|
|
pskProvider: PskFactory));
|
|
|
|
// pskCalls == 1 is PreStart's own attempt; reaching 2 is PROOF the first contact load
|
|
// ran and PublishKnownClusters's property access performed the first-use retry — not
|
|
// merely that the actor was constructed.
|
|
AwaitCondition(() => pskCalls >= 2, TimeSpan.FromSeconds(5));
|
|
});
|
|
|
|
var pskCallsAfterFirstLoad = pskCalls;
|
|
|
|
// A SECOND contact load must NOT repeat the Error. The pin only means something if that
|
|
// second load actually happened and retried the resolve again — AwaitCondition on a
|
|
// STRICTLY higher pskCalls count, taken INSIDE the very block whose zero-Error assertion
|
|
// depends on it, is what proves that rather than assuming it from elapsed time.
|
|
EventFilter.Error(contains: "will NEVER be published").Expect(0, TimeSpan.FromSeconds(5), () =>
|
|
{
|
|
actor.Tell(new CentralCommunicationActor.RefreshContacts());
|
|
AwaitCondition(() => pskCalls > pskCallsAfterFirstLoad, TimeSpan.FromSeconds(5));
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void A_supplied_dialer_factory_that_always_throws_still_drops_each_command_loudly_while_PreStart_stays_quiet()
|
|
{
|
|
// The symmetric case to Grpc_mode_with_no_dialer_wired_drops_the_command_with_an_error
|
|
// above: there the factory was never supplied (Resolve returns null with no exception,
|
|
// silent at every call site); here a factory WAS supplied and never succeeds, so it goes
|
|
// through Resolve's catch on both the PreStart attempt (firstUse: false, Debug) and every
|
|
// first-use retry (firstUse: true, still Debug — see Resolve's remarks on why severity is
|
|
// decided by the consumer, not by Resolve). The loud signal is DispatchOverGrpc's existing
|
|
// per-dispatch Error, unaffected by an exception being the reason the dialer is null rather
|
|
// than a bare absence, and unguarded — unlike the PSK provider, every dispatch reports it.
|
|
var factory = NewInMemoryDbFactory();
|
|
SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false));
|
|
var psk = new RecordingPskProvider();
|
|
|
|
IActorRef actor = null!;
|
|
EventFilter.Warning().And.Error().Expect(0, TimeSpan.FromSeconds(5), () =>
|
|
{
|
|
actor = Sys.ActorOf(CentralCommunicationActor.Props(
|
|
factory,
|
|
GrpcMode(),
|
|
() => null,
|
|
dialer: () => throw new InvalidOperationException("permanently broken"),
|
|
pskProvider: () => psk));
|
|
|
|
// Proof PreStart's resolve attempt and the first contact load it schedules have
|
|
// actually run, so this block's silence is not vacuous.
|
|
AwaitCondition(() => psk.KnownClusterPublications.Count >= 1, TimeSpan.FromSeconds(5));
|
|
});
|
|
|
|
EventFilter.Error(contains: "no command dialer is wired").ExpectOne(TimeSpan.FromSeconds(5), () =>
|
|
actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch())));
|
|
|
|
// A SECOND dispatch must ALSO log it: this signal is per-dispatch and unguarded, the
|
|
// opposite shape from the PSK provider's once-per-incarnation guard above.
|
|
EventFilter.Error(contains: "no command dialer is wired").ExpectOne(TimeSpan.FromSeconds(5), () =>
|
|
actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch())));
|
|
}
|
|
|
|
private static void SetGrpcPort(
|
|
IDbContextFactory<OtOpcUaConfigDbContext> factory, string nodeId, int? grpcPort)
|
|
{
|
|
using var db = factory.CreateDbContext();
|
|
var node = db.ClusterNodes.Single(n => n.NodeId == nodeId);
|
|
node.GrpcPort = grpcPort;
|
|
db.SaveChanges();
|
|
}
|
|
|
|
private static DispatchDeployment NewDispatch() => new(
|
|
new DeploymentId(Guid.NewGuid()), new RevisionHash("abc"), new CorrelationId(Guid.NewGuid()));
|
|
|
|
/// <summary>A spawned gRPC-mode actor plus the two seams the tests observe it through.</summary>
|
|
private sealed record GrpcRig(IActorRef Actor, RecordingDialer Dialer, RecordingPskProvider Psk);
|
|
|
|
private GrpcRig SpawnGrpcRig(
|
|
IDbContextFactory<OtOpcUaConfigDbContext> factory,
|
|
RecordingDialer? dialer = null,
|
|
IActorRef? coordinator = null)
|
|
{
|
|
dialer ??= new RecordingDialer();
|
|
var psk = new RecordingPskProvider();
|
|
var actor = Sys.ActorOf(CentralCommunicationActor.Props(
|
|
factory,
|
|
GrpcMode(),
|
|
() => coordinator,
|
|
() => dialer,
|
|
() => psk));
|
|
return new GrpcRig(actor, dialer, psk);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Waits until the actor has applied a contact load. The known-cluster publication is the
|
|
/// signal because it happens in the same handler, immediately after the target set is
|
|
/// adopted — and unlike the target set itself it is observable from outside the actor. A
|
|
/// command told before this point would be dropped as "no targets" and never retried, which
|
|
/// is correct behaviour and a flaky test.
|
|
/// </summary>
|
|
private void AwaitContactsLoaded(GrpcRig rig) =>
|
|
AwaitCondition(
|
|
() => rig.Psk.KnownClusterPublications.Count >= 1, TimeSpan.FromSeconds(5));
|
|
|
|
/// <summary>Records every dial and answers however the test asks; never opens a socket.</summary>
|
|
private sealed class RecordingDialer : IMeshCommandDialer
|
|
{
|
|
private readonly object _gate = new();
|
|
private readonly List<Dial> _calls = [];
|
|
private readonly ManualResetEventSlim _released = new(false);
|
|
|
|
/// <summary>
|
|
/// When true, every dial blocks its calling thread synchronously until
|
|
/// <see cref="Release"/>. Models the dialer's pre-await PSK lookup.
|
|
/// </summary>
|
|
public bool BlockBeforeFirstAwait { get; set; }
|
|
|
|
/// <summary>Unblocks every dial parked by <see cref="BlockBeforeFirstAwait"/>.</summary>
|
|
public void Release() => _released.Set();
|
|
|
|
/// <summary>Every dial made. Copied under the lock — the fan runs on thread-pool threads.</summary>
|
|
public IReadOnlyList<Dial> Calls
|
|
{
|
|
get { lock (_gate) return _calls.ToList(); }
|
|
}
|
|
|
|
/// <summary>Endpoints whose substring match makes the dial fault.</summary>
|
|
public string? FailEndpointsContaining { get; set; }
|
|
|
|
/// <summary>Cluster ids whose dials fault.</summary>
|
|
public string[] FailClusters { get; set; } = [];
|
|
|
|
/// <summary>
|
|
/// The fault raised for a matching dial. Fully qualified because this file's bare
|
|
/// <c>Status</c> is Akka's — an existing fact drives the actor's <c>Status.Failure</c>
|
|
/// handler, and importing Grpc.Core here would make that reference ambiguous.
|
|
/// </summary>
|
|
public Grpc.Core.RpcException? Fault { get; set; }
|
|
|
|
public Task DispatchAsync(
|
|
string nodeEndpoint, CommandEnvelope envelope, string clusterId, CancellationToken ct)
|
|
{
|
|
lock (_gate) _calls.Add(new Dial(nodeEndpoint, envelope, clusterId));
|
|
|
|
if (BlockBeforeFirstAwait)
|
|
{
|
|
// Blocks the CALLING thread, exactly as IMeshPskProvider.TryGetPsk does on a cold
|
|
// cache: an async method runs synchronously to its first await, so a dial invoked
|
|
// straight from the actor would park the mailbox here.
|
|
_released.Wait(TimeSpan.FromSeconds(30));
|
|
}
|
|
|
|
var shouldFail =
|
|
(FailEndpointsContaining is not null && nodeEndpoint.Contains(FailEndpointsContaining))
|
|
|| FailClusters.Contains(clusterId, StringComparer.OrdinalIgnoreCase);
|
|
|
|
return shouldFail && Fault is not null
|
|
? Task.FromException(Fault)
|
|
: Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>One recorded dial.</summary>
|
|
public sealed record Dial(string Endpoint, CommandEnvelope Envelope, string ClusterId);
|
|
}
|
|
|
|
/// <summary>Records the gate and invalidation calls the contact refresh is supposed to make.</summary>
|
|
private sealed class RecordingPskProvider : IMeshPskProvider
|
|
{
|
|
private readonly object _gate = new();
|
|
private readonly List<IReadOnlyList<string>> _publications = [];
|
|
private readonly List<string> _sequence = [];
|
|
private int _invalidateAllCount;
|
|
|
|
/// <summary>Names of the mutating calls made, in the order they arrived.</summary>
|
|
public IReadOnlyList<string> Sequence
|
|
{
|
|
get { lock (_gate) return _sequence.ToList(); }
|
|
}
|
|
|
|
/// <summary>Every known-cluster set published, in order.</summary>
|
|
public IReadOnlyList<IReadOnlyList<string>> KnownClusterPublications
|
|
{
|
|
get { lock (_gate) return _publications.ToList(); }
|
|
}
|
|
|
|
/// <summary>How many times the whole cache was dropped.</summary>
|
|
public int InvalidateAllCount => Volatile.Read(ref _invalidateAllCount);
|
|
|
|
public bool TryGetPsk(string clusterId, [NotNullWhen(true)] out string? psk)
|
|
{
|
|
psk = "psk";
|
|
return true;
|
|
}
|
|
|
|
public bool Matches(string clusterId, string presented) => true;
|
|
|
|
public void SetKnownClusters(IReadOnlyCollection<string> clusterIds)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
_publications.Add(clusterIds.ToList());
|
|
_sequence.Add(nameof(SetKnownClusters));
|
|
}
|
|
}
|
|
|
|
public void Invalidate(string clusterId) { }
|
|
|
|
public void InvalidateAll()
|
|
{
|
|
Interlocked.Increment(ref _invalidateAllCount);
|
|
lock (_gate) _sequence.Add(nameof(InvalidateAll));
|
|
}
|
|
}
|
|
}
|