feat: wire SQLite replication between site nodes and fix ConfigurationDatabase tests

Add SiteReplicationActor (runs on every site node) to replicate deployed
configs and store-and-forward buffer operations to the standby peer via
cluster member discovery and fire-and-forget Tell. Wire ReplicationService
handler and pass replication actor to DeploymentManagerActor singleton.

Fix 5 pre-existing ConfigurationDatabase test failures: RowVersion NOT NULL
on SQLite, stale migration name assertion, and seed data count mismatch.
This commit is contained in:
Joseph Doherty
2026-03-18 08:28:02 -04:00
parent f063fb1ca3
commit eb8ead58d2
23 changed files with 707 additions and 33 deletions

View File

@@ -15,6 +15,7 @@ public static class ExternalSystemCommands
command.Add(BuildCreate(contactPointsOption, formatOption));
command.Add(BuildUpdate(contactPointsOption, formatOption));
command.Add(BuildDelete(contactPointsOption, formatOption));
command.Add(BuildMethodGroup(contactPointsOption, formatOption));
return command;
}
@@ -110,4 +111,121 @@ public static class ExternalSystemCommands
});
return cmd;
}
// ── Method subcommands ──
private static Command BuildMethodGroup(Option<string> contactPointsOption, Option<string> formatOption)
{
var group = new Command("method") { Description = "Manage external system methods" };
group.Add(BuildMethodList(contactPointsOption, formatOption));
group.Add(BuildMethodGet(contactPointsOption, formatOption));
group.Add(BuildMethodCreate(contactPointsOption, formatOption));
group.Add(BuildMethodUpdate(contactPointsOption, formatOption));
group.Add(BuildMethodDelete(contactPointsOption, formatOption));
return group;
}
private static Command BuildMethodList(Option<string> contactPointsOption, Option<string> formatOption)
{
var sysIdOption = new Option<int>("--external-system-id") { Description = "External system ID", Required = true };
var cmd = new Command("list") { Description = "List methods for an external system" };
cmd.Add(sysIdOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, contactPointsOption, formatOption,
new ListExternalSystemMethodsCommand(result.GetValue(sysIdOption)));
});
return cmd;
}
private static Command BuildMethodGet(Option<string> contactPointsOption, Option<string> formatOption)
{
var idOption = new Option<int>("--id") { Description = "Method ID", Required = true };
var cmd = new Command("get") { Description = "Get an external system method by ID" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, contactPointsOption, formatOption,
new GetExternalSystemMethodCommand(result.GetValue(idOption)));
});
return cmd;
}
private static Command BuildMethodCreate(Option<string> contactPointsOption, Option<string> formatOption)
{
var sysIdOption = new Option<int>("--external-system-id") { Description = "External system ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Method name", Required = true };
var httpMethodOption = new Option<string>("--http-method") { Description = "HTTP method (GET, POST, PUT, DELETE)", Required = true };
var pathOption = new Option<string>("--path") { Description = "URL path (e.g. /api/Add)", Required = true };
var paramsOption = new Option<string?>("--params") { Description = "Parameter definitions JSON" };
var returnOption = new Option<string?>("--return") { Description = "Return definition JSON" };
var cmd = new Command("create") { Description = "Create an external system method" };
cmd.Add(sysIdOption);
cmd.Add(nameOption);
cmd.Add(httpMethodOption);
cmd.Add(pathOption);
cmd.Add(paramsOption);
cmd.Add(returnOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, contactPointsOption, formatOption,
new CreateExternalSystemMethodCommand(
result.GetValue(sysIdOption),
result.GetValue(nameOption)!,
result.GetValue(httpMethodOption)!,
result.GetValue(pathOption)!,
result.GetValue(paramsOption),
result.GetValue(returnOption)));
});
return cmd;
}
private static Command BuildMethodUpdate(Option<string> contactPointsOption, Option<string> formatOption)
{
var idOption = new Option<int>("--id") { Description = "Method ID", Required = true };
var nameOption = new Option<string?>("--name") { Description = "Method name" };
var httpMethodOption = new Option<string?>("--http-method") { Description = "HTTP method" };
var pathOption = new Option<string?>("--path") { Description = "URL path" };
var paramsOption = new Option<string?>("--params") { Description = "Parameter definitions JSON" };
var returnOption = new Option<string?>("--return") { Description = "Return definition JSON" };
var cmd = new Command("update") { Description = "Update an external system method" };
cmd.Add(idOption);
cmd.Add(nameOption);
cmd.Add(httpMethodOption);
cmd.Add(pathOption);
cmd.Add(paramsOption);
cmd.Add(returnOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, contactPointsOption, formatOption,
new UpdateExternalSystemMethodCommand(
result.GetValue(idOption),
result.GetValue(nameOption),
result.GetValue(httpMethodOption),
result.GetValue(pathOption),
result.GetValue(paramsOption),
result.GetValue(returnOption)));
});
return cmd;
}
private static Command BuildMethodDelete(Option<string> contactPointsOption, Option<string> formatOption)
{
var idOption = new Option<int>("--id") { Description = "Method ID", Required = true };
var cmd = new Command("delete") { Description = "Delete an external system method" };
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, contactPointsOption, formatOption,
new DeleteExternalSystemMethodCommand(result.GetValue(idOption)));
});
return cmd;
}
}

