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:
166
src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs
Normal file
166
src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user