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;
}
}
}