fix(historian): #693 Gateway AtTime evaluated from raw reads with the shared AtTimeEvaluator
Gateway HistoryRead-AtTime passed AVEVA's own interpolation through: live it answered the previous value as Good at a stored NULL's own time, null + Bad at a Good sample right after a NULL, a drifted 99.99999999944 for a stored 100, and no Interpolated bit. It is now served from raw gateway reads and evaluated with Sql.Common's AtTimeEvaluator, as the SQL backends do (Part 11 §6.5.4). GatewayAtTimeReader: one <=1,024-row forward window read [first-1ms, last+1ms] for the batch, a walk-back (density-seeded, x4 growth, whole-remainder shortcut, bisects dense history) for the first time's prev, and an 18-row probe for the last time's next; truncated window reads continue or jump with a density-sized margin. All bounds whole ms; a row counts only strictly inside its request (AVEVA's boundary row never a sample); Bad rows value-less (#690); never past now + 1 day; strict-gateway ceiling learned (#691.3) without re-probing. 10 dense times = 1 read (exact) / 3 (interpolated); 10 hourly = 12. Kill switch ServerHistorian:GatewayAtTimeFromRaw (default true; false = the old AVEVA passthrough). Known limit: the wire carries no data type, so numeric tags interpolate as Double (Boolean/integer nodes are not stepped/rounded as on the SQL backends).
This commit is contained in:
@@ -112,6 +112,7 @@ Processed read is `BadAggregateNotSupported` and no history aggregate functions
|
|||||||
"CallTimeout": "00:00:30",
|
"CallTimeout": "00:00:30",
|
||||||
"MaxTieClusterOverfetch": 65536,
|
"MaxTieClusterOverfetch": 65536,
|
||||||
"HonorReturnBounds": true,
|
"HonorReturnBounds": true,
|
||||||
|
"GatewayAtTimeFromRaw": true,
|
||||||
"HistoryReadBatchParallelism": 4,
|
"HistoryReadBatchParallelism": 4,
|
||||||
"MaxConcurrentHistoryReadBatches": 16,
|
"MaxConcurrentHistoryReadBatches": 16,
|
||||||
"HistoryReadDeadline": "00:01:00",
|
"HistoryReadDeadline": "00:01:00",
|
||||||
@@ -146,6 +147,7 @@ Processed read is `BadAggregateNotSupported` and no history aggregate functions
|
|||||||
| `CallTimeout` | TimeSpan | `00:00:30` | **Gateway backend only.** Per-call deadline applied to each unary gateway read. A read for a tag the historian does not yet know about — e.g. a freshly-historized tag that has **not** been provisioned (see [Tag auto-provisioning](#tag-auto-provisioning-ensuretags)) — can run to this **full** deadline before the gateway errors, so an unprovisioned-tag HistoryRead can block for ~30 s. Lower this to fail faster, at the cost of truncating legitimately long reads. |
|
| `CallTimeout` | TimeSpan | `00:00:30` | **Gateway backend only.** Per-call deadline applied to each unary gateway read. A read for a tag the historian does not yet know about — e.g. a freshly-historized tag that has **not** been provisioned (see [Tag auto-provisioning](#tag-auto-provisioning-ensuretags)) — can run to this **full** deadline before the gateway errors, so an unprovisioned-tag HistoryRead can block for ~30 s. Lower this to fail faster, at the cost of truncating legitimately long reads. |
|
||||||
| `MaxTieClusterOverfetch` | int | `65536` | (Node-manager paging; all backends — a `SqlServer` PK rules out tie clusters, so it is moot there.) Maximum samples the server will fetch in one shot to page through a tie cluster (multiple samples sharing one `SourceTimestamp`). A cluster larger than this ceiling fails `BadTooManyOperations` (#686; was `BadHistoryOperationUnsupported`). Also the per-read row budget of a Gateway reverse raw read (walked back from forward reads, #686): one timestamp holding more samples is refused `BadTooManyOperations`. Values below 1 count as 1. Raise to handle abnormally large tie clusters; the default covers all normal-data cases. |
|
| `MaxTieClusterOverfetch` | int | `65536` | (Node-manager paging; all backends — a `SqlServer` PK rules out tie clusters, so it is moot there.) Maximum samples the server will fetch in one shot to page through a tie cluster (multiple samples sharing one `SourceTimestamp`). A cluster larger than this ceiling fails `BadTooManyOperations` (#686; was `BadHistoryOperationUnsupported`). Also the per-read row budget of a Gateway reverse raw read (walked back from forward reads, #686): one timestamp holding more samples is refused `BadTooManyOperations`. Values below 1 count as 1. Raise to handle abnormally large tie clusters; the default covers all normal-data cases. |
|
||||||
| `HonorReturnBounds` | bool | `true` | (#691, all backends.) Whether HistoryRead-Raw honours `ReturnBounds` (Part 11 bounding values, [Bounding values](#bounding-values-returnbounds-691)). `false` is the kill switch back to the pre-#691 behaviour: the flag is ignored (stored samples only) and a modified read carrying it is not refused `Bad_InvalidArgument`. Note the .NET SDK defaults both `ReturnBounds` and `IsReadModified` to `true`. |
|
| `HonorReturnBounds` | bool | `true` | (#691, all backends.) Whether HistoryRead-Raw honours `ReturnBounds` (Part 11 bounding values, [Bounding values](#bounding-values-returnbounds-691)). `false` is the kill switch back to the pre-#691 behaviour: the flag is ignored (stored samples only) and a modified read carrying it is not refused `Bad_InvalidArgument`. Note the .NET SDK defaults both `ReturnBounds` and `IsReadModified` to `true`. |
|
||||||
|
| `GatewayAtTimeFromRaw` | bool | `true` | (#693, **Gateway backend only.**) Whether HistoryRead-AtTime is served from raw gateway reads evaluated with the SQL backends' AtTime rules ([Gateway AtTime](#gateway-attime-is-evaluated-from-raw-reads-693)). `false` is the kill switch back to the pre-#693 answer: AVEVA's own interpolation (the gateway's `ReadAtTime`) passed through unchanged. |
|
||||||
| `HistoryReadBatchParallelism` | int | `4` | Max historized nodes served **concurrently** within one HistoryRead batch (arch-review 03/S3). The SDK's `HistoryRead*` override surface is synchronous, so each arm bridges once at its boundary; this turns per-node serving from sequential (`N × CallTimeout` worst case) into `⌈N / P⌉ × CallTimeout`. Also caps the gateway-load multiplication a single client can command. `≤ 1` serves sequentially. |
|
| `HistoryReadBatchParallelism` | int | `4` | Max historized nodes served **concurrently** within one HistoryRead batch (arch-review 03/S3). The SDK's `HistoryRead*` override surface is synchronous, so each arm bridges once at its boundary; this turns per-node serving from sequential (`N × CallTimeout` worst case) into `⌈N / P⌉ × CallTimeout`. Also caps the gateway-load multiplication a single client can command. `≤ 1` serves sequentially. |
|
||||||
| `MaxConcurrentHistoryReadBatches` | int | `16` | Process-wide limit on HistoryRead (and HistoryUpdate) batches served **concurrently** (arch-review 03/S3). A flood of history clients degrades to `BadTooManyOperations` rather than exhausting the SDK request-thread pool. With `HistoryReadBatchParallelism` this caps total in-flight gateway reads at `MaxConcurrentHistoryReadBatches × HistoryReadBatchParallelism` (default 16 × 4 = 64). A batch that cannot get a slot within `min(HistoryReadDeadline, 5 s)` fails every handle `BadTooManyOperations`. |
|
| `MaxConcurrentHistoryReadBatches` | int | `16` | Process-wide limit on HistoryRead (and HistoryUpdate) batches served **concurrently** (arch-review 03/S3). A flood of history clients degrades to `BadTooManyOperations` rather than exhausting the SDK request-thread pool. With `HistoryReadBatchParallelism` this caps total in-flight gateway reads at `MaxConcurrentHistoryReadBatches × HistoryReadBatchParallelism` (default 16 × 4 = 64). A batch that cannot get a slot within `min(HistoryReadDeadline, 5 s)` fails every handle `BadTooManyOperations`. |
|
||||||
| `MaxTimeWeightedRawRows` | int | `1000000` | `SqlServer` / `QuestDb` (#671), and the `Gateway` per-sample aggregates (#681 — every per-sample read there streams raw samples, so the cap applies to the window's raw rows; see [Aggregate set](#aggregate-set-and-badaggregatenotsupported-285)). The most raw in-window samples one [time-weighted aggregate](#time-weighted-aggregates-part-13-671) read (TimeAverage, TimeAverage2, **Total**, Total2, Interpolative, and — since #680 — **PercentGood/PercentBad**) may stream into the server-side evaluator — that family is **not** pushed down to SQL. The window query is capped at this + 1 rows; one row past the cap fails **that node** with `BadTooManyOperations` (not counted as a backend fault) instead of holding a pooled connection until `HistoryReadDeadline` and returning `BadTimeout`. Bound rows outside the window do not count; per-sample aggregates are not capped on the SQL backends (pushed down). 1 000 000 ≈ 11.5 days of a 1 Hz tag. Non-positive = unbounded (a `Validate()` warning). `Gateway` (#688): the row budget it asks the gateway for (`cap + 18`) is clamped at the gateway's default `MaxValuesCeiling` (1,000,000) while the cap is at or under it, so the default works against a gateway with `AllowStreamingBeyondCeiling=false`; against a gateway with a *lower* ceiling the refused budget is retried once at the ceiling the gateway names (#691.3), so no change is needed (`ceiling − 18` only saves that round-trip). |
|
| `MaxTimeWeightedRawRows` | int | `1000000` | `SqlServer` / `QuestDb` (#671), and the `Gateway` per-sample aggregates (#681 — every per-sample read there streams raw samples, so the cap applies to the window's raw rows; see [Aggregate set](#aggregate-set-and-badaggregatenotsupported-285)). The most raw in-window samples one [time-weighted aggregate](#time-weighted-aggregates-part-13-671) read (TimeAverage, TimeAverage2, **Total**, Total2, Interpolative, and — since #680 — **PercentGood/PercentBad**) may stream into the server-side evaluator — that family is **not** pushed down to SQL. The window query is capped at this + 1 rows; one row past the cap fails **that node** with `BadTooManyOperations` (not counted as a backend fault) instead of holding a pooled connection until `HistoryReadDeadline` and returning `BadTimeout`. Bound rows outside the window do not count; per-sample aggregates are not capped on the SQL backends (pushed down). 1 000 000 ≈ 11.5 days of a 1 Hz tag. Non-positive = unbounded (a `Validate()` warning). `Gateway` (#688): the row budget it asks the gateway for (`cap + 18`) is clamped at the gateway's default `MaxValuesCeiling` (1,000,000) while the cap is at or under it, so the default works against a gateway with `AllowStreamingBeyondCeiling=false`; against a gateway with a *lower* ceiling the refused budget is retried once at the ceiling the gateway names (#691.3), so no change is needed (`ceiling − 18` only saves that round-trip). |
|
||||||
@@ -759,6 +761,43 @@ data.
|
|||||||
it could only ever be a Part 11 *interpolated* bound. The start bound on the Gateway is the real prior sample, found by the
|
it could only ever be a Part 11 *interpolated* bound. The start bound on the Gateway is the real prior sample, found by the
|
||||||
#686 walk-back.
|
#686 walk-back.
|
||||||
|
|
||||||
|
### Gateway AtTime is evaluated from raw reads (#693)
|
||||||
|
|
||||||
|
> ⚠️ **Client-visible (#693).** Gateway AtTime used to pass AVEVA's own interpolated answer (the gateway's `ReadAtTime`) through,
|
||||||
|
> which broke the [AtTime rules](#attime-reads) every other backend follows (Part 11 §6.5.4). Live (`HistGW.LiveTest.Gate685`:
|
||||||
|
> 100 @ 19:00:00, 107 @ 19:00:02, **NULL** @ 19:00:03, 108 @ 19:00:05) it returned **107 Good at the NULL's own time**, **null + Bad
|
||||||
|
> at 19:00:05** (a stored Good 108 right after the NULL), **99.99999999944 for the stored 100**, and no Interpolated bit on any
|
||||||
|
> interpolated value. It is now answered from **raw** reads with the SQL backends' shared `AtTimeEvaluator` (as #681 did for the
|
||||||
|
> per-sample aggregates), so the same stored samples give the same answer on every backend: the exact stored value (no
|
||||||
|
> Interpolated bit) at a stored time; null + the outage's Bad code at or after a stored NULL; the previous value held,
|
||||||
|
> `UncertainDataSubNormal` + Interpolated, before a Bad neighbour; linear + Interpolated between usable neighbours; `BadNoData`
|
||||||
|
> before the first sample; stepped after the last (`UncertainDataSubNormal` once later than now). Kill switch:
|
||||||
|
> `ServerHistorian:GatewayAtTimeFromRaw = false`.
|
||||||
|
|
||||||
|
- **Read plan (`GatewayAtTimeReader`).** Each requested time needs its stored sample at-or-before (`prev`) and after (`next`; read only
|
||||||
|
when the evaluator uses it — a usable `prev` that is not an exact hit). The distinct times are served by **one** forward window read
|
||||||
|
`[first − 1 ms, last + 1 ms]` of at most 1,024 rows, plus a **walk-back** for the sample before the first time and one small forward
|
||||||
|
**probe** (18 rows) for the sample after the last. Measured on the fake: ten times one second apart on a 1 Hz tag cost **1 read**
|
||||||
|
(all exact hits) or **3** (interpolated); ten times an hour apart on a 1 Hz tag **12** (≈ 9.5k rows, never the 32k between); one
|
||||||
|
time 400 days after the only samples **5**; a tag with no history **4**. A window read that fills its budget resolves the times before
|
||||||
|
its last row; the next time either continues from there (if the rows in between should fit one read) or jumps to it with a read
|
||||||
|
that starts a density-sized margin earlier (so its `prev` is normally in the same read).
|
||||||
|
- **Walk-back** (the gateway reads forward only): segments stepping back from the time, the first sized from the window read's density
|
||||||
|
(else 1 minute), growing ×4 when empty; after two empty segments the whole remainder down to the epoch is asked once (a complete
|
||||||
|
answer ends the walk — sparse history and no history are found in a handful of reads). A segment that fills its budget is narrowed
|
||||||
|
towards the time to about a quarter of a read at the density it showed — never below a row it saw — so dense history is bisected,
|
||||||
|
not paged (a 1 kHz tag behind a 10-minute gap: 7 reads, < 10k rows). At most 64 reads; more than 1,024 stored samples within one
|
||||||
|
millisecond fail the node `BadTooManyOperations` (not counted as a backend failure).
|
||||||
|
- **Wire shape.** Every request bound is a whole millisecond and a row counts only **strictly inside** its request, so AVEVA's synthetic
|
||||||
|
boundary row at the query start (#687) — and any row at the query end — is never taken for a stored sample. A Bad row is value-less
|
||||||
|
(#690). Nothing is asked past now + 1 day (the gateway's AVEVA may refuse a far-future end; a time beyond it is stepped from the last
|
||||||
|
sample). Every stream is drained. A strict gateway's ceiling (#691.3) is learned on its first refusal and honoured; a full read is
|
||||||
|
ordinary paging here, so it is not re-probed.
|
||||||
|
- ⚠️ **Values are Double (or String).** The gateway wire carries no data type — every historizable type is stored as an AVEVA analog
|
||||||
|
(a Boolean as a 0/1 `Int2`) — so every numeric tag is **interpolated linearly**: a Boolean or integer node can read a fractional
|
||||||
|
value between two samples. The SQL backends step a Boolean and round an Int64; AVEVA's own interpolation follows the tag's
|
||||||
|
interpolation type (stair-step by default for integer tags). Matching them needs the node's data type plumbed to the data source.
|
||||||
|
|
||||||
### Bounding values (`ReturnBounds`, #691)
|
### Bounding values (`ReturnBounds`, #691)
|
||||||
|
|
||||||
⚠️ **Client-visible — a breaking change for .NET clients that rely on SDK defaults.** Until #691
|
⚠️ **Client-visible — a breaking change for .NET clients that rely on SDK defaults.** Until #691
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #693: HistoryRead AtTime on the Gateway backend, served from RAW gateway reads and evaluated with the SQL backends'
|
||||||
|
/// shared <see cref="AtTimeEvaluator"/> (Part 11 §6.5.4) — as #681 did for the per-sample aggregates — so the Gateway
|
||||||
|
/// answers exactly what SqlServer and QuestDB answer for the same stored samples. AVEVA's own interpolated answer (the
|
||||||
|
/// gateway's <c>ReadAtTime</c>) is not used: live, it returned the previous value as Good at a stored NULL's own time,
|
||||||
|
/// null + Bad at a Good sample right after a NULL, a drifted value (99.99999999944) at an exact stored 100, and no
|
||||||
|
/// Interpolated bit.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>What each requested time needs.</b> <c>prev</c> = the latest stored sample at or before it, <c>next</c> = the
|
||||||
|
/// first stored sample after it. <c>next</c> is only read when the evaluator uses it (a usable <c>prev</c> that is not
|
||||||
|
/// an exact hit).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Read plan — O(1) reads for a dense batch, never two round-trips per timestamp by default.</b> The distinct
|
||||||
|
/// requested times are walked in ascending order with ONE forward window read <c>[t − 1 ms, max + 1 ms]</c> of at most
|
||||||
|
/// <see cref="ReadRows"/> rows, which yields every in-window neighbour. The sample before the first time comes from a
|
||||||
|
/// walk-back (below), the sample after the last time from one small forward probe. A window read that fills its budget
|
||||||
|
/// (dense data, widely spread times) resolves the times before its last row; the next unresolved time either
|
||||||
|
/// continues from that row (when the rows in between are expected to fit one read) or jumps there with a read that
|
||||||
|
/// starts a density-sized margin before it (so its <c>prev</c> is normally in the same read). Ten times one second
|
||||||
|
/// apart on a 1 Hz tag cost 1 read (all exact hits) or 3 (interpolated); ten times an hour apart about one read each.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Walk-back</b> (the gateway reads forward only): segments <c>(lo, hi]</c> stepping back from <c>hi</c>, each one
|
||||||
|
/// forward read; an empty segment grows the next ×4 (from the density the window read showed, else
|
||||||
|
/// <see cref="InitialWalkWidth"/>), and after two empty segments the whole remainder down to the epoch is asked once
|
||||||
|
/// (a complete answer ends the walk). A segment that fills its budget is narrowed towards <c>hi</c> to a width sized
|
||||||
|
/// from the density it showed, never below a row it saw (rows exist there) — so dense history is bisected, not paged.
|
||||||
|
/// At most <see cref="MaxWalkReads"/> reads.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Wire shape.</b> Every request bound is a whole millisecond (AVEVA stores milliseconds and truncates request
|
||||||
|
/// times); a row counts only strictly INSIDE its request — AVEVA's synthetic boundary row at the query start (the value
|
||||||
|
/// in effect there, <c>Quality = 133</c>, or a NULL row) can never be taken for a stored sample, nor a possible row at
|
||||||
|
/// the query end; the epoch and before hold nothing. A Bad row (a stored NULL arrives as value 0, <c>q=1 opc=0</c>,
|
||||||
|
/// #690) is value-less. Every stream is drained, never abandoned (a cancelled gateway stream evicts its pooled
|
||||||
|
/// historian session). Nothing is asked past <c>now + </c><see cref="RawAggregateReader.LookAheadHorizon"/> (the
|
||||||
|
/// historian stores nothing further ahead, and the gateway's AVEVA may refuse a far-future end); a strict gateway's
|
||||||
|
/// row ceiling (#691.3) is learned and honoured — a short read is ordinary paging here, never re-probed.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Values.</b> A gateway value is a Double or a String whatever the node's data type, so numeric tags interpolate
|
||||||
|
/// linearly (the SQL backends step a Boolean and round an Int64; the gateway wire carries no type).
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class GatewayAtTimeReader
|
||||||
|
{
|
||||||
|
/// <summary>The row budget of a window read and a walk-back segment.</summary>
|
||||||
|
internal const int ReadRows = 1024;
|
||||||
|
|
||||||
|
/// <summary>The row budget of the forward probe for the sample after the last time (boundary row + ties + one).</summary>
|
||||||
|
internal const int ProbeRows = 2 + RawAggregateReader.PreWindowAllowance;
|
||||||
|
|
||||||
|
/// <summary>The first walk-back segment's width when the window read showed no density.</summary>
|
||||||
|
internal static readonly TimeSpan InitialWalkWidth = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
|
/// <summary>The widest walk-back segment sized from density, before ×4 growth.</summary>
|
||||||
|
private static readonly TimeSpan MaxSeededWidth = TimeSpan.FromDays(1);
|
||||||
|
|
||||||
|
/// <summary>The most reads one walk-back may take.</summary>
|
||||||
|
internal const int MaxWalkReads = 64;
|
||||||
|
|
||||||
|
private const long Ms = TimeSpan.TicksPerMillisecond;
|
||||||
|
|
||||||
|
private static readonly DateTime Floor = RawAggregateReader.ProbeFloor;
|
||||||
|
|
||||||
|
/// <summary>One drained read: the rows strictly inside the request (ascending) and whether the stream ended short.</summary>
|
||||||
|
private readonly record struct Read(List<StoredSample> Rows, bool Complete);
|
||||||
|
|
||||||
|
/// <summary>Evaluates AtTime for <paramref name="timestampsUtc"/> (same length and order, duplicates included).</summary>
|
||||||
|
/// <param name="client">The gateway client.</param>
|
||||||
|
/// <param name="ceiling">The data source's learned gateway row ceiling (#691.3).</param>
|
||||||
|
/// <param name="tag">Historian tag name.</param>
|
||||||
|
/// <param name="timestampsUtc">Requested times (any kind; treated as UTC, as on the SQL backends).</param>
|
||||||
|
/// <param name="now">Server timestamp, the beyond-now rule and the look-ahead horizon.</param>
|
||||||
|
/// <param name="ct">Cancellation.</param>
|
||||||
|
/// <returns>One value per requested time, stamped with it.</returns>
|
||||||
|
/// <exception cref="HistoryReadLimitExceededException">More than <see cref="ReadRows"/> rows within one millisecond.</exception>
|
||||||
|
public static async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(IHistorianGatewayClient client, GatewayRowCeiling ceiling,
|
||||||
|
string tag, IReadOnlyList<DateTime> timestampsUtc, DateTime now, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var requested = timestampsUtc.Select(t => DateTime.SpecifyKind(t, DateTimeKind.Utc)).ToArray();
|
||||||
|
if (requested.Length == 0) return [];
|
||||||
|
var neighbours = await ResolveAsync(new Ctx(client, ceiling, tag, ct), requested.Distinct().Order().ToArray(), now)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
var result = new DataValueSnapshot[requested.Length];
|
||||||
|
for (var i = 0; i < requested.Length; i++)
|
||||||
|
{
|
||||||
|
var t = requested[i];
|
||||||
|
var (prev, next) = neighbours[t.Ticks];
|
||||||
|
result[i] = AtTimeEvaluator.Evaluate(t, prev, next, now, now);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record Ctx(IHistorianGatewayClient Client, GatewayRowCeiling Ceiling, string Tag, CancellationToken Ct);
|
||||||
|
|
||||||
|
/// <summary>prev / next for every time of <paramref name="times"/> (distinct, ascending).</summary>
|
||||||
|
private static async Task<Dictionary<long, (StoredSample? Prev, StoredSample? Next)>> ResolveAsync(
|
||||||
|
Ctx ctx, DateTime[] times, DateTime now)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<long, (StoredSample?, StoredSample?)>(times.Length);
|
||||||
|
var horizon = MsFloor(DateTime.MaxValue - now > RawAggregateReader.LookAheadHorizon
|
||||||
|
? DateTime.SpecifyKind(now + RawAggregateReader.LookAheadHorizon, DateTimeKind.Utc)
|
||||||
|
: DateTime.MaxValue.AddTicks(-Ms));
|
||||||
|
|
||||||
|
// Nothing is stored at or before the epoch.
|
||||||
|
var i = 0;
|
||||||
|
for (; i < times.Length && times[i] <= Floor; i++) result[times[i].Ticks] = (null, null);
|
||||||
|
if (i == times.Length) return result;
|
||||||
|
|
||||||
|
// Reads are planned on times clamped to the horizon (nothing is stored beyond it).
|
||||||
|
var qe = MsFloor(Min(times[^1], horizon)) + TimeSpan.FromMilliseconds(1);
|
||||||
|
var qs = RequestStart(Min(times[i], horizon));
|
||||||
|
StoredSample? before = null; // the latest stored sample at or before qs …
|
||||||
|
var beforeKnown = false; // … once known
|
||||||
|
DateTime? walkKnown = null; // a stored sample exists here (at or before qs): the walk-back never goes below it
|
||||||
|
var walkWidth = InitialWalkWidth;
|
||||||
|
var probeFrom = -1; // first index whose next lies at or after qe (complete last read)
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var read = await ReadTrustedAsync(ctx, qs, qe, ReadRows).ConfigureAwait(false);
|
||||||
|
var rows = read.Rows;
|
||||||
|
if (!read.Complete && rows.Count == 0)
|
||||||
|
throw TooDense(ctx.Tag, qs);
|
||||||
|
if (rows.Count >= 2 && !beforeKnown && walkKnown is null)
|
||||||
|
walkWidth = Clamp(TimeSpan.FromTicks(4 * (rows[^1].TimeUtc - rows[0].TimeUtc).Ticks / (rows.Count - 1)));
|
||||||
|
|
||||||
|
// A short stream saw every row up to qe; a full one every row before its last row's time x.
|
||||||
|
var x = read.Complete ? qe : rows[^1].TimeUtc;
|
||||||
|
var j = -1; // index of the last row at or before the current time
|
||||||
|
var k = i;
|
||||||
|
for (; k < times.Length; k++)
|
||||||
|
{
|
||||||
|
var t = times[k];
|
||||||
|
var tr = Min(t, horizon);
|
||||||
|
if (!read.Complete && tr >= x) break;
|
||||||
|
while (j + 1 < rows.Count && rows[j + 1].TimeUtc <= tr) j++;
|
||||||
|
|
||||||
|
StoredSample? prev;
|
||||||
|
if (j >= 0) prev = rows[j];
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!beforeKnown)
|
||||||
|
{
|
||||||
|
before = await WalkBackAsync(ctx, qs, walkKnown, walkWidth).ConfigureAwait(false);
|
||||||
|
beforeKnown = true;
|
||||||
|
}
|
||||||
|
prev = before;
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredSample? next = null;
|
||||||
|
if (NeedsNext(t, prev) && t < horizon)
|
||||||
|
{
|
||||||
|
if (j + 1 < rows.Count) next = rows[j + 1];
|
||||||
|
else if (probeFrom < 0) probeFrom = k; // after every row of a complete read: the probe answers it
|
||||||
|
}
|
||||||
|
result[t.Ticks] = (prev, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (k == times.Length) break;
|
||||||
|
|
||||||
|
// Truncated before times[k]: continue from the last row, or jump to times[k] with a density-sized margin.
|
||||||
|
var tn = Min(times[k], horizon);
|
||||||
|
var spanTicks = Math.Max(1, (x - rows[0].TimeUtc).Ticks);
|
||||||
|
var density = (double)rows.Count / spanTicks; // rows per tick
|
||||||
|
var gapRows = density * (tn - x).Ticks;
|
||||||
|
var margin = Clamp(TimeSpan.FromTicks((long)(ReadRows / 4 / Math.Max(density, 1e-18))));
|
||||||
|
var resume = MsFloor(x) - TimeSpan.FromMilliseconds(1);
|
||||||
|
var nextQs = gapRows < ReadRows / 2.0 ? resume : Max(RequestStart(tn - Min(margin, tn - Floor)), resume);
|
||||||
|
if (nextQs <= qs) throw TooDense(ctx.Tag, x);
|
||||||
|
|
||||||
|
if (nextQs < x)
|
||||||
|
{
|
||||||
|
// Every row before x was seen: the latest at or before nextQs is known.
|
||||||
|
var lastIndex = rows.FindLastIndex(r => r.TimeUtc <= nextQs);
|
||||||
|
if (lastIndex >= 0)
|
||||||
|
{
|
||||||
|
before = rows[lastIndex];
|
||||||
|
beforeKnown = true;
|
||||||
|
}
|
||||||
|
walkKnown = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
beforeKnown = false;
|
||||||
|
before = null;
|
||||||
|
walkKnown = x;
|
||||||
|
walkWidth = margin;
|
||||||
|
}
|
||||||
|
qs = nextQs;
|
||||||
|
i = k;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (probeFrom >= 0 && qe - TimeSpan.FromMilliseconds(1) < horizon)
|
||||||
|
{
|
||||||
|
var after = await FirstAfterAsync(ctx, qe - TimeSpan.FromMilliseconds(1), horizon, Min(times[^1], horizon)).ConfigureAwait(false);
|
||||||
|
if (after is not null)
|
||||||
|
for (var k = probeFrom; k < times.Length; k++)
|
||||||
|
{
|
||||||
|
var t = times[k];
|
||||||
|
var (prev, next) = result[t.Ticks];
|
||||||
|
if (next is null && t < horizon && NeedsNext(t, prev) && after.Value.TimeUtc > t)
|
||||||
|
result[t.Ticks] = (prev, after);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The evaluator reads <c>next</c> only for a usable <c>prev</c> that is not an exact hit.</summary>
|
||||||
|
private static bool NeedsNext(DateTime t, StoredSample? prev) => prev is { IsUsable: true } p && p.TimeUtc != t;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The latest stored sample with time in <c>(Floor, hi]</c>, walking back in forward-read segments (see the type
|
||||||
|
/// remarks). <paramref name="known"/>: a stored sample exists at that time (≤ <paramref name="hi"/>).
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<StoredSample?> WalkBackAsync(Ctx ctx, DateTime hi, DateTime? known, TimeSpan width)
|
||||||
|
{
|
||||||
|
var empties = 0;
|
||||||
|
var triedRest = false;
|
||||||
|
for (var reads = 0; reads < MaxWalkReads; reads++)
|
||||||
|
{
|
||||||
|
if (hi <= Floor) return null;
|
||||||
|
var rest = known is null && !triedRest && empties >= 2;
|
||||||
|
DateTime lo;
|
||||||
|
if (rest)
|
||||||
|
{
|
||||||
|
lo = Floor;
|
||||||
|
triedRest = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lo = (hi - Floor).Ticks > width.Ticks ? hi - width : Floor;
|
||||||
|
if (known is { } kn && lo < kn) lo = kn.AddTicks(-1); // never below a row known to exist (it is the floor)
|
||||||
|
}
|
||||||
|
|
||||||
|
var read = await ReadTrustedAsync(ctx, MsFloor(lo), MsFloor(hi) + TimeSpan.FromMilliseconds(1), ReadRows)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
StoredSample? first = null, last = null;
|
||||||
|
DateTime? lastAny = null;
|
||||||
|
foreach (var r in read.Rows)
|
||||||
|
{
|
||||||
|
if (r.TimeUtc > lo && r.TimeUtc <= hi)
|
||||||
|
{
|
||||||
|
first ??= r;
|
||||||
|
last = r;
|
||||||
|
}
|
||||||
|
lastAny = r.TimeUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (read.Complete)
|
||||||
|
{
|
||||||
|
if (last is not null) return last;
|
||||||
|
if (lo <= Floor) return null;
|
||||||
|
hi = lo;
|
||||||
|
empties++;
|
||||||
|
width = Grow(width);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncated. A whole-remainder read of deep history proves nothing: carry on with segments.
|
||||||
|
if (rest) continue;
|
||||||
|
if (last is not { } seen || (known is { } k0 && seen.TimeUtc <= k0))
|
||||||
|
throw TooDense(ctx.Tag, lastAny ?? lo);
|
||||||
|
var spanTicks = Math.Max(1, (seen.TimeUtc - first!.Value.TimeUtc).Ticks); // the density of the rows seen
|
||||||
|
known = seen.TimeUtc;
|
||||||
|
// Aim at about a quarter of a read from the density just seen, and never wider than half the open stretch.
|
||||||
|
var aimed = TimeSpan.FromTicks(Math.Max(Ms, spanTicks / 4));
|
||||||
|
var half = TimeSpan.FromTicks(Math.Max(Ms, (hi - seen.TimeUtc).Ticks / 2));
|
||||||
|
width = aimed < half ? aimed : half;
|
||||||
|
empties = 0;
|
||||||
|
}
|
||||||
|
throw new HistoryReadLimitExceededException(MaxWalkReads,
|
||||||
|
$"The AtTime read of '{ctx.Tag}' could not find the stored sample before a requested time within {MaxWalkReads} raw reads.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The first stored sample after <paramref name="after"/> in <c>(qs, end)</c>, from a small forward read.</summary>
|
||||||
|
private static async Task<StoredSample?> FirstAfterAsync(Ctx ctx, DateTime qs, DateTime end, DateTime after)
|
||||||
|
{
|
||||||
|
foreach (var budget in new[] { ProbeRows, ReadRows })
|
||||||
|
{
|
||||||
|
var read = await ReadTrustedAsync(ctx, qs, end, budget).ConfigureAwait(false);
|
||||||
|
foreach (var r in read.Rows)
|
||||||
|
if (r.TimeUtc > after) return r;
|
||||||
|
if (read.Complete) return null;
|
||||||
|
}
|
||||||
|
throw TooDense(ctx.Tag, qs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One forward read <c>[qs, qe]</c> (whole milliseconds), drained, keeping only rows strictly inside it and after the
|
||||||
|
/// epoch. A strict gateway's ceiling is learned on refusal and then asked for directly; a full read is not re-probed
|
||||||
|
/// (the caller pages on, it does not need the rest of this read).
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<Read> ReadTrustedAsync(Ctx ctx, DateTime qs, DateTime qe, int budget)
|
||||||
|
{
|
||||||
|
var (read, _) = await ctx.Ceiling.ReadAsync(budget, b => PassAsync(ctx, qs, qe, b), (_, _) => false)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return read;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Read> PassAsync(Ctx ctx, DateTime qs, DateTime qe, int budget)
|
||||||
|
{
|
||||||
|
var rows = new List<StoredSample>();
|
||||||
|
var received = 0;
|
||||||
|
await foreach (var raw in ctx.Client.ReadRawAsync(ctx.Tag, qs, qe, budget, ctx.Ct).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
received++; // drained to completion, never broken out of
|
||||||
|
if (raw.Timestamp is null) continue;
|
||||||
|
var t = raw.Timestamp.ToDateTime();
|
||||||
|
if (t > qs && t < qe && t > Floor) rows.Add(ToStored(raw, t));
|
||||||
|
}
|
||||||
|
return new Read(rows, received < budget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A gateway row as a stored sample; a Bad row carries no value (#690: a stored NULL arrives as value 0).</summary>
|
||||||
|
private static StoredSample ToStored(HistorianSample raw, DateTime t)
|
||||||
|
{
|
||||||
|
var status = SampleMapper.ToStatus(raw);
|
||||||
|
var value = StatusBits.IsBad(status) ? HistorianValue.Empty
|
||||||
|
: raw.HasNumericValue ? HistorianValue.FromDouble(raw.NumericValue)
|
||||||
|
: raw.HasStringValue ? HistorianValue.FromString(raw.StringValue)
|
||||||
|
: HistorianValue.Empty;
|
||||||
|
return StoredSample.FromStored(t, value, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The window read's start for a time: a millisecond before its millisecond (never before the epoch).</summary>
|
||||||
|
private static DateTime RequestStart(DateTime t)
|
||||||
|
{
|
||||||
|
var s = MsFloor(t) - TimeSpan.FromMilliseconds(1);
|
||||||
|
return s < Floor ? Floor : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime MsFloor(DateTime t) => new(t.Ticks - t.Ticks % Ms, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private static DateTime Min(DateTime a, DateTime b) => a <= b ? a : b;
|
||||||
|
|
||||||
|
private static DateTime Max(DateTime a, DateTime b) => a >= b ? a : b;
|
||||||
|
|
||||||
|
private static TimeSpan Min(TimeSpan a, TimeSpan b) => a <= b ? a : b;
|
||||||
|
|
||||||
|
private static TimeSpan Clamp(TimeSpan w) =>
|
||||||
|
w < TimeSpan.FromMilliseconds(1) ? TimeSpan.FromMilliseconds(1) : w > MaxSeededWidth ? MaxSeededWidth : w;
|
||||||
|
|
||||||
|
private static TimeSpan Grow(TimeSpan w) => w.Ticks > TimeSpan.MaxValue.Ticks / 4 ? TimeSpan.MaxValue : w * 4;
|
||||||
|
|
||||||
|
private static HistoryReadLimitExceededException TooDense(string tag, DateTime at) =>
|
||||||
|
new(ReadRows,
|
||||||
|
$"The AtTime read of '{tag}' met more than {ReadRows} stored samples within about one millisecond of " +
|
||||||
|
$"{at:O}; the Gateway backend serves AtTime from raw reads.");
|
||||||
|
}
|
||||||
+51
-1
@@ -46,6 +46,10 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
|||||||
private readonly ILogger<GatewayHistorianDataSource> _logger;
|
private readonly ILogger<GatewayHistorianDataSource> _logger;
|
||||||
private readonly int _maxRawRows;
|
private readonly int _maxRawRows;
|
||||||
private readonly TimeProvider _time;
|
private readonly TimeProvider _time;
|
||||||
|
private readonly bool _atTimeFromRaw;
|
||||||
|
|
||||||
|
/// <summary>#693: AtTime from raw reads (true) or AVEVA's own interpolation (the kill switch).</summary>
|
||||||
|
internal bool AtTimeFromRaw => _atTimeFromRaw;
|
||||||
|
|
||||||
/// <summary>A strict gateway's row ceiling, learned from its first refusal (#691.3).</summary>
|
/// <summary>A strict gateway's row ceiling, learned from its first refusal (#691.3).</summary>
|
||||||
private readonly GatewayRowCeiling _ceiling = new();
|
private readonly GatewayRowCeiling _ceiling = new();
|
||||||
@@ -67,9 +71,15 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
|||||||
/// <= 0 = unbounded). Past it the node fails <c>BadTooManyOperations</c>.
|
/// <= 0 = unbounded). Past it the node fails <c>BadTooManyOperations</c>.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="timeProvider">Clock for result server timestamps and the raw look-ahead horizon; null = system.</param>
|
/// <param name="timeProvider">Clock for result server timestamps and the raw look-ahead horizon; null = system.</param>
|
||||||
|
/// <param name="atTimeFromRaw">
|
||||||
|
/// #693: serve AtTime from raw reads with the shared AtTime rules (<see cref="GatewayAtTimeReader"/>; default);
|
||||||
|
/// <c>false</c> = AVEVA's own interpolation passed through (<c>ServerHistorian:GatewayAtTimeFromRaw</c> kill switch).
|
||||||
|
/// </param>
|
||||||
public GatewayHistorianDataSource(IHistorianGatewayClient client, ILogger<GatewayHistorianDataSource> logger,
|
public GatewayHistorianDataSource(IHistorianGatewayClient client, ILogger<GatewayHistorianDataSource> logger,
|
||||||
int maxRawRows = TimeWeightedAggregator.DefaultMaxWindowRows, TimeProvider? timeProvider = null)
|
int maxRawRows = TimeWeightedAggregator.DefaultMaxWindowRows, TimeProvider? timeProvider = null,
|
||||||
|
bool atTimeFromRaw = true)
|
||||||
{
|
{
|
||||||
|
_atTimeFromRaw = atTimeFromRaw;
|
||||||
ArgumentNullException.ThrowIfNull(client);
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
ArgumentNullException.ThrowIfNull(logger);
|
ArgumentNullException.ThrowIfNull(logger);
|
||||||
_client = client;
|
_client = client;
|
||||||
@@ -364,8 +374,48 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
|||||||
internal static bool IsMaxValuesCeilingRejection(Exception ex) => GatewayReadLimits.IsCeilingRejection(ex);
|
internal static bool IsMaxValuesCeilingRejection(Exception ex) => GatewayReadLimits.IsCeilingRejection(ex);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// #693: served from RAW gateway reads — each requested time's stored sample at-or-before and after it — evaluated with
|
||||||
|
/// the SQL backends' shared <see cref="AtTimeEvaluator"/> (Part 11 §6.5.4), so the Gateway answers what SqlServer and
|
||||||
|
/// QuestDB answer for the same stored samples; see <see cref="GatewayAtTimeReader"/> for the read plan and its cost.
|
||||||
|
/// AVEVA's own interpolated answer (the gateway's <c>ReadAtTime</c>) returned the previous value as Good at a stored
|
||||||
|
/// NULL's time, null + Bad at a Good sample right after a NULL, a drifted value at an exact stored sample, and no
|
||||||
|
/// Interpolated bit; it is only used when <c>ServerHistorian:GatewayAtTimeFromRaw = false</c> (kill switch).
|
||||||
|
/// A gateway value is a Double (or a String) whatever the node's type, so every numeric tag is interpolated linearly
|
||||||
|
/// (on the SQL backends a Boolean is stepped and an Int64 rounded — the gateway wire carries no type to tell).
|
||||||
|
/// </remarks>
|
||||||
public async Task<HistoryReadResult> ReadAtTimeAsync(
|
public async Task<HistoryReadResult> ReadAtTimeAsync(
|
||||||
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(timestampsUtc);
|
||||||
|
if (!_atTimeFromRaw) return await ReadAtTimeNativeAsync(fullReference, timestampsUtc, cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var now = _time.GetUtcNow().UtcDateTime;
|
||||||
|
var values = await GatewayAtTimeReader
|
||||||
|
.ReadAsync(_client, _ceiling, fullReference, timestampsUtc, now, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
RecordOutcome(success: true, error: null);
|
||||||
|
return new HistoryReadResult(values, ContinuationPoint: null);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw; // caller-requested, not a backend fault
|
||||||
|
}
|
||||||
|
catch (HistoryReadLimitExceededException)
|
||||||
|
{
|
||||||
|
throw; // a client-request limit (a pathological tie cluster), not a backend fault
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
RecordReadFailure(ex);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The pre-#693 AtTime: AVEVA's own interpolation, aligned to the request (kill switch only).</summary>
|
||||||
|
private async Task<HistoryReadResult> ReadAtTimeNativeAsync(
|
||||||
|
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
+3
-1
@@ -40,7 +40,9 @@ public static class GatewayHistorian
|
|||||||
HistorianGatewayClientAdapter.Create(options, loggerFactory),
|
HistorianGatewayClientAdapter.Create(options, loggerFactory),
|
||||||
logger,
|
logger,
|
||||||
// #681: the raw-evaluated per-sample aggregates share the SQL backends' raw-row cap.
|
// #681: the raw-evaluated per-sample aggregates share the SQL backends' raw-row cap.
|
||||||
maxRawRows: options.MaxTimeWeightedRawRows);
|
maxRawRows: options.MaxTimeWeightedRawRows,
|
||||||
|
// #693: AtTime from raw reads + the shared evaluator; false = AVEVA's own interpolation (kill switch).
|
||||||
|
atTimeFromRaw: options.GatewayAtTimeFromRaw);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -97,6 +97,15 @@ public sealed class ServerHistorianOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool HonorReturnBounds { get; init; } = true;
|
public bool HonorReturnBounds { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #693, <c>Gateway</c> backend only: whether HistoryRead-AtTime is served from RAW gateway reads evaluated with
|
||||||
|
/// the SQL backends' shared AtTime rules (exact stored value at a stored time, linear between usable neighbours with
|
||||||
|
/// the Interpolated bit, held before a Bad neighbour as Uncertain, null + the outage's Bad code inside an outage,
|
||||||
|
/// <c>BadNoData</c> before the first sample). Default <c>true</c>. <c>false</c> is the kill switch back to the
|
||||||
|
/// pre-#693 answer: AVEVA's own interpolation (the gateway's <c>ReadAtTime</c>), passed through unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public bool GatewayAtTimeFromRaw { get; init; } = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maximum number of historized nodes served CONCURRENTLY within one HistoryRead batch (arch-review
|
/// Maximum number of historized nodes served CONCURRENTLY within one HistoryRead batch (arch-review
|
||||||
/// 03/S3). The SDK's <c>HistoryRead*</c> override surface is synchronous, so each arm block-bridges
|
/// 03/S3). The SDK's <c>HistoryRead*</c> override surface is synchronous, so each arm block-bridges
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #693: Gateway HistoryRead AtTime is served from RAW reads and evaluated with the SQL backends' shared
|
||||||
|
/// <c>AtTimeEvaluator</c> (Part 11 §6.5.4) — never AVEVA's own interpolated answer, which the live gate caught returning
|
||||||
|
/// the previous value as Good at a stored NULL's own time, null + Bad at a Good sample right after a NULL, a drifted value
|
||||||
|
/// at an exact stored sample, and no Interpolated bit. The fake is AVEVA-shaped (the real wire shape): ReadRaw over
|
||||||
|
/// <c>[start, end]</c> ascending, capped, led by the synthetic boundary row at the query start (the value in effect there,
|
||||||
|
/// <c>Quality = 133</c>, or a NULL row), NULL rows as <c>NumericValue = 0</c> + <c>q=1 opc=0</c>, millisecond timestamps;
|
||||||
|
/// and it REJECTS what the real gateway rejects (<c>max_values <= 0</c>, <c>end < start</c>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GatewayAtTimeTests
|
||||||
|
{
|
||||||
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
private const uint Good = 0u;
|
||||||
|
private const uint Bad = 0x80000000u;
|
||||||
|
private const uint BadNoData = 0x809B0000u;
|
||||||
|
private const uint GoodInterpolated = 0x00000402u;
|
||||||
|
private const uint UncertainInterpolated = 0x40A40402u; // UncertainDataSubNormal | InfoType DataValue | Interpolated
|
||||||
|
|
||||||
|
/// <summary>The live gate's tag <c>HistGW.LiveTest.Gate685</c>: 19:00:03 is a stored NULL.</summary>
|
||||||
|
private static readonly DateTime T0 = new(2026, 10, 12, 19, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private static readonly DateTime Now = T0.AddHours(1);
|
||||||
|
|
||||||
|
private static DateTime At(double seconds) => T0.AddTicks((long)(seconds * TimeSpan.TicksPerSecond));
|
||||||
|
|
||||||
|
private static List<(DateTime T, double V, bool Null)> Gate685() =>
|
||||||
|
[(At(0), 100, false), (At(2), 107, false), (At(3), 0, true), (At(5), 108, false)];
|
||||||
|
|
||||||
|
/// <summary>An AVEVA-shaped store over sorted rows, counting reads and rejecting what the real gateway rejects.</summary>
|
||||||
|
private sealed class AvevaStore
|
||||||
|
{
|
||||||
|
private readonly (DateTime T, double V, bool Null)[] _rows;
|
||||||
|
|
||||||
|
public AvevaStore(IEnumerable<(DateTime T, double V, bool Null)> rows) => _rows = rows.OrderBy(r => r.T).ToArray();
|
||||||
|
|
||||||
|
/// <summary>Also emit a row at the exact query END carrying the value in effect there (unverified AVEVA behaviour).</summary>
|
||||||
|
public bool TerminalRow { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Total rows streamed (cost).</summary>
|
||||||
|
public long RowsStreamed;
|
||||||
|
|
||||||
|
public FakeHistorianGatewayClient Client(int? ceiling = null) => new()
|
||||||
|
{
|
||||||
|
RawSource = Serve,
|
||||||
|
MaxValuesCeiling = ceiling,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static HistorianSample Row(DateTime t, double v, bool isNull, uint quality = 0) => new()
|
||||||
|
{
|
||||||
|
Tag = "T",
|
||||||
|
Timestamp = Timestamp.FromDateTime(t),
|
||||||
|
NumericValue = isNull ? 0 : v,
|
||||||
|
OpcQuality = isNull ? 0u : 192u,
|
||||||
|
Quality = isNull ? 1u : quality,
|
||||||
|
};
|
||||||
|
|
||||||
|
private int FirstAtOrAfter(DateTime t)
|
||||||
|
{
|
||||||
|
int lo = 0, hi = _rows.Length;
|
||||||
|
while (lo < hi)
|
||||||
|
{
|
||||||
|
var mid = (lo + hi) / 2;
|
||||||
|
if (_rows[mid].T < t) lo = mid + 1; else hi = mid;
|
||||||
|
}
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HistorianSample InEffect(DateTime t)
|
||||||
|
{
|
||||||
|
var i = FirstAtOrAfter(t) - 1;
|
||||||
|
if (i < 0) return new HistorianSample { Tag = "T", Timestamp = Timestamp.FromDateTime(t), NumericValue = 0, OpcQuality = 0, Quality = 1 };
|
||||||
|
var p = _rows[i];
|
||||||
|
return Row(t, p.V, p.Null, p.Null ? 1u : 133u);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<HistorianSample> Serve(DateTime start, DateTime end, int max)
|
||||||
|
{
|
||||||
|
if (max <= 0)
|
||||||
|
throw new Grpc.Core.RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument, "max_values must be greater than zero."));
|
||||||
|
if (end < start)
|
||||||
|
throw new Grpc.Core.RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument,
|
||||||
|
"start_time must be less than or equal to end_time."));
|
||||||
|
if (end > Now.AddDays(2))
|
||||||
|
throw new Grpc.Core.RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument,
|
||||||
|
"end_time is outside the historian's time range"));
|
||||||
|
if (start.Ticks % TimeSpan.TicksPerMillisecond != 0 || end.Ticks % TimeSpan.TicksPerMillisecond != 0)
|
||||||
|
throw new InvalidOperationException("AVEVA truncates request times to the millisecond; send millisecond bounds.");
|
||||||
|
|
||||||
|
var rows = new List<HistorianSample>();
|
||||||
|
var i = FirstAtOrAfter(start);
|
||||||
|
if (i >= _rows.Length || _rows[i].T != start) rows.Add(InEffect(start));
|
||||||
|
for (; i < _rows.Length && _rows[i].T <= end && rows.Count < max; i++)
|
||||||
|
rows.Add(Row(_rows[i].T, _rows[i].V, _rows[i].Null));
|
||||||
|
if (TerminalRow && rows.Count < max && (i >= _rows.Length || _rows[i].T > end) && _rows.All(r => r.T != end))
|
||||||
|
rows.Add(InEffect(end));
|
||||||
|
var served = rows.Take(max).ToList();
|
||||||
|
RowsStreamed += served.Count;
|
||||||
|
return served;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GatewayHistorianDataSource Source(FakeHistorianGatewayClient fake) =>
|
||||||
|
new(fake, NullLogger<GatewayHistorianDataSource>.Instance, timeProvider: new FixedTime(Now));
|
||||||
|
|
||||||
|
private sealed class FixedTime(DateTime now) : TimeProvider
|
||||||
|
{
|
||||||
|
public override DateTimeOffset GetUtcNow() => new(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IReadOnlyList<DataValueSnapshot>> AtTime(FakeHistorianGatewayClient fake, params DateTime[] times)
|
||||||
|
{
|
||||||
|
var r = await Source(fake).ReadAtTimeAsync("T", times, Ct);
|
||||||
|
r.Samples.Count.ShouldBe(times.Length);
|
||||||
|
for (var i = 0; i < times.Length; i++) r.Samples[i].SourceTimestampUtc.ShouldBe(times[i]);
|
||||||
|
var refused = fake.MaxValuesCeiling is { } c ? fake.RawCalls.Count(x => x.MaxValues > c) : 0;
|
||||||
|
fake.RawStreamsCompleted.ShouldBe(fake.ReadRawCallCount - refused); // never abandons a gateway stream
|
||||||
|
fake.ReadAtTimeCallCount.ShouldBe(0); // never AVEVA's own interpolation
|
||||||
|
return r.Samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the four live findings ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact] // live: 19:00:03 (a stored NULL) ⇒ 107 Good
|
||||||
|
public async Task At_a_stored_NULLs_own_time_is_null_and_Bad()
|
||||||
|
{
|
||||||
|
var s = (await AtTime(new AvevaStore(Gate685()).Client(), At(3))).Single();
|
||||||
|
s.Value.ShouldBeNull();
|
||||||
|
s.StatusCode.ShouldBe(Bad);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] // live: 19:00:05 (stored 108, right after the NULL) ⇒ null + Bad
|
||||||
|
public async Task At_a_Good_sample_right_after_a_NULL_is_the_stored_value()
|
||||||
|
{
|
||||||
|
var s = (await AtTime(new AvevaStore(Gate685()).Client(), At(5))).Single();
|
||||||
|
s.Value.ShouldBe(108d);
|
||||||
|
s.StatusCode.ShouldBe(Good);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] // live: 99.99999999944 for a stored 100
|
||||||
|
public async Task At_an_exact_stored_sample_is_the_exact_stored_value_without_the_Interpolated_bit()
|
||||||
|
{
|
||||||
|
var s = await AtTime(new AvevaStore(Gate685()).Client(), At(0), At(2));
|
||||||
|
s[0].Value.ShouldBe(100d);
|
||||||
|
s[0].StatusCode.ShouldBe(Good);
|
||||||
|
s[1].Value.ShouldBe(107d);
|
||||||
|
s[1].StatusCode.ShouldBe(Good);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact] // live: no Interpolated bit
|
||||||
|
public async Task Between_two_Good_samples_is_linear_with_the_Interpolated_bit()
|
||||||
|
{
|
||||||
|
var s = (await AtTime(new AvevaStore(Gate685()).Client(), At(1))).Single();
|
||||||
|
s.Value.ShouldBe(103.5d);
|
||||||
|
s.StatusCode.ShouldBe(GoodInterpolated);
|
||||||
|
new Opc.Ua.StatusCode(s.StatusCode).AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Interpolated);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the rest of the AtTime rules --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Before_a_Bad_neighbour_the_previous_value_is_held_Uncertain_and_Interpolated()
|
||||||
|
{
|
||||||
|
var s = (await AtTime(new AvevaStore(Gate685()).Client(), At(2.5))).Single();
|
||||||
|
s.Value.ShouldBe(107d);
|
||||||
|
s.StatusCode.ShouldBe(UncertainInterpolated);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Inside_an_outage_is_null_with_the_outages_Bad_code()
|
||||||
|
{
|
||||||
|
var s = (await AtTime(new AvevaStore(Gate685()).Client(), At(4))).Single();
|
||||||
|
s.Value.ShouldBeNull();
|
||||||
|
s.StatusCode.ShouldBe(Bad);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Before_the_first_sample_is_BadNoData_not_the_boundary_rows_zero()
|
||||||
|
{
|
||||||
|
var s = await AtTime(new AvevaStore(Gate685()).Client(), At(-1), At(-0.001), At(-3600));
|
||||||
|
s.ShouldAllBe(x => x.StatusCode == BadNoData && x.Value == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task After_the_last_sample_is_stepped_Good_until_now_then_Uncertain()
|
||||||
|
{
|
||||||
|
var s = await AtTime(new AvevaStore(Gate685()).Client(), At(10), Now.AddMinutes(5), Now.AddDays(3));
|
||||||
|
s[0].Value.ShouldBe(108d);
|
||||||
|
s[0].StatusCode.ShouldBe(GoodInterpolated);
|
||||||
|
s[1].Value.ShouldBe(108d);
|
||||||
|
s[1].StatusCode.ShouldBe(UncertainInterpolated); // later than now: not known to still hold
|
||||||
|
s[2].Value.ShouldBe(108d);
|
||||||
|
s[2].StatusCode.ShouldBe(UncertainInterpolated); // beyond the horizon: never sent to the gateway (it refuses)
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_mixed_request_keeps_order_and_duplicates()
|
||||||
|
{
|
||||||
|
var s = await AtTime(new AvevaStore(Gate685()).Client(), At(5), At(1), At(-1), At(3), At(1), At(2.5));
|
||||||
|
s.Select(x => x.Value).ShouldBe(new object?[] { 108d, 103.5d, null, null, 103.5d, 107d });
|
||||||
|
s.Select(x => x.StatusCode).ShouldBe(new[] { Good, GoodInterpolated, BadNoData, Bad, GoodInterpolated, UncertainInterpolated });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_empty_request_reads_nothing()
|
||||||
|
{
|
||||||
|
var fake = new AvevaStore(Gate685()).Client();
|
||||||
|
(await Source(fake).ReadAtTimeAsync("T", [], Ct)).Samples.ShouldBeEmpty();
|
||||||
|
fake.ReadRawCallCount.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_sub_millisecond_request_time_interpolates_between_the_millisecond_samples()
|
||||||
|
{
|
||||||
|
var s = (await AtTime(new AvevaStore(Gate685()).Client(), At(0.0005))).Single();
|
||||||
|
((double)s.Value!).ShouldBe(100.00175d, 1e-9);
|
||||||
|
s.StatusCode.ShouldBe(GoodInterpolated);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_row_at_the_query_end_never_counts_as_a_stored_sample()
|
||||||
|
{
|
||||||
|
var store = new AvevaStore(Gate685()) { TerminalRow = true };
|
||||||
|
var s = await AtTime(store.Client(), At(1), At(2.5), At(4), At(10));
|
||||||
|
s.Select(x => x.Value).ShouldBe(new object?[] { 103.5d, 107d, null, 108d });
|
||||||
|
s.Select(x => x.StatusCode).ShouldBe(new[] { GoodInterpolated, UncertainInterpolated, Bad, GoodInterpolated });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_string_tag_is_stepped()
|
||||||
|
{
|
||||||
|
var fake = new FakeHistorianGatewayClient
|
||||||
|
{
|
||||||
|
RawSource = (start, end, max) =>
|
||||||
|
{
|
||||||
|
var rows = new List<HistorianSample>
|
||||||
|
{
|
||||||
|
new() { Tag = "T", Timestamp = Timestamp.FromDateTime(start), StringValue = "", OpcQuality = 0, Quality = 1 },
|
||||||
|
};
|
||||||
|
foreach (var (t, v) in new[] { (At(0), "a"), (At(10), "b") })
|
||||||
|
if (t >= start && t <= end)
|
||||||
|
rows.Add(new HistorianSample { Tag = "T", Timestamp = Timestamp.FromDateTime(t), StringValue = v, OpcQuality = 192 });
|
||||||
|
return rows.Take(max);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var s = (await AtTime(fake, At(5))).Single();
|
||||||
|
s.Value.ShouldBe("a");
|
||||||
|
s.StatusCode.ShouldBe(GoodInterpolated);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- cost ------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static IEnumerable<(DateTime T, double V, bool Null)> OneHertz(DateTime from, int seconds) =>
|
||||||
|
Enumerable.Range(0, seconds).Select(i => (from.AddSeconds(i), (double)i, false));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ten_exact_hits_on_dense_data_are_one_read()
|
||||||
|
{
|
||||||
|
var fake = new AvevaStore(OneHertz(T0.AddHours(-1), 3600)).Client();
|
||||||
|
var times = Enumerable.Range(0, 10).Select(i => T0.AddMinutes(-30).AddSeconds(i)).ToArray();
|
||||||
|
var s = await AtTime(fake, times);
|
||||||
|
s.Select(x => x.Value).ShouldBe(Enumerable.Range(1800, 10).Select(i => (object?)(double)i));
|
||||||
|
s.ShouldAllBe(x => x.StatusCode == Good);
|
||||||
|
fake.ReadRawCallCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ten_interpolated_times_on_dense_data_are_three_reads()
|
||||||
|
{
|
||||||
|
var fake = new AvevaStore(OneHertz(T0.AddHours(-1), 3600)).Client();
|
||||||
|
var times = Enumerable.Range(0, 10).Select(i => T0.AddMinutes(-30).AddSeconds(i + 0.5)).ToArray();
|
||||||
|
var s = await AtTime(fake, times);
|
||||||
|
s.Select(x => x.Value).ShouldBe(Enumerable.Range(1800, 10).Select(i => (object?)(i + 0.5)));
|
||||||
|
s.ShouldAllBe(x => x.StatusCode == GoodInterpolated);
|
||||||
|
fake.ReadRawCallCount.ShouldBe(3); // the window, the sample before the first time, the sample after the last
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ten_times_an_hour_apart_on_dense_data_cost_about_one_read_each_not_the_rows_between()
|
||||||
|
{
|
||||||
|
var store = new AvevaStore(OneHertz(T0.AddHours(-12), 12 * 3600));
|
||||||
|
var fake = store.Client();
|
||||||
|
var times = Enumerable.Range(0, 10).Select(i => T0.AddHours(-11).AddHours(i).AddSeconds(0.5)).ToArray();
|
||||||
|
var s = await AtTime(fake, times);
|
||||||
|
s.Select(x => x.Value).ShouldBe(Enumerable.Range(0, 10).Select(i => (object?)(3600.0 * (i + 1) + 0.5)));
|
||||||
|
s.ShouldAllBe(x => x.StatusCode == GoodInterpolated);
|
||||||
|
fake.ReadRawCallCount.ShouldBe(12); // 10 window reads (each finds its own prev) + 1 walk-back + 1 probe
|
||||||
|
store.RowsStreamed.ShouldBeLessThan(10 * 1100); // never the ~32,400 rows between the first and last time
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sparse_history_far_back_is_found_in_a_bounded_number_of_reads()
|
||||||
|
{
|
||||||
|
var fake = new AvevaStore([(T0.AddDays(-400), 42, false), (T0.AddDays(-400).AddSeconds(10), 43, false)]).Client();
|
||||||
|
var s = (await AtTime(fake, T0)).Single();
|
||||||
|
s.Value.ShouldBe(43d);
|
||||||
|
s.StatusCode.ShouldBe(GoodInterpolated);
|
||||||
|
fake.ReadRawCallCount.ShouldBe(5); // window, 1-min + 4-min segments, the whole remainder, the probe after
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_tag_with_no_history_is_BadNoData_in_a_bounded_number_of_reads()
|
||||||
|
{
|
||||||
|
var fake = new AvevaStore([]).Client();
|
||||||
|
var s = await AtTime(fake, At(1), At(2));
|
||||||
|
s.ShouldAllBe(x => x.StatusCode == BadNoData);
|
||||||
|
fake.ReadRawCallCount.ShouldBe(4); // window, two segments, the whole remainder (no probe: nothing to interpolate)
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dense_data_far_denser_than_one_read_still_finds_the_neighbours()
|
||||||
|
{
|
||||||
|
// 1 kHz for 10 minutes: a 1-minute walk-back segment holds 60,000 rows (truncated) — it must narrow, not fail.
|
||||||
|
var rows = Enumerable.Range(0, 600_000).Select(i => (T0.AddMinutes(-20).AddMilliseconds(i), (double)i, false));
|
||||||
|
var fake = new AvevaStore(rows).Client();
|
||||||
|
var t = T0.AddMinutes(-15).AddTicks(5000); // 0.5 ms past sample 300,000
|
||||||
|
var s = (await AtTime(fake, t)).Single();
|
||||||
|
s.Value.ShouldBe(300_000.5d);
|
||||||
|
s.StatusCode.ShouldBe(GoodInterpolated);
|
||||||
|
fake.ReadRawCallCount.ShouldBe(2); // the window (holds the prev) + the probe
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_walk_back_into_data_far_denser_than_one_read_narrows_instead_of_paging()
|
||||||
|
{
|
||||||
|
// 1 kHz for 10 minutes, then a 10-minute gap: the walk-back crosses the gap and lands in 600,000 rows.
|
||||||
|
var rows = Enumerable.Range(0, 600_000).Select(i => (T0.AddMinutes(-20).AddMilliseconds(i), (double)i, false));
|
||||||
|
var store = new AvevaStore(rows);
|
||||||
|
var fake = store.Client();
|
||||||
|
var s = (await AtTime(fake, T0.AddMinutes(-5))).Single();
|
||||||
|
s.Value.ShouldBe(599_999d);
|
||||||
|
s.StatusCode.ShouldBe(GoodInterpolated); // stepped from the last sample (before now)
|
||||||
|
fake.ReadRawCallCount.ShouldBe(7); // window, 1m, 4m, remainder (truncated), 16m (truncated), 256 ms, probe
|
||||||
|
store.RowsStreamed.ShouldBeLessThan(10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_strict_gateway_with_a_small_ceiling_is_still_served()
|
||||||
|
{
|
||||||
|
var store = new AvevaStore(OneHertz(T0.AddHours(-1), 3600));
|
||||||
|
var fake = store.Client(ceiling: 100);
|
||||||
|
var times = Enumerable.Range(0, 10).Select(i => T0.AddMinutes(-30).AddSeconds(i * 30 + 0.5)).ToArray();
|
||||||
|
var s = await AtTime(fake, times);
|
||||||
|
s.Select(x => x.Value).ShouldBe(Enumerable.Range(0, 10).Select(i => (object?)(1800 + i * 30 + 0.5)));
|
||||||
|
s.ShouldAllBe(x => x.StatusCode == GoodInterpolated);
|
||||||
|
fake.RawCalls.Count(c => c.MaxValues > 100).ShouldBe(1); // learned once, then remembered — a short page is not an error
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Success_is_recorded_once()
|
||||||
|
{
|
||||||
|
var fake = new AvevaStore(Gate685()).Client();
|
||||||
|
var ds = Source(fake);
|
||||||
|
await ds.ReadAtTimeAsync("T", [At(1), At(4)], Ct);
|
||||||
|
var h = ds.GetHealthSnapshot();
|
||||||
|
h.TotalQueries.ShouldBe(1);
|
||||||
|
h.TotalSuccesses.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_gateway_fault_is_a_recorded_failure()
|
||||||
|
{
|
||||||
|
var fake = new AvevaStore(Gate685()).Client();
|
||||||
|
fake.ThrowOnRead = new Grpc.Core.RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.Unavailable, "down"));
|
||||||
|
var ds = Source(fake);
|
||||||
|
await Should.ThrowAsync<Exception>(() => ds.ReadAtTimeAsync("T", [At(1)], Ct));
|
||||||
|
ds.GetHealthSnapshot().TotalFailures.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- kill switch -----------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_kill_switch_restores_AVEVAs_own_answer()
|
||||||
|
{
|
||||||
|
var fake = new AvevaStore(Gate685()).Client();
|
||||||
|
fake.AtTimeSamples = [new HistorianSample { NumericValue = 107, OpcQuality = 192, Timestamp = Timestamp.FromDateTime(At(3)) }];
|
||||||
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance, atTimeFromRaw: false);
|
||||||
|
var s = (await ds.ReadAtTimeAsync("T", [At(3)], Ct)).Samples.Single();
|
||||||
|
s.Value.ShouldBe(107d);
|
||||||
|
fake.ReadAtTimeCallCount.ShouldBe(1);
|
||||||
|
fake.ReadRawCallCount.ShouldBe(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -106,7 +106,7 @@ public sealed class GatewayHistorianDataSourceTests
|
|||||||
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
var t1 = t0.AddSeconds(1);
|
var t1 = t0.AddSeconds(1);
|
||||||
fake.AtTimeSamples = new[] { new HistorianSample { NumericValue = 5.0, OpcQuality = 192, Timestamp = Timestamp.FromDateTime(t0) } };
|
fake.AtTimeSamples = new[] { new HistorianSample { NumericValue = 5.0, OpcQuality = 192, Timestamp = Timestamp.FromDateTime(t0) } };
|
||||||
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance, atTimeFromRaw: false); // #693 kill switch
|
||||||
var r = await ds.ReadAtTimeAsync("T", new[] { t0, t1 }, TestContext.Current.CancellationToken);
|
var r = await ds.ReadAtTimeAsync("T", new[] { t0, t1 }, TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(2, r.Samples.Count); // exactly one per requested ts, in order
|
Assert.Equal(2, r.Samples.Count); // exactly one per requested ts, in order
|
||||||
Assert.Equal(5.0, r.Samples[0].Value);
|
Assert.Equal(5.0, r.Samples[0].Value);
|
||||||
@@ -122,7 +122,7 @@ public sealed class GatewayHistorianDataSourceTests
|
|||||||
{
|
{
|
||||||
new HistorianSample { NumericValue = 0, OpcQuality = 0, Quality = 1, Timestamp = Timestamp.FromDateTime(t0) },
|
new HistorianSample { NumericValue = 0, OpcQuality = 0, Quality = 1, Timestamp = Timestamp.FromDateTime(t0) },
|
||||||
};
|
};
|
||||||
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance, atTimeFromRaw: false); // #693 kill switch
|
||||||
var r = await ds.ReadAtTimeAsync("T", new[] { t0 }, TestContext.Current.CancellationToken);
|
var r = await ds.ReadAtTimeAsync("T", new[] { t0 }, TestContext.Current.CancellationToken);
|
||||||
Assert.Null(r.Samples[0].Value);
|
Assert.Null(r.Samples[0].Value);
|
||||||
Assert.Equal(0x80000000u, r.Samples[0].StatusCode);
|
Assert.Equal(0x80000000u, r.Samples[0].StatusCode);
|
||||||
|
|||||||
+14
@@ -117,4 +117,18 @@ public sealed class HistorianGatewayClientAdapterTests
|
|||||||
var dataSource = GatewayHistorian.CreateDataSource(opts, services);
|
var dataSource = GatewayHistorian.CreateDataSource(opts, services);
|
||||||
Assert.IsType<GatewayHistorianDataSource>(dataSource);
|
Assert.IsType<GatewayHistorianDataSource>(dataSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory] // #693: the ServerHistorian:GatewayAtTimeFromRaw kill switch reaches the data source (default on)
|
||||||
|
[InlineData(null, true)]
|
||||||
|
[InlineData(true, true)]
|
||||||
|
[InlineData(false, false)]
|
||||||
|
public void Factory_passes_the_AtTime_kill_switch(bool? configured, bool expected)
|
||||||
|
{
|
||||||
|
var opts = configured is { } c
|
||||||
|
? new ServerHistorianOptions { Enabled = true, Endpoint = "https://localhost:5222", ApiKey = "histgw_x_y", GatewayAtTimeFromRaw = c }
|
||||||
|
: new ServerHistorianOptions { Enabled = true, Endpoint = "https://localhost:5222", ApiKey = "histgw_x_y" };
|
||||||
|
using var services = new ServiceCollection().BuildServiceProvider();
|
||||||
|
var dataSource = Assert.IsType<GatewayHistorianDataSource>(GatewayHistorian.CreateDataSource(opts, services));
|
||||||
|
dataSource.AtTimeFromRaw.ShouldBe(expected);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user