View File

@@ -300,6 +300,9 @@ public static class TemplateCommands
var lockedOption = new Option<bool>("--locked") { Description = "Lock status" };
lockedOption.DefaultValueFactory = _ => false;
var paramsOption = new Option<string?>("--parameters") { Description = "Parameter definitions JSON" };
var returnOption = new Option<string?>("--return-def") { Description = "Return definition JSON" };
var addCmd = new Command("add") { Description = "Add a script to a template" };
addCmd.Add(templateIdOption);
addCmd.Add(nameOption);
@@ -307,6 +310,8 @@ public static class TemplateCommands
addCmd.Add(triggerTypeOption);
addCmd.Add(triggerConfigOption);
addCmd.Add(lockedOption);
addCmd.Add(paramsOption);
addCmd.Add(returnOption);
addCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
@@ -317,7 +322,9 @@ public static class TemplateCommands
result.GetValue(codeOption)!,
result.GetValue(triggerTypeOption)!,
result.GetValue(triggerConfigOption),
result.GetValue(lockedOption)));
result.GetValue(lockedOption),
result.GetValue(paramsOption),
result.GetValue(returnOption)));
});
group.Add(addCmd);
@@ -329,6 +336,9 @@ public static class TemplateCommands
var updateLockedOption = new Option<bool>("--locked") { Description = "Lock status" };
updateLockedOption.DefaultValueFactory = _ => false;
var updateParamsOption = new Option<string?>("--parameters") { Description = "Parameter definitions JSON" };
var updateReturnOption = new Option<string?>("--return-def") { Description = "Return definition JSON" };
var updateCmd = new Command("update") { Description = "Update a template script" };
updateCmd.Add(updateIdOption);
updateCmd.Add(updateNameOption);
@@ -336,6 +346,8 @@ public static class TemplateCommands
updateCmd.Add(updateTriggerTypeOption);
updateCmd.Add(updateTriggerConfigOption);
updateCmd.Add(updateLockedOption);
updateCmd.Add(updateParamsOption);
updateCmd.Add(updateReturnOption);
updateCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
@@ -346,7 +358,9 @@ public static class TemplateCommands
result.GetValue(updateCodeOption)!,
result.GetValue(updateTriggerTypeOption)!,
result.GetValue(updateTriggerConfigOption),
result.GetValue(updateLockedOption)));
result.GetValue(updateLockedOption),
result.GetValue(updateParamsOption),
result.GetValue(updateReturnOption)));
});
group.Add(updateCmd);

View File

@@ -11,15 +11,11 @@
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository
@inject CommunicationService CommunicationService
@inject IJSRuntime JS
@implements IDisposable
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Debug View</h4>
<div class="alert alert-info py-1 px-2 mb-0 small">
Debug view streams are lost on failover. Re-open if connection drops.
</div>
</div>
<h4 class="mb-3">Debug View</h4>
<ToastNotification @ref="_toast" />
@@ -182,6 +178,24 @@
_loading = false;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
var storedSiteId = await JS.InvokeAsync<string>("localStorage.getItem", "debugView.siteId");
var storedInstanceName = await JS.InvokeAsync<string>("localStorage.getItem", "debugView.instanceName");
if (!string.IsNullOrEmpty(storedSiteId) && int.TryParse(storedSiteId, out var siteId)
&& !string.IsNullOrEmpty(storedInstanceName))
{
_selectedSiteId = siteId;
await LoadInstancesForSite();
_selectedInstanceName = storedInstanceName;
StateHasChanged();
await Connect();
}
}
private async Task LoadInstancesForSite()
{
_siteInstances.Clear();
@@ -224,6 +238,12 @@
}
_connected = true;
// Persist selection to localStorage for auto-reconnect on refresh
await JS.InvokeVoidAsync("localStorage.setItem", "debugView.siteId", _selectedSiteId.ToString());
await JS.InvokeVoidAsync("localStorage.setItem", "debugView.instanceName", _selectedInstanceName);
await JS.InvokeVoidAsync("localStorage.setItem", "debugView.siteIdentifier", site.SiteIdentifier);
_toast.ShowSuccess($"Connected to {_selectedInstanceName}");
// Periodic refresh (simulating SignalR push by re-subscribing)
@@ -253,7 +273,7 @@
_connecting = false;
}
private void Disconnect()
private async Task Disconnect()
{
_refreshTimer?.Dispose();
_refreshTimer = null;
@@ -268,6 +288,11 @@
}
}
// Clear persisted selection — user explicitly disconnected
await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.siteId");
await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.instanceName");
await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.siteIdentifier");
_connected = false;
_snapshot = null;
_attributeValues.Clear();

View File

@@ -4,6 +4,7 @@
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using ScadaLink.CentralUI
@using ScadaLink.CentralUI.Components.Layout

View File

