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:
+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