fix(management): invalidate ExternalDefinitionLookupCache on external-system / DB-connection edits (ScadaBridge#65)

ManagementActor now drops the central definition cache post-commit (via the existing
AfterConfigurationCommit seam) after every external system, external-system method and
database connection create/update/delete, so an edit takes effect on the editing node
immediately instead of after DefinitionCacheTtl. ManagementService gains a project reference
to ExternalSystemGateway. The other central node still converges within the TTL.
This commit is contained in:
Joseph Doherty
2026-11-09 12:05:20 -05:00
parent e0ec2a0507
commit e1734675e9
3 changed files with 134 additions and 0 deletions
@@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems; using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.ExternalSystemGateway;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
@@ -2364,6 +2365,23 @@ public class ManagementActor : ReceiveActor
HasAuthConfiguration = !string.IsNullOrEmpty(d.AuthConfiguration), HasAuthConfiguration = !string.IsNullOrEmpty(d.AuthConfiguration),
}; };
/// <summary>
/// Drops this node's <see cref="ExternalDefinitionLookupCache"/> once an external-system /
/// external-system-method / database-connection edit has committed, so the next inbound-method
/// 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).
/// </summary>
private static Task InvalidateExternalDefinitionCacheAfterCommit(IServiceProvider sp)
{
var cache = sp.GetService<ExternalDefinitionLookupCache>();
return cache is null
? Task.CompletedTask
: AfterConfigurationCommit(sp, () => { cache.InvalidateAll(); return Task.CompletedTask; });
}
private static async Task<object?> HandleListExternalSystems(IServiceProvider sp) private static async Task<object?> HandleListExternalSystems(IServiceProvider sp)
{ {
var repo = sp.GetRequiredService<IExternalSystemRepository>(); var repo = sp.GetRequiredService<IExternalSystemRepository>();
@@ -2392,6 +2410,7 @@ public class ManagementActor : ReceiveActor
await repo.AddExternalSystemAsync(def); await repo.AddExternalSystemAsync(def);
await repo.SaveChangesAsync(); await repo.SaveChangesAsync();
var publicShape = ExternalSystemPublicShape(def); var publicShape = ExternalSystemPublicShape(def);
await InvalidateExternalDefinitionCacheAfterCommit(sp);
await AuditAsync(sp, user, "Create", "ExternalSystem", def.Id.ToString(), def.Name, publicShape); await AuditAsync(sp, user, "Create", "ExternalSystem", def.Id.ToString(), def.Name, publicShape);
return publicShape; return publicShape;
} }
@@ -2414,6 +2433,7 @@ public class ManagementActor : ReceiveActor
await repo.UpdateExternalSystemAsync(def); await repo.UpdateExternalSystemAsync(def);
await repo.SaveChangesAsync(); await repo.SaveChangesAsync();
var publicShape = ExternalSystemPublicShape(def); var publicShape = ExternalSystemPublicShape(def);
await InvalidateExternalDefinitionCacheAfterCommit(sp);
await AuditAsync(sp, user, "Update", "ExternalSystem", def.Id.ToString(), def.Name, publicShape); await AuditAsync(sp, user, "Update", "ExternalSystem", def.Id.ToString(), def.Name, publicShape);
return publicShape; return publicShape;
} }
@@ -2423,6 +2443,7 @@ public class ManagementActor : ReceiveActor
var repo = sp.GetRequiredService<IExternalSystemRepository>(); var repo = sp.GetRequiredService<IExternalSystemRepository>();
await repo.DeleteExternalSystemAsync(cmd.ExternalSystemId); await repo.DeleteExternalSystemAsync(cmd.ExternalSystemId);
await repo.SaveChangesAsync(); await repo.SaveChangesAsync();
await InvalidateExternalDefinitionCacheAfterCommit(sp);
await AuditAsync(sp, user, "Delete", "ExternalSystem", cmd.ExternalSystemId.ToString(), cmd.ExternalSystemId.ToString(), null); await AuditAsync(sp, user, "Delete", "ExternalSystem", cmd.ExternalSystemId.ToString(), cmd.ExternalSystemId.ToString(), null);
return true; return true;
} }
@@ -2450,6 +2471,7 @@ public class ManagementActor : ReceiveActor
}; };
await repo.AddExternalSystemMethodAsync(method); await repo.AddExternalSystemMethodAsync(method);
await repo.SaveChangesAsync(); await repo.SaveChangesAsync();
await InvalidateExternalDefinitionCacheAfterCommit(sp);
await AuditAsync(sp, user, "Create", "ExternalSystemMethod", method.Id.ToString(), method.Name, method); await AuditAsync(sp, user, "Create", "ExternalSystemMethod", method.Id.ToString(), method.Name, method);
return method; return method;
} }
@@ -2466,6 +2488,7 @@ public class ManagementActor : ReceiveActor
if (cmd.ReturnDefinition != null) method.ReturnDefinition = cmd.ReturnDefinition; if (cmd.ReturnDefinition != null) method.ReturnDefinition = cmd.ReturnDefinition;
await repo.UpdateExternalSystemMethodAsync(method); await repo.UpdateExternalSystemMethodAsync(method);
await repo.SaveChangesAsync(); await repo.SaveChangesAsync();
await InvalidateExternalDefinitionCacheAfterCommit(sp);
await AuditAsync(sp, user, "Update", "ExternalSystemMethod", method.Id.ToString(), method.Name, method); await AuditAsync(sp, user, "Update", "ExternalSystemMethod", method.Id.ToString(), method.Name, method);
return method; return method;
} }
@@ -2475,6 +2498,7 @@ public class ManagementActor : ReceiveActor
var repo = sp.GetRequiredService<IExternalSystemRepository>(); var repo = sp.GetRequiredService<IExternalSystemRepository>();
await repo.DeleteExternalSystemMethodAsync(cmd.MethodId); await repo.DeleteExternalSystemMethodAsync(cmd.MethodId);
await repo.SaveChangesAsync(); await repo.SaveChangesAsync();
await InvalidateExternalDefinitionCacheAfterCommit(sp);
await AuditAsync(sp, user, "Delete", "ExternalSystemMethod", cmd.MethodId.ToString(), cmd.MethodId.ToString(), null); await AuditAsync(sp, user, "Delete", "ExternalSystemMethod", cmd.MethodId.ToString(), cmd.MethodId.ToString(), null);
return true; return true;
} }
@@ -3681,6 +3705,7 @@ public class ManagementActor : ReceiveActor
if (cmd.RetryDelay is { } retryDelay) def.RetryDelay = retryDelay; if (cmd.RetryDelay is { } retryDelay) def.RetryDelay = retryDelay;
await repo.AddDatabaseConnectionAsync(def); await repo.AddDatabaseConnectionAsync(def);
await repo.SaveChangesAsync(); await repo.SaveChangesAsync();
await InvalidateExternalDefinitionCacheAfterCommit(sp);
await AuditAsync(sp, user, "Create", "DatabaseConnection", def.Id.ToString(), def.Name, new { def.Id, def.Name }); await AuditAsync(sp, user, "Create", "DatabaseConnection", def.Id.ToString(), def.Name, new { def.Id, def.Name });
return DatabaseConnectionPublicShape(def); return DatabaseConnectionPublicShape(def);
} }
@@ -3699,6 +3724,7 @@ public class ManagementActor : ReceiveActor
if (cmd.RetryDelay is { } retryDelay) def.RetryDelay = retryDelay; if (cmd.RetryDelay is { } retryDelay) def.RetryDelay = retryDelay;
await repo.UpdateDatabaseConnectionAsync(def); await repo.UpdateDatabaseConnectionAsync(def);
await repo.SaveChangesAsync(); await repo.SaveChangesAsync();
await InvalidateExternalDefinitionCacheAfterCommit(sp);
await AuditAsync(sp, user, "Update", "DatabaseConnection", def.Id.ToString(), def.Name, new { def.Id, def.Name }); await AuditAsync(sp, user, "Update", "DatabaseConnection", def.Id.ToString(), def.Name, new { def.Id, def.Name });
return DatabaseConnectionPublicShape(def); return DatabaseConnectionPublicShape(def);
} }
@@ -3708,6 +3734,7 @@ public class ManagementActor : ReceiveActor
var repo = sp.GetRequiredService<IExternalSystemRepository>(); var repo = sp.GetRequiredService<IExternalSystemRepository>();
await repo.DeleteDatabaseConnectionAsync(cmd.DatabaseConnectionId); await repo.DeleteDatabaseConnectionAsync(cmd.DatabaseConnectionId);
await repo.SaveChangesAsync(); await repo.SaveChangesAsync();
await InvalidateExternalDefinitionCacheAfterCommit(sp);
await AuditAsync(sp, user, "Delete", "DatabaseConnection", cmd.DatabaseConnectionId.ToString(), cmd.DatabaseConnectionId.ToString(), null); await AuditAsync(sp, user, "Delete", "DatabaseConnection", cmd.DatabaseConnectionId.ToString(), cmd.DatabaseConnectionId.ToString(), null);
return true; return true;
} }
@@ -24,5 +24,6 @@
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj" /> <ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj" />
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj" /> <ProjectReference Include="../ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj" />
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.InboundAPI/ZB.MOM.WW.ScadaBridge.InboundAPI.csproj" /> <ProjectReference Include="../ZB.MOM.WW.ScadaBridge.InboundAPI/ZB.MOM.WW.ScadaBridge.InboundAPI.csproj" />
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,106 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
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.Management;
using ZB.MOM.WW.ScadaBridge.ExternalSystemGateway;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// ScadaBridge#65: the central <see cref="ExternalDefinitionLookupCache"/> used to be dropped only
/// by its TTL, so an external-system / method / database-connection edit made through the management
/// path was invisible to inbound-method calls on the same node for up to <c>DefinitionCacheTtl</c>.
/// Every such mutation now invalidates it once the change is committed.
/// </summary>
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));
private readonly ServiceCollection _services = new ServiceCollection().AddTestConfigurationTransaction();
/// <summary>Initializes the harness with one external system, one method and one DB connection.</summary>
public ExternalDefinitionCacheInvalidationTests()
{
_repo.GetExternalSystemByIdAsync(1, Arg.Any<CancellationToken>())
.Returns(_ => new ExternalSystemDefinition("Erp", "http://erp", "None") { Id = 1 });
_repo.GetExternalSystemMethodByIdAsync(2, Arg.Any<CancellationToken>())
.Returns(_ => new ExternalSystemMethod("Get", "GET", "/x") { Id = 2, ExternalSystemDefinitionId = 1 });
_repo.GetDatabaseConnectionByIdAsync(3, Arg.Any<CancellationToken>())
.Returns(_ => new DatabaseConnectionDefinition("Db", "Server=x") { Id = 3 });
_services.AddScoped(_ => _repo);
_services.AddScoped(_ => Substitute.For<IAuditService>());
_services.AddSingleton(_cache);
}
private IActorRef CreateActor() =>
Sys.ActorOf(Props.Create(() => new ManagementActor(
_services.BuildServiceProvider(), NullLogger<ManagementActor>.Instance)));
private static ManagementEnvelope Admin(object command) =>
new(new AuthenticatedUser("admin", "Admin", new[] { "Designer" }, Array.Empty<string>()),
command, Guid.NewGuid().ToString("N"));
private async Task<int> PrimeAndCountFetches()
{
var fetches = 0;
Task<ExternalSystemDefinition?> Fetch()
{
fetches++;
return Task.FromResult<ExternalSystemDefinition?>(new ExternalSystemDefinition("Erp", "http://erp", "None"));
}
await _cache.GetSystemAsync("Erp", Fetch);
await _cache.GetSystemAsync("Erp", Fetch);
Assert.Equal(1, fetches); // second read was served from the cache
return fetches;
}
private async Task<bool> IsStillCached()
{
var refetched = false;
await _cache.GetSystemAsync("Erp", () =>
{
refetched = true;
return Task.FromResult<ExternalSystemDefinition?>(new ExternalSystemDefinition("Erp", "http://erp", "None"));
});
return !refetched;
}
/// <summary>Every external-system / method / database-connection mutation drops the cached definitions.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EveryDefinitionMutation_InvalidatesTheCache()
{
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)
{
_cache.InvalidateAll();
await PrimeAndCountFetches();
Assert.True(await IsStillCached());
actor.Tell(Admin(command));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.False(await IsStillCached()); // dropped by the edit, so the lookup re-fetched
}
}
}