@@ -1,3 +1,5 @@
using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Interfaces.Services;
/// <summary>
@@ -34,4 +36,27 @@ public record ExternalCallResult(
bool Success,
string? ResponseJson,
string? ErrorMessage,
bool WasBuffered = false);
bool WasBuffered = false)
{
private dynamic? _response;
private bool _responseParsed;
/// <summary>
/// Parsed response as a dynamic object. Returns null if ResponseJson is null or empty.
/// Access properties directly: result.Response.result, result.Response.items[0].name, etc.
/// </summary>
public dynamic? Response
{
get
{
if (!_responseParsed)
{
_response = string.IsNullOrEmpty(ResponseJson)
? null
: new DynamicJsonElement(System.Text.Json.JsonDocument.Parse(ResponseJson).RootElement);
_responseParsed = true;
}
return _response;
}
}
}

View File

@@ -5,3 +5,10 @@ public record GetExternalSystemCommand(int ExternalSystemId);
public record CreateExternalSystemCommand(string Name, string EndpointUrl, string AuthType, string? AuthConfiguration);
public record UpdateExternalSystemCommand(int ExternalSystemId, string Name, string EndpointUrl, string AuthType, string? AuthConfiguration);
public record DeleteExternalSystemCommand(int ExternalSystemId);
// External System Methods
public record ListExternalSystemMethodsCommand(int ExternalSystemId);
public record GetExternalSystemMethodCommand(int MethodId);
public record CreateExternalSystemMethodCommand(int ExternalSystemId, string Name, string HttpMethod, string Path, string? ParameterDefinitions = null, string? ReturnDefinition = null);
public record UpdateExternalSystemMethodCommand(int MethodId, string? Name = null, string? HttpMethod = null, string? Path = null, string? ParameterDefinitions = null, string? ReturnDefinition = null);
public record DeleteExternalSystemMethodCommand(int MethodId);

View File

@@ -14,8 +14,8 @@ public record DeleteTemplateAttributeCommand(int AttributeId);
public record AddTemplateAlarmCommand(int TemplateId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked);
public record UpdateTemplateAlarmCommand(int AlarmId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked);
public record DeleteTemplateAlarmCommand(int AlarmId);
public record AddTemplateScriptCommand(int TemplateId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked);
public record UpdateTemplateScriptCommand(int ScriptId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked);
public record AddTemplateScriptCommand(int TemplateId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked, string? ParameterDefinitions = null, string? ReturnDefinition = null);
public record UpdateTemplateScriptCommand(int ScriptId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked, string? ParameterDefinitions = null, string? ReturnDefinition = null);
public record DeleteTemplateScriptCommand(int ScriptId);
public record AddTemplateCompositionCommand(int TemplateId, string InstanceName, int ComposedTemplateId);
public record DeleteTemplateCompositionCommand(int CompositionId);

View File

@@ -0,0 +1,92 @@
using System.Dynamic;
using System.Text.Json;
namespace ScadaLink.Commons.Types;
/// <summary>
/// Wraps a JsonElement as a dynamic object for convenient property access in scripts.
/// Supports property access (obj.name), indexing (obj.items[0]), and ToString().
/// </summary>
public class DynamicJsonElement : DynamicObject
{
private readonly JsonElement _element;
public DynamicJsonElement(JsonElement element)
{
_element = element;
}
public override bool TryGetMember(GetMemberBinder binder, out object? result)
{
if (_element.ValueKind == JsonValueKind.Object &&
_element.TryGetProperty(binder.Name, out var prop))
{
result = Wrap(prop);
return true;
}
result = null;
return false;
}
public override bool TryGetIndex(GetIndexBinder binder, object[] indexes, out object? result)
{
if (_element.ValueKind == JsonValueKind.Array &&
indexes.Length == 1 && indexes[0] is int index)
{
var arrayLength = _element.GetArrayLength();
if (index >= 0 && index < arrayLength)
{
result = Wrap(_element[index]);
return true;
}
}
result = null;
return false;
}
public override bool TryConvert(ConvertBinder binder, out object? result)
{
result = ConvertTo(binder.Type);
return result != null || binder.Type == typeof(object);
}
public override string ToString()
{
return _element.ValueKind switch
{
JsonValueKind.String => _element.GetString() ?? "",
JsonValueKind.Number => _element.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => "",
_ => _element.GetRawText()
};
}
private object? ConvertTo(Type type)
{
if (type == typeof(string)) return ToString();
if (type == typeof(int) && _element.ValueKind == JsonValueKind.Number) return _element.GetInt32();
if (type == typeof(long) && _element.ValueKind == JsonValueKind.Number) return _element.GetInt64();
if (type == typeof(double) && _element.ValueKind == JsonValueKind.Number) return _element.GetDouble();
if (type == typeof(decimal) && _element.ValueKind == JsonValueKind.Number) return _element.GetDecimal();
if (type == typeof(bool) && (_element.ValueKind == JsonValueKind.True || _element.ValueKind == JsonValueKind.False))
return _element.GetBoolean();
return null;
}
private static object? Wrap(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Object => new DynamicJsonElement(element),
JsonValueKind.Array => new DynamicJsonElement(element),
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => element.GetRawText()
};
}
}

View File

