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:
Joseph Doherty
2026-07-23 11:55:43 -04:00
parent 5c40908a55
commit 9565827275
4 changed files with 223 additions and 11 deletions
@@ -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