feat(mesh-phase4): retire DB-reachability from ServiceLevel on DB-less nodes
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -580,6 +580,143 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
||||
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
// ── Per-cluster mesh Phase 4: DB-less driver-only nodes ─────────────────────────────────────
|
||||
// A driver-only node has no ConfigDb (LocalDb is its config store), so there is no DB to probe.
|
||||
// Such nodes run with dbHealthProbe: null + dbLess: true and must still publish the full
|
||||
// survive-alone ServiceLevel (240 follower / 250 Primary) — DbReachable is treated as a
|
||||
// constant true because the in-process LocalDb can never be "unreachable"; only snapshot
|
||||
// staleness demotes.
|
||||
|
||||
/// <summary>DB-less, member Up, snapshot fresh, follower (non-Primary) → 240. No probe wired,
|
||||
/// no DbHealthStatus ever arrives; the DB-less branch must still reach full service.</summary>
|
||||
[Fact]
|
||||
public void DbLess_healthy_follower_publishes_240()
|
||||
{
|
||||
var publisher = new RecordingPublisher();
|
||||
var local = NodeId.Parse("db-less-follower");
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
||||
serviceLevel: publisher, localNode: local, dbLess: true,
|
||||
staleWindow: TimeSpan.FromSeconds(30)));
|
||||
|
||||
actor.Tell(new RedundancyStateChanged(
|
||||
Nodes: new[]
|
||||
{
|
||||
new NodeRedundancyState(local, RedundancyRole.Secondary,
|
||||
IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
|
||||
},
|
||||
CorrelationId.NewId()));
|
||||
|
||||
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
|
||||
duration: TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>DB-less, member Up, snapshot fresh, Primary → 250 (basis 240 + 10 Primary bonus).</summary>
|
||||
[Fact]
|
||||
public void DbLess_healthy_primary_publishes_250()
|
||||
{
|
||||
var publisher = new RecordingPublisher();
|
||||
var local = NodeId.Parse("db-less-primary");
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
||||
serviceLevel: publisher, localNode: local, dbLess: true,
|
||||
staleWindow: TimeSpan.FromSeconds(30)));
|
||||
|
||||
actor.Tell(new RedundancyStateChanged(
|
||||
Nodes: new[]
|
||||
{
|
||||
new NodeRedundancyState(local, RedundancyRole.Primary,
|
||||
IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
|
||||
},
|
||||
CorrelationId.NewId()));
|
||||
|
||||
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
|
||||
duration: TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>DB-less, snapshot stale (entry.AsOfUtc older than the stale window) → 200. Staleness
|
||||
/// is derived from snapshot age ALONE (no DB-health term), so a DB-less node still demotes to 200
|
||||
/// when it is running behind on config.</summary>
|
||||
[Fact]
|
||||
public void DbLess_stale_snapshot_publishes_200()
|
||||
{
|
||||
var publisher = new RecordingPublisher();
|
||||
var local = NodeId.Parse("db-less-stale");
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
||||
serviceLevel: publisher, localNode: local, dbLess: true,
|
||||
staleWindow: TimeSpan.FromSeconds(2)));
|
||||
|
||||
actor.Tell(new RedundancyStateChanged(
|
||||
Nodes: new[]
|
||||
{
|
||||
new NodeRedundancyState(local, RedundancyRole.Secondary,
|
||||
IsClusterLeader: false, IsDriverPrimary: false,
|
||||
DateTime.UtcNow - TimeSpan.FromMinutes(1)),
|
||||
},
|
||||
CorrelationId.NewId()));
|
||||
|
||||
AwaitAssert(() => publisher.Levels.ShouldContain((byte)200),
|
||||
duration: TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>DB-less, Detached → 0. The Detached / missing-entry guard runs ABOVE the DB-less
|
||||
/// branch, so a DB-less node that detaches still fails closed to 0. Goes healthy (250) first so
|
||||
/// the transition down to 0 is observable (not dedup'd against the initial 0).</summary>
|
||||
[Fact]
|
||||
public void DbLess_detached_publishes_0()
|
||||
{
|
||||
var publisher = new RecordingPublisher();
|
||||
var local = NodeId.Parse("db-less-detached");
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
||||
serviceLevel: publisher, localNode: local, dbLess: true,
|
||||
staleWindow: TimeSpan.FromSeconds(30)));
|
||||
|
||||
actor.Tell(new RedundancyStateChanged(
|
||||
Nodes: new[]
|
||||
{
|
||||
new NodeRedundancyState(local, RedundancyRole.Primary,
|
||||
IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
|
||||
},
|
||||
CorrelationId.NewId()));
|
||||
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
|
||||
duration: TimeSpan.FromMilliseconds(500));
|
||||
|
||||
actor.Tell(new RedundancyStateChanged(
|
||||
Nodes: new[]
|
||||
{
|
||||
new NodeRedundancyState(local, RedundancyRole.Detached,
|
||||
IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
|
||||
},
|
||||
CorrelationId.NewId()));
|
||||
|
||||
AwaitAssert(() => publisher.Levels.ShouldContain((byte)0),
|
||||
duration: TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Regression: a DB-BACKED node (probe wired + DbHealthStatus reachable, dbLess default
|
||||
/// false) is unchanged — it still computes 240 via the calculator path. Proves the new DB-less
|
||||
/// branch did not disturb the existing DB-backed seam.</summary>
|
||||
[Fact]
|
||||
public void DbBacked_node_unchanged_still_publishes_240()
|
||||
{
|
||||
var publisher = new RecordingPublisher();
|
||||
var local = NodeId.Parse("db-backed-node");
|
||||
var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
|
||||
new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
||||
serviceLevel: publisher, localNode: local, dbHealthProbe: probe));
|
||||
|
||||
actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
|
||||
actor.Tell(new RedundancyStateChanged(
|
||||
Nodes: new[]
|
||||
{
|
||||
new NodeRedundancyState(local, RedundancyRole.Secondary,
|
||||
IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
|
||||
},
|
||||
CorrelationId.NewId()));
|
||||
|
||||
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
|
||||
duration: TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Stub DB-health probe actor that answers <see cref="DbHealthProbeActor.GetStatus"/>
|
||||
/// with a fixed status, so the calculator path is deterministic without a real timer.</summary>
|
||||
private sealed class StubDbHealth : Akka.Actor.ReceiveActor
|
||||
|
||||
Reference in New Issue
Block a user