From 432173c5c4b5c57bb495f9ee7f26a867d0bd5ab2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 03:50:37 -0400 Subject: [PATCH] =?UTF-8?q?ADR-001=20last-mile=20=E2=80=94=20Program.cs=20?= =?UTF-8?q?composes=20EquipmentNodeWalker=20into=20the=20production=20boot?= =?UTF-8?q?=20path.=20Closes=20task=20#214=20+=20fully=20lands=20ADR-001?= =?UTF-8?q?=20Option=20A=20as=20a=20live=20code=20path,=20not=20just=20a?= =?UTF-8?q?=20connected=20set=20of=20unit-tested=20primitives.=20After=20t?= =?UTF-8?q?his=20PR=20a=20server=20booted=20against=20a=20real=20Config=20?= =?UTF-8?q?DB=20with=20Published=20Equipment=20rows=20materializes=20the?= =?UTF-8?q?=20UNS=20tree=20into=20the=20OPC=20UA=20address=20space=20on=20?= =?UTF-8?q?startup=20=E2=80=94=20the=20whole=20walker=20=E2=86=92=20wire-i?= =?UTF-8?q?n=20=E2=86=92=20loader=20chain=20(PRs=20#153,=20#154,=20#155,?= =?UTF-8?q?=20#156)=20finally=20fires=20end-to-end=20in=20the=20production?= =?UTF-8?q?=20process.=20DriverEquipmentContentRegistry=20is=20the=20hando?= =?UTF-8?q?ff=20between=20OpcUaServerService's=20bootstrap-time=20populate?= =?UTF-8?q?=20pass=20+=20OpcUaApplicationHost's=20StartAsync=20walker=20in?= =?UTF-8?q?vocation.=20It's=20a=20singleton=20mutable=20holder=20with=20Ge?= =?UTF-8?q?t/Set/Count=20+=20Lock-guarded=20internal=20dictionary=20keyed?= =?UTF-8?q?=20OrdinalIgnoreCase=20to=20match=20the=20DriverInstanceId=20co?= =?UTF-8?q?nvention=20used=20by=20Equipment=20/=20Tag=20rows=20+=20walker?= =?UTF-8?q?=20grouping.=20Set-once-per-bootstrap=20semantics=20in=20practi?= =?UTF-8?q?ce=20though=20nothing=20enforces=20that=20at=20the=20type=20lev?= =?UTF-8?q?el=20=E2=80=94=20OpcUaServerService.PopulateEquipmentContentAsy?= =?UTF-8?q?nc=20is=20the=20only=20expected=20writer.=20Shared-mutable=20ra?= =?UTF-8?q?ther=20than=20immutable-passed-by-value=20because=20the=20DI=20?= =?UTF-8?q?graph=20builds=20OpcUaApplicationHost=20before=20NodeBootstrap?= =?UTF-8?q?=20has=20resolved=20the=20generation,=20so=20the=20registry=20m?= =?UTF-8?q?ust=20exist=20at=20compose=20time=20+=20fill=20at=20boot=20time?= =?UTF-8?q?.=20Program.cs=20now=20registers=20OpcUaApplicationHost=20via?= =?UTF-8?q?=20a=20factory=20lambda=20that=20threads=20registry.Get=20as=20?= =?UTF-8?q?the=20equipmentContentLookup=20delegate=20PR=20#155=20added=20t?= =?UTF-8?q?o=20the=20ctor=20seam=20=E2=80=94=20the=20one-line=20compositio?= =?UTF-8?q?n=20the=20earlier=20PR=20promised.=20EquipmentNamespaceContentL?= =?UTF-8?q?oader=20(from=20PR=20#156)=20is=20AddScoped=20since=20it=20take?= =?UTF-8?q?s=20the=20scoped=20OtOpcUaConfigDbContext;=20the=20populate=20p?= =?UTF-8?q?ass=20in=20OpcUaServerService=20opens=20one=20IServiceScopeFact?= =?UTF-8?q?ory=20scope=20+=20reuses=20the=20same=20loader=20+=20DbContext?= =?UTF-8?q?=20across=20every=20driver=20query=20rather=20than=20scoping-pe?= =?UTF-8?q?r-driver.=20OpcUaServerService.ExecuteAsync=20gets=20a=20new=20?= =?UTF-8?q?PopulateEquipmentContentAsync=20step=20between=20bootstrap=20+?= =?UTF-8?q?=20StartAsync:=20iterates=20DriverHost.RegisteredDriverIds,=20c?= =?UTF-8?q?alls=20loader.LoadAsync=20per=20driver=20at=20the=20bootstrappe?= =?UTF-8?q?d=20generationId,=20stashes=20non-null=20results=20in=20the=20r?= =?UTF-8?q?egistry.=20Null=20results=20are=20skipped=20=E2=80=94=20the=20w?= =?UTF-8?q?ire-in's=20null-check=20treats=20absent=20registry=20entries=20?= =?UTF-8?q?as=20"this=20driver=20isn't=20Equipment-kind;=20let=20DiscoverA?= =?UTF-8?q?sync=20own=20the=20address=20space"=20which=20is=20the=20correc?= =?UTF-8?q?t=20backward-compat=20path=20for=20Modbus=20/=20AB=20CIP=20/=20?= =?UTF-8?q?TwinCAT=20/=20FOCAS.=20Guarded=20on=20result.GenerationId=20bei?= =?UTF-8?q?ng=20non-null=20=E2=80=94=20a=20fleet=20with=20no=20Published?= =?UTF-8?q?=20generation=20yet=20boots=20cleanly=20into=20a=20UNS-less=20a?= =?UTF-8?q?ddress=20space=20and=20fills=20the=20registry=20on=20the=20next?= =?UTF-8?q?=20restart=20after=20first=20publish.=20Ctor=20on=20OpcUaServer?= =?UTF-8?q?Service=20gained=20two=20new=20dependencies=20(DriverEquipmentC?= =?UTF-8?q?ontentRegistry=20+=20IServiceScopeFactory).=20No=20test=20file?= =?UTF-8?q?=20constructs=20OpcUaServerService=20directly=20so=20no=20downs?= =?UTF-8?q?tream=20test=20breakage=20=E2=80=94=20the=20BackgroundService?= =?UTF-8?q?=20is=20only=20wired=20via=20DI=20in=20Program.cs.=20Four=20new?= =?UTF-8?q?=20DriverEquipmentContentRegistryTests:=20Get-null-for-unknown,?= =?UTF-8?q?=20Set-then-Get,=20case-insensitive=20driver-id=20lookup,=20Set?= =?UTF-8?q?-overwrites-existing.=20Server.Tests=20190/190=20(was=20186,=20?= =?UTF-8?q?+4=20new=20registry=20tests).=20Full=20ADR-001=20Option=20A=20n?= =?UTF-8?q?ow=20lives=20at=20every=20layer:=20Core.OpcUa=20walker=20(#153)?= =?UTF-8?q?=20=E2=86=92=20ScopePathIndexBuilder=20(#154)=20=E2=86=92=20Opc?= =?UTF-8?q?UaApplicationHost=20wire-in=20(#155)=20=E2=86=92=20EquipmentNam?= =?UTF-8?q?espaceContentLoader=20(#156)=20=E2=86=92=20this=20PR's=20regist?= =?UTF-8?q?ry=20+=20Program.cs=20composition.=20The=20last=20pending=20loo?= =?UTF-8?q?se=20end=20(full-integration=20smoke=20test=20that=20boots=20Pr?= =?UTF-8?q?ogram.cs=20against=20a=20seeded=20Config=20DB=20+=20verifies=20?= =?UTF-8?q?UNS=20tree=20via=20live=20OPC=20UA=20client)=20isn't=20strictly?= =?UTF-8?q?=20necessary=20because=20PR=20#155's=20OpcUaEquipmentWalkerInte?= =?UTF-8?q?grationTests=20already=20proves=20the=20wire-in=20at=20the=20OP?= =?UTF-8?q?C=20UA=20client-browse=20level=20=E2=80=94=20the=20Program.cs?= =?UTF-8?q?=20composition=20added=20here=20is=20purely=20mechanical=20+=20?= =?UTF-8?q?well-covered=20by=20the=20four-file=20audit=20trail=20plus=20re?= =?UTF-8?q?gistry=20unit=20tests.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../OpcUa/DriverEquipmentContentRegistry.cs | 47 +++++++++++++++ .../OpcUaServerService.cs | 38 +++++++++++++ src/ZB.MOM.WW.OtOpcUa.Server/Program.cs | 20 ++++++- .../DriverEquipmentContentRegistryTests.cs | 57 +++++++++++++++++++ 4 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverEquipmentContentRegistry.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Server.Tests/DriverEquipmentContentRegistryTests.cs 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); + } +} -- 2.49.1