Merge ph-T8 (pluggable historian)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
using System.Data;
|
||||
using Microsoft.Data.SqlClient.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
|
||||
/// <summary>One sample bound for a <c>SampleRow</c> TVP.</summary>
|
||||
/// <param name="TimeUtc">Source timestamp (UTC, full tick precision).</param>
|
||||
/// <param name="Value">Typed value.</param>
|
||||
/// <param name="StatusCode">OPC UA status code (stored bit-for-bit as int).</param>
|
||||
internal readonly record struct SqlSampleRow(DateTime TimeUtc, HistorianValue Value, uint StatusCode);
|
||||
|
||||
/// <summary>The (ValueKind, NumValue, IntValue, StrValue) column quad for a value.</summary>
|
||||
internal readonly record struct SampleColumns(byte ValueKind, double? NumValue, long? IntValue, string? StrValue);
|
||||
|
||||
/// <summary>
|
||||
/// HistorianValue ⇄ column mapping (spec §4) and the <c>SqlDataRecord</c> TVP builders. Mapping:
|
||||
/// Double → Num; Int64 → Int + Num(double); Boolean → Int 0/1 + Num 0/1; String → Str; DateTime → Int (ticks);
|
||||
/// Empty → all NULL.
|
||||
/// </summary>
|
||||
internal static class SampleRowMapper
|
||||
{
|
||||
private static readonly SqlMetaData[] SampleMeta =
|
||||
[
|
||||
new("SourceTimeUtc", SqlDbType.DateTime2, 0, 7),
|
||||
new("ValueKind", SqlDbType.TinyInt),
|
||||
new("NumValue", SqlDbType.Float),
|
||||
new("IntValue", SqlDbType.BigInt),
|
||||
new("StrValue", SqlDbType.NVarChar, SqlMetaData.Max),
|
||||
new("StatusCode", SqlDbType.Int),
|
||||
];
|
||||
|
||||
private static readonly SqlMetaData[] TimeMeta = [new("T", SqlDbType.DateTime2, 0, 7)];
|
||||
|
||||
/// <summary>Splits a value into its column quad.</summary>
|
||||
/// <param name="v">Typed value.</param>
|
||||
/// <returns>The columns to store.</returns>
|
||||
public static SampleColumns ToColumns(HistorianValue v) => v.Kind switch
|
||||
{
|
||||
HistorianValueKind.Double => new((byte)v.Kind, v.Number, null, null),
|
||||
HistorianValueKind.Int64 => new((byte)v.Kind, v.Number, v.Integer, null),
|
||||
HistorianValueKind.Boolean => new((byte)v.Kind, v.Integer != 0 ? 1d : 0d, v.Integer != 0 ? 1L : 0L, null),
|
||||
HistorianValueKind.String => new((byte)v.Kind, null, null, v.Text ?? ""),
|
||||
HistorianValueKind.DateTime => new((byte)v.Kind, null, v.Integer, null),
|
||||
_ => new((byte)HistorianValueKind.Empty, null, null, null),
|
||||
};
|
||||
|
||||
/// <summary>Rebuilds a value from stored columns.</summary>
|
||||
/// <param name="valueKind">ValueKind column.</param>
|
||||
/// <param name="num">NumValue column (null = NULL).</param>
|
||||
/// <param name="integer">IntValue column (null = NULL).</param>
|
||||
/// <param name="str">StrValue column (null = NULL).</param>
|
||||
/// <returns>The typed value; an unknown kind yields <see cref="HistorianValue.Empty"/>.</returns>
|
||||
public static HistorianValue FromColumns(byte valueKind, double? num, long? integer, string? str) => (HistorianValueKind)valueKind switch
|
||||
{
|
||||
HistorianValueKind.Double => HistorianValue.FromDouble(num ?? 0d),
|
||||
HistorianValueKind.Int64 => HistorianValue.FromInt64(integer ?? 0L),
|
||||
HistorianValueKind.Boolean => HistorianValue.FromBoolean((integer ?? 0L) != 0),
|
||||
HistorianValueKind.String => HistorianValue.FromString(str ?? ""),
|
||||
HistorianValueKind.DateTime => new HistorianValue(HistorianValueKind.DateTime, 0d, integer ?? 0L, null),
|
||||
_ => HistorianValue.Empty,
|
||||
};
|
||||
|
||||
/// <summary>Stores a uint status bit-for-bit in the int column.</summary>
|
||||
/// <param name="status">Status code.</param>
|
||||
/// <returns>Same bits as int.</returns>
|
||||
public static int ToDbStatus(uint status) => unchecked((int)status);
|
||||
|
||||
/// <summary>Reads the int status column back to a uint code.</summary>
|
||||
/// <param name="dbStatus">Column value.</param>
|
||||
/// <returns>Same bits as uint.</returns>
|
||||
public static uint FromDbStatus(int dbStatus) => unchecked((uint)dbStatus);
|
||||
|
||||
/// <summary>Builds <c>SampleRow</c> TVP records (datetime2(7); never a DataTable). Lazy; enumerate once.</summary>
|
||||
/// <param name="rows">Samples; must be time-unique (the TVP has a PK on time) and non-empty when bound.</param>
|
||||
/// <returns>Records for <see cref="SqlHistorianConnection.AddStructured"/>.</returns>
|
||||
public static IEnumerable<SqlDataRecord> ToSampleRecords(IEnumerable<SqlSampleRow> rows)
|
||||
{
|
||||
foreach (var r in rows)
|
||||
{
|
||||
var c = ToColumns(r.Value);
|
||||
var rec = new SqlDataRecord(SampleMeta);
|
||||
rec.SetDateTime(0, DateTime.SpecifyKind(r.TimeUtc, DateTimeKind.Utc));
|
||||
rec.SetByte(1, c.ValueKind);
|
||||
if (c.NumValue is { } n) rec.SetDouble(2, n); else rec.SetDBNull(2);
|
||||
if (c.IntValue is { } i) rec.SetInt64(3, i); else rec.SetDBNull(3);
|
||||
if (c.StrValue is { } s) rec.SetString(4, s); else rec.SetDBNull(4);
|
||||
rec.SetInt32(5, ToDbStatus(r.StatusCode));
|
||||
yield return rec;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Builds <c>TimeRow</c> TVP records (datetime2(7)). Lazy; enumerate once.</summary>
|
||||
/// <param name="timesUtc">Distinct UTC times; non-empty when bound.</param>
|
||||
/// <returns>Records for <see cref="SqlHistorianConnection.AddStructured"/>.</returns>
|
||||
public static IEnumerable<SqlDataRecord> ToTimeRecords(IEnumerable<DateTime> timesUtc)
|
||||
{
|
||||
foreach (var t in timesUtc)
|
||||
{
|
||||
var rec = new SqlDataRecord(TimeMeta);
|
||||
rec.SetDateTime(0, DateTime.SpecifyKind(t, DateTimeKind.Utc));
|
||||
yield return rec;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
-- OtOpcUa SQL Server historian backend - schema version 1.
|
||||
-- $(Schema) is substituted by SqlServerSchemaBootstrapper (identifier-validated) BEFORE the script is split on GO lines.
|
||||
-- Every statement is guarded so a re-run is a no-op.
|
||||
IF SCHEMA_ID(N'$(Schema)') IS NULL EXEC(N'CREATE SCHEMA [$(Schema)]')
|
||||
GO
|
||||
IF OBJECT_ID(N'[$(Schema)].[SchemaVersion]', N'U') IS NULL
|
||||
CREATE TABLE [$(Schema)].[SchemaVersion] (
|
||||
Version int NOT NULL PRIMARY KEY,
|
||||
AppliedUtc datetime2(7) NOT NULL)
|
||||
GO
|
||||
IF OBJECT_ID(N'[$(Schema)].[Tag]', N'U') IS NULL
|
||||
CREATE TABLE [$(Schema)].[Tag] (
|
||||
TagId int IDENTITY(1,1) NOT NULL PRIMARY KEY,
|
||||
Name nvarchar(400) NOT NULL CONSTRAINT UQ_Tag_Name UNIQUE,
|
||||
DataType tinyint NOT NULL,
|
||||
EngineeringUnit nvarchar(64) NULL,
|
||||
Description nvarchar(400) NULL,
|
||||
CreatedUtc datetime2(7) NOT NULL,
|
||||
UpdatedUtc datetime2(7) NOT NULL)
|
||||
GO
|
||||
IF OBJECT_ID(N'[$(Schema)].[Sample]', N'U') IS NULL
|
||||
CREATE TABLE [$(Schema)].[Sample] (
|
||||
TagId int NOT NULL,
|
||||
SourceTimeUtc datetime2(7) NOT NULL,
|
||||
ValueKind tinyint NOT NULL,
|
||||
NumValue float NULL,
|
||||
IntValue bigint NULL,
|
||||
StrValue nvarchar(max) NULL,
|
||||
StatusCode int NOT NULL,
|
||||
ServerTimeUtc datetime2(7) NOT NULL,
|
||||
CONSTRAINT PK_Sample PRIMARY KEY CLUSTERED (TagId, SourceTimeUtc),
|
||||
CONSTRAINT FK_Sample_Tag FOREIGN KEY (TagId) REFERENCES [$(Schema)].[Tag](TagId))
|
||||
GO
|
||||
IF OBJECT_ID(N'[$(Schema)].[Event]', N'U') IS NULL
|
||||
CREATE TABLE [$(Schema)].[Event] (
|
||||
EventId nvarchar(64) NOT NULL CONSTRAINT PK_Event PRIMARY KEY NONCLUSTERED,
|
||||
SourceName nvarchar(400) NOT NULL,
|
||||
AlarmId nvarchar(400) NOT NULL,
|
||||
AlarmName nvarchar(400) NOT NULL,
|
||||
AlarmTypeName nvarchar(100) NOT NULL,
|
||||
Severity int NOT NULL,
|
||||
EventKind nvarchar(50) NOT NULL,
|
||||
Message nvarchar(max) NULL,
|
||||
UserName nvarchar(200) NULL,
|
||||
Comment nvarchar(max) NULL,
|
||||
EventTimeUtc datetime2(7) NOT NULL,
|
||||
ReceivedTimeUtc datetime2(7) NOT NULL)
|
||||
GO
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'CX_Event_Time' AND object_id = OBJECT_ID(N'[$(Schema)].[Event]'))
|
||||
CREATE CLUSTERED INDEX CX_Event_Time ON [$(Schema)].[Event] (EventTimeUtc, EventId)
|
||||
GO
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_Event_Source_Time' AND object_id = OBJECT_ID(N'[$(Schema)].[Event]'))
|
||||
CREATE INDEX IX_Event_Source_Time ON [$(Schema)].[Event] (SourceName, EventTimeUtc)
|
||||
GO
|
||||
IF TYPE_ID(N'[$(Schema)].[SampleRow]') IS NULL
|
||||
CREATE TYPE [$(Schema)].[SampleRow] AS TABLE (
|
||||
SourceTimeUtc datetime2(7) NOT NULL PRIMARY KEY,
|
||||
ValueKind tinyint NOT NULL,
|
||||
NumValue float NULL,
|
||||
IntValue bigint NULL,
|
||||
StrValue nvarchar(max) NULL,
|
||||
StatusCode int NOT NULL)
|
||||
GO
|
||||
IF TYPE_ID(N'[$(Schema)].[TimeRow]') IS NULL
|
||||
CREATE TYPE [$(Schema)].[TimeRow] AS TABLE (T datetime2(7) NOT NULL PRIMARY KEY)
|
||||
GO
|
||||
IF NOT EXISTS (SELECT 1 FROM [$(Schema)].[SchemaVersion] WHERE Version = 1)
|
||||
INSERT [$(Schema)].[SchemaVersion] (Version, AppliedUtc) VALUES (1, SYSUTCDATETIME())
|
||||
GO
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Data;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Connection + command helpers shared by every SQL historian component.
|
||||
/// <para>Usage contract for writers/readers: call <see cref="SqlServerSchemaBootstrapper.EnsureAsync"/>
|
||||
/// once (it is cheap after the first success), then <see cref="OpenAsync"/>. Build SQL with <see cref="Q"/>
|
||||
/// for every object name and bind every timestamp through <see cref="AddDateTime2"/> or a TVP built by
|
||||
/// <see cref="SampleRowMapper"/> — never a <c>DataTable</c> (it infers 3.33 ms <c>datetime</c>).</para>
|
||||
/// </summary>
|
||||
internal sealed class SqlHistorianConnection
|
||||
{
|
||||
private static readonly int[] TransientNumbers =
|
||||
[1205, -2, 40613, 40197, 40501, 49918, 4060, 233, 10053, 10054, 10060, 11001];
|
||||
|
||||
private readonly string _connectionString;
|
||||
|
||||
/// <summary>Creates the helper; options must already be valid.</summary>
|
||||
/// <param name="options">Validated options.</param>
|
||||
public SqlHistorianConnection(SqlServerHistorianOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
if (!SqlServerHistorianOptions.IsValidSchemaName(options.Schema))
|
||||
throw new ArgumentException("Invalid schema name.", nameof(options));
|
||||
_connectionString = options.ConnectionString;
|
||||
Schema = options.Schema;
|
||||
CommandTimeoutSeconds = Math.Max(1, (int)Math.Ceiling(options.CommandTimeout.TotalSeconds));
|
||||
}
|
||||
|
||||
/// <summary>The validated schema name.</summary>
|
||||
public string Schema { get; }
|
||||
|
||||
/// <summary>Command timeout applied by <see cref="CreateCommand"/>, in seconds.</summary>
|
||||
public int CommandTimeoutSeconds { get; }
|
||||
|
||||
/// <summary>Schema-qualifies an object name: <c>[schema].[obj]</c>.</summary>
|
||||
/// <param name="obj">Unquoted object name (a fixed literal from this project, never user input).</param>
|
||||
/// <returns>The quoted two-part name.</returns>
|
||||
public string Q(string obj) => $"[{Schema}].[{obj}]";
|
||||
|
||||
/// <summary>Opens a new connection (no bootstrap; see the class remarks).</summary>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>An open connection the caller disposes.</returns>
|
||||
public async Task<SqlConnection> OpenAsync(CancellationToken ct)
|
||||
{
|
||||
var conn = new SqlConnection(_connectionString);
|
||||
try
|
||||
{
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
return conn;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await conn.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Creates a text command with the configured timeout.</summary>
|
||||
/// <param name="conn">Open connection.</param>
|
||||
/// <param name="sql">Command text.</param>
|
||||
/// <param name="tx">Optional transaction.</param>
|
||||
/// <returns>The command.</returns>
|
||||
public SqlCommand CreateCommand(SqlConnection conn, string sql, SqlTransaction? tx = null)
|
||||
{
|
||||
var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.CommandTimeout = CommandTimeoutSeconds;
|
||||
cmd.Transaction = tx;
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/// <summary>Adds a <c>datetime2(7)</c> scalar parameter (full 100 ns tick precision, UTC kind).</summary>
|
||||
/// <param name="cmd">Target command.</param>
|
||||
/// <param name="name">Parameter name including <c>@</c>.</param>
|
||||
/// <param name="value">Timestamp; treated as UTC.</param>
|
||||
/// <returns>The added parameter.</returns>
|
||||
public static SqlParameter AddDateTime2(SqlCommand cmd, string name, DateTime value)
|
||||
{
|
||||
var p = new SqlParameter(name, SqlDbType.DateTime2)
|
||||
{
|
||||
Scale = 7,
|
||||
Value = DateTime.SpecifyKind(value, DateTimeKind.Utc),
|
||||
};
|
||||
cmd.Parameters.Add(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
/// <summary>Adds a table-valued parameter of one of this schema's table types.</summary>
|
||||
/// <param name="cmd">Target command.</param>
|
||||
/// <param name="name">Parameter name including <c>@</c>.</param>
|
||||
/// <param name="typeName">Unqualified type name (<c>SampleRow</c> or <c>TimeRow</c>).</param>
|
||||
/// <param name="rows">Non-empty <c>SqlDataRecord</c> sequence (SqlClient rejects an empty one — skip the call).</param>
|
||||
/// <returns>The added parameter.</returns>
|
||||
public SqlParameter AddStructured(SqlCommand cmd, string name, string typeName,
|
||||
IEnumerable<Microsoft.Data.SqlClient.Server.SqlDataRecord> rows)
|
||||
{
|
||||
var p = new SqlParameter(name, SqlDbType.Structured) { TypeName = Q(typeName), Value = rows };
|
||||
cmd.Parameters.Add(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
/// <summary>Reads a <c>datetime2</c> column as a UTC <see cref="DateTime"/> (the driver returns Unspecified).</summary>
|
||||
/// <param name="reader">Reader positioned on a row.</param>
|
||||
/// <param name="ordinal">Column ordinal.</param>
|
||||
/// <returns>UTC timestamp.</returns>
|
||||
public static DateTime GetUtc(SqlDataReader reader, int ordinal) =>
|
||||
DateTime.SpecifyKind(reader.GetDateTime(ordinal), DateTimeKind.Utc);
|
||||
|
||||
/// <summary>True for connection/timeout/deadlock/throttling errors worth retrying.</summary>
|
||||
/// <param name="ex">Exception from a SQL call.</param>
|
||||
/// <returns>Whether a retry may succeed.</returns>
|
||||
public static bool IsTransient(Exception ex)
|
||||
{
|
||||
if (ex is TimeoutException) return true;
|
||||
if (ex is not SqlException sql) return false;
|
||||
if (sql.IsTransient) return true;
|
||||
foreach (SqlError e in sql.Errors)
|
||||
if (Array.IndexOf(TransientNumbers, e.Number) >= 0) return true;
|
||||
return Array.IndexOf(TransientNumbers, sql.Number) >= 0;
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="ex"/> is a unique-key violation (2627/2601): "already present".</summary>
|
||||
/// <param name="ex">Exception from a SQL call.</param>
|
||||
/// <returns>Whether it is a duplicate-key error.</returns>
|
||||
public static bool IsDuplicateKey(Exception ex) =>
|
||||
ex is SqlException sql && sql.Errors.Cast<SqlError>().Any(e => e.Number is 2627 or 2601);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Every OPC UA status this backend emits, as named constants (inline literals are invisible to
|
||||
/// <c>StatusCodeParityTests</c>). Values mirror <c>Opc.Ua.StatusCodes</c>.
|
||||
/// </summary>
|
||||
internal static class SqlHistorianStatusCodes
|
||||
{
|
||||
public const uint Good = 0x00000000u;
|
||||
public const uint GoodNoData = 0x00A50000u;
|
||||
public const uint GoodEntryInserted = 0x00A20000u;
|
||||
public const uint GoodEntryReplaced = 0x00A30000u;
|
||||
public const uint BadNoData = 0x809B0000u;
|
||||
public const uint BadEntryExists = 0x809F0000u;
|
||||
public const uint BadNoEntryExists = 0x80A00000u;
|
||||
public const uint BadInvalidTimestamp = 0x80230000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
public const uint BadAggregateInvalidInputs = 0x80D60000u;
|
||||
public const uint BadHistoryOperationUnsupported = 0x80720000u;
|
||||
public const uint UncertainDataSubNormal = 0x40A40000u;
|
||||
}
|
||||
|
||||
/// <summary>Part 4 §7.39 historian info bits OR-ed onto a status code.</summary>
|
||||
internal static class HistorianInfoBits
|
||||
{
|
||||
/// <summary>InfoType = DataValue.</summary>
|
||||
public const uint InfoTypeDataValue = 0x00000400u;
|
||||
|
||||
/// <summary>The value was produced by an aggregate calculation.</summary>
|
||||
public const uint HistorianCalculated = 0x1u;
|
||||
|
||||
/// <summary>The value was interpolated.</summary>
|
||||
public const uint HistorianInterpolated = 0x2u;
|
||||
|
||||
/// <summary>Marks <paramref name="status"/> as a Calculated data value.</summary>
|
||||
/// <param name="status">Base status code.</param>
|
||||
/// <returns>Status with InfoType and Calculated bits set.</returns>
|
||||
public static uint WithCalculated(uint status) => status | InfoTypeDataValue | HistorianCalculated;
|
||||
|
||||
/// <summary>Marks <paramref name="status"/> as an Interpolated data value.</summary>
|
||||
/// <param name="status">Base status code.</param>
|
||||
/// <returns>Status with InfoType and Interpolated bits set.</returns>
|
||||
public static uint WithInterpolated(uint status) => status | InfoTypeDataValue | HistorianInterpolated;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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 schema version 1 exists and fails every historian call with a clear error otherwise.
|
||||
/// </summary>
|
||||
public bool AutoCreateSchema { get; set; } = true;
|
||||
|
||||
[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.");
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Thrown when <c>AutoCreateSchema=false</c> and the historian schema is absent or behind.</summary>
|
||||
public sealed class HistorianSchemaMissingException : Exception
|
||||
{
|
||||
/// <summary>Creates the exception.</summary>
|
||||
/// <param name="message">Message naming the missing version.</param>
|
||||
public HistorianSchemaMissingException(string message) : base(message) { }
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
using System.Data;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Lazily applies the embedded, idempotent schema scripts once per instance, under a SQL application lock
|
||||
/// so concurrent nodes/instances serialise. A failure is NOT latched: the next <see cref="EnsureAsync"/> retries.
|
||||
/// With <c>AutoCreateSchema=false</c> it only verifies <c>MAX(Version) >= 1</c>.
|
||||
/// </summary>
|
||||
internal sealed partial class SqlServerSchemaBootstrapper
|
||||
{
|
||||
/// <summary>The schema version this build requires.</summary>
|
||||
public const int RequiredVersion = 1;
|
||||
|
||||
private const string LockResource = "OtOpcUaHistorianSchema";
|
||||
private const int LockTimeoutMs = 30_000;
|
||||
|
||||
private readonly SqlHistorianConnection _conn;
|
||||
private readonly bool _autoCreate;
|
||||
private readonly ILogger _log;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private volatile bool _ready;
|
||||
|
||||
/// <summary>Creates the bootstrapper.</summary>
|
||||
/// <param name="conn">Connection helper.</param>
|
||||
/// <param name="options">Validated options.</param>
|
||||
/// <param name="log">Optional logger.</param>
|
||||
public SqlServerSchemaBootstrapper(SqlHistorianConnection conn, SqlServerHistorianOptions options, ILogger? log = null)
|
||||
{
|
||||
_conn = conn;
|
||||
_autoCreate = options.AutoCreateSchema;
|
||||
_log = log ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
/// <summary>Ensures the schema is ready; no-op after the first success.</summary>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>A task completing when the schema is usable.</returns>
|
||||
/// <exception cref="HistorianSchemaMissingException">AutoCreateSchema is false and version 1 is absent.</exception>
|
||||
public async Task EnsureAsync(CancellationToken ct)
|
||||
{
|
||||
if (_ready) return;
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_ready) return;
|
||||
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
if (_autoCreate) await ApplyAsync(conn, ct).ConfigureAwait(false);
|
||||
else await VerifyAsync(conn, ct).ConfigureAwait(false);
|
||||
_ready = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> ReadVersionAsync(SqlConnection conn, CancellationToken ct)
|
||||
{
|
||||
await using var cmd = _conn.CreateCommand(conn,
|
||||
$"IF OBJECT_ID(N'{_conn.Q("SchemaVersion")}', N'U') IS NULL SELECT 0 ELSE SELECT ISNULL(MAX(Version), 0) FROM {_conn.Q("SchemaVersion")}");
|
||||
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
private async Task VerifyAsync(SqlConnection conn, CancellationToken ct)
|
||||
{
|
||||
var v = await ReadVersionAsync(conn, ct).ConfigureAwait(false);
|
||||
if (v < RequiredVersion)
|
||||
throw new HistorianSchemaMissingException(
|
||||
$"Historian schema [{_conn.Schema}] is missing version {RequiredVersion} (found {v}) and AutoCreateSchema is false. " +
|
||||
"Apply docs/historian/sqlserver/001_initial.sql or enable AutoCreateSchema.");
|
||||
}
|
||||
|
||||
private async Task ApplyAsync(SqlConnection conn, CancellationToken ct)
|
||||
{
|
||||
await AcquireLockAsync(conn, ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var current = await ReadVersionAsync(conn, ct).ConfigureAwait(false);
|
||||
foreach (var (version, name, text) in LoadScripts())
|
||||
{
|
||||
if (version <= current) continue;
|
||||
foreach (var batch in SplitBatches(text.Replace("$(Schema)", _conn.Schema, StringComparison.Ordinal)))
|
||||
{
|
||||
await using var cmd = _conn.CreateCommand(conn, batch);
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
_log.LogInformation("Applied historian schema script {Script} to [{Schema}]", name, _conn.Schema);
|
||||
}
|
||||
await VerifyAsync(conn, ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ReleaseLockAsync(conn).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AcquireLockAsync(SqlConnection conn, CancellationToken ct)
|
||||
{
|
||||
await using var cmd = _conn.CreateCommand(conn, "sp_getapplock");
|
||||
cmd.CommandType = CommandType.StoredProcedure;
|
||||
cmd.CommandTimeout = Math.Max(cmd.CommandTimeout, LockTimeoutMs / 1000 + 15);
|
||||
cmd.Parameters.AddWithValue("@Resource", LockResource);
|
||||
cmd.Parameters.AddWithValue("@LockMode", "Exclusive");
|
||||
cmd.Parameters.AddWithValue("@LockOwner", "Session");
|
||||
cmd.Parameters.AddWithValue("@LockTimeout", LockTimeoutMs);
|
||||
var rc = new SqlParameter("@rc", SqlDbType.Int) { Direction = ParameterDirection.ReturnValue };
|
||||
cmd.Parameters.Add(rc);
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
if ((int)rc.Value < 0)
|
||||
throw new TimeoutException($"Could not acquire historian schema applock within {LockTimeoutMs} ms (sp_getapplock returned {rc.Value}).");
|
||||
}
|
||||
|
||||
private async Task ReleaseLockAsync(SqlConnection conn)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var cmd = _conn.CreateCommand(conn, "sp_releaseapplock");
|
||||
cmd.CommandType = CommandType.StoredProcedure;
|
||||
cmd.Parameters.AddWithValue("@Resource", LockResource);
|
||||
cmd.Parameters.AddWithValue("@LockOwner", "Session");
|
||||
await cmd.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Closing the session releases a Session-owned lock anyway.
|
||||
_log.LogDebug(ex, "sp_releaseapplock failed; the lock dies with the connection");
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^\s*GO\s*$", RegexOptions.Multiline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex GoLine();
|
||||
|
||||
internal static IEnumerable<string> SplitBatches(string script) =>
|
||||
GoLine().Split(script).Select(b => b.Trim()).Where(b => b.Length > 0);
|
||||
|
||||
private static List<(int Version, string Name, string Text)> LoadScripts()
|
||||
{
|
||||
var asm = typeof(SqlServerSchemaBootstrapper).Assembly;
|
||||
var list = new List<(int, string, string)>();
|
||||
foreach (var res in asm.GetManifestResourceNames())
|
||||
{
|
||||
var m = Regex.Match(res, @"^Schema\.(\d+)_.*\.sql$");
|
||||
if (!m.Success) continue;
|
||||
using var s = asm.GetManifestResourceStream(res)!;
|
||||
using var r = new StreamReader(s);
|
||||
list.Add((int.Parse(m.Groups[1].Value), res, r.ReadToEnd()));
|
||||
}
|
||||
return list.OrderBy(x => x.Item1).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Tag name → <c>TagId</c> resolution shared by writes, reads, provisioning and updates. Caches positive
|
||||
/// results only. Both methods run the schema bootstrap first, so callers need not.
|
||||
/// </summary>
|
||||
internal sealed class SqlServerTagCatalog
|
||||
{
|
||||
private readonly SqlHistorianConnection _conn;
|
||||
private readonly SqlServerSchemaBootstrapper _bootstrap;
|
||||
private readonly ConcurrentDictionary<string, int> _cache = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Creates the catalog.</summary>
|
||||
/// <param name="conn">Connection helper.</param>
|
||||
/// <param name="bootstrap">Schema bootstrapper.</param>
|
||||
public SqlServerTagCatalog(SqlHistorianConnection conn, SqlServerSchemaBootstrapper bootstrap)
|
||||
{
|
||||
_conn = conn;
|
||||
_bootstrap = bootstrap;
|
||||
}
|
||||
|
||||
/// <summary>Read-only lookup; never creates a row.</summary>
|
||||
/// <param name="name">Historian tag name.</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The TagId, or null when the tag does not exist.</returns>
|
||||
public async Task<int?> TryGetTagIdAsync(string name, CancellationToken ct)
|
||||
{
|
||||
if (_cache.TryGetValue(name, out var cached)) return cached;
|
||||
await _bootstrap.EnsureAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
var id = await SelectIdAsync(conn, name, ct).ConfigureAwait(false);
|
||||
if (id is { } v) _cache[name] = v;
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>Returns the TagId, creating the row on a miss. Safe under concurrency (HOLDLOCK MERGE; 2627/2601 re-select).</summary>
|
||||
/// <param name="name">Historian tag name.</param>
|
||||
/// <param name="dataType">Initial <c>Tag.DataType</c> (only used on create; provisioning corrects it later).</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The TagId.</returns>
|
||||
public async Task<int> GetOrCreateTagIdAsync(string name, byte dataType, CancellationToken ct)
|
||||
{
|
||||
if (_cache.TryGetValue(name, out var cached)) return cached;
|
||||
await _bootstrap.EnsureAsync(ct).ConfigureAwait(false);
|
||||
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
var sql =
|
||||
$"MERGE {_conn.Q("Tag")} WITH (HOLDLOCK) AS t USING (SELECT @name AS Name) AS s ON t.Name = s.Name " +
|
||||
"WHEN NOT MATCHED THEN INSERT (Name, DataType, CreatedUtc, UpdatedUtc) VALUES (@name, @dt, SYSUTCDATETIME(), SYSUTCDATETIME());";
|
||||
try
|
||||
{
|
||||
await using var cmd = _conn.CreateCommand(conn, sql);
|
||||
cmd.Parameters.Add(new SqlParameter("@name", System.Data.SqlDbType.NVarChar, 400) { Value = name });
|
||||
cmd.Parameters.Add(new SqlParameter("@dt", System.Data.SqlDbType.TinyInt) { Value = dataType });
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (SqlException ex) when (SqlHistorianConnection.IsDuplicateKey(ex))
|
||||
{
|
||||
// A concurrent creator won; fall through to the re-select.
|
||||
}
|
||||
var id = await SelectIdAsync(conn, name, ct).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException($"Tag '{name}' vanished after create.");
|
||||
_cache[name] = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>Drops a cached id (e.g. after a tag row is deleted).</summary>
|
||||
/// <param name="name">Historian tag name.</param>
|
||||
public void Invalidate(string name) => _cache.TryRemove(name, out _);
|
||||
|
||||
private async Task<int?> SelectIdAsync(SqlConnection conn, string name, CancellationToken ct)
|
||||
{
|
||||
await using var cmd = _conn.CreateCommand(conn, $"SELECT TagId FROM {_conn.Q("Tag")} WHERE Name = @name");
|
||||
cmd.Parameters.Add(new SqlParameter("@name", System.Data.SqlDbType.NVarChar, 400) { Value = name });
|
||||
var o = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
return o is null or DBNull ? null : Convert.ToInt32(o);
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
<!-- Runtime owns IHistorianBackendProvider / ServerHistorianOptions; same diamond as Driver.Historian.Gateway
|
||||
(Host -> {Runtime, SqlServer}, SqlServer -> Runtime). This project does NOT reference the Gateway project. -->
|
||||
<ProjectReference Include="..\..\Server\ZB.MOM.WW.OtOpcUa.Runtime\ZB.MOM.WW.OtOpcUa.Runtime.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Logical name is stable regardless of RootNamespace: "Schema.001_initial.sql". -->
|
||||
<EmbeddedResource Include="Schema\*.sql" LogicalName="Schema.%(Filename)%(Extension)"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user