feat(runtime): per-script execution timeout overriding the global default (#9)
Spec promised a per-script timeout but only the global ScriptExecutionTimeoutSeconds existed. Add nullable TemplateScript.ExecutionTimeoutSeconds threaded through EF + flattening (ResolvedScript) to ScriptExecutionActor/AlarmExecutionActor, which use perScript ?? global for the execution CTS. Includes the EF migration for the new column.
This commit is contained in:
@@ -52,6 +52,15 @@ public class TemplateScript
|
||||
/// </summary>
|
||||
public TimeSpan? MinTimeBetweenRuns { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-script execution timeout in seconds, or null to use the site's global
|
||||
/// default (<c>SiteRuntimeOptions.ScriptExecutionTimeoutSeconds</c>). A
|
||||
/// non-positive value (≤ 0) is treated the same as null — i.e. fall back to
|
||||
/// the global default — by the Site Runtime. Seconds (not a TimeSpan) to keep
|
||||
/// the unit consistent with the global option it overrides.
|
||||
/// </summary>
|
||||
public int? ExecutionTimeoutSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when this row was copied from the base template and has not been
|
||||
/// overridden on the derived template. Changes to the base flow downward
|
||||
|
||||
@@ -174,6 +174,14 @@ public sealed record ResolvedScript
|
||||
|
||||
/// <summary>Gets the minimum time between script executions.</summary>
|
||||
public TimeSpan? MinTimeBetweenRuns { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-script execution timeout in seconds, or null to use the site's global
|
||||
/// default. A non-positive value is treated as null (use global) by the Site
|
||||
/// Runtime. Seconds (not TimeSpan) to match the global option it overrides.
|
||||
/// </summary>
|
||||
public int? ExecutionTimeoutSeconds { get; init; }
|
||||
|
||||
/// <summary>Gets the source of this script.</summary>
|
||||
public string Source { get; init; } = "Template";
|
||||
|
||||
|
||||
+5
@@ -178,6 +178,11 @@ public class TemplateScriptConfiguration : IEntityTypeConfiguration<TemplateScri
|
||||
builder.Property(s => s.ReturnDefinition)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
// M2.5 (#9): nullable per-script execution timeout (seconds). Null = use
|
||||
// the site's global ScriptExecutionTimeoutSeconds default.
|
||||
builder.Property(s => s.ExecutionTimeoutSeconds)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasIndex(s => new { s.TemplateId, s.Name }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
+1733
File diff suppressed because it is too large
Load Diff
+28
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTemplateScriptExecutionTimeout : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ExecutionTimeoutSeconds",
|
||||
table: "TemplateScripts",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExecutionTimeoutSeconds",
|
||||
table: "TemplateScripts");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -1313,6 +1313,9 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("ExecutionTimeoutSeconds")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IsInherited")
|
||||
.HasColumnType("bit");
|
||||
|
||||
|
||||
@@ -72,6 +72,15 @@ public class AlarmActor : ReceiveActor
|
||||
private readonly string? _onTriggerScriptName;
|
||||
private readonly Script<object?>? _onTriggerCompiledScript;
|
||||
|
||||
/// <summary>
|
||||
/// M2.5 (#9): the on-trigger script's per-script execution timeout in seconds,
|
||||
/// or null to use the global default. Forwarded to each spawned
|
||||
/// <see cref="AlarmExecutionActor"/>, which applies <c>perScript ?? global</c>
|
||||
/// (treating ≤ 0 as "use global"). The value comes from the referenced
|
||||
/// on-trigger script's <see cref="ResolvedScript.ExecutionTimeoutSeconds"/>.
|
||||
/// </summary>
|
||||
private readonly int? _onTriggerExecutionTimeoutSeconds;
|
||||
|
||||
// Expression trigger: compiled expression + the attribute snapshot it
|
||||
// evaluates against. This field is the single home for the compiled
|
||||
// expression on the hot path.
|
||||
@@ -107,6 +116,9 @@ public class AlarmActor : ReceiveActor
|
||||
/// <param name="serviceProvider">Optional DI service provider used to resolve the optional
|
||||
/// <see cref="ISiteEventLogger"/> for M1.5 <c>alarm</c> operational events. Fire-and-forget;
|
||||
/// a logging failure never affects alarm evaluation.</param>
|
||||
/// <param name="onTriggerExecutionTimeoutSeconds">M2.5 (#9): the on-trigger script's per-script
|
||||
/// execution timeout in seconds (from its <see cref="ResolvedScript.ExecutionTimeoutSeconds"/>),
|
||||
/// or null/non-positive to use the global default.</param>
|
||||
public AlarmActor(
|
||||
string alarmName,
|
||||
string instanceName,
|
||||
@@ -119,7 +131,9 @@ public class AlarmActor : ReceiveActor
|
||||
Script<object?>? compiledTriggerExpression = null,
|
||||
IReadOnlyDictionary<string, object?>? initialAttributes = null,
|
||||
ISiteHealthCollector? healthCollector = null,
|
||||
IServiceProvider? serviceProvider = null)
|
||||
IServiceProvider? serviceProvider = null,
|
||||
// M2.5 (#9): per-script timeout for the on-trigger script (null = global).
|
||||
int? onTriggerExecutionTimeoutSeconds = null)
|
||||
{
|
||||
_alarmName = alarmName;
|
||||
_instanceName = instanceName;
|
||||
@@ -135,6 +149,7 @@ public class AlarmActor : ReceiveActor
|
||||
_priority = alarmConfig.PriorityLevel;
|
||||
_onTriggerScriptName = alarmConfig.OnTriggerScriptCanonicalName;
|
||||
_onTriggerCompiledScript = onTriggerCompiledScript;
|
||||
_onTriggerExecutionTimeoutSeconds = onTriggerExecutionTimeoutSeconds;
|
||||
_compiledTriggerExpression = compiledTriggerExpression;
|
||||
|
||||
// Seed the trigger-expression attribute snapshot from the instance's
|
||||
@@ -574,7 +589,9 @@ public class AlarmActor : ReceiveActor
|
||||
_instanceActor,
|
||||
_sharedScriptLibrary,
|
||||
_options,
|
||||
_logger));
|
||||
_logger,
|
||||
// M2.5 (#9): per-script timeout from the on-trigger script (null = global).
|
||||
_onTriggerExecutionTimeoutSeconds));
|
||||
|
||||
Context.ActorOf(props, executionId);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ public class AlarmExecutionActor : ReceiveActor
|
||||
/// <param name="sharedScriptLibrary">Shared script library providing common utilities.</param>
|
||||
/// <param name="options">Site runtime configuration options, including the execution timeout.</param>
|
||||
/// <param name="logger">Logger for execution diagnostics.</param>
|
||||
/// <param name="executionTimeoutSeconds">M2.5 (#9): the on-trigger script's per-script execution timeout in seconds. Null or non-positive falls back to the global <see cref="SiteRuntimeOptions.ScriptExecutionTimeoutSeconds"/>.</param>
|
||||
public AlarmExecutionActor(
|
||||
string alarmName,
|
||||
string instanceName,
|
||||
@@ -38,7 +39,10 @@ public class AlarmExecutionActor : ReceiveActor
|
||||
IActorRef instanceActor,
|
||||
SharedScriptLibrary sharedScriptLibrary,
|
||||
SiteRuntimeOptions options,
|
||||
ILogger logger)
|
||||
ILogger logger,
|
||||
// M2.5 (#9): per-script execution timeout override (seconds) for the
|
||||
// alarm on-trigger script. Null or non-positive falls back to the global.
|
||||
int? executionTimeoutSeconds = null)
|
||||
{
|
||||
var self = Self;
|
||||
var parent = Context.Parent;
|
||||
@@ -46,7 +50,8 @@ public class AlarmExecutionActor : ReceiveActor
|
||||
ExecuteAlarmScript(
|
||||
alarmName, instanceName, level, priority, message,
|
||||
compiledScript, instanceActor,
|
||||
sharedScriptLibrary, options, self, parent, logger);
|
||||
sharedScriptLibrary, options, self, parent, logger,
|
||||
executionTimeoutSeconds);
|
||||
}
|
||||
|
||||
private static void ExecuteAlarmScript(
|
||||
@@ -61,9 +66,15 @@ public class AlarmExecutionActor : ReceiveActor
|
||||
SiteRuntimeOptions options,
|
||||
IActorRef self,
|
||||
IActorRef parent,
|
||||
ILogger logger)
|
||||
ILogger logger,
|
||||
int? executionTimeoutSeconds)
|
||||
{
|
||||
var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds);
|
||||
// M2.5 (#9): per-script timeout overrides the global default. A null or
|
||||
// non-positive per-script value (≤ 0) falls back to the global.
|
||||
var timeout = TimeSpan.FromSeconds(
|
||||
executionTimeoutSeconds is { } perScript && perScript > 0
|
||||
? perScript
|
||||
: options.ScriptExecutionTimeoutSeconds);
|
||||
|
||||
// SiteRuntime-009: run the alarm on-trigger body on the dedicated
|
||||
// script-execution scheduler, not the shared .NET thread pool.
|
||||
|
||||
@@ -754,6 +754,10 @@ public class InstanceActor : ReceiveActor
|
||||
foreach (var alarm in _configuration.Alarms)
|
||||
{
|
||||
Script<object?>? onTriggerScript = null;
|
||||
// M2.5 (#9): the on-trigger script's per-script execution timeout,
|
||||
// captured from its ResolvedScript so the AlarmExecutionActor can
|
||||
// apply perScript ?? global. Null when there is no on-trigger script.
|
||||
int? onTriggerTimeoutSeconds = null;
|
||||
|
||||
// Compile on-trigger script if defined
|
||||
if (!string.IsNullOrEmpty(alarm.OnTriggerScriptCanonicalName))
|
||||
@@ -763,6 +767,7 @@ public class InstanceActor : ReceiveActor
|
||||
|
||||
if (triggerScriptDef != null)
|
||||
{
|
||||
onTriggerTimeoutSeconds = triggerScriptDef.ExecutionTimeoutSeconds;
|
||||
var result = _compilationService.Compile(
|
||||
$"alarm-trigger-{alarm.CanonicalName}", triggerScriptDef.Code);
|
||||
if (result.IsSuccess)
|
||||
@@ -794,7 +799,9 @@ public class InstanceActor : ReceiveActor
|
||||
triggerExpression,
|
||||
attributeSnapshot,
|
||||
_healthCollector,
|
||||
_serviceProvider));
|
||||
_serviceProvider,
|
||||
// M2.5 (#9): per-script timeout for the alarm on-trigger script.
|
||||
onTriggerTimeoutSeconds));
|
||||
|
||||
var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}");
|
||||
_alarmActors[alarm.CanonicalName] = actorRef;
|
||||
|
||||
@@ -43,6 +43,13 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
private Script<object?>? _compiledScript;
|
||||
private ScriptTriggerConfig? _triggerConfig;
|
||||
private TimeSpan? _minTimeBetweenRuns;
|
||||
|
||||
/// <summary>
|
||||
/// M2.5 (#9): the per-script execution timeout in seconds, or null to use the
|
||||
/// global default. Threaded down to each spawned <see cref="ScriptExecutionActor"/>,
|
||||
/// which applies <c>perScript ?? global</c> (and treats ≤ 0 as "use global").
|
||||
/// </summary>
|
||||
private readonly int? _executionTimeoutSeconds;
|
||||
private DateTimeOffset _lastExecutionTime = DateTimeOffset.MinValue;
|
||||
private int _executionCounter;
|
||||
private readonly Commons.Types.Scripts.ScriptScope _scope;
|
||||
@@ -112,6 +119,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
_healthCollector = healthCollector;
|
||||
_serviceProvider = serviceProvider;
|
||||
_minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns;
|
||||
_executionTimeoutSeconds = scriptConfig.ExecutionTimeoutSeconds;
|
||||
_scope = scriptConfig.Scope;
|
||||
_compiledTriggerExpression = compiledTriggerExpression;
|
||||
|
||||
@@ -426,7 +434,9 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
_serviceProvider,
|
||||
// Audit Log #23 (ParentExecutionId): null for trigger-driven runs;
|
||||
// an inbound-API-routed call supplies the inbound request's id.
|
||||
parentExecutionId));
|
||||
parentExecutionId,
|
||||
// M2.5 (#9): per-script timeout override (null = use global).
|
||||
_executionTimeoutSeconds));
|
||||
|
||||
Context.ActorOf(props, executionId);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ public class ScriptExecutionActor : ReceiveActor
|
||||
/// <param name="healthCollector">Optional health collector for recording execution metrics.</param>
|
||||
/// <param name="serviceProvider">Optional DI service provider for script execution services.</param>
|
||||
/// <param name="parentExecutionId">ExecutionId of the spawning inbound-API execution for audit correlation; null for normal runs.</param>
|
||||
/// <param name="executionTimeoutSeconds">M2.5 (#9): per-script execution timeout in seconds. Null or non-positive falls back to the global <see cref="SiteRuntimeOptions.ScriptExecutionTimeoutSeconds"/>.</param>
|
||||
public ScriptExecutionActor(
|
||||
string scriptName,
|
||||
string instanceName,
|
||||
@@ -65,7 +66,10 @@ public class ScriptExecutionActor : ReceiveActor
|
||||
// Audit Log #23 (ParentExecutionId): the spawning execution's
|
||||
// ExecutionId for an inbound-API-routed call. Null for normal
|
||||
// (tag-change / timer) runs and nested Script.Call invocations.
|
||||
Guid? parentExecutionId = null)
|
||||
Guid? parentExecutionId = null,
|
||||
// M2.5 (#9): per-script execution timeout override (seconds). Null or
|
||||
// non-positive falls back to the global ScriptExecutionTimeoutSeconds.
|
||||
int? executionTimeoutSeconds = null)
|
||||
{
|
||||
// Immediately begin execution
|
||||
var self = Self;
|
||||
@@ -75,7 +79,7 @@ public class ScriptExecutionActor : ReceiveActor
|
||||
scriptName, instanceName, compiledScript, parameters, callDepth,
|
||||
instanceActor, sharedScriptLibrary, options, replyTo, correlationId,
|
||||
self, parent, logger, scope, healthCollector, serviceProvider,
|
||||
parentExecutionId);
|
||||
parentExecutionId, executionTimeoutSeconds);
|
||||
}
|
||||
|
||||
private static void ExecuteScript(
|
||||
@@ -95,9 +99,15 @@ public class ScriptExecutionActor : ReceiveActor
|
||||
Commons.Types.Scripts.ScriptScope scope,
|
||||
ISiteHealthCollector? healthCollector,
|
||||
IServiceProvider? serviceProvider,
|
||||
Guid? parentExecutionId)
|
||||
Guid? parentExecutionId,
|
||||
int? executionTimeoutSeconds)
|
||||
{
|
||||
var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds);
|
||||
// M2.5 (#9): per-script timeout overrides the global default. A null or
|
||||
// non-positive per-script value (≤ 0) falls back to the global.
|
||||
var timeout = TimeSpan.FromSeconds(
|
||||
executionTimeoutSeconds is { } perScript && perScript > 0
|
||||
? perScript
|
||||
: options.ScriptExecutionTimeoutSeconds);
|
||||
|
||||
// SiteRuntime-009: run the script body on the dedicated script-execution
|
||||
// scheduler, not the shared .NET thread pool, so blocking script I/O cannot
|
||||
|
||||
@@ -141,7 +141,8 @@ public class DiffService
|
||||
a.TriggerConfiguration == b.TriggerConfiguration &&
|
||||
a.ParameterDefinitions == b.ParameterDefinitions &&
|
||||
a.ReturnDefinition == b.ReturnDefinition &&
|
||||
a.MinTimeBetweenRuns == b.MinTimeBetweenRuns;
|
||||
a.MinTimeBetweenRuns == b.MinTimeBetweenRuns &&
|
||||
a.ExecutionTimeoutSeconds == b.ExecutionTimeoutSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// Compares two <see cref="ConnectionConfig"/> instances for equality across
|
||||
|
||||
@@ -830,6 +830,10 @@ public class FlatteningService
|
||||
ParameterDefinitions = script.ParameterDefinitions,
|
||||
ReturnDefinition = script.ReturnDefinition,
|
||||
MinTimeBetweenRuns = script.MinTimeBetweenRuns,
|
||||
// M2.5 (#9): per-script timeout rides along on the winning row.
|
||||
// Scripts inherit/override at whole-row granularity (no per-field
|
||||
// merge), so this follows the same rule as the script body/MinTime.
|
||||
ExecutionTimeoutSeconds = script.ExecutionTimeoutSeconds,
|
||||
Source = source
|
||||
};
|
||||
idByName[script.Name] = script.Id;
|
||||
|
||||
@@ -83,7 +83,10 @@ public class RevisionHashService
|
||||
TriggerConfiguration = s.TriggerConfiguration,
|
||||
ParameterDefinitions = s.ParameterDefinitions,
|
||||
ReturnDefinition = s.ReturnDefinition,
|
||||
MinTimeBetweenRunsTicks = s.MinTimeBetweenRuns?.Ticks
|
||||
MinTimeBetweenRunsTicks = s.MinTimeBetweenRuns?.Ticks,
|
||||
// M2.5 (#9): include the per-script timeout so a change to it
|
||||
// is detected as a configuration change (staleness/redeploy).
|
||||
ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds
|
||||
})
|
||||
.ToList(),
|
||||
Connections = configuration.Connections is { Count: > 0 }
|
||||
@@ -244,6 +247,10 @@ public class RevisionHashService
|
||||
/// </summary>
|
||||
public string Code { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// M2.5 (#9): the per-script execution timeout in seconds (null = global).
|
||||
/// </summary>
|
||||
public int? ExecutionTimeoutSeconds { get; init; }
|
||||
/// <summary>
|
||||
/// Whether the script is locked.
|
||||
/// </summary>
|
||||
public bool IsLocked { get; init; }
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||
/// Override granularity:
|
||||
/// - Attributes: Value and Description overridable; DataType and DataSourceReference fixed.
|
||||
/// - Alarms: Priority, TriggerConfiguration, Description, OnTriggerScript overridable; Name and TriggerType fixed.
|
||||
/// - Scripts: Code, TriggerConfiguration, MinTimeBetweenRuns, params/return overridable; Name fixed.
|
||||
/// - Scripts: Code, TriggerConfiguration, MinTimeBetweenRuns, ExecutionTimeoutSeconds, params/return overridable; Name fixed.
|
||||
/// - Lock flag applies to the entire member (attribute/alarm/script).
|
||||
/// </summary>
|
||||
public static class LockEnforcer
|
||||
|
||||
@@ -687,6 +687,8 @@ public class TemplateService
|
||||
existing.TriggerType = proposed.TriggerType;
|
||||
existing.TriggerConfiguration = proposed.TriggerConfiguration;
|
||||
existing.MinTimeBetweenRuns = proposed.MinTimeBetweenRuns;
|
||||
// M2.5 (#9): per-script execution timeout is an overridable field.
|
||||
existing.ExecutionTimeoutSeconds = proposed.ExecutionTimeoutSeconds;
|
||||
existing.ParameterDefinitions = proposed.ParameterDefinitions;
|
||||
existing.ReturnDefinition = proposed.ReturnDefinition;
|
||||
existing.IsLocked = proposed.IsLocked;
|
||||
@@ -1013,6 +1015,7 @@ public class TemplateService
|
||||
ParameterDefinitions = script.ParameterDefinitions,
|
||||
ReturnDefinition = script.ReturnDefinition,
|
||||
MinTimeBetweenRuns = script.MinTimeBetweenRuns,
|
||||
ExecutionTimeoutSeconds = script.ExecutionTimeoutSeconds,
|
||||
IsInherited = true,
|
||||
LockedInDerived = false,
|
||||
});
|
||||
|
||||
@@ -2339,6 +2339,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
ParameterDefinitions = s.ParameterDefinitions,
|
||||
ReturnDefinition = s.ReturnDefinition,
|
||||
MinTimeBetweenRuns = s.MinTimeBetweenRuns,
|
||||
ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds,
|
||||
Source = "Template",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,7 +99,10 @@ public sealed record TemplateScriptDto(
|
||||
string? ParameterDefinitions,
|
||||
string? ReturnDefinition,
|
||||
bool IsLocked,
|
||||
TimeSpan? MinTimeBetweenRuns);
|
||||
TimeSpan? MinTimeBetweenRuns,
|
||||
// M2.5 (#9): per-script execution timeout (seconds). Additive trailing field;
|
||||
// null on bundles written before this field existed.
|
||||
int? ExecutionTimeoutSeconds = null);
|
||||
|
||||
public sealed record TemplateCompositionDto(
|
||||
string InstanceName,
|
||||
|
||||
@@ -74,7 +74,8 @@ public sealed class EntitySerializer
|
||||
ParameterDefinitions: s.ParameterDefinitions,
|
||||
ReturnDefinition: s.ReturnDefinition,
|
||||
IsLocked: s.IsLocked,
|
||||
MinTimeBetweenRuns: s.MinTimeBetweenRuns)).ToList(),
|
||||
MinTimeBetweenRuns: s.MinTimeBetweenRuns,
|
||||
ExecutionTimeoutSeconds: s.ExecutionTimeoutSeconds)).ToList(),
|
||||
Compositions: t.Compositions.Select(c => new TemplateCompositionDto(
|
||||
InstanceName: c.InstanceName,
|
||||
ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList());
|
||||
@@ -227,6 +228,7 @@ public sealed class EntitySerializer
|
||||
ReturnDefinition = s.ReturnDefinition,
|
||||
IsLocked = s.IsLocked,
|
||||
MinTimeBetweenRuns = s.MinTimeBetweenRuns,
|
||||
ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds,
|
||||
});
|
||||
}
|
||||
return t;
|
||||
|
||||
+28
@@ -61,6 +61,34 @@ public class TemplateEngineRepositoryTests : IDisposable
|
||||
Assert.Equal("Slot1", loaded.Compositions.First().InstanceName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TemplateScript_ExecutionTimeoutSeconds_RoundTripsThroughEf()
|
||||
{
|
||||
// M2.5 (#9): the nullable per-script execution timeout must persist and
|
||||
// reload through EF — both an explicit value and a null (use-global).
|
||||
var template = new Template("TimeoutTemplate");
|
||||
template.Scripts.Add(new TemplateScript("WithTimeout", "return 1;")
|
||||
{
|
||||
ExecutionTimeoutSeconds = 45
|
||||
});
|
||||
template.Scripts.Add(new TemplateScript("NoTimeout", "return 2;")); // null
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Detach so the reload comes from the store, not the change tracker.
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var loaded = await _context.Templates
|
||||
.Include(t => t.Scripts)
|
||||
.SingleAsync(t => t.Name == "TimeoutTemplate");
|
||||
|
||||
var withTimeout = loaded.Scripts.Single(s => s.Name == "WithTimeout");
|
||||
Assert.Equal(45, withTimeout.ExecutionTimeoutSeconds);
|
||||
|
||||
var noTimeout = loaded.Scripts.Single(s => s.Name == "NoTimeout");
|
||||
Assert.Null(noTimeout.ExecutionTimeoutSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTemplateWithChildrenAsync_ReturnsNull_WhenTemplateDoesNotExist()
|
||||
{
|
||||
|
||||
@@ -185,6 +185,84 @@ public class ExecutionActorTests : TestKit, IDisposable
|
||||
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptExecutionActor_PerScriptTimeout_OverridesLongerGlobal()
|
||||
{
|
||||
// M2.5 (#9): a short per-script timeout (1s) must win over a long global
|
||||
// (300s), so the busy loop is cancelled at the per-script value.
|
||||
var compiled = CompileScript(
|
||||
"while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
|
||||
var replyTo = CreateTestProbe();
|
||||
var instanceActor = CreateTestProbe();
|
||||
|
||||
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
|
||||
"Slow", "Inst1", compiled, null, 0,
|
||||
instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 300),
|
||||
replyTo.Ref, "corr-perscript", NullLogger.Instance,
|
||||
ScriptScope.Root, null, null, null,
|
||||
/* executionTimeoutSeconds */ 1)));
|
||||
|
||||
Watch(exec);
|
||||
|
||||
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("timed out", result.ErrorMessage);
|
||||
|
||||
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptExecutionActor_NullPerScriptTimeout_FallsBackToGlobal()
|
||||
{
|
||||
// M2.5 (#9): a null per-script timeout falls back to the global (1s here),
|
||||
// so the busy loop is still cancelled at the global value.
|
||||
var compiled = CompileScript(
|
||||
"while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
|
||||
var replyTo = CreateTestProbe();
|
||||
var instanceActor = CreateTestProbe();
|
||||
|
||||
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
|
||||
"Slow", "Inst1", compiled, null, 0,
|
||||
instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1),
|
||||
replyTo.Ref, "corr-fallback", NullLogger.Instance,
|
||||
ScriptScope.Root, null, null, null,
|
||||
/* executionTimeoutSeconds */ null)));
|
||||
|
||||
Watch(exec);
|
||||
|
||||
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("timed out", result.ErrorMessage);
|
||||
|
||||
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptExecutionActor_NonPositivePerScriptTimeout_FallsBackToGlobal()
|
||||
{
|
||||
// M2.5 (#9): a non-positive per-script value (<= 0) is treated as "use
|
||||
// global", so the busy loop is cancelled at the global (1s) value.
|
||||
var compiled = CompileScript(
|
||||
"while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
|
||||
var replyTo = CreateTestProbe();
|
||||
var instanceActor = CreateTestProbe();
|
||||
|
||||
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
|
||||
"Slow", "Inst1", compiled, null, 0,
|
||||
instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1),
|
||||
replyTo.Ref, "corr-clamp", NullLogger.Instance,
|
||||
ScriptScope.Root, null, null, null,
|
||||
/* executionTimeoutSeconds */ 0)));
|
||||
|
||||
Watch(exec);
|
||||
|
||||
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("timed out", result.ErrorMessage);
|
||||
|
||||
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptExecutionActor_NoReplyTo_StillStopsAfterCompletion()
|
||||
{
|
||||
@@ -234,4 +312,25 @@ public class ExecutionActorTests : TestKit, IDisposable
|
||||
// Even on a throwing on-trigger body, the actor must self-stop.
|
||||
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmExecutionActor_PerScriptTimeout_OverridesLongerGlobal()
|
||||
{
|
||||
// M2.5 (#9): the alarm on-trigger script's per-script timeout (1s) wins
|
||||
// over a long global (300s). The busy loop is cancelled and the actor
|
||||
// self-stops (the timeout is logged, alarm continues).
|
||||
var compiled = CompileScript(
|
||||
"while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
|
||||
var instanceActor = CreateTestProbe();
|
||||
|
||||
var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
|
||||
"HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
|
||||
compiled, instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 300),
|
||||
NullLogger.Instance, /* executionTimeoutSeconds */ 1)));
|
||||
|
||||
Watch(exec);
|
||||
// If the per-script timeout were ignored it would block ~300s and this
|
||||
// ExpectTerminated would fail; with the override it stops within ~1s.
|
||||
ExpectTerminated(exec, TimeSpan.FromSeconds(10));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,6 +371,94 @@ public class FlatteningServiceTests
|
||||
Assert.Equal("return base;", script.Code);
|
||||
}
|
||||
|
||||
// ── M2.5 (#9): per-script execution timeout threads to ResolvedScript ───
|
||||
|
||||
[Fact]
|
||||
public void Flatten_SingleTemplate_ScriptExecutionTimeoutSecondsThreadsThrough()
|
||||
{
|
||||
var template = CreateTemplate(1, "Base");
|
||||
template.Scripts.Add(new TemplateScript("Slow", "// slow") { ExecutionTimeoutSeconds = 5 });
|
||||
template.Scripts.Add(new TemplateScript("Default", "// default")); // null → use global
|
||||
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[template],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var slow = result.Value.Scripts.First(s => s.CanonicalName == "Slow");
|
||||
Assert.Equal(5, slow.ExecutionTimeoutSeconds);
|
||||
var dflt = result.Value.Scripts.First(s => s.CanonicalName == "Default");
|
||||
Assert.Null(dflt.ExecutionTimeoutSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_DerivedScriptOverride_ExecutionTimeoutFollowsWinningRow()
|
||||
{
|
||||
// Scripts inherit/override at whole-row granularity: an explicit override
|
||||
// row on the derived template (IsInherited = false) fully replaces the
|
||||
// base row, so its ExecutionTimeoutSeconds wins — exactly like the body.
|
||||
var baseTemplate = CreateTemplate(2, "Sensor");
|
||||
baseTemplate.Scripts.Add(new TemplateScript("Sample", "return base;")
|
||||
{
|
||||
ExecutionTimeoutSeconds = 10
|
||||
});
|
||||
|
||||
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
|
||||
derived.Scripts.Add(new TemplateScript("Sample", "return derived;")
|
||||
{
|
||||
ExecutionTimeoutSeconds = 3
|
||||
});
|
||||
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[derived, baseTemplate],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var script = result.Value.Scripts.First(s => s.CanonicalName == "Sample");
|
||||
Assert.Equal("return derived;", script.Code);
|
||||
Assert.Equal(3, script.ExecutionTimeoutSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InheritedScriptOnDerived_ExecutionTimeoutFollowsBaseRow()
|
||||
{
|
||||
// A stale inherited copy on the derived template (IsInherited = true) is
|
||||
// ignored; the base row wins, carrying the base ExecutionTimeoutSeconds.
|
||||
var baseTemplate = CreateTemplate(2, "Sensor");
|
||||
baseTemplate.Scripts.Add(new TemplateScript("Sample", "return base;")
|
||||
{
|
||||
ExecutionTimeoutSeconds = 10
|
||||
});
|
||||
|
||||
var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2);
|
||||
derived.Scripts.Add(new TemplateScript("Sample", "stale code")
|
||||
{
|
||||
IsInherited = true,
|
||||
ExecutionTimeoutSeconds = 3
|
||||
});
|
||||
|
||||
var instance = CreateInstance();
|
||||
var result = _sut.Flatten(
|
||||
instance,
|
||||
[derived, baseTemplate],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var script = result.Value.Scripts.First(s => s.CanonicalName == "Sample");
|
||||
Assert.Equal("return base;", script.Code);
|
||||
Assert.Equal(10, script.ExecutionTimeoutSeconds);
|
||||
}
|
||||
|
||||
// ── TemplateEngine-002: per-slot alarm override ────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user