Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianOptions.cs
T
Joseph Doherty cfd403710e feat(historian): Part 11 modified-value history on SqlServer + QuestDB (#668)
Every HistoryUpdate a SQL backend applies now leaves a modification trail
(prior value, principal, Part 11 update type, modification time), and
HistoryReadRaw with IsReadModified=true serves it as HistoryModifiedData.

- Core: HistoryUpdateContext threaded into all three IHistorianDataUpdater
  methods; IHistorianDataSource.ReadModifiedAsync (default NotSupported =>
  BadHistoryOperationUnsupported on Gateway/Null); HistoryModified* contracts.
- SqlServer: schema v2 (002_sample_modified.sql, RequiredVersion=2, docs copy);
  trail written by OUTPUT ... INTO on the same MERGE/DELETE statement, so a
  failed update leaves no audit row; retention purges SampleModified per tag on
  the source-time cutoff; ReadModifiedAsync keyed on (SourceTimeUtc, ModifiedId).
- QuestDB: append-only {prefix}sample_modified (TTL like samples), trail row
  written BEFORE the change (orphan-on-crash, never an untracked change);
  existence reads now carry the prior value; ReadModifiedAsync.
- Node manager: modified reads dispatch + page via the #669 continuation store
  (HistoryContinuationState gains HistoryCursorKind + LastModifiedId), gated by
  the existing HistoryRead decision; session principal / key id passed to the
  updater. IsDeleteModified stays unsupported.
- Client.CLI: historyread --modified.
- Tests: node-manager dispatch/paging/cursor binding/principal threading,
  over-the-wire HistoryModifiedData, shared ModifiedValueConformance on both
  backends, SqlServer atomicity (forced trail failure => no change) + v1->v2
  upgrade + retention trail purge.
- Docs: Historian.md (Modified-value history section, schema v2 upgrade note),
  security.md, Client.CLI.md, CLAUDE.md.
2026-10-11 09:26:36 -04:00

69 lines
3.4 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 required schema version (<c>SqlServerSchemaBootstrapper.RequiredVersion</c>, currently 2) exists and
/// fails every historian call with a clear error otherwise.
/// </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;
}
}