using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; /// /// The one place that maps value kinds to Tag.DataType (the schema stores a byte). /// Used only when the writer has to create a tag row on a miss; provisioning later corrects it. /// internal static class SqlHistorianDataTypes { /// Data type used when a tag is created from a batch that holds no typed value. public const DriverDataType DefaultForEmpty = DriverDataType.Float64; /// Maps a value kind to the stored Tag.DataType byte (Empty ⇒ ). /// Value kind. /// The as a byte. public static byte FromKind(HistorianValueKind kind) => (byte)(kind switch { HistorianValueKind.Double => DriverDataType.Float64, HistorianValueKind.Int64 => DriverDataType.Int64, HistorianValueKind.Boolean => DriverDataType.Boolean, HistorianValueKind.String => DriverDataType.String, HistorianValueKind.DateTime => DriverDataType.DateTime, _ => DefaultForEmpty, }); } /// /// over the SQL Sample table. Redelivery-idempotent: rows are /// inserted only where (TagId, SourceTimeUtc) is absent, so the recorder's at-least-once redelivery /// leaves the first-stored sample untouched. Never throws; returns false on any failure so the recorder keeps /// the entries and its #538 per-tag quarantine keeps working. /// /// /// Non-finite doubles. NaN / ±Infinity cannot live in a SQL float (the TVP throws). They are stored /// as keeping the original status code, so the sample's presence and /// status survive and the tag is not quarantined for one bad reading. /// Duplicate timestamps within a batch collapse to the first (matching insert-if-absent across batches). /// internal sealed class SqlServerHistorianValueWriter : IHistorianValueWriter { private readonly SqlHistorianConnection _conn; private readonly SqlServerSchemaBootstrapper _bootstrap; private readonly SqlServerTagCatalog _catalog; private readonly ILogger _log; private readonly Func _utcNow; /// Creates the writer. /// Connection helper. /// Schema bootstrapper. /// Tag catalog. /// Optional logger. /// Clock for null timestamps (tests). public SqlServerHistorianValueWriter(SqlHistorianConnection conn, SqlServerSchemaBootstrapper bootstrap, SqlServerTagCatalog catalog, ILogger? log = null, Func? utcNow = null) { _conn = conn; _bootstrap = bootstrap; _catalog = catalog; _log = log ?? NullLogger.Instance; _utcNow = utcNow ?? (() => DateTime.UtcNow); } /// public async Task WriteLiveValuesAsync(string tag, IReadOnlyList values, CancellationToken ct) { try { await _bootstrap.EnsureAsync(ct).ConfigureAwait(false); if (values is null || values.Count == 0) return true; var rows = Prepare(values, _utcNow); var dataType = SqlHistorianDataTypes.FromKind(rows.Select(r => r.Value.Kind).FirstOrDefault(k => k != HistorianValueKind.Empty)); var tagId = await _catalog.GetOrCreateTagIdAsync(tag, dataType, ct).ConfigureAwait(false); var sql = $"INSERT INTO {_conn.Q("Sample")} (TagId, SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, ServerTimeUtc) " + "SELECT @tagId, r.SourceTimeUtc, r.ValueKind, r.NumValue, r.IntValue, r.StrValue, r.StatusCode, SYSUTCDATETIME() " + "FROM @rows r WHERE NOT EXISTS (SELECT 1 FROM " + _conn.Q("Sample") + " s WITH (UPDLOCK, HOLDLOCK) WHERE s.TagId = @tagId AND s.SourceTimeUtc = r.SourceTimeUtc)"; // A duplicate-key error rolls the WHOLE statement back, so "someone else wrote a row" is NOT proof our rows are // present. The retry re-evaluates NOT EXISTS against what is now there; only its success counts. for (var attempt = 0; ; attempt++) { try { await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false); await using var cmd = _conn.CreateCommand(conn, sql); cmd.Parameters.Add(new SqlParameter("@tagId", System.Data.SqlDbType.Int) { Value = tagId }); _conn.AddStructured(cmd, "@rows", "SampleRow", SampleRowMapper.ToSampleRecords(rows)); await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); return true; } catch (SqlException ex) when (attempt < 2 && SqlHistorianConnection.IsDuplicateKey(ex)) { } } } catch (Exception ex) { _log.LogWarning("SQL historian live-value write failed ({Exception}); will retry.", ex.GetType().Name); return false; } } /// /// Normalises a batch for the TVP: null timestamp ⇒ now, non-finite doubles ⇒ Empty (status kept), and /// duplicate timestamps collapsed to the first. /// /// Input samples, in append order. /// Clock for null timestamps. /// Time-unique rows. internal static List Prepare(IReadOnlyList values, Func utcNow) { var seen = new HashSet(); var rows = new List(values.Count); foreach (var v in values) { var t = v.TimestampUtc ?? utcNow(); t = t.Kind == DateTimeKind.Local ? t.ToUniversalTime() : DateTime.SpecifyKind(t, DateTimeKind.Utc); if (!seen.Add(t.Ticks)) continue; var value = v.Value; if (value.Kind == HistorianValueKind.Double && !double.IsFinite(value.Number)) value = HistorianValue.Empty; rows.Add(new SqlSampleRow(t, value, v.StatusCode)); } return rows; } }