using Akka.Actor; using Akka.Cluster; using Akka.Cluster.Tools.Singleton; using Akka.Configuration; using Microsoft.Extensions.Options; using ScadaLink.ClusterInfrastructure; using ScadaLink.Host.Actors; using ScadaLink.SiteRuntime; using ScadaLink.SiteRuntime.Actors; using ScadaLink.SiteRuntime.Persistence; namespace ScadaLink.Host.Actors; /// /// Hosted service that manages the Akka.NET actor system lifecycle. /// Creates the actor system on start, registers actors, and triggers /// CoordinatedShutdown on stop. /// public class AkkaHostedService : IHostedService { private readonly IServiceProvider _serviceProvider; private readonly NodeOptions _nodeOptions; private readonly ClusterOptions _clusterOptions; private readonly ILogger _logger; private ActorSystem? _actorSystem; public AkkaHostedService( IServiceProvider serviceProvider, IOptions nodeOptions, IOptions clusterOptions, ILogger logger) { _serviceProvider = serviceProvider; _nodeOptions = nodeOptions.Value; _clusterOptions = clusterOptions.Value; _logger = logger; } /// /// Gets the actor system once started. Null before StartAsync completes. /// public ActorSystem? ActorSystem => _actorSystem; public Task StartAsync(CancellationToken cancellationToken) { var seedNodesStr = string.Join(",", _clusterOptions.SeedNodes.Select(s => $"\"{s}\"")); // For site nodes, include a site-specific role (e.g., "site-SiteA") alongside the base role var roles = BuildRoles(); var rolesStr = string.Join(",", roles.Select(r => $"\"{r}\"")); var hocon = $@" akka {{ actor {{ provider = cluster }} remote {{ dot-netty.tcp {{ hostname = ""{_nodeOptions.NodeHostname}"" port = {_nodeOptions.RemotingPort} }} }} cluster {{ seed-nodes = [{seedNodesStr}] roles = [{rolesStr}] min-nr-of-members = {_clusterOptions.MinNrOfMembers} split-brain-resolver {{ active-strategy = {_clusterOptions.SplitBrainResolverStrategy} stable-after = {_clusterOptions.StableAfter.TotalSeconds:F0}s keep-oldest {{ down-if-alone = on }} }} failure-detector {{ heartbeat-interval = {_clusterOptions.HeartbeatInterval.TotalSeconds:F0}s acceptable-heartbeat-pause = {_clusterOptions.FailureDetectionThreshold.TotalSeconds:F0}s }} run-coordinated-shutdown-when-down = on }} coordinated-shutdown {{ run-by-clr-shutdown-hook = on }} }}"; var config = ConfigurationFactory.ParseString(hocon); _actorSystem = ActorSystem.Create("scadalink", config); _logger.LogInformation( "Akka.NET actor system 'scadalink' started. Role={Role}, Roles={Roles}, Hostname={Hostname}, Port={Port}", _nodeOptions.Role, string.Join(", ", roles), _nodeOptions.NodeHostname, _nodeOptions.RemotingPort); // Register the dead letter monitor actor var loggerFactory = _serviceProvider.GetRequiredService(); var dlmLogger = loggerFactory.CreateLogger(); _actorSystem.ActorOf( Props.Create(() => new DeadLetterMonitorActor(dlmLogger)), "dead-letter-monitor"); // For site nodes, register the Deployment Manager as a cluster singleton if (_nodeOptions.Role.Equals("Site", StringComparison.OrdinalIgnoreCase)) { RegisterSiteActors(); } return Task.CompletedTask; } public async Task StopAsync(CancellationToken cancellationToken) { if (_actorSystem != null) { _logger.LogInformation("Shutting down Akka.NET actor system via CoordinatedShutdown..."); var shutdown = Akka.Actor.CoordinatedShutdown.Get(_actorSystem); await shutdown.Run(Akka.Actor.CoordinatedShutdown.ClrExitReason.Instance); _logger.LogInformation("Akka.NET actor system shutdown complete."); } } /// /// Builds the list of cluster roles for this node. Site nodes get both "Site" /// and a site-specific role (e.g., "site-SiteA") to scope singleton placement. /// private List BuildRoles() { var roles = new List { _nodeOptions.Role }; if (_nodeOptions.Role.Equals("Site", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(_nodeOptions.SiteId)) { roles.Add($"site-{_nodeOptions.SiteId}"); } return roles; } /// /// Registers site-specific actors including the Deployment Manager cluster singleton. /// The singleton is scoped to the site-specific cluster role so it runs on exactly /// one node within this site's cluster. /// private void RegisterSiteActors() { var siteRole = $"site-{_nodeOptions.SiteId}"; var storage = _serviceProvider.GetRequiredService(); var siteRuntimeOptionsValue = _serviceProvider.GetService>()?.Value ?? new SiteRuntimeOptions(); var dmLogger = _serviceProvider.GetRequiredService() .CreateLogger(); // Create the Deployment Manager as a cluster singleton var singletonProps = ClusterSingletonManager.Props( singletonProps: Props.Create(() => new DeploymentManagerActor( storage, siteRuntimeOptionsValue, dmLogger)), terminationMessage: PoisonPill.Instance, settings: ClusterSingletonManagerSettings.Create(_actorSystem!) .WithRole(siteRole) .WithSingletonName("deployment-manager")); _actorSystem!.ActorOf(singletonProps, "deployment-manager-singleton"); // Create a proxy for other actors to communicate with the singleton var proxyProps = ClusterSingletonProxy.Props( singletonManagerPath: "/user/deployment-manager-singleton", settings: ClusterSingletonProxySettings.Create(_actorSystem) .WithRole(siteRole) .WithSingletonName("deployment-manager")); _actorSystem.ActorOf(proxyProps, "deployment-manager-proxy"); _logger.LogInformation( "Site actors registered. DeploymentManager singleton scoped to role={SiteRole}", siteRole); } }