8c6d6aff6a
Moves the database-independent logic of the SQL Server historian backend into a new shared library for the upcoming QuestDB backend (#666): status codes and bits, value/column mapping, StoredSample + quality clamp, the AtTime and aggregate evaluators, BucketPlan, LiveWritePreparer and HistoryUpdatePlanner (both with a resolutionTicks parameter, default 1), tag-name rules, the bootstrap failure cache, health counters, alarm event ids, the event continuation and severity clamp. The SQL Server backend now calls it; its T-SQL, TVP builders, connection, bootstrapper and catalog stay. Tests: StatusCodeOracleTests, ValueWriterPrepareTests, the column half of SampleRowMapperTests and ProcessedReadTests.Every_enum_member_is_either_served_or_refused move to the new Sql.Common.Tests under the same names. SqlServer integration tests get mechanical renames only. Test-name set across the three projects is identical to the baseline (160 tests). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
92 lines
4.8 KiB
C#
92 lines
4.8 KiB
C#
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||
|
||
/// <summary>
|
||
/// The bucket layout of a processed (aggregate) read. Bucket <c>i</c> = <c>[start + i·interval,
|
||
/// start + (i+1)·interval)</c> (start-inclusive, end-exclusive to the tick; the last bucket is truncated at
|
||
/// <c>end</c>). An interval <= 0 ⇒ one interval <c>[start, end)</c>. <c>start == end</c> ⇒ no intervals.
|
||
/// <c>end < start</c> (reverse) ⇒ the window is swapped, computed forward, and assembled in descending bucket
|
||
/// order (buckets stay anchored at the earlier time).
|
||
/// </summary>
|
||
public sealed class BucketPlan
|
||
{
|
||
/// <summary>Spec §9.7: a processed read covering more intervals than this is refused.</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>The earlier end of the window (the forward start).</summary>
|
||
public DateTime StartUtc { get; }
|
||
|
||
/// <summary>The later end of the window (the forward, exclusive end).</summary>
|
||
public DateTime EndUtc { get; }
|
||
|
||
/// <summary>True when the request came in reversed (<c>end < start</c>).</summary>
|
||
public bool Reversed { get; }
|
||
|
||
/// <summary>Bucket width in 100 ns ticks (clamped to the window; 0 for an empty window).</summary>
|
||
public long IntervalTicks { get; }
|
||
|
||
/// <summary>Number of buckets (0 for an empty window).</summary>
|
||
public long BucketCount { get; }
|
||
|
||
/// <summary><see cref="StartUtc"/> as 100 ns ticks since the Unix epoch.</summary>
|
||
public long StartUnixTicks => (StartUtc - DateTime.UnixEpoch).Ticks;
|
||
|
||
/// <summary>Plans the buckets of a processed read.</summary>
|
||
/// <param name="startUtc">Requested start.</param>
|
||
/// <param name="endUtc">Requested end (earlier than start for a reverse read).</param>
|
||
/// <param name="interval">Bucket width; <= 0 means one bucket over the whole window.</param>
|
||
/// <returns>The plan.</returns>
|
||
/// <exception cref="ArgumentOutOfRangeException">The window holds more than <see cref="MaxBuckets"/> intervals
|
||
/// (parameter name <c>interval</c>).</exception>
|
||
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);
|
||
}
|
||
|
||
/// <summary>The start time of bucket <paramref name="index"/> (UTC kind).</summary>
|
||
/// <param name="index">Zero-based bucket index.</param>
|
||
/// <returns>The bucket start.</returns>
|
||
public DateTime BucketStart(long index) =>
|
||
DateTime.SpecifyKind(new DateTime(StartUtc.Ticks + index * IntervalTicks), DateTimeKind.Utc);
|
||
|
||
/// <summary>One snapshot per bucket in window order; missing buckets get <paramref name="empty"/>; reversed if the window was.</summary>
|
||
/// <param name="byBucket">Evaluated snapshots of the non-empty buckets, by bucket index.</param>
|
||
/// <param name="empty">Builds the snapshot of a bucket with no row, from its start time.</param>
|
||
/// <returns><see cref="BucketCount"/> snapshots.</returns>
|
||
public IReadOnlyList<DataValueSnapshot> Assemble(IReadOnlyDictionary<long, DataValueSnapshot> byBucket,
|
||
Func<DateTime, DataValueSnapshot> empty)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(byBucket);
|
||
ArgumentNullException.ThrowIfNull(empty);
|
||
var result = new List<DataValueSnapshot>((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;
|
||
}
|
||
}
|