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:
@@ -0,0 +1,80 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge#100 item 3: the central cache-invalidation bus publishes typed invalidations on
|
||||
/// one topic, and the subscriber hands the peer's (never its own) to every handler.
|
||||
/// </summary>
|
||||
public class CentralCacheInvalidationBusTests : TestKit
|
||||
{
|
||||
private sealed class RecordingHandler : ICentralCacheInvalidationHandler
|
||||
{
|
||||
public List<ICentralCacheInvalidation> Applied { get; } = new();
|
||||
|
||||
public void Apply(ICentralCacheInvalidation invalidation)
|
||||
{
|
||||
lock (Applied) Applied.Add(invalidation);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingHandler : ICentralCacheInvalidationHandler
|
||||
{
|
||||
public void Apply(ICentralCacheInvalidation invalidation) => throw new InvalidOperationException("boom");
|
||||
}
|
||||
|
||||
private readonly CentralCacheInvalidationBus _bus = new(NullLogger<CentralCacheInvalidationBus>.Instance);
|
||||
|
||||
/// <summary>Publishing wraps the invalidation with this node's origin id on the shared topic.</summary>
|
||||
[Fact]
|
||||
public void Publish_SendsAnEnvelopeOnTheSharedTopic()
|
||||
{
|
||||
var mediator = CreateTestProbe();
|
||||
_bus.SetMediator(mediator.Ref);
|
||||
|
||||
_bus.Publish(new RoleMappingsInvalidated());
|
||||
|
||||
var publish = mediator.ExpectMsg<Publish>();
|
||||
Assert.Equal("central-cache-invalidation", publish.Topic);
|
||||
var envelope = Assert.IsType<CentralCacheInvalidationEnvelope>(publish.Message);
|
||||
Assert.Equal(_bus.OriginId, envelope.OriginId);
|
||||
Assert.IsType<RoleMappingsInvalidated>(envelope.Invalidation);
|
||||
mediator.ExpectNoMsg(TimeSpan.FromMilliseconds(100)); // only PSK also uses the legacy topic
|
||||
}
|
||||
|
||||
/// <summary>Before the mediator exists publishing is a silent no-op.</summary>
|
||||
[Fact]
|
||||
public void Publish_WithoutAMediator_DoesNotThrow()
|
||||
{
|
||||
_bus.Publish(new ExternalDefinitionsInvalidated());
|
||||
}
|
||||
|
||||
/// <summary>The peer's invalidation reaches every handler; this node's own broadcast is skipped.</summary>
|
||||
[Fact]
|
||||
public void Subscriber_AppliesPeerInvalidations_AndSkipsItsOwn()
|
||||
{
|
||||
var handler = new RecordingHandler();
|
||||
var subscriber = Sys.ActorOf(Props.Create(() => new CentralCacheInvalidationSubscriber(
|
||||
_bus, new ICentralCacheInvalidationHandler[] { new ThrowingHandler(), handler })));
|
||||
|
||||
subscriber.Tell(new CentralCacheInvalidationEnvelope(_bus.OriginId, new RoleMappingsInvalidated()));
|
||||
subscriber.Tell(new CentralCacheInvalidationEnvelope("peer", new ExternalDefinitionsInvalidated()));
|
||||
subscriber.Tell(new CentralCacheInvalidationEnvelope("peer", new InboundApiMethodInvalidated("M")));
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
lock (handler.Applied)
|
||||
{
|
||||
// A throwing handler ahead of it does not stop delivery.
|
||||
Assert.Equal(
|
||||
new ICentralCacheInvalidation[] { new ExternalDefinitionsInvalidated(), new InboundApiMethodInvalidated("M") },
|
||||
handler.Applied);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -419,6 +419,26 @@ public class CentralCompositionRootTests : IDisposable
|
||||
_factory.Services.GetService<ZB.MOM.WW.Secrets.Abstractions.ISecretCacheInvalidator>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge#100 item 3: central publishes peer invalidations through the shared bus, and
|
||||
/// every cache that takes part (site PSK, role mappings, external definitions) is registered
|
||||
/// as a handler for the subscriber.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Central_CacheInvalidation_BusAndHandlersAreRegistered()
|
||||
{
|
||||
Assert.IsType<CentralCacheInvalidationBus>(
|
||||
_factory.Services.GetService<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.ICentralCacheInvalidationPublisher>());
|
||||
var handlers = _factory.Services
|
||||
.GetServices<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.ICentralCacheInvalidationHandler>()
|
||||
.Select(h => h.GetType())
|
||||
.ToList();
|
||||
Assert.Contains(typeof(SitePskRotationInvalidator), handlers);
|
||||
Assert.Contains(typeof(ZB.MOM.WW.ScadaBridge.Security.RoleMappingCache), handlers);
|
||||
Assert.Contains(typeof(ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.ExternalDefinitionLookupCache), handlers);
|
||||
Assert.Equal(handlers.Count, handlers.Distinct().Count());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the shared audit seam resolves to the central direct-write bridge.</summary>
|
||||
[Fact]
|
||||
public void Central_SharedAuditSeam_IsTheCentralDirectWriteBridge()
|
||||
|
||||
@@ -3,6 +3,8 @@ using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
@@ -10,35 +12,43 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #96: an in-place rotation of <c>SB-GRPC-PSK-{siteId}</c> written through the secrets
|
||||
/// write path must drop central's cached key on this node and on the peer central node.
|
||||
/// write path must drop central's cached key on this node and on the peer central node. Since
|
||||
/// ScadaBridge#100 item 3 the peer is told over the shared central cache-invalidation topic.
|
||||
/// </summary>
|
||||
public class SitePskRotationInvalidatorTests : TestKit
|
||||
{
|
||||
private readonly ISecretCacheInvalidator _inner = Substitute.For<ISecretCacheInvalidator>();
|
||||
private readonly ISitePskProvider _psk = Substitute.For<ISitePskProvider>();
|
||||
private readonly CentralCacheInvalidationBus _bus = new(NullLogger<CentralCacheInvalidationBus>.Instance);
|
||||
|
||||
private SitePskRotationInvalidator Create() =>
|
||||
new(_inner, _psk, NullLogger<SitePskRotationInvalidator>.Instance);
|
||||
private SitePskRotationInvalidator Create(ICentralCacheInvalidationPublisher? publisher) =>
|
||||
new(_inner, _psk, publisher, NullLogger<SitePskRotationInvalidator>.Instance);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that writing a site's PSK secret evicts the resolver cache, drops the site's
|
||||
/// cached key and publishes the invalidation to the peer.
|
||||
/// cached key and publishes the invalidation on the shared topic and, for a mixed-version
|
||||
/// peer, bare on the legacy #96 topic.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WritingAPskSecret_DropsTheCachedKey_AndTellsThePeer()
|
||||
{
|
||||
var mediator = CreateTestProbe();
|
||||
var invalidator = Create();
|
||||
invalidator.SetMediator(mediator.Ref);
|
||||
_bus.SetMediator(mediator.Ref);
|
||||
var invalidator = Create(_bus);
|
||||
|
||||
// The secrets library lowercases names, so the site id arrives lowercased too.
|
||||
invalidator.Invalidate(new SecretName("SB-GRPC-PSK-site-a"));
|
||||
|
||||
_inner.Received(1).Invalidate(new SecretName("SB-GRPC-PSK-site-a"));
|
||||
_psk.Received(1).Invalidate("site-a");
|
||||
var publish = mediator.ExpectMsg<Publish>();
|
||||
Assert.Equal(SitePskRotationInvalidator.Topic, publish.Topic);
|
||||
Assert.Equal(new SitePskInvalidated("site-a"), publish.Message);
|
||||
var shared = mediator.ExpectMsg<Publish>();
|
||||
Assert.Equal(CentralCacheInvalidationBus.Topic, shared.Topic);
|
||||
var envelope = Assert.IsType<CentralCacheInvalidationEnvelope>(shared.Message);
|
||||
Assert.Equal(new SitePskInvalidated("site-a"), envelope.Invalidation);
|
||||
Assert.Equal(_bus.OriginId, envelope.OriginId);
|
||||
var legacy = mediator.ExpectMsg<Publish>();
|
||||
Assert.Equal("site-psk-invalidation", legacy.Topic);
|
||||
Assert.Equal(new SitePskInvalidated("site-a"), legacy.Message);
|
||||
}
|
||||
|
||||
/// <summary>Negative control: any other secret only evicts the resolver cache.</summary>
|
||||
@@ -46,8 +56,8 @@ public class SitePskRotationInvalidatorTests : TestKit
|
||||
public void WritingAnyOtherSecret_OnlyEvictsTheResolverCache()
|
||||
{
|
||||
var mediator = CreateTestProbe();
|
||||
var invalidator = Create();
|
||||
invalidator.SetMediator(mediator.Ref);
|
||||
_bus.SetMediator(mediator.Ref);
|
||||
var invalidator = Create(_bus);
|
||||
|
||||
invalidator.Invalidate(new SecretName("SMTP-PASSWORD"));
|
||||
|
||||
@@ -56,13 +66,15 @@ public class SitePskRotationInvalidatorTests : TestKit
|
||||
mediator.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that before the actor system exists an invalidation still applies locally.</summary>
|
||||
/// <summary>Verifies that without a publisher (or before the actor system exists) an invalidation still applies locally.</summary>
|
||||
[Fact]
|
||||
public void WithoutAMediator_TheLocalInvalidationStillApplies()
|
||||
public void WithoutAPublisher_TheLocalInvalidationStillApplies()
|
||||
{
|
||||
Create().Invalidate(new SecretName("SB-GRPC-PSK-site-a"));
|
||||
Create(null).Invalidate(new SecretName("SB-GRPC-PSK-site-a"));
|
||||
Create(_bus).Invalidate(new SecretName("SB-GRPC-PSK-site-b")); // bus has no mediator yet
|
||||
|
||||
_psk.Received(1).Invalidate("site-a");
|
||||
_psk.Received(1).Invalidate("site-b");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -73,11 +85,12 @@ public class SitePskRotationInvalidatorTests : TestKit
|
||||
public void Subscriber_AppliesAPeerInvalidation_WithoutRepublishing()
|
||||
{
|
||||
var mediator = CreateTestProbe();
|
||||
var invalidator = Create();
|
||||
invalidator.SetMediator(mediator.Ref);
|
||||
var subscriber = Sys.ActorOf(Props.Create(() => new SitePskInvalidationSubscriber(invalidator)));
|
||||
_bus.SetMediator(mediator.Ref);
|
||||
var invalidator = Create(_bus);
|
||||
var subscriber = Sys.ActorOf(Props.Create(() => new CentralCacheInvalidationSubscriber(
|
||||
_bus, new ICentralCacheInvalidationHandler[] { invalidator })));
|
||||
|
||||
subscriber.Tell(new SitePskInvalidated("site-b"));
|
||||
subscriber.Tell(new CentralCacheInvalidationEnvelope("peer-node", new SitePskInvalidated("site-b")));
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
@@ -86,4 +99,20 @@ public class SitePskRotationInvalidatorTests : TestKit
|
||||
});
|
||||
mediator.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Migration: a bare <see cref="SitePskInvalidated"/> from a peer still on the #96 topic is
|
||||
/// applied too.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Subscriber_AppliesALegacyBarePskInvalidation()
|
||||
{
|
||||
var invalidator = Create(_bus);
|
||||
var subscriber = Sys.ActorOf(Props.Create(() => new CentralCacheInvalidationSubscriber(
|
||||
_bus, new ICentralCacheInvalidationHandler[] { invalidator })));
|
||||
|
||||
subscriber.Tell(new SitePskInvalidated("site-c"));
|
||||
|
||||
AwaitAssert(() => _psk.Received(1).Invalidate("site-c"));
|
||||
}
|
||||
}
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
+52
@@ -6,6 +6,7 @@ using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
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.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.ExternalSystemGateway;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
@@ -23,6 +24,9 @@ public class ExternalDefinitionCacheInvalidationTests : TestKit
|
||||
private readonly IExternalSystemRepository _repo = Substitute.For<IExternalSystemRepository>();
|
||||
// A TTL far longer than the test: only an explicit InvalidateAll can make the cache re-fetch.
|
||||
private readonly ExternalDefinitionLookupCache _cache = new(TimeSpan.FromHours(1));
|
||||
// The other central node's cache, reached only through the peer invalidation (audit D3 / #83).
|
||||
private readonly ExternalDefinitionLookupCache _peerCache = new(TimeSpan.FromHours(1));
|
||||
private readonly PeerCacheInvalidationPublisher _publisher;
|
||||
private readonly ServiceCollection _services = new ServiceCollection().AddTestConfigurationTransaction();
|
||||
|
||||
/// <summary>Initializes the harness with one external system, one method and one DB connection.</summary>
|
||||
@@ -37,6 +41,8 @@ public class ExternalDefinitionCacheInvalidationTests : TestKit
|
||||
_services.AddScoped(_ => _repo);
|
||||
_services.AddScoped(_ => Substitute.For<IAuditService>());
|
||||
_services.AddSingleton(_cache);
|
||||
_publisher = new PeerCacheInvalidationPublisher(_peerCache);
|
||||
_services.AddSingleton<ICentralCacheInvalidationPublisher>(_publisher);
|
||||
}
|
||||
|
||||
private IActorRef CreateActor() =>
|
||||
@@ -103,4 +109,50 @@ public class ExternalDefinitionCacheInvalidationTests : TestKit
|
||||
Assert.False(await IsStillCached()); // dropped by the edit, so the lookup re-fetched
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit D3 / #83 (ScadaBridge#100 item 3): every definition edit made through this node
|
||||
/// drops the peer central node's cache too, instead of leaving it to <c>DefinitionCacheTtl</c>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EveryDefinitionMutation_InvalidatesThePeerCache()
|
||||
{
|
||||
object[] commands =
|
||||
[
|
||||
new CreateExternalSystemCommand("New", "http://n", "None", null),
|
||||
new UpdateExternalSystemCommand(1, "Erp", "http://erp2", "None", null),
|
||||
new DeleteExternalSystemCommand(1),
|
||||
new CreateExternalSystemMethodCommand(1, "M", "GET", "/m"),
|
||||
new UpdateExternalSystemMethodCommand(2, Path: "/y"),
|
||||
new DeleteExternalSystemMethodCommand(2),
|
||||
new CreateDatabaseConnectionDefCommand("Db2", "Server=y"),
|
||||
new UpdateDatabaseConnectionDefCommand(3, "Db", "Server=z"),
|
||||
new DeleteDatabaseConnectionDefCommand(3),
|
||||
];
|
||||
var actor = CreateActor();
|
||||
foreach (var command in commands)
|
||||
{
|
||||
var fetches = 0;
|
||||
Task<ExternalSystemDefinition?> Fetch()
|
||||
{
|
||||
fetches++;
|
||||
return Task.FromResult<ExternalSystemDefinition?>(new ExternalSystemDefinition("Erp", "http://erp", "None"));
|
||||
}
|
||||
|
||||
_peerCache.InvalidateAll();
|
||||
await _peerCache.GetSystemAsync("Erp", Fetch);
|
||||
await _peerCache.GetSystemAsync("Erp", Fetch);
|
||||
Assert.Equal(1, fetches); // positive control: the peer serves from its cache
|
||||
|
||||
actor.Tell(Admin(command));
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
|
||||
await _peerCache.GetSystemAsync("Erp", Fetch);
|
||||
Assert.Equal(2, fetches); // the peer re-fetched
|
||||
}
|
||||
|
||||
Assert.Equal(commands.Length, _publisher.Published.Count);
|
||||
Assert.All(_publisher.Published, i => Assert.IsType<ExternalDefinitionsInvalidated>(i));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Test stand-in for the central cache-invalidation bus (ScadaBridge#100 item 3): records every
|
||||
/// published invalidation and hands it to the "peer" node's handlers, as the subscriber on the
|
||||
/// other central node would.
|
||||
/// </summary>
|
||||
internal sealed class PeerCacheInvalidationPublisher : ICentralCacheInvalidationPublisher
|
||||
{
|
||||
private readonly ICentralCacheInvalidationHandler[] _peerHandlers;
|
||||
|
||||
/// <summary>Creates the publisher.</summary>
|
||||
/// <param name="peerHandlers">The peer node's handlers.</param>
|
||||
public PeerCacheInvalidationPublisher(params ICentralCacheInvalidationHandler[] peerHandlers) =>
|
||||
_peerHandlers = peerHandlers;
|
||||
|
||||
/// <summary>Gets every invalidation published.</summary>
|
||||
public List<ICentralCacheInvalidation> Published { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Publish(ICentralCacheInvalidation invalidation)
|
||||
{
|
||||
lock (Published) Published.Add(invalidation);
|
||||
foreach (var handler in _peerHandlers)
|
||||
handler.Apply(invalidation);
|
||||
}
|
||||
}
|
||||
+47
@@ -6,6 +6,7 @@ using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
|
||||
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.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
@@ -20,6 +21,9 @@ public class RoleMappingCacheInvalidationTests : TestKit
|
||||
{
|
||||
private readonly ISecurityRepository _repo = Substitute.For<ISecurityRepository>();
|
||||
private readonly RoleMappingCache _cache = new(TimeProvider.System);
|
||||
// The other central node's cache, reached only through the peer invalidation (audit D1).
|
||||
private readonly RoleMappingCache _peerCache = new(TimeProvider.System);
|
||||
private readonly PeerCacheInvalidationPublisher _publisher;
|
||||
private readonly ServiceCollection _services = new ServiceCollection().AddTestConfigurationTransaction();
|
||||
private List<LdapGroupMapping> _mappings = [new("Ops", "Deployer") { Id = 5 }];
|
||||
|
||||
@@ -33,6 +37,8 @@ public class RoleMappingCacheInvalidationTests : TestKit
|
||||
_services.AddScoped(_ => _repo);
|
||||
_services.AddScoped(_ => Substitute.For<IAuditService>());
|
||||
_services.AddSingleton(_cache);
|
||||
_publisher = new PeerCacheInvalidationPublisher(_peerCache);
|
||||
_services.AddSingleton<ICentralCacheInvalidationPublisher>(_publisher);
|
||||
}
|
||||
|
||||
private IActorRef CreateActor() =>
|
||||
@@ -84,4 +90,45 @@ public class RoleMappingCacheInvalidationTests : TestKit
|
||||
await _repo.Received(1).GetAllMappingsAsync(Arg.Any<CancellationToken>()); // re-read, not served stale
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit D1 (ScadaBridge#100 item 3): a revocation made through this node reaches the peer
|
||||
/// central node's snapshot at once, instead of after <c>RoleMappingCacheSeconds</c>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DeleteRoleMapping_IsVisibleOnThePeerNode()
|
||||
{
|
||||
var peerMapper = new RoleMapper(_repo, _peerCache);
|
||||
Assert.Contains("Deployer", (await peerMapper.MapGroupsToRolesAsync(["Ops"])).Roles); // primes the peer
|
||||
|
||||
_mappings = [];
|
||||
CreateActor().Tell(Admin(new DeleteRoleMappingCommand(5)));
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Empty((await peerMapper.MapGroupsToRolesAsync(["Ops"])).Roles);
|
||||
}
|
||||
|
||||
/// <summary>Every mapping / scope-rule mutation tells the peer, once, after commit.</summary>
|
||||
[Fact]
|
||||
public void EveryMappingMutation_TellsThePeer()
|
||||
{
|
||||
object[] commands =
|
||||
[
|
||||
new CreateRoleMappingCommand("Eng", "Designer"),
|
||||
new UpdateRoleMappingCommand(5, "Ops", "Viewer"),
|
||||
new DeleteRoleMappingCommand(5),
|
||||
new AddScopeRuleCommand(5, 2),
|
||||
new DeleteScopeRuleCommand(9),
|
||||
];
|
||||
var actor = CreateActor();
|
||||
foreach (var command in commands)
|
||||
{
|
||||
actor.Tell(Admin(command));
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
Assert.Equal(commands.Length, _publisher.Published.Count);
|
||||
Assert.All(_publisher.Published, i => Assert.IsType<RoleMappingsInvalidated>(i));
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -13,6 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
@@ -48,6 +49,7 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
private readonly ServiceProvider _provider;
|
||||
private readonly RecordingScriptArtifactChangeBus _artifactBus = new();
|
||||
private readonly CountingDefinitionCacheInvalidator _definitionCache = new();
|
||||
private readonly RecordingCacheInvalidationPublisher _peerPublisher = new();
|
||||
|
||||
/// <summary>Initializes a new instance of the <c>BundleImporterApplyTests</c> class.</summary>
|
||||
public BundleImporterApplyTests()
|
||||
@@ -103,6 +105,8 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
services.AddSingleton<IScriptArtifactChangeBus>(_artifactBus);
|
||||
// Gitea #83: observable post-commit drop of the external-definition lookup cache.
|
||||
services.AddSingleton<IExternalDefinitionCacheInvalidator>(_definitionCache);
|
||||
// ScadaBridge#100 item 3: observable post-commit broadcast to the peer central node.
|
||||
services.AddSingleton<ICentralCacheInvalidationPublisher>(_peerPublisher);
|
||||
// The stale-instance probe is implemented in DeploymentManager
|
||||
// and the flatten/hash primitives in TemplateEngine — register both so
|
||||
// BundleImporter resolves a real IStaleInstanceProbe and ApplyAsync can
|
||||
@@ -1946,6 +1950,8 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
}
|
||||
|
||||
Assert.Equal(1, _definitionCache.Calls);
|
||||
// ScadaBridge#100 item 3 / #83: the peer central node is told to drop its copy too.
|
||||
Assert.Contains(_peerPublisher.Published, i => i is ExternalDefinitionsInvalidated);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a failed apply publishes no notifications.</summary>
|
||||
@@ -1998,6 +2004,20 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
Assert.Empty(_artifactBus.Received);
|
||||
// Gitea #83: nothing committed, so the definition cache is left alone.
|
||||
Assert.Equal(0, _definitionCache.Calls);
|
||||
Assert.Empty(_peerPublisher.Published);
|
||||
}
|
||||
|
||||
/// <summary>Records peer cache invalidations (ScadaBridge#100 item 3).</summary>
|
||||
private sealed class RecordingCacheInvalidationPublisher : ICentralCacheInvalidationPublisher
|
||||
{
|
||||
/// <summary>Gets the invalidations published.</summary>
|
||||
public List<ICentralCacheInvalidation> Published { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Publish(ICentralCacheInvalidation invalidation)
|
||||
{
|
||||
lock (Published) Published.Add(invalidation);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Counts post-commit external-definition cache invalidations (Gitea #83).</summary>
|
||||
|
||||
Reference in New Issue
Block a user