fix(overview): resolve a group's leader against its own members (scadaproj#43)
LeaderResolver searched every instance of the application, so co-located groups (QA: ScadaBridge central-a and site-a both on vq03) got the first-listed group's instance name on their leader chip. Resolution is now scoped to the group's members; an unmatched address keeps its raw form. - tests: QA co-located central/site layout; cross-group host not borrowed - CLAUDE.md: correct the leader-resolution / 127.0.0.1 guidance - code-reviews: Overview-002 Resolved; README regenerated Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -30,21 +30,21 @@ service `ZbOverview` on **:5320** under the shared NSSM, `ASPNETCORE_ENVIRONMENT
|
||||
|
||||
- **Publish self-contained** (`-r win-x64 --self-contained true`). wonder has **no .NET 10 runtime**
|
||||
— every app there carries its own. An FDD publish installs fine and then fails at service start.
|
||||
- **Registry `BaseUrl` must not put co-located instances on a shared host name.** Leader-address →
|
||||
instance resolution matches on **host only** (Akka and HTTP ports differ by design), which assumes
|
||||
distinct hosts per instance — true on the rig, false on a single box, where the fleet-wide scan
|
||||
lets the first matching entry win the chip for *every* group. On wonder that made ScadaBridge's
|
||||
**site** card claim `Leader · central`, a different Akka cluster. Fixed by using `127.0.0.1`,
|
||||
which matches nothing, so each chip falls back to the raw Akka address.
|
||||
- **Leader resolution is per group (fixed 2026-11-09, Overview-002 / scadaproj#43).** Leader-address →
|
||||
instance resolution matches on **host only** (Akka and HTTP ports differ by design). It used to scan
|
||||
the whole application, so co-located groups stole each other's chip — on vd03 ScadaBridge's **site**
|
||||
card claimed `Leader · central`, and the same happened on QA, where central-a and site-a both run on
|
||||
vq03 (the earlier note that QA "resolves as designed" was wrong). It now searches only the group's
|
||||
own members and keeps the raw Akka address when none matches. The vd03 `127.0.0.1` BaseUrls are no
|
||||
longer needed for correctness (each vd03 group is single-node); they remain harmless. Two members
|
||||
of the **same** group on one host would still be indistinguishable by host.
|
||||
|
||||
**QA runs an instance on EACH pair node — `wonder-app-vq03:5320` and (since 2026-08-14)
|
||||
`wonder-app-vq04:5320`** — both watching the same registry, now **10 instances across 4
|
||||
applications** after the 2026-08-14 VQ04 singleton clone (MxGateway + HistorianGateway added on
|
||||
vq04; see `deployment.md` §7.1). It is the fastest way to read QA's whole topology.
|
||||
Its registry uses **real FQDNs**, and that is correct rather than a divergence: the `127.0.0.1`
|
||||
rule above exists only because VD03 co-locates every instance on one host. QA is a **2-node pair**
|
||||
(`vq03`/`vq04`) with genuinely distinct hosts, so leader resolution works as designed and the chips
|
||||
resolve to real names (`node-a (vq03)`). Carry the *reason*, not the literal `127.0.0.1`.
|
||||
Its registry uses **real FQDNs**. Each QA group's two members are on distinct hosts (`vq03`/`vq04`),
|
||||
which per-group resolution needs; groups sharing a host no longer matter (see above).
|
||||
|
||||
## Commands (from the index)
|
||||
|
||||
|
||||
@@ -45,16 +45,13 @@ public static class LeaderResolver
|
||||
if (!members.Exists(m => m.HasActiveRole))
|
||||
continue;
|
||||
|
||||
groups.Add(ResolveGroup(name!, members, all));
|
||||
groups.Add(ResolveGroup(name!, members));
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
private static GroupSnapshot ResolveGroup(
|
||||
string name,
|
||||
IReadOnlyList<InstanceSnapshot> members,
|
||||
IReadOnlyList<InstanceSnapshot> allInstances)
|
||||
private static GroupSnapshot ResolveGroup(string name, IReadOnlyList<InstanceSnapshot> members)
|
||||
{
|
||||
// Only members that are actually talking get a vote. A Down or Unreachable node's last
|
||||
// known leader is stale by definition, and counting it would manufacture a disagreement
|
||||
@@ -80,9 +77,14 @@ public static class LeaderResolver
|
||||
.First()
|
||||
.Key;
|
||||
|
||||
// Resolved against the group's OWN members only. A group's leader is one of its members by
|
||||
// definition, and co-located groups of one application (QA: ScadaBridge central-a and
|
||||
// site-a both on vq03) share hosts — searching the whole application let the first-listed
|
||||
// group's instance win every later group's chip (Overview-002). An unmatched address keeps
|
||||
// its raw form rather than borrowing a name from another cluster.
|
||||
return new GroupSnapshot(
|
||||
name,
|
||||
ResolveToInstanceName(winner, allInstances),
|
||||
ResolveToInstanceName(winner, members),
|
||||
winner,
|
||||
Disagreement: distinct.Count > 1,
|
||||
distinct);
|
||||
|
||||
@@ -171,6 +171,40 @@ public class LeaderResolverTests
|
||||
Assert.All(groups, g => Assert.False(g.Disagreement));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_CoLocatedGroups_ResolveTheLeaderWithinTheirOwnGroup()
|
||||
{
|
||||
// Overview-002 (scadaproj#43): the QA layout — ScadaBridge central-a and site-a both on
|
||||
// vq03, central-b and site-b on vq04, all in ONE application. Resolving against every
|
||||
// instance named the site group's leader "central-a" (a node in another Akka cluster).
|
||||
var groups = LeaderResolver.Resolve(
|
||||
[
|
||||
Snapshots.Instance("central-a", "central", "http://vq03:9000", leader: "akka.tcp://scadabridge@vq03:8081"),
|
||||
Snapshots.Instance("central-b", "central", "http://vq04:9000", leader: "akka.tcp://scadabridge@vq03:8081"),
|
||||
Snapshots.Instance("site-a", "site", "http://vq03:8084", leader: "akka.tcp://scadabridge@vq03:8082"),
|
||||
Snapshots.Instance("site-b", "site", "http://vq04:8084", leader: "akka.tcp://scadabridge@vq03:8082"),
|
||||
]);
|
||||
|
||||
Assert.Equal("central-a", groups.Single(g => g.Name == "central").LeaderInstance);
|
||||
Assert.Equal("site-a", groups.Single(g => g.Name == "site").LeaderInstance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_LeaderHostOnlyInAnotherGroup_IsNotAttributedAcrossGroups()
|
||||
{
|
||||
// A leader that matches no member of its own group keeps its raw address rather than
|
||||
// borrowing a name from a different cluster that happens to share the host.
|
||||
var groups = LeaderResolver.Resolve(
|
||||
[
|
||||
Snapshots.Instance("central-a", "central", "http://shared-host:9000", leader: "akka.tcp://scadabridge@shared-host:8081"),
|
||||
Snapshots.Instance("site-a", "site", "http://127.0.0.1:8084", leader: "akka.tcp://scadabridge@shared-host:8082"),
|
||||
]);
|
||||
|
||||
var site = groups.Single(g => g.Name == "site");
|
||||
Assert.Null(site.LeaderInstance);
|
||||
Assert.Equal("akka.tcp://scadabridge@shared-host:8082", site.LeaderAddress);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("akka.tcp://scadabridge@site-a-a:8082", "site-a-a")]
|
||||
[InlineData("akka.tcp://otopcua@10.100.0.35:4053", "10.100.0.35")]
|
||||
|
||||
Reference in New Issue
Block a user