From 8c6d6aff6a352045e45cdc5cdf5c761569921aad Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 11 Oct 2026 01:26:49 -0400 Subject: [PATCH] refactor(historian): extract Driver.Historian.Sql.Common from the SQL Server backend (no behaviour change) (1a) Moves the database-independent logic of the SQL Server historian backend into a new shared library for the upcoming QuestDB backend (#666): status codes and bits, value/column mapping, StoredSample + quality clamp, the AtTime and aggregate evaluators, BucketPlan, LiveWritePreparer and HistoryUpdatePlanner (both with a resolutionTicks parameter, default 1), tag-name rules, the bootstrap failure cache, health counters, alarm event ids, the event continuation and severity clamp. The SQL Server backend now calls it; its T-SQL, TVP builders, connection, bootstrapper and catalog stay. Tests: StatusCodeOracleTests, ValueWriterPrepareTests, the column half of SampleRowMapperTests and ProcessedReadTests.Every_enum_member_is_either_served_or_refused move to the new Sql.Common.Tests under the same names. SqlServer integration tests get mechanical renames only. Test-name set across the three projects is identical to the baseline (160 tests). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- ZB.MOM.WW.OtOpcUa.slnx | 2 + .../AggregateEvaluator.cs | 78 ++++++ .../AggregateFamily.cs | 33 +++ .../AlarmEventIds.cs | 61 +++++ .../AtTimeEvaluator.cs | 67 +++++ .../BootstrapFailureCache.cs | 46 ++++ .../BucketPlan.cs | 91 +++++++ .../BucketRow.cs | 57 +++++ .../EventContinuation.cs | 11 + .../HistorianDataTypes.cs} | 8 +- .../HistorianHealthCounters.cs | 70 ++++++ .../HistorianInfoBits.cs | 32 +++ .../HistorianSampleRow.cs | 9 + .../HistorianSchemaMissingException.cs | 9 + .../HistorianStatusCodes.cs | 27 ++ .../HistorianTagNames.cs | 17 ++ .../HistoricalEventRows.cs | 10 + .../HistoryUpdatePlanner.cs | 175 +++++++++++++ .../LiveWritePreparer.cs | 54 ++++ .../SampleColumns.cs | 66 +++++ .../StatusBits.cs | 33 +++ .../StoredSample.cs | 32 +++ ...OtOpcUa.Driver.Historian.Sql.Common.csproj | 22 ++ .../SampleRowMapper.cs | 61 +---- .../SqlAggregateQueries.cs | 31 +-- .../SqlHistorianStatusCodes.cs | 81 ------ .../SqlServerAlarmHistorianWriter.cs | 55 +---- .../SqlServerHistorianDataSource.cs | 230 +++--------------- .../SqlServerHistorianDataUpdater.cs | 115 +++------ .../SqlServerHistorianOptions.cs | 8 - .../SqlServerHistorianValueWriter.cs | 42 +--- .../SqlServerSchemaBootstrapper.cs | 22 +- .../SqlServerTagCatalog.cs | 14 +- ....OtOpcUa.Driver.Historian.SqlServer.csproj | 2 + ....WW.OtOpcUa.Core.Abstractions.Tests.csproj | 2 + .../ProcessedReadTests.cs | 16 ++ .../SampleRowMapperTests.cs | 45 ++++ .../StatusCodeOracleTests.cs | 10 +- .../ValueWriterPrepareTests.cs | 18 +- ...a.Driver.Historian.Sql.Common.Tests.csproj | 28 +++ .../LongNameTests.cs | 1 + .../ProcessedReadTests.cs | 7 - .../QualityContractTests.cs | 5 +- .../RawReadTests.cs | 7 +- .../SchemaBootstrapIntegrationTests.cs | 9 +- .../UpdateTests.cs | 47 ++-- .../WriteTests.cs | 9 +- .../AlarmEventIdTests.cs | 19 +- .../SampleRowMapperTests.cs | 40 +-- 49 files changed, 1262 insertions(+), 672 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateEvaluator.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateFamily.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AlarmEventIds.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AtTimeEvaluator.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/BootstrapFailureCache.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/BucketPlan.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/BucketRow.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/EventContinuation.cs rename src/Drivers/{ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlHistorianDataTypes.cs => ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianDataTypes.cs} (72%) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianHealthCounters.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianInfoBits.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianSampleRow.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianSchemaMissingException.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianStatusCodes.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianTagNames.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistoricalEventRows.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistoryUpdatePlanner.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/LiveWritePreparer.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/SampleColumns.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/StatusBits.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/StoredSample.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.csproj delete mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlHistorianStatusCodes.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ProcessedReadTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SampleRowMapperTests.cs rename tests/Drivers/{ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests => ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests}/StatusCodeOracleTests.cs (77%) rename tests/Drivers/{ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests => ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests}/ValueWriterPrepareTests.cs (68%) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 959ac9ac..16290f6c 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -27,6 +27,7 @@ + @@ -101,6 +102,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateEvaluator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateEvaluator.cs new file mode 100644 index 00000000..f79c3747 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateEvaluator.cs @@ -0,0 +1,78 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +/// +/// Maps one bucket's statistics () to the OPC UA value and status of an aggregate. +/// +/// +/// +/// 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 the numeric value (Boolean = 0/1). These are the simple per-sample +/// definitions, not Part 13's interval-boundary (interpolated bound / time-weighted) semantics. +/// +/// +public static class AggregateEvaluator +{ + /// Evaluates over one non-empty bucket. + /// Requested aggregate. + /// The bucket's statistics. + /// The bucket's start time (the result timestamp). + /// Server timestamp. + /// The calculated value. + /// The aggregate is not one of the thirteen served. + public 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, HistorianStatusCodes.Good); + case HistoryAggregateType.PercentBad: return Calc(100d * r.Bad / r.Total, HistorianStatusCodes.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, HistorianStatusCodes.BadAggregateInvalidInputs); + + var status = r.Good == r.Total ? HistorianStatusCodes.Good : HistorianStatusCodes.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, HistorianStatusCodes.BadAggregateInvalidInputs); + case HistoryAggregateType.MinimumActualTime: + case HistoryAggregateType.MaximumActualTime: + return Calc(r.Extreme, status, r.ExtremeTime); + default: + throw new HistoryAggregateNotSupportedException(aggregate); + } + } + + /// The result for a bucket with no samples (or no usable sample): BadNoData + Calculated. + /// The bucket's start time. + /// Server timestamp. + /// The no-data value. + public static DataValueSnapshot NoData(DateTime t, DateTime now) => + new(null, HistorianInfoBits.WithCalculated(HistorianStatusCodes.BadNoData), t, now); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateFamily.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateFamily.cs new file mode 100644 index 00000000..761a0cab --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateFamily.cs @@ -0,0 +1,33 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +/// The query shape that serves an aggregate. One query per family. +public enum AggregateFamily +{ + /// Average / Minimum / Maximum / Total / Count / Range / PercentGood / PercentBad: one GROUP BY. + Simple, + + /// Start / End / Delta: first + last usable sample per bucket. + StartEnd, + + /// MinimumActualTime / MaximumActualTime: extreme usable sample per bucket, ties ⇒ earliest. + ActualTime, +} + +/// Which aggregates the SQL historian backends serve, and by which family. +public static class AggregateFamilies +{ + /// The family that serves , or null when the backends do not. + /// Requested aggregate. + /// The family, or null for an unsupported aggregate. + public static AggregateFamily? 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 => AggregateFamily.Simple, + HistoryAggregateType.Start or HistoryAggregateType.End or HistoryAggregateType.Delta => AggregateFamily.StartEnd, + HistoryAggregateType.MinimumActualTime or HistoryAggregateType.MaximumActualTime => AggregateFamily.ActualTime, + _ => null, + }; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AlarmEventIds.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AlarmEventIds.cs new file mode 100644 index 00000000..5dbaf08b --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AlarmEventIds.cs @@ -0,0 +1,61 @@ +using System.Security.Cryptography; +using System.Text; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +/// +/// The deterministic alarm-event id and numeric severity the SQL historian backends store, so the store-and-forward +/// drain's at-least-once redelivery (and both pair nodes writing the same event) converge on one row. +/// +public static class AlarmEventIds +{ + /// + /// Lowercase-hex SHA-256 over length-prefixed UTF-8 fields (little-endian int32 byte length, -1 for null): + /// EquipmentPath, AlarmId, EventKind, TimestampUtc.Ticks (decimal), User, Comment, Message. Length-prefixing keeps + /// field boundaries unambiguous ("a|b"+"c" ≠ "a"+"b|c"). + /// + /// The event. + /// 64 hex characters. + public static string ComputeEventId(AlarmHistorianEvent e) + { + using var ms = new MemoryStream(); + Field(ms, e.EquipmentPath); + Field(ms, e.AlarmId); + Field(ms, e.EventKind); + Field(ms, e.TimestampUtc.Ticks.ToString(System.Globalization.CultureInfo.InvariantCulture)); + Field(ms, e.User); + Field(ms, e.Comment); + Field(ms, e.Message); + return Convert.ToHexStringLower(SHA256.HashData(ms.ToArray())); + } + + /// The numeric Part 9 priority stored for a severity (same values the gateway path sends). + /// Alarm severity. + /// 200 / 500 / 700 / 900; 500 for anything else. + public static int ToNumericSeverity(AlarmSeverity s) => s switch + { + AlarmSeverity.Low => 200, + AlarmSeverity.Medium => 500, + AlarmSeverity.High => 700, + AlarmSeverity.Critical => 900, + _ => 500, + }; + + private static void Field(MemoryStream ms, string? value) + { + Span len = stackalloc byte[4]; + if (value is null) + { + System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(len, -1); + ms.Write(len); + return; + } + + var bytes = Encoding.UTF8.GetBytes(value); + System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(len, bytes.Length); + ms.Write(len); + ms.Write(bytes); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AtTimeEvaluator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AtTimeEvaluator.cs new file mode 100644 index 00000000..9440b843 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AtTimeEvaluator.cs @@ -0,0 +1,67 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +/// The AtTime rules, applied to one requested time and its stored neighbours. +public static class AtTimeEvaluator +{ + /// + /// Applies the AtTime rules to one requested time (the result is stamped t): + /// + /// prev at exactly t ⇒ the stored value and status, unchanged. + /// No prev ⇒ BadNoData, null value. + /// prev Bad (incl. an Empty outage marker) ⇒ t lies inside an outage: null value + /// carrying prev's Bad status code (info bits stripped) — never better than its source. + /// prev usable and next Bad ⇒ prev's value held (stepped, NOT interpolated into the + /// outage) with UncertainDataSubNormal + Interpolated bits. + /// Double/Int64 with a usable next ⇒ linear interpolation (Int64 rounded to the nearest integer, + /// ties away from zero, keeping the type); status Good + Interpolated when both neighbours are Good, + /// else UncertainDataSubNormal + Interpolated. + /// Boolean/String/DateTime, or no next (after the last sample) ⇒ stepped: prev's value with + /// prev's status code + Interpolated bits — except that a time later than now that is stepped from the + /// last sample is UncertainDataSubNormal + Interpolated. + /// + /// + /// Requested time. + /// The stored sample at or before , if any. + /// The stored sample after , if any. + /// Server timestamp for the result. + /// The current time (for the beyond-now rule). + /// The value at . + public static DataValueSnapshot Evaluate(DateTime t, StoredSample? prev, StoredSample? next, DateTime serverTime, DateTime now) + { + if (prev is not { } p) + return new DataValueSnapshot(null, HistorianStatusCodes.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(HistorianStatusCodes.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 ? HistorianStatusCodes.Good : HistorianStatusCodes.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(HistorianStatusCodes.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); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/BootstrapFailureCache.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/BootstrapFailureCache.cs new file mode 100644 index 00000000..2dfd1f67 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/BootstrapFailureCache.cs @@ -0,0 +1,46 @@ +using System.Runtime.ExceptionServices; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +/// +/// The negative cache a schema bootstrapper keeps for its last failure: within the cache window the failure is +/// re-thrown without touching the database, so a missing schema (or an outage) does not serialise every write, read +/// and alarm event behind one semaphore and one round-trip each. A failure is NOT latched: after the window the +/// caller retries. Not thread-safe; the caller guards it (the bootstrapper's gate). +/// +public sealed class BootstrapFailureCache +{ + /// How long the last bootstrap failure is re-thrown before the database is tried again (5 s). + public static readonly TimeSpan DefaultDuration = TimeSpan.FromSeconds(5); + + private readonly TimeProvider _clock; + private readonly TimeSpan _duration; + private ExceptionDispatchInfo? _lastFailure; + private DateTimeOffset _retryAfter; + + /// Creates the cache. + /// Clock for the cache window. + /// How long a recorded failure is re-thrown (normally ). + public BootstrapFailureCache(TimeProvider clock, TimeSpan duration) + { + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _duration = duration; + } + + /// Re-throws the last recorded failure (original stack preserved) while its window is open. + public void ThrowIfCached() + { + if (_lastFailure is { } cached && _clock.GetUtcNow() < _retryAfter) cached.Throw(); + } + + /// Records a failure; it is re-thrown by for the cache window. + /// The bootstrap failure. + public void Record(Exception ex) + { + _lastFailure = ExceptionDispatchInfo.Capture(ex); + _retryAfter = _clock.GetUtcNow() + _duration; + } + + /// Forgets any recorded failure (after a success). + public void Clear() => _lastFailure = null; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/BucketPlan.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/BucketPlan.cs new file mode 100644 index 00000000..29c95bd1 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/BucketPlan.cs @@ -0,0 +1,91 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +/// +/// The bucket layout of a processed (aggregate) read. Bucket i = [start + i·interval, +/// start + (i+1)·interval) (start-inclusive, end-exclusive to the tick; the last bucket is truncated at +/// end). An interval <= 0 ⇒ one interval [start, end). start == end ⇒ no intervals. +/// end < start (reverse) ⇒ the window is swapped, computed forward, and assembled in descending bucket +/// order (buckets stay anchored at the earlier time). +/// +public sealed class BucketPlan +{ + /// Spec §9.7: a processed read covering more intervals than this is refused. + public const int MaxBuckets = 100_000; + + private BucketPlan(DateTime startUtc, DateTime endUtc, bool reversed, long intervalTicks, long bucketCount) + { + StartUtc = startUtc; + EndUtc = endUtc; + Reversed = reversed; + IntervalTicks = intervalTicks; + BucketCount = bucketCount; + } + + /// The earlier end of the window (the forward start). + public DateTime StartUtc { get; } + + /// The later end of the window (the forward, exclusive end). + public DateTime EndUtc { get; } + + /// True when the request came in reversed (end < start). + public bool Reversed { get; } + + /// Bucket width in 100 ns ticks (clamped to the window; 0 for an empty window). + public long IntervalTicks { get; } + + /// Number of buckets (0 for an empty window). + public long BucketCount { get; } + + /// as 100 ns ticks since the Unix epoch. + public long StartUnixTicks => (StartUtc - DateTime.UnixEpoch).Ticks; + + /// Plans the buckets of a processed read. + /// Requested start. + /// Requested end (earlier than start for a reverse read). + /// Bucket width; <= 0 means one bucket over the whole window. + /// The plan. + /// The window holds more than intervals + /// (parameter name interval). + public static BucketPlan Create(DateTime startUtc, DateTime endUtc, TimeSpan interval) + { + 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 > MaxBuckets) + throw new ArgumentOutOfRangeException(nameof(interval), bucketCount, + $"A processed read may cover at most {MaxBuckets} intervals; this window holds {bucketCount}."); + return new BucketPlan(start, end, reverse, intervalTicks, bucketCount); + } + + /// The start time of bucket (UTC kind). + /// Zero-based bucket index. + /// The bucket start. + public DateTime BucketStart(long index) => + DateTime.SpecifyKind(new DateTime(StartUtc.Ticks + index * IntervalTicks), DateTimeKind.Utc); + + /// One snapshot per bucket in window order; missing buckets get ; reversed if the window was. + /// Evaluated snapshots of the non-empty buckets, by bucket index. + /// Builds the snapshot of a bucket with no row, from its start time. + /// snapshots. + public IReadOnlyList Assemble(IReadOnlyDictionary byBucket, + Func empty) + { + ArgumentNullException.ThrowIfNull(byBucket); + ArgumentNullException.ThrowIfNull(empty); + var result = new List((int)BucketCount); + for (long i = 0; i < BucketCount; i++) + { + var bucketStart = BucketStart(i); + result.Add(byBucket.TryGetValue(i, out var v) ? v : empty(bucketStart)); + } + if (Reversed) result.Reverse(); + return result; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/BucketRow.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/BucketRow.cs new file mode 100644 index 00000000..2f178a39 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/BucketRow.cs @@ -0,0 +1,57 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +/// +/// Per-bucket statistics one aggregate query row produces (one non-empty bucket). Every backend's SQL fills the same +/// fields; turns them into the OPC UA value and status. +/// +/// +/// Sample classes: usable = severity Good or Uncertain AND a stored value (an Empty sample is an outage marker +/// and counts as Bad whatever its code); good = usable with severity Good; bad = not usable. +/// Every value field is computed over usable samples only. +/// +public sealed class BucketRow +{ + /// Zero-based bucket index within the window. + public long Bucket { get; set; } + + /// All samples in the bucket. + public int Total { get; set; } + + /// Usable samples. + public int Usable { get; set; } + + /// Good samples. + public int Good { get; set; } + + /// Bad (not usable) samples. + public int Bad { get; set; } + + /// Usable String / DateTime samples. + public int NonNumeric { get; set; } + + /// Average of the usable numeric values (null when none). + public double? Avg { get; set; } + + /// Minimum of the usable numeric values (null when none). + public double? Min { get; set; } + + /// Maximum of the usable numeric values (null when none). + public double? Max { get; set; } + + /// Sum of the usable numeric values (null when none). + public double? Sum { get; set; } + + /// First usable sample's value by time (Empty when none). + public HistorianValue First { get; set; } + + /// Last usable sample's value by time (Empty when none). + public HistorianValue Last { get; set; } + + /// Extreme (min or max) usable numeric value (null when none). + public double? Extreme { get; set; } + + /// Time of (ties ⇒ earliest; null when none). + public DateTime? ExtremeTime { get; set; } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/EventContinuation.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/EventContinuation.cs new file mode 100644 index 00000000..5e3ef7cb --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/EventContinuation.cs @@ -0,0 +1,11 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +/// The opaque Events continuation point of the SQL historian backends (only its presence is contractual). +public static class EventContinuation +{ + /// Encodes the first not-returned event as "{EventTimeUtc.Ticks}|{EventId}". + /// The event's time. + /// The event's id. + /// The continuation text (callers send its UTF-8 bytes). + public static string Encode(DateTime eventTimeUtc, string eventId) => $"{eventTimeUtc.Ticks}|{eventId}"; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlHistorianDataTypes.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianDataTypes.cs similarity index 72% rename from src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlHistorianDataTypes.cs rename to src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianDataTypes.cs index 90dbdb53..8e1787bb 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlHistorianDataTypes.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianDataTypes.cs @@ -1,18 +1,18 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; /// -/// The one place that maps value kinds to Tag.DataType (the schema stores a byte). +/// The one place that maps value kinds to the stored tag data type (a byte). /// Used by the value writer and the updater only when they have to create a tag row on a miss; provisioning /// later corrects it. /// -internal static class SqlHistorianDataTypes +public static class HistorianDataTypes { /// Data type used when a tag is created from a batch that holds no typed value. public const DriverDataType DefaultForEmpty = DriverDataType.Float64; - /// Maps a value kind to the stored Tag.DataType byte (Empty ⇒ ). + /// Maps a value kind to the stored tag data-type byte (Empty ⇒ ). /// Value kind. /// The as a byte. public static byte FromKind(HistorianValueKind kind) => (byte)(kind switch diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianHealthCounters.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianHealthCounters.cs new file mode 100644 index 00000000..1519b971 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianHealthCounters.cs @@ -0,0 +1,70 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +/// +/// The read-side health counters a SQL historian data source reports through +/// . Lock-guarded; safe to call from concurrent reads. +/// +public sealed class HistorianHealthCounters +{ + private readonly object _lock = new(); + private DateTime? _lastSuccessUtc; + private DateTime? _lastFailureUtc; + private string? _lastError; + private long _totalQueries; + private long _totalSuccesses; + private long _totalFailures; + private int _consecutiveFailures; + + /// Records a successful query. + public void RecordSuccess() + { + lock (_lock) + { + _totalQueries++; + _totalSuccesses++; + _consecutiveFailures = 0; + _lastSuccessUtc = DateTime.UtcNow; + } + } + + /// Records a failed query. An (deadline / client abort) is not a backend fault and is ignored. + /// The failure. + public void RecordFailure(Exception ex) + { + ArgumentNullException.ThrowIfNull(ex); + if (ex is OperationCanceledException) return; + lock (_lock) + { + _totalQueries++; + _totalFailures++; + _consecutiveFailures++; + _lastFailureUtc = DateTime.UtcNow; + _lastError = $"{ex.GetType().Name}: {ex.Message}"; + } + } + + /// The current counters. The connection is reported open when there has been a success and no failure since. + /// The snapshot. + public HistorianHealthSnapshot Snapshot() + { + lock (_lock) + { + 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: []); + } + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianInfoBits.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianInfoBits.cs new file mode 100644 index 00000000..1a067546 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianInfoBits.cs @@ -0,0 +1,32 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +/// Part 4 §7.39 historian info bits OR-ed onto a status code. +public static class HistorianInfoBits +{ + /// InfoType = DataValue. + public const uint InfoTypeDataValue = 0x00000400u; + + /// The value was produced by an aggregate calculation. + public const uint HistorianCalculated = 0x1u; + + /// The value was interpolated. + public const uint HistorianInterpolated = 0x2u; + + /// + /// Marks as a Calculated data value. Any historian bits the status already carries + /// (e.g. a stored Interpolated sample) are cleared first, so the result is never both. + /// + /// Base status code. + /// Status with InfoType and Calculated bits set. + public static uint WithCalculated(uint status) => + (status & ~StatusBits.HistorianBitsMask) | InfoTypeDataValue | HistorianCalculated; + + /// + /// Marks as an Interpolated data value. Any historian bits the status already + /// carries (e.g. a stored Calculated sample) are cleared first, so the result is never both. + /// + /// Base status code. + /// Status with InfoType and Interpolated bits set. + public static uint WithInterpolated(uint status) => + (status & ~StatusBits.HistorianBitsMask) | InfoTypeDataValue | HistorianInterpolated; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianSampleRow.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianSampleRow.cs new file mode 100644 index 00000000..cc37c856 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianSampleRow.cs @@ -0,0 +1,9 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +/// One sample bound for storage. +/// Source timestamp (UTC, at the backend's resolution). +/// Typed value. +/// OPC UA status code (stored bit-for-bit). +public readonly record struct HistorianSampleRow(DateTime TimeUtc, HistorianValue Value, uint StatusCode); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianSchemaMissingException.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianSchemaMissingException.cs new file mode 100644 index 00000000..f0955e94 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianSchemaMissingException.cs @@ -0,0 +1,9 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +/// Thrown when AutoCreateSchema=false and the historian schema is absent or behind. +public sealed class HistorianSchemaMissingException : Exception +{ + /// Creates the exception. + /// Message naming the missing version. + public HistorianSchemaMissingException(string message) : base(message) { } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianStatusCodes.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianStatusCodes.cs new file mode 100644 index 00000000..e8896f66 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianStatusCodes.cs @@ -0,0 +1,27 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +/// +/// Every OPC UA status the SQL historian backends emit, as named constants (inline literals are invisible to +/// StatusCodeParityTests). Values mirror Opc.Ua.StatusCodes. +/// +public static class HistorianStatusCodes +{ + public const uint Good = 0x00000000u; + public const uint GoodEntryInserted = 0x00A20000u; + public const uint GoodEntryReplaced = 0x00A30000u; + public const uint BadNoData = 0x809B0000u; + public const uint BadEntryExists = 0x809F0000u; + public const uint BadNoEntryExists = 0x80A00000u; + public const uint BadInvalidTimestamp = 0x80230000u; + public const uint BadAggregateInvalidInputs = 0x80D60000u; + public const uint UncertainDataSubNormal = 0x40A40000u; + + /// Stored for a non-finite Double (NaN / ±Infinity) that arrived with a non-Bad status. + public const uint BadOutOfRange = 0x803C0000u; + + /// + /// A HistoryUpdate value that cannot be stored: a Double NaN / ±Infinity, an Empty (null) value with a non-Bad + /// status, or a tag name longer than . + /// + public const uint BadInvalidArgument = 0x80AB0000u; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianTagNames.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianTagNames.cs new file mode 100644 index 00000000..aa3a1b73 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianTagNames.cs @@ -0,0 +1,17 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +/// +/// Historian tag-name rules. Names longer than are never truncated: a sized parameter +/// would silently cut them and resolve them onto whatever tag owns the first characters, so +/// they are refused (writes) or answered as not found (reads). +/// +public static class HistorianTagNames +{ + /// Longest storable tag (and event source) name; longer names are refused. + public const int MaxLength = 400; + + /// True when is storable without truncation. + /// Historian tag name. + /// Whether the name is non-null and at most characters. + public static bool IsValid(string? name) => name is not null && name.Length <= MaxLength; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistoricalEventRows.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistoricalEventRows.cs new file mode 100644 index 00000000..70de0a4b --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistoricalEventRows.cs @@ -0,0 +1,10 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +/// Helpers for turning stored event rows into HistoricalEvents. +public static class HistoricalEventRows +{ + /// Clamps a stored severity into the range the event carries. + /// Stored severity. + /// The severity clamped to 0..65535. + public static ushort ClampSeverity(int severity) => (ushort)Math.Clamp(severity, 0, ushort.MaxValue); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistoryUpdatePlanner.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistoryUpdatePlanner.cs new file mode 100644 index 00000000..3e9c27db --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistoryUpdatePlanner.cs @@ -0,0 +1,175 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +/// What the backend did with one HistoryUpdate winner row. +public enum UpsertOutcome +{ + /// The row was absent and was inserted. + Inserted, + + /// The row existed and was replaced. + Replaced, +} + +/// The database-independent half of one UpdateDataAsync call (see ). +public sealed class UpdatePlan +{ + internal UpdatePlan(uint?[] results, IReadOnlyList<(int Index, HistorianSampleRow Row)> winners, + long?[] keys, IReadOnlyDictionary winnerByTick) + { + Results = results; + Winners = winners; + Keys = keys; + WinnerByTick = winnerByTick; + } + + /// Pre-decided codes (invalid ts, NaN, superseded duplicate…); null = decided by the backend. + public uint?[] Results { get; } + + /// Rows to send, de-duplicated per mode, in input order. + public IReadOnlyList<(int Index, HistorianSampleRow Row)> Winners { get; } + + /// Per input item: its (truncated) timestamp ticks, or null when the timestamp is invalid. + internal long?[] Keys { get; } + + /// (Truncated) tick → input index of the item actually sent. + internal IReadOnlyDictionary WinnerByTick { get; } +} + +/// +/// Per-item result rules for OPC UA HistoryUpdate (spec §4 "Updates"): exactly one status per input item, in input +/// order. +/// Duplicate timestamps within one request (compared at the backend's resolution): Insert — the first +/// occurrence is applied, later ones are BadEntryExists; Replace/Update — last wins, and every earlier +/// occurrence of that timestamp reports the same code as the winning one. DeleteAtTime — the first occurrence +/// reports the real outcome, later ones BadNoEntryExists. +/// Invalid items. A null or timestamp is BadInvalidTimestamp. +/// A Double NaN/±Infinity is rejected with BadInvalidArgument. An Empty (null) value is only accepted with a +/// Bad status (an operator-authored outage marker); with a Good or Uncertain status it is BadInvalidArgument. +/// Other items still proceed. +/// Tag names over characters are refused, never truncated: +/// every item reports BadInvalidArgument and nothing is sent. +/// +public static class HistoryUpdatePlanner +{ + /// The pre-pass: classifies every item and picks the winner row per timestamp. + /// Historian tag name. + /// Insert, Replace or Update. + /// Input items, in request order. + /// 1 = 100 ns (SQL Server); 10 = timestamps truncated to µs BEFORE de-duplication (QuestDB). + /// The plan. + public static UpdatePlan Plan(string tag, HistoryUpdateMode mode, IReadOnlyList values, + long resolutionTicks = 1) + { + ArgumentNullException.ThrowIfNull(tag); + ArgumentNullException.ThrowIfNull(values); + ArgumentOutOfRangeException.ThrowIfLessThan(resolutionTicks, 1); + var results = new uint?[values.Count]; + var winner = new Dictionary(); // tick -> index of the item actually sent + if (!HistorianTagNames.IsValid(tag)) + { + Array.Fill(results, HistorianStatusCodes.BadInvalidArgument); + return new UpdatePlan(results, [], new long?[values.Count], winner); + } + var times = new DateTime[values.Count]; + var keys = new long?[values.Count]; + for (var i = 0; i < values.Count; i++) + { + var v = values[i]; + if (v.TimestampUtc is not { } ts || ts == DateTime.MinValue) + { + results[i] = HistorianStatusCodes.BadInvalidTimestamp; + continue; + } + times[i] = LiveWritePreparer.Truncate(ts, resolutionTicks); + keys[i] = times[i].Ticks; + if ((v.Value.Kind == HistorianValueKind.Double && !double.IsFinite(v.Value.Number)) + || (v.Value.Kind == HistorianValueKind.Empty && !StatusBits.IsBad(v.StatusCode))) + { + results[i] = HistorianStatusCodes.BadInvalidArgument; + continue; + } + var key = times[i].Ticks; + if (!winner.TryGetValue(key, out _)) + winner[key] = i; + else if (mode == HistoryUpdateMode.Insert) + results[i] = HistorianStatusCodes.BadEntryExists; // first occurrence stays the winner + else + winner[key] = i; // last wins + } + + var send = winner.Values.OrderBy(i => i) + .Select(i => (i, new HistorianSampleRow(times[i], values[i].Value, values[i].StatusCode))) + .ToList(); + return new UpdatePlan(results, send, keys, winner); + } + + /// The post-pass: fills in each winner's code from what the backend wrote, then the superseded duplicates. + /// The plan from . + /// The same mode passed to . + /// Ticks of each winner that the backend actually wrote → Inserted/Replaced; absent ⇒ not + /// written (Insert: the row already existed ⇒ BadEntryExists; Replace: no row ⇒ BadNoEntryExists). + /// One status per input item, in input order. + public static IReadOnlyList Complete(UpdatePlan plan, HistoryUpdateMode mode, + IReadOnlyDictionary outcomeByTick) + { + ArgumentNullException.ThrowIfNull(plan); + ArgumentNullException.ThrowIfNull(outcomeByTick); + var results = (uint?[])plan.Results.Clone(); + + // No written row means the absent branch: Insert => matched (exists); Replace => not matched. + var absent = mode == HistoryUpdateMode.Replace + ? HistorianStatusCodes.BadNoEntryExists + : HistorianStatusCodes.BadEntryExists; + foreach (var (index, row) in plan.Winners) + results[index] = outcomeByTick.TryGetValue(row.TimeUtc.Ticks, out var o) + ? o == UpsertOutcome.Inserted ? HistorianStatusCodes.GoodEntryInserted : HistorianStatusCodes.GoodEntryReplaced + : absent; + + var final = new uint[results.Length]; + for (var i = 0; i < results.Length; i++) final[i] = results[i] ?? 0u; + + // Superseded earlier duplicates (Replace/Update) share the winner's code. + if (mode != HistoryUpdateMode.Insert) + for (var i = 0; i < results.Length; i++) + if (plan.Keys[i] is { } key && results[i] is null + && plan.WinnerByTick.TryGetValue(key, out var w) && w != i) + final[i] = final[w]; + return final; + } + + /// + /// DeleteAtTime results: ⇒ BadInvalidTimestamp; the first occurrence of a + /// deleted time ⇒ Good; everything else (unknown tag, no row, later duplicate) ⇒ BadNoEntryExists. + /// + /// Requested times, in request order (already at the backend's resolution). + /// Ticks of the rows the backend actually deleted. + /// Whether the tag exists. + /// One status per requested time. + public static IReadOnlyList DeleteAtTimeResults(IReadOnlyList times, ISet deletedTicks, bool tagKnown) + { + ArgumentNullException.ThrowIfNull(times); + ArgumentNullException.ThrowIfNull(deletedTicks); + var results = new uint[times.Count]; + var first = new Dictionary(); + for (var i = 0; i < times.Count; i++) + { + results[i] = HistorianStatusCodes.BadNoEntryExists; + if (times[i] == DateTime.MinValue) results[i] = HistorianStatusCodes.BadInvalidTimestamp; + else first.TryAdd(times[i].Ticks, i); + } + if (!tagKnown) return results; + foreach (var (ticks, idx) in first) + if (deletedTicks.Contains(ticks)) + results[idx] = HistorianStatusCodes.Good; + return results; + } + + /// DeleteRaw result: unknown tag or nothing deleted ⇒ BadNoData; otherwise Good. + /// Whether the tag exists. + /// Rows the backend deleted. + /// The status. + public static uint DeleteRawResult(bool tagKnown, long rowsAffected) => + tagKnown && rowsAffected > 0 ? HistorianStatusCodes.Good : HistorianStatusCodes.BadNoData; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/LiveWritePreparer.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/LiveWritePreparer.cs new file mode 100644 index 00000000..d10faa74 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/LiveWritePreparer.cs @@ -0,0 +1,54 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +/// +/// Normalises a live-value batch for storage. Non-finite doubles. NaN / ±Infinity are stored as +/// so the sample's presence survives. An Empty sample is always Bad: +/// a non-finite Double whose status is not already Bad is stored with BadOutOfRange; any other Empty with a +/// non-Bad status is stored with BadNoData; an existing Bad code is kept. Duplicate timestamps within +/// a batch collapse to the first (matching insert-if-absent across batches). +/// +public static class LiveWritePreparer +{ + /// + /// Normalises a batch: null timestamp ⇒ now, timestamps truncated to , + /// non-finite doubles ⇒ Empty, every Empty forced Bad, and duplicate (truncated) timestamps collapsed to the first. + /// + /// Input samples, in append order. + /// Clock for null timestamps. + /// 1 = keep 100 ns (SQL Server); 10 = truncate to µs BEFORE de-duplication (QuestDB). + /// Time-unique rows. + public static List Prepare(IReadOnlyList values, Func utcNow, + long resolutionTicks = 1) + { + ArgumentOutOfRangeException.ThrowIfLessThan(resolutionTicks, 1); + var seen = new HashSet(); + var rows = new List(values.Count); + foreach (var v in values) + { + var t = v.TimestampUtc ?? utcNow(); + t = t.Kind == DateTimeKind.Local ? t.ToUniversalTime() : DateTime.SpecifyKind(t, DateTimeKind.Utc); + t = Truncate(t, resolutionTicks); + if (!seen.Add(t.Ticks)) continue; + var value = v.Value; + var status = v.StatusCode; + if (value.Kind == HistorianValueKind.Double && !double.IsFinite(value.Number)) + { + value = HistorianValue.Empty; + if (!StatusBits.IsBad(status)) status = HistorianStatusCodes.BadOutOfRange; + } + else if (value.Kind == HistorianValueKind.Empty && !StatusBits.IsBad(status)) + { + status = HistorianStatusCodes.BadNoData; + } + rows.Add(new HistorianSampleRow(t, value, status)); + } + + return rows; + } + + /// Truncates down to a multiple of , keeping its kind. + internal static DateTime Truncate(DateTime t, long resolutionTicks) => + resolutionTicks == 1 ? t : t.AddTicks(-(t.Ticks % resolutionTicks)); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/SampleColumns.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/SampleColumns.cs new file mode 100644 index 00000000..f8ee6bb1 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/SampleColumns.cs @@ -0,0 +1,66 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +/// The (ValueKind, NumValue, IntValue, StrValue) column quad for a value. +/// The as a byte. +/// Numeric column (null = NULL). +/// Integer column (null = NULL). +/// String column (null = NULL). +public readonly record struct SampleColumns(byte ValueKind, double? NumValue, long? IntValue, string? StrValue); + +/// +/// HistorianValue ⇄ column mapping (spec §4). Mapping: Double → Num; Int64 → Int + Num(double); +/// Boolean → Int 0/1 + Num 0/1; String → Str; DateTime → Int (ticks); Empty → all NULL. +/// +public static class SampleColumnMapper +{ + /// Splits a value into its column quad. + /// Typed value. + /// The columns to store. + public static SampleColumns ToColumns(HistorianValue v) => v.Kind switch + { + HistorianValueKind.Double => new((byte)v.Kind, v.Number, null, null), + HistorianValueKind.Int64 => new((byte)v.Kind, v.Number, v.Integer, null), + HistorianValueKind.Boolean => new((byte)v.Kind, v.Integer != 0 ? 1d : 0d, v.Integer != 0 ? 1L : 0L, null), + HistorianValueKind.String => new((byte)v.Kind, null, null, v.Text ?? ""), + HistorianValueKind.DateTime => new((byte)v.Kind, null, v.Integer, null), + _ => new((byte)HistorianValueKind.Empty, null, null, null), + }; + + /// Rebuilds a value from stored columns. + /// ValueKind column. + /// NumValue column (null = NULL). + /// IntValue column (null = NULL). + /// StrValue column (null = NULL). + /// The typed value; an unknown kind yields . + public static HistorianValue FromColumns(byte valueKind, double? num, long? integer, string? str) => (HistorianValueKind)valueKind switch + { + HistorianValueKind.Double => HistorianValue.FromDouble(num ?? 0d), + HistorianValueKind.Int64 => HistorianValue.FromInt64(integer ?? 0L), + HistorianValueKind.Boolean => HistorianValue.FromBoolean((integer ?? 0L) != 0), + HistorianValueKind.String => HistorianValue.FromString(str ?? ""), + HistorianValueKind.DateTime => new HistorianValue(HistorianValueKind.DateTime, 0d, integer ?? 0L, null), + _ => HistorianValue.Empty, + }; + + /// Stores a uint status bit-for-bit in an int column. + /// Status code. + /// Same bits as int. + public static int ToDbStatus(uint status) => unchecked((int)status); + + /// Reads an int status column back to a uint code. + /// Column value. + /// Same bits as uint. + public static uint FromDbStatus(int dbStatus) => unchecked((uint)dbStatus); + + /// Stores a uint status in a 64-bit column (zero-extended, so it is never negative). + /// Status code. + /// The status as a non-negative long. + public static long ToLongStatus(uint status) => status; + + /// Reads a 64-bit status column back to a uint code (low 32 bits). + /// Column value. + /// The status code. + public static uint FromLongStatus(long dbStatus) => unchecked((uint)dbStatus); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/StatusBits.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/StatusBits.cs new file mode 100644 index 00000000..cf276ce2 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/StatusBits.cs @@ -0,0 +1,33 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +/// +/// Bit masks over a 32-bit OPC UA status code (Part 4 §7.39). These are masks, not emitted codes, so their +/// names deliberately avoid the Good/Bad/Uncertain prefix the status-code parity scan keys on. +/// +public static class StatusBits +{ + /// Severity + sub-code (top 16 bits) of a status, i.e. the code without info bits. + public const uint CodeBitsMask = 0xFFFF0000u; + + /// The two severity bits (Good = 00, Uncertain = 01, Bad = 10). + public const uint SeverityMask = 0xC0000000u; + + /// The severity bit set on every Bad code. + public const uint SeverityBadBit = 0x80000000u; + + /// The severity bit set on every Uncertain code (bit 30). + public const uint SeverityUncertainBit = 0x40000000u; + + /// The two historian info bits (Calculated, Interpolated) in the low bits of the info field. + public const uint HistorianBitsMask = 0x3u; + + /// True when has Bad severity. + /// A status code. + /// Whether the Bad severity bit is set. + public static bool IsBad(uint status) => (status & SeverityBadBit) != 0; + + /// True when has Good severity. + /// A status code. + /// Whether both severity bits are clear. + public static bool IsGood(uint status) => (status & SeverityMask) == 0; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/StoredSample.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/StoredSample.cs new file mode 100644 index 00000000..b155d4bc --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/StoredSample.cs @@ -0,0 +1,32 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; + +/// A stored sample as read back: source time, typed value and stored status. +/// Source timestamp (UTC). +/// Typed value ( for an outage marker). +/// Stored OPC UA status code. +public 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); + + /// + /// Builds a sample from stored columns, applying the quality clamp: an Empty (no-value) row whose stored status + /// is not Bad is served as BadNoData — defence in depth for rows written before the writer forced every + /// Empty sample Bad. + /// + /// Source timestamp (UTC). + /// Stored value. + /// Stored status code. + /// The sample, clamped. + public static StoredSample FromStored(DateTime timeUtc, HistorianValue value, uint status) + { + if (value.Kind == HistorianValueKind.Empty && !StatusBits.IsBad(status)) + status = HistorianStatusCodes.BadNoData; + return new StoredSample(timeUtc, value, status); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.csproj new file mode 100644 index 00000000..8e0cb860 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.csproj @@ -0,0 +1,22 @@ + + + + true + true + $(NoWarn);CS1591 + AnyCPU;x64 + ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common + + + + + + + + + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SampleRowMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SampleRowMapper.cs index e9e20ac2..49ba2ccf 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SampleRowMapper.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SampleRowMapper.cs @@ -1,22 +1,12 @@ using System.Data; using Microsoft.Data.SqlClient.Server; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; -/// One sample bound for a SampleRow TVP. -/// Source timestamp (UTC, full tick precision). -/// Typed value. -/// OPC UA status code (stored bit-for-bit as int). -internal readonly record struct SqlSampleRow(DateTime TimeUtc, HistorianValue Value, uint StatusCode); - -/// The (ValueKind, NumValue, IntValue, StrValue) column quad for a value. -internal readonly record struct SampleColumns(byte ValueKind, double? NumValue, long? IntValue, string? StrValue); - /// -/// HistorianValue ⇄ column mapping (spec §4) and the SqlDataRecord TVP builders. Mapping: -/// Double → Num; Int64 → Int + Num(double); Boolean → Int 0/1 + Num 0/1; String → Str; DateTime → Int (ticks); -/// Empty → all NULL. +/// The SqlDataRecord TVP builders. The HistorianValue ⇄ column mapping itself is +/// (shared with the other SQL backends). /// internal static class SampleRowMapper { @@ -32,60 +22,21 @@ internal static class SampleRowMapper private static readonly SqlMetaData[] TimeMeta = [new("T", SqlDbType.DateTime2, 0, 7)]; - /// Splits a value into its column quad. - /// Typed value. - /// The columns to store. - public static SampleColumns ToColumns(HistorianValue v) => v.Kind switch - { - HistorianValueKind.Double => new((byte)v.Kind, v.Number, null, null), - HistorianValueKind.Int64 => new((byte)v.Kind, v.Number, v.Integer, null), - HistorianValueKind.Boolean => new((byte)v.Kind, v.Integer != 0 ? 1d : 0d, v.Integer != 0 ? 1L : 0L, null), - HistorianValueKind.String => new((byte)v.Kind, null, null, v.Text ?? ""), - HistorianValueKind.DateTime => new((byte)v.Kind, null, v.Integer, null), - _ => new((byte)HistorianValueKind.Empty, null, null, null), - }; - - /// Rebuilds a value from stored columns. - /// ValueKind column. - /// NumValue column (null = NULL). - /// IntValue column (null = NULL). - /// StrValue column (null = NULL). - /// The typed value; an unknown kind yields . - public static HistorianValue FromColumns(byte valueKind, double? num, long? integer, string? str) => (HistorianValueKind)valueKind switch - { - HistorianValueKind.Double => HistorianValue.FromDouble(num ?? 0d), - HistorianValueKind.Int64 => HistorianValue.FromInt64(integer ?? 0L), - HistorianValueKind.Boolean => HistorianValue.FromBoolean((integer ?? 0L) != 0), - HistorianValueKind.String => HistorianValue.FromString(str ?? ""), - HistorianValueKind.DateTime => new HistorianValue(HistorianValueKind.DateTime, 0d, integer ?? 0L, null), - _ => HistorianValue.Empty, - }; - - /// Stores a uint status bit-for-bit in the int column. - /// Status code. - /// Same bits as int. - public static int ToDbStatus(uint status) => unchecked((int)status); - - /// Reads the int status column back to a uint code. - /// Column value. - /// Same bits as uint. - public static uint FromDbStatus(int dbStatus) => unchecked((uint)dbStatus); - /// Builds SampleRow TVP records (datetime2(7); never a DataTable). Lazy; enumerate once. /// Samples; must be time-unique (the TVP has a PK on time) and non-empty when bound. /// Records for . - public static IEnumerable ToSampleRecords(IEnumerable rows) + public static IEnumerable ToSampleRecords(IEnumerable rows) { foreach (var r in rows) { - var c = ToColumns(r.Value); + var c = SampleColumnMapper.ToColumns(r.Value); var rec = new SqlDataRecord(SampleMeta); rec.SetDateTime(0, DateTime.SpecifyKind(r.TimeUtc, DateTimeKind.Utc)); rec.SetByte(1, c.ValueKind); if (c.NumValue is { } n) rec.SetDouble(2, n); else rec.SetDBNull(2); if (c.IntValue is { } i) rec.SetInt64(3, i); else rec.SetDBNull(3); if (c.StrValue is { } s) rec.SetString(4, s); else rec.SetDBNull(4); - rec.SetInt32(5, ToDbStatus(r.StatusCode)); + rec.SetInt32(5, SampleColumnMapper.ToDbStatus(r.StatusCode)); yield return rec; } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs index fa10b21b..050cb231 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs @@ -1,22 +1,10 @@ -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; -/// The SQL shape that serves an aggregate. One query per family. -internal enum SqlAggregateFamily -{ - /// Average / Minimum / Maximum / Total / Count / Range / PercentGood / PercentBad: one GROUP BY. - Simple, - - /// Start / End / Delta: first + last usable sample per bucket (ROW_NUMBER). - StartEnd, - - /// MinimumActualTime / MaximumActualTime: extreme usable sample per bucket, ties ⇒ earliest. - ActualTime, -} - /// -/// Push-down T-SQL for processed (aggregate) reads (spec §4 "Processed"). Every query takes +/// Push-down T-SQL for processed (aggregate) reads (spec §4 "Processed"), one query per +/// ( picks it). Every query takes /// @id (int TagId), @start/@end (datetime2(7), the forward window /// [start, end)) and @intervalTicks (bigint bucket width in 100 ns ticks), and returns one row /// per non-empty bucket. The caller generates every interval and fills the empty ones. @@ -52,19 +40,6 @@ internal static class SqlAggregateQueries /// ValueKind of String (4) and DateTime (5): usable but not numeric. private const string NonNumericKinds = "4, 5"; - /// The family that serves , or null when this backend does not. - /// Requested aggregate. - /// The family, or null for an unsupported aggregate. - 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, - }; - /// /// Average / Minimum / Maximum / Total / Count / Range / PercentGood / PercentBad. /// Family columns: 6 AvgValue, 7 MinValue, 8 MaxValue, 9 SumValue (float, NULL when no usable diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlHistorianStatusCodes.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlHistorianStatusCodes.cs deleted file mode 100644 index ea5b18e2..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlHistorianStatusCodes.cs +++ /dev/null @@ -1,81 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; - -/// -/// Every OPC UA status this backend emits, as named constants (inline literals are invisible to -/// StatusCodeParityTests). Values mirror Opc.Ua.StatusCodes. -/// -internal static class SqlHistorianStatusCodes -{ - public const uint Good = 0x00000000u; - public const uint GoodEntryInserted = 0x00A20000u; - public const uint GoodEntryReplaced = 0x00A30000u; - public const uint BadNoData = 0x809B0000u; - public const uint BadEntryExists = 0x809F0000u; - public const uint BadNoEntryExists = 0x80A00000u; - public const uint BadInvalidTimestamp = 0x80230000u; - public const uint BadAggregateInvalidInputs = 0x80D60000u; - public const uint UncertainDataSubNormal = 0x40A40000u; - - /// Stored for a non-finite Double (NaN / ±Infinity) that arrived with a non-Bad status. - public const uint BadOutOfRange = 0x803C0000u; -} - -/// -/// Bit masks over a 32-bit OPC UA status code (Part 4 §7.39). These are masks, not emitted codes, so their -/// names deliberately avoid the Good/Bad/Uncertain prefix the status-code parity scan keys on. -/// -internal static class StatusBits -{ - /// Severity + sub-code (top 16 bits) of a status, i.e. the code without info bits. - public const uint CodeBitsMask = 0xFFFF0000u; - - /// The two severity bits (Good = 00, Uncertain = 01, Bad = 10). - public const uint SeverityMask = 0xC0000000u; - - /// The severity bit set on every Bad code. - public const uint SeverityBadBit = 0x80000000u; - - /// The two historian info bits (Calculated, Interpolated) in the low bits of the info field. - public const uint HistorianBitsMask = 0x3u; - - /// True when has Bad severity. - /// A status code. - /// Whether the Bad severity bit is set. - public static bool IsBad(uint status) => (status & SeverityBadBit) != 0; - - /// True when has Good severity. - /// A status code. - /// Whether both severity bits are clear. - public static bool IsGood(uint status) => (status & SeverityMask) == 0; -} - -/// Part 4 §7.39 historian info bits OR-ed onto a status code. -internal static class HistorianInfoBits -{ - /// InfoType = DataValue. - public const uint InfoTypeDataValue = 0x00000400u; - - /// The value was produced by an aggregate calculation. - public const uint HistorianCalculated = 0x1u; - - /// The value was interpolated. - public const uint HistorianInterpolated = 0x2u; - - /// - /// Marks as a Calculated data value. Any historian bits the status already carries - /// (e.g. a stored Interpolated sample) are cleared first, so the result is never both. - /// - /// Base status code. - /// Status with InfoType and Calculated bits set. - public static uint WithCalculated(uint status) => - (status & ~StatusBits.HistorianBitsMask) | InfoTypeDataValue | HistorianCalculated; - - /// - /// Marks as an Interpolated data value. Any historian bits the status already - /// carries (e.g. a stored Calculated sample) are cleared first, so the result is never both. - /// - /// Base status code. - /// Status with InfoType and Interpolated bits set. - public static uint WithInterpolated(uint status) => - (status & ~StatusBits.HistorianBitsMask) | InfoTypeDataValue | HistorianInterpolated; -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerAlarmHistorianWriter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerAlarmHistorianWriter.cs index a1b40406..715ff59c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerAlarmHistorianWriter.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerAlarmHistorianWriter.cs @@ -1,17 +1,16 @@ using System.Data; -using System.Security.Cryptography; -using System.Text; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; /// /// over the SQL Event table. Per event: insert-if-absent keyed by a -/// deterministic , so the store-and-forward drain's at-least-once redelivery (and both +/// deterministic , so the store-and-forward drain's at-least-once redelivery (and both /// pair nodes writing the same event) converge on one row. Outcomes: Ack on insert or already-present; /// RetryPlease on transient SQL / schema-bootstrap / unclassified SQL errors and on cancellation; PermanentFail only /// for data errors (truncation, conversion, null, range, constraint); unknown/non-SQL exceptions retry. Never throws. @@ -76,12 +75,12 @@ internal sealed class SqlServerAlarmHistorianWriter : IAlarmHistorianWriter { await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false); await using var cmd = _conn.CreateCommand(conn, sql); - cmd.Parameters.Add(new SqlParameter("@id", SqlDbType.NVarChar, 64) { Value = ComputeEventId(e) }); + cmd.Parameters.Add(new SqlParameter("@id", SqlDbType.NVarChar, 64) { Value = AlarmEventIds.ComputeEventId(e) }); cmd.Parameters.Add(new SqlParameter("@src", SqlDbType.NVarChar, -1) { Value = e.EquipmentPath ?? "" }); cmd.Parameters.Add(new SqlParameter("@aid", SqlDbType.NVarChar, -1) { Value = e.AlarmId ?? "" }); cmd.Parameters.Add(new SqlParameter("@an", SqlDbType.NVarChar, -1) { Value = e.AlarmName ?? "" }); cmd.Parameters.Add(new SqlParameter("@at", SqlDbType.NVarChar, -1) { Value = e.AlarmTypeName ?? "" }); - cmd.Parameters.Add(new SqlParameter("@sev", SqlDbType.Int) { Value = ToNumericSeverity(e.Severity) }); + cmd.Parameters.Add(new SqlParameter("@sev", SqlDbType.Int) { Value = AlarmEventIds.ToNumericSeverity(e.Severity) }); cmd.Parameters.Add(new SqlParameter("@ek", SqlDbType.NVarChar, -1) { Value = e.EventKind ?? "" }); cmd.Parameters.Add(new SqlParameter("@msg", SqlDbType.NVarChar, -1) { Value = (object?)e.Message ?? DBNull.Value }); cmd.Parameters.Add(new SqlParameter("@usr", SqlDbType.NVarChar, -1) { Value = (object?)e.User ?? DBNull.Value }); @@ -116,50 +115,4 @@ internal sealed class SqlServerAlarmHistorianWriter : IAlarmHistorianWriter return HistorianWriteOutcome.PermanentFail; return HistorianWriteOutcome.RetryPlease; } - - // Same numeric Part 9 priorities the gateway path sends (AlarmEventMapper.ToNumericSeverity). - private static int ToNumericSeverity(AlarmSeverity s) => s switch - { - AlarmSeverity.Low => 200, - AlarmSeverity.Medium => 500, - AlarmSeverity.High => 700, - AlarmSeverity.Critical => 900, - _ => 500, - }; - - /// - /// Lowercase-hex SHA-256 over length-prefixed UTF-8 fields (little-endian int32 byte length, -1 for null): - /// EquipmentPath, AlarmId, EventKind, TimestampUtc.Ticks (decimal), User, Comment, Message. Length-prefixing keeps - /// field boundaries unambiguous ("a|b"+"c" ≠ "a"+"b|c"). - /// - /// The event. - /// 64 hex characters. - internal static string ComputeEventId(AlarmHistorianEvent e) - { - using var ms = new MemoryStream(); - Field(ms, e.EquipmentPath); - Field(ms, e.AlarmId); - Field(ms, e.EventKind); - Field(ms, e.TimestampUtc.Ticks.ToString(System.Globalization.CultureInfo.InvariantCulture)); - Field(ms, e.User); - Field(ms, e.Comment); - Field(ms, e.Message); - return Convert.ToHexStringLower(SHA256.HashData(ms.ToArray())); - } - - private static void Field(MemoryStream ms, string? value) - { - Span len = stackalloc byte[4]; - if (value is null) - { - System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(len, -1); - ms.Write(len); - return; - } - - var bytes = Encoding.UTF8.GetBytes(value); - System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(len, bytes.Length); - ms.Write(len); - ms.Write(bytes); - } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs index d2ef21b0..c580cdea 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs @@ -4,6 +4,7 @@ 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; @@ -39,7 +40,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; /// on Raw and AtTime alike. /// /// -/// Long names. A tag name or event source name longer than +/// 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. /// @@ -47,7 +48,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; public sealed class SqlServerHistorianDataSource : IHistorianDataSource { /// Spec §9.7: a processed read covering more intervals than this is refused. - public const int MaxProcessedBuckets = 100_000; + public const int MaxProcessedBuckets = BucketPlan.MaxBuckets; private const string SampleColumns = "SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, ServerTimeUtc"; @@ -57,14 +58,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource 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; + private readonly HistorianHealthCounters _health = new(); /// Creates the data source over the provider's shared connection, bootstrapper and tag catalog. /// Connection helper. @@ -192,7 +186,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource 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); + byTicks[t.Ticks] = AtTimeEvaluator.Evaluate(t, p, n, serverTime, now); } } @@ -202,7 +196,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource 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); + : new DataValueSnapshot(null, HistorianStatusCodes.BadNoData, t, now); } RecordSuccess(); @@ -215,44 +209,6 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource } } - /// 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 /// @@ -269,7 +225,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource { // 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) + if (sourceName is not null && sourceName.Length > HistorianTagNames.MaxLength) return new HistoricalEventsResult([], null); try { @@ -301,10 +257,10 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource SqlHistorianConnection.GetUtc(reader, 2), SqlHistorianConnection.GetUtc(reader, 3), reader.IsDBNull(4) ? null : reader.GetString(4), - (ushort)Math.Clamp(reader.GetInt32(5), 0, ushort.MaxValue)); + HistoricalEventRows.ClampSeverity(reader.GetInt32(5))); if (bounded && events.Count == maxEvents) { - continuation = Encoding.UTF8.GetBytes($"{e.EventTimeUtc.Ticks}|{e.EventId}"); + continuation = Encoding.UTF8.GetBytes(EventContinuation.Encode(e.EventTimeUtc, e.EventId)); break; } events.Add(e); @@ -352,57 +308,38 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource 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}."); + var family = AggregateFamilies.FamilyOf(aggregate) ?? throw new HistoryAggregateNotSupportedException(aggregate); + var plan = BucketPlan.Create(startUtc, endUtc, interval); try { var now = _time.GetUtcNow().UtcDateTime; - var rows = new Dictionary(); - var tagId = bucketCount == 0 ? null : await ResolveAsync(fullReference, cancellationToken).ConfigureAwait(false); + var evaluated = new Dictionary(); + var tagId = plan.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), + AggregateFamily.Simple => SqlAggregateQueries.Simple(sample), + AggregateFamily.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 }); + 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 }); 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; + evaluated[row.Bucket] = AggregateEvaluator.Evaluate(aggregate, row, plan.BucketStart(row.Bucket), now); } } - 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(); + var result = plan.Assemble(evaluated, bucketStart => AggregateEvaluator.NoData(bucketStart, now)); RecordSuccess(); return new HistoryReadResult(result, ContinuationPoint: null); @@ -414,56 +351,13 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource } } - 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) + 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 : SampleRowMapper.FromColumns(r.GetByte(i), D(r, i + 1), L(r, i + 2), S(r, i + 3)); + 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 { @@ -477,13 +371,13 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource var f = SqlAggregateQueries.FirstFamilyColumn; switch (family) { - case SqlAggregateFamily.Simple: + 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 SqlAggregateFamily.StartEnd: + case AggregateFamily.StartEnd: row.First = V(reader, f); row.Last = V(reader, f + 5); break; @@ -495,44 +389,23 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource 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. + /// 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( + 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 = 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); + 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. @@ -541,50 +414,15 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource // ------------------------------------------------------------------------------------------ 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: []); - } - } + public HistorianHealthSnapshot GetHealthSnapshot() => _health.Snapshot(); - private void RecordSuccess() - { - lock (_healthLock) - { - _totalQueries++; - _totalSuccesses++; - _consecutiveFailures = 0; - _lastSuccessUtc = DateTime.UtcNow; - } - } + private void RecordSuccess() => _health.RecordSuccess(); private void RecordFailure(Exception ex) { - // A cancelled read (deadline / client abort) is not a backend fault. + // A cancelled read (deadline / client abort) is not a backend fault (the counters ignore it too). if (ex is OperationCanceledException) return; - lock (_healthLock) - { - _totalQueries++; - _totalFailures++; - _consecutiveFailures++; - _lastFailureUtc = DateTime.UtcNow; - _lastError = $"{ex.GetType().Name}: {ex.Message}"; - } + _health.RecordFailure(ex); _log.LogDebug("SQL historian read failed ({ExceptionType})", ex.GetType().Name); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataUpdater.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataUpdater.cs index efd0a65d..057dfe45 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataUpdater.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataUpdater.cs @@ -1,18 +1,9 @@ using Microsoft.Data.SqlClient; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; -/// Status codes owned by the updater (named consts so StatusCodeParityTests sees them). -internal static class SqlHistorianUpdateStatusCodes -{ - /// - /// A value that cannot be stored: a Double NaN / ±Infinity, an Empty (null) value with a non-Bad status, or a - /// tag name longer than . - /// - public const uint BadInvalidArgument = 0x80AB0000u; -} - /// /// OPC UA HistoryUpdate against the SQL Server sample table (spec §4 "Updates"). One transaction per call; /// exactly one status per input item, in input order. @@ -26,7 +17,7 @@ internal static class SqlHistorianUpdateStatusCodes /// accepted with a Bad status (an operator-authored outage marker); with a Good or Uncertain status it is /// BadInvalidArgument, because a stored sample is never served better than "no value". Other items /// still proceed. -/// Tag names over characters are refused, never +/// Tag names over characters are refused, never /// truncated: every item of the call (and DeleteRaw's single result) reports BadInvalidArgument and nothing /// is written. /// Unknown tag. Insert/Update create it via ; Replace and the @@ -52,70 +43,26 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater { ArgumentNullException.ThrowIfNull(tag); ArgumentNullException.ThrowIfNull(values); - var results = new uint[values.Count]; - if (!SqlServerTagCatalog.IsValidName(tag)) - { - Array.Fill(results, SqlHistorianUpdateStatusCodes.BadInvalidArgument); - return results; - } - var winner = new Dictionary(); // tick -> index of the item actually sent - for (var i = 0; i < values.Count; i++) - { - var v = values[i]; - if (v.TimestampUtc is not { } ts || ts == DateTime.MinValue) - { - results[i] = SqlHistorianStatusCodes.BadInvalidTimestamp; - continue; - } - if ((v.Value.Kind == HistorianValueKind.Double && !double.IsFinite(v.Value.Number)) - || (v.Value.Kind == HistorianValueKind.Empty && !StatusBits.IsBad(v.StatusCode))) - { - results[i] = SqlHistorianUpdateStatusCodes.BadInvalidArgument; - continue; - } - if (!winner.TryGetValue(ts.Ticks, out _)) - winner[ts.Ticks] = i; - else if (mode == HistoryUpdateMode.Insert) - results[i] = SqlHistorianStatusCodes.BadEntryExists; // first occurrence stays the winner - else - winner[ts.Ticks] = i; // last wins - } - - var send = winner.Values.OrderBy(i => i).ToList(); - if (send.Count == 0) return results; + var plan = HistoryUpdatePlanner.Plan(tag, mode, values); + if (plan.Winners.Count == 0) return HistoryUpdatePlanner.Complete(plan, mode, new Dictionary()); var tagId = mode == HistoryUpdateMode.Replace ? await _tags.TryGetTagIdAsync(tag, ct).ConfigureAwait(false) - : await _tags.GetOrCreateTagIdAsync(tag, SqlHistorianDataTypes.FromKind(values[send[0]].Value.Kind), ct).ConfigureAwait(false); + : await _tags.GetOrCreateTagIdAsync(tag, HistorianDataTypes.FromKind(plan.Winners[0].Row.Value.Kind), ct).ConfigureAwait(false); var outcome = tagId is null - ? new Dictionary() - : await MergeAsync(tagId.Value, mode, send.Select(i => new SqlSampleRow( - values[i].TimestampUtc!.Value, values[i].Value, values[i].StatusCode)).ToList(), ct).ConfigureAwait(false); - - // No OUTPUT row means the MERGE's absent branch: Insert => matched (exists); Replace => not matched. - var absent = mode == HistoryUpdateMode.Replace - ? SqlHistorianStatusCodes.BadNoEntryExists - : SqlHistorianStatusCodes.BadEntryExists; - foreach (var i in send) - results[i] = outcome.TryGetValue(values[i].TimestampUtc!.Value.Ticks, out var code) ? code : absent; - - // Superseded earlier duplicates (Replace/Update) share the winner's code. - if (mode != HistoryUpdateMode.Insert) - for (var i = 0; i < values.Count; i++) - if (values[i].TimestampUtc is { } ts && results[i] == 0 - && winner.TryGetValue(ts.Ticks, out var w) && w != i) - results[i] = results[w]; - return results; + ? new Dictionary() + : await MergeAsync(tagId.Value, mode, plan.Winners.Select(w => w.Row).ToList(), ct).ConfigureAwait(false); + return HistoryUpdatePlanner.Complete(plan, mode, outcome); } /// public async Task DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, CancellationToken ct) { ArgumentNullException.ThrowIfNull(tag); - if (!SqlServerTagCatalog.IsValidName(tag)) return SqlHistorianUpdateStatusCodes.BadInvalidArgument; + if (!HistorianTagNames.IsValid(tag)) return HistorianStatusCodes.BadInvalidArgument; var tagId = await _tags.TryGetTagIdAsync(tag, ct).ConfigureAwait(false); - if (tagId is null) return SqlHistorianStatusCodes.BadNoData; + if (tagId is null) return HistoryUpdatePlanner.DeleteRawResult(tagKnown: false, 0); await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false); await using var tx = (SqlTransaction)await conn.BeginTransactionAsync(ct).ConfigureAwait(false); int rows; @@ -128,7 +75,7 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater rows = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } await tx.CommitAsync(ct).ConfigureAwait(false); - return rows > 0 ? SqlHistorianStatusCodes.Good : SqlHistorianStatusCodes.BadNoData; + return HistoryUpdatePlanner.DeleteRawResult(tagKnown: true, rows); } /// @@ -136,22 +83,19 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater { ArgumentNullException.ThrowIfNull(tag); ArgumentNullException.ThrowIfNull(timesUtc); - var results = new uint[timesUtc.Count]; - if (!SqlServerTagCatalog.IsValidName(tag)) + if (!HistorianTagNames.IsValid(tag)) { - Array.Fill(results, SqlHistorianUpdateStatusCodes.BadInvalidArgument); - return results; + var refused = new uint[timesUtc.Count]; + Array.Fill(refused, HistorianStatusCodes.BadInvalidArgument); + return refused; } - var first = new Dictionary(); - for (var i = 0; i < timesUtc.Count; i++) - { - results[i] = SqlHistorianStatusCodes.BadNoEntryExists; - if (timesUtc[i] == DateTime.MinValue) results[i] = SqlHistorianStatusCodes.BadInvalidTimestamp; - else first.TryAdd(timesUtc[i].Ticks, i); - } - if (first.Count == 0) return results; + var distinct = new HashSet(); + foreach (var t in timesUtc) + if (t != DateTime.MinValue) distinct.Add(t.Ticks); + var deleted = new HashSet(); + if (distinct.Count == 0) return HistoryUpdatePlanner.DeleteAtTimeResults(timesUtc, deleted, tagKnown: false); var tagId = await _tags.TryGetTagIdAsync(tag, ct).ConfigureAwait(false); - if (tagId is null) return results; + if (tagId is null) return HistoryUpdatePlanner.DeleteAtTimeResults(timesUtc, deleted, tagKnown: false); await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false); await using var tx = (SqlTransaction)await conn.BeginTransactionAsync(ct).ConfigureAwait(false); @@ -160,18 +104,17 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater { cmd.Parameters.Add(new SqlParameter("@tagId", System.Data.SqlDbType.Int) { Value = tagId.Value }); _conn.AddStructured(cmd, "@times", "TimeRow", - SampleRowMapper.ToTimeRecords(first.Keys.Select(t => new DateTime(t, DateTimeKind.Utc)))); + SampleRowMapper.ToTimeRecords(distinct.Select(t => new DateTime(t, DateTimeKind.Utc)))); await using var rd = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); while (await rd.ReadAsync(ct).ConfigureAwait(false)) - if (first.TryGetValue(SqlHistorianConnection.GetUtc(rd, 0).Ticks, out var idx)) - results[idx] = SqlHistorianStatusCodes.Good; + deleted.Add(SqlHistorianConnection.GetUtc(rd, 0).Ticks); } await tx.CommitAsync(ct).ConfigureAwait(false); - return results; + return HistoryUpdatePlanner.DeleteAtTimeResults(timesUtc, deleted, tagKnown: true); } - private async Task> MergeAsync(int tagId, HistoryUpdateMode mode, - List rows, CancellationToken ct) + private async Task> MergeAsync(int tagId, HistoryUpdateMode mode, + List rows, CancellationToken ct) { var sql = $"MERGE {_conn.Q("Sample")} WITH (HOLDLOCK) AS t USING @rows AS r ON t.TagId = @tagId AND t.SourceTimeUtc = r.SourceTimeUtc "; if (mode != HistoryUpdateMode.Insert) @@ -182,7 +125,7 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater "VALUES (@tagId, r.SourceTimeUtc, r.ValueKind, r.NumValue, r.IntValue, r.StrValue, r.StatusCode, SYSUTCDATETIME()) "; sql += "OUTPUT $action, r.SourceTimeUtc;"; - var outcome = new Dictionary(); + var outcome = new Dictionary(); await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false); await using var tx = (SqlTransaction)await conn.BeginTransactionAsync(ct).ConfigureAwait(false); await using (var cmd = _conn.CreateCommand(conn, sql, tx)) @@ -192,8 +135,8 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater await using var rd = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); while (await rd.ReadAsync(ct).ConfigureAwait(false)) outcome[SqlHistorianConnection.GetUtc(rd, 1).Ticks] = rd.GetString(0) == "INSERT" - ? SqlHistorianStatusCodes.GoodEntryInserted - : SqlHistorianStatusCodes.GoodEntryReplaced; + ? UpsertOutcome.Inserted + : UpsertOutcome.Replaced; } await tx.CommitAsync(ct).ConfigureAwait(false); return outcome; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianOptions.cs index ed23c993..d5669b05 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianOptions.cs @@ -44,11 +44,3 @@ public sealed partial class SqlServerHistorianOptions return errors; } } - -/// Thrown when AutoCreateSchema=false and the historian schema is absent or behind. -public sealed class HistorianSchemaMissingException : Exception -{ - /// Creates the exception. - /// Message naming the missing version. - public HistorianSchemaMissingException(string message) : base(message) { } -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianValueWriter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianValueWriter.cs index 75f10757..4efa8da6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianValueWriter.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianValueWriter.cs @@ -2,6 +2,7 @@ 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; @@ -21,6 +22,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; /// Tag names over characters are refused (the write /// returns false, a per-tag failure for the recorder's #538 quarantine), never truncated onto another tag. /// Duplicate timestamps within a batch collapse to the first (matching insert-if-absent across batches). +/// The batch normalisation itself is . /// internal sealed class SqlServerHistorianValueWriter : IHistorianValueWriter { @@ -51,7 +53,7 @@ internal sealed class SqlServerHistorianValueWriter : IHistorianValueWriter { try { - if (!SqlServerTagCatalog.IsValidName(tag)) + if (!HistorianTagNames.IsValid(tag)) { _log.LogWarning("SQL historian live-value write refused: tag name is missing or longer than {Max} characters.", SqlServerTagCatalog.MaxNameLength); @@ -60,8 +62,8 @@ internal sealed class SqlServerHistorianValueWriter : IHistorianValueWriter await _bootstrap.EnsureAsync(ct).ConfigureAwait(false); if (values is null || values.Count == 0) return true; - var rows = Prepare(values, _utcNow); - var dataType = SqlHistorianDataTypes.FromKind(rows.Select(r => r.Value.Kind).FirstOrDefault(k => k != HistorianValueKind.Empty)); + var rows = LiveWritePreparer.Prepare(values, _utcNow); + var dataType = HistorianDataTypes.FromKind(rows.Select(r => r.Value.Kind).FirstOrDefault(k => k != HistorianValueKind.Empty)); var tagId = await _catalog.GetOrCreateTagIdAsync(tag, dataType, ct).ConfigureAwait(false); var sql = @@ -94,38 +96,4 @@ internal sealed class SqlServerHistorianValueWriter : IHistorianValueWriter return false; } } - - /// - /// Normalises a batch for the TVP: null timestamp ⇒ now, non-finite doubles ⇒ Empty, every Empty forced Bad - /// (non-finite ⇒ BadOutOfRange, otherwise BadNoData, an existing Bad code kept), and duplicate - /// timestamps collapsed to the first. - /// - /// Input samples, in append order. - /// Clock for null timestamps. - /// Time-unique rows. - internal static List Prepare(IReadOnlyList values, Func utcNow) - { - var seen = new HashSet(); - var rows = new List(values.Count); - foreach (var v in values) - { - var t = v.TimestampUtc ?? utcNow(); - t = t.Kind == DateTimeKind.Local ? t.ToUniversalTime() : DateTime.SpecifyKind(t, DateTimeKind.Utc); - if (!seen.Add(t.Ticks)) continue; - var value = v.Value; - var status = v.StatusCode; - if (value.Kind == HistorianValueKind.Double && !double.IsFinite(value.Number)) - { - value = HistorianValue.Empty; - if (!StatusBits.IsBad(status)) status = SqlHistorianStatusCodes.BadOutOfRange; - } - else if (value.Kind == HistorianValueKind.Empty && !StatusBits.IsBad(status)) - { - status = SqlHistorianStatusCodes.BadNoData; - } - rows.Add(new SqlSampleRow(t, value, status)); - } - - return rows; - } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerSchemaBootstrapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerSchemaBootstrapper.cs index 528b48fd..5539e251 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerSchemaBootstrapper.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerSchemaBootstrapper.cs @@ -1,17 +1,17 @@ using System.Data; using System.Reflection; -using System.Runtime.ExceptionServices; using System.Text.RegularExpressions; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; /// /// Lazily applies the embedded, idempotent schema scripts once per instance, under a SQL application lock /// so concurrent nodes/instances serialise. A failure is NOT latched: an after -/// retries. Within that window the last failure is re-thrown without touching +/// retries (). Within that window the last failure is re-thrown without touching /// SQL, so a missing schema (or a SQL outage) does not serialise every write, read and alarm event behind one /// semaphore and one round-trip each. /// With AutoCreateSchema=false it only verifies MAX(Version) >= 1. @@ -24,17 +24,12 @@ internal sealed partial class SqlServerSchemaBootstrapper private const string LockResource = "OtOpcUaHistorianSchema"; private const int LockTimeoutMs = 30_000; - /// How long the last bootstrap failure is re-thrown before SQL is tried again. - public static readonly TimeSpan FailureCacheDuration = TimeSpan.FromSeconds(5); - private readonly SqlHistorianConnection _conn; private readonly bool _autoCreate; private readonly ILogger _log; - private readonly TimeProvider _time; private readonly SemaphoreSlim _gate = new(1, 1); + private readonly BootstrapFailureCache _failure; // guarded by _gate private volatile bool _ready; - private ExceptionDispatchInfo? _lastFailure; // guarded by _gate - private DateTimeOffset _retryAfter; // guarded by _gate /// Creates the bootstrapper. /// Connection helper. @@ -47,7 +42,7 @@ internal sealed partial class SqlServerSchemaBootstrapper _conn = conn; _autoCreate = options.AutoCreateSchema; _log = log ?? NullLogger.Instance; - _time = timeProvider ?? TimeProvider.System; + _failure = new BootstrapFailureCache(timeProvider ?? TimeProvider.System, BootstrapFailureCache.DefaultDuration); } /// Ensures the schema is ready; no-op after the first success. @@ -61,23 +56,22 @@ internal sealed partial class SqlServerSchemaBootstrapper try { if (_ready) return; - if (_lastFailure is { } cached && _time.GetUtcNow() < _retryAfter) cached.Throw(); + _failure.ThrowIfCached(); try { await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false); if (_autoCreate) await ApplyAsync(conn, ct).ConfigureAwait(false); else await VerifyAsync(conn, ct).ConfigureAwait(false); _ready = true; - _lastFailure = null; + _failure.Clear(); } catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested) { // A caller's own cancellation is not a backend failure and is not cached. - _lastFailure = ExceptionDispatchInfo.Capture(ex); - _retryAfter = _time.GetUtcNow() + FailureCacheDuration; + _failure.Record(ex); _log.LogWarning("Historian schema bootstrap for [{Schema}] failed ({Exception}: {Message}); " + "historian calls fail fast for {Seconds} s before retrying.", - _conn.Schema, ex.GetType().Name, ex.Message, FailureCacheDuration.TotalSeconds); + _conn.Schema, ex.GetType().Name, ex.Message, BootstrapFailureCache.DefaultDuration.TotalSeconds); throw; } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerTagCatalog.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerTagCatalog.cs index b3e16121..1e5071e6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerTagCatalog.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerTagCatalog.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using Microsoft.Data.SqlClient; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; @@ -13,8 +14,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; /// internal sealed class SqlServerTagCatalog { - /// Width of the Tag.Name column; longer names are refused. - public const int MaxNameLength = 400; + /// Width of the Tag.Name column (= ); longer names are refused. + public const int MaxNameLength = HistorianTagNames.MaxLength; private readonly SqlHistorianConnection _conn; private readonly SqlServerSchemaBootstrapper _bootstrap; @@ -29,18 +30,13 @@ internal sealed class SqlServerTagCatalog _bootstrap = bootstrap; } - /// True when fits the Tag.Name column without truncation. - /// Historian tag name. - /// Whether the name is non-null and at most characters. - public static bool IsValidName(string? name) => name is not null && name.Length <= MaxNameLength; - /// Read-only lookup; never creates a row. A name over is not found. /// Historian tag name. /// Cancellation. /// The TagId, or null when the tag does not exist. public async Task TryGetTagIdAsync(string name, CancellationToken ct) { - if (!IsValidName(name)) return null; + if (!HistorianTagNames.IsValid(name)) return null; if (_cache.TryGetValue(name, out var cached)) return cached; await _bootstrap.EnsureAsync(ct).ConfigureAwait(false); await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false); @@ -57,7 +53,7 @@ internal sealed class SqlServerTagCatalog /// The name is longer than . public async Task GetOrCreateTagIdAsync(string name, byte dataType, CancellationToken ct) { - if (!IsValidName(name)) + if (!HistorianTagNames.IsValid(name)) throw new ArgumentException($"Historian tag name exceeds {MaxNameLength} characters.", nameof(name)); if (_cache.TryGetValue(name, out var cached)) return cached; await _bootstrap.EnsureAsync(ct).ConfigureAwait(false); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.csproj index ff5e29c5..353f5660 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.csproj @@ -11,6 +11,8 @@ + + diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj index 7c38ec29..6cae72d6 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj @@ -43,6 +43,8 @@ DriverTypeNames guard skips it — but it does hard-code OPC UA status constants, which puts it in StatusCodeParityTests' scope. --> + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ProcessedReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ProcessedReadTests.cs new file mode 100644 index 00000000..cce8846f --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ProcessedReadTests.cs @@ -0,0 +1,16 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests; + +/// The DB-free part of the former SQL Server integration ProcessedReadTests. +public sealed class ProcessedReadTests +{ + [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().Count(a => AggregateFamilies.FamilyOf(a) is not null).ShouldBe(13); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SampleRowMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SampleRowMapperTests.cs new file mode 100644 index 00000000..43187cd6 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SampleRowMapperTests.cs @@ -0,0 +1,45 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests; + +/// The column round-trip half of the former SQL Server SampleRowMapperTests (the TVP half stays there). +public sealed class SampleRowMapperTests +{ + public static TheoryData Values => new() + { + HistorianValue.FromDouble(12.5), + HistorianValue.FromInt64(long.MinValue + 3), + HistorianValue.FromBoolean(true), + HistorianValue.FromBoolean(false), + HistorianValue.FromString("héllo"), + HistorianValue.FromString(""), + HistorianValue.FromDateTime(new DateTime(2026, 10, 9, 1, 2, 3, DateTimeKind.Utc).AddTicks(1234567)), + HistorianValue.Empty, + }; + + [Theory] + [MemberData(nameof(Values))] + public void Every_kind_round_trips_through_the_column_quad(HistorianValue v) + { + var c = SampleColumnMapper.ToColumns(v); + SampleColumnMapper.FromColumns(c.ValueKind, c.NumValue, c.IntValue, c.StrValue).ShouldBe(v); + } + + [Fact] + public void Columns_follow_the_spec_mapping() + { + SampleColumnMapper.ToColumns(HistorianValue.FromInt64(7)).ShouldBe(new SampleColumns(2, 7d, 7L, null)); + SampleColumnMapper.ToColumns(HistorianValue.FromBoolean(true)).ShouldBe(new SampleColumns(3, 1d, 1L, null)); + SampleColumnMapper.ToColumns(HistorianValue.FromDouble(1.5)).ShouldBe(new SampleColumns(1, 1.5, null, null)); + SampleColumnMapper.ToColumns(HistorianValue.Empty).ShouldBe(new SampleColumns(0, null, null, null)); + } + + [Fact] + public void Status_is_stored_bit_for_bit() + { + SampleColumnMapper.ToDbStatus(0x80050000u).ShouldBeLessThan(0); + SampleColumnMapper.FromDbStatus(SampleColumnMapper.ToDbStatus(0x80050000u)).ShouldBe(0x80050000u); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests/StatusCodeOracleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/StatusCodeOracleTests.cs similarity index 77% rename from tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests/StatusCodeOracleTests.cs rename to tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/StatusCodeOracleTests.cs index 1c49e992..aa50f312 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests/StatusCodeOracleTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/StatusCodeOracleTests.cs @@ -1,7 +1,7 @@ using Shouldly; using Xunit; -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests; +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests; /// The pinned SDK is the oracle for the info-bit constants (StatusCodeParityTests covers the named codes). public sealed class StatusCodeOracleTests @@ -10,7 +10,7 @@ public sealed class StatusCodeOracleTests public void Calculated_bits_match_the_SDK() { var sdk = new Opc.Ua.StatusCode(Opc.Ua.StatusCodes.Good).SetAggregateBits(Opc.Ua.AggregateBits.Calculated); - sdk.Code.ShouldBe(HistorianInfoBits.WithCalculated(SqlHistorianStatusCodes.Good)); + sdk.Code.ShouldBe(HistorianInfoBits.WithCalculated(HistorianStatusCodes.Good)); ((uint)Opc.Ua.AggregateBits.Calculated).ShouldBe(HistorianInfoBits.HistorianCalculated); } @@ -18,7 +18,7 @@ public sealed class StatusCodeOracleTests public void Interpolated_bits_match_the_SDK() { var sdk = new Opc.Ua.StatusCode(Opc.Ua.StatusCodes.Good).SetAggregateBits(Opc.Ua.AggregateBits.Interpolated); - sdk.Code.ShouldBe(HistorianInfoBits.WithInterpolated(SqlHistorianStatusCodes.Good)); + sdk.Code.ShouldBe(HistorianInfoBits.WithInterpolated(HistorianStatusCodes.Good)); ((uint)Opc.Ua.AggregateBits.Interpolated).ShouldBe(HistorianInfoBits.HistorianInterpolated); } @@ -30,9 +30,9 @@ public sealed class StatusCodeOracleTests [Fact] public void Setting_one_historian_bit_clears_the_other() { - var calculatedUncertain = SqlHistorianStatusCodes.UncertainDataSubNormal | 0x400u | HistorianInfoBits.HistorianCalculated; + var calculatedUncertain = HistorianStatusCodes.UncertainDataSubNormal | 0x400u | HistorianInfoBits.HistorianCalculated; var interpolated = HistorianInfoBits.WithInterpolated(calculatedUncertain); - interpolated.ShouldBe(SqlHistorianStatusCodes.UncertainDataSubNormal | 0x400u | HistorianInfoBits.HistorianInterpolated); + interpolated.ShouldBe(HistorianStatusCodes.UncertainDataSubNormal | 0x400u | HistorianInfoBits.HistorianInterpolated); new Opc.Ua.StatusCode(interpolated).AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Interpolated); var interpolatedGood = 0x400u | HistorianInfoBits.HistorianInterpolated; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests/ValueWriterPrepareTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ValueWriterPrepareTests.cs similarity index 68% rename from tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests/ValueWriterPrepareTests.cs rename to tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ValueWriterPrepareTests.cs index 3f0ee8b5..77e24c06 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests/ValueWriterPrepareTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ValueWriterPrepareTests.cs @@ -2,7 +2,7 @@ using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests; +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests; public sealed class ValueWriterPrepareTests { @@ -13,7 +13,7 @@ public sealed class ValueWriterPrepareTests { const uint uncertain = 0x40000000u; const uint badNoCommunication = 0x80310000u; - var rows = SqlServerHistorianValueWriter.Prepare( + var rows = LiveWritePreparer.Prepare( [ new(T, HistorianValue.FromDouble(double.NaN), uncertain), new(T.AddTicks(1), HistorianValue.FromDouble(double.PositiveInfinity), 0), @@ -21,26 +21,26 @@ public sealed class ValueWriterPrepareTests new(T.AddTicks(3), HistorianValue.FromDouble(1.5), 0), ], () => T); rows.Select(r => r.Value.Kind).ShouldBe([HistorianValueKind.Empty, HistorianValueKind.Empty, HistorianValueKind.Empty, HistorianValueKind.Double]); - rows.Select(r => r.StatusCode).ShouldBe([SqlHistorianStatusCodes.BadOutOfRange, SqlHistorianStatusCodes.BadOutOfRange, + rows.Select(r => r.StatusCode).ShouldBe([HistorianStatusCodes.BadOutOfRange, HistorianStatusCodes.BadOutOfRange, badNoCommunication, 0u]); } [Fact] public void A_non_bad_Empty_is_stored_BadNoData_and_a_bad_Empty_keeps_its_code() { - var rows = SqlServerHistorianValueWriter.Prepare( + var rows = LiveWritePreparer.Prepare( [ new(T, HistorianValue.Empty, 0), new(T.AddTicks(1), HistorianValue.Empty, 0x40A40000u), new(T.AddTicks(2), HistorianValue.Empty, 0x80310000u), ], () => T); - rows.Select(r => r.StatusCode).ShouldBe([SqlHistorianStatusCodes.BadNoData, SqlHistorianStatusCodes.BadNoData, 0x80310000u]); + rows.Select(r => r.StatusCode).ShouldBe([HistorianStatusCodes.BadNoData, HistorianStatusCodes.BadNoData, 0x80310000u]); } [Fact] public void Duplicate_timestamps_keep_the_first_and_null_timestamp_uses_the_clock() { - var rows = SqlServerHistorianValueWriter.Prepare( + var rows = LiveWritePreparer.Prepare( [ new(T, HistorianValue.FromInt64(1), 0), new(T, HistorianValue.FromInt64(2), 0), new(null, HistorianValue.FromInt64(3), 0), @@ -53,8 +53,8 @@ public sealed class ValueWriterPrepareTests [Fact] public void Data_type_mapping_is_one_table() { - SqlHistorianDataTypes.FromKind(HistorianValueKind.Double).ShouldBe((byte)DriverDataType.Float64); - SqlHistorianDataTypes.FromKind(HistorianValueKind.Boolean).ShouldBe((byte)DriverDataType.Boolean); - SqlHistorianDataTypes.FromKind(HistorianValueKind.Empty).ShouldBe((byte)DriverDataType.Float64); + HistorianDataTypes.FromKind(HistorianValueKind.Double).ShouldBe((byte)DriverDataType.Float64); + HistorianDataTypes.FromKind(HistorianValueKind.Boolean).ShouldBe((byte)DriverDataType.Boolean); + HistorianDataTypes.FromKind(HistorianValueKind.Empty).ShouldBe((byte)DriverDataType.Float64); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests.csproj new file mode 100644 index 00000000..487e0dd4 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/LongNameTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/LongNameTests.cs index b5fa9548..1b34e04d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/LongNameTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/LongNameTests.cs @@ -1,6 +1,7 @@ using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ProcessedReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ProcessedReadTests.cs index 841ffb1a..bc98a74c 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ProcessedReadTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ProcessedReadTests.cs @@ -360,13 +360,6 @@ public sealed class ProcessedReadTests(SqlHistorianFixture fx) 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().Count(a => SqlAggregateQueries.FamilyOf(a) is not null).ShouldBe(13); - } - [Fact] public async Task The_bucket_count_is_capped_at_100000() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/QualityContractTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/QualityContractTests.cs index 7666d8c2..c08a856a 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/QualityContractTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/QualityContractTests.cs @@ -1,6 +1,7 @@ using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests; @@ -96,8 +97,8 @@ public sealed class QualityContractTests(SqlHistorianFixture fx) new(At(2), HistorianValue.Empty, BadNoCommunication), new(At(3), HistorianValue.FromDouble(1), 0u), ], Ct); - r.ShouldBe([BadInvalidArgument, BadInvalidArgument, SqlHistorianStatusCodes.GoodEntryInserted, - SqlHistorianStatusCodes.GoodEntryInserted]); + r.ShouldBe([BadInvalidArgument, BadInvalidArgument, HistorianStatusCodes.GoodEntryInserted, + HistorianStatusCodes.GoodEntryInserted]); (await db.Source().ReadRawAsync("u", At(0), At(10), 0, Ct)).Samples .Select(s => s.StatusCode).ShouldBe([BadNoCommunication, 0u]); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/RawReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/RawReadTests.cs index 531d3473..8d62d081 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/RawReadTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/RawReadTests.cs @@ -2,6 +2,7 @@ using Microsoft.Data.SqlClient; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests; @@ -59,7 +60,7 @@ internal sealed class ReadTestDb } foreach (var (t, v, status) in samples) { - var col = SampleRowMapper.ToColumns(v); + var col = SampleColumnMapper.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)"); @@ -69,7 +70,7 @@ internal sealed class ReadTestDb 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)); + cmd.Parameters.AddWithValue("@st", SampleColumnMapper.ToDbStatus(status)); SqlHistorianConnection.AddDateTime2(cmd, "@srv", t.AddSeconds(1)); await cmd.ExecuteNonQueryAsync(); } @@ -253,7 +254,7 @@ public sealed class RawReadTests(SqlHistorianFixture fx) // 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); - clock.Now += SqlServerSchemaBootstrapper.FailureCacheDuration; // past the 5 s failure cache (M-2) + clock.Now += BootstrapFailureCache.DefaultDuration; // past the 5 s failure cache (M-2) (await src.ReadRawAsync("A", At(0), At(1), 0, Ct)).Samples.ShouldBeEmpty(); src.GetHealthSnapshot().ConsecutiveFailures.ShouldBe(0); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SchemaBootstrapIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SchemaBootstrapIntegrationTests.cs index 9f90db17..6fd3ca86 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SchemaBootstrapIntegrationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SchemaBootstrapIntegrationTests.cs @@ -1,6 +1,7 @@ using Microsoft.Data.SqlClient; using Shouldly; using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests; @@ -82,12 +83,12 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx) var checker = new SqlServerSchemaBootstrapper(new SqlHistorianConnection(o), o, timeProvider: clock); await Should.ThrowAsync(() => checker.EnsureAsync(CancellationToken.None)); await Boot(Opts(s)).EnsureAsync(CancellationToken.None); - clock.Now += SqlServerSchemaBootstrapper.FailureCacheDuration; + clock.Now += BootstrapFailureCache.DefaultDuration; await checker.EnsureAsync(CancellationToken.None); // same instance retries once the failure cache expires } /// - /// M-2: within of a failure the bootstrapper + /// M-2: within of a failure the bootstrapper /// re-throws it without touching SQL — proven by creating the schema in between: a call that went to SQL /// would now pass. /// @@ -103,7 +104,7 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx) await Boot(Opts(s)).EnsureAsync(CancellationToken.None); // the schema now exists - clock.Now += SqlServerSchemaBootstrapper.FailureCacheDuration - TimeSpan.FromTicks(1); + clock.Now += BootstrapFailureCache.DefaultDuration - TimeSpan.FromTicks(1); await Should.ThrowAsync(() => checker.EnsureAsync(CancellationToken.None)); clock.Now += TimeSpan.FromTicks(1); @@ -156,7 +157,7 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx) "SELECT @id, SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, SYSUTCDATETIME() FROM @rows"); cmd.Parameters.AddWithValue("@id", tagId); conn.AddStructured(cmd, "@rows", "SampleRow", - SampleRowMapper.ToSampleRecords([new SqlSampleRow(t, ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistorianValue.FromDouble(1), 0u)])); + SampleRowMapper.ToSampleRecords([new HistorianSampleRow(t, ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistorianValue.FromDouble(1), 0u)])); await cmd.ExecuteNonQueryAsync(); await using var q = conn.CreateCommand(c, $"SELECT SourceTimeUtc FROM {conn.Q("Sample")}"); await using var r = await q.ExecuteReaderAsync(); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/UpdateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/UpdateTests.cs index 670aeead..98001164 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/UpdateTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/UpdateTests.cs @@ -2,14 +2,15 @@ using Microsoft.Data.SqlClient; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests; [Collection(SqlHistorianCollection.Name)] public sealed class UpdateTests(SqlHistorianFixture fx) { - private const uint Good = SqlHistorianStatusCodes.Good; - private const uint UncertainStatus = SqlHistorianStatusCodes.UncertainDataSubNormal; + private const uint Good = HistorianStatusCodes.Good; + private const uint UncertainStatus = HistorianStatusCodes.UncertainDataSubNormal; // Deliberately NOT millisecond aligned: sub-ms ticks must survive the TVP, MERGE join and DELETE join. private static readonly DateTime T0 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); @@ -58,10 +59,10 @@ public sealed class UpdateTests(SqlHistorianFixture fx) { var (u, s) = Make(); var r1 = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2)], Ct); - r1.ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.GoodEntryInserted]); + r1.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryInserted]); // Mixed: T(2) exists, T(3) new, T(1) exists - out of timestamp order on purpose. var r2 = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(3), 3), V(T(2), 99), V(T(1), 98)], Ct); - r2.ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.BadEntryExists, SqlHistorianStatusCodes.BadEntryExists]); + r2.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.BadEntryExists, HistorianStatusCodes.BadEntryExists]); var rows = await RowsAsync(s, "t"); rows.Select(x => x.Num).ShouldBe([1d, 2d, 3d]); // existing values untouched rows.Select(x => x.Time).ShouldBe([T(1), T(2), T(3)]); // sub-ms ticks preserved @@ -74,7 +75,7 @@ public sealed class UpdateTests(SqlHistorianFixture fx) await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2)], Ct); var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(T(2), 22, UncertainStatus), V(T(5), 5), V(T(1), 11)], Ct); - r.ShouldBe([SqlHistorianStatusCodes.GoodEntryReplaced, SqlHistorianStatusCodes.BadNoEntryExists, SqlHistorianStatusCodes.GoodEntryReplaced]); + r.ShouldBe([HistorianStatusCodes.GoodEntryReplaced, HistorianStatusCodes.BadNoEntryExists, HistorianStatusCodes.GoodEntryReplaced]); var rows = await RowsAsync(s, "t"); rows.Count.ShouldBe(2); // T(5) was not created rows[0].Num.ShouldBe(11d); @@ -89,7 +90,7 @@ public sealed class UpdateTests(SqlHistorianFixture fx) var (u, s) = Make(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct); var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Update, [V(T(2), 2), V(T(1), 10)], Ct); - r.ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.GoodEntryReplaced]); + r.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryReplaced]); (await RowsAsync(s, "t")).Select(x => x.Num).ShouldBe([10d, 2d]); } @@ -98,13 +99,13 @@ public sealed class UpdateTests(SqlHistorianFixture fx) { var (u, s) = Make(); (await u.UpdateDataAsync("rep", HistoryUpdateMode.Replace, [V(T(1), 1)], Ct)) - .ShouldBe([SqlHistorianStatusCodes.BadNoEntryExists]); - (await u.DeleteRawAsync("del", T(0), T(9), Ct)).ShouldBe(SqlHistorianStatusCodes.BadNoData); - (await u.DeleteAtTimeAsync("del2", [T(1)], Ct)).ShouldBe([SqlHistorianStatusCodes.BadNoEntryExists]); + .ShouldBe([HistorianStatusCodes.BadNoEntryExists]); + (await u.DeleteRawAsync("del", T(0), T(9), Ct)).ShouldBe(HistorianStatusCodes.BadNoData); + (await u.DeleteAtTimeAsync("del2", [T(1)], Ct)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]); (await TagCountAsync(s)).ShouldBe(0); // none of those created a tag - (await u.UpdateDataAsync("ins", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct)).ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted]); - (await u.UpdateDataAsync("upd", HistoryUpdateMode.Update, [V(T(1), 1)], Ct)).ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted]); + (await u.UpdateDataAsync("ins", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct)).ShouldBe([HistorianStatusCodes.GoodEntryInserted]); + (await u.UpdateDataAsync("upd", HistoryUpdateMode.Update, [V(T(1), 1)], Ct)).ShouldBe([HistorianStatusCodes.GoodEntryInserted]); (await TagCountAsync(s)).ShouldBe(2); } @@ -114,13 +115,13 @@ public sealed class UpdateTests(SqlHistorianFixture fx) var (u, s) = Make(); var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(null, 1), V(T(1), 2), V(DateTime.MinValue, 3)], Ct); - r.ShouldBe([SqlHistorianStatusCodes.BadInvalidTimestamp, SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.BadInvalidTimestamp]); + r.ShouldBe([HistorianStatusCodes.BadInvalidTimestamp, HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.BadInvalidTimestamp]); (await RowsAsync(s, "t")).Count.ShouldBe(1); // All-invalid request: no DB work, no tag created, still one code per item. var (u2, s2) = Make(); await u2.UpdateDataAsync("seed", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct); // bootstraps the schema - (await u2.UpdateDataAsync("x", HistoryUpdateMode.Insert, [V(null, 1)], Ct)).ShouldBe([SqlHistorianStatusCodes.BadInvalidTimestamp]); + (await u2.UpdateDataAsync("x", HistoryUpdateMode.Insert, [V(null, 1)], Ct)).ShouldBe([HistorianStatusCodes.BadInvalidTimestamp]); (await TagCountAsync(s2)).ShouldBe(1); // only "seed"; "x" was never created } @@ -130,8 +131,8 @@ public sealed class UpdateTests(SqlHistorianFixture fx) var (u, s) = Make(); var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), double.NaN), V(T(2), 2), V(T(3), double.PositiveInfinity), V(T(4), double.NegativeInfinity)], Ct); - r.ShouldBe([SqlHistorianUpdateStatusCodes.BadInvalidArgument, SqlHistorianStatusCodes.GoodEntryInserted, - SqlHistorianUpdateStatusCodes.BadInvalidArgument, SqlHistorianUpdateStatusCodes.BadInvalidArgument]); + r.ShouldBe([HistorianStatusCodes.BadInvalidArgument, HistorianStatusCodes.GoodEntryInserted, + HistorianStatusCodes.BadInvalidArgument, HistorianStatusCodes.BadInvalidArgument]); (await RowsAsync(s, "t")).Select(x => x.Num).ShouldBe([2d]); } @@ -140,7 +141,7 @@ public sealed class UpdateTests(SqlHistorianFixture fx) { var (u, s) = Make(); var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(1), 99)], Ct); - r.ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.BadEntryExists]); + r.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.BadEntryExists]); (await RowsAsync(s, "t")).Select(x => x.Num).ShouldBe([1d, 2d]); } @@ -150,11 +151,11 @@ public sealed class UpdateTests(SqlHistorianFixture fx) var (u, s) = Make(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct); var rep = await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(T(1), 5), V(T(1), 6)], Ct); - rep.ShouldBe([SqlHistorianStatusCodes.GoodEntryReplaced, SqlHistorianStatusCodes.GoodEntryReplaced]); + rep.ShouldBe([HistorianStatusCodes.GoodEntryReplaced, HistorianStatusCodes.GoodEntryReplaced]); (await RowsAsync(s, "t")).Single().Num.ShouldBe(6d); var upd = await u.UpdateDataAsync("t", HistoryUpdateMode.Update, [V(T(7), 7), V(T(7), 8)], Ct); - upd.ShouldBe([SqlHistorianStatusCodes.GoodEntryInserted, SqlHistorianStatusCodes.GoodEntryInserted]); + upd.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryInserted]); (await RowsAsync(s, "t")).Last().Num.ShouldBe(8d); } @@ -164,7 +165,7 @@ public sealed class UpdateTests(SqlHistorianFixture fx) var (u, s) = Make(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [new HistorizationValue(T(1), HistorianValue.FromDouble(1.5), Good)], Ct); (await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, - [new HistorizationValue(T(1), HistorianValue.FromString("hi"), Good)], Ct)).ShouldBe([SqlHistorianStatusCodes.GoodEntryReplaced]); + [new HistorizationValue(T(1), HistorianValue.FromString("hi"), Good)], Ct)).ShouldBe([HistorianStatusCodes.GoodEntryReplaced]); var rows = await RowsAsync(s, "t"); rows.Single().Num.ShouldBeNull(); // NumValue cleared by the String replace } @@ -177,9 +178,9 @@ public sealed class UpdateTests(SqlHistorianFixture fx) // [T(1), T(3)) removes T(1) and T(2); T(3) (the end) survives. (await u.DeleteRawAsync("t", T(1), T(3), Ct)).ShouldBe(Good); (await RowsAsync(s, "t")).Select(x => x.Time).ShouldBe([T(3)]); - (await u.DeleteRawAsync("t", T(1), T(3), Ct)).ShouldBe(SqlHistorianStatusCodes.BadNoData); + (await u.DeleteRawAsync("t", T(1), T(3), Ct)).ShouldBe(HistorianStatusCodes.BadNoData); // sub-ms boundary: a start one tick after T(3) excludes it. - (await u.DeleteRawAsync("t", T(3).AddTicks(1), T(3).AddTicks(100), Ct)).ShouldBe(SqlHistorianStatusCodes.BadNoData); + (await u.DeleteRawAsync("t", T(3).AddTicks(1), T(3).AddTicks(100), Ct)).ShouldBe(HistorianStatusCodes.BadNoData); (await RowsAsync(s, "t")).Count.ShouldBe(1); } @@ -189,7 +190,7 @@ public sealed class UpdateTests(SqlHistorianFixture fx) var (u, s) = Make(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(3), 3)], Ct); var r = await u.DeleteAtTimeAsync("t", [T(3), T(2).AddTicks(1) /* near miss */, T(1), DateTime.MinValue], Ct); - r.ShouldBe([Good, SqlHistorianStatusCodes.BadNoEntryExists, Good, SqlHistorianStatusCodes.BadInvalidTimestamp]); + r.ShouldBe([Good, HistorianStatusCodes.BadNoEntryExists, Good, HistorianStatusCodes.BadInvalidTimestamp]); (await RowsAsync(s, "t")).Select(x => x.Time).ShouldBe([T(2)]); } @@ -198,7 +199,7 @@ public sealed class UpdateTests(SqlHistorianFixture fx) { var (u, s) = Make(); await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct); - (await u.DeleteAtTimeAsync("t", [T(1), T(1)], Ct)).ShouldBe([Good, SqlHistorianStatusCodes.BadNoEntryExists]); + (await u.DeleteAtTimeAsync("t", [T(1), T(1)], Ct)).ShouldBe([Good, HistorianStatusCodes.BadNoEntryExists]); (await RowsAsync(s, "t")).ShouldBeEmpty(); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/WriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/WriteTests.cs index 9b8a4ac1..3945257e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/WriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/WriteTests.cs @@ -3,6 +3,7 @@ using Microsoft.Data.SqlClient; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests; @@ -55,11 +56,11 @@ public sealed class WriteTests(SqlHistorianFixture fx) var rows = await RowsAsync(s, "tag.a"); rows.Count.ShouldBe(6); for (var i = 0; i < 6; i++) rows[i].T.Ticks.ShouldBe(vals[i].TimestampUtc!.Value.Ticks); - var back = rows.Select(r => SampleRowMapper.FromColumns(r.Kind, r.N, r.I, r.S)).ToList(); + var back = rows.Select(r => SampleColumnMapper.FromColumns(r.Kind, r.N, r.I, r.S)).ToList(); back.ShouldBe(vals.Select(v => v.Value).ToList()); (rows[5].N, rows[5].I, rows[5].S).ShouldBe((null, null, null)); rows[1].Status.ShouldBe(unchecked((int)0x40000000u)); - SampleRowMapper.FromDbStatus(rows[5].Status).ShouldBe(0x80310000u); + SampleColumnMapper.FromDbStatus(rows[5].Status).ShouldBe(0x80310000u); } [Fact] @@ -101,8 +102,8 @@ public sealed class WriteTests(SqlHistorianFixture fx) rows.Count.ShouldBe(3); rows[0].Kind.ShouldBe((byte)HistorianValueKind.Empty); rows[0].N.ShouldBeNull(); - rows[0].Status.ShouldBe(unchecked((int)SqlHistorianStatusCodes.BadOutOfRange)); // Uncertain NaN ⇒ Bad (I-1) - rows[1].Status.ShouldBe(unchecked((int)SqlHistorianStatusCodes.BadOutOfRange)); // Good +Inf ⇒ Bad (I-1) + rows[0].Status.ShouldBe(unchecked((int)HistorianStatusCodes.BadOutOfRange)); // Uncertain NaN ⇒ Bad (I-1) + rows[1].Status.ShouldBe(unchecked((int)HistorianStatusCodes.BadOutOfRange)); // Good +Inf ⇒ Bad (I-1) rows[2].N.ShouldBe(7); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests/AlarmEventIdTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests/AlarmEventIdTests.cs index 03b12e75..3050398e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests/AlarmEventIdTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests/AlarmEventIdTests.cs @@ -2,6 +2,7 @@ using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests; @@ -16,8 +17,8 @@ public sealed class AlarmEventIdTests [Fact] public void Id_is_stable_lowercase_hex_sha256() { - var a = SqlServerAlarmHistorianWriter.ComputeEventId(Ev()); - a.ShouldBe(SqlServerAlarmHistorianWriter.ComputeEventId(Ev())); + var a = AlarmEventIds.ComputeEventId(Ev()); + a.ShouldBe(AlarmEventIds.ComputeEventId(Ev())); a.Length.ShouldBe(64); a.ShouldBe(a.ToLowerInvariant()); } @@ -26,23 +27,23 @@ public sealed class AlarmEventIdTests public void Field_boundaries_are_unambiguous() { // The delimiter-based scheme collided on these; length prefixes must not. - SqlServerAlarmHistorianWriter.ComputeEventId(Ev(path: "a|b", id: "c")) - .ShouldNotBe(SqlServerAlarmHistorianWriter.ComputeEventId(Ev(path: "a", id: "b|c"))); - SqlServerAlarmHistorianWriter.ComputeEventId(Ev(path: "ab", id: "c")) - .ShouldNotBe(SqlServerAlarmHistorianWriter.ComputeEventId(Ev(path: "a", id: "bc"))); + AlarmEventIds.ComputeEventId(Ev(path: "a|b", id: "c")) + .ShouldNotBe(AlarmEventIds.ComputeEventId(Ev(path: "a", id: "b|c"))); + AlarmEventIds.ComputeEventId(Ev(path: "ab", id: "c")) + .ShouldNotBe(AlarmEventIds.ComputeEventId(Ev(path: "a", id: "bc"))); } [Fact] public void Null_and_empty_differ_and_every_field_participates() { - var basis = SqlServerAlarmHistorianWriter.ComputeEventId(Ev()); - SqlServerAlarmHistorianWriter.ComputeEventId(Ev(comment: null)).ShouldNotBe(SqlServerAlarmHistorianWriter.ComputeEventId(Ev(comment: ""))); + var basis = AlarmEventIds.ComputeEventId(Ev()); + AlarmEventIds.ComputeEventId(Ev(comment: null)).ShouldNotBe(AlarmEventIds.ComputeEventId(Ev(comment: ""))); foreach (var other in new[] { Ev(path: "Q"), Ev(id: "B"), Ev(kind: "Cleared"), Ev(user: "v"), Ev(comment: "d"), Ev(msg: "x"), Ev(t: T.AddTicks(1)), }) - SqlServerAlarmHistorianWriter.ComputeEventId(other).ShouldNotBe(basis); + AlarmEventIds.ComputeEventId(other).ShouldNotBe(basis); } [Theory] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests/SampleRowMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests/SampleRowMapperTests.cs index c71aefe5..909b3810 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests/SampleRowMapperTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests/SampleRowMapperTests.cs @@ -2,52 +2,18 @@ using Microsoft.Data.SqlClient.Server; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests; +/// The TVP half of the mapper tests (the column round-trip half moved to Sql.Common.Tests). public sealed class SampleRowMapperTests { - public static TheoryData Values => new() - { - HistorianValue.FromDouble(12.5), - HistorianValue.FromInt64(long.MinValue + 3), - HistorianValue.FromBoolean(true), - HistorianValue.FromBoolean(false), - HistorianValue.FromString("héllo"), - HistorianValue.FromString(""), - HistorianValue.FromDateTime(new DateTime(2026, 10, 9, 1, 2, 3, DateTimeKind.Utc).AddTicks(1234567)), - HistorianValue.Empty, - }; - - [Theory] - [MemberData(nameof(Values))] - public void Every_kind_round_trips_through_the_column_quad(HistorianValue v) - { - var c = SampleRowMapper.ToColumns(v); - SampleRowMapper.FromColumns(c.ValueKind, c.NumValue, c.IntValue, c.StrValue).ShouldBe(v); - } - - [Fact] - public void Columns_follow_the_spec_mapping() - { - SampleRowMapper.ToColumns(HistorianValue.FromInt64(7)).ShouldBe(new SampleColumns(2, 7d, 7L, null)); - SampleRowMapper.ToColumns(HistorianValue.FromBoolean(true)).ShouldBe(new SampleColumns(3, 1d, 1L, null)); - SampleRowMapper.ToColumns(HistorianValue.FromDouble(1.5)).ShouldBe(new SampleColumns(1, 1.5, null, null)); - SampleRowMapper.ToColumns(HistorianValue.Empty).ShouldBe(new SampleColumns(0, null, null, null)); - } - - [Fact] - public void Status_is_stored_bit_for_bit() - { - SampleRowMapper.ToDbStatus(0x80050000u).ShouldBeLessThan(0); - SampleRowMapper.FromDbStatus(SampleRowMapper.ToDbStatus(0x80050000u)).ShouldBe(0x80050000u); - } - [Fact] public void Sample_records_keep_sub_millisecond_ticks() { var t = new DateTime(2026, 10, 9, 0, 0, 0, DateTimeKind.Utc).AddTicks(1234567); - var rec = SampleRowMapper.ToSampleRecords([new SqlSampleRow(t, HistorianValue.FromDouble(2), 0x80050000u)]).Single(); + var rec = SampleRowMapper.ToSampleRecords([new HistorianSampleRow(t, HistorianValue.FromDouble(2), 0x80050000u)]).Single(); rec.GetDateTime(0).Ticks.ShouldBe(t.Ticks); rec.GetByte(1).ShouldBe((byte)1); rec.GetDouble(2).ShouldBe(2d);