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);
+ }
+}