using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; /// /// 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 so the sample's presence survives and the tag is not quarantined /// for one bad reading. An Empty sample is always Bad (the quality contract: a stored sample is never /// served better than "no value"): a non-finite Double whose status is not already Bad is stored with /// BadOutOfRange; any other Empty with a non-Bad status is stored with BadNoData. An existing /// Bad code (e.g. the comms-loss BadNoCommunication marker) is kept as it is. /// Tag names over characters are refused (the write /// returns false, a per-tag failure for the recorder's #538 quarantine), never truncated onto another tag. /// Duplicate timestamps within a batch collapse to the first (matching insert-if-absent across batches). /// The batch normalisation itself is . /// 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 { if (!HistorianTagNames.IsValid(tag)) { _log.LogWarning("SQL historian live-value write refused: tag name is missing or longer than {Max} characters.", SqlServerTagCatalog.MaxNameLength); return false; } await _bootstrap.EnsureAsync(ct).ConfigureAwait(false); if (values is null || values.Count == 0) return true; var rows = LiveWritePreparer.Prepare(values, _utcNow); var dataType = HistorianDataTypes.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; } } }