feat(central): one central cache-invalidation topic; role-mapping and external-definition caches follow peer edits (ScadaBridge#100 item 3, ScadaBridge#83)

The #96 site-psk-invalidation topic was the first cross-node invalidation broadcast.
It is generalised into ONE central DistributedPubSub topic, central-cache-invalidation,
carrying typed messages (Commons ICentralCacheInvalidation: SitePskInvalidated,
RoleMappingsInvalidated, ExternalDefinitionsInvalidated, InboundApiMethodInvalidated)
in an envelope stamped with the publisher's process-unique origin id.

- Host: CentralCacheInvalidationBus (ICentralCacheInvalidationPublisher, no-op until
  the mediator is set, never throws) and CentralCacheInvalidationSubscriber, which
  skips its own node's envelopes and hands the peer's to every registered
  ICentralCacheInvalidationHandler (a failing handler does not stop the rest).
  Replaces SitePskInvalidationSubscriber.
- Migration of #96: SitePskRotationInvalidator publishes through the bus and is the
  PSK handler; SitePskInvalidated keeps its type name and is also published bare on
  site-psk-invalidation, which the subscriber still listens to, so a mixed-version
  central pair keeps exchanging PSK invalidations. Remove after one release.
- Audit D1: RoleMappingCache is a handler; ManagementActor's post-commit role-mapping
  invalidation also publishes RoleMappingsInvalidated, so a revocation is immediate on
  the peer instead of after RoleMappingCacheSeconds.
- Audit D3 / #83 (peer half): ExternalDefinitionLookupCache is a handler; the 9
  ManagementActor handlers and the Transport bundle importer publish
  ExternalDefinitionsInvalidated after commit. (#83's local import half was already in.)

TTLs remain the fallback for a lost broadcast or a direct SQL edit. Tests: bus and
subscriber unit tests (origin filter, legacy topic), peer-node tests through the real
caches in ManagementService.Tests, importer publish, composition root, and a real
two-node cluster from the production HOCON (CentralCacheInvalidationTwoNodeTests)
proving every message type crosses with the production serializer. Docs: Host,
Communication, Security, ManagementService, ESG, and the #85 cache audit.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 15:50:03 -05:00
parent 063fb21815
commit 153609ef8a
27 changed files with 794 additions and 110 deletions
@@ -21,6 +21,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
@@ -2386,15 +2387,22 @@ public class ManagementActor : ReceiveActor
/// call on this node resolves the fresh definition rather than waiting out
/// <c>DefinitionCacheTtl</c> (ScadaBridge#65). Queued post-commit: invalidating before the commit
/// would let a concurrent lookup re-cache the pre-edit row. The cache is per-node, so the other
/// central node still converges within <c>DefinitionCacheTtl</c>. A no-op when the cache is not
/// registered (site roles, plain test hosts).
/// central node is told with an <see cref="ExternalDefinitionsInvalidated"/> on the central
/// cache-invalidation topic (audit D3 / #83, ScadaBridge#100 item 3). A no-op when neither the
/// cache nor the publisher is registered (site roles, plain test hosts).
/// </summary>
private static Task InvalidateExternalDefinitionCacheAfterCommit(IServiceProvider sp)
{
var cache = sp.GetService<ExternalDefinitionLookupCache>();
return cache is null
var peers = sp.GetService<ICentralCacheInvalidationPublisher>();
return cache is null && peers is null
? Task.CompletedTask
: AfterConfigurationCommit(sp, () => { cache.InvalidateAll(); return Task.CompletedTask; });
: AfterConfigurationCommit(sp, () =>
{
cache?.InvalidateAll();
peers?.Publish(new ExternalDefinitionsInvalidated());
return Task.CompletedTask;
});
}
private static async Task<object?> HandleListExternalSystems(IServiceProvider sp)
@@ -2902,15 +2910,23 @@ public class ManagementActor : ReceiveActor
/// Drops this node's <see cref="RoleMappingCache"/> snapshot once a mapping / scope-rule
/// change has committed, so the next role mapping on this node sees it immediately rather
/// than after the cache TTL (lane K review M1). Queued post-commit: invalidating before the
/// commit would let a concurrent mapping re-cache the pre-change table. Other central
/// nodes still converge within <c>RoleMappingCacheSeconds</c>.
/// commit would let a concurrent mapping re-cache the pre-change table. The peer central node
/// is told with a <see cref="RoleMappingsInvalidated"/> on the central cache-invalidation topic
/// (audit D1, ScadaBridge#100 item 3), so a revocation takes effect there at once too;
/// <c>RoleMappingCacheSeconds</c> is only the fallback if that broadcast is lost.
/// </summary>
private static Task InvalidateRoleMappingCacheAfterCommit(IServiceProvider sp)
{
var cache = sp.GetService<RoleMappingCache>();
return cache is null
var peers = sp.GetService<ICentralCacheInvalidationPublisher>();
return cache is null && peers is null
? Task.CompletedTask
: AfterConfigurationCommit(sp, () => { cache.Invalidate(); return Task.CompletedTask; });
: AfterConfigurationCommit(sp, () =>
{
cache?.Invalidate();
peers?.Publish(new RoleMappingsInvalidated());
return Task.CompletedTask;
});
}
private static async Task<object?> HandleCreateRoleMapping(IServiceProvider sp, CreateRoleMappingCommand cmd, string user)