From da683d4fe9d285a8b2cbfaf352054a1f1707b386 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 18 Mar 2026 09:30:12 -0400 Subject: [PATCH] fix: lazy-compile API method scripts and prefix composed alarm trigger attributes - InboundScriptExecutor lazy-compiles scripts on first request, solving the multi-node problem where methods created via CLI/UI were only compiled on the ManagementActor's node, not the node handling the HTTP request. - ManagementActor hot-registers API method scripts on create/update/delete for the local node. - FlatteningService prefixes the "attribute" field in composed alarm trigger configs with the composition instance name so alarms evaluate against the correct path-qualified attribute (e.g. CoolingTank.Level not Level). --- .../InboundScriptExecutor.cs | 27 +++++++---- .../ManagementActor.cs | 14 ++++++ .../ScadaLink.ManagementService.csproj | 1 + .../Flattening/FlatteningService.cs | 46 +++++++++++++++++++ 4 files changed, 79 insertions(+), 9 deletions(-) diff --git a/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs b/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs index fd0cdc8..cc607d7 100644 --- a/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs +++ b/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs @@ -15,9 +15,12 @@ public class InboundScriptExecutor private readonly ILogger _logger; private readonly Dictionary>> _scriptHandlers = new(); - public InboundScriptExecutor(ILogger logger) + private readonly IServiceProvider _serviceProvider; + + public InboundScriptExecutor(ILogger logger, IServiceProvider serviceProvider) { _logger = logger; + _serviceProvider = serviceProvider; } /// @@ -28,6 +31,14 @@ public class InboundScriptExecutor _scriptHandlers[methodName] = handler; } + /// + /// Removes a compiled script handler for a method name. + /// + public void RemoveHandler(string methodName) + { + _scriptHandlers.Remove(methodName); + } + /// /// Compiles and registers a single API method script. /// @@ -107,16 +118,14 @@ public class InboundScriptExecutor var context = new InboundScriptContext(parameters, route, cts.Token); object? result; - if (_scriptHandlers.TryGetValue(method.Name, out var handler)) + if (!_scriptHandlers.TryGetValue(method.Name, out var handler)) { - result = await handler(context).WaitAsync(cts.Token); - } - else - { - // No compiled handler — this means the script hasn't been registered. - // In production, we'd compile the method.Script and cache it. - return new InboundScriptResult(false, null, "Script not compiled or registered for this method"); + // Lazy compile on first request (handles methods created after startup) + if (!CompileAndRegister(method)) + return new InboundScriptResult(false, null, "Script compilation failed for this method"); + handler = _scriptHandlers[method.Name]; } + result = await handler(context).WaitAsync(cts.Token); var resultJson = result != null ? JsonSerializer.Serialize(result) diff --git a/src/ScadaLink.ManagementService/ManagementActor.cs b/src/ScadaLink.ManagementService/ManagementActor.cs index 9e1f050..3e8a6d6 100644 --- a/src/ScadaLink.ManagementService/ManagementActor.cs +++ b/src/ScadaLink.ManagementService/ManagementActor.cs @@ -1059,6 +1059,10 @@ public class ManagementActor : ReceiveActor }; await repo.AddApiMethodAsync(method); await repo.SaveChangesAsync(); + + // Hot-register the compiled script so it's immediately available + sp.GetService()?.CompileAndRegister(method); + return method; } @@ -1073,14 +1077,24 @@ public class ManagementActor : ReceiveActor method.ReturnDefinition = cmd.ReturnDefinition; await repo.UpdateApiMethodAsync(method); await repo.SaveChangesAsync(); + + // Re-compile and register the updated script + sp.GetService()?.CompileAndRegister(method); + return method; } private static async Task HandleDeleteApiMethod(IServiceProvider sp, DeleteApiMethodCommand cmd) { var repo = sp.GetRequiredService(); + var method = await repo.GetApiMethodByIdAsync(cmd.ApiMethodId); await repo.DeleteApiMethodAsync(cmd.ApiMethodId); await repo.SaveChangesAsync(); + + // Remove the compiled script handler + if (method != null) + sp.GetService()?.RemoveHandler(method.Name); + return true; } diff --git a/src/ScadaLink.ManagementService/ScadaLink.ManagementService.csproj b/src/ScadaLink.ManagementService/ScadaLink.ManagementService.csproj index 2354404..64d6ba6 100644 --- a/src/ScadaLink.ManagementService/ScadaLink.ManagementService.csproj +++ b/src/ScadaLink.ManagementService/ScadaLink.ManagementService.csproj @@ -19,5 +19,6 @@ + diff --git a/src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs b/src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs index ac4529d..cad7615 100644 --- a/src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs +++ b/src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs @@ -320,6 +320,7 @@ public class FlatteningService alarms[canonicalName] = alarm with { CanonicalName = canonicalName, + TriggerConfiguration = PrefixTriggerAttribute(alarm.TriggerConfiguration, prefix), Source = "Composed" }; } @@ -396,6 +397,51 @@ public class FlatteningService } } + /// + /// Prefixes the "attribute" (or "attributeName") field in alarm trigger configuration JSON + /// with the composition instance name, so composed alarms monitor the path-qualified attribute. + /// + private static string? PrefixTriggerAttribute(string? triggerConfigJson, string prefix) + { + if (string.IsNullOrEmpty(triggerConfigJson)) return triggerConfigJson; + + try + { + using var doc = System.Text.Json.JsonDocument.Parse(triggerConfigJson); + var root = doc.RootElement; + + // Find the attribute key name used + string? attrKey = null; + if (root.TryGetProperty("attribute", out _)) attrKey = "attribute"; + else if (root.TryGetProperty("attributeName", out _)) attrKey = "attributeName"; + + if (attrKey == null) return triggerConfigJson; + + var attrValue = root.GetProperty(attrKey).GetString(); + if (string.IsNullOrEmpty(attrValue)) return triggerConfigJson; + + // Rebuild JSON with prefixed attribute name + using var ms = new System.IO.MemoryStream(); + using (var writer = new System.Text.Json.Utf8JsonWriter(ms)) + { + writer.WriteStartObject(); + foreach (var prop in root.EnumerateObject()) + { + if (prop.Name == attrKey) + writer.WriteString(attrKey, $"{prefix}.{attrValue}"); + else + prop.WriteTo(writer); + } + writer.WriteEndObject(); + } + return System.Text.Encoding.UTF8.GetString(ms.ToArray()); + } + catch + { + return triggerConfigJson; + } + } + /// /// Resolves alarm on-trigger script references from script IDs to canonical names. /// This is done by finding the script in the template chain whose ID matches the alarm's OnTriggerScriptId,