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:
Joseph Doherty
2026-07-10 03:57:13 -04:00
parent 2767e4bca1
commit 16bab58d85
3 changed files with 182 additions and 42 deletions
@@ -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 ---