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; /// /// 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 them 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 = 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 TimeProvider _time; 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; /// 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. internal SqlServerHistorianDataSource( SqlHistorianConnection conn, SqlServerSchemaBootstrapper bootstrap, SqlServerTagCatalog catalog, ILogger? log = null, TimeProvider? timeProvider = 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; _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; } } // ------------------------------------------------------------------------------------------ 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] = AtTimeValue(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, SqlHistorianStatusCodes.BadNoData, t, now); } RecordSuccess(); return new HistoryReadResult(result, ContinuationPoint: null); } catch (Exception ex) { RecordFailure(ex); throw; } } /// Applies the AtTime rules (see ) to one requested time. private static DataValueSnapshot AtTimeValue(DateTime t, StoredSample? prev, StoredSample? next, DateTime serverTime, DateTime now) { 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); // Inside an outage: report the outage's own Bad code, never an upgraded Uncertain. if (!p.IsUsable) return new DataValueSnapshot(null, p.Status & StatusBits.CodeBitsMask, t, serverTime); // Usable previous, Bad next: hold the previous value up to the outage; do not interpolate into it. if (next is { IsUsable: false }) return new DataValueSnapshot(p.Value.ToObject(), 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); } // Beyond the last sample AND beyond now: the value is held into the future, so it is not known to be Good. if (next is null && t > now) return new DataValueSnapshot(p.Value.ToObject(), HistorianInfoBits.WithInterpolated(SqlHistorianStatusCodes.UncertainDataSubNormal), 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 /// /// /// [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 async Task ReadEventsAsync( string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, 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 > SqlServerTagCatalog.MaxNameLength) 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 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, 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), (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 /// /// /// /// 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) ⇒ the window is swapped, computed forward, and returned /// in descending bucket order (buckets stay anchored at the earlier time). /// /// /// Per interval: no samples ⇒ BadNoData. PercentGood / PercentBad ⇒ 100 × good|bad / all over /// every sample, status Good. 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 status — including BadNoData — /// carries the Calculated info bits. Timestamps are the bucket start, except Minimum/MaximumActualTime /// (the sample's time; ties ⇒ earliest). /// /// /// 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, not Part 13's interval-boundary (interpolated bound / time-weighted) semantics. /// /// /// The aggregate is not one of the thirteen 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 = 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; // Everything stays in 100 ns ticks (≤ ≈3.2e18 across 0001–9999, inside a long) — no ns scaling that could // wrap. An interval wider than the window is one bucket, so clamp it to the window; the ceiling division // is written without "+ interval - 1" so a TimeSpan near MaxValue cannot overflow either. var intervalTicks = interval > TimeSpan.Zero ? Math.Min(interval.Ticks, windowTicks) : windowTicks; long bucketCount = windowTicks == 0 ? 0 : windowTicks / intervalTicks + (windowTicks % intervalTicks == 0 ? 0 : 1); 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 = _time.GetUtcNow().UtcDateTime; var rows = new Dictionary(); 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("@intervalTicks", SqlDbType.BigInt) { Value = intervalTicks }); 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((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; } /// One aggregate query row (one non-empty bucket). 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 /// A stored sample read from the {SampleColumns} column run starting at an ordinal. private readonly record struct StoredSample(DateTime TimeUtc, HistorianValue Value, uint Status) { /// Severity Good or Uncertain, and a value present (an Empty sample is an outage marker). public bool IsUsable => !StatusBits.IsBad(Status) && Value.Kind != HistorianValueKind.Empty; /// Usable with severity Good. public bool IsGood => IsUsable && StatusBits.IsGood(Status); } /// /// 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 = 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)); var status = SampleRowMapper.FromDbStatus(r.GetInt32(o + 5)); if (value.Kind == HistorianValueKind.Empty && !StatusBits.IsBad(status)) status = SqlHistorianStatusCodes.BadNoData; return new StoredSample(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() { 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); } /// public void Dispose() { // Connections are per call (pooled by SqlClient); nothing to release. } }