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).
This commit is contained in:
Joseph Doherty
2026-03-18 09:30:12 -04:00
parent db387c6613
commit da683d4fe9
4 changed files with 79 additions and 9 deletions

View File

@@ -15,9 +15,12 @@ public class InboundScriptExecutor
private readonly ILogger<InboundScriptExecutor> _logger;
private readonly Dictionary<string, Func<InboundScriptContext, Task<object?>>> _scriptHandlers = new();
public InboundScriptExecutor(ILogger<InboundScriptExecutor> logger)
private readonly IServiceProvider _serviceProvider;
public InboundScriptExecutor(ILogger<InboundScriptExecutor> logger, IServiceProvider serviceProvider)
{
_logger = logger;
_serviceProvider = serviceProvider;
}
/// <summary>
@@ -28,6 +31,14 @@ public class InboundScriptExecutor
_scriptHandlers[methodName] = handler;
}
/// <summary>
/// Removes a compiled script handler for a method name.
/// </summary>
public void RemoveHandler(string methodName)
{
_scriptHandlers.Remove(methodName);
}
/// <summary>
/// Compiles and registers a single API method script.
/// </summary>
@@ -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)

View File

@@ -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<InboundAPI.InboundScriptExecutor>()?.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<InboundAPI.InboundScriptExecutor>()?.CompileAndRegister(method);
return method;
}
private static async Task<object?> HandleDeleteApiMethod(IServiceProvider sp, DeleteApiMethodCommand cmd)
{
var repo = sp.GetRequiredService<IInboundApiRepository>();
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<InboundAPI.InboundScriptExecutor>()?.RemoveHandler(method.Name);
return true;
}

View File

@@ -19,5 +19,6 @@
<ProjectReference Include="../ScadaLink.Communication/ScadaLink.Communication.csproj" />
<ProjectReference Include="../ScadaLink.Security/ScadaLink.Security.csproj" />
<ProjectReference Include="../ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" />
<ProjectReference Include="..\ScadaLink.InboundAPI\ScadaLink.InboundAPI.csproj" />
</ItemGroup>
</Project>

View File

@@ -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
}
}
/// <summary>
/// Prefixes the "attribute" (or "attributeName") field in alarm trigger configuration JSON
/// with the composition instance name, so composed alarms monitor the path-qualified attribute.
/// </summary>
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;
}
}
/// <summary>
/// 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,