fix(transport): drop the external-definition lookup cache after a committed bundle import (ScadaBridge#83)

The bundle importer writes external systems, methods and database connections
through the raw DbContext, bypassing the ManagementActor handlers that
invalidate ExternalDefinitionLookupCache (#65), so the importing node served
pre-import definitions for up to DefinitionCacheTtl. New Commons seam
IExternalDefinitionCacheInvalidator, implemented by the cache and registered as
the same singleton; BundleImporter takes it optionally and invalidates once
after every committed import (never on rollback; never throws).

Peer-central-node invalidation (the other half of #83) is NOT done here: the
issue leaves open whether the 10 s TTL bound is acceptable for the peer.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 12:40:43 -05:00
parent 6d5bb6189f
commit 03de3dcc14
6 changed files with 132 additions and 2 deletions
@@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests;
@@ -12,6 +13,26 @@ namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests;
/// </summary>
public class ServiceWiringTests
{
/// <summary>
/// Gitea #83: the Commons invalidation seam resolves to the SAME lookup-cache singleton the
/// gateway reads through, so a bundle import that invalidates it actually clears the cache.
/// </summary>
[Fact]
public void DefinitionCacheInvalidator_IsTheLookupCacheSingleton()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
services.AddSingleton(Substitute.For<IExternalSystemRepository>());
services.AddExternalSystemGateway();
using var provider = services.BuildServiceProvider();
Assert.Same(
provider.GetRequiredService<ExternalDefinitionLookupCache>(),
provider.GetRequiredService<IExternalDefinitionCacheInvalidator>());
}
/// <summary>Verifies that the per-system connection cap is applied to the primary handler of the named HTTP client.</summary>
[Fact]
public void MaxConcurrentConnectionsPerSystem_IsAppliedToTheNamedHttpClientPrimaryHandler()
@@ -47,6 +47,7 @@ public sealed class BundleImporterApplyTests : IDisposable
{
private readonly ServiceProvider _provider;
private readonly RecordingScriptArtifactChangeBus _artifactBus = new();
private readonly CountingDefinitionCacheInvalidator _definitionCache = new();
/// <summary>Initializes a new instance of the <c>BundleImporterApplyTests</c> class.</summary>
public BundleImporterApplyTests()
@@ -100,6 +101,8 @@ public sealed class BundleImporterApplyTests : IDisposable
// publish is observable. BundleImporter takes IScriptArtifactChangeBus?
// optionally, so this registration flows in via DI activation.
services.AddSingleton<IScriptArtifactChangeBus>(_artifactBus);
// Gitea #83: observable post-commit drop of the external-definition lookup cache.
services.AddSingleton<IExternalDefinitionCacheInvalidator>(_definitionCache);
// 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
@@ -1910,6 +1913,41 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.Contains("DelmiaRecipeDownload", notification.Names); // FAILS today: Adds are filtered out
}
/// <summary>
/// Gitea #83: a committed import drops the node's external-definition lookup cache once,
/// because the importer writes external systems / database connections without going
/// through the ManagementActor handlers that otherwise invalidate it.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_committed_import_invalidates_the_external_definition_cache()
{
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.ExternalSystemDefinitions.Add(new ExternalSystemDefinition("Mes", "https://mes.example/api", "None"));
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.ExternalSystemDefinitions.RemoveRange(ctx.ExternalSystemDefinitions);
await ctx.SaveChangesAsync();
}
Assert.Equal(0, _definitionCache.Calls);
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("ExternalSystem", "Mes", ResolutionAction.Add, null) },
user: "bob");
}
Assert.Equal(1, _definitionCache.Calls);
}
/// <summary>Verifies that a failed apply publishes no notifications.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -1958,6 +1996,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);
}
/// <summary>Counts post-commit external-definition cache invalidations (Gitea #83).</summary>
private sealed class CountingDefinitionCacheInvalidator : IExternalDefinitionCacheInvalidator
{
private int _calls;
/// <summary>Gets the number of invalidations received.</summary>
public int Calls => Volatile.Read(ref _calls);
/// <inheritdoc />
public void InvalidateAll() => Interlocked.Increment(ref _calls);
}
/// <summary>Records every published notification for assertion.</summary>