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
@@ -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));
}
}