diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverEquipmentContentRegistry.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverEquipmentContentRegistry.cs new file mode 100644 index 0000000..822c6c8 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverEquipmentContentRegistry.cs @@ -0,0 +1,47 @@ +using ZB.MOM.WW.OtOpcUa.Core.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa; + +/// +/// Holds pre-loaded snapshots keyed by +/// DriverInstanceId. Populated once during startup +/// (after resolves the generation) so the synchronous lookup +/// delegate on can serve the walker from memory without +/// blocking on async DB I/O mid-dispatch. +/// +/// +/// 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 before 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 +/// + the wire-in falls back to the "no UNS content, let DiscoverAsync own +/// it" path that PR #155 established. +/// +public sealed class DriverEquipmentContentRegistry +{ + private readonly Dictionary _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; } } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs index b451f2c..c090bef 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs @@ -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 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(); } + + /// + /// Pre-load an EquipmentNamespaceContent 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 OtOpcUaConfigDbContext is shared across all + /// per-driver queries rather than paying scope-setup overhead per driver. + /// + private async Task PopulateEquipmentContentAsync(long generationId, CancellationToken ct) + { + using var scope = scopeFactory.CreateScope(); + var loader = scope.ServiceProvider.GetRequiredService(); + + 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); + } } diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs index 75f911b..7c6c81f 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs @@ -86,7 +86,25 @@ builder.Services.AddSingleton(sp => ldapOptions.Enabled builder.Services.AddSingleton(_ => new LiteDbConfigCache(options.LocalCachePath)); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); + +// 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(); +builder.Services.AddScoped(); + +builder.Services.AddSingleton(sp => +{ + var registry = sp.GetRequiredService(); + return new OpcUaApplicationHost( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + equipmentContentLookup: registry.Get); +}); builder.Services.AddHostedService(); // Central-config DB access for the host-status publisher (LMX follow-up #7). Scoped context diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/DriverEquipmentContentRegistryTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/DriverEquipmentContentRegistryTests.cs new file mode 100644 index 0000000..6e55d55 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/DriverEquipmentContentRegistryTests.cs @@ -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); + } +}