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> /// </summary>
public TimeSpan? MinTimeBetweenRuns { get; set; } 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> /// <summary>
/// True when this row was copied from the base template and has not been /// 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 /// 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> /// <summary>Gets the minimum time between script executions.</summary>
public TimeSpan? MinTimeBetweenRuns { get; init; } 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> /// <summary>Gets the source of this script.</summary>
public string Source { get; init; } = "Template"; public string Source { get; init; } = "Template";
@@ -178,6 +178,11 @@ public class TemplateScriptConfiguration : IEntityTypeConfiguration<TemplateScri
builder.Property(s => s.ReturnDefinition) builder.Property(s => s.ReturnDefinition)
.HasMaxLength(4000); .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(); 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() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("ExecutionTimeoutSeconds")
.HasColumnType("int");
b.Property<bool>("IsInherited") b.Property<bool>("IsInherited")
.HasColumnType("bit"); .HasColumnType("bit");
@@ -72,6 +72,15 @@ public class AlarmActor : ReceiveActor
private readonly string? _onTriggerScriptName; private readonly string? _onTriggerScriptName;
private readonly Script<object?>? _onTriggerCompiledScript; 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 // Expression trigger: compiled expression + the attribute snapshot it
// evaluates against. This field is the single home for the compiled // evaluates against. This field is the single home for the compiled
// expression on the hot path. // 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 /// <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; /// <see cref="ISiteEventLogger"/> for M1.5 <c>alarm</c> operational events. Fire-and-forget;
/// a logging failure never affects alarm evaluation.</param> /// 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( public AlarmActor(
string alarmName, string alarmName,
string instanceName, string instanceName,
@@ -119,7 +131,9 @@ public class AlarmActor : ReceiveActor
Script<object?>? compiledTriggerExpression = null, Script<object?>? compiledTriggerExpression = null,
IReadOnlyDictionary<string, object?>? initialAttributes = null, IReadOnlyDictionary<string, object?>? initialAttributes = null,
ISiteHealthCollector? healthCollector = 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; _alarmName = alarmName;
_instanceName = instanceName; _instanceName = instanceName;
@@ -135,6 +149,7 @@ public class AlarmActor : ReceiveActor
_priority = alarmConfig.PriorityLevel; _priority = alarmConfig.PriorityLevel;
_onTriggerScriptName = alarmConfig.OnTriggerScriptCanonicalName; _onTriggerScriptName = alarmConfig.OnTriggerScriptCanonicalName;
_onTriggerCompiledScript = onTriggerCompiledScript; _onTriggerCompiledScript = onTriggerCompiledScript;
_onTriggerExecutionTimeoutSeconds = onTriggerExecutionTimeoutSeconds;
_compiledTriggerExpression = compiledTriggerExpression; _compiledTriggerExpression = compiledTriggerExpression;
// Seed the trigger-expression attribute snapshot from the instance's // Seed the trigger-expression attribute snapshot from the instance's
@@ -574,7 +589,9 @@ public class AlarmActor : ReceiveActor
_instanceActor, _instanceActor,
_sharedScriptLibrary, _sharedScriptLibrary,
_options, _options,
_logger)); _logger,
// M2.5 (#9): per-script timeout from the on-trigger script (null = global).
_onTriggerExecutionTimeoutSeconds));
Context.ActorOf(props, executionId); Context.ActorOf(props, executionId);
} }
@@ -28,6 +28,7 @@ public class AlarmExecutionActor : ReceiveActor
/// <param name="sharedScriptLibrary">Shared script library providing common utilities.</param> /// <param name="sharedScriptLibrary">Shared script library providing common utilities.</param>
/// <param name="options">Site runtime configuration options, including the execution timeout.</param> /// <param name="options">Site runtime configuration options, including the execution timeout.</param>
/// <param name="logger">Logger for execution diagnostics.</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( public AlarmExecutionActor(
string alarmName, string alarmName,
string instanceName, string instanceName,
@@ -38,7 +39,10 @@ public class AlarmExecutionActor : ReceiveActor
IActorRef instanceActor, IActorRef instanceActor,
SharedScriptLibrary sharedScriptLibrary, SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options, 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 self = Self;
var parent = Context.Parent; var parent = Context.Parent;
@@ -46,7 +50,8 @@ public class AlarmExecutionActor : ReceiveActor
ExecuteAlarmScript( ExecuteAlarmScript(
alarmName, instanceName, level, priority, message, alarmName, instanceName, level, priority, message,
compiledScript, instanceActor, compiledScript, instanceActor,
sharedScriptLibrary, options, self, parent, logger); sharedScriptLibrary, options, self, parent, logger,
executionTimeoutSeconds);
} }
private static void ExecuteAlarmScript( private static void ExecuteAlarmScript(
@@ -61,9 +66,15 @@ public class AlarmExecutionActor : ReceiveActor
SiteRuntimeOptions options, SiteRuntimeOptions options,
IActorRef self, IActorRef self,
IActorRef parent, 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 // SiteRuntime-009: run the alarm on-trigger body on the dedicated
// script-execution scheduler, not the shared .NET thread pool. // script-execution scheduler, not the shared .NET thread pool.
@@ -754,6 +754,10 @@ public class InstanceActor : ReceiveActor
foreach (var alarm in _configuration.Alarms) foreach (var alarm in _configuration.Alarms)
{ {
Script<object?>? onTriggerScript = null; 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 // Compile on-trigger script if defined
if (!string.IsNullOrEmpty(alarm.OnTriggerScriptCanonicalName)) if (!string.IsNullOrEmpty(alarm.OnTriggerScriptCanonicalName))
@@ -763,6 +767,7 @@ public class InstanceActor : ReceiveActor
if (triggerScriptDef != null) if (triggerScriptDef != null)
{ {
onTriggerTimeoutSeconds = triggerScriptDef.ExecutionTimeoutSeconds;
var result = _compilationService.Compile( var result = _compilationService.Compile(
$"alarm-trigger-{alarm.CanonicalName}", triggerScriptDef.Code); $"alarm-trigger-{alarm.CanonicalName}", triggerScriptDef.Code);
if (result.IsSuccess) if (result.IsSuccess)
@@ -794,7 +799,9 @@ public class InstanceActor : ReceiveActor
triggerExpression, triggerExpression,
attributeSnapshot, attributeSnapshot,
_healthCollector, _healthCollector,
_serviceProvider)); _serviceProvider,
// M2.5 (#9): per-script timeout for the alarm on-trigger script.
onTriggerTimeoutSeconds));
var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}"); var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}");
_alarmActors[alarm.CanonicalName] = actorRef; _alarmActors[alarm.CanonicalName] = actorRef;
@@ -43,6 +43,13 @@ public class ScriptActor : ReceiveActor, IWithTimers
private Script<object?>? _compiledScript; private Script<object?>? _compiledScript;
private ScriptTriggerConfig? _triggerConfig; private ScriptTriggerConfig? _triggerConfig;
private TimeSpan? _minTimeBetweenRuns; 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 DateTimeOffset _lastExecutionTime = DateTimeOffset.MinValue;
private int _executionCounter; private int _executionCounter;
private readonly Commons.Types.Scripts.ScriptScope _scope; private readonly Commons.Types.Scripts.ScriptScope _scope;
@@ -112,6 +119,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
_healthCollector = healthCollector; _healthCollector = healthCollector;
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns; _minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns;
_executionTimeoutSeconds = scriptConfig.ExecutionTimeoutSeconds;
_scope = scriptConfig.Scope; _scope = scriptConfig.Scope;
_compiledTriggerExpression = compiledTriggerExpression; _compiledTriggerExpression = compiledTriggerExpression;
@@ -426,7 +434,9 @@ public class ScriptActor : ReceiveActor, IWithTimers
_serviceProvider, _serviceProvider,
// Audit Log #23 (ParentExecutionId): null for trigger-driven runs; // Audit Log #23 (ParentExecutionId): null for trigger-driven runs;
// an inbound-API-routed call supplies the inbound request's id. // 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); 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="healthCollector">Optional health collector for recording execution metrics.</param>
/// <param name="serviceProvider">Optional DI service provider for script execution services.</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="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( public ScriptExecutionActor(
string scriptName, string scriptName,
string instanceName, string instanceName,
@@ -65,7 +66,10 @@ public class ScriptExecutionActor : ReceiveActor
// Audit Log #23 (ParentExecutionId): the spawning execution's // Audit Log #23 (ParentExecutionId): the spawning execution's
// ExecutionId for an inbound-API-routed call. Null for normal // ExecutionId for an inbound-API-routed call. Null for normal
// (tag-change / timer) runs and nested Script.Call invocations. // (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 // Immediately begin execution
var self = Self; var self = Self;
@@ -75,7 +79,7 @@ public class ScriptExecutionActor : ReceiveActor
scriptName, instanceName, compiledScript, parameters, callDepth, scriptName, instanceName, compiledScript, parameters, callDepth,
instanceActor, sharedScriptLibrary, options, replyTo, correlationId, instanceActor, sharedScriptLibrary, options, replyTo, correlationId,
self, parent, logger, scope, healthCollector, serviceProvider, self, parent, logger, scope, healthCollector, serviceProvider,
parentExecutionId); parentExecutionId, executionTimeoutSeconds);
} }
private static void ExecuteScript( private static void ExecuteScript(
@@ -95,9 +99,15 @@ public class ScriptExecutionActor : ReceiveActor
Commons.Types.Scripts.ScriptScope scope, Commons.Types.Scripts.ScriptScope scope,
ISiteHealthCollector? healthCollector, ISiteHealthCollector? healthCollector,
IServiceProvider? serviceProvider, 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 // SiteRuntime-009: run the script body on the dedicated script-execution
// scheduler, not the shared .NET thread pool, so blocking script I/O cannot // 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.TriggerConfiguration == b.TriggerConfiguration &&
a.ParameterDefinitions == b.ParameterDefinitions && a.ParameterDefinitions == b.ParameterDefinitions &&
a.ReturnDefinition == b.ReturnDefinition && a.ReturnDefinition == b.ReturnDefinition &&
a.MinTimeBetweenRuns == b.MinTimeBetweenRuns; a.MinTimeBetweenRuns == b.MinTimeBetweenRuns &&
a.ExecutionTimeoutSeconds == b.ExecutionTimeoutSeconds;
/// <summary> /// <summary>
/// Compares two <see cref="ConnectionConfig"/> instances for equality across /// Compares two <see cref="ConnectionConfig"/> instances for equality across
@@ -830,6 +830,10 @@ public class FlatteningService
ParameterDefinitions = script.ParameterDefinitions, ParameterDefinitions = script.ParameterDefinitions,
ReturnDefinition = script.ReturnDefinition, ReturnDefinition = script.ReturnDefinition,
MinTimeBetweenRuns = script.MinTimeBetweenRuns, 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 Source = source
}; };
idByName[script.Name] = script.Id; idByName[script.Name] = script.Id;
@@ -83,7 +83,10 @@ public class RevisionHashService
TriggerConfiguration = s.TriggerConfiguration, TriggerConfiguration = s.TriggerConfiguration,
ParameterDefinitions = s.ParameterDefinitions, ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition, 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(), .ToList(),
Connections = configuration.Connections is { Count: > 0 } Connections = configuration.Connections is { Count: > 0 }
@@ -244,6 +247,10 @@ public class RevisionHashService
/// </summary> /// </summary>
public string Code { get; init; } = string.Empty; public string Code { get; init; } = string.Empty;
/// <summary> /// <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. /// Whether the script is locked.
/// </summary> /// </summary>
public bool IsLocked { get; init; } public bool IsLocked { get; init; }
@@ -17,7 +17,7 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine;
/// Override granularity: /// Override granularity:
/// - Attributes: Value and Description overridable; DataType and DataSourceReference fixed. /// - Attributes: Value and Description overridable; DataType and DataSourceReference fixed.
/// - Alarms: Priority, TriggerConfiguration, Description, OnTriggerScript overridable; Name and TriggerType 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). /// - Lock flag applies to the entire member (attribute/alarm/script).
/// </summary> /// </summary>
public static class LockEnforcer public static class LockEnforcer
@@ -687,6 +687,8 @@ public class TemplateService
existing.TriggerType = proposed.TriggerType; existing.TriggerType = proposed.TriggerType;
existing.TriggerConfiguration = proposed.TriggerConfiguration; existing.TriggerConfiguration = proposed.TriggerConfiguration;
existing.MinTimeBetweenRuns = proposed.MinTimeBetweenRuns; existing.MinTimeBetweenRuns = proposed.MinTimeBetweenRuns;
// M2.5 (#9): per-script execution timeout is an overridable field.
existing.ExecutionTimeoutSeconds = proposed.ExecutionTimeoutSeconds;
existing.ParameterDefinitions = proposed.ParameterDefinitions; existing.ParameterDefinitions = proposed.ParameterDefinitions;
existing.ReturnDefinition = proposed.ReturnDefinition; existing.ReturnDefinition = proposed.ReturnDefinition;
existing.IsLocked = proposed.IsLocked; existing.IsLocked = proposed.IsLocked;
@@ -1013,6 +1015,7 @@ public class TemplateService
ParameterDefinitions = script.ParameterDefinitions, ParameterDefinitions = script.ParameterDefinitions,
ReturnDefinition = script.ReturnDefinition, ReturnDefinition = script.ReturnDefinition,
MinTimeBetweenRuns = script.MinTimeBetweenRuns, MinTimeBetweenRuns = script.MinTimeBetweenRuns,
ExecutionTimeoutSeconds = script.ExecutionTimeoutSeconds,
IsInherited = true, IsInherited = true,
LockedInDerived = false, LockedInDerived = false,
}); });
@@ -2339,6 +2339,7 @@ public sealed class BundleImporter : IBundleImporter
ParameterDefinitions = s.ParameterDefinitions, ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition, ReturnDefinition = s.ReturnDefinition,
MinTimeBetweenRuns = s.MinTimeBetweenRuns, MinTimeBetweenRuns = s.MinTimeBetweenRuns,
ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds,
Source = "Template", Source = "Template",
}); });
} }
@@ -99,7 +99,10 @@ public sealed record TemplateScriptDto(
string? ParameterDefinitions, string? ParameterDefinitions,
string? ReturnDefinition, string? ReturnDefinition,
bool IsLocked, 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( public sealed record TemplateCompositionDto(
string InstanceName, string InstanceName,
@@ -74,7 +74,8 @@ public sealed class EntitySerializer
ParameterDefinitions: s.ParameterDefinitions, ParameterDefinitions: s.ParameterDefinitions,
ReturnDefinition: s.ReturnDefinition, ReturnDefinition: s.ReturnDefinition,
IsLocked: s.IsLocked, IsLocked: s.IsLocked,
MinTimeBetweenRuns: s.MinTimeBetweenRuns)).ToList(), MinTimeBetweenRuns: s.MinTimeBetweenRuns,
ExecutionTimeoutSeconds: s.ExecutionTimeoutSeconds)).ToList(),
Compositions: t.Compositions.Select(c => new TemplateCompositionDto( Compositions: t.Compositions.Select(c => new TemplateCompositionDto(
InstanceName: c.InstanceName, InstanceName: c.InstanceName,
ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList()); ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList());
@@ -227,6 +228,7 @@ public sealed class EntitySerializer
ReturnDefinition = s.ReturnDefinition, ReturnDefinition = s.ReturnDefinition,
IsLocked = s.IsLocked, IsLocked = s.IsLocked,
MinTimeBetweenRuns = s.MinTimeBetweenRuns, MinTimeBetweenRuns = s.MinTimeBetweenRuns,
ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds,
}); });
} }
return t; return t;
@@ -61,6 +61,34 @@ public class TemplateEngineRepositoryTests : IDisposable
Assert.Equal("Slot1", loaded.Compositions.First().InstanceName); 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] [Fact]
public async Task GetTemplateWithChildrenAsync_ReturnsNull_WhenTemplateDoesNotExist() public async Task GetTemplateWithChildrenAsync_ReturnsNull_WhenTemplateDoesNotExist()
{ {
@@ -185,6 +185,84 @@ public class ExecutionActorTests : TestKit, IDisposable
ExpectTerminated(exec, TimeSpan.FromSeconds(5)); 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] [Fact]
public void ScriptExecutionActor_NoReplyTo_StillStopsAfterCompletion() 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. // Even on a throwing on-trigger body, the actor must self-stop.
ExpectTerminated(exec, TimeSpan.FromSeconds(5)); 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); 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 ──────────────────────── // ── TemplateEngine-002: per-slot alarm override ────────────────────────
[Fact] [Fact]