6aa5edaf30
- I1 ScriptRunGuard: a run refused because the previous one is still executing is a Bad publish plus otopcua.script.run_refused, and no longer a quarantine strike. Only a timeout (and an allocation breach) counts; a refusal neither adds to nor resets the timeout streak; a completed run (value or throw) resets it. On a 1 Hz trigger with a 2 s budget a single 4 s overrun is one timeout plus two refusals and must not quarantine. Tests updated across the guard, the alarm engine, the Calculation evaluator and the VT evaluator, plus Refusals_neither_count_toward_nor_reset_the_timeout_streak. - I3 ScriptedAlarmEngine: the quality bucket is tracked only after the predicate completes (or in the inputs-not-ready path), so the tracked bucket is the last one annotated. A refused or quarantined predicate used to re-track the input bucket Good before the attempt and then flip it Bad, emitting QualityChanged on every push. The run-guard tests now count emissions (exactly 1 across N pushes, refused and quarantined cases), with a healthy sibling's dispatch count as the positive control that every push was processed. Both were red before the fix. Refs #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
114 lines
6.3 KiB
C#
114 lines
6.3 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
|
|
|
/// <summary>
|
|
/// Static factory registration helper for <see cref="CalculationDriver"/>. The Server's
|
|
/// <c>DriverFactoryBootstrap</c> calls <see cref="Register"/> once at startup; the bootstrapper then
|
|
/// materialises <c>Calculation</c> DriverInstance rows into live driver instances. Mirrors
|
|
/// <c>ModbusDriverFactoryExtensions</c>, with the addition of a <see cref="ScriptRootLogger"/> so a
|
|
/// script failure fans out onto the <c>script-logs</c> topic (via the root logger's
|
|
/// <c>ScriptLogTopicSink</c>) — the calc driver has no Akka access of its own.
|
|
/// </summary>
|
|
public static class CalculationDriverFactoryExtensions
|
|
{
|
|
/// <summary>The <c>DriverInstance.DriverType</c> string this factory registers under.</summary>
|
|
public const string DriverTypeName = "Calculation";
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
|
AllowTrailingCommas = true,
|
|
Converters = { new JsonStringEnumConverter() },
|
|
};
|
|
|
|
/// <summary>
|
|
/// Register the Calculation factory with the driver registry. The optional
|
|
/// <paramref name="loggerFactory"/> + <paramref name="scriptRoot"/> are captured at registration
|
|
/// time; both may be null (the guard test invokes <c>Register(registry)</c> reflectively with the
|
|
/// trailing parameters defaulted), in which case the driver runs with the null logger + a no-op
|
|
/// script-logger.
|
|
/// </summary>
|
|
/// <param name="registry">The driver factory registry to register with.</param>
|
|
/// <param name="loggerFactory">Optional logger factory for per-instance diagnostics loggers.</param>
|
|
/// <param name="scriptRoot">Optional root script logger (fans failure entries onto the <c>script-logs</c> topic).</param>
|
|
public static void Register(
|
|
DriverFactoryRegistry registry,
|
|
ILoggerFactory? loggerFactory = null,
|
|
ScriptRootLogger? scriptRoot = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(registry);
|
|
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, scriptRoot));
|
|
}
|
|
|
|
/// <summary>Public overload for the Server-side bootstrapper + test consumers.</summary>
|
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
|
/// <param name="driverConfigJson">The merged driver config JSON (carries the <c>RawTags</c> array).</param>
|
|
/// <returns>The constructed <see cref="CalculationDriver"/>.</returns>
|
|
public static CalculationDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
|
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null, scriptRoot: null);
|
|
|
|
/// <summary>Logger/script-logger-aware overload — used by <see cref="Register"/>'s closure via DI.</summary>
|
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
|
/// <param name="driverConfigJson">The merged driver config JSON (carries the <c>RawTags</c> array).</param>
|
|
/// <param name="loggerFactory">Optional logger factory for per-instance diagnostics loggers.</param>
|
|
/// <param name="scriptRoot">Optional root script logger for failure fan-out.</param>
|
|
/// <returns>The constructed <see cref="CalculationDriver"/>.</returns>
|
|
public static CalculationDriver CreateInstance(
|
|
string driverInstanceId, string driverConfigJson,
|
|
ILoggerFactory? loggerFactory, ScriptRootLogger? scriptRoot)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
|
|
|
var dto = JsonSerializer.Deserialize<CalculationDriverConfigDto>(driverConfigJson, JsonOptions)
|
|
?? throw new InvalidOperationException(
|
|
$"Calculation driver config for '{driverInstanceId}' deserialised to null");
|
|
|
|
// Run-guard knobs are optional and default to the node-wide ScriptingOptions defaults; only an
|
|
// explicitly authored key overrides one (so a config carrying neither binds Scripting = null).
|
|
ScriptingOptions? scripting = null;
|
|
if (dto.MaxAllocBytesPerRun is not null || dto.QuarantineAfterConsecutiveTimeouts is not null)
|
|
{
|
|
scripting = new ScriptingOptions();
|
|
if (dto.MaxAllocBytesPerRun is { } alloc) scripting.MaxAllocBytesPerRun = alloc;
|
|
if (dto.QuarantineAfterConsecutiveTimeouts is { } n) scripting.QuarantineAfterConsecutiveTimeouts = n;
|
|
}
|
|
|
|
var options = new CalculationDriverOptions
|
|
{
|
|
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
|
|
RunTimeout = dto.RunTimeoutMs is { } ms && ms > 0
|
|
? TimeSpan.FromMilliseconds(ms) : TimeSpan.FromSeconds(2),
|
|
Scripting = scripting,
|
|
};
|
|
|
|
return new CalculationDriver(
|
|
options, driverInstanceId, scriptRoot,
|
|
logger: loggerFactory?.CreateLogger<CalculationDriver>());
|
|
}
|
|
|
|
/// <summary>The merged-config DTO the calc factory binds from. The Calculation driver has no
|
|
/// backend/endpoint config — only the injected raw tags (+ an optional evaluation-timeout override).</summary>
|
|
internal sealed class CalculationDriverConfigDto
|
|
{
|
|
/// <summary>The driver's authored raw calc tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName).</summary>
|
|
public List<RawTagEntry>? RawTags { get; init; }
|
|
|
|
/// <summary>Optional per-script evaluation wall-clock budget override, in milliseconds (default 2000).</summary>
|
|
public int? RunTimeoutMs { get; init; }
|
|
|
|
/// <summary>Optional run-guard allocation budget per script run, in bytes (default 64 MB; ≤ 0 disables).</summary>
|
|
public long? MaxAllocBytesPerRun { get; init; }
|
|
|
|
/// <summary>Optional run-guard quarantine threshold: consecutive timeouts (refusals do not count) before a tag's script stops being evaluated (default 3).</summary>
|
|
public int? QuarantineAfterConsecutiveTimeouts { get; init; }
|
|
}
|
|
}
|