@@ -60,5 +60,6 @@ public enum ValidationCategory
ReturnTypeMismatch,
TriggerOperandType,
OnTriggerScriptNotFound,
CrossCallViolation
CrossCallViolation,
MissingMetadata
}

View File

@@ -83,8 +83,18 @@ public class FlatteningPipeline : IFlatteningPipeline
var config = flattenResult.Value;
// Load shared scripts for semantic validation
var sharedScriptEntities = await _templateRepo.GetAllSharedScriptsAsync(cancellationToken);
var resolvedSharedScripts = sharedScriptEntities.Select(s => new ResolvedScript
{
CanonicalName = s.Name,
Code = s.Code,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition
}).ToList();
// Validate
var validation = _validationService.Validate(config);
var validation = _validationService.Validate(config, resolvedSharedScripts);
// Compute revision hash
var hash = _revisionHashService.ComputeHash(config);

View File

@@ -11,9 +11,11 @@ using ScadaLink.Communication.Actors;
using ScadaLink.Host.Actors;
using ScadaLink.SiteRuntime;
using ScadaLink.SiteRuntime.Actors;
using ScadaLink.SiteRuntime.Messages;
using ScadaLink.SiteRuntime.Persistence;
using ScadaLink.SiteRuntime.Scripts;
using ScadaLink.SiteRuntime.Streaming;
using ScadaLink.StoreAndForward;
namespace ScadaLink.Host.Actors;
@@ -231,6 +233,26 @@ akka {{
// Resolve the health collector for the Deployment Manager
var siteHealthCollector = _serviceProvider.GetService<ScadaLink.HealthMonitoring.ISiteHealthCollector>();
// Create SiteReplicationActor on every node (not a singleton)
var sfStorage = _serviceProvider.GetRequiredService<StoreAndForwardStorage>();
var replicationService = _serviceProvider.GetRequiredService<ReplicationService>();
var replicationLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger<SiteReplicationActor>();
var replicationActor = _actorSystem!.ActorOf(
Props.Create(() => new SiteReplicationActor(
storage, sfStorage, replicationService, siteRole, replicationLogger)),
"site-replication");
// Wire S&F replication handler to forward operations via the replication actor
replicationService.SetReplicationHandler(op =>
{
replicationActor.Tell(new ReplicateStoreAndForward(op));
return Task.CompletedTask;
});
_logger.LogInformation("SiteReplicationActor created and S&F replication handler wired");
// Create the Deployment Manager as a cluster singleton
var singletonProps = ClusterSingletonManager.Props(
singletonProps: Props.Create(() => new DeploymentManagerActor(
@@ -241,6 +263,7 @@ akka {{
siteRuntimeOptionsValue,
dmLogger,
dclManager,
replicationActor,
siteHealthCollector,
_serviceProvider)),
terminationMessage: PoisonPill.Instance,
@@ -267,6 +290,9 @@ akka {{
dmProxy)),
"site-communication");
// Register local handlers with SiteCommunicationActor
siteCommActor.Tell(new RegisterLocalHandler(LocalHandlerType.Artifacts, dmProxy));
// Register SiteCommunicationActor with ClusterClientReceptionist so central ClusterClients can reach it
ClusterClientReceptionist.Get(_actorSystem).RegisterService(siteCommActor);

View File

@@ -90,6 +90,8 @@ public class ManagementActor : ReceiveActor
or ValidateTemplateCommand
or CreateExternalSystemCommand or UpdateExternalSystemCommand
or DeleteExternalSystemCommand
or CreateExternalSystemMethodCommand or UpdateExternalSystemMethodCommand
or DeleteExternalSystemMethodCommand
or CreateNotificationListCommand or UpdateNotificationListCommand
or DeleteNotificationListCommand
or UpdateSmtpConfigCommand
@@ -178,6 +180,11 @@ public class ManagementActor : ReceiveActor
CreateExternalSystemCommand cmd => await HandleCreateExternalSystem(sp, cmd),
UpdateExternalSystemCommand cmd => await HandleUpdateExternalSystem(sp, cmd),
DeleteExternalSystemCommand cmd => await HandleDeleteExternalSystem(sp, cmd),
ListExternalSystemMethodsCommand cmd => await HandleListExternalSystemMethods(sp, cmd),
GetExternalSystemMethodCommand cmd => await HandleGetExternalSystemMethod(sp, cmd),
CreateExternalSystemMethodCommand cmd => await HandleCreateExternalSystemMethod(sp, cmd),
UpdateExternalSystemMethodCommand cmd => await HandleUpdateExternalSystemMethod(sp, cmd),
DeleteExternalSystemMethodCommand cmd => await HandleDeleteExternalSystemMethod(sp, cmd),
// Notification Lists
ListNotificationListsCommand => await HandleListNotificationLists(sp),
@@ -568,6 +575,55 @@ public class ManagementActor : ReceiveActor
return true;
}
private static async Task<object?> HandleListExternalSystemMethods(IServiceProvider sp, ListExternalSystemMethodsCommand cmd)
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
return await repo.GetMethodsByExternalSystemIdAsync(cmd.ExternalSystemId);
}
private static async Task<object?> HandleGetExternalSystemMethod(IServiceProvider sp, GetExternalSystemMethodCommand cmd)
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
return await repo.GetExternalSystemMethodByIdAsync(cmd.MethodId);
}
private static async Task<object?> HandleCreateExternalSystemMethod(IServiceProvider sp, CreateExternalSystemMethodCommand cmd)
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
var method = new ExternalSystemMethod(cmd.Name, cmd.HttpMethod, cmd.Path)
{
ExternalSystemDefinitionId = cmd.ExternalSystemId,
ParameterDefinitions = cmd.ParameterDefinitions,
ReturnDefinition = cmd.ReturnDefinition
};
await repo.AddExternalSystemMethodAsync(method);
await repo.SaveChangesAsync();
return method;
}
private static async Task<object?> HandleUpdateExternalSystemMethod(IServiceProvider sp, UpdateExternalSystemMethodCommand cmd)
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
var method = await repo.GetExternalSystemMethodByIdAsync(cmd.MethodId)
?? throw new InvalidOperationException($"ExternalSystemMethod with ID {cmd.MethodId} not found.");
if (cmd.Name != null) method.Name = cmd.Name;
if (cmd.HttpMethod != null) method.HttpMethod = cmd.HttpMethod;
if (cmd.Path != null) method.Path = cmd.Path;
if (cmd.ParameterDefinitions != null) method.ParameterDefinitions = cmd.ParameterDefinitions;
if (cmd.ReturnDefinition != null) method.ReturnDefinition = cmd.ReturnDefinition;
await repo.UpdateExternalSystemMethodAsync(method);
await repo.SaveChangesAsync();
return method;
}
private static async Task<object?> HandleDeleteExternalSystemMethod(IServiceProvider sp, DeleteExternalSystemMethodCommand cmd)
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
await repo.DeleteExternalSystemMethodAsync(cmd.MethodId);
await repo.SaveChangesAsync();
return true;
}
// ========================================================================
// Notification handlers
// ========================================================================
@@ -850,7 +906,9 @@ public class ManagementActor : ReceiveActor
{
TriggerType = cmd.TriggerType,
TriggerConfiguration = cmd.TriggerConfiguration,
IsLocked = cmd.IsLocked
IsLocked = cmd.IsLocked,
ParameterDefinitions = cmd.ParameterDefinitions,
ReturnDefinition = cmd.ReturnDefinition
};
var result = await svc.AddScriptAsync(cmd.TemplateId, script, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
@@ -863,7 +921,9 @@ public class ManagementActor : ReceiveActor
{
TriggerType = cmd.TriggerType,
TriggerConfiguration = cmd.TriggerConfiguration,
IsLocked = cmd.IsLocked
IsLocked = cmd.IsLocked,
ParameterDefinitions = cmd.ParameterDefinitions,
ReturnDefinition = cmd.ReturnDefinition
};
var result = await svc.UpdateScriptAsync(cmd.ScriptId, script, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);

View File

@@ -6,6 +6,7 @@ using ScadaLink.Commons.Messages.Deployment;
using ScadaLink.Commons.Messages.Lifecycle;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.HealthMonitoring;
using ScadaLink.SiteRuntime.Messages;
using ScadaLink.SiteRuntime.Persistence;
using ScadaLink.SiteRuntime.Scripts;
using ScadaLink.SiteRuntime.Streaming;
@@ -31,6 +32,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
private readonly SiteRuntimeOptions _options;
private readonly ILogger<DeploymentManagerActor> _logger;
private readonly IActorRef? _dclManager;
private readonly IActorRef? _replicationActor;
private readonly ISiteHealthCollector? _healthCollector;
private readonly IServiceProvider? _serviceProvider;
private readonly Dictionary<string, IActorRef> _instanceActors = new();
@@ -46,6 +48,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
SiteRuntimeOptions options,
ILogger<DeploymentManagerActor> logger,
IActorRef? dclManager = null,
IActorRef? replicationActor = null,
ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = null)
{
@@ -55,6 +58,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
_streamManager = streamManager;
_options = options;
_dclManager = dclManager;
_replicationActor = replicationActor;
_healthCollector = healthCollector;
_serviceProvider = serviceProvider;
_logger = logger;
@@ -238,6 +242,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// Static overrides are reset on redeployment per design decision
await _storage.ClearStaticOverridesAsync(instanceName);
// Replicate to standby node
_replicationActor?.Tell(new ReplicateConfigDeploy(
instanceName, command.FlattenedConfigurationJson,
command.DeploymentId, command.RevisionHash, true));
return new DeployPersistenceResult(command.DeploymentId, instanceName, true, null, sender);
}).ContinueWith(t =>
{
@@ -285,6 +294,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
var sender = Sender;
_storage.SetInstanceEnabledAsync(instanceName, false).ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
_replicationActor?.Tell(new ReplicateConfigSetEnabled(instanceName, false));
return new InstanceLifecycleResponse(
command.CommandId,
instanceName,
@@ -308,6 +320,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Task.Run(async () =>
{
await _storage.SetInstanceEnabledAsync(instanceName, true);
_replicationActor?.Tell(new ReplicateConfigSetEnabled(instanceName, true));
var configs = await _storage.GetAllDeployedConfigsAsync();
var config = configs.FirstOrDefault(c => c.InstanceUniqueName == instanceName);
return new EnableResult(command, config, null, sender);
@@ -365,6 +378,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
var sender = Sender;
_storage.RemoveDeployedConfigAsync(instanceName).ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
_replicationActor?.Tell(new ReplicateConfigRemove(instanceName));
return new InstanceLifecycleResponse(
command.CommandId,
instanceName,
@@ -548,6 +564,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
}
// Replicate artifacts to standby node
_replicationActor?.Tell(new ReplicateArtifacts(command));
return new ArtifactDeploymentResponse(
command.DeploymentId, "", true, null, DateTimeOffset.UtcNow);
}

View File

@@ -261,6 +261,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
"interval" => ParseIntervalTrigger(triggerConfigJson),
"valuechange" => ParseValueChangeTrigger(triggerConfigJson),
"conditional" => ParseConditionalTrigger(triggerConfigJson),
"call" => null, // No automatic trigger — invoked only via Instance.CallScript()
_ => null
};
}

View File

@@ -0,0 +1,214 @@
using Akka.Actor;
using Akka.Cluster;
using Akka.Event;
using Microsoft.Extensions.Logging;
using ScadaLink.SiteRuntime.Messages;
using ScadaLink.SiteRuntime.Persistence;
using ScadaLink.StoreAndForward;
namespace ScadaLink.SiteRuntime.Actors;
/// <summary>
/// Runs on every site node (not a singleton). Handles both config and S&amp;F replication
/// between site cluster peers.
///
/// Outbound: receives local replication requests and forwards to peer via ActorSelection.
/// Inbound: receives replicated operations from peer and applies to local SQLite.
/// Uses fire-and-forget (Tell) — no ack wait per design.
/// </summary>
public class SiteReplicationActor : ReceiveActor
{
private readonly SiteStorageService _storage;
private readonly StoreAndForwardStorage _sfStorage;
private readonly ReplicationService _replicationService;
private readonly string _siteRole;
private readonly ILogger<SiteReplicationActor> _logger;
private readonly Cluster _cluster;
private Address? _peerAddress;
public SiteReplicationActor(
SiteStorageService storage,
StoreAndForwardStorage sfStorage,
ReplicationService replicationService,
string siteRole,
ILogger<SiteReplicationActor> logger)
{
_storage = storage;
_sfStorage = sfStorage;
_replicationService = replicationService;
_siteRole = siteRole;
_logger = logger;
_cluster = Cluster.Get(Context.System);
// Cluster member events
Receive<ClusterEvent.MemberUp>(HandleMemberUp);
Receive<ClusterEvent.MemberRemoved>(HandleMemberRemoved);
Receive<ClusterEvent.CurrentClusterState>(HandleCurrentClusterState);
// Outbound — forward to peer
Receive<ReplicateConfigDeploy>(msg => SendToPeer(new ApplyConfigDeploy(
msg.InstanceName, msg.ConfigJson, msg.DeploymentId, msg.RevisionHash, msg.IsEnabled)));
Receive<ReplicateConfigRemove>(msg => SendToPeer(new ApplyConfigRemove(msg.InstanceName)));
Receive<ReplicateConfigSetEnabled>(msg => SendToPeer(new ApplyConfigSetEnabled(
msg.InstanceName, msg.IsEnabled)));
Receive<ReplicateArtifacts>(msg => SendToPeer(new ApplyArtifacts(msg.Command)));
Receive<ReplicateStoreAndForward>(msg => SendToPeer(new ApplyStoreAndForward(msg.Operation)));
// Inbound — apply from peer
Receive<ApplyConfigDeploy>(HandleApplyConfigDeploy);
Receive<ApplyConfigRemove>(HandleApplyConfigRemove);
Receive<ApplyConfigSetEnabled>(HandleApplyConfigSetEnabled);
Receive<ApplyArtifacts>(HandleApplyArtifacts);
Receive<ApplyStoreAndForward>(HandleApplyStoreAndForward);
}
protected override void PreStart()
{
base.PreStart();
_cluster.Subscribe(Self, ClusterEvent.SubscriptionInitialStateMode.InitialStateAsSnapshot,
typeof(ClusterEvent.MemberUp),
typeof(ClusterEvent.MemberRemoved));
_logger.LogInformation("SiteReplicationActor started, subscribing to cluster events for role {Role}", _siteRole);
}
protected override void PostStop()
{
_cluster.Unsubscribe(Self);
base.PostStop();
}
private void HandleCurrentClusterState(ClusterEvent.CurrentClusterState state)
{
foreach (var member in state.Members)
{
if (member.Status == MemberStatus.Up)
TryTrackPeer(member);
}
}
private void HandleMemberUp(ClusterEvent.MemberUp evt)
{
TryTrackPeer(evt.Member);
}
private void HandleMemberRemoved(ClusterEvent.MemberRemoved evt)
{
if (evt.Member.Address.Equals(_peerAddress))
{
_logger.LogInformation("Peer node removed: {Address}", _peerAddress);
_peerAddress = null;
}
}
private void TryTrackPeer(Member member)
{
// Must have our site role, and must not be self
if (member.HasRole(_siteRole) && !member.Address.Equals(_cluster.SelfAddress))
{
_peerAddress = member.Address;
_logger.LogInformation("Peer node tracked: {Address}", _peerAddress);
}
}
private void SendToPeer(object message)
{
if (_peerAddress == null)
{
_logger.LogDebug("No peer available, dropping replication message {Type}", message.GetType().Name);
return;
}
var path = new RootActorPath(_peerAddress) / "user" / "site-replication";
Context.ActorSelection(path).Tell(message);
}
// ── Inbound handlers ──
private void HandleApplyConfigDeploy(ApplyConfigDeploy msg)
{
_logger.LogInformation("Applying replicated config deploy for {Instance}", msg.InstanceName);
_storage.StoreDeployedConfigAsync(
msg.InstanceName, msg.ConfigJson, msg.DeploymentId, msg.RevisionHash, msg.IsEnabled)
.ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "Failed to apply replicated deploy for {Instance}", msg.InstanceName);
});
}
private void HandleApplyConfigRemove(ApplyConfigRemove msg)
{
_logger.LogInformation("Applying replicated config remove for {Instance}", msg.InstanceName);
_storage.RemoveDeployedConfigAsync(msg.InstanceName)
.ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "Failed to apply replicated remove for {Instance}", msg.InstanceName);
});
}
private void HandleApplyConfigSetEnabled(ApplyConfigSetEnabled msg)
{
_logger.LogInformation("Applying replicated set-enabled={Enabled} for {Instance}", msg.IsEnabled, msg.InstanceName);
_storage.SetInstanceEnabledAsync(msg.InstanceName, msg.IsEnabled)
.ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "Failed to apply replicated set-enabled for {Instance}", msg.InstanceName);
});
}
private void HandleApplyArtifacts(ApplyArtifacts msg)
{
var command = msg.Command;
_logger.LogInformation("Applying replicated artifacts, deploymentId={DeploymentId}", command.DeploymentId);
Task.Run(async () =>
{
try
{
if (command.SharedScripts != null)
foreach (var s in command.SharedScripts)
await _storage.StoreSharedScriptAsync(s.Name, s.Code, s.ParameterDefinitions, s.ReturnDefinition);
if (command.ExternalSystems != null)
foreach (var es in command.ExternalSystems)
await _storage.StoreExternalSystemAsync(es.Name, es.EndpointUrl, es.AuthType, es.AuthConfiguration, es.MethodDefinitionsJson);
if (command.DatabaseConnections != null)
foreach (var db in command.DatabaseConnections)
await _storage.StoreDatabaseConnectionAsync(db.Name, db.ConnectionString, db.MaxRetries, db.RetryDelay);
if (command.NotificationLists != null)
foreach (var nl in command.NotificationLists)
await _storage.StoreNotificationListAsync(nl.Name, nl.RecipientEmails);
if (command.DataConnections != null)
foreach (var dc in command.DataConnections)
await _storage.StoreDataConnectionDefinitionAsync(dc.Name, dc.Protocol, dc.ConfigurationJson);
if (command.SmtpConfigurations != null)
foreach (var smtp in command.SmtpConfigurations)
await _storage.StoreSmtpConfigurationAsync(smtp.Name, smtp.Server, smtp.Port, smtp.AuthMode,
smtp.FromAddress, smtp.Username, smtp.Password, smtp.OAuthConfig);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to apply replicated artifacts");
}
});
}
private void HandleApplyStoreAndForward(ApplyStoreAndForward msg)
{
_logger.LogDebug("Applying replicated S&F operation {OpType} for message {Id}",
msg.Operation.OperationType, msg.Operation.MessageId);
_replicationService.ApplyReplicatedOperationAsync(msg.Operation, _sfStorage)
.ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "Failed to apply replicated S&F operation {Id}", msg.Operation.MessageId);
});
}
}

