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
@@ -0,0 +1,44 @@
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// Applies a peer central node's <see cref="InboundApiMethodInvalidated"/> to this node
/// (ScadaBridge#103): drops the method's compiled handler, its known-bad compile record and its
/// cached <c>ApiMethod</c> row, so the next request on this node reads the stored row and compiles
/// it afresh.
/// </summary>
/// <remarks>
/// Compile state (#75) is per central node. Without this, a recompile on one node cleared the
/// known-bad record only there: the peer kept refusing the method with
/// <c>SCRIPT_COMPILE_FAILED</c> while its record matched the unchanged script text, because the
/// per-request revision check only notices a changed script. The peer does not recompile eagerly;
/// its next request (or its own recompile) compiles the current text.
/// </remarks>
public sealed class InboundApiCacheInvalidationHandler : ICentralCacheInvalidationHandler
{
private readonly InboundScriptExecutor _executor;
private readonly ApiMethodCache _methodCache;
/// <summary>Creates the handler.</summary>
/// <param name="executor">This node's inbound script executor.</param>
/// <param name="methodCache">This node's API-method row cache.</param>
public InboundApiCacheInvalidationHandler(InboundScriptExecutor executor, ApiMethodCache methodCache)
{
ArgumentNullException.ThrowIfNull(executor);
ArgumentNullException.ThrowIfNull(methodCache);
_executor = executor;
_methodCache = methodCache;
}
/// <inheritdoc />
public void Apply(ICentralCacheInvalidation invalidation)
{
if (invalidation is InboundApiMethodInvalidated { MethodName: { Length: > 0 } name })
{
_executor.InvalidateMethod(name);
_methodCache.Invalidate(name);
}
}
}
@@ -1,5 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
@@ -50,6 +52,10 @@ public static class ServiceCollectionExtensions
// and the executor's per-request revision check alone keeps handlers fresh.
services.AddHostedService<ScriptArtifactChangeSubscriber>();
// ScadaBridge#103: a create / update / delete / recompile made through the other central
// node drops this node's handler, known-bad record and cached row for the method.
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICentralCacheInvalidationHandler, InboundApiCacheInvalidationHandler>());
return services;
}
}
@@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
using ZB.MOM.WW.ScadaBridge.InboundAPI;
@@ -13,7 +14,8 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService;
/// re-sent. Shared by the <see cref="ManagementActor"/> handlers (CLI / Management API) and
/// the Central UI, which call it under the same role gate, so every surface reports the same
/// thing. Compile state is per central node — each node compiles and caches independently —
/// so every result names the node it came from.
/// so every result names the node it came from. A recompile also drops the method's compile
/// state on the peer central node (ScadaBridge#103), which recompiles on its next request.
/// </summary>
public static class ApiMethodCompileOperations
{
@@ -36,7 +38,9 @@ public static class ApiMethodCompileOperations
/// cached handler and clears the known-bad record, a failure records the new diagnostics
/// (a previously working handler is never replaced by a failed compile, but the stored row
/// stays authoritative, see <see cref="InboundScriptExecutor.CompileAndRegister(ApiMethod, out IReadOnlyList{string})"/>).
/// The node's cached row is dropped too, so the next request reads the stored row.
/// The node's cached row is dropped too, so the next request reads the stored row, and the
/// peer central node is told to drop its handler, known-bad record and cached row for the
/// method (ScadaBridge#103), so a recompile clears a stale compile failure on both nodes.
/// Audited as <c>Recompile</c>.
/// </summary>
/// <param name="sp">The scoped service provider.</param>
@@ -48,6 +52,7 @@ public static class ApiMethodCompileOperations
var method = await LoadAsync(sp, apiMethodId);
var compiled = RequireExecutor(sp).CompileAndRegister(method, out var errors);
sp.GetService<ApiMethodCache>()?.Invalidate(method.Name);
sp.GetService<ICentralCacheInvalidationPublisher>()?.Publish(new InboundApiMethodInvalidated(method.Name));
var result = new ApiMethodCompileResult(method.Id, method.Name, compiled, errors, NodeName(sp));
await sp.GetRequiredService<IAuditService>().LogAsync(
@@ -3915,15 +3915,24 @@ public class ManagementActor : ReceiveActor
/// <c>ApiMethodCacheTtl</c>: a just-deleted method stayed callable (the executor recompiled
/// it from the cached row), and a cached not-found answered 404 for a just-created one.
/// Queued post-commit, like the role-mapping invalidation, so a concurrent request cannot
/// re-cache the pre-change row. The peer central node still converges within the TTL
/// (default 5 s) — see <c>docs/plans/2026-11-09-central-cache-staleness-audit.md</c>.
/// re-cache the pre-change row. The peer central node is told with an
/// <see cref="InboundApiMethodInvalidated"/> on the central cache-invalidation topic
/// (ScadaBridge#103), which drops its row, compiled handler and known-bad record for the
/// method; the TTL (default 5 s) is only the fallback — see
/// <c>docs/plans/2026-11-09-central-cache-staleness-audit.md</c>.
/// </summary>
private static Task InvalidateApiMethodCacheAfterCommit(IServiceProvider sp, string name)
{
var cache = sp.GetService<InboundAPI.ApiMethodCache>();
return cache is null
var peers = sp.GetService<ICentralCacheInvalidationPublisher>();
return cache is null && peers is null
? Task.CompletedTask
: AfterConfigurationCommit(sp, () => { cache.Invalidate(name); return Task.CompletedTask; });
: AfterConfigurationCommit(sp, () =>
{
cache?.Invalidate(name);
peers?.Publish(new InboundApiMethodInvalidated(name));
return Task.CompletedTask;
});
}
/// <summary>
@@ -1399,6 +1399,12 @@ public sealed class BundleImporter : IBundleImporter
=> resolutionMap != null
&& ResolveOrDefault(resolutionMap, entityType, name).Action == ResolutionAction.Skip;
/// <summary>
/// How <see cref="CollectLockedOverrideWarnings"/> matches override names to template
/// members: ordinal, exactly as the flattener does (ScadaBridge#103).
/// </summary>
private static readonly StringComparer LockedOverrideNameComparer = StringComparer.Ordinal;
/// <summary>
/// Best-effort scan for instance overrides that target a LOCKED template member.
/// The flattener drops such overrides (an override on a locked attribute / alarm /
@@ -1416,6 +1422,16 @@ public sealed class BundleImporter : IBundleImporter
/// instance's template is resolved from the bundle DTO (the version about to be
/// written) first, else the pre-existing target template.
/// </para>
/// <para>
/// Member names match EXACTLY (<see cref="LockedOverrideNameComparer"/>, ordinal), as the
/// flattener does: <c>FlatteningService.ApplyInstanceOverrides</c> /
/// <c>ApplyInstanceAlarmOverrides</c> / <c>ApplyInstanceNativeAlarmSourceOverrides</c> look
/// the override name up in ordinal-keyed dictionaries (ScadaBridge#103 decision). An override
/// whose name differs from a locked member only by case therefore 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 SQL indexes on member names being case-insensitive does not
/// change this: they stop two members differing only by case, not a case-mismatched override.
/// </para>
/// </summary>
private static IReadOnlyList<(string Instance, string Message)> CollectLockedOverrideWarnings(
BundleContentDto content,
@@ -1439,15 +1455,15 @@ public sealed class BundleImporter : IBundleImporter
HashSet<string> lockedSources;
if (bundleTemplates.TryGetValue(inst.TemplateName, out var dto))
{
lockedAttrs = new(dto.Attributes.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
lockedAlarms = new(dto.Alarms.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
lockedSources = new(dto.NativeAlarmSources.Where(s => s.IsLocked).Select(s => s.Name), StringComparer.Ordinal);
lockedAttrs = new(dto.Attributes.Where(a => a.IsLocked).Select(a => a.Name), LockedOverrideNameComparer);
lockedAlarms = new(dto.Alarms.Where(a => a.IsLocked).Select(a => a.Name), LockedOverrideNameComparer);
lockedSources = new(dto.NativeAlarmSources.Where(s => s.IsLocked).Select(s => s.Name), LockedOverrideNameComparer);
}
else if (targetByName.TryGetValue(inst.TemplateName, out var tgt))
{
lockedAttrs = new(tgt.Attributes.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
lockedAlarms = new(tgt.Alarms.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
lockedSources = new(tgt.NativeAlarmSources.Where(s => s.IsLocked).Select(s => s.Name), StringComparer.Ordinal);
lockedAttrs = new(tgt.Attributes.Where(a => a.IsLocked).Select(a => a.Name), LockedOverrideNameComparer);
lockedAlarms = new(tgt.Alarms.Where(a => a.IsLocked).Select(a => a.Name), LockedOverrideNameComparer);
lockedSources = new(tgt.NativeAlarmSources.Where(s => s.IsLocked).Select(s => s.Name), LockedOverrideNameComparer);
}
else
{
@@ -2528,7 +2544,7 @@ public sealed class BundleImporter : IBundleImporter
private void PublishScriptArtifactChanges(IReadOnlyList<ImportResolution> resolutions)
{
if (_scriptArtifactChangeBus is null)
if (_scriptArtifactChangeBus is null && _cacheInvalidationPublisher is null)
{
return;
}
@@ -2560,11 +2576,21 @@ public sealed class BundleImporter : IBundleImporter
return;
}
_scriptArtifactChangeBus!.Publish(new ScriptArtifactsChanged(
_scriptArtifactChangeBus?.Publish(new ScriptArtifactsChanged(
ArtifactKind: kind,
Names: names,
Source: "BundleImport",
OccurredAtUtc: _timeProvider.GetUtcNow()));
// ScadaBridge#103: the node-local bus above only reaches this node's executor; the
// peer central node is told per imported API method too.
if (kind == ScriptArtifactKinds.ApiMethod && _cacheInvalidationPublisher is not null)
{
foreach (var name in names)
{
_cacheInvalidationPublisher.Publish(new InboundApiMethodInvalidated(name));
}
}
}
}