ADR-001 last-mile � Program.cs composes walker into production boot (#214) #157
@@ -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; } }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user