e790938f94
QuestDB modified-history reads dropped every still-pending trail row that was not the latest at its source time, so V0->V1 (applied marker never written) followed by V1->V2 hid the V0->V1 change for good. A pending row below the latest is now served when the NEXT trail row at that time recorded a different prior value (kind, value, status or the prior's server_ts) - the next change saw what this one wrote; an orphan of a change that never landed leaves the same prior for the next row and stays hidden. QuestDB window functions reject VARCHAR, so the next row's prior comes from a LEFT JOIN on (ts, lead(id)). The updater's comment and marker-failure log no longer claim modified reads "still serve it" unconditionally. Regression tests (QuestDB 10.0.1 throwaway container): marker lost then a replace / delete at the same time; an orphan followed by a marker-lost retry then a later change (orphan still dropped); the random marker-mix fuzz now varies the recorded priors. All four fail on the old filter. NITs: - SqlServerHistorianValueWriter: a SqlException raised while the token is cancelled (SqlClient's "Operation cancelled by user") is rethrown as OperationCanceledException instead of booked as a failed write. Integration test holds a TABLOCKX on Sample and cancels mid-INSERT; it returned false before. - SqlServerHistorianDataUpdater.DeleteRawBatches is an Interlocked counter. - HistoryReadEvents: an event filter whose SDK validation throws (unknown operator) failed with the generic BadUnexpectedError; the node manager now re-throws any failed event-filter validation as BadEventFilterInvalid. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
111 lines
6.1 KiB
C#
111 lines
6.1 KiB
C#
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;
|
|
|
|
/// <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. Returns false on any failure so the recorder keeps the entries and its
|
|
/// per-tag quarantine keeps working — except a cancellation of the caller's token, which is rethrown: it ends the
|
|
/// PASS, and booked as a tag failure it could tip a healthy tag over its attempt budget.
|
|
/// </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"/> so the sample's presence survives and the tag is not quarantined
|
|
/// for one bad reading. <b>An Empty sample is always Bad</b> (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
|
|
/// <c>BadOutOfRange</c>; any other Empty with a non-Bad status is stored with <c>BadNoData</c>. An existing
|
|
/// Bad code (e.g. the comms-loss <c>BadNoCommunication</c> marker) is kept as it is.
|
|
/// <b>Tag names over <see cref="SqlServerTagCatalog.MaxNameLength"/> characters</b> are refused (the write
|
|
/// returns false, a per-tag failure for the recorder's quarantine), never truncated onto another tag.
|
|
/// <b>Duplicate timestamps within a batch</b> collapse to the first (matching insert-if-absent across batches).
|
|
/// The batch normalisation itself is <see cref="LiveWritePreparer.Prepare"/>.
|
|
/// </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
|
|
{
|
|
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 (OperationCanceledException) when (ct.IsCancellationRequested)
|
|
{
|
|
throw; // the drain pass was cancelled (shutdown) — not a write failure of this tag
|
|
}
|
|
catch (SqlException ex) when (ct.IsCancellationRequested)
|
|
{
|
|
// SqlClient reports a command cancelled mid-flight as a SqlException ("Operation cancelled by user"), not an
|
|
// OperationCanceledException: still the drain pass's cancellation, never a write failure of this tag.
|
|
throw new OperationCanceledException("The SQL historian live-value write was cancelled.", ex, ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log.LogWarning("SQL historian live-value write failed ({Exception}); will retry.", ex.GetType().Name);
|
|
return false;
|
|
}
|
|
}
|
|
}
|