feat(mesh): ClusterRoleInfo exposes the node's own cluster-scoped role
Phase 6 Task 1. IClusterRoleInfo gains ClusterRole/ClusterId, derived from the node's configured AkkaClusterOptions.Roles at construction time (not live Cluster.State) so the identity is available before the cluster forms. First cluster-scoped role wins when more than one is configured, logged as a Warning. Updates the FakeClusterRoleInfo test double in ServiceCollectionExtensionsTests to satisfy the new interface members. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -20,6 +20,8 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
|||||||
private readonly ILogger<ClusterRoleInfo> _logger;
|
private readonly ILogger<ClusterRoleInfo> _logger;
|
||||||
private readonly CommonsNodeId _localNode;
|
private readonly CommonsNodeId _localNode;
|
||||||
private readonly HashSet<string> _localRoles;
|
private readonly HashSet<string> _localRoles;
|
||||||
|
private readonly string? _clusterRole;
|
||||||
|
private readonly string? _clusterId;
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
private readonly Dictionary<string, Member?> _roleLeaders = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, Member?> _roleLeaders = new(StringComparer.Ordinal);
|
||||||
private readonly Dictionary<string, HashSet<Member>> _membersByRole = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, HashSet<Member>> _membersByRole = new(StringComparer.Ordinal);
|
||||||
@@ -39,6 +41,23 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
|||||||
_localNode = CommonsNodeId.Parse($"{options.Value.PublicHostname}:{options.Value.Port}");
|
_localNode = CommonsNodeId.Parse($"{options.Value.PublicHostname}:{options.Value.Port}");
|
||||||
_localRoles = new HashSet<string>(options.Value.Roles, StringComparer.Ordinal);
|
_localRoles = new HashSet<string>(options.Value.Roles, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// Derived from the node's OWN configured roles (not live Cluster.State) so this identity is
|
||||||
|
// available at host-build time, before the cluster forms. Iterate the ORIGINAL array — not
|
||||||
|
// _localRoles — because first-wins requires configuration order, which HashSet does not
|
||||||
|
// guarantee.
|
||||||
|
var clusterRoles = options.Value.Roles.Where(RoleParser.IsClusterRole).ToArray();
|
||||||
|
if (clusterRoles.Length > 0)
|
||||||
|
{
|
||||||
|
_clusterRole = clusterRoles[0];
|
||||||
|
_clusterId = RoleParser.ClusterIdFromRole(_clusterRole);
|
||||||
|
if (clusterRoles.Length > 1)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Node configured with {Count} cluster-scoped roles ({Roles}); using the first ({ClusterRole})",
|
||||||
|
clusterRoles.Length, string.Join(", ", clusterRoles), _clusterRole);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SeedFromCurrentState();
|
SeedFromCurrentState();
|
||||||
_subscriber = system.ActorOf(Props.Create(() => new SubscriberActor(this)), "clusterroleinfo-subscriber");
|
_subscriber = system.ActorOf(Props.Create(() => new SubscriberActor(this)), "clusterroleinfo-subscriber");
|
||||||
}
|
}
|
||||||
@@ -49,6 +68,12 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IReadOnlySet<string> LocalRoles => _localRoles;
|
public IReadOnlySet<string> LocalRoles => _localRoles;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string? ClusterRole => _clusterRole;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string? ClusterId => _clusterId;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool HasRole(string role) => _localRoles.Contains(role);
|
public bool HasRole(string role) => _localRoles.Contains(role);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,21 @@ public interface IClusterRoleInfo
|
|||||||
NodeId LocalNode { get; }
|
NodeId LocalNode { get; }
|
||||||
/// <summary>Gets the set of roles assigned to the local node.</summary>
|
/// <summary>Gets the set of roles assigned to the local node.</summary>
|
||||||
IReadOnlySet<string> LocalRoles { get; }
|
IReadOnlySet<string> LocalRoles { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the local node's cluster-scoped role (e.g. <c>cluster-SITE-A</c>), or <c>null</c> when
|
||||||
|
/// the node carries none. Derived from the node's OWN configured roles at construction time —
|
||||||
|
/// NOT from live cluster membership — so it is available before the cluster forms (per-cluster
|
||||||
|
/// mesh, Phase 6). When more than one cluster-scoped role is configured, the first one wins.
|
||||||
|
/// </summary>
|
||||||
|
string? ClusterRole { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the ClusterId extracted from <see cref="ClusterRole"/> (e.g. <c>SITE-A</c>), or
|
||||||
|
/// <c>null</c> when the node carries no cluster-scoped role.
|
||||||
|
/// </summary>
|
||||||
|
string? ClusterId { get; }
|
||||||
|
|
||||||
/// <summary>Checks if the local node has the specified role.</summary>
|
/// <summary>Checks if the local node has the specified role.</summary>
|
||||||
/// <param name="role">Role name to check.</param>
|
/// <param name="role">Role name to check.</param>
|
||||||
/// <returns>True if the local node has the role; otherwise, false.</returns>
|
/// <returns>True if the local node has the role; otherwise, false.</returns>
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.Hosting;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Guards <see cref="ClusterRoleInfo.ClusterRole"/> / <see cref="ClusterRoleInfo.ClusterId"/> — the
|
||||||
|
/// node's OWN cluster-scoped role (per-cluster mesh, Phase 6), derived from the node's configured
|
||||||
|
/// <see cref="AkkaClusterOptions.Roles"/> at construction time, NOT from live cluster membership.
|
||||||
|
/// That distinction matters: this identity must be readable before the cluster forms, so later
|
||||||
|
/// Phase-6 work (singleton registration, a startup validator) can act on it at host-build time.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClusterRoleInfoTests
|
||||||
|
{
|
||||||
|
private static AkkaClusterOptions SampleOptions(params string[] roles) => new()
|
||||||
|
{
|
||||||
|
Port = 0,
|
||||||
|
Hostname = "127.0.0.1",
|
||||||
|
PublicHostname = "127.0.0.1",
|
||||||
|
SeedNodes = Array.Empty<string>(),
|
||||||
|
Roles = roles,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Boots a real host through the production <see cref="ServiceCollectionExtensions.WithOtOpcUaClusterBootstrap"/>
|
||||||
|
/// entry point (mirrors <c>SplitBrainResolverActivationTests</c>) — <see cref="ClusterRoleInfo"/>'s
|
||||||
|
/// constructor needs a real clustered <see cref="ActorSystem"/> to subscribe to cluster events,
|
||||||
|
/// even though the properties under test never touch live membership — resolves
|
||||||
|
/// <see cref="IClusterRoleInfo"/> from DI, and returns its <c>(ClusterRole, ClusterId)</c> pair
|
||||||
|
/// after tearing the host down. Both start/stop live in this helper rather than the test methods
|
||||||
|
/// so the CancellationToken-less calls stay off xunit1051's radar (mirrors
|
||||||
|
/// <c>SplitBrainResolverActivationTests.EffectiveConfigAsync</c>).
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<(string? ClusterRole, string? ClusterId)> ResolveAsync(AkkaClusterOptions options)
|
||||||
|
{
|
||||||
|
var builder = Host.CreateDefaultBuilder();
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
services.AddSingleton<IOptions<AkkaClusterOptions>>(Options.Create(options));
|
||||||
|
services.AddAkka("otopcua-role-info-test", (ab, sp) => ab.WithOtOpcUaClusterBootstrap(sp));
|
||||||
|
services.AddSingleton<IClusterRoleInfo, ClusterRoleInfo>();
|
||||||
|
});
|
||||||
|
|
||||||
|
using var host = builder.Build();
|
||||||
|
await host.StartAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var roleInfo = host.Services.GetRequiredService<IClusterRoleInfo>();
|
||||||
|
return (roleInfo.ClusterRole, roleInfo.ClusterId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await host.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A node configured with a single cluster-scoped role exposes it and its ClusterId.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Single_cluster_role_exposes_role_and_id()
|
||||||
|
{
|
||||||
|
var (clusterRole, clusterId) = await ResolveAsync(SampleOptions("driver", "cluster-SITE-A"));
|
||||||
|
|
||||||
|
clusterRole.ShouldBe("cluster-SITE-A");
|
||||||
|
clusterId.ShouldBe("SITE-A");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A node configured with only fixed roles carries no cluster identity.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task No_cluster_role_configured_yields_null()
|
||||||
|
{
|
||||||
|
var (clusterRole, clusterId) = await ResolveAsync(SampleOptions("admin", "driver"));
|
||||||
|
|
||||||
|
clusterRole.ShouldBeNull();
|
||||||
|
clusterId.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// More than one cluster-scoped role is a misconfiguration; the first configured wins rather
|
||||||
|
/// than throwing.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Multiple_cluster_roles_first_wins()
|
||||||
|
{
|
||||||
|
var (clusterRole, clusterId) = await ResolveAsync(SampleOptions("driver", "cluster-MAIN", "cluster-SITE-B"));
|
||||||
|
|
||||||
|
clusterRole.ShouldBe("cluster-MAIN");
|
||||||
|
clusterId.ShouldBe("MAIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An empty role list carries no cluster identity either.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Empty_roles_yields_null()
|
||||||
|
{
|
||||||
|
var (clusterRole, clusterId) = await ResolveAsync(SampleOptions());
|
||||||
|
|
||||||
|
clusterRole.ShouldBeNull();
|
||||||
|
clusterId.ShouldBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -193,6 +193,10 @@ public sealed class ServiceCollectionExtensionsTests
|
|||||||
public NodeId LocalNode { get; } = NodeId.Parse("test-node");
|
public NodeId LocalNode { get; } = NodeId.Parse("test-node");
|
||||||
/// <summary>Gets the local roles.</summary>
|
/// <summary>Gets the local roles.</summary>
|
||||||
public IReadOnlySet<string> LocalRoles { get; } = new HashSet<string>(["driver"]);
|
public IReadOnlySet<string> LocalRoles { get; } = new HashSet<string>(["driver"]);
|
||||||
|
/// <summary>Gets the local node's cluster-scoped role. None configured in this fake.</summary>
|
||||||
|
public string? ClusterRole => null;
|
||||||
|
/// <summary>Gets the ClusterId extracted from <see cref="ClusterRole"/>. None configured in this fake.</summary>
|
||||||
|
public string? ClusterId => null;
|
||||||
/// <summary>Determines whether the local node has the specified role.</summary>
|
/// <summary>Determines whether the local node has the specified role.</summary>
|
||||||
/// <param name="role">The role to check.</param>
|
/// <param name="role">The role to check.</param>
|
||||||
public bool HasRole(string role) => LocalRoles.Contains(role);
|
public bool HasRole(string role) => LocalRoles.Contains(role);
|
||||||
|
|||||||
Reference in New Issue
Block a user