ADR-001 last-mile � Program.cs composes walker into production boot (#214) #157

Merged
dohertj2 merged 1 commits from equipment-content-wiring into v2 2026-04-20 03:52:32 -04:00
4 changed files with 161 additions and 1 deletions

View File

@@ -0,0 +1,47 @@
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
/// <summary>
/// Holds pre-loaded <see cref="EquipmentNamespaceContent"/> snapshots keyed by
/// <c>DriverInstanceId</c>. Populated once during <see cref="OpcUaServerService"/> startup
/// (after <see cref="NodeBootstrap"/> resolves the generation) so the synchronous lookup
/// delegate on <see cref="OpcUaApplicationHost"/> can serve the walker from memory without
/// blocking on async DB I/O mid-dispatch.
/// </summary>
/// <remarks>
/// <para>The registry is intentionally a shared mutable singleton with set-once-per-bootstrap
/// semantics rather than an immutable map passed by value — the composition in Program.cs
/// builds <see cref="OpcUaApplicationHost"/> before <see cref="NodeBootstrap"/> runs, so the
/// registry must exist at DI-compose time but be empty until the generation is known. A
/// driver registered after the initial populate pass simply returns null from
/// <see cref="Get"/> + the wire-in falls back to the "no UNS content, let DiscoverAsync own
/// it" path that PR #155 established.</para>
/// </remarks>
public sealed class DriverEquipmentContentRegistry
{
private readonly Dictionary<string, EquipmentNamespaceContent> _content =
new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _lock = new();
public EquipmentNamespaceContent? Get(string driverInstanceId)
{
lock (_lock)
{
return _content.TryGetValue(driverInstanceId, out var c) ? c : null;
}
}
public void Set(string driverInstanceId, EquipmentNamespaceContent content)
{
lock (_lock)
{
_content[driverInstanceId] = content;
}
}
public int Count
{
get { lock (_lock) { return _content.Count; } }
}
}

View File

@@ -1,3 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
@@ -15,6 +16,8 @@ public sealed class OpcUaServerService(
NodeBootstrap bootstrap,
DriverHost driverHost,
OpcUaApplicationHost applicationHost,
DriverEquipmentContentRegistry equipmentContentRegistry,
IServiceScopeFactory scopeFactory,
ILogger<OpcUaServerService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -24,6 +27,15 @@ public sealed class OpcUaServerService(
var result = await bootstrap.LoadCurrentGenerationAsync(stoppingToken);
logger.LogInformation("Bootstrap complete: source={Source} generation={Gen}", result.Source, result.GenerationId);
// ADR-001 Option A — populate per-driver Equipment namespace snapshots into the
// registry before StartAsync walks the address space. The walker on the OPC UA side
// reads synchronously from the registry; pre-loading here means the hot path stays
// non-blocking + each driver pays at most one Config-DB query at bootstrap time.
// Skipped when no generation is Published yet — the fleet boots into a UNS-less
// address space until the first publish, then the registry fills on next restart.
if (result.GenerationId is { } gen)
await PopulateEquipmentContentAsync(gen, stoppingToken);
// PR 17: stand up the OPC UA server + drive discovery per registered driver. Driver
// registration itself (RegisterAsync on DriverHost) happens during an earlier DI
// extension once the central config DB query + per-driver factory land; for now the
@@ -48,4 +60,30 @@ public sealed class OpcUaServerService(
await applicationHost.DisposeAsync();
await driverHost.DisposeAsync();
}
/// <summary>
/// Pre-load an <c>EquipmentNamespaceContent</c> snapshot for each registered driver at
/// the bootstrapped generation. Null results (driver has no Equipment rows —
/// Modbus/AB CIP/TwinCAT/FOCAS today per decisions #116#121) are skipped: the walker
/// wire-in sees Get(driverId) return null + falls back to DiscoverAsync-owns-it.
/// Opens one scope so the scoped <c>OtOpcUaConfigDbContext</c> is shared across all
/// per-driver queries rather than paying scope-setup overhead per driver.
/// </summary>
private async Task PopulateEquipmentContentAsync(long generationId, CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var loader = scope.ServiceProvider.GetRequiredService<EquipmentNamespaceContentLoader>();
var loaded = 0;
foreach (var driverId in driverHost.RegisteredDriverIds)
{
var content = await loader.LoadAsync(driverId, generationId, ct).ConfigureAwait(false);
if (content is null) continue;
equipmentContentRegistry.Set(driverId, content);
loaded++;
}
logger.LogInformation(
"Equipment namespace snapshots loaded for {Count}/{Total} driver(s) at generation {Gen}",
loaded, driverHost.RegisteredDriverIds.Count, generationId);
}
}

View File

@@ -86,7 +86,25 @@ builder.Services.AddSingleton<IUserAuthenticator>(sp => ldapOptions.Enabled
builder.Services.AddSingleton<ILocalConfigCache>(_ => new LiteDbConfigCache(options.LocalCachePath));
builder.Services.AddSingleton<DriverHost>();
builder.Services.AddSingleton<NodeBootstrap>();
builder.Services.AddSingleton<OpcUaApplicationHost>();
// ADR-001 Option A wiring — the registry is the handoff between OpcUaServerService's
// bootstrap-time population pass + OpcUaApplicationHost's StartAsync walker invocation.
// DriverEquipmentContentRegistry.Get is the equipmentContentLookup delegate that PR #155
// added to OpcUaApplicationHost's ctor seam.
builder.Services.AddSingleton<DriverEquipmentContentRegistry>();
builder.Services.AddScoped<EquipmentNamespaceContentLoader>();
builder.Services.AddSingleton<OpcUaApplicationHost>(sp =>
{
var registry = sp.GetRequiredService<DriverEquipmentContentRegistry>();
return new OpcUaApplicationHost(
sp.GetRequiredService<OpcUaServerOptions>(),
sp.GetRequiredService<DriverHost>(),
sp.GetRequiredService<IUserAuthenticator>(),
sp.GetRequiredService<ILoggerFactory>(),
sp.GetRequiredService<ILogger<OpcUaApplicationHost>>(),
equipmentContentLookup: registry.Get);
});
builder.Services.AddHostedService<OpcUaServerService>();
// Central-config DB access for the host-status publisher (LMX follow-up #7). Scoped context

View File

@@ -0,0 +1,57 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
[Trait("Category", "Unit")]
public sealed class DriverEquipmentContentRegistryTests
{
private static readonly EquipmentNamespaceContent EmptyContent =
new(Areas: [], Lines: [], Equipment: [], Tags: []);
[Fact]
public void Get_Returns_Null_For_Unknown_Driver()
{
var registry = new DriverEquipmentContentRegistry();
registry.Get("galaxy-prod").ShouldBeNull();
registry.Count.ShouldBe(0);
}
[Fact]
public void Set_Then_Get_Returns_Stored_Content()
{
var registry = new DriverEquipmentContentRegistry();
registry.Set("galaxy-prod", EmptyContent);
registry.Get("galaxy-prod").ShouldBeSameAs(EmptyContent);
registry.Count.ShouldBe(1);
}
[Fact]
public void Get_Is_Case_Insensitive_For_Driver_Id()
{
// DriverInstanceId keys are OrdinalIgnoreCase across the codebase (Equipment /
// Tag rows, walker grouping). Registry matches that contract so callers don't have
// to canonicalize driver ids before lookup.
var registry = new DriverEquipmentContentRegistry();
registry.Set("Galaxy-Prod", EmptyContent);
registry.Get("galaxy-prod").ShouldBeSameAs(EmptyContent);
registry.Get("GALAXY-PROD").ShouldBeSameAs(EmptyContent);
}
[Fact]
public void Set_Overwrites_Existing_Content_For_Same_Driver()
{
var registry = new DriverEquipmentContentRegistry();
var first = EmptyContent;
var second = new EquipmentNamespaceContent([], [], [], []);
registry.Set("galaxy-prod", first);
registry.Set("galaxy-prod", second);
registry.Get("galaxy-prod").ShouldBeSameAs(second);
registry.Count.ShouldBe(1);
}
}