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.
This commit is contained in:
Joseph Doherty
2026-03-16 20:34:56 -04:00
parent 4896ac8ae9
commit e9e6165914
19 changed files with 1792 additions and 18 deletions

View File

@@ -0,0 +1,22 @@
namespace ScadaLink.Commons.Messages.Instance;
/// <summary>
/// Request to get the current value of an attribute from an Instance Actor.
/// Uses the Ask pattern for system boundaries; Tell pattern is preferred for hot-path.
/// </summary>
public record GetAttributeRequest(
string CorrelationId,
string InstanceUniqueName,
string AttributeName,
DateTimeOffset Timestamp);
/// <summary>
/// Response containing the current value of an attribute.
/// </summary>
public record GetAttributeResponse(
string CorrelationId,
string InstanceUniqueName,
string AttributeName,
object? Value,
bool Found,
DateTimeOffset Timestamp);

View File

@@ -0,0 +1,23 @@
namespace ScadaLink.Commons.Messages.Instance;
/// <summary>
/// Command to set a static attribute value on an Instance Actor.
/// Updates in-memory state and persists the override to SQLite.
/// </summary>
public record SetStaticAttributeCommand(
string CorrelationId,
string InstanceUniqueName,
string AttributeName,
string Value,
DateTimeOffset Timestamp);
/// <summary>
/// Response confirming that a static attribute was set.
/// </summary>
public record SetStaticAttributeResponse(
string CorrelationId,
string InstanceUniqueName,
string AttributeName,
bool Success,
string? ErrorMessage,
DateTimeOffset Timestamp);

View File

