From bc8ff7a5fe8df0ab1ce87e051eb904bb304286b6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 18 May 2026 05:49:11 -0400 Subject: [PATCH] feat(phase7): wire RingBufferHistoryWriter as production IHistoryWriter for virtual tags (Gap 5) Closes Phase 7 Gap 5: VirtualTagEngine called IHistoryWriter.Record per evaluation when Historize=true but Phase7EngineComposer always passed NullHistoryWriter, so virtual-tag history was computed but never persisted. The fix: - New RingBufferHistoryWriter implements both IHistoryWriter (write port for the evaluation pipeline) and IHistorianDataSource (read port for IHistoryRouter so OPC UA HistoryRead on virtual-tag nodes resolves here). Maintains one bounded ring buffer (1000 samples, configurable) per tag path; Record() is O(1) and never blocks evaluation. - Phase7EngineComposer.Compose now accepts IHistoryRouter? and, when any VirtualTagDefinition.Historize=true, creates a RingBufferHistoryWriter, passes it to VirtualTagEngine as historyWriter, adds it to the disposables list, and registers it under the "virtual:" prefix in the router for HistoryRead dispatch. - Phase7Composer accepts IHistoryRouter? from DI (already registered as singleton in Program.cs) and threads it through to Phase7EngineComposer.Compose. - NullHistoryWriter remains as fallback when no tags request historization. - 16 new unit tests in RingBufferHistoryWriterTests.cs cover ring-buffer semantics, eviction, per-tag isolation, ReadRawAsync windowing, IHistorianDataSource stubs, router registration, and the Historize=false / null-router fallback paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Phase7/Phase7Composer.cs | 9 +- .../Phase7/Phase7EngineComposer.cs | 44 ++- .../Phase7/RingBufferHistoryWriter.cs | 235 +++++++++++++ .../Phase7/RingBufferHistoryWriterTests.cs | 308 ++++++++++++++++++ 4 files changed, 592 insertions(+), 4 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/RingBufferHistoryWriter.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/RingBufferHistoryWriterTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs index c834a37..7a54343 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs @@ -7,6 +7,7 @@ using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; +using ZB.MOM.WW.OtOpcUa.Server.History; using ZB.MOM.WW.OtOpcUa.Server.OpcUa; namespace ZB.MOM.WW.OtOpcUa.Server.Phase7; @@ -42,6 +43,7 @@ public sealed class Phase7Composer : IAsyncDisposable private readonly DriverEquipmentContentRegistry _equipmentRegistry; private readonly IAlarmHistorianSink _historianSink; private readonly IAlarmHistorianWriter? _injectedWriter; + private readonly IHistoryRouter? _historyRouter; private readonly ILoggerFactory _loggerFactory; private readonly Serilog.ILogger _scriptLogger; private readonly ILogger _logger; @@ -61,13 +63,15 @@ public sealed class Phase7Composer : IAsyncDisposable ILoggerFactory loggerFactory, Serilog.ILogger scriptLogger, ILogger logger, - IAlarmHistorianWriter? injectedWriter = null) + IAlarmHistorianWriter? injectedWriter = null, + IHistoryRouter? historyRouter = null) { _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); _driverHost = driverHost ?? throw new ArgumentNullException(nameof(driverHost)); _equipmentRegistry = equipmentRegistry ?? throw new ArgumentNullException(nameof(equipmentRegistry)); _historianSink = historianSink ?? throw new ArgumentNullException(nameof(historianSink)); _injectedWriter = injectedWriter; + _historyRouter = historyRouter; _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); _scriptLogger = scriptLogger ?? throw new ArgumentNullException(nameof(scriptLogger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -117,7 +121,8 @@ public sealed class Phase7Composer : IAsyncDisposable alarmStateStore: new InMemoryAlarmStateStore(), historianSink: historianSink, rootScriptLogger: _scriptLogger, - loggerFactory: _loggerFactory); + loggerFactory: _loggerFactory, + historyRouter: _historyRouter); _logger.LogInformation( "Phase 7: composed engines from generation {Gen} — {Vt} virtual tag(s), {Al} scripted alarm(s), {Sc} script(s)", diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs index 1414331..8f1583c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs @@ -5,6 +5,7 @@ using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; using ZB.MOM.WW.OtOpcUa.Core.Scripting; using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Core.VirtualTags; +using ZB.MOM.WW.OtOpcUa.Server.History; namespace ZB.MOM.WW.OtOpcUa.Server.Phase7; @@ -32,6 +33,14 @@ namespace ZB.MOM.WW.OtOpcUa.Server.Phase7; /// public static class Phase7EngineComposer { + /// + /// Prefix used when registering the virtual-tag ring-buffer history source in + /// . All virtual-tag UNS paths are prefixed with this + /// string so the router resolves reads to the + /// rather than a driver-owned historian. + /// + public const string VirtualTagHistoryPrefix = "virtual:"; + public static Phase7ComposedSources Compose( IReadOnlyList