View File

@@ -0,0 +1,34 @@
using ScadaLink.Commons.Messages.Artifacts;
using ScadaLink.StoreAndForward;
namespace ScadaLink.SiteRuntime.Messages;
/// <summary>
/// Outbound messages — sent by local DeploymentManagerActor/S&amp;F service
/// to the local SiteReplicationActor for forwarding to the peer node.
/// </summary>
public record ReplicateConfigDeploy(
string InstanceName, string ConfigJson, string DeploymentId, string RevisionHash, bool IsEnabled);
public record ReplicateConfigRemove(string InstanceName);
public record ReplicateConfigSetEnabled(string InstanceName, bool IsEnabled);
public record ReplicateArtifacts(DeployArtifactsCommand Command);
public record ReplicateStoreAndForward(ReplicationOperation Operation);
/// <summary>
/// Inbound messages — received from the peer's SiteReplicationActor
/// and applied to local SQLite storage.
/// </summary>
public record ApplyConfigDeploy(
string InstanceName, string ConfigJson, string DeploymentId, string RevisionHash, bool IsEnabled);
public record ApplyConfigRemove(string InstanceName);
public record ApplyConfigSetEnabled(string InstanceName, bool IsEnabled);
public record ApplyArtifacts(DeployArtifactsCommand Command);
public record ApplyStoreAndForward(ReplicationOperation Operation);

