Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianOptions.cs
T
Joseph Doherty 0658d244d0 feat(historian): #674 HistoryUpdate posture — future-skew bound, Primary gate, SqlServer v1 accepted, QuestDB TTL pin
- HistoryUpdate items with SourceTimestamp > now + ServerHistorian:HistoryUpdateMaxFutureSkew
  (default 1 day, <=0 disables) => BadOutOfRange in the node manager (every backend, never sent);
  clock injected via OtOpcUaNodeManager.Clock (host TimeProvider).
- HistoryUpdate is Primary-only: off-Primary => BadInvalidState per node, after authorization,
  same IDriverPrimaryRoleSource verdict as the native-ack gate; HistoryRead ungated.
- SqlServer: base schema requirement back to v1; SupportsModifiedTrail (v>=2). On v1 with
  AutoCreateSchema=false: writes/reads/events/retention work, modified reads + HistoryUpdate =>
  BadHistoryOperationUnsupported with nothing sent, retention skips SampleModified, one Warning per boot.
- QuestDB integration test pinning wall-clock TTL (far-future row evicts only partitions older
  than the TTL by the wall clock), with a positive control.
- Docs: Historian.md, Configuration.md, security.md, CLAUDE.md (incl. QuestDB retry duplicates,
  #674.4 anonymous-readable trail with ACL off, #674.5 not implemented).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-11 11:18:44 -04:00

71 lines
3.6 KiB
C#

using System.Text.RegularExpressions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
/// <summary>
/// Options for the SQL Server historian backend (the <c>ServerHistorian:SqlServer</c> config block).
/// </summary>
public sealed partial class SqlServerHistorianOptions
{
/// <summary>Required. ADO.NET connection string for the historian database (never the ConfigDb).</summary>
public string ConnectionString { get; set; } = "";
/// <summary>SQL schema holding the historian objects. Spliced into DDL/DML, so it is regex-validated.</summary>
public string Schema { get; set; } = "hist";
/// <summary>Per-command timeout. Must be positive.</summary>
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// <c>true</c> creates the schema and applies missing scripts on first use; <c>false</c> only verifies
/// that the base schema version (<c>SqlServerSchemaBootstrapper.RequiredVersion</c>, 1) exists and fails every
/// historian call with a clear error otherwise. A verified v1 schema (no Part 11 modification trail, script 002
/// not applied) is served with modified reads and HistoryUpdate refused <c>BadHistoryOperationUnsupported</c>
/// and one Warning per boot (Gitea #674).
/// </summary>
public bool AutoCreateSchema { get; set; } = true;
/// <summary>
/// Days of history to keep, measured on source time. <c>null</c> (default) keeps everything. When set,
/// a driver node deletes older <c>hist.Sample</c>/<c>hist.Event</c> rows every <see cref="PurgeInterval"/>.
/// </summary>
public int? RetentionDays { get; set; }
/// <summary>How often the retention purge runs. Must be positive.</summary>
public TimeSpan PurgeInterval { get; set; } = TimeSpan.FromHours(1);
/// <summary>
/// Rows per <c>DELETE TOP(n)</c> batch. Default 4000 stays under SQL Server's 5000-lock escalation
/// threshold so a purge never escalates to a table lock.
/// </summary>
public int PurgeBatchSize { get; set; } = 4000;
[GeneratedRegex("^[A-Za-z_][A-Za-z0-9_]{0,127}$")]
private static partial Regex IdentifierRegex();
/// <summary>True when <paramref name="schema"/> is a safe SQL identifier.</summary>
/// <param name="schema">Candidate schema name.</param>
/// <returns>Whether it matches <c>^[A-Za-z_][A-Za-z0-9_]{0,127}$</c>.</returns>
public static bool IsValidSchemaName(string? schema) => schema is not null && IdentifierRegex().IsMatch(schema);
/// <summary>Validates the options without touching SQL.</summary>
/// <returns>One message per problem; empty when valid.</returns>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(ConnectionString))
errors.Add("ServerHistorian:SqlServer:ConnectionString is required.");
if (!IsValidSchemaName(Schema))
errors.Add("ServerHistorian:SqlServer:Schema must be a SQL identifier matching ^[A-Za-z_][A-Za-z0-9_]{0,127}$.");
if (CommandTimeout <= TimeSpan.Zero)
errors.Add("ServerHistorian:SqlServer:CommandTimeout must be greater than zero.");
if (RetentionDays is < 1)
errors.Add("ServerHistorian:SqlServer:RetentionDays must be at least 1 when set (omit it to keep history forever).");
if (PurgeInterval <= TimeSpan.Zero)
errors.Add("ServerHistorian:SqlServer:PurgeInterval must be greater than zero.");
if (PurgeBatchSize < 1)
errors.Add("ServerHistorian:SqlServer:PurgeBatchSize must be at least 1.");
return errors;
}
}