@@ -1,8 +1,13 @@
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;
@@ -41,6 +46,10 @@ public class AkkaHostedService : IHostedService
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 {{
@@ -54,7 +63,7 @@ akka {{
}}
cluster {{
seed-nodes = [{seedNodesStr}]
roles = [""{_nodeOptions.Role}""]
roles = [{rolesStr}]
min-nr-of-members = {_clusterOptions.MinNrOfMembers}
split-brain-resolver {{
active-strategy = {_clusterOptions.SplitBrainResolverStrategy}
@@ -78,8 +87,9 @@ akka {{
_actorSystem = ActorSystem.Create("scadalink", config);
_logger.LogInformation(
"Akka.NET actor system 'scadalink' started. Role={Role}, Hostname={Hostname}, Port={Port}",
"Akka.NET actor system 'scadalink' started. Role={Role}, Roles={Roles}, Hostname={Hostname}, Port={Port}",
_nodeOptions.Role,
string.Join(", ", roles),
_nodeOptions.NodeHostname,
_nodeOptions.RemotingPort);
@@ -90,6 +100,12 @@ akka {{
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;
}
@@ -103,4 +119,62 @@ akka {{
_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);
}
}

View File

@@ -136,8 +136,9 @@ try
services.AddExternalSystemGateway();
services.AddNotificationService();
// Site-only components
services.AddSiteRuntime();
// Site-only components — AddSiteRuntime registers SiteStorageService with SQLite path
var siteDbPath = context.Configuration["ScadaLink:Database:SiteDbPath"] ?? "site.db";
services.AddSiteRuntime($"Data Source={siteDbPath}");
services.AddDataConnectionLayer();
services.AddStoreAndForward();
services.AddSiteEventLogging();
@@ -148,6 +149,7 @@ try
// Options binding
BindSharedOptions(services, context.Configuration);
services.Configure<SiteRuntimeOptions>(context.Configuration.GetSection("ScadaLink:SiteRuntime"));
services.Configure<DataConnectionOptions>(context.Configuration.GetSection("ScadaLink:DataConnection"));
services.Configure<StoreAndForwardOptions>(context.Configuration.GetSection("ScadaLink:StoreAndForward"));
services.Configure<SiteEventLogOptions>(context.Configuration.GetSection("ScadaLink:SiteEventLog"));

View File

@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Akka.Cluster.Hosting" Version="1.5.62" />
<PackageReference Include="Akka.Cluster.Tools" Version="1.5.62" />
<PackageReference Include="Akka.Hosting" Version="1.5.62" />
<PackageReference Include="Akka.Remote.Hosting" Version="1.5.62" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />

View File

@@ -0,0 +1,358 @@
using Akka.Actor;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Messages.Deployment;
using ScadaLink.Commons.Messages.Lifecycle;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.SiteRuntime.Persistence;
namespace ScadaLink.SiteRuntime.Actors;
/// <summary>
/// Site-side Deployment Manager — runs as a cluster singleton within the site cluster.
/// On startup, reads all deployed configs from SQLite and creates Instance Actors
/// for enabled instances in staggered batches.
///
/// Handles: DeployInstance, DisableInstance, EnableInstance, DeleteInstance.
///
/// Supervision strategy: OneForOneStrategy with Resume for Instance Actors
/// so that a single instance failure does not cascade to siblings.
/// </summary>
public class DeploymentManagerActor : ReceiveActor, IWithTimers
{
private readonly SiteStorageService _storage;
private readonly SiteRuntimeOptions _options;
private readonly ILogger<DeploymentManagerActor> _logger;
private readonly Dictionary<string, IActorRef> _instanceActors = new();
public ITimerScheduler Timers { get; set; } = null!;
public DeploymentManagerActor(
SiteStorageService storage,
SiteRuntimeOptions options,
ILogger<DeploymentManagerActor> logger)
{
_storage = storage;
_options = options;
_logger = logger;
// Lifecycle commands
Receive<DeployInstanceCommand>(HandleDeploy);
Receive<DisableInstanceCommand>(HandleDisable);
Receive<EnableInstanceCommand>(HandleEnable);
Receive<DeleteInstanceCommand>(HandleDelete);
// Internal startup messages
Receive<StartupConfigsLoaded>(HandleStartupConfigsLoaded);
Receive<StartNextBatch>(HandleStartNextBatch);
// Internal enable result
Receive<EnableResult>(HandleEnableResult);
// Internal deploy persistence result
Receive<DeployPersistenceResult>(HandleDeployPersistenceResult);
}
protected override void PreStart()
{
base.PreStart();
_logger.LogInformation("DeploymentManagerActor starting — loading deployed configs from SQLite...");
// Load all configs asynchronously and pipe to self
_storage.GetAllDeployedConfigsAsync().ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
return new StartupConfigsLoaded(t.Result, null);
return new StartupConfigsLoaded([], t.Exception?.GetBaseException().Message);
}).PipeTo(Self);
}
/// <summary>
/// OneForOneStrategy: Resume on exceptions to preserve Instance Actor state,
/// Stop only on ActorInitializationException (actor failed to start).
/// </summary>
protected override SupervisorStrategy SupervisorStrategy()
{
return new OneForOneStrategy(
maxNrOfRetries: -1,
withinTimeRange: TimeSpan.FromMinutes(1),
decider: Decider.From(ex =>
{
if (ex is ActorInitializationException)
{
_logger.LogError(ex, "Instance Actor failed to initialize, stopping");
return Directive.Stop;
}
_logger.LogWarning(ex, "Instance Actor threw exception, resuming");
return Directive.Resume;
}));
}
/// <summary>
/// Processes the loaded configs from SQLite and begins staggered Instance Actor creation.
/// </summary>
private void HandleStartupConfigsLoaded(StartupConfigsLoaded msg)
{
if (msg.Error != null)
{
_logger.LogError("Failed to load deployed configs: {Error}", msg.Error);
return;
}
var enabledConfigs = msg.Configs.Where(c => c.IsEnabled).ToList();
_logger.LogInformation(
"Loaded {Total} deployed configs ({Enabled} enabled) from SQLite",
msg.Configs.Count, enabledConfigs.Count);
if (enabledConfigs.Count == 0)
return;
// Start the first batch immediately
var batchState = new BatchState(enabledConfigs, 0);
Self.Tell(new StartNextBatch(batchState));
}
/// <summary>
/// Creates Instance Actors in batches with a configurable delay between batches
/// to prevent reconnection storms on failover.
/// </summary>
private void HandleStartNextBatch(StartNextBatch msg)
{
var state = msg.State;
var batchSize = _options.StartupBatchSize;
var startIdx = state.NextIndex;
var endIdx = Math.Min(startIdx + batchSize, state.Configs.Count);
_logger.LogDebug(
"Creating Instance Actors batch [{Start}..{End}) of {Total}",
startIdx, endIdx, state.Configs.Count);
for (var i = startIdx; i < endIdx; i++)
{
var config = state.Configs[i];
CreateInstanceActor(config.InstanceUniqueName, config.ConfigJson);
}
// Schedule next batch if there are more, using Timers (IWithTimers)
if (endIdx < state.Configs.Count)
{
var nextState = new BatchState(state.Configs, endIdx);
Timers.StartSingleTimer(
"startup-batch",
new StartNextBatch(nextState),
TimeSpan.FromMilliseconds(_options.StartupBatchDelayMs));
}
else
{
_logger.LogInformation(
"All {Count} Instance Actors created", state.Configs.Count);
}
}
/// <summary>
/// Handles a new deployment: stores config in SQLite, clears previous static overrides,
/// and creates or replaces the Instance Actor.
/// </summary>
private void HandleDeploy(DeployInstanceCommand command)
{
var instanceName = command.InstanceUniqueName;
_logger.LogInformation(
"Deploying instance {Instance}, deploymentId={DeploymentId}",
instanceName, command.DeploymentId);
// Stop existing actor if present (redeployment replaces)
if (_instanceActors.TryGetValue(instanceName, out var existing))
{
Context.Stop(existing);
_instanceActors.Remove(instanceName);
}
// Create the Instance Actor immediately
CreateInstanceActor(instanceName, command.FlattenedConfigurationJson);
// Persist to SQLite and clear static overrides asynchronously
var sender = Sender;
Task.Run(async () =>
{
await _storage.StoreDeployedConfigAsync(
instanceName,
command.FlattenedConfigurationJson,
command.DeploymentId,
command.RevisionHash,
isEnabled: true);
// Static overrides are reset on redeployment per design decision
await _storage.ClearStaticOverridesAsync(instanceName);
return new DeployPersistenceResult(command.DeploymentId, instanceName, true, null, sender);
}).ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
return t.Result;
return new DeployPersistenceResult(
command.DeploymentId, instanceName, false,
t.Exception?.GetBaseException().Message, sender);
}).PipeTo(Self);
// Reply immediately — deployment is applied (actor is running)
sender.Tell(new DeploymentStatusResponse(
command.DeploymentId,
instanceName,
DeploymentStatus.Success,
null,
DateTimeOffset.UtcNow));
}
private void HandleDeployPersistenceResult(DeployPersistenceResult result)
{
if (!result.Success)
{
_logger.LogError(
"Failed to persist deployment {DeploymentId} for {Instance}: {Error}",
result.DeploymentId, result.InstanceName, result.Error);
}
}
/// <summary>
/// Disables an instance: stops the actor and marks as disabled in SQLite.
/// </summary>
private void HandleDisable(DisableInstanceCommand command)
{
var instanceName = command.InstanceUniqueName;
if (_instanceActors.TryGetValue(instanceName, out var actor))
{
Context.Stop(actor);
_instanceActors.Remove(instanceName);
}
var sender = Sender;
_storage.SetInstanceEnabledAsync(instanceName, false).ContinueWith(t =>
{
return new InstanceLifecycleResponse(
command.CommandId,
instanceName,
t.IsCompletedSuccessfully,
t.Exception?.GetBaseException().Message,
DateTimeOffset.UtcNow);
}).PipeTo(sender);
_logger.LogInformation("Instance {Instance} disabled", instanceName);
}
/// <summary>
/// Enables an instance: marks as enabled in SQLite and re-creates the Instance Actor
/// from the stored config.
/// </summary>
private void HandleEnable(EnableInstanceCommand command)
{
var instanceName = command.InstanceUniqueName;
var sender = Sender;
Task.Run(async () =>
{
await _storage.SetInstanceEnabledAsync(instanceName, true);
var configs = await _storage.GetAllDeployedConfigsAsync();
var config = configs.FirstOrDefault(c => c.InstanceUniqueName == instanceName);
return new EnableResult(command, config, null, sender);
}).ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
return t.Result;
return new EnableResult(command, null, t.Exception?.GetBaseException().Message, sender);
}).PipeTo(Self);
}
/// <summary>
/// Processes enable result in the actor context (thread-safe).
/// </summary>
private void HandleEnableResult(EnableResult result)
{
var instanceName = result.Command.InstanceUniqueName;
if (result.Error != null || result.Config == null)
{
var error = result.Error ?? $"No deployed config found for {instanceName}";
result.OriginalSender.Tell(new InstanceLifecycleResponse(
result.Command.CommandId, instanceName, false, error, DateTimeOffset.UtcNow));
return;
}
if (!_instanceActors.ContainsKey(instanceName))
{
CreateInstanceActor(instanceName, result.Config.ConfigJson);
}
result.OriginalSender.Tell(new InstanceLifecycleResponse(
result.Command.CommandId, instanceName, true, null, DateTimeOffset.UtcNow));
_logger.LogInformation("Instance {Instance} enabled", instanceName);
}
/// <summary>
/// Deletes an instance: stops the actor and removes config from SQLite.
/// Note: store-and-forward messages are NOT cleared per design decision.
/// </summary>
private void HandleDelete(DeleteInstanceCommand command)
{
var instanceName = command.InstanceUniqueName;
if (_instanceActors.TryGetValue(instanceName, out var actor))
{
Context.Stop(actor);
_instanceActors.Remove(instanceName);
}
var sender = Sender;
_storage.RemoveDeployedConfigAsync(instanceName).ContinueWith(t =>
{
return new InstanceLifecycleResponse(
command.CommandId,
instanceName,
t.IsCompletedSuccessfully,
t.Exception?.GetBaseException().Message,
DateTimeOffset.UtcNow);
}).PipeTo(sender);
_logger.LogInformation("Instance {Instance} deleted", instanceName);
}
/// <summary>
/// Creates a child Instance Actor with the given name and configuration JSON.
/// </summary>
internal void CreateInstanceActor(string instanceName, string configJson)
{
if (_instanceActors.ContainsKey(instanceName))
{
_logger.LogWarning("Instance Actor {Instance} already exists, skipping creation", instanceName);
return;
}
var loggerFactory = new LoggerFactory();
var props = Props.Create(() => new InstanceActor(
instanceName,
configJson,
_storage,
loggerFactory.CreateLogger<InstanceActor>()));
var actorRef = Context.ActorOf(props, instanceName);
_instanceActors[instanceName] = actorRef;
_logger.LogDebug("Created Instance Actor for {Instance}", instanceName);
}
/// <summary>
/// Gets the count of active Instance Actors (for testing/diagnostics).
/// </summary>
internal int InstanceActorCount => _instanceActors.Count;
// ── Internal messages ──
internal record StartupConfigsLoaded(List<DeployedInstance> Configs, string? Error);
internal record StartNextBatch(BatchState State);
internal record BatchState(List<DeployedInstance> Configs, int NextIndex);
internal record EnableResult(
EnableInstanceCommand Command, DeployedInstance? Config, string? Error, IActorRef OriginalSender);
internal record DeployPersistenceResult(
string DeploymentId, string InstanceName, bool Success, string? Error, IActorRef OriginalSender);
}

View File

@@ -0,0 +1,166 @@
using Akka.Actor;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Messages.Instance;
using ScadaLink.Commons.Messages.Lifecycle;
using ScadaLink.Commons.Types.Flattening;
using ScadaLink.SiteRuntime.Persistence;
using System.Text.Json;
namespace ScadaLink.SiteRuntime.Actors;
/// <summary>
/// Represents a single deployed instance at runtime. Holds the in-memory attribute state
/// (loaded from FlattenedConfiguration + static overrides from SQLite).
///
/// The Instance Actor is the single source of truth for runtime instance state.
/// All state mutations are serialized through the actor mailbox.
/// </summary>
public class InstanceActor : ReceiveActor
{
private readonly string _instanceUniqueName;
private readonly SiteStorageService _storage;
private readonly ILogger _logger;
private readonly Dictionary<string, object?> _attributes = new();
private FlattenedConfiguration? _configuration;
public InstanceActor(
string instanceUniqueName,
string configJson,
SiteStorageService storage,
ILogger logger)
{
_instanceUniqueName = instanceUniqueName;
_storage = storage;
_logger = logger;
// Deserialize the flattened configuration
_configuration = JsonSerializer.Deserialize<FlattenedConfiguration>(configJson);
// Load default attribute values from the flattened configuration
if (_configuration != null)
{
foreach (var attr in _configuration.Attributes)
{
_attributes[attr.CanonicalName] = attr.Value;
}
}
// Handle attribute queries (Tell pattern — sender gets response)
Receive<GetAttributeRequest>(HandleGetAttribute);
// Handle static attribute writes
Receive<SetStaticAttributeCommand>(HandleSetStaticAttribute);
// Handle lifecycle messages
Receive<DisableInstanceCommand>(_ =>
{
_logger.LogInformation("Instance {Instance} received disable command", _instanceUniqueName);
// Disable handled by parent DeploymentManagerActor
Sender.Tell(new InstanceLifecycleResponse(
_.CommandId, _instanceUniqueName, true, null, DateTimeOffset.UtcNow));
});
Receive<EnableInstanceCommand>(_ =>
{
_logger.LogInformation("Instance {Instance} received enable command", _instanceUniqueName);
Sender.Tell(new InstanceLifecycleResponse(
_.CommandId, _instanceUniqueName, true, null, DateTimeOffset.UtcNow));
});
// Handle internal messages
Receive<LoadOverridesResult>(HandleOverridesLoaded);
}
protected override void PreStart()
{
base.PreStart();
_logger.LogInformation("InstanceActor started for {Instance}", _instanceUniqueName);
// Asynchronously load static overrides from SQLite and pipe to self
var self = Self;
_storage.GetStaticOverridesAsync(_instanceUniqueName).ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
return new LoadOverridesResult(t.Result, null);
return new LoadOverridesResult(new Dictionary<string, string>(), t.Exception?.GetBaseException().Message);
}).PipeTo(self);
}
/// <summary>
/// Returns the current attribute value. Uses Tell pattern; sender gets the response.
/// </summary>
private void HandleGetAttribute(GetAttributeRequest request)
{
var found = _attributes.TryGetValue(request.AttributeName, out var value);
Sender.Tell(new GetAttributeResponse(
request.CorrelationId,
_instanceUniqueName,
request.AttributeName,
value,
found,
DateTimeOffset.UtcNow));
}
/// <summary>
/// Updates a static attribute in memory and persists the override to SQLite.
/// </summary>
private void HandleSetStaticAttribute(SetStaticAttributeCommand command)
{
_attributes[command.AttributeName] = command.Value;
// Persist asynchronously — fire and forget since the actor is the source of truth
var self = Self;
var sender = Sender;
_storage.SetStaticOverrideAsync(_instanceUniqueName, command.AttributeName, command.Value)
.ContinueWith(t =>
{
var success = t.IsCompletedSuccessfully;
var error = t.Exception?.GetBaseException().Message;
if (!success)
{
// Value is already in memory; log the persistence failure
// In-memory state is authoritative
}
return new SetStaticAttributeResponse(
command.CorrelationId,
_instanceUniqueName,
command.AttributeName,
success,
error,
DateTimeOffset.UtcNow);
}).PipeTo(sender);
}
/// <summary>
/// Applies static overrides loaded from SQLite on top of default values.
/// </summary>
private void HandleOverridesLoaded(LoadOverridesResult result)
{
if (result.Error != null)
{
_logger.LogWarning(
"Failed to load static overrides for {Instance}: {Error}",
_instanceUniqueName, result.Error);
return;
}
foreach (var kvp in result.Overrides)
{
_attributes[kvp.Key] = kvp.Value;
}
_logger.LogDebug(
"Loaded {Count} static overrides for {Instance}",
result.Overrides.Count, _instanceUniqueName);
}
/// <summary>
/// Read-only access to current attribute count (for testing/diagnostics).
/// </summary>
public int AttributeCount => _attributes.Count;
/// <summary>
/// Internal message for async override loading result.
/// </summary>
internal record LoadOverridesResult(Dictionary<string, string> Overrides, string? Error);
}

