feat(scripting): bind the Scripting section node-wide and clamp its knobs
Program.cs binds ScriptingOptions from Scripting:* once, clamps every numeric knob through ScriptingOptions.Sanitized (logging a Warning per adjustment) and registers the one instance. It now reaches all three script seams: RoslynVirtualTagEvaluator, the scripted-alarm engine built in DriverHostActor (new trailing Props parameter, wired in WithOtOpcUaRuntimeActors) and every Calculation driver (node options as the base, per-driver config keys override one knob on a copy). ScriptRunGuard sanitizes again in its ctor and computes the stuck threshold with a saturating multiply, so an absurd StuckOrphanQuarantineFactor can no longer throw OverflowException on a refused run. Refs #731 #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -96,9 +96,11 @@ public sealed class ScriptRunGuard
|
||||
/// <param name="logger">Where the quarantine Error line goes; null ⇒ Serilog's global logger.</param>
|
||||
public ScriptRunGuard(ScriptingOptions? options = null, ILogger? logger = null)
|
||||
{
|
||||
options ??= new ScriptingOptions();
|
||||
// Clamped here as well as at the bind site, because options also arrive from a Calculation driver's own
|
||||
// config and from tests. An unclamped factor made the refusal path's timeout * factor overflow.
|
||||
options = (options ?? new ScriptingOptions()).Sanitized(out _);
|
||||
_maxAllocBytesPerRun = options.MaxAllocBytesPerRun;
|
||||
_quarantineThreshold = Math.Max(1, options.QuarantineAfterConsecutiveTimeouts);
|
||||
_quarantineThreshold = options.QuarantineAfterConsecutiveTimeouts;
|
||||
_stuckFactor = options.StuckOrphanQuarantineFactor;
|
||||
_logger = logger ?? Log.Logger;
|
||||
}
|
||||
@@ -197,7 +199,11 @@ public sealed class ScriptRunGuard
|
||||
if (_stuckFactor > 0 && Volatile.Read(ref state.RunStartedTimestamp) is var startedAt and not 0)
|
||||
{
|
||||
var running = Stopwatch.GetElapsedTime(startedAt);
|
||||
var threshold = timeout * _stuckFactor;
|
||||
// Saturating: even the clamped maximum factor times a huge timeout must not overflow TimeSpan.
|
||||
var thresholdTicks = timeout.Ticks * _stuckFactor;
|
||||
var threshold = thresholdTicks >= TimeSpan.MaxValue.Ticks
|
||||
? TimeSpan.MaxValue
|
||||
: TimeSpan.FromTicks((long)thresholdTicks);
|
||||
if (running > threshold)
|
||||
{
|
||||
Quarantine(key, state,
|
||||
|
||||
@@ -53,4 +53,85 @@ public sealed class ScriptingOptions
|
||||
/// refusal. Zero or negative disables the rule. Default 10.
|
||||
/// </summary>
|
||||
public double StuckOrphanQuarantineFactor { get; set; } = DefaultStuckOrphanQuarantineFactor;
|
||||
|
||||
/// <summary>Upper bound <see cref="Sanitized"/> applies to <see cref="StuckOrphanQuarantineFactor"/>.</summary>
|
||||
public const double MaxStuckOrphanQuarantineFactor = 1000;
|
||||
|
||||
/// <summary>Upper bound <see cref="Sanitized"/> applies to <see cref="QuarantineAfterConsecutiveTimeouts"/>.</summary>
|
||||
public const int MaxQuarantineAfterConsecutiveTimeouts = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Lower bound <see cref="Sanitized"/> applies to a positive <see cref="MaxAllocBytesPerRun"/>: 1 MB. A
|
||||
/// smaller budget would quarantine practically every script on its first run.
|
||||
/// </summary>
|
||||
public const long MinMaxAllocBytesPerRun = 1024L * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a copy with every numeric knob pulled into a sane range. The bind site calls it and logs
|
||||
/// <paramref name="adjustments"/>. <see cref="ScriptRunGuard"/> calls it again, so options that reach the
|
||||
/// guard by another route (a Calculation driver's own config, a test) are clamped too.
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="StuckOrphanQuarantineFactor"/>: NaN becomes the default, a negative value becomes 0
|
||||
/// (disabled), and a value above <see cref="MaxStuckOrphanQuarantineFactor"/> becomes that maximum.
|
||||
/// Unclamped, an absurd factor made <c>timeout * factor</c> throw <see cref="OverflowException"/> on
|
||||
/// the first refused run.</item>
|
||||
/// <item><see cref="QuarantineAfterConsecutiveTimeouts"/>: into [1, <see cref="MaxQuarantineAfterConsecutiveTimeouts"/>].</item>
|
||||
/// <item><see cref="MaxAllocBytesPerRun"/>: a negative value becomes 0 (disabled), and a positive value
|
||||
/// below <see cref="MinMaxAllocBytesPerRun"/> becomes that minimum.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="adjustments">One line per value that was out of range, naming its key. A negative value that
|
||||
/// already meant "disabled" is normalised without a line.</param>
|
||||
/// <returns>A new, clamped instance. This instance is not modified.</returns>
|
||||
public ScriptingOptions Sanitized(out IReadOnlyList<string> adjustments)
|
||||
{
|
||||
var notes = new List<string>();
|
||||
|
||||
var factor = StuckOrphanQuarantineFactor;
|
||||
if (double.IsNaN(factor))
|
||||
{
|
||||
notes.Add($"{SectionName}:{nameof(StuckOrphanQuarantineFactor)} is NaN; using the default {DefaultStuckOrphanQuarantineFactor}.");
|
||||
factor = DefaultStuckOrphanQuarantineFactor;
|
||||
}
|
||||
else if (factor < 0)
|
||||
{
|
||||
factor = 0;
|
||||
}
|
||||
else if (factor > MaxStuckOrphanQuarantineFactor)
|
||||
{
|
||||
notes.Add($"{SectionName}:{nameof(StuckOrphanQuarantineFactor)} {factor} is above the maximum; clamped to {MaxStuckOrphanQuarantineFactor}.");
|
||||
factor = MaxStuckOrphanQuarantineFactor;
|
||||
}
|
||||
|
||||
var threshold = QuarantineAfterConsecutiveTimeouts;
|
||||
if (threshold < 1)
|
||||
{
|
||||
notes.Add($"{SectionName}:{nameof(QuarantineAfterConsecutiveTimeouts)} {threshold} is below 1; clamped to 1.");
|
||||
threshold = 1;
|
||||
}
|
||||
else if (threshold > MaxQuarantineAfterConsecutiveTimeouts)
|
||||
{
|
||||
notes.Add($"{SectionName}:{nameof(QuarantineAfterConsecutiveTimeouts)} {threshold} is above the maximum; clamped to {MaxQuarantineAfterConsecutiveTimeouts}.");
|
||||
threshold = MaxQuarantineAfterConsecutiveTimeouts;
|
||||
}
|
||||
|
||||
var alloc = MaxAllocBytesPerRun;
|
||||
if (alloc < 0)
|
||||
{
|
||||
alloc = 0;
|
||||
}
|
||||
else if (alloc > 0 && alloc < MinMaxAllocBytesPerRun)
|
||||
{
|
||||
notes.Add($"{SectionName}:{nameof(MaxAllocBytesPerRun)} {alloc} is below the minimum; clamped to {MinMaxAllocBytesPerRun}.");
|
||||
alloc = MinMaxAllocBytesPerRun;
|
||||
}
|
||||
|
||||
adjustments = notes;
|
||||
return new ScriptingOptions
|
||||
{
|
||||
MaxAllocBytesPerRun = alloc,
|
||||
QuarantineAfterConsecutiveTimeouts = threshold,
|
||||
StuckOrphanQuarantineFactor = factor,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+44
-11
@@ -38,13 +38,16 @@ public static class CalculationDriverFactoryExtensions
|
||||
/// <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>
|
||||
/// <param name="scripting">The node-wide <c>Scripting</c> options every Calculation driver inherits; a key
|
||||
/// authored in a driver's own config overrides that one knob. Null ⇒ the compiled defaults.</param>
|
||||
public static void Register(
|
||||
DriverFactoryRegistry registry,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
ScriptRootLogger? scriptRoot = null)
|
||||
ScriptRootLogger? scriptRoot = null,
|
||||
ScriptingOptions? scripting = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, scriptRoot));
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, scriptRoot, scripting));
|
||||
}
|
||||
|
||||
/// <summary>Public overload for the Server-side bootstrapper + test consumers.</summary>
|
||||
@@ -63,6 +66,18 @@ public static class CalculationDriverFactoryExtensions
|
||||
public static CalculationDriver CreateInstance(
|
||||
string driverInstanceId, string driverConfigJson,
|
||||
ILoggerFactory? loggerFactory, ScriptRootLogger? scriptRoot)
|
||||
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory, scriptRoot, nodeScripting: null);
|
||||
|
||||
/// <summary>The full overload: also takes the node-wide <c>Scripting</c> options the driver inherits.</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>
|
||||
/// <param name="nodeScripting">The node-wide run-guard options; null ⇒ the compiled defaults.</param>
|
||||
/// <returns>The constructed <see cref="CalculationDriver"/>.</returns>
|
||||
public static CalculationDriver CreateInstance(
|
||||
string driverInstanceId, string driverConfigJson,
|
||||
ILoggerFactory? loggerFactory, ScriptRootLogger? scriptRoot, ScriptingOptions? nodeScripting)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
@@ -71,15 +86,7 @@ public static class CalculationDriverFactoryExtensions
|
||||
?? 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 scripting = ResolveScripting(dto.MaxAllocBytesPerRun, dto.QuarantineAfterConsecutiveTimeouts, nodeScripting);
|
||||
|
||||
var options = new CalculationDriverOptions
|
||||
{
|
||||
@@ -94,6 +101,32 @@ public static class CalculationDriverFactoryExtensions
|
||||
logger: loggerFactory?.CreateLogger<CalculationDriver>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The run-guard options for one Calculation driver. The node-wide options are the base. A key authored
|
||||
/// in the driver's config overrides only its own knob, on a copy, so the shared node object is never
|
||||
/// written. With neither, the result is null and the guard uses its compiled defaults.
|
||||
/// </summary>
|
||||
/// <param name="maxAllocBytesPerRun">The driver config's <c>maxAllocBytesPerRun</c>, or null.</param>
|
||||
/// <param name="quarantineAfterConsecutiveTimeouts">The driver config's <c>quarantineAfterConsecutiveTimeouts</c>, or null.</param>
|
||||
/// <param name="nodeScripting">The node-wide options, or null.</param>
|
||||
/// <returns>The options to hand the driver, or null.</returns>
|
||||
internal static ScriptingOptions? ResolveScripting(
|
||||
long? maxAllocBytesPerRun, int? quarantineAfterConsecutiveTimeouts, ScriptingOptions? nodeScripting)
|
||||
{
|
||||
if (maxAllocBytesPerRun is null && quarantineAfterConsecutiveTimeouts is null) return nodeScripting;
|
||||
|
||||
var resolved = new ScriptingOptions();
|
||||
if (nodeScripting is not null)
|
||||
{
|
||||
resolved.MaxAllocBytesPerRun = nodeScripting.MaxAllocBytesPerRun;
|
||||
resolved.QuarantineAfterConsecutiveTimeouts = nodeScripting.QuarantineAfterConsecutiveTimeouts;
|
||||
resolved.StuckOrphanQuarantineFactor = nodeScripting.StuckOrphanQuarantineFactor;
|
||||
}
|
||||
if (maxAllocBytesPerRun is { } alloc) resolved.MaxAllocBytesPerRun = alloc;
|
||||
if (quarantineAfterConsecutiveTimeouts is { } n) resolved.QuarantineAfterConsecutiveTimeouts = n;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// <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
|
||||
|
||||
@@ -68,6 +68,8 @@ public static class DriverFactoryBootstrap
|
||||
// The calc driver needs the root script logger so a script failure fans out onto the
|
||||
// script-logs topic; resolve it here (null on nodes without the script pipeline wired).
|
||||
var scriptRoot = sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger>();
|
||||
// The node-wide Scripting section (bound + clamped in Program.cs); null in test graphs ⇒ defaults.
|
||||
var scripting = sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptingOptions>();
|
||||
// The Galaxy driver resolves its Gateway.ApiKeySecretRef secret: arm through the
|
||||
// shared ISecretResolver (registered unconditionally by AddZbSecrets on every node).
|
||||
var secretResolver = sp.GetRequiredService<ISecretResolver>();
|
||||
@@ -84,7 +86,7 @@ public static class DriverFactoryBootstrap
|
||||
// failures; without this it writes to NullLogger in production.
|
||||
ConfigureAbTransportLogging(sp);
|
||||
Register(registry, loggerFactory, secretResolver, primaryRoleSource, ResolveAbCipDefaultTransport(sp),
|
||||
scriptRoot);
|
||||
scriptRoot, scripting);
|
||||
return registry;
|
||||
});
|
||||
services.AddSingleton<IDriverFactory>(sp =>
|
||||
@@ -209,11 +211,12 @@ public static class DriverFactoryBootstrap
|
||||
ISecretResolver secretResolver,
|
||||
IDriverPrimaryRoleSource primaryRoleSource,
|
||||
Driver.AbCip.AbCipTransport abCipDefaultTransport,
|
||||
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
|
||||
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null,
|
||||
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptingOptions? scripting = null)
|
||||
{
|
||||
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry, loggerFactory, abCipDefaultTransport);
|
||||
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
|
||||
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot, scripting);
|
||||
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
|
||||
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
|
||||
@@ -365,10 +365,22 @@ if (hasDriver)
|
||||
// F8b — production virtual-tag evaluator (Roslyn-compiled scripts cached per expression).
|
||||
// Replaces the F8-default NullVirtualTagEvaluator so VirtualTagActor evaluates real user
|
||||
// scripts at runtime.
|
||||
// Node-wide script run-guard knobs (Scripting:MaxAllocBytesPerRun / QuarantineAfterConsecutiveTimeouts /
|
||||
// StuckOrphanQuarantineFactor). Bound once and clamped into sane ranges here, so an absurd value is a startup
|
||||
// Warning rather than an OverflowException inside a script run. The one instance is shared by every script
|
||||
// seam on the node: the virtual-tag evaluator below, the scripted-alarm engine (DriverHostActor, via
|
||||
// WithOtOpcUaRuntimeActors) and every Calculation driver (DriverFactoryBootstrap).
|
||||
var scriptingOptions = (builder.Configuration.GetSection(ScriptingOptions.SectionName).Get<ScriptingOptions>()
|
||||
?? new ScriptingOptions()).Sanitized(out var scriptingAdjustments);
|
||||
foreach (var adjustment in scriptingAdjustments)
|
||||
Log.Warning("Scripting misconfiguration detected at startup: {Adjustment}", adjustment);
|
||||
builder.Services.AddSingleton(scriptingOptions);
|
||||
|
||||
builder.Services.AddSingleton<RoslynVirtualTagEvaluator>(sp =>
|
||||
new RoslynVirtualTagEvaluator(
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynVirtualTagEvaluator>(),
|
||||
sp.GetRequiredService<ScriptRootLogger>()));
|
||||
sp.GetRequiredService<ScriptRootLogger>(),
|
||||
scripting: sp.GetRequiredService<ScriptingOptions>()));
|
||||
builder.Services.AddSingleton<IVirtualTagEvaluator>(sp => sp.GetRequiredService<RoslynVirtualTagEvaluator>());
|
||||
|
||||
// Script-log fan-out (Layer 0). The DPS publisher resolves the ActorSystem lazily so it never
|
||||
|
||||
@@ -202,6 +202,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
private readonly IHistoryWriter _historyWriter;
|
||||
private readonly IActorRef? _virtualTagHostOverride;
|
||||
private readonly ScriptRootLogger? _scriptRootLogger;
|
||||
|
||||
/// <summary>Node-wide script run-guard options for the scripted-alarm engine; null ⇒ compiled defaults.</summary>
|
||||
private readonly ScriptingOptions? _scripting;
|
||||
private readonly IActorRef? _scriptedAlarmHostOverride;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
|
||||
@@ -694,6 +697,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// capture to a mesh-partner Primary the cluster failure detector reports unreachable.</param>
|
||||
/// <param name="historyUpdateRoleView">Where the OPC UA HistoryUpdate verdict is published for the node manager's
|
||||
/// HistoryUpdate Primary gate. Null on nodes that wire no redundancy manager.</param>
|
||||
/// <param name="scripting">The node-wide <c>Scripting</c> run-guard options handed to the scripted-alarm
|
||||
/// engine. Null ⇒ the compiled defaults.</param>
|
||||
/// <returns>The Akka.NET <see cref="Akka.Actor.Props"/> used to spawn this actor.</returns>
|
||||
public static Props Props(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
|
||||
@@ -728,7 +733,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
Func<CommonsNodeId, bool>? sharesAlarmQueueWithLocal = null,
|
||||
TimeSpan? applyOutcomeTimeout = null,
|
||||
bool captureWhilePrimaryUnreachable = true,
|
||||
IHistoryUpdateRoleView? historyUpdateRoleView = null) =>
|
||||
IHistoryUpdateRoleView? historyUpdateRoleView = null,
|
||||
ScriptingOptions? scripting = null) =>
|
||||
// WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an
|
||||
// expression tree. Six IActorRef? parameters and several interface-typed ones mean a
|
||||
// mis-ordered argument is usually type-compatible and therefore compiles clean, then binds
|
||||
@@ -741,7 +747,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher,
|
||||
alarmStateStore, telemetryHub, primaryRoleSource, historizationRoleView,
|
||||
sharesHistorizationScopeWithLocal, applyWedgeTimeout, reprovisionBackoff, commsRecoveryAuditDelay,
|
||||
sharesAlarmQueueWithLocal, applyOutcomeTimeout, captureWhilePrimaryUnreachable, historyUpdateRoleView));
|
||||
sharesAlarmQueueWithLocal, applyOutcomeTimeout, captureWhilePrimaryUnreachable, historyUpdateRoleView,
|
||||
scripting));
|
||||
|
||||
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
|
||||
/// <param name="dbFactory">Database context factory for configuration database access.</param>
|
||||
@@ -830,6 +837,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// (<see cref="PrimaryGatePolicy.ShouldServeHistoryUpdate"/> — the data-plane verdict, but closed until the first
|
||||
/// redundancy snapshot) is published for the node manager's HistoryUpdate Primary gate. Null on nodes that wire no
|
||||
/// redundancy manager.</param>
|
||||
/// <param name="scripting">The node-wide <c>Scripting</c> run-guard options handed to the scripted-alarm
|
||||
/// engine. Null ⇒ the compiled defaults.</param>
|
||||
public DriverHostActor(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
|
||||
CommonsNodeId localNode,
|
||||
@@ -863,8 +872,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
Func<CommonsNodeId, bool>? sharesAlarmQueueWithLocal = null,
|
||||
TimeSpan? applyOutcomeTimeout = null,
|
||||
bool captureWhilePrimaryUnreachable = true,
|
||||
IHistoryUpdateRoleView? historyUpdateRoleView = null)
|
||||
IHistoryUpdateRoleView? historyUpdateRoleView = null,
|
||||
ScriptingOptions? scripting = null)
|
||||
{
|
||||
_scripting = scripting;
|
||||
_captureWhilePrimaryUnreachable = captureWhilePrimaryUnreachable;
|
||||
_historyUpdateRoleView = historyUpdateRoleView;
|
||||
_applyOutcomeTimeout = applyOutcomeTimeout ?? DefaultApplyOutcomeTimeout;
|
||||
@@ -1181,7 +1192,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
var store = _alarmStateStore;
|
||||
var upstream = new DependencyMuxTagUpstreamSource();
|
||||
var engine = new ScriptedAlarmEngine(
|
||||
upstream, store, new ScriptLoggerFactory(_scriptRootLogger.Logger), _scriptRootLogger.Logger);
|
||||
upstream, store, new ScriptLoggerFactory(_scriptRootLogger.Logger), _scriptRootLogger.Logger,
|
||||
scripting: _scripting);
|
||||
_scriptedAlarmHost = Context.ActorOf(
|
||||
ScriptedAlarmHostActor.Props(
|
||||
_opcUaPublishActor, _dependencyMux, upstream, engine, _localNode, telemetryHub: _telemetryHub),
|
||||
|
||||
@@ -638,7 +638,10 @@ public static class ServiceCollectionExtensions
|
||||
sharesAlarmQueueWithLocal: BuildAlarmQueuePeerPredicate(
|
||||
resolver.GetService<IConfiguration>(), roleInfo, replicationPeerHost),
|
||||
// The HistoryUpdate verdict (closed until the first redundancy snapshot).
|
||||
historyUpdateRoleView: resolver.GetService<IHistoryUpdateRoleView>()),
|
||||
historyUpdateRoleView: resolver.GetService<IHistoryUpdateRoleView>(),
|
||||
// The node-wide Scripting section (bound + clamped in Program.cs) for the scripted-alarm
|
||||
// engine. Unbound in test graphs ⇒ null ⇒ the compiled defaults.
|
||||
scripting: resolver.GetService<ScriptingOptions>()),
|
||||
DriverHostActorName);
|
||||
registry.Register<DriverHostActorKey>(driverHost);
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ScriptingOptions.Sanitized"/> — the bind-time clamp of the <c>Scripting</c> section's numeric
|
||||
/// knobs. An absurd value must be pulled into a sane range at bind time rather than surfacing later as an
|
||||
/// <see cref="OverflowException"/> inside a script run (<c>timeout * StuckOrphanQuarantineFactor</c>).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptingOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Defaults_pass_through_unchanged_with_no_adjustments()
|
||||
{
|
||||
var sanitized = new ScriptingOptions().Sanitized(out var adjustments);
|
||||
|
||||
adjustments.ShouldBeEmpty();
|
||||
sanitized.MaxAllocBytesPerRun.ShouldBe(ScriptingOptions.DefaultMaxAllocBytesPerRun);
|
||||
sanitized.QuarantineAfterConsecutiveTimeouts.ShouldBe(ScriptingOptions.DefaultQuarantineAfterConsecutiveTimeouts);
|
||||
sanitized.StuckOrphanQuarantineFactor.ShouldBe(ScriptingOptions.DefaultStuckOrphanQuarantineFactor);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(double.MaxValue, ScriptingOptions.MaxStuckOrphanQuarantineFactor)]
|
||||
[InlineData(double.PositiveInfinity, ScriptingOptions.MaxStuckOrphanQuarantineFactor)]
|
||||
[InlineData(1e300, ScriptingOptions.MaxStuckOrphanQuarantineFactor)]
|
||||
[InlineData(double.NaN, ScriptingOptions.DefaultStuckOrphanQuarantineFactor)]
|
||||
[InlineData(-5, 0)]
|
||||
[InlineData(double.NegativeInfinity, 0)]
|
||||
[InlineData(0, 0)]
|
||||
[InlineData(2.5, 2.5)]
|
||||
public void StuckOrphanQuarantineFactor_is_clamped(double configured, double expected)
|
||||
{
|
||||
var sanitized = new ScriptingOptions { StuckOrphanQuarantineFactor = configured }.Sanitized(out var adjustments);
|
||||
|
||||
sanitized.StuckOrphanQuarantineFactor.ShouldBe(expected);
|
||||
// Negative already means "disabled" and is documented as such; normalising it to 0 is not worth a warning.
|
||||
if (!double.IsNaN(configured) && configured >= 0 && configured <= ScriptingOptions.MaxStuckOrphanQuarantineFactor)
|
||||
adjustments.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1)]
|
||||
[InlineData(-3, 1)]
|
||||
[InlineData(int.MaxValue, ScriptingOptions.MaxQuarantineAfterConsecutiveTimeouts)]
|
||||
[InlineData(5, 5)]
|
||||
public void QuarantineAfterConsecutiveTimeouts_is_clamped(int configured, int expected) =>
|
||||
new ScriptingOptions { QuarantineAfterConsecutiveTimeouts = configured }.Sanitized(out _)
|
||||
.QuarantineAfterConsecutiveTimeouts.ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1L, 0L)]
|
||||
[InlineData(0L, 0L)]
|
||||
[InlineData(1024L, ScriptingOptions.MinMaxAllocBytesPerRun)]
|
||||
[InlineData(long.MaxValue, long.MaxValue)]
|
||||
public void MaxAllocBytesPerRun_is_clamped(long configured, long expected) =>
|
||||
new ScriptingOptions { MaxAllocBytesPerRun = configured }.Sanitized(out _)
|
||||
.MaxAllocBytesPerRun.ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void An_out_of_range_value_is_reported_as_an_adjustment_naming_the_key()
|
||||
{
|
||||
_ = new ScriptingOptions { StuckOrphanQuarantineFactor = 1e300 }.Sanitized(out var adjustments);
|
||||
|
||||
adjustments.ShouldHaveSingleItem().ShouldContain("Scripting:StuckOrphanQuarantineFactor");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitized_returns_a_copy_and_leaves_the_source_untouched()
|
||||
{
|
||||
var source = new ScriptingOptions { StuckOrphanQuarantineFactor = 1e300 };
|
||||
|
||||
var sanitized = source.Sanitized(out _);
|
||||
|
||||
sanitized.ShouldNotBeSameAs(source);
|
||||
source.StuckOrphanQuarantineFactor.ShouldBe(1e300);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The guard takes options from more places than the bind (the Calculation driver's per-driver DTO, tests),
|
||||
/// so it must not overflow either: an absurd factor reaching the guard directly is clamped, and a refusal
|
||||
/// behind a young orphan is still a plain refusal rather than an <see cref="OverflowException"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_absurd_factor_reaching_the_guard_directly_does_not_overflow_on_a_refusal()
|
||||
{
|
||||
var guard = new ScriptRunGuard(new ScriptingOptions { StuckOrphanQuarantineFactor = double.MaxValue });
|
||||
const string spin =
|
||||
"""
|
||||
var end = System.DateTime.UtcNow.AddSeconds(3);
|
||||
while (System.DateTime.UtcNow < end) { }
|
||||
return 1;
|
||||
""";
|
||||
using var eval = ScriptEvaluator<FakeScriptContext, int>.Compile(spin);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var timeout = TimeSpan.FromMilliseconds(60);
|
||||
|
||||
await Should.ThrowAsync<ScriptTimeoutException>(
|
||||
() => guard.RunAsync("k", spin, eval, new FakeScriptContext(), timeout, ct));
|
||||
// The refusal path evaluates timeout * factor. Unclamped, TimeSpan * double.MaxValue throws OverflowException.
|
||||
await Should.ThrowAsync<ScriptRunRefusedException>(
|
||||
() => guard.RunAsync("k", spin, eval, new FakeScriptContext(), timeout, ct));
|
||||
// A huge timeout times the clamped maximum factor must not overflow either.
|
||||
await Should.ThrowAsync<ScriptRunRefusedException>(
|
||||
() => guard.RunAsync("k", spin, eval, new FakeScriptContext(), TimeSpan.FromDays(365 * 1000), ct));
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The Calculation driver's run-guard options: the node-wide <c>Scripting</c> section is the base, and a key
|
||||
/// authored in the driver's own config overrides only that knob. Before the section was bound, a Calculation
|
||||
/// driver always ran on the compiled defaults whatever the node configured.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CalculationScriptingOptionsResolutionTests
|
||||
{
|
||||
private static readonly ScriptingOptions Node = new()
|
||||
{
|
||||
MaxAllocBytesPerRun = 8L * 1024 * 1024,
|
||||
QuarantineAfterConsecutiveTimeouts = 7,
|
||||
StuckOrphanQuarantineFactor = 4,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void No_driver_keys_and_no_node_options_resolves_null_so_the_guard_uses_its_defaults() =>
|
||||
CalculationDriverFactoryExtensions.ResolveScripting(null, null, null).ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void No_driver_keys_inherits_every_node_knob()
|
||||
{
|
||||
var resolved = CalculationDriverFactoryExtensions.ResolveScripting(null, null, Node).ShouldNotBeNull();
|
||||
|
||||
resolved.MaxAllocBytesPerRun.ShouldBe(Node.MaxAllocBytesPerRun);
|
||||
resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(7);
|
||||
resolved.StuckOrphanQuarantineFactor.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_driver_key_overrides_only_its_own_knob_and_the_node_object_is_not_mutated()
|
||||
{
|
||||
var resolved = CalculationDriverFactoryExtensions.ResolveScripting(null, 2, Node).ShouldNotBeNull();
|
||||
|
||||
resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(2);
|
||||
resolved.MaxAllocBytesPerRun.ShouldBe(Node.MaxAllocBytesPerRun, "the unauthored key keeps the node value");
|
||||
resolved.StuckOrphanQuarantineFactor.ShouldBe(4, "the stuck factor has no driver key; it is node-wide");
|
||||
resolved.ShouldNotBeSameAs(Node);
|
||||
Node.QuarantineAfterConsecutiveTimeouts.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_driver_key_with_no_node_options_layers_on_the_compiled_defaults()
|
||||
{
|
||||
var resolved = CalculationDriverFactoryExtensions.ResolveScripting(0L, null, null).ShouldNotBeNull();
|
||||
|
||||
resolved.MaxAllocBytesPerRun.ShouldBe(0);
|
||||
resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(ScriptingOptions.DefaultQuarantineAfterConsecutiveTimeouts);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user