fix(inbound-api): API-method recompile/invalidate reaches the peer central node; stale doc; locked-override name rule (ScadaBridge#103)

(a) Inbound API compile state (#75) is per central node: a recompile cleared the
known-bad record only on the node that answered, and the peer kept refusing the
unchanged script with SCRIPT_COMPILE_FAILED (the per-request revision check only
notices a changed script). Create / update / delete (ManagementActor post-commit),
RecompileApiMethod, and API methods written by a Transport bundle import now publish
InboundApiMethodInvalidated on the central cache-invalidation topic (#100 item 3).
The new InboundApiCacheInvalidationHandler drops that method's compiled handler,
known-bad record and cached ApiMethod row on the peer, which compiles the current
text on its next request.

(b) docs/components/InboundAPI.md no longer claims a direct DB edit stays stale
until restart: the per-request revision check picks it up once the node's cached
row expires (ApiMethodCacheTtl, 5 s). Component-InboundAPI, Component-Host and the
#85 cache audit describe the peer invalidation.

(c) Decision: the import's locked-override warnings keep EXACT (ordinal) name
matching, because FlatteningService matches override names ordinally. An override
differing from a locked member only by case never reaches that member's lock check
(the flattener drops it as unresolved), so a "targets a LOCKED member" warning for it
would be false. The comparer is now named (LockedOverrideNameComparer) and documented,
and pinned by tests on both sides (flattener + import).

Tests: the peer's known-bad record is cleared by a recompile (and validate leaves it
alone); create/update/delete and the import publish the invalidation; the handler
drops only the named method; composition root registers the handler; case-only
override gets no lock warning; the flattener matches override names exactly.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 16:04:07 -05:00
parent 153609ef8a
commit 0c2e852a62
16 changed files with 304 additions and 24 deletions
@@ -1375,6 +1375,42 @@ public sealed class SiteInstanceImportTests : IDisposable
&& b.BlockerReason!.Contains("locked", StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// ScadaBridge#103: the lock warning matches member names exactly, like the flattener. An
/// override named "setpoint" never reaches the locked "SetPoint" (the flattener drops it as
/// unresolved), so it must not be reported as targeting a locked member.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Import_OverrideDifferingOnlyByCaseFromALockedAttribute_NoLockWarning()
{
await SeedLockedMemberClosureAsync(lockAttribute: true, attributeOverrideName: "setpoint");
var sessionId = await ExportAllSitesAndLoadAsync();
await WipeSiteClosureAsync();
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
await ctx.SaveChangesAsync();
}
var result = await ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Skip, null),
new("Site", "plant-1", ResolutionAction.Add, null),
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
Connections: Array.Empty<ConnectionMapping>()));
Assert.DoesNotContain(result.Warnings, w =>
w.Contains("locked", StringComparison.OrdinalIgnoreCase));
}
/// <summary>Verifies that an override on an unlocked attribute produces no lock warning.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -1417,7 +1453,8 @@ public sealed class SiteInstanceImportTests : IDisposable
/// The attribute override is always present; the native-source override (and its
/// connection) are seeded only when a native source is involved.
/// </summary>
private async Task SeedLockedMemberClosureAsync(bool lockAttribute = false, bool lockNativeSource = false)
private async Task SeedLockedMemberClosureAsync(
bool lockAttribute = false, bool lockNativeSource = false, string attributeOverrideName = "SetPoint")
{
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
@@ -1462,7 +1499,7 @@ public sealed class SiteInstanceImportTests : IDisposable
SiteId = site.Id,
State = InstanceState.Enabled,
};
instance.AttributeOverrides.Add(new InstanceAttributeOverride("SetPoint") { OverrideValue = "42" });
instance.AttributeOverrides.Add(new InstanceAttributeOverride(attributeOverrideName) { OverrideValue = "42" });
if (lockNativeSource)
{
instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("NativeSrc")