feat(historian): SQL Server read side — raw, at-time, events, push-down processed (ph-T10)
SqlServerHistorianDataSource implements IHistorianDataSource over the T8 schema: raw forward/reverse/point with the 0 = unbounded sentinel; AtTime in one statement (TimeRow TVP + two OUTER APPLYs) with linear/stepped/no-prior rules and no interpolation across a Bad neighbour or Empty outage marker; events with a maxEvents+1 truncation probe and a non-null continuation point; processed aggregates pushed down to T-SQL (SqlAggregateQueries) with nanosecond-exact bucket edges, Calculated bits on every result, Uncertain on any non-Good input, BadAggregateInvalidInputs for numeric aggregates over String/DateTime, a 100 000-bucket cap and HistoryAggregateNotSupportedException for the rest. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
|
||||
/// <summary>The SQL shape that serves an aggregate. One query per family.</summary>
|
||||
internal enum SqlAggregateFamily
|
||||
{
|
||||
/// <summary>Average / Minimum / Maximum / Total / Count / Range / PercentGood / PercentBad: one GROUP BY.</summary>
|
||||
Simple,
|
||||
|
||||
/// <summary>Start / End / Delta: first + last usable sample per bucket (<c>ROW_NUMBER</c>).</summary>
|
||||
StartEnd,
|
||||
|
||||
/// <summary>MinimumActualTime / MaximumActualTime: extreme usable sample per bucket, ties ⇒ earliest.</summary>
|
||||
ActualTime,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Push-down T-SQL for processed (aggregate) reads (spec §4 "Processed"). Every query takes
|
||||
/// <c>@id</c> (int TagId), <c>@start</c>/<c>@end</c> (<c>datetime2(7)</c>, the forward window
|
||||
/// <c>[start, end)</c>) and <c>@intervalNs</c> (bigint bucket width in nanoseconds), and returns one row
|
||||
/// per <b>non-empty</b> bucket. The caller generates every interval and fills the empty ones.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Bucketing.</b> <c>b = DATEDIFF_BIG(nanosecond, @start, SourceTimeUtc) / @intervalNs</c>. The spec
|
||||
/// sketched <c>millisecond</c>, but <c>DATEDIFF</c> counts unit <i>boundaries crossed</i>, not elapsed
|
||||
/// time, so with a non-millisecond-aligned <c>@start</c> a sample one tick before a bucket edge would
|
||||
/// land in the next bucket. <c>datetime2(7)</c> values are whole multiples of 100 ns, so the nanosecond
|
||||
/// boundary count IS the exact elapsed time and the integer division gives start-inclusive /
|
||||
/// end-exclusive buckets to the tick. (Range limit: ≈292 years per window.)
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Sample classes</b> (status stored bit-for-bit in an <c>int</c>, so the severity bits are the sign
|
||||
/// and bit 30): <i>usable</i> = severity Good or Uncertain (<c>StatusCode >= 0</c>) AND a stored value
|
||||
/// (<c>ValueKind <> 0</c>) — an <c>Empty</c> sample is an outage marker and counts as Bad whatever its
|
||||
/// code; <i>good</i> = usable with severity Good (<c>StatusCode < 0x40000000</c>); <i>bad</i> = not
|
||||
/// usable. Every value column below is computed over usable samples only.
|
||||
/// </para>
|
||||
/// Common result columns (ordinals 0–5): <c>Bucket, TotalCount, UsableCount, GoodCount, BadCount,
|
||||
/// NonNumericCount</c> (usable String/DateTime samples). Family columns follow.
|
||||
/// </remarks>
|
||||
internal static class SqlAggregateQueries
|
||||
{
|
||||
/// <summary>Ordinal of the first family-specific column.</summary>
|
||||
public const int FirstFamilyColumn = 6;
|
||||
|
||||
/// <summary><c>ValueKind</c> of String (4) and DateTime (5): usable but not numeric.</summary>
|
||||
private const string NonNumericKinds = "4, 5";
|
||||
|
||||
/// <summary>The family that serves <paramref name="aggregate"/>, or null when this backend does not.</summary>
|
||||
/// <param name="aggregate">Requested aggregate.</param>
|
||||
/// <returns>The family, or null for an unsupported aggregate.</returns>
|
||||
public static SqlAggregateFamily? FamilyOf(HistoryAggregateType aggregate) => aggregate switch
|
||||
{
|
||||
HistoryAggregateType.Average or HistoryAggregateType.Minimum or HistoryAggregateType.Maximum
|
||||
or HistoryAggregateType.Total or HistoryAggregateType.Count or HistoryAggregateType.Range
|
||||
or HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad => SqlAggregateFamily.Simple,
|
||||
HistoryAggregateType.Start or HistoryAggregateType.End or HistoryAggregateType.Delta => SqlAggregateFamily.StartEnd,
|
||||
HistoryAggregateType.MinimumActualTime or HistoryAggregateType.MaximumActualTime => SqlAggregateFamily.ActualTime,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Average / Minimum / Maximum / Total / Count / Range / PercentGood / PercentBad.
|
||||
/// Family columns: <c>6 AvgValue, 7 MinValue, 8 MaxValue, 9 SumValue</c> (float, NULL when no usable
|
||||
/// numeric sample).
|
||||
/// </summary>
|
||||
/// <param name="sample">Schema-qualified <c>Sample</c> table name.</param>
|
||||
/// <returns>The SQL text.</returns>
|
||||
public static string Simple(string sample) =>
|
||||
Classified(sample) +
|
||||
"SELECT b, " + CommonCounts() + ", " +
|
||||
"AVG(CASE WHEN u = 1 THEN NumValue END) AS AvgValue, " +
|
||||
"MIN(CASE WHEN u = 1 THEN NumValue END) AS MinValue, " +
|
||||
"MAX(CASE WHEN u = 1 THEN NumValue END) AS MaxValue, " +
|
||||
"SUM(CASE WHEN u = 1 THEN NumValue END) AS SumValue " +
|
||||
"FROM s GROUP BY b ORDER BY b;";
|
||||
|
||||
/// <summary>
|
||||
/// Start / End / Delta: the first and last usable sample of each bucket by <c>SourceTimeUtc</c>.
|
||||
/// Family columns: <c>6..10</c> first sample (<c>ValueKind, NumValue, IntValue, StrValue, SourceTimeUtc</c>),
|
||||
/// <c>11..15</c> last sample (same order); all NULL when the bucket has no usable sample.
|
||||
/// </summary>
|
||||
/// <param name="sample">Schema-qualified <c>Sample</c> table name.</param>
|
||||
/// <returns>The SQL text.</returns>
|
||||
public static string StartEnd(string sample) =>
|
||||
Classified(sample) +
|
||||
", st AS (SELECT b, " + CommonCounts() + " FROM s GROUP BY b)" +
|
||||
", f AS (SELECT b, ValueKind, NumValue, IntValue, StrValue, SourceTimeUtc, " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY b ORDER BY SourceTimeUtc ASC) AS rf, " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY b ORDER BY SourceTimeUtc DESC) AS rl FROM s WHERE u = 1) " +
|
||||
"SELECT st.*, " +
|
||||
"x.ValueKind, x.NumValue, x.IntValue, x.StrValue, x.SourceTimeUtc, " +
|
||||
"y.ValueKind, y.NumValue, y.IntValue, y.StrValue, y.SourceTimeUtc " +
|
||||
"FROM st LEFT JOIN f x ON x.b = st.b AND x.rf = 1 LEFT JOIN f y ON y.b = st.b AND y.rl = 1 ORDER BY st.b;";
|
||||
|
||||
/// <summary>
|
||||
/// MinimumActualTime / MaximumActualTime: the usable sample with the smallest (largest) <c>NumValue</c>,
|
||||
/// ties broken by the earliest <c>SourceTimeUtc</c>. Family columns: <c>6 NumValue, 7 SourceTimeUtc</c>
|
||||
/// (NULL when the bucket has no usable numeric sample).
|
||||
/// </summary>
|
||||
/// <param name="sample">Schema-qualified <c>Sample</c> table name.</param>
|
||||
/// <param name="maximum">True for MaximumActualTime.</param>
|
||||
/// <returns>The SQL text.</returns>
|
||||
public static string ActualTime(string sample, bool maximum) =>
|
||||
Classified(sample) +
|
||||
", st AS (SELECT b, " + CommonCounts() + " FROM s GROUP BY b)" +
|
||||
", r AS (SELECT b, NumValue, SourceTimeUtc, " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY b ORDER BY NumValue " + (maximum ? "DESC" : "ASC") + ", SourceTimeUtc ASC) AS rn " +
|
||||
"FROM s WHERE u = 1 AND NumValue IS NOT NULL) " +
|
||||
"SELECT st.*, r.NumValue, r.SourceTimeUtc " +
|
||||
"FROM st LEFT JOIN r ON r.b = st.b AND r.rn = 1 ORDER BY st.b;";
|
||||
|
||||
/// <summary>The <c>s</c> CTE: the window's samples with bucket index and the usable/good flags.</summary>
|
||||
private static string Classified(string sample) =>
|
||||
"WITH s AS (SELECT DATEDIFF_BIG(nanosecond, @start, SourceTimeUtc) / @intervalNs AS b, " +
|
||||
"SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, " +
|
||||
"CASE WHEN StatusCode >= 0 AND ValueKind <> 0 THEN 1 ELSE 0 END AS u, " +
|
||||
"CASE WHEN StatusCode >= 0 AND StatusCode < 1073741824 AND ValueKind <> 0 THEN 1 ELSE 0 END AS g " +
|
||||
$"FROM {sample} WHERE TagId = @id AND SourceTimeUtc >= @start AND SourceTimeUtc < @end) ";
|
||||
|
||||
/// <summary>The five common count columns (after <c>b</c>).</summary>
|
||||
private static string CommonCounts() =>
|
||||
"COUNT(*) AS TotalCount, SUM(u) AS UsableCount, SUM(g) AS GoodCount, COUNT(*) - SUM(u) AS BadCount, " +
|
||||
$"SUM(CASE WHEN u = 1 AND ValueKind IN ({NonNumericKinds}) THEN 1 ELSE 0 END) AS NonNumericCount";
|
||||
}
|
||||
+556
@@ -0,0 +1,556 @@
|
||||
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>
|
||||
/// SQL Server read side of the historian (spec §4 "Reads"): Raw (forward / reverse / point), AtTime
|
||||
/// (one statement, <c>TimeRow</c> TVP + two <c>OUTER APPLY</c>s), Events, and Processed aggregates pushed
|
||||
/// down to T-SQL (<see cref="SqlAggregateQueries"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Reads never create tags.</b> Tag names resolve through the shared <see cref="SqlServerTagCatalog"/>'s
|
||||
/// read-only lookup (positive results cached); an unknown tag is a successful empty read (Raw), an
|
||||
/// all-<c>BadNoData</c> read (AtTime / Processed), never an error.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Faults.</b> A SQL / bootstrap exception is recorded in <see cref="GetHealthSnapshot"/> (so a missing
|
||||
/// schema with <c>AutoCreateSchema=false</c> surfaces in <c>LastError</c>) and rethrown; the node manager
|
||||
/// turns it into a per-node Bad. Client-request errors — an unsupported aggregate
|
||||
/// (<see cref="HistoryAggregateNotSupportedException"/> ⇒ <c>BadAggregateNotSupported</c>) and a processed
|
||||
/// window over <see cref="MaxProcessedBuckets"/> intervals (<see cref="ArgumentOutOfRangeException"/>, which
|
||||
/// the node manager's generic catch surfaces as <c>BadHistoryOperationUnsupported</c> for that node only)
|
||||
/// — are thrown before any I/O and are not counted as backend failures.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Bad samples.</b> A stored sample is <i>usable</i> when its status severity is Good or Uncertain and it
|
||||
/// carries a value; an <c>Empty</c>-kind sample is an outage marker (comms-loss <c>BadNoCommunication</c>
|
||||
/// or a driver's Bad publish) and is Bad whatever its code. Raw returns every sample as stored, outage
|
||||
/// markers included. Aggregates exclude Bad samples from the math, report the interval Uncertain when
|
||||
/// any non-Good sample is present, and count them in PercentBad. AtTime never interpolates across a Bad
|
||||
/// neighbour (see <see cref="ReadAtTimeAsync"/>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
{
|
||||
/// <summary>Spec §9.7: a processed read covering more intervals than this is refused.</summary>
|
||||
public const int MaxProcessedBuckets = 100_000;
|
||||
|
||||
private const string SampleColumns = "SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, ServerTimeUtc";
|
||||
|
||||
private readonly SqlHistorianConnection _conn;
|
||||
private readonly SqlServerSchemaBootstrapper _bootstrap;
|
||||
private readonly SqlServerTagCatalog _catalog;
|
||||
private readonly ILogger _log;
|
||||
|
||||
private readonly object _healthLock = new();
|
||||
private DateTime? _lastSuccessUtc;
|
||||
private DateTime? _lastFailureUtc;
|
||||
private string? _lastError;
|
||||
private long _totalQueries;
|
||||
private long _totalSuccesses;
|
||||
private long _totalFailures;
|
||||
private int _consecutiveFailures;
|
||||
|
||||
/// <summary>Creates the data source over the provider's shared connection, bootstrapper and tag catalog.</summary>
|
||||
/// <param name="conn">Connection helper.</param>
|
||||
/// <param name="bootstrap">Schema bootstrapper (shared with the other SQL seams).</param>
|
||||
/// <param name="catalog">Tag catalog (shared; reads use only its read-only lookup).</param>
|
||||
/// <param name="log">Optional logger.</param>
|
||||
internal SqlServerHistorianDataSource(
|
||||
SqlHistorianConnection conn, SqlServerSchemaBootstrapper bootstrap, SqlServerTagCatalog catalog, ILogger? log = null)
|
||||
{
|
||||
_conn = conn ?? throw new ArgumentNullException(nameof(conn));
|
||||
_bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap));
|
||||
_catalog = catalog ?? throw new ArgumentNullException(nameof(catalog));
|
||||
_log = log ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------ Raw
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Forward (<c>start < end</c>): <c>[start, end)</c> ascending. Reverse (<c>end < start</c>, Part 11):
|
||||
/// <c>(end, start]</c> descending. <c>start == end</c>: the sample exactly at that time (Part 11's point
|
||||
/// read; it is also what the node manager's tie-cluster over-fetch asks for). <paramref name="maxValuesPerNode"/>
|
||||
/// <c>0</c> is the node manager's "unbounded" sentinel; values above <see cref="int.MaxValue"/> clamp.
|
||||
/// The node manager pages by re-reading from the last returned timestamp inclusive and trimming it; the
|
||||
/// <c>(TagId, SourceTimeUtc)</c> primary key rules out tie clusters, so that is exact.
|
||||
/// </remarks>
|
||||
public async Task<HistoryReadResult> ReadRawAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReference);
|
||||
var top = maxValuesPerNode == 0 ? int.MaxValue : (int)Math.Min(maxValuesPerNode, int.MaxValue);
|
||||
try
|
||||
{
|
||||
var tagId = await ResolveAsync(fullReference, cancellationToken).ConfigureAwait(false);
|
||||
var samples = new List<DataValueSnapshot>();
|
||||
if (tagId is { } id)
|
||||
{
|
||||
string where, order;
|
||||
if (startUtc == endUtc) { where = "SourceTimeUtc = @lo"; order = "ASC"; }
|
||||
else if (startUtc < endUtc) { where = "SourceTimeUtc >= @lo AND SourceTimeUtc < @hi"; order = "ASC"; }
|
||||
else { where = "SourceTimeUtc > @lo AND SourceTimeUtc <= @hi"; order = "DESC"; }
|
||||
var (lo, hi) = startUtc <= endUtc ? (startUtc, endUtc) : (endUtc, startUtc);
|
||||
|
||||
await using var conn = await _conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = _conn.CreateCommand(conn,
|
||||
$"SELECT TOP (@top) {SampleColumns} FROM {_conn.Q("Sample")} " +
|
||||
$"WHERE TagId = @id AND {where} ORDER BY SourceTimeUtc {order}");
|
||||
cmd.Parameters.Add(new SqlParameter("@top", SqlDbType.Int) { Value = top });
|
||||
cmd.Parameters.Add(new SqlParameter("@id", SqlDbType.Int) { Value = id });
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@lo", lo);
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@hi", hi);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var s = ReadStored(reader, 0);
|
||||
samples.Add(new DataValueSnapshot(s.Value.ToObject(), s.Status, s.TimeUtc,
|
||||
SqlHistorianConnection.GetUtc(reader, 6)));
|
||||
}
|
||||
}
|
||||
|
||||
RecordSuccess();
|
||||
return new HistoryReadResult(samples, ContinuationPoint: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RecordFailure(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------ AtTime
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// One statement: the distinct requested times go in a <c>TimeRow</c> TVP and each picks up its
|
||||
/// previous-or-equal (<c>p</c>) and next (<c>n</c>) stored sample via two <c>OUTER APPLY (SELECT TOP 1 …)</c>.
|
||||
/// Rules, per requested time <c>T</c> (the result is stamped <c>T</c>):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>p</c> at exactly <c>T</c> ⇒ the stored value and status, unchanged.</item>
|
||||
/// <item>No <c>p</c> ⇒ <c>BadNoData</c>, null value.</item>
|
||||
/// <item><c>p</c> or <c>n</c> Bad (incl. an <c>Empty</c> outage marker) ⇒ <c>UncertainDataSubNormal</c> +
|
||||
/// Interpolated bits, and NO interpolation across it: the value is <c>p</c>'s held value when <c>p</c> is
|
||||
/// usable (data held up to the outage), else null (inside an outage).</item>
|
||||
/// <item>Double/Int64 with a usable <c>n</c> ⇒ linear interpolation of <c>NumValue</c> (Int64 rounded to the
|
||||
/// nearest integer, ties away from zero, keeping the type); status <c>Good</c> + Interpolated when both
|
||||
/// neighbours are Good, else <c>UncertainDataSubNormal</c> + Interpolated (never better than the
|
||||
/// inputs).</item>
|
||||
/// <item>Boolean/String/DateTime, or no <c>n</c> (after the last sample) ⇒ stepped: <c>p</c>'s value with
|
||||
/// <c>p</c>'s status code + Interpolated bits.</item>
|
||||
/// </list>
|
||||
/// Unknown tag ⇒ every entry <c>BadNoData</c>.
|
||||
/// </remarks>
|
||||
public async Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReference);
|
||||
ArgumentNullException.ThrowIfNull(timestampsUtc);
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var byTicks = new Dictionary<long, DataValueSnapshot>(timestampsUtc.Count);
|
||||
var tagId = timestampsUtc.Count == 0 ? null : await ResolveAsync(fullReference, cancellationToken).ConfigureAwait(false);
|
||||
if (tagId is { } id)
|
||||
{
|
||||
var distinct = timestampsUtc.Select(t => DateTime.SpecifyKind(t, DateTimeKind.Utc)).Distinct().ToList();
|
||||
var cols = "s.SourceTimeUtc, s.ValueKind, s.NumValue, s.IntValue, s.StrValue, s.StatusCode, s.ServerTimeUtc";
|
||||
var sql =
|
||||
$"SELECT r.T, p.*, n.* FROM @times r " +
|
||||
$"OUTER APPLY (SELECT TOP 1 {cols} FROM {_conn.Q("Sample")} s WHERE s.TagId = @id AND s.SourceTimeUtc <= r.T ORDER BY s.SourceTimeUtc DESC) p " +
|
||||
$"OUTER APPLY (SELECT TOP 1 {cols} FROM {_conn.Q("Sample")} s WHERE s.TagId = @id AND s.SourceTimeUtc > r.T ORDER BY s.SourceTimeUtc ASC) n";
|
||||
await using var conn = await _conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = _conn.CreateCommand(conn, sql);
|
||||
cmd.Parameters.Add(new SqlParameter("@id", SqlDbType.Int) { Value = id });
|
||||
_conn.AddStructured(cmd, "@times", "TimeRow", SampleRowMapper.ToTimeRecords(distinct));
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var t = SqlHistorianConnection.GetUtc(reader, 0);
|
||||
StoredSample? p = reader.IsDBNull(1) ? null : ReadStored(reader, 1);
|
||||
StoredSample? n = reader.IsDBNull(8) ? null : ReadStored(reader, 8);
|
||||
var serverTime = p is { } ps && ps.TimeUtc == t ? SqlHistorianConnection.GetUtc(reader, 7) : now;
|
||||
byTicks[t.Ticks] = AtTimeValue(t, p, n, serverTime);
|
||||
}
|
||||
}
|
||||
|
||||
var result = new DataValueSnapshot[timestampsUtc.Count];
|
||||
for (var i = 0; i < result.Length; i++)
|
||||
{
|
||||
var t = DateTime.SpecifyKind(timestampsUtc[i], DateTimeKind.Utc);
|
||||
result[i] = byTicks.TryGetValue(t.Ticks, out var v)
|
||||
? v
|
||||
: new DataValueSnapshot(null, SqlHistorianStatusCodes.BadNoData, t, now);
|
||||
}
|
||||
|
||||
RecordSuccess();
|
||||
return new HistoryReadResult(result, ContinuationPoint: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RecordFailure(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Applies the AtTime rules (see <see cref="ReadAtTimeAsync"/>) to one requested time.</summary>
|
||||
private static DataValueSnapshot AtTimeValue(DateTime t, StoredSample? prev, StoredSample? next, DateTime serverTime)
|
||||
{
|
||||
if (prev is not { } p)
|
||||
return new DataValueSnapshot(null, SqlHistorianStatusCodes.BadNoData, t, serverTime);
|
||||
if (p.TimeUtc == t)
|
||||
return new DataValueSnapshot(p.Value.ToObject(), p.Status, t, serverTime);
|
||||
|
||||
if (!p.IsUsable || next is { IsUsable: false })
|
||||
return new DataValueSnapshot(p.IsUsable ? p.Value.ToObject() : null,
|
||||
HistorianInfoBits.WithInterpolated(SqlHistorianStatusCodes.UncertainDataSubNormal), t, serverTime);
|
||||
|
||||
if (next is { } n && p.Value.Kind is HistorianValueKind.Double or HistorianValueKind.Int64
|
||||
&& n.Value.TryGetDouble(out var nv))
|
||||
{
|
||||
var fraction = (double)(t.Ticks - p.TimeUtc.Ticks) / (n.TimeUtc.Ticks - p.TimeUtc.Ticks);
|
||||
var v = p.Value.Number + (nv - p.Value.Number) * fraction;
|
||||
object value = p.Value.Kind == HistorianValueKind.Int64
|
||||
? (long)Math.Round(v, MidpointRounding.AwayFromZero)
|
||||
: v;
|
||||
var status = p.IsGood && n.IsGood ? SqlHistorianStatusCodes.Good : SqlHistorianStatusCodes.UncertainDataSubNormal;
|
||||
return new DataValueSnapshot(value, HistorianInfoBits.WithInterpolated(status), t, serverTime);
|
||||
}
|
||||
|
||||
// Stepped (non-numeric kind, or after the last sample): hold the previous value and its status.
|
||||
return new DataValueSnapshot(p.Value.ToObject(), HistorianInfoBits.WithInterpolated(p.Status), t, serverTime);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------ Events
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <c>[start, end)</c> by <c>EventTimeUtc</c> (a reversed window is swapped), ordered by
|
||||
/// <c>(EventTimeUtc, EventId)</c> ascending — the clustered index. <paramref name="maxEvents"/> <= 0 or
|
||||
/// <see cref="int.MaxValue"/> (the node manager's "no limit") ⇒ unbounded. Otherwise <c>maxEvents + 1</c>
|
||||
/// rows are fetched; when the extra row arrives it is dropped and the continuation point is the UTF-8
|
||||
/// of <c>"{EventTimeUtc.Ticks}|{EventId}"</c> of that first not-returned event (opaque to callers; only
|
||||
/// its presence is contractual).
|
||||
/// </remarks>
|
||||
public async Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _bootstrap.EnsureAsync(cancellationToken).ConfigureAwait(false);
|
||||
var (lo, hi) = startUtc <= endUtc ? (startUtc, endUtc) : (endUtc, startUtc);
|
||||
var bounded = maxEvents > 0 && maxEvents < int.MaxValue;
|
||||
var sql =
|
||||
$"SELECT {(bounded ? "TOP (@take) " : "")}EventId, SourceName, EventTimeUtc, ReceivedTimeUtc, Message, Severity " +
|
||||
$"FROM {_conn.Q("Event")} WHERE EventTimeUtc >= @lo AND EventTimeUtc < @hi" +
|
||||
(sourceName is null ? "" : " AND SourceName = @source") +
|
||||
" ORDER BY EventTimeUtc ASC, EventId ASC";
|
||||
|
||||
await using var conn = await _conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = _conn.CreateCommand(conn, sql);
|
||||
if (bounded) cmd.Parameters.Add(new SqlParameter("@take", SqlDbType.BigInt) { Value = (long)maxEvents + 1 });
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@lo", lo);
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@hi", hi);
|
||||
if (sourceName is not null)
|
||||
cmd.Parameters.Add(new SqlParameter("@source", SqlDbType.NVarChar, 400) { Value = sourceName });
|
||||
|
||||
var events = new List<HistoricalEvent>();
|
||||
byte[]? continuation = null;
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var e = new HistoricalEvent(
|
||||
reader.GetString(0),
|
||||
reader.GetString(1),
|
||||
SqlHistorianConnection.GetUtc(reader, 2),
|
||||
SqlHistorianConnection.GetUtc(reader, 3),
|
||||
reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
(ushort)Math.Clamp(reader.GetInt32(5), 0, ushort.MaxValue));
|
||||
if (bounded && events.Count == maxEvents)
|
||||
{
|
||||
continuation = Encoding.UTF8.GetBytes($"{e.EventTimeUtc.Ticks}|{e.EventId}");
|
||||
break;
|
||||
}
|
||||
events.Add(e);
|
||||
}
|
||||
|
||||
RecordSuccess();
|
||||
return new HistoricalEventsResult(events, continuation);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RecordFailure(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------ Processed
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One result per interval of <c>[start, end)</c>, bucket <c>i</c> = <c>[start + i·interval,
|
||||
/// start + (i+1)·interval)</c> (start-inclusive, end-exclusive to the tick; the last bucket is truncated at
|
||||
/// <c>end</c>). <paramref name="interval"/> <= 0 ⇒ one interval <c>[start, end)</c>. <c>start == end</c>
|
||||
/// ⇒ no intervals. <c>end < start</c> (reverse) ⇒ the window is swapped, computed forward, and returned
|
||||
/// in descending bucket order (buckets stay anchored at the earlier time).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per interval: no samples ⇒ <c>BadNoData</c>. PercentGood / PercentBad ⇒ <c>100 × good|bad / all</c> over
|
||||
/// every sample, status Good. Otherwise no usable sample ⇒ <c>BadNoData</c>; a numeric aggregate with a usable
|
||||
/// String/DateTime sample ⇒ <c>BadAggregateInvalidInputs</c>; else the value, Good when every sample is Good,
|
||||
/// <c>UncertainDataSubNormal</c> when any is Bad or Uncertain. Every status — including <c>BadNoData</c> —
|
||||
/// carries the Calculated info bits. Timestamps are the bucket start, except Minimum/MaximumActualTime
|
||||
/// (the sample's time; ties ⇒ earliest).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Values: Count is <see cref="int"/>; Start/End keep the sample's type; every other aggregate is
|
||||
/// <see cref="double"/> computed over <c>NumValue</c> (Boolean = 0/1). These are the simple per-sample
|
||||
/// definitions, not Part 13's interval-boundary (interpolated bound / time-weighted) semantics.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="HistoryAggregateNotSupportedException">The aggregate is not one of the thirteen served.</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">The window holds more than <see cref="MaxProcessedBuckets"/> intervals.</exception>
|
||||
public async Task<HistoryReadResult> ReadProcessedAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||
HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReference);
|
||||
var family = SqlAggregateQueries.FamilyOf(aggregate) ?? throw new HistoryAggregateNotSupportedException(aggregate);
|
||||
|
||||
var reverse = endUtc < startUtc;
|
||||
var (start, end) = reverse ? (endUtc, startUtc) : (startUtc, endUtc);
|
||||
var windowTicks = end.Ticks - start.Ticks;
|
||||
var intervalTicks = interval > TimeSpan.Zero ? interval.Ticks : windowTicks;
|
||||
long bucketCount = windowTicks == 0 ? 0 : (windowTicks + intervalTicks - 1) / intervalTicks;
|
||||
if (bucketCount > MaxProcessedBuckets)
|
||||
throw new ArgumentOutOfRangeException(nameof(interval), bucketCount,
|
||||
$"A processed read may cover at most {MaxProcessedBuckets} intervals; this window holds {bucketCount}.");
|
||||
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var rows = new Dictionary<long, BucketRow>();
|
||||
var tagId = bucketCount == 0 ? null : await ResolveAsync(fullReference, cancellationToken).ConfigureAwait(false);
|
||||
if (tagId is { } id)
|
||||
{
|
||||
var sample = _conn.Q("Sample");
|
||||
var sql = family switch
|
||||
{
|
||||
SqlAggregateFamily.Simple => SqlAggregateQueries.Simple(sample),
|
||||
SqlAggregateFamily.StartEnd => SqlAggregateQueries.StartEnd(sample),
|
||||
_ => SqlAggregateQueries.ActualTime(sample, aggregate == HistoryAggregateType.MaximumActualTime),
|
||||
};
|
||||
await using var conn = await _conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = _conn.CreateCommand(conn, sql);
|
||||
cmd.Parameters.Add(new SqlParameter("@id", SqlDbType.Int) { Value = id });
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@start", DateTime.SpecifyKind(start, DateTimeKind.Utc));
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@end", DateTime.SpecifyKind(end, DateTimeKind.Utc));
|
||||
cmd.Parameters.Add(new SqlParameter("@intervalNs", SqlDbType.BigInt) { Value = intervalTicks * 100L });
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var row = ReadBucket(reader, family);
|
||||
rows[row.Bucket] = row;
|
||||
}
|
||||
}
|
||||
|
||||
var result = new List<DataValueSnapshot>((int)bucketCount);
|
||||
for (long i = 0; i < bucketCount; i++)
|
||||
{
|
||||
var bucketStart = DateTime.SpecifyKind(new DateTime(start.Ticks + i * intervalTicks), DateTimeKind.Utc);
|
||||
result.Add(rows.TryGetValue(i, out var row)
|
||||
? Evaluate(aggregate, row, bucketStart, now)
|
||||
: NoData(bucketStart, now));
|
||||
}
|
||||
if (reverse) result.Reverse();
|
||||
|
||||
RecordSuccess();
|
||||
return new HistoryReadResult(result, ContinuationPoint: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RecordFailure(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static DataValueSnapshot NoData(DateTime t, DateTime now) =>
|
||||
new(null, HistorianInfoBits.WithCalculated(SqlHistorianStatusCodes.BadNoData), t, now);
|
||||
|
||||
private static DataValueSnapshot Evaluate(HistoryAggregateType aggregate, BucketRow r, DateTime bucketStart, DateTime now)
|
||||
{
|
||||
DataValueSnapshot Calc(object? value, uint status, DateTime? t = null) =>
|
||||
new(value, HistorianInfoBits.WithCalculated(status), t ?? bucketStart, now);
|
||||
|
||||
if (r.Total == 0) return NoData(bucketStart, now);
|
||||
switch (aggregate)
|
||||
{
|
||||
case HistoryAggregateType.PercentGood: return Calc(100d * r.Good / r.Total, SqlHistorianStatusCodes.Good);
|
||||
case HistoryAggregateType.PercentBad: return Calc(100d * r.Bad / r.Total, SqlHistorianStatusCodes.Good);
|
||||
}
|
||||
if (r.Usable == 0) return NoData(bucketStart, now);
|
||||
|
||||
var anyText = aggregate is HistoryAggregateType.Count or HistoryAggregateType.Start or HistoryAggregateType.End;
|
||||
if (!anyText && r.NonNumeric > 0)
|
||||
return Calc(null, SqlHistorianStatusCodes.BadAggregateInvalidInputs);
|
||||
|
||||
var status = r.Good == r.Total ? SqlHistorianStatusCodes.Good : SqlHistorianStatusCodes.UncertainDataSubNormal;
|
||||
switch (aggregate)
|
||||
{
|
||||
case HistoryAggregateType.Count: return Calc(r.Usable, status);
|
||||
case HistoryAggregateType.Average: return Calc(r.Avg, status);
|
||||
case HistoryAggregateType.Minimum: return Calc(r.Min, status);
|
||||
case HistoryAggregateType.Maximum: return Calc(r.Max, status);
|
||||
case HistoryAggregateType.Total: return Calc(r.Sum, status);
|
||||
case HistoryAggregateType.Range: return Calc(r.Max - r.Min, status);
|
||||
case HistoryAggregateType.Start: return Calc(r.First.ToObject(), status);
|
||||
case HistoryAggregateType.End: return Calc(r.Last.ToObject(), status);
|
||||
case HistoryAggregateType.Delta:
|
||||
return r.First.TryGetDouble(out var a) && r.Last.TryGetDouble(out var b)
|
||||
? Calc(b - a, status)
|
||||
: Calc(null, SqlHistorianStatusCodes.BadAggregateInvalidInputs);
|
||||
case HistoryAggregateType.MinimumActualTime:
|
||||
case HistoryAggregateType.MaximumActualTime:
|
||||
return Calc(r.Extreme, status, r.ExtremeTime);
|
||||
default:
|
||||
throw new HistoryAggregateNotSupportedException(aggregate);
|
||||
}
|
||||
}
|
||||
|
||||
private static BucketRow ReadBucket(SqlDataReader reader, SqlAggregateFamily family)
|
||||
{
|
||||
static double? D(SqlDataReader r, int i) => r.IsDBNull(i) ? null : r.GetDouble(i);
|
||||
static long? L(SqlDataReader r, int i) => r.IsDBNull(i) ? null : r.GetInt64(i);
|
||||
static string? S(SqlDataReader r, int i) => r.IsDBNull(i) ? null : r.GetString(i);
|
||||
static HistorianValue V(SqlDataReader r, int i) =>
|
||||
r.IsDBNull(i) ? HistorianValue.Empty : SampleRowMapper.FromColumns(r.GetByte(i), D(r, i + 1), L(r, i + 2), S(r, i + 3));
|
||||
|
||||
var row = new BucketRow
|
||||
{
|
||||
Bucket = reader.GetInt64(0),
|
||||
Total = reader.GetInt32(1),
|
||||
Usable = reader.GetInt32(2),
|
||||
Good = reader.GetInt32(3),
|
||||
Bad = reader.GetInt32(4),
|
||||
NonNumeric = reader.GetInt32(5),
|
||||
};
|
||||
var f = SqlAggregateQueries.FirstFamilyColumn;
|
||||
switch (family)
|
||||
{
|
||||
case SqlAggregateFamily.Simple:
|
||||
row.Avg = D(reader, f);
|
||||
row.Min = D(reader, f + 1);
|
||||
row.Max = D(reader, f + 2);
|
||||
row.Sum = D(reader, f + 3);
|
||||
break;
|
||||
case SqlAggregateFamily.StartEnd:
|
||||
row.First = V(reader, f);
|
||||
row.Last = V(reader, f + 5);
|
||||
break;
|
||||
default:
|
||||
row.Extreme = D(reader, f);
|
||||
row.ExtremeTime = reader.IsDBNull(f + 1) ? null : SqlHistorianConnection.GetUtc(reader, f + 1);
|
||||
break;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/// <summary>One aggregate query row (one non-empty bucket).</summary>
|
||||
private sealed class BucketRow
|
||||
{
|
||||
public long Bucket;
|
||||
public int Total, Usable, Good, Bad, NonNumeric;
|
||||
public double? Avg, Min, Max, Sum, Extreme;
|
||||
public DateTime? ExtremeTime;
|
||||
public HistorianValue First, Last;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------ Shared
|
||||
|
||||
/// <summary>A stored sample read from the <c>{SampleColumns}</c> column run starting at an ordinal.</summary>
|
||||
private readonly record struct StoredSample(DateTime TimeUtc, HistorianValue Value, uint Status)
|
||||
{
|
||||
/// <summary>Severity Good or Uncertain, and a value present (an Empty sample is an outage marker).</summary>
|
||||
public bool IsUsable => (Status & 0x80000000u) == 0 && Value.Kind != HistorianValueKind.Empty;
|
||||
|
||||
/// <summary>Usable with severity Good.</summary>
|
||||
public bool IsGood => IsUsable && (Status & 0xC0000000u) == 0;
|
||||
}
|
||||
|
||||
/// <summary>Reads <c>SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode</c> starting at <paramref name="o"/>.</summary>
|
||||
private static StoredSample ReadStored(SqlDataReader r, int o) => new(
|
||||
SqlHistorianConnection.GetUtc(r, o),
|
||||
SampleRowMapper.FromColumns(
|
||||
r.GetByte(o + 1),
|
||||
r.IsDBNull(o + 2) ? null : r.GetDouble(o + 2),
|
||||
r.IsDBNull(o + 3) ? null : r.GetInt64(o + 3),
|
||||
r.IsDBNull(o + 4) ? null : r.GetString(o + 4)),
|
||||
SampleRowMapper.FromDbStatus(r.GetInt32(o + 5)));
|
||||
|
||||
/// <summary>Read-only tag lookup (never creates a row); runs the schema bootstrap on a cache miss.</summary>
|
||||
private Task<int?> ResolveAsync(string name, CancellationToken ct) => _catalog.TryGetTagIdAsync(name, ct);
|
||||
|
||||
// ------------------------------------------------------------------------------------------ Health
|
||||
|
||||
/// <inheritdoc />
|
||||
public HistorianHealthSnapshot GetHealthSnapshot()
|
||||
{
|
||||
lock (_healthLock)
|
||||
{
|
||||
var open = _totalSuccesses > 0 && _consecutiveFailures == 0;
|
||||
return new HistorianHealthSnapshot(
|
||||
TotalQueries: _totalQueries,
|
||||
TotalSuccesses: _totalSuccesses,
|
||||
TotalFailures: _totalFailures,
|
||||
ConsecutiveFailures: _consecutiveFailures,
|
||||
LastSuccessTime: _lastSuccessUtc,
|
||||
LastFailureTime: _lastFailureUtc,
|
||||
LastError: _lastError,
|
||||
ProcessConnectionOpen: open,
|
||||
EventConnectionOpen: open,
|
||||
ActiveProcessNode: null,
|
||||
ActiveEventNode: null,
|
||||
Nodes: []);
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordSuccess()
|
||||
{
|
||||
lock (_healthLock)
|
||||
{
|
||||
_totalQueries++;
|
||||
_totalSuccesses++;
|
||||
_consecutiveFailures = 0;
|
||||
_lastSuccessUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordFailure(Exception ex)
|
||||
{
|
||||
// A cancelled read (deadline / client abort) is not a backend fault.
|
||||
if (ex is OperationCanceledException) return;
|
||||
lock (_healthLock)
|
||||
{
|
||||
_totalQueries++;
|
||||
_totalFailures++;
|
||||
_consecutiveFailures++;
|
||||
_lastFailureUtc = DateTime.UtcNow;
|
||||
_lastError = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
_log.LogDebug("SQL historian read failed ({ExceptionType})", ex.GetType().Name);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
// Connections are per call (pooled by SqlClient); nothing to release.
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
|
||||
|
||||
/// <summary>AtTime rules (spec §4): exact, linear, stepped, no-prior, Bad neighbour (no bridging).</summary>
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class AtTimeReadTests(SqlHistorianFixture fx)
|
||||
{
|
||||
private static readonly CancellationToken Ct = CancellationToken.None;
|
||||
private static DateTime At(double s) => ReadTestDb.At(s);
|
||||
|
||||
// Hand-computed status literals: Interpolated = InfoType DataValue 0x400 | Interpolated 0x2.
|
||||
private const uint GoodInterpolated = 0x00000402u;
|
||||
private const uint UncertainInterpolated = 0x40A40402u; // UncertainDataSubNormal | 0x402
|
||||
private const uint BadNoData = 0x809B0000u;
|
||||
|
||||
[Fact]
|
||||
public async Task Interpolated_bits_are_the_SDK_AggregateBits_Interpolated()
|
||||
{
|
||||
var code = new Opc.Ua.StatusCode(GoodInterpolated);
|
||||
code.AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Interpolated);
|
||||
Opc.Ua.StatusCode.IsGood(code).ShouldBeTrue();
|
||||
new Opc.Ua.StatusCode(UncertainInterpolated).CodeBits.ShouldBe(Opc.Ua.StatusCodes.UncertainDataSubNormal);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exact_hit_returns_the_stored_value_and_status()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
var s = (await db.Source().ReadAtTimeAsync("A", [At(10), At(20)], Ct)).Samples;
|
||||
s[0].Value.ShouldBe(20d);
|
||||
s[0].StatusCode.ShouldBe(0u);
|
||||
s[0].SourceTimestampUtc.ShouldBe(At(10));
|
||||
s[0].ServerTimestampUtc.ShouldBe(At(11)); // the stored ServerTimeUtc
|
||||
s[1].Value.ShouldBe(30d); // a stored Bad sample comes back as stored
|
||||
s[1].StatusCode.ShouldBe(0x80000000u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Between_two_good_doubles_is_linearly_interpolated_good()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
var s = (await db.Source().ReadAtTimeAsync("A", [At(5), At(2.5), At(32.5)], Ct)).Samples;
|
||||
s[0].Value.ShouldBe(15d); // 10 + (20-10) * 5/10
|
||||
s[1].Value.ShouldBe(12.5d); // 10 + 10 * 2.5/10
|
||||
s[2].Value.ShouldBe(42.5d); // 40 + 10 * 2.5/10
|
||||
s.ShouldAllBe(x => x.StatusCode == GoodInterpolated);
|
||||
s[0].SourceTimestampUtc.ShouldBe(At(5)); // stamped at the requested (sub-ms) time
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task No_previous_sample_is_BadNoData()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
var s = (await db.Source().ReadAtTimeAsync("A", [At(0).AddTicks(-1)], Ct)).Samples.Single();
|
||||
s.Value.ShouldBeNull();
|
||||
s.StatusCode.ShouldBe(BadNoData);
|
||||
s.SourceTimestampUtc.ShouldBe(At(0).AddTicks(-1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_bad_next_neighbour_holds_the_previous_value_uncertain()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
var s = (await db.Source().ReadAtTimeAsync("A", [At(15)], Ct)).Samples.Single();
|
||||
s.Value.ShouldBe(20d); // NOT 25 (interpolating toward the Bad 30)
|
||||
s.StatusCode.ShouldBe(UncertainInterpolated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_bad_previous_neighbour_yields_no_value_uncertain()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
var s = (await db.Source().ReadAtTimeAsync("A", [At(25)], Ct)).Samples.Single();
|
||||
s.Value.ShouldBeNull(); // NOT 35 (30→40) and NOT 30 (a Bad value)
|
||||
s.StatusCode.ShouldBe(UncertainInterpolated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Interpolation_never_bridges_an_outage_marker()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAsync("M",
|
||||
(At(0), HistorianValue.FromDouble(10), ReadTestDb.Good),
|
||||
(At(5), HistorianValue.Empty, ReadTestDb.BadNoCommunication),
|
||||
(At(10), HistorianValue.FromDouble(30), ReadTestDb.Good));
|
||||
var s = (await db.Source().ReadAtTimeAsync("M", [At(2.5), At(7.5), At(5)], Ct)).Samples;
|
||||
s[0].Value.ShouldBe(10d); // bridging 10→30 would give 15
|
||||
s[0].StatusCode.ShouldBe(UncertainInterpolated);
|
||||
s[1].Value.ShouldBeNull(); // bridging would give 25
|
||||
s[1].StatusCode.ShouldBe(UncertainInterpolated);
|
||||
s[2].Value.ShouldBeNull(); // exact hit on the marker: as stored
|
||||
s[2].StatusCode.ShouldBe(0x80310000u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task After_the_last_sample_the_value_is_stepped()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
var s = (await db.Source().ReadAtTimeAsync("A", [At(45)], Ct)).Samples.Single();
|
||||
s.Value.ShouldBe(50d);
|
||||
s.StatusCode.ShouldBe(GoodInterpolated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_uncertain_neighbour_makes_the_interpolation_uncertain()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAsync("U",
|
||||
(At(0), HistorianValue.FromDouble(10), ReadTestDb.Uncertain),
|
||||
(At(10), HistorianValue.FromDouble(20), ReadTestDb.Good));
|
||||
var s = (await db.Source().ReadAtTimeAsync("U", [At(5)], Ct)).Samples.Single();
|
||||
s.Value.ShouldBe(15d);
|
||||
s.StatusCode.ShouldBe(UncertainInterpolated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Int64_is_interpolated_and_rounded_keeping_its_type()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAsync("I",
|
||||
(At(0), HistorianValue.FromInt64(10), ReadTestDb.Good),
|
||||
(At(10), HistorianValue.FromInt64(15), ReadTestDb.Good));
|
||||
var s = (await db.Source().ReadAtTimeAsync("I", [At(5), At(2)], Ct)).Samples;
|
||||
s[0].Value.ShouldBe(13L); // 12.5 ⇒ 13 (away from zero)
|
||||
s[1].Value.ShouldBe(11L); // 11.0
|
||||
s[0].StatusCode.ShouldBe(GoodInterpolated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Boolean_string_and_datetime_are_stepped_from_the_previous_sample()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
var d0 = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddTicks(3);
|
||||
await db.SeedAsync("B",
|
||||
(At(0), HistorianValue.FromBoolean(true), ReadTestDb.Good),
|
||||
(At(10), HistorianValue.FromBoolean(false), ReadTestDb.Good));
|
||||
await db.SeedAsync("S",
|
||||
(At(0), HistorianValue.FromString("alpha"), ReadTestDb.Uncertain),
|
||||
(At(10), HistorianValue.FromString("beta"), ReadTestDb.Good));
|
||||
await db.SeedAsync("D",
|
||||
(At(0), HistorianValue.FromDateTime(d0), ReadTestDb.Good),
|
||||
(At(10), HistorianValue.FromDateTime(d0.AddDays(1)), ReadTestDb.Good));
|
||||
var src = db.Source();
|
||||
var b = (await src.ReadAtTimeAsync("B", [At(9)], Ct)).Samples.Single();
|
||||
b.Value.ShouldBe(true);
|
||||
b.StatusCode.ShouldBe(GoodInterpolated);
|
||||
var s = (await src.ReadAtTimeAsync("S", [At(5)], Ct)).Samples.Single();
|
||||
s.Value.ShouldBe("alpha");
|
||||
s.StatusCode.ShouldBe(0x40000402u); // the previous sample's code (Uncertain) + Interpolated
|
||||
(await src.ReadAtTimeAsync("D", [At(5)], Ct)).Samples.Single().Value.ShouldBe(d0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Result_matches_request_length_and_order_including_duplicates()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
var s = (await db.Source().ReadAtTimeAsync("A", [At(5), At(-1), At(5), At(10)], Ct)).Samples;
|
||||
s.Count.ShouldBe(4);
|
||||
s.Select(x => x.Value).ShouldBe([15d, null, 15d, 20d]);
|
||||
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(5), At(-1), At(5), At(10)]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_request_and_unknown_tag()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
var src = db.Source();
|
||||
(await src.ReadAtTimeAsync("A", [], Ct)).Samples.ShouldBeEmpty();
|
||||
var s = (await src.ReadAtTimeAsync("nope", [At(1), At(2)], Ct)).Samples;
|
||||
s.Count.ShouldBe(2);
|
||||
s.ShouldAllBe(x => x.StatusCode == BadNoData && x.Value == null);
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
|
||||
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class EventReadTests(SqlHistorianFixture fx)
|
||||
{
|
||||
private static readonly CancellationToken Ct = CancellationToken.None;
|
||||
private static DateTime At(double s) => ReadTestDb.At(s);
|
||||
|
||||
private static async Task InsertEventAsync(ReadTestDb db, string id, string source, DateTime t, string? message, int severity)
|
||||
{
|
||||
await using var c = await db.Conn.OpenAsync(Ct);
|
||||
await using var cmd = db.Conn.CreateCommand(c,
|
||||
$"INSERT {db.Conn.Q("Event")} (EventId, SourceName, AlarmId, AlarmName, AlarmTypeName, Severity, EventKind, Message, " +
|
||||
"UserName, Comment, EventTimeUtc, ReceivedTimeUtc) VALUES (@id, @src, 'a', 'n', 'T', @sev, 'Activated', @msg, NULL, NULL, @t, @r)");
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
cmd.Parameters.AddWithValue("@src", source);
|
||||
cmd.Parameters.AddWithValue("@sev", severity);
|
||||
cmd.Parameters.AddWithValue("@msg", (object?)message ?? DBNull.Value);
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@t", t);
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@r", t.AddTicks(3));
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private static async Task<ReadTestDb> SeedAsync(SqlHistorianFixture fx)
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await InsertEventAsync(db, "e1", "Eq/1", At(0), "first", 500);
|
||||
await InsertEventAsync(db, "e2", "Eq/2", At(1), null, 700);
|
||||
await InsertEventAsync(db, "e3", "Eq/1", At(2), "third", 900);
|
||||
await InsertEventAsync(db, "e4", "Eq/1", At(3), "at end", 100);
|
||||
return db;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Source_filter_window_order_and_field_mapping()
|
||||
{
|
||||
var db = await SeedAsync(fx);
|
||||
var r = await db.Source().ReadEventsAsync("Eq/1", At(0), At(3), 0, Ct);
|
||||
r.Events.Select(e => e.EventId).ShouldBe(["e1", "e3"]); // e4 at end excluded, e2 other source
|
||||
var e = r.Events[0];
|
||||
e.SourceName.ShouldBe("Eq/1");
|
||||
e.EventTimeUtc.ShouldBe(At(0));
|
||||
e.EventTimeUtc.Kind.ShouldBe(DateTimeKind.Utc);
|
||||
e.ReceivedTimeUtc.ShouldBe(At(0).AddTicks(3));
|
||||
e.Message.ShouldBe("first");
|
||||
e.Severity.ShouldBe((ushort)500);
|
||||
r.ContinuationPoint.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_source_returns_every_source_and_null_messages()
|
||||
{
|
||||
var db = await SeedAsync(fx);
|
||||
var r = await db.Source().ReadEventsAsync(null, At(0), At(10), 0, Ct);
|
||||
r.Events.Select(e => e.EventId).ShouldBe(["e1", "e2", "e3", "e4"]);
|
||||
r.Events[1].Message.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Truncation_drops_the_extra_row_and_returns_a_continuation_point()
|
||||
{
|
||||
var db = await SeedAsync(fx);
|
||||
var r = await db.Source().ReadEventsAsync(null, At(0), At(3), 2, Ct); // 3 matching rows
|
||||
r.Events.Select(e => e.EventId).ShouldBe(["e1", "e2"]);
|
||||
r.ContinuationPoint.ShouldNotBeNull();
|
||||
Encoding.UTF8.GetString(r.ContinuationPoint!).ShouldBe($"{At(2).Ticks}|e3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_exact_fit_or_an_unbounded_read_has_no_continuation_point()
|
||||
{
|
||||
var db = await SeedAsync(fx);
|
||||
var src = db.Source();
|
||||
var exact = await src.ReadEventsAsync(null, At(0), At(3), 3, Ct);
|
||||
exact.Events.Count.ShouldBe(3);
|
||||
exact.ContinuationPoint.ShouldBeNull();
|
||||
(await src.ReadEventsAsync(null, At(0), At(10), int.MaxValue, Ct)).Events.Count.ShouldBe(4); // node manager's "no limit"
|
||||
(await src.ReadEventsAsync(null, At(0), At(10), -1, Ct)).ContinuationPoint.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reversed_window_is_swapped()
|
||||
{
|
||||
var db = await SeedAsync(fx);
|
||||
(await db.Source().ReadEventsAsync(null, At(3), At(0), 0, Ct)).Events.Select(e => e.EventId)
|
||||
.ShouldBe(["e1", "e2", "e3"]);
|
||||
}
|
||||
}
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Push-down aggregates over tag A (+0=10, +10=20, +20=Bad(30), +30=40, +40=50), 20 s intervals from the
|
||||
/// sub-millisecond <see cref="ReadTestDb.T0"/>, window [t0, t0+80s) ⇒ four buckets:
|
||||
/// B0 [0,20) = {10, 20}; B1 [20,40) = {Bad 30, 40}; B2 [40,60) = {50}; B3 [60,80) = {} (empty).
|
||||
/// Every expected value below is hand-computed.
|
||||
/// </summary>
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class ProcessedReadTests(SqlHistorianFixture fx)
|
||||
{
|
||||
private static readonly CancellationToken Ct = CancellationToken.None;
|
||||
private static readonly TimeSpan Twenty = TimeSpan.FromSeconds(20);
|
||||
private static DateTime At(double s) => ReadTestDb.At(s);
|
||||
|
||||
// Calculated = InfoType DataValue 0x400 | Calculated 0x1, OR-ed onto the code.
|
||||
private const uint GoodCalc = 0x00000401u;
|
||||
private const uint UncertainCalc = 0x40A40401u; // UncertainDataSubNormal | 0x401
|
||||
private const uint NoDataCalc = 0x809B0401u; // BadNoData | 0x401
|
||||
private const uint InvalidInputsCalc = 0x80D60401u; // BadAggregateInvalidInputs | 0x401
|
||||
|
||||
private static async Task<HistoryReadResult> ReadA(ReadTestDb db, HistoryAggregateType agg) =>
|
||||
await db.Source().ReadProcessedAsync("A", At(0), At(80), Twenty, agg, Ct);
|
||||
|
||||
[Fact]
|
||||
public void The_status_literals_carry_the_SDK_Calculated_aggregate_bits()
|
||||
{
|
||||
new Opc.Ua.StatusCode(GoodCalc).AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Calculated);
|
||||
new Opc.Ua.StatusCode(UncertainCalc).AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Calculated);
|
||||
new Opc.Ua.StatusCode(UncertainCalc).CodeBits.ShouldBe(Opc.Ua.StatusCodes.UncertainDataSubNormal);
|
||||
new Opc.Ua.StatusCode(NoDataCalc).CodeBits.ShouldBe(Opc.Ua.StatusCodes.BadNoData);
|
||||
new Opc.Ua.StatusCode(NoDataCalc).AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Calculated);
|
||||
new Opc.Ua.StatusCode(InvalidInputsCalc).CodeBits.ShouldBe(Opc.Ua.StatusCodes.BadAggregateInvalidInputs);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HistoryAggregateType.Average, 15d, 40d, 50d)]
|
||||
[InlineData(HistoryAggregateType.Minimum, 10d, 40d, 50d)]
|
||||
[InlineData(HistoryAggregateType.Maximum, 20d, 40d, 50d)]
|
||||
[InlineData(HistoryAggregateType.Total, 30d, 40d, 50d)]
|
||||
[InlineData(HistoryAggregateType.Start, 10d, 40d, 50d)]
|
||||
[InlineData(HistoryAggregateType.End, 20d, 40d, 50d)]
|
||||
[InlineData(HistoryAggregateType.Delta, 10d, 0d, 0d)]
|
||||
[InlineData(HistoryAggregateType.Range, 10d, 0d, 0d)]
|
||||
public async Task Numeric_aggregates_over_the_fixture(HistoryAggregateType agg, double b0, double b1, double b2)
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
var s = (await ReadA(db, agg)).Samples;
|
||||
|
||||
s.Count.ShouldBe(4);
|
||||
s.Select(x => x.Value).ShouldBe([b0, b1, b2, null]);
|
||||
// B1 holds a Bad sample alongside a usable one ⇒ Uncertain; the empty B3 ⇒ BadNoData.
|
||||
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc, NoDataCalc]);
|
||||
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(20), At(40), At(60)]); // bucket starts
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Count_counts_usable_samples_as_int()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
var s = (await ReadA(db, HistoryAggregateType.Count)).Samples;
|
||||
s.Select(x => x.Value).ShouldBe([(object?)2, 1, 1, null]);
|
||||
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc, NoDataCalc]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HistoryAggregateType.PercentGood, 100d, 50d, 100d)]
|
||||
[InlineData(HistoryAggregateType.PercentBad, 0d, 50d, 0d)]
|
||||
public async Task Percent_aggregates_count_every_sample(HistoryAggregateType agg, double b0, double b1, double b2)
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
var s = (await ReadA(db, agg)).Samples;
|
||||
s.Select(x => x.Value).ShouldBe([b0, b1, b2, null]);
|
||||
// A percentage is a statement about quality, so it is not itself degraded by the Bad input.
|
||||
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, GoodCalc, NoDataCalc]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Actual_time_aggregates_stamp_the_sample_time()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
var min = (await ReadA(db, HistoryAggregateType.MinimumActualTime)).Samples;
|
||||
min.Select(x => x.Value).ShouldBe([10d, 40d, 50d, null]);
|
||||
min.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(30), At(40), At(60)]);
|
||||
min.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc, NoDataCalc]);
|
||||
var max = (await ReadA(db, HistoryAggregateType.MaximumActualTime)).Samples;
|
||||
max.Select(x => x.Value).ShouldBe([20d, 40d, 50d, null]);
|
||||
max.Select(x => x.SourceTimestampUtc).ShouldBe([At(10), At(30), At(40), At(60)]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Actual_time_ties_resolve_to_the_earliest_sample()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAsync("T",
|
||||
(At(0), HistorianValue.FromDouble(5), ReadTestDb.Good),
|
||||
(At(2), HistorianValue.FromDouble(3), ReadTestDb.Good),
|
||||
(At(4), HistorianValue.FromDouble(3), ReadTestDb.Good),
|
||||
(At(6), HistorianValue.FromDouble(7), ReadTestDb.Good),
|
||||
(At(8), HistorianValue.FromDouble(7), ReadTestDb.Good));
|
||||
var src = db.Source();
|
||||
var min = (await src.ReadProcessedAsync("T", At(0), At(10), TimeSpan.Zero, HistoryAggregateType.MinimumActualTime, Ct)).Samples.Single();
|
||||
min.Value.ShouldBe(3d);
|
||||
min.SourceTimestampUtc.ShouldBe(At(2));
|
||||
var max = (await src.ReadProcessedAsync("T", At(0), At(10), TimeSpan.Zero, HistoryAggregateType.MaximumActualTime, Ct)).Samples.Single();
|
||||
max.Value.ShouldBe(7d);
|
||||
max.SourceTimestampUtc.ShouldBe(At(6));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bucket_edges_are_start_inclusive_end_exclusive_to_the_tick()
|
||||
{
|
||||
// T0 is not millisecond-aligned: a DATEDIFF(millisecond) bucket formula would push the sample one tick
|
||||
// before the +20 s edge into bucket 1 (it counts millisecond boundaries crossed, not elapsed time).
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
var edge = At(20);
|
||||
await db.SeedAsync("E",
|
||||
(At(0).AddTicks(-1), HistorianValue.FromDouble(1000), ReadTestDb.Good), // before start: excluded
|
||||
(At(0), HistorianValue.FromDouble(1), ReadTestDb.Good), // == start: B0
|
||||
(edge.AddTicks(-1), HistorianValue.FromDouble(2), ReadTestDb.Good), // last tick of B0
|
||||
(edge, HistorianValue.FromDouble(4), ReadTestDb.Good), // first tick of B1
|
||||
(At(40).AddTicks(-1), HistorianValue.FromDouble(8), ReadTestDb.Good), // last tick of B1
|
||||
(At(40), HistorianValue.FromDouble(2000), ReadTestDb.Good)); // == end: excluded
|
||||
var s = (await db.Source().ReadProcessedAsync("E", At(0), At(40), Twenty, HistoryAggregateType.Total, Ct)).Samples;
|
||||
s.Select(x => x.Value).ShouldBe([3d, 12d]);
|
||||
s.ShouldAllBe(x => x.StatusCode == GoodCalc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_partial_last_interval_is_truncated_at_end()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
var s = (await db.Source().ReadProcessedAsync("A", At(0), At(50), Twenty, HistoryAggregateType.Average, Ct)).Samples;
|
||||
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(20), At(40)]);
|
||||
s.Select(x => x.Value).ShouldBe([15d, 40d, 50d]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_non_positive_interval_is_one_interval_over_the_window()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
var src = db.Source();
|
||||
var avg = (await src.ReadProcessedAsync("A", At(0), At(50), TimeSpan.Zero, HistoryAggregateType.Average, Ct)).Samples.Single();
|
||||
avg.Value.ShouldBe(30d); // usable {10, 20, 40, 50}
|
||||
avg.StatusCode.ShouldBe(UncertainCalc);
|
||||
avg.SourceTimestampUtc.ShouldBe(At(0));
|
||||
(await src.ReadProcessedAsync("A", At(0), At(50), TimeSpan.FromSeconds(-1), HistoryAggregateType.Count, Ct))
|
||||
.Samples.Single().Value.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reverse_window_is_computed_forward_and_returned_descending()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
var s = (await db.Source().ReadProcessedAsync("A", At(80), At(0), Twenty, HistoryAggregateType.Average, Ct)).Samples;
|
||||
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(60), At(40), At(20), At(0)]);
|
||||
s.Select(x => x.Value).ShouldBe([null, 50d, 40d, 15d]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Equal_start_and_end_yields_no_intervals()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
(await db.Source().ReadProcessedAsync("A", At(10), At(10), Twenty, HistoryAggregateType.Average, Ct))
|
||||
.Samples.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Outage_markers_are_bad_samples_in_aggregates()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAsync("M",
|
||||
(At(0), HistorianValue.FromDouble(10), ReadTestDb.Good),
|
||||
(At(5), HistorianValue.Empty, ReadTestDb.BadNoCommunication),
|
||||
(At(10), HistorianValue.FromDouble(30), ReadTestDb.Good));
|
||||
var src = db.Source();
|
||||
async Task<DataValueSnapshot> One(HistoryAggregateType a) =>
|
||||
(await src.ReadProcessedAsync("M", At(0), At(20), Twenty, a, Ct)).Samples.Single();
|
||||
|
||||
var avg = await One(HistoryAggregateType.Average);
|
||||
avg.Value.ShouldBe(20d);
|
||||
avg.StatusCode.ShouldBe(UncertainCalc);
|
||||
(await One(HistoryAggregateType.Count)).Value.ShouldBe(2);
|
||||
(await One(HistoryAggregateType.Start)).Value.ShouldBe(10d);
|
||||
((double)(await One(HistoryAggregateType.PercentBad)).Value!).ShouldBe(33.333333333333336, 1e-9); // 100 × 1/3
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_interval_of_only_bad_samples_is_BadNoData_except_the_percentages()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAsync("Z",
|
||||
(At(1), HistorianValue.Empty, ReadTestDb.BadNoCommunication),
|
||||
(At(2), HistorianValue.FromDouble(5), ReadTestDb.Bad));
|
||||
var src = db.Source();
|
||||
async Task<DataValueSnapshot> One(HistoryAggregateType a) =>
|
||||
(await src.ReadProcessedAsync("Z", At(0), At(20), Twenty, a, Ct)).Samples.Single();
|
||||
|
||||
(await One(HistoryAggregateType.Average)).StatusCode.ShouldBe(NoDataCalc);
|
||||
(await One(HistoryAggregateType.Count)).StatusCode.ShouldBe(NoDataCalc);
|
||||
var pb = await One(HistoryAggregateType.PercentBad);
|
||||
pb.Value.ShouldBe(100d);
|
||||
pb.StatusCode.ShouldBe(GoodCalc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Uncertain_samples_are_used_but_make_the_interval_uncertain()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAsync("U",
|
||||
(At(1), HistorianValue.FromDouble(4), ReadTestDb.Uncertain),
|
||||
(At(2), HistorianValue.FromDouble(6), ReadTestDb.Good));
|
||||
var s = (await db.Source().ReadProcessedAsync("U", At(0), At(20), Twenty, HistoryAggregateType.Average, Ct)).Samples.Single();
|
||||
s.Value.ShouldBe(5d);
|
||||
s.StatusCode.ShouldBe(UncertainCalc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task String_tags_serve_count_start_end_and_refuse_numeric_aggregates()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAsync("S",
|
||||
(At(0), HistorianValue.FromString("a"), ReadTestDb.Good),
|
||||
(At(10), HistorianValue.FromString("b"), ReadTestDb.Good));
|
||||
var src = db.Source();
|
||||
async Task<DataValueSnapshot> One(HistoryAggregateType a) =>
|
||||
(await src.ReadProcessedAsync("S", At(0), At(20), Twenty, a, Ct)).Samples.Single();
|
||||
|
||||
(await One(HistoryAggregateType.Count)).Value.ShouldBe(2);
|
||||
(await One(HistoryAggregateType.Start)).Value.ShouldBe("a");
|
||||
(await One(HistoryAggregateType.End)).Value.ShouldBe("b");
|
||||
(await One(HistoryAggregateType.PercentGood)).Value.ShouldBe(100d);
|
||||
foreach (var a in new[]
|
||||
{
|
||||
HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum,
|
||||
HistoryAggregateType.Total, HistoryAggregateType.Delta, HistoryAggregateType.Range,
|
||||
HistoryAggregateType.MinimumActualTime, HistoryAggregateType.MaximumActualTime,
|
||||
})
|
||||
{
|
||||
var r = await One(a);
|
||||
r.StatusCode.ShouldBe(InvalidInputsCalc, a.ToString());
|
||||
r.Value.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DateTime_tags_refuse_numeric_aggregates()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAsync("D", (At(0), HistorianValue.FromDateTime(At(-100)), ReadTestDb.Good));
|
||||
(await db.Source().ReadProcessedAsync("D", At(0), At(20), Twenty, HistoryAggregateType.Average, Ct))
|
||||
.Samples.Single().StatusCode.ShouldBe(InvalidInputsCalc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Boolean_tags_aggregate_as_zero_or_one()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAsync("B",
|
||||
(At(0), HistorianValue.FromBoolean(true), ReadTestDb.Good),
|
||||
(At(5), HistorianValue.FromBoolean(false), ReadTestDb.Good),
|
||||
(At(10), HistorianValue.FromBoolean(true), ReadTestDb.Good),
|
||||
(At(15), HistorianValue.FromBoolean(true), ReadTestDb.Good));
|
||||
var src = db.Source();
|
||||
async Task<DataValueSnapshot> One(HistoryAggregateType a) =>
|
||||
(await src.ReadProcessedAsync("B", At(0), At(20), Twenty, a, Ct)).Samples.Single();
|
||||
(await One(HistoryAggregateType.Average)).Value.ShouldBe(0.75d);
|
||||
(await One(HistoryAggregateType.Total)).Value.ShouldBe(3d);
|
||||
(await One(HistoryAggregateType.Start)).Value.ShouldBe(true);
|
||||
(await One(HistoryAggregateType.Count)).Value.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_tag_yields_every_interval_as_BadNoData()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
var s = (await db.Source().ReadProcessedAsync("nope", At(0), At(60), Twenty, HistoryAggregateType.Average, Ct)).Samples;
|
||||
s.Count.ShouldBe(3);
|
||||
s.ShouldAllBe(x => x.StatusCode == NoDataCalc && x.Value == null);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HistoryAggregateType.Interpolative)]
|
||||
[InlineData(HistoryAggregateType.TimeAverage)]
|
||||
[InlineData(HistoryAggregateType.StartBound)]
|
||||
[InlineData(HistoryAggregateType.EndBound)]
|
||||
[InlineData(HistoryAggregateType.DeltaBounds)]
|
||||
[InlineData(HistoryAggregateType.DurationGood)]
|
||||
[InlineData(HistoryAggregateType.DurationBad)]
|
||||
[InlineData(HistoryAggregateType.WorstQuality)]
|
||||
[InlineData(HistoryAggregateType.NumberOfTransitions)]
|
||||
[InlineData(HistoryAggregateType.AnnotationCount)]
|
||||
[InlineData(HistoryAggregateType.StandardDeviationPopulation)]
|
||||
[InlineData(HistoryAggregateType.VariancePopulation)]
|
||||
public async Task Unsupported_aggregates_throw_not_supported_and_are_not_health_failures(HistoryAggregateType agg)
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
var src = db.Source();
|
||||
var ex = await Should.ThrowAsync<HistoryAggregateNotSupportedException>(
|
||||
() => src.ReadProcessedAsync("A", At(0), At(20), Twenty, agg, Ct));
|
||||
ex.Aggregate.ShouldBe(agg);
|
||||
src.GetHealthSnapshot().TotalQueries.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_enum_member_is_either_served_or_refused()
|
||||
{
|
||||
// Thirteen served; anything appended to the enum later falls to the refusal (total default).
|
||||
Enum.GetValues<HistoryAggregateType>().Count(a => SqlAggregateQueries.FamilyOf(a) is not null).ShouldBe(13);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_bucket_count_is_capped_at_100000()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
var src = db.Source();
|
||||
var oneTick = TimeSpan.FromTicks(1);
|
||||
(await src.ReadProcessedAsync("nope", At(0), At(0).AddTicks(100_000), oneTick, HistoryAggregateType.Count, Ct))
|
||||
.Samples.Count.ShouldBe(100_000);
|
||||
await Should.ThrowAsync<ArgumentOutOfRangeException>(
|
||||
() => src.ReadProcessedAsync("nope", At(0), At(0).AddTicks(100_001), oneTick, HistoryAggregateType.Count, Ct));
|
||||
var h = src.GetHealthSnapshot();
|
||||
h.TotalFailures.ShouldBe(0); // a client-request error, not a backend fault
|
||||
}
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a fresh schema with plain INSERTs (independent of the value writer) and builds a data source over it.
|
||||
/// Shared by the four read test classes. <see cref="T0"/> is deliberately NOT millisecond-aligned.
|
||||
/// </summary>
|
||||
internal sealed class ReadTestDb
|
||||
{
|
||||
/// <summary>Base time: 2026-10-09 08:00:00.1234567 UTC (sub-millisecond ticks on purpose).</summary>
|
||||
public static readonly DateTime T0 = new DateTime(2026, 10, 9, 8, 0, 0, DateTimeKind.Utc).AddTicks(1_234_567);
|
||||
|
||||
public const uint Good = 0x00000000u;
|
||||
public const uint Bad = 0x80000000u;
|
||||
public const uint BadNoCommunication = 0x80310000u;
|
||||
public const uint Uncertain = 0x40000000u;
|
||||
|
||||
private ReadTestDb(SqlServerHistorianOptions options, SqlHistorianConnection conn, SqlServerSchemaBootstrapper boot)
|
||||
{
|
||||
Options = options;
|
||||
Conn = conn;
|
||||
Boot = boot;
|
||||
}
|
||||
|
||||
public SqlServerHistorianOptions Options { get; }
|
||||
public SqlHistorianConnection Conn { get; }
|
||||
public SqlServerSchemaBootstrapper Boot { get; }
|
||||
|
||||
public static DateTime At(double seconds) => T0.AddTicks((long)(seconds * TimeSpan.TicksPerSecond));
|
||||
|
||||
public static async Task<ReadTestDb> CreateAsync(SqlHistorianFixture fx)
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var o = new SqlServerHistorianOptions { ConnectionString = fx.ConnectionString, Schema = SqlHistorianFixture.UniqueSchema() };
|
||||
var conn = new SqlHistorianConnection(o);
|
||||
var boot = new SqlServerSchemaBootstrapper(conn, o);
|
||||
await boot.EnsureAsync(CancellationToken.None);
|
||||
return new ReadTestDb(o, conn, boot);
|
||||
}
|
||||
|
||||
/// <summary>A fresh data source (fresh catalog cache) over this schema.</summary>
|
||||
public SqlServerHistorianDataSource Source() => new(Conn, Boot, new SqlServerTagCatalog(Conn, Boot));
|
||||
|
||||
/// <summary>Inserts a tag row and the given samples. ServerTimeUtc = source time + 1 s (so tests can tell them apart).</summary>
|
||||
public async Task<int> SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples)
|
||||
{
|
||||
await using var c = await Conn.OpenAsync(CancellationToken.None);
|
||||
int id;
|
||||
await using (var cmd = Conn.CreateCommand(c,
|
||||
$"INSERT {Conn.Q("Tag")} (Name, DataType, CreatedUtc, UpdatedUtc) OUTPUT inserted.TagId VALUES (@n, 0, SYSUTCDATETIME(), SYSUTCDATETIME())"))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@n", tag);
|
||||
id = (int)(await cmd.ExecuteScalarAsync())!;
|
||||
}
|
||||
foreach (var (t, v, status) in samples)
|
||||
{
|
||||
var col = SampleRowMapper.ToColumns(v);
|
||||
await using var cmd = Conn.CreateCommand(c,
|
||||
$"INSERT {Conn.Q("Sample")} (TagId, SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, ServerTimeUtc) " +
|
||||
"VALUES (@id, @t, @k, @num, @int, @str, @st, @srv)");
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@t", t);
|
||||
cmd.Parameters.AddWithValue("@k", col.ValueKind);
|
||||
cmd.Parameters.Add(new SqlParameter("@num", System.Data.SqlDbType.Float) { Value = (object?)col.NumValue ?? DBNull.Value });
|
||||
cmd.Parameters.Add(new SqlParameter("@int", System.Data.SqlDbType.BigInt) { Value = (object?)col.IntValue ?? DBNull.Value });
|
||||
cmd.Parameters.Add(new SqlParameter("@str", System.Data.SqlDbType.NVarChar, -1) { Value = (object?)col.StrValue ?? DBNull.Value });
|
||||
cmd.Parameters.AddWithValue("@st", SampleRowMapper.ToDbStatus(status));
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@srv", t.AddSeconds(1));
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>The fixture tag A of the brief: +0=10, +10=20, +20=Bad(30), +30=40, +40=50 (Double).</summary>
|
||||
public Task<int> SeedAAsync(string name = "A") => SeedAsync(name,
|
||||
(At(0), HistorianValue.FromDouble(10), Good),
|
||||
(At(10), HistorianValue.FromDouble(20), Good),
|
||||
(At(20), HistorianValue.FromDouble(30), Bad),
|
||||
(At(30), HistorianValue.FromDouble(40), Good),
|
||||
(At(40), HistorianValue.FromDouble(50), Good));
|
||||
|
||||
public async Task<long> ScalarAsync(string sql)
|
||||
{
|
||||
await using var c = await Conn.OpenAsync(CancellationToken.None);
|
||||
await using var cmd = Conn.CreateCommand(c, sql);
|
||||
return Convert.ToInt64(await cmd.ExecuteScalarAsync());
|
||||
}
|
||||
}
|
||||
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class RawReadTests(SqlHistorianFixture fx)
|
||||
{
|
||||
private static readonly CancellationToken Ct = CancellationToken.None;
|
||||
private static DateTime At(double s) => ReadTestDb.At(s);
|
||||
|
||||
private static double?[] Values(HistoryReadResult r) => r.Samples.Select(s => (double?)s.Value).ToArray();
|
||||
|
||||
[Fact]
|
||||
public async Task Forward_read_is_start_inclusive_end_exclusive_ascending_with_exact_ticks()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
var r = await db.Source().ReadRawAsync("A", At(10), At(40), 0, Ct);
|
||||
|
||||
Values(r).ShouldBe([20d, 30d, 40d]); // +10 included, +40 (== end) excluded
|
||||
r.Samples.Select(s => s.SourceTimestampUtc!.Value.Ticks).ShouldBe([At(10).Ticks, At(20).Ticks, At(30).Ticks]);
|
||||
r.Samples.Select(s => s.StatusCode).ShouldBe([ReadTestDb.Good, ReadTestDb.Bad, ReadTestDb.Good]);
|
||||
r.Samples[0].SourceTimestampUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
|
||||
r.Samples[0].ServerTimestampUtc.Ticks.ShouldBe(At(11).Ticks); // stored ServerTimeUtc, not "now"
|
||||
r.ContinuationPoint.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Forward_read_honours_the_limit()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
Values(await db.Source().ReadRawAsync("A", At(0), At(100), 2, Ct)).ShouldBe([10d, 20d]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Zero_max_values_is_unbounded()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
Values(await db.Source().ReadRawAsync("A", At(-1), At(100), 0, Ct)).ShouldBe([10d, 20d, 30d, 40d, 50d]);
|
||||
Values(await db.Source().ReadRawAsync("A", At(-1), At(100), uint.MaxValue, Ct)).Length.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reverse_read_is_descending_over_end_exclusive_start_inclusive()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
// start=+30, end=+10 ⇒ (+10, +30] descending.
|
||||
Values(await db.Source().ReadRawAsync("A", At(30), At(10), 0, Ct)).ShouldBe([40d, 30d]);
|
||||
Values(await db.Source().ReadRawAsync("A", At(100), At(-1), 2, Ct)).ShouldBe([50d, 40d]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Equal_start_and_end_is_a_point_read()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
Values(await db.Source().ReadRawAsync("A", At(10), At(10), 0, Ct)).ShouldBe([20d]);
|
||||
(await db.Source().ReadRawAsync("A", At(10).AddTicks(1), At(10).AddTicks(1), 0, Ct)).Samples.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_resumed_page_from_the_last_timestamp_inclusive_continues_without_gaps()
|
||||
{
|
||||
// The node manager's paging: read cap N, resume at the last returned timestamp INCLUSIVE, trim it.
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
var src = db.Source();
|
||||
var p1 = await src.ReadRawAsync("A", At(0), At(100), 2, Ct);
|
||||
var p2 = await src.ReadRawAsync("A", p1.Samples[^1].SourceTimestampUtc!.Value, At(100), 2, Ct);
|
||||
Values(p1).ShouldBe([10d, 20d]);
|
||||
Values(p2).ShouldBe([20d, 30d]); // first is the boundary sample the node manager trims
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_tag_is_an_empty_result_and_reads_never_create_tags()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
var src = db.Source();
|
||||
(await src.ReadRawAsync("nope", At(0), At(10), 0, Ct)).Samples.ShouldBeEmpty();
|
||||
await src.ReadAtTimeAsync("nope", [At(1)], Ct);
|
||||
await src.ReadProcessedAsync("nope", At(0), At(10), TimeSpan.FromSeconds(5), HistoryAggregateType.Average, Ct);
|
||||
(await db.ScalarAsync($"SELECT COUNT(*) FROM {db.Conn.Q("Tag")}")).ShouldBe(0);
|
||||
src.GetHealthSnapshot().TotalFailures.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Outage_markers_and_bad_samples_come_back_with_their_bad_status()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAsync("M",
|
||||
(At(0), HistorianValue.FromDouble(10), ReadTestDb.Good),
|
||||
(At(5), HistorianValue.Empty, ReadTestDb.BadNoCommunication),
|
||||
(At(7), HistorianValue.FromDouble(99), ReadTestDb.Bad));
|
||||
var r = await db.Source().ReadRawAsync("M", At(0), At(10), 0, Ct);
|
||||
r.Samples.Count.ShouldBe(3);
|
||||
r.Samples[1].Value.ShouldBeNull();
|
||||
r.Samples[1].StatusCode.ShouldBe(0x80310000u);
|
||||
r.Samples[2].Value.ShouldBe(99d);
|
||||
r.Samples[2].StatusCode.ShouldBe(0x80000000u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Every_kind_round_trips_to_its_CLR_type()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
var dt = new DateTime(2025, 1, 2, 3, 4, 5, DateTimeKind.Utc).AddTicks(7);
|
||||
await db.SeedAsync("K",
|
||||
(At(0), HistorianValue.FromInt64(-9_000_000_000L), ReadTestDb.Good),
|
||||
(At(1), HistorianValue.FromBoolean(true), ReadTestDb.Good),
|
||||
(At(2), HistorianValue.FromString("héllo"), ReadTestDb.Good),
|
||||
(At(3), HistorianValue.FromDateTime(dt), ReadTestDb.Good),
|
||||
(At(4), HistorianValue.FromDouble(1.5), ReadTestDb.Uncertain));
|
||||
var s = (await db.Source().ReadRawAsync("K", At(0), At(5), 0, Ct)).Samples;
|
||||
s[0].Value.ShouldBe(-9_000_000_000L);
|
||||
s[1].Value.ShouldBe(true);
|
||||
s[2].Value.ShouldBe("héllo");
|
||||
s[3].Value.ShouldBe(dt);
|
||||
((DateTime)s[3].Value!).Kind.ShouldBe(DateTimeKind.Utc);
|
||||
s[4].Value.ShouldBe(1.5);
|
||||
s[4].StatusCode.ShouldBe(0x40000000u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_reads_are_counted_in_health()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
var src = db.Source();
|
||||
await src.ReadRawAsync("A", At(0), At(10), 0, Ct);
|
||||
await src.ReadRawAsync("A", At(0), At(10), 0, Ct);
|
||||
var h = src.GetHealthSnapshot();
|
||||
h.TotalQueries.ShouldBe(2);
|
||||
h.TotalSuccesses.ShouldBe(2);
|
||||
h.ConsecutiveFailures.ShouldBe(0);
|
||||
h.LastSuccessTime.ShouldNotBeNull();
|
||||
h.ProcessConnectionOpen.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_missing_schema_surfaces_in_health_LastError_and_is_not_latched()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var o = new SqlServerHistorianOptions
|
||||
{
|
||||
ConnectionString = fx.ConnectionString, Schema = SqlHistorianFixture.UniqueSchema(), AutoCreateSchema = false,
|
||||
};
|
||||
var conn = new SqlHistorianConnection(o);
|
||||
var boot = new SqlServerSchemaBootstrapper(conn, o);
|
||||
var src = new SqlServerHistorianDataSource(conn, boot, new SqlServerTagCatalog(conn, boot));
|
||||
|
||||
await Should.ThrowAsync<HistorianSchemaMissingException>(() => src.ReadRawAsync("A", At(0), At(1), 0, Ct));
|
||||
await Should.ThrowAsync<HistorianSchemaMissingException>(() => src.ReadEventsAsync(null, At(0), At(1), 0, Ct));
|
||||
var h = src.GetHealthSnapshot();
|
||||
h.TotalFailures.ShouldBe(2);
|
||||
h.ConsecutiveFailures.ShouldBe(2);
|
||||
h.LastError!.ShouldContain("missing version 1");
|
||||
h.ProcessConnectionOpen.ShouldBeFalse();
|
||||
|
||||
// Another node creates the schema; the same data source then succeeds (bootstrap failure is not latched).
|
||||
var creator = new SqlServerHistorianOptions { ConnectionString = o.ConnectionString, Schema = o.Schema };
|
||||
await new SqlServerSchemaBootstrapper(new SqlHistorianConnection(creator), creator).EnsureAsync(Ct);
|
||||
(await src.ReadRawAsync("A", At(0), At(1), 0, Ct)).Samples.ShouldBeEmpty();
|
||||
src.GetHealthSnapshot().ConsecutiveFailures.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user