docs(historian): #686 review — reverse raw walk details, uncapped Gateway raw reads, tie-cluster status

This commit is contained in:
Joseph Doherty
2026-10-12 15:11:21 -04:00
parent cf1af55720
commit 2d03268975
2 changed files with 32 additions and 14 deletions
+31 -13
View File
@@ -143,7 +143,7 @@ Processed read is `BadAggregateNotSupported` and no history aggregate functions
| `AllowUntrustedServerCertificate` | bool | `false` | **Gateway backend only.** Accept a self-signed / untrusted server certificate (dev / on-prem only). |
| `CaCertificatePath` | string\|null | `null` | **Gateway backend only.** PEM CA file pinning the gateway's TLS chain. Null/empty uses the OS trust store. |
| `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 `BadHistoryOperationUnsupported`. 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. |
| `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`. |
| `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). |
@@ -612,7 +612,8 @@ paging time-based:
>
> **Cluster larger than `MaxTieClusterOverfetch`.** If the over-fetch itself reaches the ceiling
> without spanning the full cluster, the node fails **loudly** with
> `BadHistoryOperationUnsupported` and the tag + timestamp + ceiling are logged. Remedies: raise
> `BadTooManyOperations` (a request limit; `BadHistoryOperationUnsupported` before #686 — events reads changed the same
> way) and the tag + timestamp + ceiling are logged. Remedies: raise
> `MaxTieClusterOverfetch` (or `NumValuesPerNode`) to cover the full cluster, or investigate the
> data anomaly (raw samples normally carry strictly increasing distinct timestamps).
>
@@ -630,17 +631,28 @@ backend:
(`… > lo AND … <= hi ORDER BY time DESC`).
- **Gateway** (the default backend) reports `false` — the gateway's `ReadRaw` streams ascending only and rejects
`start > end` with `InvalidArgument`. The server never sends it one (and the source refuses one unsent, not counted as a
health failure): it **walks the window back in bounded forward reads** (`ReverseRawReader`). It reads one segment
`(segLo, segHi]` forward — requested `[segLo, segHi + resolution]` and filtered on the samples' own timestamps, which also
drops the rows AVEVA fabricates at a query's start — keeps it newest-first, and steps to the next older segment until the
page is full or the window is exhausted. Segments start at **1 minute** and grow ×4 while sparse, so "the last N values"
of a live tag is normally one small read and sparse history is found in a few dozen tiny ones. Each segment read asks for
at most `MaxTieClusterOverfetch + 1` rows; a read that comes back full may be truncated (its newest rows are the ones
needed), so it is discarded and the segment re-read at a quarter of the width — **never answered from a truncated
read**. A single-timestamp tie cluster larger than `MaxTieClusterOverfetch` cannot be served newest-first this way and
the node is refused **`BadTooManyOperations`** (a request limit, not a backend fault). The walk stops at the **Unix epoch**
(AVEVA holds nothing earlier): a walked-back read returns no sample before 1970. Paging is unchanged — a reverse cursor
resumes as a fresh walk from its resume point.
health failure); it serves the reverse window from forward reads (`ReverseRawReader`):
- **Uncapped** (`NumValuesPerNode = 0`): **one** forward read of the whole window, filtered and reversed in memory — the
cost and memory of an uncapped forward read.
- **Capped: a bounded walk back.** It reads one segment `(segLo, segHi]` forward, keeps it newest-first, and steps to
the next older segment until the page is full or the window is exhausted. A segment is requested as
`[⌊segLo⌋, ⌈segHi + 1 tick⌉]`, both rounded **outward to the millisecond**, and filtered on the samples' own
timestamps — exact for an inclusive or exclusive forward end, and the row AVEVA fabricates at a query's (possibly
rounded-up) start can never land inside the segment. The first segment is **1 minute** (or the width the previous
page ended on — it rides the continuation point); each next width is aimed at about half the row budget from the
density just observed, growing at most ×4. So "the last N values" of a live tag is normally one read, a dense tag
costs about one read per page, and sparse history is found in a few dozen tiny ones.
- **Never answered from a truncated read.** The row budget is `M = max(1, MaxTieClusterOverfetch)`; a segment read asks
for `M + 2` rows (room for the fabricated row and one to see past the segment). Rows stream ascending, so a read is
complete when it came back short or its last row is past the segment; otherwise it is discarded — re-read once with
room for the rows before the segment that crowded it (a neighbouring tie cluster, at most `2M + 4` rows), or narrowed
to the density it showed. Only **one timestamp holding more than `M` samples** is refused, **`BadTooManyOperations`**
(a request limit, not a backend fault); a cluster of exactly `M`, or neighbouring clusters each within `M`, is served.
- **Bounds.** The walk starts no later than **now + 1 day** (a `StartTime = DateTime.MaxValue` read does not send
year-9999 query starts) and never reads before the **Unix epoch** (AVEVA holds nothing earlier): a walked-back read
returns only samples strictly after 1970-01-01T00:00:00Z and no later than now + 1 day.
Paging is unchanged — a reverse cursor resumes as a fresh walk from its resume point.
- **Null** reports `true` (empty in either direction).
> ⚠️ **Client-visible (#686).** Since #669 an end-only capped raw read, and any explicit reverse raw read, on the Gateway
@@ -648,6 +660,12 @@ backend:
> refused `start > end`). Before #669 an end-only read on Gateway returned the **oldest** N values ascending. Both now return
> the Part 11 answer — the latest values at or before `EndTime`, newest first. **Events differ:** an end-only events read on
> Gateway still keeps its forward meaning and an explicit reverse events read is refused (see below).
>
> ⚠️ **Also fixed with #686: uncapped raw reads on Gateway.** `NumValuesPerNode = 0` ("all values") used to be forwarded as
> `max_values = 0`, which the client package and the gateway reject — every uncapped raw read (forward too) failed the node
> and counted as a backend failure. It is now sent unbounded (`int.MaxValue`); a gateway configured with
> `AllowStreamingBeyondCeiling=false` refuses that over its `MaxValuesCeiling`, which is answered **`BadTooManyOperations`**
> (not a backend fault). A cancelled read (client cancel / `HistoryReadDeadline`) is no longer counted as a backend failure.
Continuation points are bound to the OPC UA session (the SDK's
`ServerConfiguration.MaxHistoryContinuationPoints` cap, default 100, with oldest-eviction; points