Merge ph-T8 (pluggable historian)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 13:00:51 -04:00
19 changed files with 1187 additions and 0 deletions
@@ -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) { }
}
@@ -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) &gt;= 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);
}
}
@@ -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>