diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json index 31d5d140..0f44ec79 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json @@ -15,6 +15,9 @@ "Enabled": false, "DatabasePath": "alarm-historian.db", "PipeName": "OtOpcUaHistorian", - "SharedSecret": "" + "SharedSecret": "", + "DrainIntervalSeconds": 5, + "Capacity": 1000000, + "DeadLetterRetentionDays": 30 } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs index 11121d79..aec679e5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs @@ -1,3 +1,6 @@ +using System.Collections.Generic; +using System.IO; + namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian; /// @@ -29,4 +32,28 @@ public sealed class AlarmHistorianOptions /// Maximum number of queued rows the drain worker forwards in a single batch. public int BatchSize { get; init; } = 100; + + /// Seconds between drain-worker ticks. Defaults to 5. + public int DrainIntervalSeconds { get; init; } = 5; + + /// Maximum queued rows before the sink evicts the oldest. Defaults to 1,000,000 + /// (matches SqliteStoreAndForwardSink's DefaultCapacity). + public long Capacity { get; init; } = 1_000_000; + + /// Days to retain dead-lettered rows before purge. Defaults to 30. + public int DeadLetterRetentionDays { get; init; } = 30; + + /// Returns operator-facing misconfiguration warnings for an Enabled historian + /// (empty when disabled or correctly configured). Pure — the registration logs each entry. + /// Zero or more human-readable warning messages. + public IReadOnlyList Validate() + { + var warnings = new List(); + if (!Enabled) return warnings; + if (string.IsNullOrWhiteSpace(SharedSecret)) + warnings.Add("AlarmHistorian:SharedSecret is empty while the historian is enabled — the Wonderware sidecar Hello frame will carry an empty secret."); + if (!Path.IsPathRooted(DatabasePath)) + warnings.Add($"AlarmHistorian:DatabasePath '{DatabasePath}' is relative — it resolves against the process working directory (e.g. System32 for a Windows service). Set an absolute path."); + return warnings; + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs index 7af3aec5..67003c07 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs @@ -72,6 +72,9 @@ public static class ServiceCollectionExtensions var opts = configuration.GetSection(AlarmHistorianOptions.SectionName).Get(); if (opts is not { Enabled: true }) return services; // leave the Null default from AddOtOpcUaRuntime + foreach (var warning in opts.Validate()) + Serilog.Log.Logger.ForContext().Warning("{HistorianConfigWarning}", warning); + services.AddSingleton(sp => { // SqliteStoreAndForwardSink takes a Serilog ILogger (not Microsoft.Extensions.Logging). @@ -81,8 +84,10 @@ public static class ServiceCollectionExtensions opts.DatabasePath, writerFactory(opts, sp), Serilog.Log.Logger.ForContext(), - batchSize: opts.BatchSize); - sink.StartDrainLoop(TimeSpan.FromSeconds(5)); + batchSize: opts.BatchSize, + capacity: opts.Capacity, + deadLetterRetention: TimeSpan.FromDays(opts.DeadLetterRetentionDays)); + sink.StartDrainLoop(TimeSpan.FromSeconds(opts.DrainIntervalSeconds)); return sink; }); return services; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AlarmHistorianRegistrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AlarmHistorianRegistrationTests.cs index bcef6db2..b1d0b9e6 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AlarmHistorianRegistrationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AlarmHistorianRegistrationTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian; @@ -91,4 +92,49 @@ public sealed class AlarmHistorianRegistrationTests try { Directory.Delete(tempDir, recursive: true); } catch (IOException) { /* best effort */ } } } + + [Fact] + public void Section_binds_drain_capacity_and_retention_knobs() + { + var config = ConfigFrom(new Dictionary + { + ["AlarmHistorian:Enabled"] = "true", + ["AlarmHistorian:DrainIntervalSeconds"] = "11", + ["AlarmHistorian:Capacity"] = "500", + ["AlarmHistorian:DeadLetterRetentionDays"] = "7", + }); + + var opts = config.GetSection(AlarmHistorianOptions.SectionName).Get(); + + opts.ShouldNotBeNull(); + opts.DrainIntervalSeconds.ShouldBe(11); + opts.Capacity.ShouldBe(500); + opts.DeadLetterRetentionDays.ShouldBe(7); + } + + [Fact] + public void Validate_warns_on_empty_shared_secret_when_enabled() + { + var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "", DatabasePath = "/var/h.db" }; + opts.Validate().ShouldContain(w => w.Contains("SharedSecret")); + } + + [Fact] + public void Validate_warns_on_relative_database_path_when_enabled() + { + var opts = new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "alarm-historian.db" }; + opts.Validate().ShouldContain(w => w.Contains("DatabasePath")); + } + + [Fact] + public void Validate_is_silent_when_correctly_configured() + { + new AlarmHistorianOptions { Enabled = true, SharedSecret = "s", DatabasePath = "/abs/h.db" }.Validate().ShouldBeEmpty(); + } + + [Fact] + public void Validate_is_silent_when_disabled() + { + new AlarmHistorianOptions { Enabled = false, SharedSecret = "" }.Validate().ShouldBeEmpty(); + } }