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);