View File

@@ -23,6 +23,7 @@
<ItemGroup>
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
<ProjectReference Include="../ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" />
<ProjectReference Include="../ScadaLink.StoreAndForward/ScadaLink.StoreAndForward.csproj" />
</ItemGroup>
</Project>

View File

@@ -108,7 +108,9 @@ public class ScriptCompilationService
.WithReferences(
typeof(object).Assembly,
typeof(Enumerable).Assembly,
typeof(Math).Assembly)
typeof(Math).Assembly,
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
typeof(Commons.Types.DynamicJsonElement).Assembly)
.WithImports(
"System",
"System.Collections.Generic",

View File

@@ -104,6 +104,20 @@ public class SemanticValidator
}
}
// Validate Call-type scripts have parameter definitions
foreach (var script in configuration.Scripts)
{
if (string.Equals(script.TriggerType, "Call", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(script.ParameterDefinitions))
{
warnings.Add(ValidationEntry.Warning(ValidationCategory.MissingMetadata,
$"Call-type script '{script.CanonicalName}' has no parameter definitions.",
script.CanonicalName));
}
}
}
// Validate alarm trigger operand types
foreach (var alarm in configuration.Alarms)
{

View File

@@ -22,12 +22,15 @@ public class ConcurrencyTestDbContext : ScadaLinkDbContext
{
base.OnModelCreating(modelBuilder);
// Replace the SQL Server RowVersion with an explicit concurrency token for SQLite
// Remove the shadow RowVersion property and add a visible ConcurrencyStamp
// Replace the SQL Server RowVersion with an explicit concurrency token for SQLite.
// SQLite can't auto-generate rowversion, so disable it and use Status as the token instead.
modelBuilder.Entity<DeploymentRecord>(builder =>
{
// The shadow RowVersion property from the base config doesn't work in SQLite.
// Instead, use Status as a concurrency token for the test.
builder.Property(d => d.RowVersion)
.IsRequired(false)
.IsConcurrencyToken(false)
.ValueGeneratedNever();
builder.Property(d => d.Status).IsConcurrencyToken();
});
}

View File

@@ -68,8 +68,9 @@ public class SecurityRepositoryTests : IDisposable
await _repository.SaveChangesAsync();
var designMappings = await _repository.GetMappingsByRoleAsync("Design");
Assert.Single(designMappings);
Assert.Equal("Designers", designMappings[0].LdapGroupName);
// Seed data includes "SCADA-Designers" with role "Design", plus the one we added
Assert.Equal(2, designMappings.Count);
Assert.Contains(designMappings, m => m.LdapGroupName == "Designers");
}
[Fact]

