feat(historian): SQL Server value writer, tag provisioner, alarm writer (T9)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+168
@@ -0,0 +1,168 @@
|
||||
using System.Data;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IAlarmHistorianWriter"/> over the SQL <c>Event</c> table. Per event: insert-if-absent keyed by a
|
||||
/// deterministic <see cref="ComputeEventId"/>, so the store-and-forward drain's at-least-once redelivery (and both
|
||||
/// pair nodes writing the same event) converge on one row. Outcomes: Ack on insert or already-present;
|
||||
/// RetryPlease on transient SQL / schema-bootstrap / unclassified SQL errors and on cancellation; PermanentFail only
|
||||
/// for data errors (truncation, conversion, null, range) and non-SQL exceptions. Never throws.
|
||||
/// </summary>
|
||||
internal sealed class SqlServerAlarmHistorianWriter : IAlarmHistorianWriter
|
||||
{
|
||||
// 2628/8152 string truncation, 515 null into NOT NULL, 245/8114 conversion, 220/8115 overflow, 242/241 datetime range.
|
||||
private static readonly int[] DataErrorNumbers = [2628, 8152, 515, 245, 8114, 220, 8115, 242, 241];
|
||||
|
||||
private readonly SqlHistorianConnection _conn;
|
||||
private readonly SqlServerSchemaBootstrapper _bootstrap;
|
||||
private readonly ILogger _log;
|
||||
|
||||
/// <summary>Creates the writer.</summary>
|
||||
/// <param name="conn">Connection helper.</param>
|
||||
/// <param name="bootstrap">Schema bootstrapper.</param>
|
||||
/// <param name="log">Optional logger.</param>
|
||||
public SqlServerAlarmHistorianWriter(SqlHistorianConnection conn, SqlServerSchemaBootstrapper bootstrap, ILogger? log = null)
|
||||
{
|
||||
_conn = conn;
|
||||
_bootstrap = bootstrap;
|
||||
_log = log ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(batch);
|
||||
if (batch.Count == 0) return [];
|
||||
var outcomes = new HistorianWriteOutcome[batch.Count];
|
||||
try
|
||||
{
|
||||
await _bootstrap.EnsureAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning("SQL alarm historian bootstrap failed ({Exception}); will retry.", ex.GetType().Name);
|
||||
Array.Fill(outcomes, HistorianWriteOutcome.RetryPlease);
|
||||
return outcomes;
|
||||
}
|
||||
|
||||
for (var i = 0; i < batch.Count; i++)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) { outcomes[i] = HistorianWriteOutcome.RetryPlease; continue; }
|
||||
outcomes[i] = await WriteOneAsync(batch[i], cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return outcomes;
|
||||
}
|
||||
|
||||
private async Task<HistorianWriteOutcome> WriteOneAsync(AlarmHistorianEvent e, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// String parameters are NVARCHAR(MAX) on purpose: a sized parameter silently truncates client-side, which would
|
||||
// hide an over-long value; the server-side 8152 is what classifies it PermanentFail.
|
||||
var sql =
|
||||
$"INSERT INTO {_conn.Q("Event")} (EventId, SourceName, AlarmId, AlarmName, AlarmTypeName, Severity, EventKind, Message, UserName, Comment, EventTimeUtc, ReceivedTimeUtc) " +
|
||||
"SELECT @id, @src, @aid, @an, @at, @sev, @ek, @msg, @usr, @cmt, @t, SYSUTCDATETIME() " +
|
||||
$"WHERE NOT EXISTS (SELECT 1 FROM {_conn.Q("Event")} x WITH (UPDLOCK, HOLDLOCK) WHERE x.EventId = @id)";
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = _conn.CreateCommand(conn, sql);
|
||||
cmd.Parameters.Add(new SqlParameter("@id", SqlDbType.NVarChar, 64) { Value = ComputeEventId(e) });
|
||||
cmd.Parameters.Add(new SqlParameter("@src", SqlDbType.NVarChar, -1) { Value = e.EquipmentPath ?? "" });
|
||||
cmd.Parameters.Add(new SqlParameter("@aid", SqlDbType.NVarChar, -1) { Value = e.AlarmId ?? "" });
|
||||
cmd.Parameters.Add(new SqlParameter("@an", SqlDbType.NVarChar, -1) { Value = e.AlarmName ?? "" });
|
||||
cmd.Parameters.Add(new SqlParameter("@at", SqlDbType.NVarChar, -1) { Value = e.AlarmTypeName ?? "" });
|
||||
cmd.Parameters.Add(new SqlParameter("@sev", SqlDbType.Int) { Value = ToNumericSeverity(e.Severity) });
|
||||
cmd.Parameters.Add(new SqlParameter("@ek", SqlDbType.NVarChar, -1) { Value = e.EventKind ?? "" });
|
||||
cmd.Parameters.Add(new SqlParameter("@msg", SqlDbType.NVarChar, -1) { Value = (object?)e.Message ?? DBNull.Value });
|
||||
cmd.Parameters.Add(new SqlParameter("@usr", SqlDbType.NVarChar, -1) { Value = (object?)e.User ?? DBNull.Value });
|
||||
cmd.Parameters.Add(new SqlParameter("@cmt", SqlDbType.NVarChar, -1) { Value = (object?)e.Comment ?? DBNull.Value });
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@t", e.TimestampUtc);
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
return HistorianWriteOutcome.Ack;
|
||||
}
|
||||
// Single-row statement: a duplicate-key error means the row IS there (a concurrent writer won).
|
||||
catch (SqlException ex) when (SqlHistorianConnection.IsDuplicateKey(ex))
|
||||
{
|
||||
return HistorianWriteOutcome.Ack;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return HistorianWriteOutcome.RetryPlease;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var outcome = Classify(ex);
|
||||
_log.LogWarning("SQL alarm event write failed ({Exception}); outcome {Outcome}.", ex.GetType().Name, outcome);
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
|
||||
private static HistorianWriteOutcome Classify(Exception ex)
|
||||
{
|
||||
if (SqlHistorianConnection.IsTransient(ex)) return HistorianWriteOutcome.RetryPlease;
|
||||
if (ex is SqlException sql)
|
||||
return sql.Errors.Cast<SqlError>().Any(x => Array.IndexOf(DataErrorNumbers, x.Number) >= 0)
|
||||
? HistorianWriteOutcome.PermanentFail
|
||||
: HistorianWriteOutcome.RetryPlease; // config/auth/availability problems must not dead-letter data
|
||||
return HistorianWriteOutcome.PermanentFail;
|
||||
}
|
||||
|
||||
// Same numeric Part 9 priorities the gateway path sends (AlarmEventMapper.ToNumericSeverity).
|
||||
private static int ToNumericSeverity(AlarmSeverity s) => s switch
|
||||
{
|
||||
AlarmSeverity.Low => 200,
|
||||
AlarmSeverity.Medium => 500,
|
||||
AlarmSeverity.High => 700,
|
||||
AlarmSeverity.Critical => 900,
|
||||
_ => 500,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Lowercase-hex SHA-256 over length-prefixed UTF-8 fields (little-endian int32 byte length, -1 for null):
|
||||
/// EquipmentPath, AlarmId, EventKind, TimestampUtc.Ticks (decimal), User, Comment, Message. Length-prefixing keeps
|
||||
/// field boundaries unambiguous (<c>"a|b"+"c"</c> ≠ <c>"a"+"b|c"</c>).
|
||||
/// </summary>
|
||||
/// <param name="e">The event.</param>
|
||||
/// <returns>64 hex characters.</returns>
|
||||
internal static string ComputeEventId(AlarmHistorianEvent e)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
Field(ms, e.EquipmentPath);
|
||||
Field(ms, e.AlarmId);
|
||||
Field(ms, e.EventKind);
|
||||
Field(ms, e.TimestampUtc.Ticks.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
Field(ms, e.User);
|
||||
Field(ms, e.Comment);
|
||||
Field(ms, e.Message);
|
||||
return Convert.ToHexStringLower(SHA256.HashData(ms.ToArray()));
|
||||
}
|
||||
|
||||
private static void Field(MemoryStream ms, string? value)
|
||||
{
|
||||
Span<byte> len = stackalloc byte[4];
|
||||
if (value is null)
|
||||
{
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(len, -1);
|
||||
ms.Write(len);
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(len, bytes.Length);
|
||||
ms.Write(len);
|
||||
ms.Write(bytes);
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// The one place that maps value kinds to <c>Tag.DataType</c> (the schema stores a <see cref="DriverDataType"/> byte).
|
||||
/// Used only when the writer has to create a tag row on a miss; provisioning later corrects it.
|
||||
/// </summary>
|
||||
internal static class SqlHistorianDataTypes
|
||||
{
|
||||
/// <summary>Data type used when a tag is created from a batch that holds no typed value.</summary>
|
||||
public const DriverDataType DefaultForEmpty = DriverDataType.Float64;
|
||||
|
||||
/// <summary>Maps a value kind to the stored <c>Tag.DataType</c> byte (Empty ⇒ <see cref="DefaultForEmpty"/>).</summary>
|
||||
/// <param name="kind">Value kind.</param>
|
||||
/// <returns>The <see cref="DriverDataType"/> as a byte.</returns>
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IHistorianValueWriter"/> over the SQL <c>Sample</c> table. Redelivery-idempotent: rows are
|
||||
/// inserted only where <c>(TagId, SourceTimeUtc)</c> 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Non-finite doubles.</b> NaN / ±Infinity cannot live in a SQL <c>float</c> (the TVP throws). They are stored
|
||||
/// as <see cref="HistorianValueKind.Empty"/> keeping the original status code, so the sample's presence and
|
||||
/// status survive and the tag is not quarantined for one bad reading.
|
||||
/// <b>Duplicate timestamps within a batch</b> collapse to the first (matching insert-if-absent across batches).
|
||||
/// </remarks>
|
||||
internal sealed class SqlServerHistorianValueWriter : IHistorianValueWriter
|
||||
{
|
||||
private readonly SqlHistorianConnection _conn;
|
||||
private readonly SqlServerSchemaBootstrapper _bootstrap;
|
||||
private readonly SqlServerTagCatalog _catalog;
|
||||
private readonly ILogger _log;
|
||||
private readonly Func<DateTime> _utcNow;
|
||||
|
||||
/// <summary>Creates the writer.</summary>
|
||||
/// <param name="conn">Connection helper.</param>
|
||||
/// <param name="bootstrap">Schema bootstrapper.</param>
|
||||
/// <param name="catalog">Tag catalog.</param>
|
||||
/// <param name="log">Optional logger.</param>
|
||||
/// <param name="utcNow">Clock for null timestamps (tests).</param>
|
||||
public SqlServerHistorianValueWriter(SqlHistorianConnection conn, SqlServerSchemaBootstrapper bootstrap,
|
||||
SqlServerTagCatalog catalog, ILogger? log = null, Func<DateTime>? utcNow = null)
|
||||
{
|
||||
_conn = conn;
|
||||
_bootstrap = bootstrap;
|
||||
_catalog = catalog;
|
||||
_log = log ?? NullLogger.Instance;
|
||||
_utcNow = utcNow ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> WriteLiveValuesAsync(string tag, IReadOnlyList<HistorizationValue> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalises a batch for the TVP: null timestamp ⇒ now, non-finite doubles ⇒ Empty (status kept), and
|
||||
/// duplicate timestamps collapsed to the first.
|
||||
/// </summary>
|
||||
/// <param name="values">Input samples, in append order.</param>
|
||||
/// <param name="utcNow">Clock for null timestamps.</param>
|
||||
/// <returns>Time-unique rows.</returns>
|
||||
internal static List<SqlSampleRow> Prepare(IReadOnlyList<HistorizationValue> values, Func<DateTime> utcNow)
|
||||
{
|
||||
var seen = new HashSet<long>();
|
||||
var rows = new List<SqlSampleRow>(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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Data;
|
||||
using System.Text;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IHistorianProvisioning"/> for the SQL backend: one <c>MERGE … WITH (HOLDLOCK)</c> upsert per chunk of
|
||||
/// tags (chunked only to stay under SQL's 2100-parameter limit). Every data type is provisionable (Skipped is
|
||||
/// always 0). A null engineering unit / description in a request keeps the stored value rather than wiping it.
|
||||
/// Never throws.
|
||||
/// </summary>
|
||||
internal sealed class SqlServerTagProvisioner : IHistorianProvisioning
|
||||
{
|
||||
private const int ChunkSize = 400; // 4 parameters per tag
|
||||
private const int MaxNameLength = 400;
|
||||
private const int MaxUnitLength = 64;
|
||||
private const int MaxDescriptionLength = 400;
|
||||
private const int MaxAttempts = 4;
|
||||
|
||||
private readonly SqlHistorianConnection _conn;
|
||||
private readonly SqlServerSchemaBootstrapper _bootstrap;
|
||||
private readonly ILogger _log;
|
||||
|
||||
/// <summary>Creates the provisioner.</summary>
|
||||
/// <param name="conn">Connection helper.</param>
|
||||
/// <param name="bootstrap">Schema bootstrapper.</param>
|
||||
/// <param name="log">Optional logger.</param>
|
||||
public SqlServerTagProvisioner(SqlHistorianConnection conn, SqlServerSchemaBootstrapper bootstrap, ILogger? log = null)
|
||||
{
|
||||
_conn = conn;
|
||||
_bootstrap = bootstrap;
|
||||
_log = log ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<HistorianProvisionResult> EnsureTagsAsync(IReadOnlyList<HistorianTagProvisionRequest> requests, CancellationToken ct)
|
||||
{
|
||||
var requested = requests?.Count ?? 0;
|
||||
if (requested == 0) return new HistorianProvisionResult(0, 0, 0, 0);
|
||||
var failed = 0;
|
||||
var ensured = 0;
|
||||
try
|
||||
{
|
||||
await _bootstrap.EnsureAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// Distinct names (a MERGE source must not repeat a key); last request wins. Over-long/empty names fail alone.
|
||||
var byName = new Dictionary<string, (HistorianTagProvisionRequest Req, int Count)>(StringComparer.Ordinal);
|
||||
foreach (var r in requests!)
|
||||
{
|
||||
if (r is null || string.IsNullOrWhiteSpace(r.TagName) || r.TagName.Length > MaxNameLength) { failed++; continue; }
|
||||
byName[r.TagName] = (r, byName.TryGetValue(r.TagName, out var e) ? e.Count + 1 : 1);
|
||||
}
|
||||
|
||||
var ordered = byName.OrderBy(kv => kv.Key, StringComparer.Ordinal).Select(kv => kv.Value).ToList();
|
||||
for (var i = 0; i < ordered.Count; i += ChunkSize)
|
||||
{
|
||||
var chunk = ordered.Skip(i).Take(ChunkSize).ToList();
|
||||
var weight = chunk.Sum(c => c.Count);
|
||||
if (await TryMergeAsync(chunk.Select(c => c.Req).ToList(), ct).ConfigureAwait(false)) ensured += weight;
|
||||
else failed += weight;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning("SQL historian provisioning failed ({Exception}).", ex.GetType().Name);
|
||||
failed = requested - ensured;
|
||||
}
|
||||
|
||||
return new HistorianProvisionResult(requested, ensured, 0, failed);
|
||||
}
|
||||
|
||||
private async Task<bool> TryMergeAsync(List<HistorianTagProvisionRequest> chunk, CancellationToken ct)
|
||||
{
|
||||
var sql = new StringBuilder();
|
||||
sql.Append($"MERGE {_conn.Q("Tag")} WITH (HOLDLOCK) AS t USING (VALUES ");
|
||||
for (var i = 0; i < chunk.Count; i++)
|
||||
sql.Append(i == 0 ? "" : ",").Append($"(@n{i},@d{i},@e{i},@c{i})");
|
||||
sql.Append(") AS s (Name, DataType, EngineeringUnit, Description) ON t.Name = s.Name " +
|
||||
"WHEN MATCHED THEN UPDATE SET DataType = s.DataType, EngineeringUnit = COALESCE(s.EngineeringUnit, t.EngineeringUnit), " +
|
||||
"Description = COALESCE(s.Description, t.Description), UpdatedUtc = SYSUTCDATETIME() " +
|
||||
"WHEN NOT MATCHED THEN INSERT (Name, DataType, EngineeringUnit, Description, CreatedUtc, UpdatedUtc) " +
|
||||
"VALUES (s.Name, s.DataType, s.EngineeringUnit, s.Description, SYSUTCDATETIME(), SYSUTCDATETIME());");
|
||||
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = _conn.CreateCommand(conn, sql.ToString());
|
||||
for (var i = 0; i < chunk.Count; i++)
|
||||
{
|
||||
var r = chunk[i];
|
||||
cmd.Parameters.Add(new SqlParameter($"@n{i}", SqlDbType.NVarChar, MaxNameLength) { Value = r.TagName });
|
||||
cmd.Parameters.Add(new SqlParameter($"@d{i}", SqlDbType.TinyInt) { Value = (byte)r.DataType });
|
||||
cmd.Parameters.Add(new SqlParameter($"@e{i}", SqlDbType.NVarChar, MaxUnitLength) { Value = Clip(r.EngineeringUnit, MaxUnitLength) });
|
||||
cmd.Parameters.Add(new SqlParameter($"@c{i}", SqlDbType.NVarChar, MaxDescriptionLength) { Value = Clip(r.Description, MaxDescriptionLength) });
|
||||
}
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (SqlException ex) when (attempt < MaxAttempts &&
|
||||
(SqlHistorianConnection.IsDuplicateKey(ex) || SqlHistorianConnection.IsTransient(ex)))
|
||||
{
|
||||
await Task.Delay(20 * attempt, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning("SQL historian tag MERGE failed ({Exception}).", ex.GetType().Name);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static object Clip(string? s, int max) => s is null ? DBNull.Value : s.Length <= max ? s : s[..max];
|
||||
}
|
||||
Reference in New Issue
Block a user