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:
257
src/ScadaLink.SiteRuntime/Persistence/SiteStorageService.cs
Normal file
257
src/ScadaLink.SiteRuntime/Persistence/SiteStorageService.cs
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user