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;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
///
/// SQL Server read side of the historian (spec §4 "Reads"): Raw (forward / reverse / point), AtTime
/// (one statement, TimeRow TVP + two OUTER APPLYs), Events, and Processed aggregates pushed
/// down to T-SQL ().
///
///
///
/// Reads never create tags. Tag names resolve through the shared 's
/// read-only lookup (positive results cached); an unknown tag is a successful empty read (Raw), an
/// all-BadNoData read (AtTime / Processed), never an error.
///
///
/// Faults. A SQL / bootstrap exception is recorded in (so a missing
/// schema with AutoCreateSchema=false surfaces in LastError) and rethrown; the node manager
/// turns it into a per-node Bad. Client-request errors — an unsupported aggregate
/// ( ⇒ BadAggregateNotSupported) and a processed
/// window over intervals (, which
/// the node manager's generic catch surfaces as BadHistoryOperationUnsupported for that node only)
/// — are thrown before any I/O and are not counted as backend failures.
///
///
/// Bad samples. A stored sample is usable when its status severity is Good or Uncertain and it
/// carries a value; an Empty-kind sample is an outage marker (comms-loss BadNoCommunication
/// 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 their duration as Bad time in PercentBad. AtTime never interpolates across a Bad
/// neighbour and reports a time inside an outage with the outage's Bad code (see ).
/// An Empty sample is always served Bad: a row with no value whose stored status is not Bad (only
/// possible for rows written before the writer forced Empty samples Bad) is read back as BadNoData,
/// on Raw and AtTime alike.
///
///
/// Long names. A tag name or event source name longer than
/// can never be stored, so it is answered as not found (Raw: empty; AtTime / Processed: BadNoData;
/// Events: none) without being truncated onto another series.
///
///
public sealed class SqlServerHistorianDataSource : IHistorianDataSource
{
/// Spec §9.7: a processed read covering more intervals than this is refused.
public const int MaxProcessedBuckets = BucketPlan.MaxBuckets;
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 TimeProvider _time;
private readonly HistorianHealthCounters _health = new();
private readonly int _maxTimeWeightedRawRows;
/// Creates the data source over the provider's shared connection, bootstrapper and tag catalog.
/// Connection helper.
/// Schema bootstrapper (shared with the other SQL seams).
/// Tag catalog (shared; reads use only its read-only lookup).
/// Optional logger.
/// Clock for "now" (AtTime beyond the last sample, server timestamps); system by default.
/// Raw in-window row cap of one time-weighted read (<= 0 = unbounded).
internal SqlServerHistorianDataSource(
SqlHistorianConnection conn, SqlServerSchemaBootstrapper bootstrap, SqlServerTagCatalog catalog, ILogger? log = null,
TimeProvider? timeProvider = null, int maxTimeWeightedRawRows = TimeWeightedAggregator.DefaultMaxWindowRows)
{
_maxTimeWeightedRawRows = maxTimeWeightedRawRows;
_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;
_time = timeProvider ?? TimeProvider.System;
}
// ------------------------------------------------------------------------------------------ Raw
///
///
/// Forward (start < end): [start, end) ascending. Reverse (end < start, Part 11):
/// (end, start] descending. start == end: 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).
/// 0 is the node manager's "unbounded" sentinel; values above clamp.
/// The node manager pages by re-reading from the last returned timestamp inclusive and trimming it; the
/// (TagId, SourceTimeUtc) primary key rules out tie clusters, so that is exact.
///
public async Task 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();
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;
}
}
// ------------------------------------------------------------------------------------------ Modified (Part 11, #668)
///
///
/// Reads SampleModified (schema v2), the trail writes in the
/// same statement as each change. Windows follow ; rows are ordered by
/// (SourceTimeUtc, ModifiedId) (the clustered key after TagId, so a seek). maxValues + 1 rows
/// are fetched to report exactly. An insert's audit row carries no
/// prior value and is served null / BadNoData; its server timestamp is the modification time. An
/// unknown tag is an empty result.
///
public async Task ReadModifiedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValues, HistoryModifiedCursor? resumeAfter,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReference);
var bounded = maxValues > 0 && maxValues < int.MaxValue;
try
{
// #674: a v1 schema has no SampleModified. NotSupportedException is the interface's "no trail" signal (the
// node manager reports BadHistoryOperationUnsupported); it is not a backend failure and sends no query.
await _bootstrap.EnsureAsync(cancellationToken).ConfigureAwait(false);
if (!_bootstrap.SupportsModifiedTrail)
throw new NotSupportedException(
$"Historian schema [{_conn.Schema}] is below version {SqlServerSchemaBootstrapper.ModifiedTrailVersion}: no modified-value history.");
var values = new List();
var hasMore = false;
var tagId = await ResolveAsync(fullReference, cancellationToken).ConfigureAwait(false);
if (tagId is { } id)
{
var reverse = startUtc > endUtc;
string where;
if (startUtc == endUtc) where = "SourceTimeUtc = @lo";
else if (!reverse) where = "SourceTimeUtc >= @lo AND SourceTimeUtc < @hi";
else where = "SourceTimeUtc > @lo AND SourceTimeUtc <= @hi";
if (resumeAfter is not null)
where += reverse
? " AND (SourceTimeUtc < @ct OR (SourceTimeUtc = @ct AND ModifiedId < @cid))"
: " AND (SourceTimeUtc > @ct OR (SourceTimeUtc = @ct AND ModifiedId > @cid))";
var order = reverse ? "DESC" : "ASC";
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 {(bounded ? "TOP (@take) " : "")}SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, " +
"ServerTimeUtc, ModifiedId, ModificationTimeUtc, UpdateType, Principal " +
$"FROM {_conn.Q("SampleModified")} WHERE TagId = @id AND {where} " +
$"ORDER BY SourceTimeUtc {order}, ModifiedId {order}");
if (bounded) cmd.Parameters.Add(new SqlParameter("@take", SqlDbType.BigInt) { Value = (long)maxValues + 1 });
cmd.Parameters.Add(new SqlParameter("@id", SqlDbType.Int) { Value = id });
SqlHistorianConnection.AddDateTime2(cmd, "@lo", lo);
SqlHistorianConnection.AddDateTime2(cmd, "@hi", hi);
if (resumeAfter is not null)
{
SqlHistorianConnection.AddDateTime2(cmd, "@ct", resumeAfter.SourceTimeUtc);
cmd.Parameters.Add(new SqlParameter("@cid", SqlDbType.BigInt) { Value = resumeAfter.ModifiedId });
}
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
if (bounded && values.Count == maxValues)
{
hasMore = true;
break;
}
var sourceTime = SqlHistorianConnection.GetUtc(reader, 0);
var modifiedAt = SqlHistorianConnection.GetUtc(reader, 8);
DataValueSnapshot prior;
if (reader.IsDBNull(1))
{
// An insert: there was no prior value.
prior = new DataValueSnapshot(null, HistorianStatusCodes.BadNoData, sourceTime, modifiedAt);
}
else
{
var s = ReadStored(reader, 0);
prior = new DataValueSnapshot(s.Value.ToObject(), s.Status, s.TimeUtc,
reader.IsDBNull(6) ? modifiedAt : SqlHistorianConnection.GetUtc(reader, 6));
}
values.Add(new HistoryModifiedValue(
prior, modifiedAt, (HistoryModificationType)reader.GetByte(9), reader.GetString(10), reader.GetInt64(7), sourceTime));
}
}
RecordSuccess();
return new HistoryModifiedReadResult(values, hasMore);
}
catch (Exception ex) when (ex is not NotSupportedException)
{
RecordFailure(ex);
throw;
}
}
// ------------------------------------------------------------------------------------------ AtTime
///
///
/// One statement: the distinct requested times go in a TimeRow TVP and each picks up its
/// previous-or-equal (p) and next (n) stored sample via two OUTER APPLY (SELECT TOP 1 …).
/// Rules, per requested time T (the result is stamped T):
///
/// - p at exactly T ⇒ the stored value and status, unchanged.
/// - No p ⇒ BadNoData, null value.
/// - p Bad (incl. an Empty outage marker) ⇒ T lies inside an outage: null value
/// carrying p's Bad status code (info bits stripped) — never better than its source, per the
/// quality contract (spec §4 as amended after the T10 review).
/// - p usable and n Bad ⇒ p's value held (stepped, NOT interpolated into the outage)
/// with UncertainDataSubNormal + Interpolated bits.
/// - Double/Int64 with a usable n ⇒ linear interpolation of NumValue (Int64 rounded to the
/// nearest integer, ties away from zero, keeping the type); status Good + Interpolated when both
/// neighbours are Good, else UncertainDataSubNormal + Interpolated (never better than the
/// inputs).
/// - Boolean/String/DateTime, or no n (after the last sample) ⇒ stepped: p's value with
/// p's status code + Interpolated bits — except that a time later than now that is stepped from the
/// last sample is UncertainDataSubNormal + Interpolated (the value is not known to still hold).
///
/// Unknown tag ⇒ every entry BadNoData.
///
public async Task ReadAtTimeAsync(
string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReference);
ArgumentNullException.ThrowIfNull(timestampsUtc);
try
{
var now = _time.GetUtcNow().UtcDateTime;
var byTicks = new Dictionary(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] = AtTimeEvaluator.Evaluate(t, p, n, serverTime, now);
}
}
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, HistorianStatusCodes.BadNoData, t, now);
}
RecordSuccess();
return new HistoryReadResult(result, ContinuationPoint: null);
}
catch (Exception ex)
{
RecordFailure(ex);
throw;
}
}
// ------------------------------------------------------------------------------------------ Events
///
/// Reverse events reads are served natively (ORDER BY … DESC, Gitea #675).
public bool SupportsReverseEvents => true;
///
///
/// [start, end) by EventTimeUtc (a reversed window is swapped), ordered by
/// (EventTimeUtc, EventId) ascending — the clustered index. <= 0 or
/// (the node manager's "no limit") ⇒ unbounded. Otherwise maxEvents + 1
/// rows are fetched; when the extra row arrives it is dropped and the continuation point is the UTF-8
/// of "{EventTimeUtc.Ticks}|{EventId}" of that first not-returned event (opaque to callers; only
/// its presence is contractual).
///
public Task ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) =>
ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, reverse: false, cancellationToken);
///
///
/// Forward is the five-argument read. Reverse (Gitea #675) is (lo, hi] ordered by
/// (EventTimeUtc, EventId) DESCENDING — the clustered index scanned backwards — so TOP keeps the latest
/// events; truncation and the continuation point work as forward.
///
public async Task ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse, CancellationToken cancellationToken)
{
// No stored event can carry a source longer than the column (the alarm writer dead-letters it rather than
// truncating), so an over-long filter matches nothing — never a truncated prefix of someone else's source.
if (sourceName is not null && sourceName.Length > HistorianTagNames.MaxLength)
return new HistoricalEventsResult([], null);
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 " +
(reverse ? "EventTimeUtc > @lo AND EventTimeUtc <= @hi" : "EventTimeUtc >= @lo AND EventTimeUtc < @hi") +
(sourceName is null ? "" : " AND SourceName = @source") +
(reverse ? " ORDER BY EventTimeUtc DESC, EventId DESC" : " 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, SqlServerTagCatalog.MaxNameLength) { Value = sourceName });
var events = new List();
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),
HistoricalEventRows.ClampSeverity(reader.GetInt32(5)));
if (bounded && events.Count == maxEvents)
{
continuation = Encoding.UTF8.GetBytes(EventContinuation.Encode(e.EventTimeUtc, e.EventId));
break;
}
events.Add(e);
}
RecordSuccess();
return new HistoricalEventsResult(events, continuation);
}
catch (Exception ex)
{
RecordFailure(ex);
throw;
}
}
// ------------------------------------------------------------------------------------------ Processed
///
///
///
/// One result per interval of [start, end), bucket i = [start + i·interval,
/// start + (i+1)·interval) (start-inclusive, end-exclusive to the tick; the last bucket is truncated at
/// end). <= 0 ⇒ one interval [start, end). start == end
/// ⇒ no intervals. end < start (reverse, #677) ⇒ Part 13 / SDK anchoring: window (end, start],
/// buckets stepping back from start (the partial one is the earliest), each stamped with its later edge,
/// returned latest first ().
///
///
/// Per-sample aggregates, per interval: no samples ⇒ BadNoData. Otherwise no usable sample ⇒
/// BadNoData; a numeric aggregate with a usable String/DateTime sample ⇒ BadAggregateInvalidInputs;
/// else the value, Good when every sample is Good, UncertainDataSubNormal when any is Bad or Uncertain.
/// Every calculated status — including BadNoData — carries the Calculated info bits. Timestamps are the
/// bucket start (its later edge on a reverse read), except Minimum/MaximumActualTime (the sample's time; ties ⇒
/// earliest) and Start/End. Every aggregate but Average sets the Partial bit on the bucket the tag's earliest
/// sample lies strictly inside (forward, #677) or its latest sample lies in (#680; reverse: strictly inside).
/// PercentGood / PercentBad are duration-based since #680 (time-weighted family, below). #680: Count counts Good samples (value-based status; 0/Good on an empty bucket inside the tag's
/// data range), Start/End are the raw first/last sample of any quality, Delta is Uncertain only when the bucket's
/// first or last sample is not usable (see ).
///
///
/// Values: Count is ; Start/End keep the sample's type; every other aggregate is
/// computed over NumValue (Boolean = 0/1). These are the simple per-sample
/// definitions. The Part 13 time-weighted family (TimeAverage, TimeAverage2, Total, Total2, Interpolative, #671) is
/// not pushed down: the window's rows plus four bounding rows are streamed into the shared
/// , which fills every interval (an empty one between bounds gets a value).
///
///
/// The aggregate is not one of the seventeen served.
/// The window holds more than intervals.
public async Task ReadProcessedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReference);
var family = AggregateFamilies.FamilyOf(aggregate) ?? throw new HistoryAggregateNotSupportedException(aggregate);
var plan = BucketPlan.Create(startUtc, endUtc, interval);
try
{
var now = _time.GetUtcNow().UtcDateTime;
var tagId = plan.BucketCount == 0 ? null : await ResolveAsync(fullReference, cancellationToken).ConfigureAwait(false);
if (family == AggregateFamily.TimeWeighted)
{
var tw = new TimeWeightedAggregator(aggregate, plan);
if (tagId is { } twId) await StreamTimeWeightedAsync(twId, fullReference, plan, tw, cancellationToken).ConfigureAwait(false);
var twResult = tw.Finish(now);
RecordSuccess();
return new HistoryReadResult(twResult, ContinuationPoint: null);
}
var rows = new List();
DateTime? earliest = null, latest = null;
if (tagId is { } id)
{
var sample = _conn.Q("Sample");
var sql = family switch
{
AggregateFamily.Simple => SqlAggregateQueries.Simple(sample, plan.Reversed),
AggregateFamily.StartEnd => SqlAggregateQueries.StartEnd(sample, plan.Reversed),
_ => SqlAggregateQueries.ActualTime(sample, aggregate == HistoryAggregateType.MaximumActualTime, plan.Reversed),
};
// Second result set: the tag's data range (earliest + latest sample) — the start/end-of-data Partial rules
// (#677/#680 — any per-sample aggregate but Average, either direction) and Count's in-range empty interval (#680).
var needsRange = AggregateEvaluator.NeedsDataRange(aggregate, plan);
if (needsRange) sql += " " + SqlAggregateQueries.DataRange(sample);
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(plan.StartUtc, DateTimeKind.Utc));
SqlHistorianConnection.AddDateTime2(cmd, "@end", DateTime.SpecifyKind(plan.EndUtc, DateTimeKind.Utc));
cmd.Parameters.Add(new SqlParameter("@intervalTicks", SqlDbType.BigInt) { Value = plan.IntervalTicks });
cmd.Parameters.Add(new SqlParameter("@bucketAdjustTicks", SqlDbType.BigInt) { Value = plan.BucketIndexAdjustTicks });
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
rows.Add(ReadBucket(reader, family));
if (needsRange && await reader.NextResultAsync(cancellationToken).ConfigureAwait(false)
&& await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
if (!reader.IsDBNull(0)) earliest = SqlHistorianConnection.GetUtc(reader, 0);
if (!reader.IsDBNull(1)) latest = SqlHistorianConnection.GetUtc(reader, 1);
}
}
var result = AggregateEvaluator.EvaluateAll(aggregate, plan, rows, earliest, latest, now);
RecordSuccess();
return new HistoryReadResult(result, ContinuationPoint: null);
}
catch (HistoryReadLimitExceededException)
{
// A client-request limit (the raw-row cap), not a backend fault: not counted as a failure.
throw;
}
catch (Exception ex)
{
RecordFailure(ex);
throw;
}
}
///
/// Streams the time-weighted batch () into : the
/// two before-bounds, the window, the two after-bounds — ascending, with duplicates dropped by the aggregator.
///
private async Task StreamTimeWeightedAsync(int id, string tag, BucketPlan plan, TimeWeightedAggregator tw, CancellationToken ct)
{
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = _conn.CreateCommand(conn, SqlAggregateQueries.TimeWeighted(_conn.Q("Sample")));
cmd.Parameters.Add(new SqlParameter("@id", SqlDbType.Int) { Value = id });
SqlHistorianConnection.AddDateTime2(cmd, "@start", DateTime.SpecifyKind(plan.StartUtc, DateTimeKind.Utc));
SqlHistorianConnection.AddDateTime2(cmd, "@end", DateTime.SpecifyKind(plan.EndUtc, DateTimeKind.Utc));
cmd.Parameters.Add(new SqlParameter("@maxRows", SqlDbType.Int)
{ Value = TimeWeightedAggregator.WindowFetchLimit(_maxTimeWeightedRawRows) });
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
var resultSet = 0;
do
{
long windowRows = 0;
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
// Result set 2 is the window (TOP (@maxRows) = cap + 1): one row past the cap fails the node.
if (resultSet == 2) TimeWeightedAggregator.CheckWindowRows(++windowRows, _maxTimeWeightedRawRows, tag);
tw.Add(ReadStored(reader, 0));
}
resultSet++;
} while (await reader.NextResultAsync(ct).ConfigureAwait(false));
}
private static BucketRow ReadBucket(SqlDataReader reader, AggregateFamily 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 : SampleColumnMapper.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 AggregateFamily.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 AggregateFamily.StartEnd:
row.First = V(reader, f);
row.Last = V(reader, f + 5);
row.FirstAny = ReadStored(reader, SqlAggregateQueries.FirstAnyColumn);
row.LastAny = ReadStored(reader, SqlAggregateQueries.FirstAnyColumn + 6);
break;
default:
row.Extreme = D(reader, f);
row.ExtremeTime = reader.IsDBNull(f + 1) ? null : SqlHistorianConnection.GetUtc(reader, f + 1);
break;
}
return row;
}
// ------------------------------------------------------------------------------------------ Shared
///
/// Reads SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode starting at .
/// An Empty (no-value) row whose stored status is not Bad is clamped to BadNoData
/// () — defence in depth for rows written before the writer forced every
/// Empty sample Bad.
///
private static StoredSample ReadStored(SqlDataReader r, int o)
{
var value = SampleColumnMapper.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));
var status = SampleColumnMapper.FromDbStatus(r.GetInt32(o + 5));
return StoredSample.FromStored(SqlHistorianConnection.GetUtc(r, o), value, status);
}
/// Read-only tag lookup (never creates a row); runs the schema bootstrap on a cache miss.
private Task ResolveAsync(string name, CancellationToken ct) => _catalog.TryGetTagIdAsync(name, ct);
// ------------------------------------------------------------------------------------------ Health
///
public HistorianHealthSnapshot GetHealthSnapshot() => _health.Snapshot();
private void RecordSuccess() => _health.RecordSuccess();
private void RecordFailure(Exception ex)
{
// A cancelled read (deadline / client abort) is not a backend fault (the counters ignore it too).
if (ex is OperationCanceledException) return;
_health.RecordFailure(ex);
_log.LogDebug("SQL historian read failed ({ExceptionType})", ex.GetType().Name);
}
///
public void Dispose()
{
// Connections are per call (pooled by SqlClient); nothing to release.
}
}