View File

@@ -1,13 +1,15 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using ScadaLink.Commons.Entities.Deployment;
using ScadaLink.ConfigurationDatabase;
namespace ScadaLink.ConfigurationDatabase.Tests;
/// <summary>
/// Test DbContext that maps DateTimeOffset to a sortable string format for SQLite.
/// EF Core 10 SQLite provider does not support ORDER BY on DateTimeOffset columns.
/// Test DbContext that adapts SQL Server-specific features for SQLite:
/// - Maps DateTimeOffset to sortable ISO 8601 strings (SQLite has no native DateTimeOffset ORDER BY)
/// - Replaces SQL Server RowVersion with a nullable byte[] column (SQLite can't auto-generate rowversion)
/// </summary>
public class SqliteTestDbContext : ScadaLinkDbContext
{
@@ -19,6 +21,16 @@ public class SqliteTestDbContext : ScadaLinkDbContext
{
base.OnModelCreating(modelBuilder);
// SQLite cannot auto-generate SQL Server rowversion values.
// Replace with a nullable byte[] column so inserts don't fail with NOT NULL constraint.
modelBuilder.Entity<DeploymentRecord>(builder =>
{
builder.Property(d => d.RowVersion)
.IsRequired(false)
.IsConcurrencyToken(false)
.ValueGeneratedNever();
});
// Convert DateTimeOffset to ISO 8601 string for SQLite so ORDER BY works
var converter = new ValueConverter<DateTimeOffset, string>(
v => v.UtcDateTime.ToString("o"),

View File

@@ -21,13 +21,7 @@ public class DbContextTests : IDisposable
public DbContextTests()
{
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlite("DataSource=:memory:")
.Options;
_context = new ScadaLinkDbContext(options);
_context.Database.OpenConnection();
_context.Database.EnsureCreated();
_context = SqliteTestHelper.CreateInMemoryContext();
}
public void Dispose()
@@ -429,6 +423,6 @@ public class MigrationHelperTests : IDisposable
{
// Verify the InitialCreate migration is detected as pending
var pending = _context.Database.GetPendingMigrations().ToList();
Assert.Contains(pending, m => m.Contains("InitialCreate"));
Assert.Contains(pending, m => m.Contains("InitialSchema"));
}
}