Files
scadalink-design/src/ScadaLink.Host/Actors/AkkaHostedService.cs
Joseph Doherty e9e6165914 Phase 3A: Site runtime foundation — Akka cluster, SQLite persistence, Deployment Manager singleton, Instance Actor
- WP-1: Site cluster config (keep-oldest SBR, down-if-alone, 2s/10s failure detection)
- WP-2: Site-role host bootstrap (no Kestrel, SQLite paths)
- WP-3: SiteStorageService with deployed_configurations + static_attribute_overrides tables
- WP-4: DeploymentManagerActor as cluster singleton with staggered Instance Actor creation,
  OneForOneStrategy/Resume supervision, deploy/disable/enable/delete lifecycle
- WP-5: InstanceActor with attribute state, GetAttribute/SetAttribute, SQLite override persistence
- WP-6: CoordinatedShutdown verified for graceful singleton handover
- WP-7: Dual-node recovery (both seed nodes, min-nr-of-members=1)
- WP-8: 31 tests (storage CRUD, actor lifecycle, supervision, negative checks)
389 total tests pass, zero warnings.
2026-03-16 20:34:56 -04:00

181 lines
6.6 KiB
C#

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;
/// <summary>
/// Hosted service that manages the Akka.NET actor system lifecycle.
/// Creates the actor system on start, registers actors, and triggers
/// CoordinatedShutdown on stop.
/// </summary>
public class AkkaHostedService : IHostedService
{
private readonly IServiceProvider _serviceProvider;
private readonly NodeOptions _nodeOptions;
private readonly ClusterOptions _clusterOptions;
private readonly ILogger<AkkaHostedService> _logger;
private ActorSystem? _actorSystem;
public AkkaHostedService(
IServiceProvider serviceProvider,
IOptions<NodeOptions> nodeOptions,
IOptions<ClusterOptions> clusterOptions,
ILogger<AkkaHostedService> logger)
{
_serviceProvider = serviceProvider;
_nodeOptions = nodeOptions.Value;
_clusterOptions = clusterOptions.Value;
_logger = logger;
}
/// <summary>
/// Gets the actor system once started. Null before StartAsync completes.
/// </summary>
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<ILoggerFactory>();
var dlmLogger = loggerFactory.CreateLogger<DeadLetterMonitorActor>();
_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.");
}
}
/// <summary>
/// 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.
/// </summary>
private List<string> BuildRoles()
{
var roles = new List<string> { _nodeOptions.Role };
if (_nodeOptions.Role.Equals("Site", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrEmpty(_nodeOptions.SiteId))
{
roles.Add($"site-{_nodeOptions.SiteId}");
}
return roles;
}
/// <summary>
/// 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.
/// </summary>
private void RegisterSiteActors()
{
var siteRole = $"site-{_nodeOptions.SiteId}";
var storage = _serviceProvider.GetRequiredService<SiteStorageService>();
var siteRuntimeOptionsValue = _serviceProvider.GetService<IOptions<SiteRuntimeOptions>>()?.Value
?? new SiteRuntimeOptions();
var dmLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger<DeploymentManagerActor>();
// 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);
}
}