View File

@@ -0,0 +1,24 @@
using Microsoft.Extensions.Hosting;
namespace ScadaLink.SiteRuntime.Persistence;
/// <summary>
/// Hosted service that initializes the SQLite schema on startup.
/// Runs before the Akka actor system starts creating actors.
/// </summary>
public class SiteStorageInitializer : IHostedService
{
private readonly SiteStorageService _storage;
public SiteStorageInitializer(SiteStorageService storage)
{
_storage = storage;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
await _storage.InitializeAsync();
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -0,0 +1,257 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
namespace ScadaLink.SiteRuntime.Persistence;
/// <summary>
/// Direct SQLite persistence for site-local deployment state.
/// Stores deployed instance configurations (as JSON) and static attribute overrides.
/// This is NOT EF Core — uses Microsoft.Data.Sqlite directly for lightweight site storage.
/// </summary>
public class SiteStorageService
{
private readonly string _connectionString;
private readonly ILogger<SiteStorageService> _logger;
public SiteStorageService(string connectionString, ILogger<SiteStorageService> logger)
{
_connectionString = connectionString;
_logger = logger;
}
/// <summary>
/// Creates the SQLite tables if they do not exist.
/// Called once on site startup.
/// </summary>
public async Task InitializeAsync()
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
CREATE TABLE IF NOT EXISTS deployed_configurations (
instance_unique_name TEXT PRIMARY KEY,
config_json TEXT NOT NULL,
deployment_id TEXT NOT NULL,
revision_hash TEXT NOT NULL,
is_enabled INTEGER NOT NULL DEFAULT 1,
deployed_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS static_attribute_overrides (
instance_unique_name TEXT NOT NULL,
attribute_name TEXT NOT NULL,
override_value TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (instance_unique_name, attribute_name)
);
";
await command.ExecuteNonQueryAsync();
_logger.LogInformation("Site SQLite storage initialized at {ConnectionString}", _connectionString);
}
// ── Deployed Configuration CRUD ──
/// <summary>
/// Returns all deployed instance configurations from SQLite.
/// </summary>
public async Task<List<DeployedInstance>> GetAllDeployedConfigsAsync()
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
SELECT instance_unique_name, config_json, deployment_id, revision_hash, is_enabled, deployed_at
FROM deployed_configurations";
var results = new List<DeployedInstance>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
results.Add(new DeployedInstance
{
InstanceUniqueName = reader.GetString(0),
ConfigJson = reader.GetString(1),
DeploymentId = reader.GetString(2),
RevisionHash = reader.GetString(3),
IsEnabled = reader.GetInt64(4) != 0,
DeployedAt = reader.GetString(5)
});
}
return results;
}
/// <summary>
/// Stores or updates a deployed instance configuration. Uses UPSERT semantics.
/// </summary>
public async Task StoreDeployedConfigAsync(
string instanceName,
string configJson,
string deploymentId,
string revisionHash,
bool isEnabled)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
INSERT INTO deployed_configurations (instance_unique_name, config_json, deployment_id, revision_hash, is_enabled, deployed_at)
VALUES (@name, @json, @depId, @hash, @enabled, @deployedAt)
ON CONFLICT(instance_unique_name) DO UPDATE SET
config_json = excluded.config_json,
deployment_id = excluded.deployment_id,
revision_hash = excluded.revision_hash,
is_enabled = excluded.is_enabled,
deployed_at = excluded.deployed_at";
command.Parameters.AddWithValue("@name", instanceName);
command.Parameters.AddWithValue("@json", configJson);
command.Parameters.AddWithValue("@depId", deploymentId);
command.Parameters.AddWithValue("@hash", revisionHash);
command.Parameters.AddWithValue("@enabled", isEnabled ? 1 : 0);
command.Parameters.AddWithValue("@deployedAt", DateTimeOffset.UtcNow.ToString("O"));
await command.ExecuteNonQueryAsync();
_logger.LogDebug("Stored deployed config for {Instance}, deploymentId={DeploymentId}", instanceName, deploymentId);
}
/// <summary>
/// Removes a deployed instance configuration and its static overrides.
/// </summary>
public async Task RemoveDeployedConfigAsync(string instanceName)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var transaction = await connection.BeginTransactionAsync();
await using (var cmd = connection.CreateCommand())
{
cmd.Transaction = (SqliteTransaction)transaction;
cmd.CommandText = "DELETE FROM static_attribute_overrides WHERE instance_unique_name = @name";
cmd.Parameters.AddWithValue("@name", instanceName);
await cmd.ExecuteNonQueryAsync();
}
await using (var cmd = connection.CreateCommand())
{
cmd.Transaction = (SqliteTransaction)transaction;
cmd.CommandText = "DELETE FROM deployed_configurations WHERE instance_unique_name = @name";
cmd.Parameters.AddWithValue("@name", instanceName);
await cmd.ExecuteNonQueryAsync();
}
await transaction.CommitAsync();
_logger.LogInformation("Removed deployed config and overrides for {Instance}", instanceName);
}
/// <summary>
/// Sets the enabled/disabled state of a deployed instance.
/// </summary>
public async Task SetInstanceEnabledAsync(string instanceName, bool isEnabled)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
UPDATE deployed_configurations
SET is_enabled = @enabled
WHERE instance_unique_name = @name";
command.Parameters.AddWithValue("@enabled", isEnabled ? 1 : 0);
command.Parameters.AddWithValue("@name", instanceName);
var rows = await command.ExecuteNonQueryAsync();
if (rows == 0)
{
_logger.LogWarning("SetInstanceEnabled: instance {Instance} not found", instanceName);
}
}
// ── Static Attribute Override CRUD ──
/// <summary>
/// Returns all static attribute overrides for an instance.
/// </summary>
public async Task<Dictionary<string, string>> GetStaticOverridesAsync(string instanceName)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
SELECT attribute_name, override_value
FROM static_attribute_overrides
WHERE instance_unique_name = @name";
command.Parameters.AddWithValue("@name", instanceName);
var results = new Dictionary<string, string>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
results[reader.GetString(0)] = reader.GetString(1);
}
return results;
}
/// <summary>
/// Sets or updates a single static attribute override for an instance.
/// </summary>
public async Task SetStaticOverrideAsync(string instanceName, string attributeName, string value)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = @"
INSERT INTO static_attribute_overrides (instance_unique_name, attribute_name, override_value, updated_at)
VALUES (@name, @attr, @val, @updatedAt)
ON CONFLICT(instance_unique_name, attribute_name) DO UPDATE SET
override_value = excluded.override_value,
updated_at = excluded.updated_at";
command.Parameters.AddWithValue("@name", instanceName);
command.Parameters.AddWithValue("@attr", attributeName);
command.Parameters.AddWithValue("@val", value);
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Clears all static attribute overrides for an instance.
/// Called on redeployment to reset overrides.
/// </summary>
public async Task ClearStaticOverridesAsync(string instanceName)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = "DELETE FROM static_attribute_overrides WHERE instance_unique_name = @name";
command.Parameters.AddWithValue("@name", instanceName);
await command.ExecuteNonQueryAsync();
_logger.LogDebug("Cleared static overrides for {Instance}", instanceName);
}
}
/// <summary>
/// Represents a deployed instance configuration as stored in SQLite.
/// </summary>
public class DeployedInstance
{
public string InstanceUniqueName { get; init; } = string.Empty;
public string ConfigJson { get; init; } = string.Empty;
public string DeploymentId { get; init; } = string.Empty;
public string RevisionHash { get; init; } = string.Empty;
public bool IsEnabled { get; init; }
public string DeployedAt { get; init; } = string.Empty;
}

