Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs
Joseph Doherty 432173c5c4 ADR-001 last-mile — Program.cs composes EquipmentNodeWalker into the production boot path. Closes task #214 + fully lands ADR-001 Option A as a live code path, not just a connected set of unit-tested primitives. After this PR a server booted against a real Config DB with Published Equipment rows materializes the UNS tree into the OPC UA address space on startup — the whole walker → wire-in → loader chain (PRs #153, #154, #155, #156) finally fires end-to-end in the production process. DriverEquipmentContentRegistry is the handoff between OpcUaServerService's bootstrap-time populate pass + OpcUaApplicationHost's StartAsync walker invocation. It's a singleton mutable holder with Get/Set/Count + Lock-guarded internal dictionary keyed OrdinalIgnoreCase to match the DriverInstanceId convention used by Equipment / Tag rows + walker grouping. Set-once-per-bootstrap semantics in practice though nothing enforces that at the type level — OpcUaServerService.PopulateEquipmentContentAsync is the only expected writer. Shared-mutable rather than immutable-passed-by-value because the DI graph builds OpcUaApplicationHost before NodeBootstrap has resolved the generation, so the registry must exist at compose time + fill at boot time. Program.cs now registers OpcUaApplicationHost via a factory lambda that threads registry.Get as the equipmentContentLookup delegate PR #155 added to the ctor seam — the one-line composition the earlier PR promised. EquipmentNamespaceContentLoader (from PR #156) is AddScoped since it takes the scoped OtOpcUaConfigDbContext; the populate pass in OpcUaServerService opens one IServiceScopeFactory scope + reuses the same loader + DbContext across every driver query rather than scoping-per-driver. OpcUaServerService.ExecuteAsync gets a new PopulateEquipmentContentAsync step between bootstrap + StartAsync: iterates DriverHost.RegisteredDriverIds, calls loader.LoadAsync per driver at the bootstrapped generationId, stashes non-null results in the registry. Null results are skipped — the wire-in's null-check treats absent registry entries as "this driver isn't Equipment-kind; let DiscoverAsync own the address space" which is the correct backward-compat path for Modbus / AB CIP / TwinCAT / FOCAS. Guarded on result.GenerationId being non-null — a fleet with no Published generation yet boots cleanly into a UNS-less address space and fills the registry on the next restart after first publish. Ctor on OpcUaServerService gained two new dependencies (DriverEquipmentContentRegistry + IServiceScopeFactory). No test file constructs OpcUaServerService directly so no downstream test breakage — the BackgroundService is only wired via DI in Program.cs. Four new DriverEquipmentContentRegistryTests: Get-null-for-unknown, Set-then-Get, case-insensitive driver-id lookup, Set-overwrites-existing. Server.Tests 190/190 (was 186, +4 new registry tests). Full ADR-001 Option A now lives at every layer: Core.OpcUa walker (#153) → ScopePathIndexBuilder (#154) → OpcUaApplicationHost wire-in (#155) → EquipmentNamespaceContentLoader (#156) → this PR's registry + Program.cs composition. The last pending loose end (full-integration smoke test that boots Program.cs against a seeded Config DB + verifies UNS tree via live OPC UA client) isn't strictly necessary because PR #155's OpcUaEquipmentWalkerIntegrationTests already proves the wire-in at the OPC UA client-browse level — the Program.cs composition added here is purely mechanical + well-covered by the four-file audit trail plus registry unit tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 03:50:37 -04:00

90 lines
4.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Server;
/// <summary>
/// BackgroundService that owns the OPC UA server lifecycle (decision #30, replacing TopShelf).
/// Bootstraps config, starts the <see cref="DriverHost"/>, starts the OPC UA server via
/// <see cref="OpcUaApplicationHost"/>, drives each driver's discovery into the address space,
/// runs until stopped.
/// </summary>
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)
{
logger.LogInformation("OtOpcUa.Server starting");
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
// server comes up with whatever drivers are in DriverHost at start time.
await applicationHost.StartAsync(stoppingToken);
logger.LogInformation("OtOpcUa.Server running. Hosted drivers: {Count}", driverHost.RegisteredDriverIds.Count);
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken);
}
catch (OperationCanceledException)
{
logger.LogInformation("OtOpcUa.Server stopping");
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken);
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);
}
}