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:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user