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:
Joseph Doherty
2026-06-15 14:40:38 -04:00
parent 00304a26e6
commit 3edef09f51
22 changed files with 2094 additions and 17 deletions
@@ -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";
@@ -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();
}
}
@@ -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");
}
}
}
@@ -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;
@@ -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]