153609ef8a
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
88 lines
3.7 KiB
C#
88 lines
3.7 KiB
C#
using Akka.Actor;
|
|
using Akka.Cluster.Tools.PublishSubscribe;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Xunit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication;
|
|
using ZB.MOM.WW.ScadaBridge.Host;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster;
|
|
|
|
/// <summary>
|
|
/// ScadaBridge#100 item 3 / #83 / #103 across a REAL two-node central cluster built from the
|
|
/// production HOCON: an invalidation published on one node's bus is delivered, through
|
|
/// <c>DistributedPubSub</c> and the production serializer, to the other node's handlers — and
|
|
/// not back to the publishing node's own handlers.
|
|
/// </summary>
|
|
public class CentralCacheInvalidationTwoNodeTests
|
|
{
|
|
private sealed class RecordingHandler : ICentralCacheInvalidationHandler
|
|
{
|
|
private readonly List<ICentralCacheInvalidation> _applied = new();
|
|
|
|
public IReadOnlyList<ICentralCacheInvalidation> Applied
|
|
{
|
|
get { lock (_applied) return _applied.ToList(); }
|
|
}
|
|
|
|
public void Apply(ICentralCacheInvalidation invalidation)
|
|
{
|
|
lock (_applied) _applied.Add(invalidation);
|
|
}
|
|
}
|
|
|
|
private static (CentralCacheInvalidationBus Bus, RecordingHandler Handler) Wire(ActorSystem node)
|
|
{
|
|
var bus = new CentralCacheInvalidationBus(NullLogger<CentralCacheInvalidationBus>.Instance);
|
|
var handler = new RecordingHandler();
|
|
node.ActorOf(Props.Create(() => new CentralCacheInvalidationSubscriber(
|
|
bus, new ICentralCacheInvalidationHandler[] { handler })), "central-cache-invalidation");
|
|
bus.SetMediator(DistributedPubSub.Get(node).Mediator);
|
|
return (bus, handler);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every typed invalidation crosses to the peer intact; the publisher never applies its own.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Invalidations_ReachThePeerNode_AndNotThePublisher()
|
|
{
|
|
await using var cluster = await TwoNodeClusterFixture.StartAsync();
|
|
var a = Wire(cluster.NodeA);
|
|
var b = Wire(cluster.NodeB);
|
|
|
|
// Subscriptions spread by gossip, so probe until the peer has registered its subscriber.
|
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(20);
|
|
while (b.Handler.Applied.Count == 0)
|
|
{
|
|
Assert.True(DateTime.UtcNow < deadline, "node B never received node A's invalidation");
|
|
a.Bus.Publish(new RoleMappingsInvalidated());
|
|
await Task.Delay(250);
|
|
}
|
|
|
|
a.Bus.Publish(new ExternalDefinitionsInvalidated());
|
|
a.Bus.Publish(new InboundApiMethodInvalidated("MesMoveIn"));
|
|
a.Bus.Publish(new SitePskInvalidated("site-a"));
|
|
|
|
deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
|
while (!b.Handler.Applied.Any(i => i is SitePskInvalidated))
|
|
{
|
|
Assert.True(DateTime.UtcNow < deadline, "node B did not receive the later invalidations");
|
|
await Task.Delay(100);
|
|
}
|
|
|
|
var applied = b.Handler.Applied;
|
|
Assert.Contains(applied, i => i is RoleMappingsInvalidated);
|
|
Assert.Contains(new ExternalDefinitionsInvalidated(), applied);
|
|
Assert.Contains(new InboundApiMethodInvalidated("MesMoveIn"), applied);
|
|
// The PSK invalidation arrives twice during the migration window (shared + legacy topic).
|
|
Assert.Contains(new SitePskInvalidated("site-a"), applied);
|
|
|
|
// The publisher skips its own envelopes; only the bare legacy PSK message (no origin)
|
|
// comes back to it, which is the pre-existing #96 behaviour and idempotent.
|
|
await Task.Delay(500);
|
|
Assert.All(a.Handler.Applied, i => Assert.IsType<SitePskInvalidated>(i));
|
|
}
|
|
}
|