fix(inbound-api): revision-check compiled handler cache against the fresh ApiMethod row — heals failover/direct-SQL/known-bad staleness
Also updates the now-obsolete CompileAndRegister_NonCompilingUpdate test (InboundScriptExecutorTests.cs, not in the plan's Files list) to assert the deliberate DB-authoritative behavior: a broken save now 500s consistently on every node instead of the stale delegate silently serving. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Arch-review S1: the compiled-handler cache must be revision-checked against the
|
||||
/// freshly-fetched ApiMethod.Script so a standby node (whose cache was populated at
|
||||
/// its own startup) never serves a stale delegate after failover, and a method fixed
|
||||
/// out-of-band is never stuck behind a stale known-bad record.
|
||||
/// </summary>
|
||||
public class InboundScriptExecutorStalenessTests
|
||||
{
|
||||
private readonly InboundScriptExecutor _executor;
|
||||
private readonly RouteHelper _route;
|
||||
|
||||
public InboundScriptExecutorStalenessTests()
|
||||
{
|
||||
_executor = new InboundScriptExecutor(
|
||||
NullLogger<InboundScriptExecutor>.Instance, Substitute.For<IServiceProvider>());
|
||||
_route = new RouteHelper(
|
||||
Substitute.For<IInstanceLocator>(), Substitute.For<IInstanceRouter>());
|
||||
}
|
||||
|
||||
private Task<InboundScriptResult> Run(ApiMethod m) => _executor.ExecuteAsync(
|
||||
m, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatedScript_RecompilesInsteadOfServingStaleHandler()
|
||||
{
|
||||
// Simulates the failover scenario: this node compiled v1 at startup...
|
||||
var v1 = new ApiMethod("stale", "return 1;") { Id = 1, TimeoutSeconds = 10 };
|
||||
Assert.True(_executor.CompileAndRegister(v1));
|
||||
var r1 = await Run(v1);
|
||||
Assert.Contains("1", r1.ResultJson);
|
||||
|
||||
// ...the active node updated the method (this node never heard about it);
|
||||
// the per-request DB fetch hands ExecuteAsync the NEW row.
|
||||
var v2 = new ApiMethod("stale", "return 2;") { Id = 1, TimeoutSeconds = 10 };
|
||||
var r2 = await Run(v2);
|
||||
|
||||
Assert.True(r2.Success);
|
||||
Assert.Contains("2", r2.ResultJson); // old cache would have returned 1
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KnownBadMethod_FixedScript_CompilesInsteadOfFastFailing()
|
||||
{
|
||||
// The mirror-image variant: broken at this node's startup...
|
||||
var broken = new ApiMethod("fixme", "%%% not C# %%%") { Id = 2, TimeoutSeconds = 10 };
|
||||
Assert.False(_executor.CompileAndRegister(broken));
|
||||
var r1 = await Run(broken);
|
||||
Assert.False(r1.Success);
|
||||
|
||||
// ...fixed via the management path on the OTHER node; this node's fresh
|
||||
// DB fetch carries the fixed script.
|
||||
var fixedMethod = new ApiMethod("fixme", "return 42;") { Id = 2, TimeoutSeconds = 10 };
|
||||
var r2 = await Run(fixedMethod);
|
||||
|
||||
Assert.True(r2.Success);
|
||||
Assert.Contains("42", r2.ResultJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KnownBadMethod_SameScript_StillFastFails()
|
||||
{
|
||||
var broken = new ApiMethod("stillbad", "%%% not C# %%%") { Id = 3, TimeoutSeconds = 10 };
|
||||
Assert.False(_executor.CompileAndRegister(broken));
|
||||
|
||||
// Same script text → the fast-fail record must hold (no per-request Roslyn).
|
||||
var again = new ApiMethod("stillbad", "%%% not C# %%%") { Id = 3, TimeoutSeconds = 10 };
|
||||
var r = await Run(again);
|
||||
Assert.False(r.Success);
|
||||
Assert.Contains("Script compilation failed", r.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestSeamRegisterHandler_WithoutScript_IsNotRevisionChecked()
|
||||
{
|
||||
// RegisterHandler (script-less test seam) pins the handler regardless of row content.
|
||||
var m = new ApiMethod("pinned", "return 0;") { Id = 4, TimeoutSeconds = 10 };
|
||||
_executor.RegisterHandler("pinned", async _ => { await Task.CompletedTask; return 99; });
|
||||
var r = await Run(m);
|
||||
Assert.True(r.Success);
|
||||
Assert.Contains("99", r.ResultJson);
|
||||
}
|
||||
}
|
||||
@@ -271,12 +271,15 @@ public class InboundScriptExecutorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompileAndRegister_NonCompilingUpdate_KeepsPreviousHandler()
|
||||
public async Task CompileAndRegister_NonCompilingUpdate_ExecuteSurfacesCompileFailure()
|
||||
{
|
||||
// Register a working handler, then "update" the same method name with a script
|
||||
// that does not compile. The broken save must NOT replace the working delegate —
|
||||
// the previously registered version keeps serving requests (this is exactly the
|
||||
// behaviour that makes a broken save non-fatal while the warning is surfaced).
|
||||
// Arch-review S1 (DB-authoritative): CompileAndRegister still leaves the old
|
||||
// handler cached on a broken save (it never overwrites with a bad compile), but
|
||||
// ExecuteAsync now revision-checks the cached script against the freshly-fetched
|
||||
// row. A request carrying the broken row no longer silently serves the stale good
|
||||
// delegate — the mismatch triggers a recompile, which fails, so the method returns
|
||||
// a compilation error consistently on every node (honest failure over node-divergent
|
||||
// stale success).
|
||||
var good = new ApiMethod("evolving", "return 111;") { Id = 1, TimeoutSeconds = 10 };
|
||||
Assert.True(_executor.CompileAndRegister(good, out var goodErrors));
|
||||
Assert.Empty(goodErrors);
|
||||
@@ -285,12 +288,19 @@ public class InboundScriptExecutorTests
|
||||
Assert.False(_executor.CompileAndRegister(broken, out var brokenErrors));
|
||||
Assert.NotEmpty(brokenErrors);
|
||||
|
||||
// The old (good) handler still serves — the broken script never went live.
|
||||
// The DB row is authoritative: the broken script now 500s rather than the old
|
||||
// delegate silently serving.
|
||||
var result = await _executor.ExecuteAsync(
|
||||
broken, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Contains("111", result.ResultJson);
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("Script compilation failed", result.ErrorMessage);
|
||||
|
||||
// The still-valid row keeps serving — only the broken revision fails.
|
||||
var stillGood = await _executor.ExecuteAsync(
|
||||
good, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
|
||||
Assert.True(stillGood.Success);
|
||||
Assert.Contains("111", stillGood.ResultJson);
|
||||
}
|
||||
|
||||
// --- InboundAPI-002: lazy compile-and-fetch must be atomic, never KeyNotFoundException ---
|
||||
|
||||
Reference in New Issue
Block a user