View File

@@ -8,7 +8,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Akka" Version="1.5.62" />
<PackageReference Include="Akka.Cluster" Version="1.5.62" />
<PackageReference Include="Akka.Cluster.Tools" Version="1.5.62" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
</ItemGroup>

View File

@@ -1,18 +1,43 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ScadaLink.SiteRuntime.Persistence;
namespace ScadaLink.SiteRuntime;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers Site Runtime services including SiteStorageService for SQLite persistence.
/// The caller must register an <see cref="ISiteStorageConnectionProvider"/> or call the
/// overload with an explicit connection string.
/// </summary>
public static IServiceCollection AddSiteRuntime(this IServiceCollection services)
{
// Phase 0: skeleton only
// SiteStorageService is registered by the Host using AddSiteRuntime(connectionString)
// This overload is for backward compatibility / skeleton placeholder
return services;
}
/// <summary>
/// Registers Site Runtime services with an explicit SQLite connection string.
/// </summary>
public static IServiceCollection AddSiteRuntime(this IServiceCollection services, string siteDbConnectionString)
{
services.AddSingleton(sp =>
{
var logger = sp.GetRequiredService<ILogger<SiteStorageService>>();
return new SiteStorageService(siteDbConnectionString, logger);
});
services.AddHostedService<SiteStorageInitializer>();
return services;
}
public static IServiceCollection AddSiteRuntimeActors(this IServiceCollection services)
{
// Phase 0: placeholder for Akka actor registration
// Actor registration is handled by AkkaHostedService.RegisterSiteActors()
// which creates the DeploymentManager singleton and proxy
return services;
}
}

View File

@@ -0,0 +1,20 @@
namespace ScadaLink.SiteRuntime;
/// <summary>
/// Configuration options for the Site Runtime component.
/// Bound from ScadaLink:SiteRuntime configuration section.
/// </summary>
public class SiteRuntimeOptions
{
/// <summary>
/// Number of Instance Actors to create per batch during staggered startup.
/// Default: 20.
/// </summary>
public int StartupBatchSize { get; set; } = 20;
/// <summary>
/// Delay in milliseconds between startup batches to prevent reconnection storms.
/// Default: 100ms.
/// </summary>
public int StartupBatchDelayMs { get; set; } = 100;
}