Merge fix/689-reverse-walk-sizing: page-sized reverse walk, skip proven-empty history, reverse events on Gateway (#689 #691.4 #691.5)
This commit is contained in:
@@ -1353,7 +1353,7 @@ historian tagname, which is the tag's driver `FullName`.
|
|||||||
- **SQL Server** (`Driver.Historian.SqlServer`): `hist.Sample` PK `(TagId, SourceTimeUtc)` — writes are insert-if-absent, so outbox redelivery is idempotent. Stores every value kind with its **real status**; per-sample aggregates (10, simple definitions) are pushed down to T-SQL with bucket math in **100 ns ticks**; the Part 13 time-weighted seven (TimeAverage/TimeAverage2/Total/Total2/Interpolative, #671 — ⚠️ `Total` is now the time-weighted integral, no longer `SUM` — plus the **duration-based** PercentGood/PercentBad, #680) stream window + 4 bound rows into the shared `TimeWeightedAggregator` (both SQL backends, identical results; reverse `Interpolative` anchored at the later time; window capped by `ServerHistorian:MaxTimeWeightedRawRows` ⇒ `BadTooManyOperations`, percentages included). ⚠️ **#680 status rules (client-visible):** TimeAverage/Total are Uncertain if any part of the interval uses an Uncertain value, is bridged across a Bad sample, is extrapolated past the last sample (⇒ a live tag's latest interval is Uncertain) or is not covered; TimeAverage2/Total2 keep the SDK time-based rule (Uncertain time counts as Good) but never extrapolate past the last sample — that interval is Partial, later ones `BadNoData` (a report-by-exception tag with nothing after its last sample reads `BadNoData` until its next sample arrives). `docs/Historian.md` §"Time-weighted aggregates". ⚠️ **#680 per-sample (client-visible):** `Count` counts **Good** samples only (value-based status; an all-Bad interval is generic Bad, value null) and an empty interval **inside the tag's data range** is `0`/Good (outside it still `BadNoData`); `Start`/`End` return the **raw** first/last sample of any quality — its own timestamp and status, value null when Bad; `Delta` is Uncertain only when the interval's first or last sample is unusable; ⚠️ #684: `Average`/`Minimum`/`Maximum`/`Range`/`*ActualTime` use **Good samples only** (was Good + Uncertain; an Uncertain-only interval is `BadNoData`), Min/Max-family status Uncertain on a Bad sample, an Uncertain value beyond the Good extreme (Part 13 — the SDK ignores that) or an Uncertain non-numeric sample, and only a **Good** String/DateTime sample refuses them (`BadAggregateInvalidInputs`; Delta still refuses any usable one); and every per-sample aggregate but `Average` sets **end-of-data Partial** on the interval holding the last sample (forward incl. its earlier edge, reverse strictly inside), never on a Bad result. The data-range lookup is a key seek on SQL Server and a `tag` symbol-index seek on QuestDB (#683 — `{p}sample.tag` is `SYMBOL INDEX`; the bootstrapper ADDs it to a pre-#683 table, idempotent), read BEFORE the bucket statement so a racing append can never yield a Good `Count` of 0 (a concurrent HistoryUpdate delete is the mirror case and still can, transiently). `docs/Historian.md` §"Aggregates". Docker-dev recipe, schema and aggregate semantics: `docs/Historian.md` §"SQL Server backend". Integration tests need `HISTORIAN_SQL_TEST_CONNECTION`. **Retention (#667):** opt-in `SqlServer:RetentionDays` (+ `PurgeInterval` 1h, `PurgeBatchSize` 4000) -> `HistorianRetentionService` purges old `hist.Sample` (per tag) / `hist.Event` in `DELETE TOP(n)` batches, Primary-gated on `IHistorizationRoleView`, one purger per DB via `sp_getapplock`; counter `otopcua.historian.retention.purged{table}`. Optional `IHistorianBackendProvider.CreateRetention` (default null). `docs/Historian.md` §Retention.
|
- **SQL Server** (`Driver.Historian.SqlServer`): `hist.Sample` PK `(TagId, SourceTimeUtc)` — writes are insert-if-absent, so outbox redelivery is idempotent. Stores every value kind with its **real status**; per-sample aggregates (10, simple definitions) are pushed down to T-SQL with bucket math in **100 ns ticks**; the Part 13 time-weighted seven (TimeAverage/TimeAverage2/Total/Total2/Interpolative, #671 — ⚠️ `Total` is now the time-weighted integral, no longer `SUM` — plus the **duration-based** PercentGood/PercentBad, #680) stream window + 4 bound rows into the shared `TimeWeightedAggregator` (both SQL backends, identical results; reverse `Interpolative` anchored at the later time; window capped by `ServerHistorian:MaxTimeWeightedRawRows` ⇒ `BadTooManyOperations`, percentages included). ⚠️ **#680 status rules (client-visible):** TimeAverage/Total are Uncertain if any part of the interval uses an Uncertain value, is bridged across a Bad sample, is extrapolated past the last sample (⇒ a live tag's latest interval is Uncertain) or is not covered; TimeAverage2/Total2 keep the SDK time-based rule (Uncertain time counts as Good) but never extrapolate past the last sample — that interval is Partial, later ones `BadNoData` (a report-by-exception tag with nothing after its last sample reads `BadNoData` until its next sample arrives). `docs/Historian.md` §"Time-weighted aggregates". ⚠️ **#680 per-sample (client-visible):** `Count` counts **Good** samples only (value-based status; an all-Bad interval is generic Bad, value null) and an empty interval **inside the tag's data range** is `0`/Good (outside it still `BadNoData`); `Start`/`End` return the **raw** first/last sample of any quality — its own timestamp and status, value null when Bad; `Delta` is Uncertain only when the interval's first or last sample is unusable; ⚠️ #684: `Average`/`Minimum`/`Maximum`/`Range`/`*ActualTime` use **Good samples only** (was Good + Uncertain; an Uncertain-only interval is `BadNoData`), Min/Max-family status Uncertain on a Bad sample, an Uncertain value beyond the Good extreme (Part 13 — the SDK ignores that) or an Uncertain non-numeric sample, and only a **Good** String/DateTime sample refuses them (`BadAggregateInvalidInputs`; Delta still refuses any usable one); and every per-sample aggregate but `Average` sets **end-of-data Partial** on the interval holding the last sample (forward incl. its earlier edge, reverse strictly inside), never on a Bad result. The data-range lookup is a key seek on SQL Server and a `tag` symbol-index seek on QuestDB (#683 — `{p}sample.tag` is `SYMBOL INDEX`; the bootstrapper ADDs it to a pre-#683 table, idempotent), read BEFORE the bucket statement so a racing append can never yield a Good `Count` of 0 (a concurrent HistoryUpdate delete is the mirror case and still can, transiently). `docs/Historian.md` §"Aggregates". Docker-dev recipe, schema and aggregate semantics: `docs/Historian.md` §"SQL Server backend". Integration tests need `HISTORIAN_SQL_TEST_CONNECTION`. **Retention (#667):** opt-in `SqlServer:RetentionDays` (+ `PurgeInterval` 1h, `PurgeBatchSize` 4000) -> `HistorianRetentionService` purges old `hist.Sample` (per tag) / `hist.Event` in `DELETE TOP(n)` batches, Primary-gated on `IHistorizationRoleView`, one purger per DB via `sp_getapplock`; counter `otopcua.historian.retention.purged{table}`. Optional `IHistorianBackendProvider.CreateRetention` (default null). `docs/Historian.md` §Retention.
|
||||||
- **QuestDB** (`Driver.Historian.QuestDb`, `ServerHistorian:QuestDb:*`, Npgsql over PG wire; docker-dev overlay `docker-compose.questdb.yml`): same kinds/status/aggregates/HistoryUpdate as SqlServer, soft delete by tombstone (INSERT only — never `UPDATE`/`DELETE`; every read filters `NOT deleted`). ⚠️ **Timestamps are µs** (`QuestDbConnection.Round`/`BindTimestamp`; `IHistorianDataSource.TimestampResolutionTicks` = 10 steps HistoryRead paging), **pre-1970 cannot be stored** (writer drops, HistoryUpdate `BadOutOfRange`), every write waits for WAL visibility, TTL is enforced on write commits only, set Npgsql `Maximum Pool Size` < QuestDB's 64. `docs/Historian.md` §"QuestDB backend".
|
- **QuestDB** (`Driver.Historian.QuestDb`, `ServerHistorian:QuestDb:*`, Npgsql over PG wire; docker-dev overlay `docker-compose.questdb.yml`): same kinds/status/aggregates/HistoryUpdate as SqlServer, soft delete by tombstone (INSERT only — never `UPDATE`/`DELETE`; every read filters `NOT deleted`). ⚠️ **Timestamps are µs** (`QuestDbConnection.Round`/`BindTimestamp`; `IHistorianDataSource.TimestampResolutionTicks` = 10 steps HistoryRead paging), **pre-1970 cannot be stored** (writer drops, HistoryUpdate `BadOutOfRange`), every write waits for WAL visibility, TTL is enforced on write commits only, set Npgsql `Maximum Pool Size` < QuestDB's 64. `docs/Historian.md` §"QuestDB backend".
|
||||||
- ⚠️ **Reverse processed reads are Part 13/SDK-anchored (#677, client-visible):** every aggregate on SqlServer/QuestDB now anchors a reverse read at its later time — window `(end, start]`, partial interval earliest, each stamped with its later edge (was: earlier-anchored, stamped earlier edge); on forward reads Partial also marks the interval the data starts inside (not Average — the SDK's rule, forward-only); a non-default client `AggregateConfiguration` ⇒ `BadAggregateConfigurationRejected`; Gateway provisioning sends `IntegralDivisor = 1`. SDK-parity tests: `SdkAggregateParityTests`. `docs/Historian.md` §Aggregates.
|
- ⚠️ **Reverse processed reads are Part 13/SDK-anchored (#677, client-visible):** every aggregate on SqlServer/QuestDB now anchors a reverse read at its later time — window `(end, start]`, partial interval earliest, each stamped with its later edge (was: earlier-anchored, stamped earlier edge); on forward reads Partial also marks the interval the data starts inside (not Average — the SDK's rule, forward-only); a non-default client `AggregateConfiguration` ⇒ `BadAggregateConfigurationRejected`; Gateway provisioning sends `IntegralDivisor = 1`. SDK-parity tests: `SdkAggregateParityTests`. `docs/Historian.md` §Aggregates.
|
||||||
- ⚠️ **Reverse / end-only RAW reads work on Gateway (#686, client-visible):** `IHistorianDataSource.SupportsReverseRaw` (SqlServer/QuestDB/Null `true`, Gateway `false`, interface default `false`, guard-tested) — the node manager sends `start > end` only to a backend that declares it; otherwise `ReverseRawReader` serves it from FORWARD reads: uncapped ⇒ ONE forward read reversed in memory; capped ⇒ a bounded walk back in segments (ms-aligned request bounds, width aimed from observed density and carried page to page in the continuation point, ≤ `MaxTieClusterOverfetch + 2` rows per read — a full read is re-read, never answered truncated; only one timestamp holding more than max(1, `MaxTieClusterOverfetch`) samples ⇒ `BadTooManyOperations`; walks no later than now + 1 day and never before the Unix epoch). The raw/events tie-path oversized-cluster refusal is now `BadTooManyOperations` too (was `BadHistoryOperationUnsupported`). Uncapped Gateway raw reads (forward too) used to send `max_values = 0`, which the client/gateway reject — now unbounded (`int.MaxValue`; a ceiling refusal ⇒ `BadTooManyOperations`), and a cancelled read is no longer a backend failure. Since #669 such reads failed the node on Gateway and counted as a backend fault (the gateway rejects start > end — `GatewayHistorianDataSource.ReadRawAsync` now refuses one unsent); before #669 an end-only Gateway read returned the OLDEST N ascending — now the latest N at/before EndTime, newest first. Events still differ (end-only forward / explicit reverse refused on Gateway, #675). `docs/Historian.md` §"Open-ended and reverse raw reads".
|
- ⚠️ **Reverse / end-only RAW reads work on Gateway (#686, client-visible):** `IHistorianDataSource.SupportsReverseRaw` (SqlServer/QuestDB/Null `true`, Gateway `false`, interface default `false`, guard-tested) — the node manager sends `start > end` only to a backend that declares it; otherwise `ReverseRawReader` serves it from FORWARD reads: uncapped ⇒ ONE forward read reversed in memory; capped ⇒ a bounded walk back in segments (ms-aligned request bounds, width aimed from observed density at ≈ N + 8 rows and carried page to page in the continuation point — #689: it used to aim at half `MaxTieClusterOverfetch`, ~32k rows per page on a dense tag; a read asks for `2N + 18` rows (a discovery segment ≤ 1,026), `MaxTieClusterOverfetch` is only the tie-cluster ceiling — a full read is re-read, never answered truncated; only more than max(1, `MaxTieClusterOverfetch`) samples within ONE MILLISECOND ⇒ `BadTooManyOperations` (per ms, not per timestamp — requests are ms-rounded, and letting a segment narrow below 1 ms stepped a page through one ms a tick per read, ~10k reads); walks no later than now + 1 day and never before the Unix epoch; #691: after two empty segments it asks once for the whole remainder, and the stretch a page proved empty rides the continuation point — the last pages of a deep page-through no longer each walk ~14 reads to 1970). The raw/events tie-path oversized-cluster refusal is now `BadTooManyOperations` too (was `BadHistoryOperationUnsupported`). Uncapped Gateway raw reads (forward too) used to send `max_values = 0`, which the client/gateway reject — now unbounded (`int.MaxValue`; a ceiling refusal ⇒ `BadTooManyOperations`), and a cancelled read is no longer a backend failure. Since #669 such reads failed the node on Gateway and counted as a backend fault (the gateway rejects start > end — `GatewayHistorianDataSource.ReadRawAsync` now refuses one unsent); before #669 an end-only Gateway read returned the OLDEST N ascending — now the latest N at/before EndTime, newest first. Events follow since #691.4: a reverse/end-only capped events read on Gateway (`SupportsReverseEvents` false; Null now reports `true` and answers empty) is walked back the same way (ties sorted by EventId — the gateway orders by time only; one extra event found ⇒ continuation point) — it used to be refused `BadHistoryOperationUnsupported` (explicit reverse) or answered with the OLDEST N ascending (end-only); needs the gateway's `RuntimeDb:EventReadMaxRows` 0/unlimited (it truncates silently). Uncapped Gateway events reads (forward too) used to throw "Array dimensions exceeded" (result list pre-sized to `int.MaxValue`) — fixed. Above a truncated read the walk gallops up from the newest row seen, then bisects (never below 1 ms): a burst that ended 100 days ago ≈ 18 reads, a dense tag that stopped days ago ≈ 26. `docs/Historian.md` §"Open-ended and reverse raw reads".
|
||||||
- **Advertised aggregates (#682):** `IHistorianDataSource.SupportedAggregates` (Gateway 4 via `GatewayHistorianDataSource.ServedAggregates`, SqlServer/QuestDB 17 via `AggregateFamilies.Served`, Null **none**; a null set ⇒ all) is the one authority for `HistoryServerCapabilities/AggregateFunctions` (i=11201 — only its folder references are trimmed) and the up-front `BadAggregateNotSupported` in HistoryRead-Processed. ⚠️ `ServerCapabilities/AggregateFunctions` (i=2997) is deliberately NOT trimmed: it keeps the SDK's 37 because live `AggregateFilter` subscriptions still compute them all. ⚠️ Historian disabled ⇒ Processed is now `BadAggregateNotSupported`, not `GoodNoData`. `docs/Historian.md` §Advertised aggregates.
|
- **Advertised aggregates (#682):** `IHistorianDataSource.SupportedAggregates` (Gateway 4 via `GatewayHistorianDataSource.ServedAggregates`, SqlServer/QuestDB 17 via `AggregateFamilies.Served`, Null **none**; a null set ⇒ all) is the one authority for `HistoryServerCapabilities/AggregateFunctions` (i=11201 — only its folder references are trimmed) and the up-front `BadAggregateNotSupported` in HistoryRead-Processed. ⚠️ `ServerCapabilities/AggregateFunctions` (i=2997) is deliberately NOT trimmed: it keeps the SDK's 37 because live `AggregateFilter` subscriptions still compute them all. ⚠️ Historian disabled ⇒ Processed is now `BadAggregateNotSupported`, not `GoodNoData`. `docs/Historian.md` §Advertised aggregates.
|
||||||
- **Real status fix:** the recorder used to stamp every sample Good (`192`); it now carries the driver's status. ⚠️ On the **Gateway** backend this changes nothing stored: the gateway discards quality (C-002), so AVEVA still stores Good.
|
- **Real status fix:** the recorder used to stamp every sample Good (`192`); it now carries the driver's status. ⚠️ On the **Gateway** backend this changes nothing stored: the gateway discards quality (C-002), so AVEVA still stores Good.
|
||||||
- **Comms loss is historized as an outage marker** (`Empty` + `BadNoCommunication`, one per tag per outage) on SqlServer; dropped (`DroppedUnsupportedKind`) on Gateway. Outbox records are **format v2** — drain or delete `ContinuousHistorization:OutboxPath` before downgrading a node.
|
- **Comms loss is historized as an outage marker** (`Empty` + `BadNoCommunication`, one per tag per outage) on SqlServer; dropped (`DroppedUnsupportedKind`) on Gateway. Outbox records are **format v2** — drain or delete `ContinuousHistorization:OutboxPath` before downgrading a node.
|
||||||
@@ -1455,6 +1455,9 @@ The target HistorianGateway OtOpcUa points at **must** run with:
|
|||||||
|
|
||||||
- `RuntimeDb:Enabled=true` — enables the `WriteLiveValues` SQL live path (continuous historization).
|
- `RuntimeDb:Enabled=true` — enables the `WriteLiveValues` SQL live path (continuous historization).
|
||||||
- `RuntimeDb:EventReadsEnabled=true` — enables `ReadEvents` from `Runtime.dbo.Events` (alarm-history reads).
|
- `RuntimeDb:EventReadsEnabled=true` — enables `ReadEvents` from `Runtime.dbo.Events` (alarm-history reads).
|
||||||
|
- `RuntimeDb:EventReadMaxRows=0` (its default, unlimited) — or larger than the reverse-events walk's biggest read
|
||||||
|
(`2·MaxTieClusterOverfetch + 4`). The cap truncates **silently**, so a walked-back reverse events read (#691.4) would
|
||||||
|
lose the newest events of a segment it cut.
|
||||||
- An API key carrying scopes **`historian:read`**, **`historian:write`**, **`historian:tags:write`**.
|
- An API key carrying scopes **`historian:read`**, **`historian:write`**, **`historian:tags:write`**.
|
||||||
|
|
||||||
### Migration note (deployments upgrading from the Wonderware backend)
|
### Migration note (deployments upgrading from the Wonderware backend)
|
||||||
|
|||||||
+70
-29
@@ -639,27 +639,52 @@ backend:
|
|||||||
`[⌊segLo⌋, ⌈segHi + 1 tick⌉]`, both rounded **outward to the millisecond**, and filtered on the samples' own
|
`[⌊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
|
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
|
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
|
page ended on — it rides the continuation point); each next width is aimed from the density just observed at about
|
||||||
density just observed, growing at most ×4. So "the last N values" of a live tag is normally one read, a dense tag
|
half the **page budget** `2N + 16` (≈ N + 8 rows), growing at most ×4. So "the last N values" of a live tag is
|
||||||
costs about one read per page, and sparse history is found in a few dozen tiny ones.
|
normally one read of about N rows, a dense tag costs about one N-sized read per page, and sparse history is found in
|
||||||
- **Never answered from a truncated read.** The row budget is `M = max(1, MaxTieClusterOverfetch)`; a segment read asks
|
a few dozen tiny ones.
|
||||||
for `M + 2` rows (room for the fabricated row and one to see past the segment). Rows stream ascending, so a read is
|
- **Read sizes (#689).** A segment read asks for its budget + 2 rows (room for the fabricated row and one to see past
|
||||||
complete when it came back short or its last row is past the segment; otherwise it is discarded — re-read once with
|
the segment). The budget is the page budget `2N + 16`, except a **discovery** segment — the first of a page with no
|
||||||
room for the rows before the segment that crowded it (a neighbouring tie cluster, at most `2M + 4` rows), or narrowed
|
carried width, or one after an empty segment, whose ×4 growth may overshoot into dense data — which may read up to
|
||||||
to the density it showed. Only **one timestamp holding more than `M` samples** is refused, **`BadTooManyOperations`**
|
**1,024** rows. The tie budget `M = max(1, MaxTieClusterOverfetch)` is only a **ceiling**: a single timestamp holding
|
||||||
(a request limit, not a backend fault); a cluster of exactly `M`, or neighbouring clusters each within `M`, is served.
|
more rows than the segment budget escalates to `M`.
|
||||||
|
⚠️ Before #689 every segment was aimed at half of `M` (≈ 32k rows by default) and that width was carried page to
|
||||||
|
page, so on a 1 Hz tag from about page 5 every page of a "latest N" read fetched ~32,770 rows and took 2–5 s to
|
||||||
|
return N.
|
||||||
|
- **Never answered from a truncated read.** 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 twice the budget + 4 rows), or narrowed: to the density it
|
||||||
|
showed, and never again below the newest row it saw until what lies above that row is read. The gap above it is
|
||||||
|
searched: a segment at that density ending on the top (data running right up to it); if empty, a **gallop** up from
|
||||||
|
that row (`(row + d, top]`, `d` ×4 per step, following each truncated read) until a read comes back empty, then a
|
||||||
|
**bisection** of the bracket — never narrower than the 1 ms request granularity, which only re-sent the same request.
|
||||||
|
Measured: a dense tag that stopped two days ago ≈ **26** reads, a burst of just over 1,024 rows that ended 100 days ago
|
||||||
|
≈ **18** (was 46 bisecting the whole gap) — rather than re-reading the oldest rows of a burst over and over. Only **more than `M` samples within one millisecond** are refused,
|
||||||
|
**`BadTooManyOperations`** (a request limit, not a backend fault); a cluster of exactly `M`, or neighbouring
|
||||||
|
clusters each within `M` and a millisecond or more apart, is served. ⚠️ The unit is the **millisecond**, not the
|
||||||
|
timestamp (#691.4 re-review): requests are rounded to the millisecond, so a narrower segment is the same request —
|
||||||
|
letting the width fall below it once stepped a page through one millisecond a tick at a time (~10,000 reads). Exact
|
||||||
|
for the Gateway, which stores milliseconds; a sub-millisecond backend walked this way would refuse more than `M`
|
||||||
|
samples inside one millisecond even when no single timestamp holds that many.
|
||||||
|
- **Proving the rest empty (#691).** Walking down through empty history to the floor used to cost one read per ×4
|
||||||
|
growth step — ~14–16 reads (~2.5 s live) on **every** last page of a deep page-through. Now, after two consecutive
|
||||||
|
empty segments, the walk asks **once per page** for the whole remainder down to the floor at the page budget: a
|
||||||
|
complete answer ends the walk in one read; a truncated one (deep history) is discarded and the walk carries on. And
|
||||||
|
the stretch the page's last complete read proved empty below its oldest row (often all the way to the floor) rides
|
||||||
|
the continuation point, so the next page steps straight over it. Data backfilled into such a stretch between pages
|
||||||
|
is not seen by that read — paging was never a snapshot (the forward path misses data inserted behind its cursor too).
|
||||||
- **Bounds.** The walk starts no later than **now + 1 day** (a `StartTime = DateTime.MaxValue` read does not send
|
- **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
|
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.
|
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.
|
Paging is otherwise unchanged — a reverse cursor resumes as a fresh walk from its resume point, carrying the width and
|
||||||
|
the proven-empty stretch (session-held cursor state, never serialized, so the new fields need no versioning).
|
||||||
- **Null** reports `true` (empty in either direction).
|
- **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
|
> ⚠️ **Client-visible (#686).** Since #669 an end-only capped raw read, and any explicit reverse raw read, on the Gateway
|
||||||
> backend failed the node (`BadHistoryOperationUnsupported`) and was counted as a historian backend failure (the gateway
|
> backend failed the node (`BadHistoryOperationUnsupported`) and was counted as a historian backend failure (the gateway
|
||||||
> refused `start > end`). Before #669 an end-only read on Gateway returned the **oldest** N values ascending. Both now return
|
> 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
|
> the Part 11 answer — the latest values at or before `EndTime`, newest first. **Events** follow since #691.4 (see below).
|
||||||
> 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
|
> ⚠️ **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
|
> `max_values = 0`, which the client package and the gateway reject — every uncapped raw read (forward too) failed the node
|
||||||
@@ -744,30 +769,46 @@ says is complete carries no continuation point. `NumValuesPerNode == 0` stays un
|
|||||||
that many more rows than the cap, so every page short of the last is a FULL `NumValuesPerNode` page (#675; resumed pages used
|
that many more rows than the cap, so every page short of the last is a FULL `NumValuesPerNode` page (#675; resumed pages used
|
||||||
to come back short by the skip).
|
to come back short by the skip).
|
||||||
- A tie cluster larger than the cap is paged within by over-fetching the half-open window `[T, T + resolution)` up to
|
- A tie cluster larger than the cap is paged within by over-fetching the half-open window `[T, T + resolution)` up to
|
||||||
`MaxTieClusterOverfetch`; beyond that bound the node answers `BadHistoryOperationUnsupported` (raise the bound).
|
`MaxTieClusterOverfetch`; beyond that bound the node answers `BadTooManyOperations` (raise the bound).
|
||||||
- **Tie exactness depends on the backend.** SqlServer and QuestDB order by `(EventTimeUtc, EventId)`, so the skip count is exact.
|
- **Tie exactness depends on the backend.** SqlServer and QuestDB order by `(EventTimeUtc, EventId)`, so the skip count is exact.
|
||||||
The Gateway backend has no stable intra-timestamp order (and only a truncation flag), so ties at a page boundary are
|
The Gateway backend has no stable intra-timestamp order (and only a truncation flag), so ties at a page boundary of a
|
||||||
best-effort there.
|
**forward** read are best-effort there; a walked-back **reverse** read (below) sorts them by `EventId` itself and is exact.
|
||||||
- **Reverse event reads page newest-first (#675).** `StartTime > EndTime` reads the Part 11 reverse window `(EndTime, StartTime]`
|
- **Reverse event reads page newest-first (#675).** `StartTime > EndTime` reads the Part 11 reverse window `(EndTime, StartTime]`
|
||||||
with events in DESCENDING `(EventTimeUtc, EventId)` order, so a cap keeps the **latest** N and a continuation point is issued
|
with events in DESCENDING `(EventTimeUtc, EventId)` order, so a cap keeps the **latest** N and a continuation point is issued
|
||||||
when the source flags more. The direction is stored **in the cursor**: a reverse point resumes reverse (and a forward one
|
when the source flags more. The direction is stored **in the cursor**: a reverse point resumes reverse (and a forward one
|
||||||
forward) whatever the resume request's details say. Reverse tie clusters are over-fetched forward and sliced from the newest
|
forward) whatever the resume request's details say. Reverse tie clusters are over-fetched forward and sliced from the newest
|
||||||
end, exactly as Raw does. Before #675 a capped reverse read silently returned the EARLIEST N, ascending, Good, with no
|
end, exactly as Raw does. Before #675 a capped reverse read silently returned the EARLIEST N, ascending, Good, with no
|
||||||
continuation point.
|
continuation point.
|
||||||
- **End-only events reads: backward on SqlServer/QuestDB, forward on Gateway (#675).** `StartTime` unset
|
- **End-only events reads page backward on every backend (#675, #691.4).** `StartTime` unset (`DateTime.MinValue`) with an
|
||||||
(`DateTime.MinValue`) with an `EndTime` and `NumValuesPerNode > 0` pages backward from `EndTime` over `(MinValue, EndTime]` —
|
`EndTime` and `NumValuesPerNode > 0` pages backward from `EndTime` over `(MinValue, EndTime]` — the same rule as Raw.
|
||||||
the same rule as Raw — **only on a backend that reports `SupportsReverseEvents`** (SqlServer, QuestDB). On Gateway (the
|
Start-only reads page forward to an open upper bound (`DateTime.MaxValue`, UTC) everywhere. Without a cap an end-only read
|
||||||
default backend) and Null it keeps its pre-#675 forward `[MinValue, EndTime)` meaning, paged oldest-first, rather than
|
is forward `[MinValue, EndTime)` everywhere.
|
||||||
becoming a refused reverse read. Start-only reads page forward to an open upper bound (`DateTime.MaxValue`, UTC) everywhere.
|
- **Backends.** The server checks `IHistorianDataSource.SupportsReverseEvents`. SqlServer and QuestDB serve reverse natively
|
||||||
Without a cap an end-only read is forward `[MinValue, EndTime)` everywhere.
|
(`… > lo AND … <= hi ORDER BY time DESC, id DESC`, the `ReadEventsAsync(…, bool reverse, …)` overload) and report `true`.
|
||||||
- **Backends.** The server checks `IHistorianDataSource.SupportsReverseEvents` and then calls
|
**Gateway reports `false`** — the gateway streams ascending only and rejects `end < start` — and is never sent a reverse
|
||||||
`ReadEventsAsync(…, bool reverse, …)`. SqlServer and QuestDB serve reverse natively (`… > lo AND … <= hi ORDER BY time DESC,
|
read (Null reports `true` and answers empty either way, as for raw): since **#691.4** the server walks it back from bounded **forward** reads with the raw walk (#686/#689/#691:
|
||||||
id DESC`) and report `true`. **Gateway and Null report `false`**: an explicit reverse read (`StartTime > EndTime`) is answered
|
page-sized ms-aligned segments filtered to `(segLo, segHi]`, a read the source flagged truncated is never answered from,
|
||||||
`BadHistoryOperationUnsupported` **without calling the backend** — the gateway streams ascending only and rejects `end < start`,
|
the now + 1 day / 1970 bounds, the remainder probe and the proven-empty stretch carried in the cursor). Event specifics:
|
||||||
and draining the whole window to keep its tail would be exactly the unbounded read the cap prevents. Because the refusal is a
|
- **Ties** are sorted by `EventId` (ordinal) on every read — the gateway orders by `EventTimeUtc` only, in any order within a
|
||||||
capability check, not a caught exception, a `NotSupportedException` thrown from inside a backend (a driver library) is logged
|
timestamp — so the page is DESCENDING `(EventTimeUtc, EventId)` and a resumed page's boundary skip lines up; the
|
||||||
as a backend fault like any other. Every implementation (and any future wrapper) must declare both members itself — the
|
oversized-cluster over-fetch keeps exactly the events at `T` (the gateway's end is inclusive) in the same order.
|
||||||
interface defaults (`false` / throw) would silently disable reverse reads; `HistorianDataSourceReverseEventsGuardTests` pins it.
|
- **"More exists"** is found by walking for one event past the page (the walk has no source truncation flag to hand on).
|
||||||
|
- **Uncapped** reverse reads are one forward read of the window, sorted and reversed.
|
||||||
|
- ⚠️ **Also fixed (#691.4 review): uncapped events reads on Gateway.** `NumValuesPerNode = 0` reaches the source as
|
||||||
|
`int.MaxValue`, and `GatewayHistorianDataSource` pre-sized its result list to it ("Array dimensions exceeded") — every
|
||||||
|
uncapped Gateway events read, forward too, failed the node and counted as a backend failure. The capacity is now bounded.
|
||||||
|
- A timestamp holding more than `MaxTieClusterOverfetch` events ⇒ **`BadTooManyOperations`** for that node.
|
||||||
|
- ⚠️ **Gateway prerequisite:** the gateway's `RuntimeDb:EventReadMaxRows` must be `0` (its default, unlimited) or larger than
|
||||||
|
the walk's biggest read (`2N + 18` rows, a discovery read 1,026, a tie-cluster read up to `2·MaxTieClusterOverfetch + 4`).
|
||||||
|
That cap truncates **silently** (the stream just ends), so a segment it cut would look complete and drop its newest events —
|
||||||
|
the same blindness a forward read has to it, but on the end a reverse read is asking for.
|
||||||
|
|
||||||
|
⚠️ **Client-visible (#691.4).** On Gateway (the default backend) and Null an explicit reverse events read used to be refused
|
||||||
|
`BadHistoryOperationUnsupported` (#675), and an end-only capped events read returned the **oldest** N events ascending. Both
|
||||||
|
now return the latest events newest-first. Because the capability is checked, not a caught exception, a
|
||||||
|
`NotSupportedException` thrown from inside a backend (a driver library) is still logged as a backend fault. Every
|
||||||
|
implementation (and any future wrapper) must declare both members itself — the interface defaults (`false` / throw) would
|
||||||
|
silently send a native-reverse backend through the walk; `HistorianDataSourceReverseEventsGuardTests` pins it.
|
||||||
- **Gateway open upper bound.** The `DateTime.MaxValue` (UTC) bound of a start-only forward read is pinned offline through the
|
- **Gateway open upper bound.** The `DateTime.MaxValue` (UTC) bound of a start-only forward read is pinned offline through the
|
||||||
published client's proto `Timestamp` conversion (captured by an in-process gRPC server) and the FILETIME encoding
|
published client's proto `Timestamp` conversion (captured by an in-process gRPC server) and the FILETIME encoding
|
||||||
(`GatewayEventBoundsTests`). What a live AVEVA Historian does with a year-9999 bound is still unverified (needs the VPN).
|
(`GatewayEventBoundsTests`). What a live AVEVA Historian does with a year-9999 bound is still unverified (needs the VPN).
|
||||||
|
|||||||
@@ -120,9 +120,9 @@ public interface IHistorianDataSource : IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether this backend can serve a reverse (newest-first) events read — the reverse overload of
|
/// Whether this backend can serve a reverse (newest-first) events read — the reverse overload of
|
||||||
/// <see cref="ReadEventsAsync(string?, DateTime, DateTime, int, bool, CancellationToken)"/> (Gitea #675). The server
|
/// <see cref="ReadEventsAsync(string?, DateTime, DateTime, int, bool, CancellationToken)"/> (Gitea #675). The server
|
||||||
/// checks it BEFORE calling the source: an explicit reverse read (<c>StartTime > EndTime</c>) on a backend that
|
/// checks it BEFORE calling the source: a reverse read (explicit <c>StartTime > EndTime</c>, or an end-only capped
|
||||||
/// cannot is answered <c>BadHistoryOperationUnsupported</c> without a call, and an end-only capped read, which Part 11
|
/// read, which Part 11 reads backward) on a backend that cannot is never sent the reverse overload — since Gitea
|
||||||
/// reads backward, keeps its forward <c>[MinValue, EndTime)</c> meaning there. Default <c>false</c>; an
|
/// #691.4 the server walks it back from bounded FORWARD reads instead (#675 refused it). Default <c>false</c>; an
|
||||||
/// implementation (or a wrapper) declares it itself — a guard test pins that.
|
/// implementation (or a wrapper) declares it itself — a guard test pins that.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
bool SupportsReverseEvents => false;
|
bool SupportsReverseEvents => false;
|
||||||
@@ -143,10 +143,10 @@ public interface IHistorianDataSource : IDisposable
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The default forwards a forward read to the five-argument overload and throws
|
/// The default forwards a forward read to the five-argument overload and throws
|
||||||
/// <see cref="NotSupportedException"/> for a reverse one — a backend that cannot read newest-first (Gateway,
|
/// <see cref="NotSupportedException"/> for a reverse one — a backend that cannot read newest-first (the
|
||||||
/// Null). The server never relies on that throw: it consults <see cref="SupportsReverseEvents"/> first and
|
/// Gateway). The server never relies on that throw: it consults <see cref="SupportsReverseEvents"/> first and walks
|
||||||
/// answers <c>BadHistoryOperationUnsupported</c> without calling, rather than returning the earliest events in
|
/// such a read back from forward reads (#691.4), never returning the earliest events in ascending order as if
|
||||||
/// ascending order as if they were the answer. A backend that serves reverse must override BOTH members.
|
/// they were the answer. A backend that serves reverse natively must override BOTH members.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="sourceName">The source name to filter events, or null to return events from all sources.</param>
|
/// <param name="sourceName">The source name to filter events, or null to return events from all sources.</param>
|
||||||
|
|||||||
@@ -61,13 +61,12 @@ public sealed class NullHistorianDataSource : IHistorianDataSource
|
|||||||
public bool SupportsReverseRaw => true;
|
public bool SupportsReverseRaw => true;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks><c>false</c> (Gitea #675): there is no history to read in either direction, and an explicit reverse read
|
/// <remarks><c>true</c>, like <see cref="SupportsReverseRaw"/> (Gitea #691.4 review; <c>false</c> under #675): there is no
|
||||||
/// is answered <c>BadHistoryOperationUnsupported</c> like any backend that cannot read newest-first.</remarks>
|
/// history to read in either direction — no need for the server to walk back through empty forward reads.</remarks>
|
||||||
public bool SupportsReverseEvents => false;
|
public bool SupportsReverseEvents => true;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>Forward ⇒ the empty result; reverse ⇒ <see cref="NotSupportedException"/>, the interface contract for a
|
/// <remarks>The empty result in either direction.</remarks>
|
||||||
/// backend without <see cref="SupportsReverseEvents"/>.</remarks>
|
|
||||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||||
string? sourceName,
|
string? sourceName,
|
||||||
DateTime startUtc,
|
DateTime startUtc,
|
||||||
@@ -75,9 +74,7 @@ public sealed class NullHistorianDataSource : IHistorianDataSource
|
|||||||
int maxEvents,
|
int maxEvents,
|
||||||
bool reverse,
|
bool reverse,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
reverse
|
Task.FromResult(EmptyEvents);
|
||||||
? throw new NotSupportedException("The null historian cannot read events newest-first (reverse).")
|
|
||||||
: Task.FromResult(EmptyEvents);
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
+8
-7
@@ -393,8 +393,9 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
|||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <c>false</c>: the gateway's <c>ReadEvents</c> streams ascending only (Gitea #675). The server therefore refuses an
|
/// <c>false</c>: the gateway's <c>ReadEvents</c> streams ascending only (Gitea #675). The server never sends it a
|
||||||
/// explicit reverse events read without calling, and keeps an end-only capped read forward.
|
/// reverse read; since #691.4 it walks one back from bounded forward reads (ties sorted by EventId — the gateway
|
||||||
|
/// orders by event time only).
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool SupportsReverseEvents => false;
|
public bool SupportsReverseEvents => false;
|
||||||
|
|
||||||
@@ -404,10 +405,9 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
|||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A reverse (newest-first) read is refused with <see cref="NotSupportedException"/> — the server reports
|
/// A reverse (newest-first) read is refused with <see cref="NotSupportedException"/> (Gitea #675) — the gateway's
|
||||||
/// <c>BadHistoryOperationUnsupported</c> (Gitea #675). The gateway's <c>ReadEvents</c> streams ascending only and
|
/// <c>ReadEvents</c> streams ascending only and rejects <c>end < start</c>. The server never calls this with
|
||||||
/// rejects <c>end < start</c>, so the only way to serve "the latest N" would be to drain the whole window and
|
/// <c>reverse</c>: it walks a reverse read back from bounded forward reads (#691.4). Nothing is sent.
|
||||||
/// keep its tail, an unbounded read the cap exists to prevent. Nothing is sent.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse,
|
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse,
|
||||||
@@ -425,7 +425,8 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var hasCap = maxEvents > 0;
|
var hasCap = maxEvents > 0;
|
||||||
var collected = new List<HistoricalEvent>(hasCap ? maxEvents : 0);
|
// Bounded initial capacity: an uncapped read arrives as int.MaxValue ("Array dimensions exceeded", #691.4 review).
|
||||||
|
var collected = new List<HistoricalEvent>(hasCap ? Math.Min(maxEvents, 1024) : 0);
|
||||||
var truncated = false;
|
var truncated = false;
|
||||||
|
|
||||||
await foreach (var wireEvent in _client
|
await foreach (var wireEvent in _client
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|||||||
/// <see cref="HistoryCursorKind.Raw"/> reverse reads on a forward-only backend only (Gitea #686): the segment width the
|
/// <see cref="HistoryCursorKind.Raw"/> reverse reads on a forward-only backend only (Gitea #686): the segment width the
|
||||||
/// walk-back ended on, so the next page starts at the observed density instead of re-discovering it. 0 = none.
|
/// walk-back ended on, so the next page starts at the observed density instead of re-discovering it. 0 = none.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="EmptyFromTicks">
|
||||||
|
/// Walked reverse reads only (Gitea #691): with <paramref name="EmptyToTicks"/>, a stretch <c>(from, to)</c> the previous
|
||||||
|
/// page's walk proved holds no row, so the next page steps over it rather than re-walking it (to the 1970 floor, ~14
|
||||||
|
/// reads, at the end of a deep page-through). 0/0 = none. The cursor lives in the session's in-memory store (never
|
||||||
|
/// serialized), so a new field with a "none" default is all the versioning it needs.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="EmptyToTicks">The exclusive upper end of that stretch.</param>
|
||||||
internal sealed record HistoryContinuationState(
|
internal sealed record HistoryContinuationState(
|
||||||
string Tagname,
|
string Tagname,
|
||||||
DateTime NextStartUtc,
|
DateTime NextStartUtc,
|
||||||
@@ -65,7 +72,13 @@ internal sealed record HistoryContinuationState(
|
|||||||
long LastModifiedId = 0,
|
long LastModifiedId = 0,
|
||||||
DateTime WindowStartUtc = default,
|
DateTime WindowStartUtc = default,
|
||||||
bool Reverse = false,
|
bool Reverse = false,
|
||||||
long SegmentWidthTicks = 0);
|
long SegmentWidthTicks = 0,
|
||||||
|
long EmptyFromTicks = 0,
|
||||||
|
long EmptyToTicks = 0)
|
||||||
|
{
|
||||||
|
/// <summary>The walk-back hint this cursor carries (#686/#689/#691).</summary>
|
||||||
|
public ReverseWalkHint WalkHint => new(SegmentWidthTicks, EmptyFromTicks, EmptyToTicks);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The HistoryRead kind that issued a <see cref="HistoryContinuationState"/>.</summary>
|
/// <summary>The HistoryRead kind that issued a <see cref="HistoryContinuationState"/>.</summary>
|
||||||
internal enum HistoryCursorKind
|
internal enum HistoryCursorKind
|
||||||
|
|||||||
@@ -537,7 +537,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
/// paging over-fetches up to <c>MaxTieClusterOverfetch + 1</c> ties at that single timestamp (a
|
/// paging over-fetches up to <c>MaxTieClusterOverfetch + 1</c> ties at that single timestamp (a
|
||||||
/// <c>start == end</c> read) and slices through them via <see cref="HistoryPaging.SliceTieCluster"/>.
|
/// <c>start == end</c> read) and slices through them via <see cref="HistoryPaging.SliceTieCluster"/>.
|
||||||
/// A cluster strictly larger than this still fails that node <c>BadTooManyOperations</c> (the absurd-burst
|
/// A cluster strictly larger than this still fails that node <c>BadTooManyOperations</c> (the absurd-burst
|
||||||
/// backstop; <c>BadHistoryOperationUnsupported</c> before #686). Also the per-read row budget of a reverse raw read
|
/// backstop; <c>BadHistoryOperationUnsupported</c> before #686). Also the tie-cluster ceiling (not the segment size, #689) of a reverse read
|
||||||
/// walked back from forward reads (<see cref="ReverseRawReader"/>). Mirrors the configured
|
/// walked back from forward reads (<see cref="ReverseRawReader"/>). Mirrors the configured
|
||||||
/// <c>ServerHistorianOptions.MaxTieClusterOverfetch</c>; the Host sets it at <c>StartAsync</c>. The
|
/// <c>ServerHistorianOptions.MaxTieClusterOverfetch</c>; the Host sets it at <c>StartAsync</c>. The
|
||||||
/// default (65536) survives when the historian section is absent.
|
/// default (65536) survives when the historian section is absent.
|
||||||
@@ -5248,6 +5248,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
var endUtc = details.EndTime;
|
var endUtc = details.EndTime;
|
||||||
var numValuesPerNode = details.NumValuesPerNode;
|
var numValuesPerNode = details.NumValuesPerNode;
|
||||||
var boundarySkip = 0;
|
var boundarySkip = 0;
|
||||||
|
var walkHint = default(ReverseWalkHint); // #691.4: a walked-back read's width + proven-empty stretch
|
||||||
bool reverse;
|
bool reverse;
|
||||||
|
|
||||||
if (resumed)
|
if (resumed)
|
||||||
@@ -5271,35 +5272,30 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
// The direction is the CURSOR's (Gitea #675), never re-derived from the resume request's details: a
|
// The direction is the CURSOR's (Gitea #675), never re-derived from the resume request's details: a
|
||||||
// reverse read resumes reverse, a forward one forward.
|
// reverse read resumes reverse, a forward one forward.
|
||||||
reverse = state.Reverse;
|
reverse = state.Reverse;
|
||||||
|
walkHint = state.WalkHint;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Part 11: with one bound unset, an unset EndTime (MinValue) with a StartTime means "page FORWARD from
|
// Part 11: with one bound unset, an unset EndTime (MinValue) with a StartTime means "page FORWARD from
|
||||||
// StartTime" — normalise it to an open upper bound, or the cursor would store MinValue and the read would
|
// StartTime" — normalise it to an open upper bound, or the cursor would store MinValue and the read would
|
||||||
// look reversed. An unset StartTime with an EndTime and a count cap means "page BACKWARD from EndTime"
|
// look reversed. An unset StartTime with an EndTime and a count cap means "page BACKWARD from EndTime"
|
||||||
// (#675, mirroring raw): the reverse window (MinValue, EndTime] — but only on a backend that can read
|
// (#675, mirroring raw): the reverse window (MinValue, EndTime] — on every backend since #691.4 (a backend
|
||||||
// newest-first. Elsewhere (Gateway, the default backend) and without a count cap an end-only read keeps its
|
// that cannot read newest-first is walked back below). Without a count cap an end-only read keeps its
|
||||||
// historical forward [MinValue, EndTime) meaning rather than becoming a refused reverse read.
|
// forward [MinValue, EndTime) meaning.
|
||||||
if (endUtc == DateTime.MinValue && startUtc != DateTime.MinValue)
|
if (endUtc == DateTime.MinValue && startUtc != DateTime.MinValue)
|
||||||
endUtc = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc); // Utc kind: a proto Timestamp conversion rejects Unspecified
|
endUtc = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc); // Utc kind: a proto Timestamp conversion rejects Unspecified
|
||||||
else if (numValuesPerNode > 0 && startUtc == DateTime.MinValue && endUtc != DateTime.MinValue
|
else if (numValuesPerNode > 0 && startUtc == DateTime.MinValue && endUtc != DateTime.MinValue)
|
||||||
&& source.SupportsReverseEvents)
|
|
||||||
(startUtc, endUtc) = (endUtc, new DateTime(0, DateTimeKind.Utc));
|
(startUtc, endUtc) = (endUtc, new DateTime(0, DateTimeKind.Utc));
|
||||||
|
|
||||||
// Part 11: StartTime later than EndTime is a reverse read — events newest-first over (end, start].
|
// Part 11: StartTime later than EndTime is a reverse read — events newest-first over (end, start].
|
||||||
reverse = startUtc > endUtc;
|
reverse = startUtc > endUtc;
|
||||||
}
|
}
|
||||||
|
|
||||||
// An explicit reverse read (or a reverse cursor resumed after the source was swapped) on a backend that cannot
|
// A reverse read on a backend that cannot read newest-first (the Gateway) is never sent there (#675 refused it
|
||||||
// read newest-first: refuse it BEFORE calling the source — never the earliest events in ascending order as if
|
// here); since #691.4 it is walked back from bounded forward reads — the raw walk (#686), with ties ordered by
|
||||||
// they were the answer. Checked here rather than by catching an exception, so a NotSupportedException thrown
|
// EventId because the gateway orders by event time only. Decided by the capability, not by catching an
|
||||||
// from inside a backend (a driver library, the projection) stays a backend fault.
|
// exception, so a NotSupportedException thrown from inside a backend stays a backend fault.
|
||||||
if (reverse && !source.SupportsReverseEvents)
|
var walked = reverse && !source.SupportsReverseEvents;
|
||||||
{
|
|
||||||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
|
||||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOT under the node-manager Lock — awaiting the async source is safe here.
|
// NOT under the node-manager Lock — awaiting the async source is safe here.
|
||||||
// NumValuesPerNode==0 means "no limit — return ALL values" per OPC UA Part 4/11, so it is translated
|
// NumValuesPerNode==0 means "no limit — return ALL values" per OPC UA Part 4/11, so it is translated
|
||||||
@@ -5309,8 +5305,26 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
var fetch = EventMaxEvents(numValuesPerNode);
|
var fetch = EventMaxEvents(numValuesPerNode);
|
||||||
if (numValuesPerNode > 0 && boundarySkip > 0)
|
if (numValuesPerNode > 0 && boundarySkip > 0)
|
||||||
fetch = (int)Math.Min(int.MaxValue - 1L, (long)fetch + boundarySkip);
|
fetch = (int)Math.Min(int.MaxValue - 1L, (long)fetch + boundarySkip);
|
||||||
var sourceResult = await source.ReadEventsAsync(
|
HistoricalEventsResult sourceResult;
|
||||||
sourceName, startUtc, endUtc, fetch, reverse, ct).ConfigureAwait(false);
|
if (walked)
|
||||||
|
{
|
||||||
|
// Walk for one more than the fetch: finding it is the "more events exist" signal a source gives with its
|
||||||
|
// continuation point (the walk itself cannot flag truncation).
|
||||||
|
var want = numValuesPerNode == 0 ? 0u : (uint)Math.Min(uint.MaxValue - 1L, (long)fetch + 1);
|
||||||
|
var (rows, next) = await ReverseEventsReader.ReadAsync(
|
||||||
|
source, sourceName, startUtc, endUtc, want, MaxTieClusterOverfetch, Clock.GetUtcNow().UtcDateTime,
|
||||||
|
walkHint, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
walkHint = next;
|
||||||
|
var more = want > 0 && rows.Count > fetch;
|
||||||
|
if (more) rows.RemoveRange(fetch, rows.Count - fetch);
|
||||||
|
sourceResult = new HistoricalEventsResult(rows, more ? [0x01] : null);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sourceResult = await source.ReadEventsAsync(
|
||||||
|
sourceName, startUtc, endUtc, fetch, reverse, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Truncation is the SOURCE's word (non-null continuation point) — never inferred from the count, so a
|
// Truncation is the SOURCE's word (non-null continuation point) — never inferred from the count, so a
|
||||||
// page that happens to be exactly cap-sized but complete never emits a point.
|
// page that happens to be exactly cap-sized but complete never emits a point.
|
||||||
@@ -5350,6 +5364,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
sourceName, startUtc, startUtc.AddTicks(resolution), overfetchCap, reverse: false, ct)
|
sourceName, startUtc, startUtc.AddTicks(resolution), overfetchCap, reverse: false, ct)
|
||||||
.ConfigureAwait(false)).Events;
|
.ConfigureAwait(false)).Events;
|
||||||
|
|
||||||
|
// A walked read (#691.4) emitted its ties sorted by EventId; the gateway returns them in any order and its
|
||||||
|
// end is inclusive — keep exactly the cluster, in the walk's order, so the skip indexes the same events.
|
||||||
|
if (walked)
|
||||||
|
cluster = cluster.Where(e => e.EventTimeUtc == startUtc)
|
||||||
|
.OrderBy(e => e, EventIdTieOrder.Instance).ToList();
|
||||||
|
|
||||||
// The over-fetch reads the cluster FORWARD (ascending EventId), but a reverse read emitted its ties — and
|
// The over-fetch reads the cluster FORWARD (ascending EventId), but a reverse read emitted its ties — and
|
||||||
// counted boundarySkip — in descending order. Slice in the read's own direction, as raw does.
|
// counted boundarySkip — in descending order. Slice in the read's own direction, as raw does.
|
||||||
if (reverse) cluster = cluster.Reverse().ToList();
|
if (reverse) cluster = cluster.Reverse().ToList();
|
||||||
@@ -5378,7 +5398,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
{
|
{
|
||||||
pendingCursor = new HistoryContinuationState(
|
pendingCursor = new HistoryContinuationState(
|
||||||
sourceName ?? string.Empty, resumeAt, endUtc, nextSkip, numValuesPerNode, HistoryCursorKind.Events,
|
sourceName ?? string.Empty, resumeAt, endUtc, nextSkip, numValuesPerNode, HistoryCursorKind.Events,
|
||||||
Reverse: reverse);
|
Reverse: reverse, SegmentWidthTicks: walkHint.WidthTicks, EmptyFromTicks: walkHint.EmptyFromTicks,
|
||||||
|
EmptyToTicks: walkHint.EmptyToTicks);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (truncated && events.Count > 0)
|
else if (truncated && events.Count > 0)
|
||||||
@@ -5390,7 +5411,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
for (var i = events.Count - 1; i >= 0 && events[i].EventTimeUtc == boundary; i--) skip++;
|
for (var i = events.Count - 1; i >= 0 && events[i].EventTimeUtc == boundary; i--) skip++;
|
||||||
pendingCursor = new HistoryContinuationState(
|
pendingCursor = new HistoryContinuationState(
|
||||||
sourceName ?? string.Empty, boundary, endUtc, skip, numValuesPerNode, HistoryCursorKind.Events,
|
sourceName ?? string.Empty, boundary, endUtc, skip, numValuesPerNode, HistoryCursorKind.Events,
|
||||||
Reverse: reverse);
|
Reverse: reverse, SegmentWidthTicks: walkHint.WidthTicks, EmptyFromTicks: walkHint.EmptyFromTicks,
|
||||||
|
EmptyToTicks: walkHint.EmptyToTicks);
|
||||||
}
|
}
|
||||||
|
|
||||||
var historyEvent = ProjectEvents(events, selectClauses);
|
var historyEvent = ProjectEvents(events, selectClauses);
|
||||||
@@ -5411,6 +5433,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
errors[handle.Index] = StatusCodes.BadTimeout;
|
errors[handle.Index] = StatusCodes.BadTimeout;
|
||||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
|
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
|
||||||
}
|
}
|
||||||
|
catch (HistoryReadLimitExceededException ex)
|
||||||
|
{
|
||||||
|
// A request limit (#691.4: a walked-back reverse read met a tie cluster over the budget) — BadTooManyOperations
|
||||||
|
// for THIS node, as on the raw path; not a backend fault.
|
||||||
|
_logger.LogWarning("OtOpcUaNodeManager: HistoryReadEvents limit exceeded for node {NodeId}: {Message}", handle.NodeId, ex.Message);
|
||||||
|
errors[handle.Index] = StatusCodes.BadTooManyOperations;
|
||||||
|
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations };
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// One node's backend failure must not throw out of the batch — surface Bad for THIS node
|
// One node's backend failure must not throw out of the batch — surface Bad for THIS node
|
||||||
@@ -6184,7 +6214,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
{
|
{
|
||||||
DateTime startUtc;
|
DateTime startUtc;
|
||||||
var boundarySkip = 0;
|
var boundarySkip = 0;
|
||||||
long segmentWidth = 0; // #686: the walk-back's width, carried page to page
|
var walkHint = default(ReverseWalkHint); // #686/#689/#691: the walk-back's width + proven-empty stretch, page to page
|
||||||
|
|
||||||
string tagname;
|
string tagname;
|
||||||
if (inboundCp is { Length: > 0 })
|
if (inboundCp is { Length: > 0 })
|
||||||
@@ -6205,7 +6235,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
boundarySkip = state.BoundarySkipCount;
|
boundarySkip = state.BoundarySkipCount;
|
||||||
endUtc = state.EndUtc;
|
endUtc = state.EndUtc;
|
||||||
numValuesPerNode = state.NumValuesPerNode;
|
numValuesPerNode = state.NumValuesPerNode;
|
||||||
segmentWidth = state.SegmentWidthTicks;
|
walkHint = state.WalkHint;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -6243,9 +6273,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
{
|
{
|
||||||
var page = await ReverseRawReader.ReadAsync(
|
var page = await ReverseRawReader.ReadAsync(
|
||||||
source, tagname, startUtc, endUtc, numValuesPerNode, MaxTieClusterOverfetch,
|
source, tagname, startUtc, endUtc, numValuesPerNode, MaxTieClusterOverfetch,
|
||||||
Clock.GetUtcNow().UtcDateTime, segmentWidth, ct)
|
Clock.GetUtcNow().UtcDateTime, walkHint, ct)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
segmentWidth = page.WidthHintTicks;
|
walkHint = page.Hint;
|
||||||
sourceResult = new HistorianRead(page.Samples, ContinuationPoint: null);
|
sourceResult = new HistorianRead(page.Samples, ContinuationPoint: null);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -6324,7 +6354,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
if (nextStartUtc is { } resumeAt)
|
if (nextStartUtc is { } resumeAt)
|
||||||
{
|
{
|
||||||
var clusterState = new HistoryContinuationState(
|
var clusterState = new HistoryContinuationState(
|
||||||
tagname, resumeAt, endUtc, nextSkip, numValuesPerNode, SegmentWidthTicks: segmentWidth);
|
tagname, resumeAt, endUtc, nextSkip, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks,
|
||||||
|
EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks);
|
||||||
clusterCp = _historyContinuationStore.Save(session, clusterState);
|
clusterCp = _historyContinuationStore.Save(session, clusterState);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6347,7 +6378,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
{
|
{
|
||||||
HistoryPaging.ComputeResumeCursor(samples, out var nextStart, out var skip);
|
HistoryPaging.ComputeResumeCursor(samples, out var nextStart, out var skip);
|
||||||
var nextState = new HistoryContinuationState(
|
var nextState = new HistoryContinuationState(
|
||||||
tagname, nextStart, endUtc, skip, numValuesPerNode, SegmentWidthTicks: segmentWidth);
|
tagname, nextStart, endUtc, skip, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks,
|
||||||
|
EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks);
|
||||||
// Save may return null (no session on this request) ⇒ degrade to single-shot for this node.
|
// Save may return null (no session on this request) ⇒ degrade to single-shot for this node.
|
||||||
// Built AFTER historyData so a failure projecting samples can never orphan a stored cursor.
|
// Built AFTER historyData so a failure projecting samples can never orphan a stored cursor.
|
||||||
outboundCp = _historyContinuationStore.Save(session, nextState);
|
outboundCp = _historyContinuationStore.Save(session, nextState);
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
|
||||||
|
/// <summary>One walked-back reverse events page (<see cref="ReverseEventsReader"/>).</summary>
|
||||||
|
/// <param name="Events">At most the requested count, newest first by <c>(EventTimeUtc, EventId)</c>.</param>
|
||||||
|
/// <param name="Hint">What the next page of the same read resumes with.</param>
|
||||||
|
internal readonly record struct ReverseEventsPage(List<HistoricalEvent> Events, ReverseWalkHint Hint);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #691.4: serves a Part 11 reverse events read — <c>(lo, hi]</c> newest-first — on a backend that reads events
|
||||||
|
/// forward only (<see cref="IHistorianDataSource.SupportsReverseEvents"/> = <c>false</c>: the Gateway) with the
|
||||||
|
/// <see cref="ReverseWalk"/>. A segment read is cut short when the source flags truncation (its continuation point); ties
|
||||||
|
/// are ordered by <see cref="EventIdTieOrder"/> because the gateway orders by event time only.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ReverseEventsReader
|
||||||
|
{
|
||||||
|
/// <summary>Reads <c>(lo, hi]</c> newest-first from forward event reads.</summary>
|
||||||
|
/// <param name="source">A backend that reads events forward.</param>
|
||||||
|
/// <param name="sourceName">Event-source filter (null = all).</param>
|
||||||
|
/// <param name="hi">The later (inclusive) bound.</param>
|
||||||
|
/// <param name="lo">The earlier (exclusive) bound.</param>
|
||||||
|
/// <param name="want">How many events; <c>0</c> = all in the window (one forward read).</param>
|
||||||
|
/// <param name="maxTieCluster">The tie budget (<c>MaxTieClusterOverfetch</c>) — a ceiling only.</param>
|
||||||
|
/// <param name="nowUtc">Now, for the look-ahead clamp.</param>
|
||||||
|
/// <param name="hint">What the previous page carried (default = none).</param>
|
||||||
|
/// <param name="ct">Cancellation.</param>
|
||||||
|
/// <returns>The events, newest first, and the hint for the next page.</returns>
|
||||||
|
/// <exception cref="HistoryReadLimitExceededException">One timestamp holds more than the tie budget.</exception>
|
||||||
|
public static async Task<ReverseEventsPage> ReadAsync(
|
||||||
|
IHistorianDataSource source, string? sourceName, DateTime hi, DateTime lo, uint want, int maxTieCluster,
|
||||||
|
DateTime nowUtc, ReverseWalkHint hint, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var budget = Math.Max(1, maxTieCluster);
|
||||||
|
var (rows, next) = await ReverseWalk.WalkAsync<HistoricalEvent>(
|
||||||
|
async (start, end, cap, token) =>
|
||||||
|
{
|
||||||
|
// cap 0 = the whole window: int.MaxValue is the events contract's "no cap" (a non-positive value would
|
||||||
|
// mean the backend's DEFAULT cap).
|
||||||
|
var r = await source.ReadEventsAsync(sourceName, start, end, cap == 0 ? int.MaxValue : cap, reverse: false, token)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return new ForwardSegmentRead<HistoricalEvent>(r.Events, r.ContinuationPoint is not null);
|
||||||
|
},
|
||||||
|
ev => ev.EventTimeUtc.Ticks, EventIdTieOrder.Instance, hi, lo, want, budget, source.TimestampResolutionTicks,
|
||||||
|
nowUtc, hint,
|
||||||
|
at => new HistoryReadLimitExceededException(budget,
|
||||||
|
$"A reverse events read of '{sourceName}' at {at:O} found more than {budget} events within one millisecond; the " +
|
||||||
|
"backend reads forward only, so the latest of them cannot be selected. Increase " +
|
||||||
|
"ServerHistorian:MaxTieClusterOverfetch."),
|
||||||
|
ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return new ReverseEventsPage(rows, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,70 +5,29 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|||||||
/// <summary>One walked-back reverse raw page (<see cref="ReverseRawReader"/>).</summary>
|
/// <summary>One walked-back reverse raw page (<see cref="ReverseRawReader"/>).</summary>
|
||||||
/// <param name="Samples">At most the requested cap, newest first.</param>
|
/// <param name="Samples">At most the requested cap, newest first.</param>
|
||||||
/// <param name="WidthHintTicks">The segment width the walk ended on — the next page's starting width (0 = none).</param>
|
/// <param name="WidthHintTicks">The segment width the walk ended on — the next page's starting width (0 = none).</param>
|
||||||
internal readonly record struct ReverseRawPage(IReadOnlyList<DataValueSnapshot> Samples, long WidthHintTicks);
|
internal readonly record struct ReverseRawPage(IReadOnlyList<DataValueSnapshot> Samples, long WidthHintTicks)
|
||||||
|
{
|
||||||
|
/// <summary>Everything the next page of the same read should resume with (its width is <see cref="WidthHintTicks"/>).</summary>
|
||||||
|
public ReverseWalkHint Hint { get; init; } = new(WidthHintTicks);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gitea #686: serves a Part 11 reverse raw read — <c>(lo, hi]</c> newest-first, capped — on a historian backend that can
|
/// Gitea #686: serves a Part 11 reverse raw read — <c>(lo, hi]</c> newest-first, capped — on a historian backend that can
|
||||||
/// only read forward (<see cref="IHistorianDataSource.SupportsReverseRaw"/> = <c>false</c>: the Gateway, whose
|
/// only read forward (<see cref="IHistorianDataSource.SupportsReverseRaw"/> = <c>false</c>: the Gateway, whose
|
||||||
/// <c>ReadRaw</c> streams ascending and rejects <c>start > end</c>).
|
/// <c>ReadRaw</c> streams ascending and rejects <c>start > end</c>). The walk itself — segment sizing (#689), the
|
||||||
|
/// truncation rules, edges, ties and bounds — is <see cref="ReverseWalk"/>; this adapter supplies the raw read (a read is
|
||||||
|
/// cut short when it returned its full cap) and keeps the backend's tie order.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// <b>Uncapped</b> (<c>maxValues = 0</c>): ONE forward read of the whole window, filtered and reversed in memory — the
|
|
||||||
/// same rows, round trips and memory as an uncapped forward read (and as a native backend's uncapped reverse read).
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <b>Capped: walk back in segments.</b> Starting at <c>hi</c>, read one segment <c>(segLo, segHi]</c> FORWARD, keep
|
|
||||||
/// it newest-first, and step to the next older segment until the cap is met or <c>lo</c> is reached. The first
|
|
||||||
/// segment is <see cref="InitialSegment"/> wide, or the width the previous page of the same read ended on (carried in
|
|
||||||
/// the continuation point). After each read the next width is re-aimed at about half the row budget from the density
|
|
||||||
/// just observed, growing at most ×4 per step — so a live tag's "latest N" is usually one read, a dense tag does not
|
|
||||||
/// throw away a full read per page, and sparse history is found in a few dozen tiny reads.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <b>Never answer from a truncated read.</b> The row budget is <c>M = max(1, MaxTieClusterOverfetch)</c>; a segment
|
|
||||||
/// read asks for <c>M + 2</c> rows (room for the row an AVEVA read fabricates at its query start, and one more to see
|
|
||||||
/// past the segment). Rows stream ascending, so a read is complete for its segment when it came back short OR its last
|
|
||||||
/// row is already past <c>segHi</c>. An incomplete read is discarded: when rows BEFORE the segment (a neighbouring
|
|
||||||
/// tie cluster inside the alignment slack, the fabricated row) crowded it out it is re-read once with room for them
|
|
||||||
/// (at most <c>2M + 4</c> rows); otherwise the segment is narrowed (to about half the budget at the density the
|
|
||||||
/// truncated read showed) and re-read. Only a single timestamp holding more than <c>M</c> samples — a segment already
|
|
||||||
/// one timestamp-resolution wide that still does not fit — is refused: <see cref="HistoryReadLimitExceededException"/>
|
|
||||||
/// (⇒ <c>BadTooManyOperations</c>, a request limit, never a backend fault).
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <b>Edges.</b> A segment is requested as <c>[⌊segLo⌋, ⌈segHi + 1 tick⌉]</c>, both rounded outward to the millisecond
|
|
||||||
/// (the gateway's / AVEVA's resolution), and filtered to <c>(segLo, segHi]</c> on the samples' own timestamps. That is
|
|
||||||
/// exact whether the backend's forward end is exclusive (the interface contract) or inclusive (the gateway) and
|
|
||||||
/// whether it truncates its bounds (QuestDB-style); and because the request start is already on a millisecond, a row
|
|
||||||
/// an AVEVA read fabricates at its (possibly rounded-up) query start can never land inside the segment (#687).
|
|
||||||
/// Adjacent segments share no timestamp, so nothing is returned twice.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <b>Ties</b> come back in the backend's forward order reversed — the same order the node manager's tie-cluster
|
|
||||||
/// over-fetch uses for a reverse read — so a resumed page's boundary-tie skip lines up.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <b>Bounds.</b> The walk never asks for data after now + <see cref="LookAhead"/> (one day — the historian stores
|
|
||||||
/// nothing further ahead; a <c>StartTime = DateTime.MaxValue</c> read would otherwise send centuries-ahead query
|
|
||||||
/// starts), nor before <see cref="Floor"/> (the Unix epoch — AVEVA holds nothing earlier, and an end-only read's year-1
|
|
||||||
/// lower bound is outside the historian's time encoding). A walked-back read therefore returns only samples strictly
|
|
||||||
/// after the epoch and no later than now + one day.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
internal static class ReverseRawReader
|
internal static class ReverseRawReader
|
||||||
{
|
{
|
||||||
/// <summary>The first segment's width when no hint is carried.</summary>
|
/// <summary>The first segment's width when no hint is carried.</summary>
|
||||||
internal static readonly TimeSpan InitialSegment = TimeSpan.FromMinutes(1);
|
internal static readonly TimeSpan InitialSegment = ReverseWalk.InitialSegment;
|
||||||
|
|
||||||
/// <summary>The walk's exclusive lower limit (see the remarks).</summary>
|
/// <summary>The walk's exclusive lower limit.</summary>
|
||||||
internal static readonly DateTime Floor = DateTime.UnixEpoch;
|
internal static readonly DateTime Floor = ReverseWalk.Floor;
|
||||||
|
|
||||||
/// <summary>How far past "now" the walk starts at most (the same horizon as the Gateway's raw-aggregate look-ahead).</summary>
|
/// <summary>How far past "now" the walk starts at most.</summary>
|
||||||
internal static readonly TimeSpan LookAhead = TimeSpan.FromDays(1);
|
internal static readonly TimeSpan LookAhead = ReverseWalk.LookAhead;
|
||||||
|
|
||||||
/// <summary>Request bounds are rounded outward to this (the gateway / AVEVA timestamp resolution).</summary>
|
|
||||||
private const long Alignment = TimeSpan.TicksPerMillisecond;
|
|
||||||
|
|
||||||
/// <summary>Reads <c>(lo, hi]</c> newest-first from forward reads.</summary>
|
/// <summary>Reads <c>(lo, hi]</c> newest-first from forward reads.</summary>
|
||||||
/// <param name="source">A backend that reads forward.</param>
|
/// <param name="source">A backend that reads forward.</param>
|
||||||
@@ -76,129 +35,49 @@ internal static class ReverseRawReader
|
|||||||
/// <param name="hi">The later (inclusive) bound — the request's StartTime or a reverse resume point.</param>
|
/// <param name="hi">The later (inclusive) bound — the request's StartTime or a reverse resume point.</param>
|
||||||
/// <param name="lo">The earlier (exclusive) bound.</param>
|
/// <param name="lo">The earlier (exclusive) bound.</param>
|
||||||
/// <param name="maxValues">Cap; <c>0</c> = every sample in the window (one forward read).</param>
|
/// <param name="maxValues">Cap; <c>0</c> = every sample in the window (one forward read).</param>
|
||||||
/// <param name="maxTieCluster">Row budget per segment read (<c>MaxTieClusterOverfetch</c>; values below 1 count as 1).</param>
|
/// <param name="maxTieCluster">The tie budget (<c>MaxTieClusterOverfetch</c>; values below 1 count as 1) — a ceiling only.</param>
|
||||||
/// <param name="nowUtc">Now, for the look-ahead clamp.</param>
|
/// <param name="nowUtc">Now, for the look-ahead clamp.</param>
|
||||||
/// <param name="widthHintTicks">Starting segment width (the previous page's <see cref="ReverseRawPage.WidthHintTicks"/>), 0 = none.</param>
|
/// <param name="widthHintTicks">Starting segment width (the previous page's <see cref="ReverseRawPage.WidthHintTicks"/>), 0 = none.</param>
|
||||||
/// <param name="ct">Cancellation.</param>
|
/// <param name="ct">Cancellation.</param>
|
||||||
/// <returns>The page, newest first, and the width to start the next page with.</returns>
|
/// <returns>The page, newest first, and the width to start the next page with.</returns>
|
||||||
/// <exception cref="HistoryReadLimitExceededException">One timestamp holds more than the row budget.</exception>
|
/// <exception cref="HistoryReadLimitExceededException">One timestamp holds more than the tie budget.</exception>
|
||||||
|
public static Task<ReverseRawPage> ReadAsync(
|
||||||
|
IHistorianDataSource source, string tagname, DateTime hi, DateTime lo, uint maxValues, int maxTieCluster,
|
||||||
|
DateTime nowUtc, long widthHintTicks, CancellationToken ct) =>
|
||||||
|
ReadAsync(source, tagname, hi, lo, maxValues, maxTieCluster, nowUtc, new ReverseWalkHint(widthHintTicks), ct);
|
||||||
|
|
||||||
|
/// <summary>Reads <c>(lo, hi]</c> newest-first from forward reads, resuming with the previous page's full hint.</summary>
|
||||||
|
/// <param name="source">A backend that reads forward.</param>
|
||||||
|
/// <param name="tagname">Historian tag.</param>
|
||||||
|
/// <param name="hi">The later (inclusive) bound.</param>
|
||||||
|
/// <param name="lo">The earlier (exclusive) bound.</param>
|
||||||
|
/// <param name="maxValues">Cap; <c>0</c> = every sample in the window (one forward read).</param>
|
||||||
|
/// <param name="maxTieCluster">The tie budget — a ceiling only.</param>
|
||||||
|
/// <param name="nowUtc">Now, for the look-ahead clamp.</param>
|
||||||
|
/// <param name="hint">What the previous page carried (<see cref="ReverseRawPage.Hint"/>), default = none.</param>
|
||||||
|
/// <param name="ct">Cancellation.</param>
|
||||||
|
/// <returns>The page, newest first, and the hint for the next page.</returns>
|
||||||
|
/// <exception cref="HistoryReadLimitExceededException">One timestamp holds more than the tie budget.</exception>
|
||||||
public static async Task<ReverseRawPage> ReadAsync(
|
public static async Task<ReverseRawPage> ReadAsync(
|
||||||
IHistorianDataSource source, string tagname, DateTime hi, DateTime lo, uint maxValues, int maxTieCluster,
|
IHistorianDataSource source, string tagname, DateTime hi, DateTime lo, uint maxValues, int maxTieCluster,
|
||||||
DateTime nowUtc, long widthHintTicks, CancellationToken ct)
|
DateTime nowUtc, ReverseWalkHint hint, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var resolution = Math.Max(1L, source.TimestampResolutionTicks);
|
|
||||||
var granularity = Math.Max(Alignment, resolution);
|
|
||||||
var budget = Math.Max(1, maxTieCluster);
|
var budget = Math.Max(1, maxTieCluster);
|
||||||
var result = new List<DataValueSnapshot>();
|
var (rows, next) = await ReverseWalk.WalkAsync(
|
||||||
|
async (start, end, cap, token) =>
|
||||||
var horizon = nowUtc.Ticks > DateTime.MaxValue.Ticks - LookAhead.Ticks ? DateTime.MaxValue.Ticks : nowUtc.Ticks + LookAhead.Ticks;
|
|
||||||
var hiTicks = Math.Min(hi.Ticks, horizon);
|
|
||||||
var stopTicks = Math.Max(lo.Ticks, Floor.Ticks);
|
|
||||||
if (hiTicks <= stopTicks) return new ReverseRawPage(result, widthHintTicks);
|
|
||||||
|
|
||||||
if (maxValues == 0)
|
|
||||||
{
|
|
||||||
// One forward read of the whole window, reversed — the uncapped forward read's own cost and bound.
|
|
||||||
var all = (await source.ReadRawAsync(tagname, Utc(AlignDown(stopTicks, granularity)),
|
|
||||||
Utc(AlignUp(hiTicks + 1, granularity)), 0, ct).ConfigureAwait(false)).Samples;
|
|
||||||
for (var i = all.Count - 1; i >= 0; i--)
|
|
||||||
{
|
|
||||||
var ts = Ticks(all[i]);
|
|
||||||
if (ts > stopTicks && ts <= hiTicks) result.Add(all[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ReverseRawPage(result, widthHintTicks);
|
|
||||||
}
|
|
||||||
|
|
||||||
var maxWidth = DateTime.MaxValue.Ticks;
|
|
||||||
var width = widthHintTicks > 0 ? Math.Clamp(widthHintTicks, resolution, maxWidth) : Math.Max(resolution, InitialSegment.Ticks);
|
|
||||||
var baseCap = budget + 2;
|
|
||||||
var maxCap = 2 * budget + 4;
|
|
||||||
var segHi = hiTicks;
|
|
||||||
|
|
||||||
while (segHi > stopTicks && result.Count < maxValues)
|
|
||||||
{
|
|
||||||
var segLo = segHi - stopTicks <= width ? stopTicks : segHi - width;
|
|
||||||
var start = Utc(AlignDown(segLo, granularity));
|
|
||||||
var end = Utc(AlignUp(segHi + 1, granularity));
|
|
||||||
var cap = baseCap;
|
|
||||||
IReadOnlyList<DataValueSnapshot> rows;
|
|
||||||
var narrowed = false;
|
|
||||||
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
ct.ThrowIfCancellationRequested();
|
|
||||||
rows = (await source.ReadRawAsync(tagname, start, end, (uint)cap, ct).ConfigureAwait(false)).Samples;
|
|
||||||
if (rows.Count < cap || Ticks(rows[^1]) > segHi) break; // complete for (segLo, segHi]
|
|
||||||
|
|
||||||
var leading = 0;
|
|
||||||
var inner = 0;
|
|
||||||
var firstInner = 0L;
|
|
||||||
foreach (var r in rows)
|
|
||||||
{
|
{
|
||||||
var ts = Ticks(r);
|
var samples = (await source.ReadRawAsync(tagname, start, end, (uint)cap, token).ConfigureAwait(false)).Samples;
|
||||||
if (ts <= segLo) leading++;
|
return new ForwardSegmentRead<DataValueSnapshot>(samples, cap > 0 && samples.Count >= cap);
|
||||||
else if (ts <= segHi && inner++ == 0) firstInner = ts;
|
},
|
||||||
}
|
Ticks, tieOrder: null, hi, lo, maxValues, budget, source.TimestampResolutionTicks, nowUtc, hint,
|
||||||
|
at => new HistoryReadLimitExceededException(budget,
|
||||||
// Crowded out by rows before the segment, with the segment itself possibly within budget: re-read once
|
$"A reverse raw read of '{tagname}' at {at:O} found more than {budget} samples within one millisecond; the " +
|
||||||
// with room for them.
|
"backend reads forward only, so the latest of them cannot be selected. Increase " +
|
||||||
var roomier = leading + budget + 2;
|
"ServerHistorian:MaxTieClusterOverfetch."),
|
||||||
if (leading > 0 && inner <= budget && roomier > cap && roomier <= maxCap)
|
ct)
|
||||||
{
|
.ConfigureAwait(false);
|
||||||
cap = roomier;
|
return new ReverseRawPage(rows, next.WidthTicks) { Hint = next };
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (width <= resolution)
|
|
||||||
throw new HistoryReadLimitExceededException(budget,
|
|
||||||
$"A reverse raw read of '{tagname}' at {Utc(segHi):O} found more than {budget} samples at one " +
|
|
||||||
"timestamp; the backend reads forward only, so the latest of them cannot be selected. Increase " +
|
|
||||||
"ServerHistorian:MaxTieClusterOverfetch.");
|
|
||||||
|
|
||||||
// Narrow to about half the budget at the density the truncated read's own samples showed (their span, not
|
|
||||||
// the segment's — the segment may open on an empty stretch), at least halving.
|
|
||||||
var lastTs = Ticks(rows[^1]);
|
|
||||||
var aimed = inner > 1 && lastTs > firstInner
|
|
||||||
? (long)Math.Min((double)(lastTs - firstInner) * (budget / 2.0) / (inner - 1), width / 2.0)
|
|
||||||
: width / 4;
|
|
||||||
width = Math.Max(resolution, aimed);
|
|
||||||
narrowed = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (narrowed) continue; // re-read the same segHi, narrower
|
|
||||||
|
|
||||||
var count = 0;
|
|
||||||
for (var i = rows.Count - 1; i >= 0; i--)
|
|
||||||
{
|
|
||||||
var ts = Ticks(rows[i]);
|
|
||||||
if (ts <= segLo || ts > segHi) continue;
|
|
||||||
count++;
|
|
||||||
if (result.Count < maxValues) result.Add(rows[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-aim at about half the budget from the density just seen; grow at most x4.
|
|
||||||
var grown = width > maxWidth / 4 ? maxWidth : width * 4;
|
|
||||||
width = count == 0
|
|
||||||
? grown
|
|
||||||
: (long)Math.Clamp((double)width * Math.Max(1.0, budget / 2.0) / count, resolution, grown);
|
|
||||||
segHi = segLo;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ReverseRawPage(result, width);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static long Ticks(DataValueSnapshot s) => (s.SourceTimestampUtc ?? DateTime.MinValue).Ticks;
|
private static long Ticks(DataValueSnapshot s) => (s.SourceTimestampUtc ?? DateTime.MinValue).Ticks;
|
||||||
|
|
||||||
private static DateTime Utc(long ticks) => new(ticks, DateTimeKind.Utc);
|
|
||||||
|
|
||||||
private static long AlignDown(long ticks, long step) => ticks - ticks % step;
|
|
||||||
|
|
||||||
private static long AlignUp(long ticks, long step)
|
|
||||||
{
|
|
||||||
var down = AlignDown(ticks, step);
|
|
||||||
if (down == ticks) return ticks;
|
|
||||||
return down > DateTime.MaxValue.Ticks - step ? DateTime.MaxValue.Ticks : down + step;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
|
||||||
|
/// <summary>What one walked-back page tells the next page of the same read (carried in the continuation point).</summary>
|
||||||
|
/// <param name="WidthTicks">The segment width the walk ended on, aimed at the page's density — the next page's first width (0 = none).</param>
|
||||||
|
/// <param name="EmptyFromTicks">
|
||||||
|
/// With <paramref name="EmptyToTicks"/>: a stretch <c>(EmptyFrom, EmptyTo)</c> (both exclusive) a complete segment read
|
||||||
|
/// proved holds no row (#691) — typically from the oldest row the page saw down to the walk's floor. A resumed page
|
||||||
|
/// steps straight over it instead of re-walking it. Both 0 = none.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="EmptyToTicks">The exclusive upper end of the empty stretch.</param>
|
||||||
|
internal readonly record struct ReverseWalkHint(long WidthTicks, long EmptyFromTicks = 0, long EmptyToTicks = 0)
|
||||||
|
{
|
||||||
|
/// <summary>Whether an empty stretch is carried.</summary>
|
||||||
|
public bool HasEmptyStretch => EmptyToTicks > EmptyFromTicks && EmptyToTicks > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One forward read of a segment: the rows (ascending by time) and whether a cap cut the read short.</summary>
|
||||||
|
/// <typeparam name="T">Row type.</typeparam>
|
||||||
|
/// <param name="Rows">Rows in ascending time order (ties in any order).</param>
|
||||||
|
/// <param name="Truncated">A cap stopped the read — more rows may follow the last one.</param>
|
||||||
|
internal readonly record struct ForwardSegmentRead<T>(IReadOnlyList<T> Rows, bool Truncated);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #686/#689: the walk-back that serves a Part 11 reverse read — <c>(lo, hi]</c> newest-first, capped — from a
|
||||||
|
/// backend that can only read FORWARD. Shared by raw values (<see cref="ReverseRawReader"/>) and events; the row type
|
||||||
|
/// only has to expose a timestamp.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Uncapped</b> (<c>want = 0</c>): ONE forward read of the whole window, filtered and reversed in memory.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Capped: walk back in segments.</b> Starting at <c>hi</c>, read one segment <c>(segLo, segHi]</c> FORWARD, keep
|
||||||
|
/// it newest-first, and step to the next older segment until the cap is met or <c>lo</c> is reached. The first
|
||||||
|
/// segment is <see cref="InitialSegment"/> wide, or the width the previous page of the same read ended on (carried in
|
||||||
|
/// the continuation point). After each read the next width is re-aimed from the density just observed at about half
|
||||||
|
/// the PAGE budget <c>2N + 16</c> (≈ N + 8 rows), growing at most ×4 per step (#689 — it used to aim at half the tie
|
||||||
|
/// budget, ~32k rows, whatever N was) — so a live tag's "latest N" is usually one read of about N rows, and sparse
|
||||||
|
/// history is found in a few dozen tiny reads.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Read sizes.</b> A segment read asks for its budget + 2 rows (room for a row an AVEVA read fabricates at its query
|
||||||
|
/// start, and one to see past the segment). The budget is the page budget, except a <b>discovery</b> segment — the
|
||||||
|
/// first of a page with no carried width, or one after an empty segment (whose ×4 growth may overshoot into dense
|
||||||
|
/// data) — which may read up to <see cref="DiscoveryRows"/>. The tie budget <c>M = max(1, MaxTieClusterOverfetch)</c>
|
||||||
|
/// is only a CEILING: a single timestamp holding more rows than the segment budget escalates to <c>M</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Never answer from a truncated read.</b> Rows stream ascending, so a read is complete for its segment when it was
|
||||||
|
/// not cut short OR its last row is already past <c>segHi</c>. An incomplete read is discarded: when rows BEFORE the
|
||||||
|
/// segment (a neighbouring tie cluster inside the alignment slack, a fabricated row) crowded it out it is re-read once
|
||||||
|
/// with room for them (at most twice the budget + 4); otherwise the segment is narrowed — to the density the truncated
|
||||||
|
/// read showed, and never again below the newest row it saw (<c>known</c>: rows exist there, so walking past it before
|
||||||
|
/// finding what lies above it would re-read the same dense stretch over and over; the gap above it is bisected
|
||||||
|
/// instead). Only more than <c>M</c> rows within one millisecond — a segment no wider than the 1 ms request
|
||||||
|
/// granularity that still does not fit at the tie budget — is refused (per millisecond, not per timestamp: narrower
|
||||||
|
/// requests are the same request, and the Gateway stores milliseconds).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Proving the rest empty (#691).</b> Walking down to the floor through empty history used to take one read per ×4
|
||||||
|
/// growth step (~14 reads from a minute to the epoch) — on every page that ran out of rows. After two consecutive empty
|
||||||
|
/// segments the walk asks once per page for the WHOLE remainder <c>(lo, segHi]</c> at the page budget: a complete
|
||||||
|
/// answer ends the walk in one read; a truncated one (deep history) is discarded and the walk carries on as before. And
|
||||||
|
/// the stretch a page's last complete segment proved empty below its oldest row rides the continuation point
|
||||||
|
/// (<see cref="ReverseWalkHint.EmptyFromTicks"/>), so the next page steps over it — the rows of a page are often the
|
||||||
|
/// last ones before a stretch the previous page already read.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Edges.</b> A segment is requested as <c>[⌊segLo⌋, ⌈segHi + 1 tick⌉]</c>, both rounded outward to the millisecond
|
||||||
|
/// (the gateway's / AVEVA's resolution), and filtered to <c>(segLo, segHi]</c> on the rows' own timestamps — exact
|
||||||
|
/// whether the backend's forward end is exclusive or inclusive and whether it truncates its bounds; a row fabricated at
|
||||||
|
/// the (millisecond) query start can never land inside the segment (#687). Adjacent segments share no timestamp.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Ties</b> come back in the backend's forward order reversed, or — with a tie order given (events, whose gateway
|
||||||
|
/// order within a timestamp is arbitrary) — sorted by it and reversed, so a resumed page's boundary-tie skip lines up.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Bounds.</b> The walk never asks for data after now + <see cref="LookAhead"/> nor before <see cref="Floor"/> (the
|
||||||
|
/// Unix epoch — AVEVA holds nothing earlier). A walked-back read returns only rows strictly after the epoch and no
|
||||||
|
/// later than now + one day.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class ReverseWalk
|
||||||
|
{
|
||||||
|
/// <summary>The first segment's width when no hint is carried.</summary>
|
||||||
|
internal static readonly TimeSpan InitialSegment = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
|
/// <summary>The walk's exclusive lower limit.</summary>
|
||||||
|
internal static readonly DateTime Floor = DateTime.UnixEpoch;
|
||||||
|
|
||||||
|
/// <summary>How far past "now" the walk starts at most.</summary>
|
||||||
|
internal static readonly TimeSpan LookAhead = TimeSpan.FromDays(1);
|
||||||
|
|
||||||
|
/// <summary>Slack added to a page's row budget (#689): room for density variation between segments.</summary>
|
||||||
|
internal const int PageSlack = 16;
|
||||||
|
|
||||||
|
/// <summary>The most rows a discovery segment (see the remarks) may read before it counts as truncated (#689).</summary>
|
||||||
|
internal const int DiscoveryRows = 1024;
|
||||||
|
|
||||||
|
/// <summary>Request bounds are rounded outward to this (the gateway / AVEVA timestamp resolution).</summary>
|
||||||
|
private const long Alignment = TimeSpan.TicksPerMillisecond;
|
||||||
|
|
||||||
|
/// <summary>The rows a segment is sized for: <c>2N + </c><see cref="PageSlack"/>, never above the tie budget.</summary>
|
||||||
|
/// <param name="want">The page cap N (> 0).</param>
|
||||||
|
/// <param name="tieBudget">The tie budget M (>= 1).</param>
|
||||||
|
/// <returns>The page budget.</returns>
|
||||||
|
internal static int PageBudget(uint want, int tieBudget) => (int)Math.Clamp(2L * want + PageSlack, 1L, Math.Max(1, tieBudget));
|
||||||
|
|
||||||
|
/// <summary>Reads <c>(lo, hi]</c> newest-first from forward reads.</summary>
|
||||||
|
/// <typeparam name="T">Row type.</typeparam>
|
||||||
|
/// <param name="read">One forward read <c>[start, end]</c> asking for at most <c>cap</c> rows (<c>0</c> = all).</param>
|
||||||
|
/// <param name="ticksOf">A row's timestamp, in ticks.</param>
|
||||||
|
/// <param name="tieOrder">Order within one timestamp (null = the backend's forward order).</param>
|
||||||
|
/// <param name="hi">The later (inclusive) bound.</param>
|
||||||
|
/// <param name="lo">The earlier (exclusive) bound.</param>
|
||||||
|
/// <param name="want">Cap; <c>0</c> = every row in the window (one forward read).</param>
|
||||||
|
/// <param name="tieBudget">The tie budget M (values below 1 count as 1).</param>
|
||||||
|
/// <param name="resolutionTicks">The backend's timestamp resolution (>= 1).</param>
|
||||||
|
/// <param name="nowUtc">Now, for the look-ahead clamp.</param>
|
||||||
|
/// <param name="hint">What the previous page of the same read carried (default = none).</param>
|
||||||
|
/// <param name="tooManyAtOneTimestamp">Builds the exception for a timestamp holding more than M rows.</param>
|
||||||
|
/// <param name="ct">Cancellation.</param>
|
||||||
|
/// <returns>At most <paramref name="want"/> rows, newest first, and the hint for the next page.</returns>
|
||||||
|
public static async Task<(List<T> Rows, ReverseWalkHint Next)> WalkAsync<T>(
|
||||||
|
Func<DateTime, DateTime, int, CancellationToken, Task<ForwardSegmentRead<T>>> read,
|
||||||
|
Func<T, long> ticksOf,
|
||||||
|
IComparer<T>? tieOrder,
|
||||||
|
DateTime hi, DateTime lo, uint want, int tieBudget, long resolutionTicks, DateTime nowUtc,
|
||||||
|
ReverseWalkHint hint, Func<DateTime, Exception> tooManyAtOneTimestamp, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resolution = Math.Max(1L, resolutionTicks);
|
||||||
|
var granularity = Math.Max(Alignment, resolution);
|
||||||
|
var budget = Math.Max(1, tieBudget);
|
||||||
|
var result = new List<T>();
|
||||||
|
|
||||||
|
var horizon = nowUtc.Ticks > DateTime.MaxValue.Ticks - LookAhead.Ticks ? DateTime.MaxValue.Ticks : nowUtc.Ticks + LookAhead.Ticks;
|
||||||
|
var hiTicks = Math.Min(hi.Ticks, horizon);
|
||||||
|
var stopTicks = Math.Max(lo.Ticks, Floor.Ticks);
|
||||||
|
if (hiTicks <= stopTicks) return (result, hint);
|
||||||
|
|
||||||
|
if (want == 0)
|
||||||
|
{
|
||||||
|
// One forward read of the whole window, reversed — the uncapped forward read's own cost and bound.
|
||||||
|
var all = (await read(Utc(AlignDown(stopTicks, granularity)), Utc(AlignUp(hiTicks + 1, granularity)), 0, ct)
|
||||||
|
.ConfigureAwait(false)).Rows;
|
||||||
|
AppendNewestFirst(all, ticksOf, tieOrder, stopTicks, hiTicks, result, int.MaxValue);
|
||||||
|
return (result, hint);
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxWidth = DateTime.MaxValue.Ticks;
|
||||||
|
var discovering = hint.WidthTicks <= 0;
|
||||||
|
var width = discovering ? Math.Max(resolution, InitialSegment.Ticks) : Math.Clamp(hint.WidthTicks, resolution, maxWidth);
|
||||||
|
var pageBudget = PageBudget(want, budget);
|
||||||
|
var discoveryBudget = Math.Min(budget, Math.Max(pageBudget, DiscoveryRows));
|
||||||
|
|
||||||
|
// `known` > 0: a truncated read saw rows up to this timestamp (below segHi), and nothing is known of (known, segHi].
|
||||||
|
// Segments stay above it until that stretch is read; then `knownWidth` (the density the truncated read showed)
|
||||||
|
// sizes the segment that ends on it. Searching the gap: first a top-anchored segment at that density (data running
|
||||||
|
// right up to segHi); if it is empty, the data stops somewhere above `known`, so GALLOP up from it — read
|
||||||
|
// (known + lowerBy, segHi] with lowerBy growing ×4, `known` following what each truncated read saw — until a read
|
||||||
|
// comes back empty (the top is now bracketed below its segLo), then BISECT that bracket. Any complete read holding
|
||||||
|
// rows ends the search. A burst that ended long before segHi is found in a few reads instead of bisecting the whole
|
||||||
|
// gap down to the millisecond, and a long dense stretch is crossed geometrically, not a budget at a time (#691.4
|
||||||
|
// review). `noGallop` falls back to plain top-anchored halving if a galloping read is crowded by rows before it.
|
||||||
|
var known = 0L;
|
||||||
|
var knownWidth = 0L;
|
||||||
|
var lowerBy = 0L;
|
||||||
|
var bisecting = false;
|
||||||
|
var noGallop = false;
|
||||||
|
var segHi = hiTicks;
|
||||||
|
|
||||||
|
// #691: the remainder probe (once per page, after two empty segments) and the empty stretches.
|
||||||
|
var emptyRun = 0;
|
||||||
|
var probed = false;
|
||||||
|
var carried = hint.HasEmptyStretch ? (hint.EmptyFromTicks, hint.EmptyToTicks) : (0L, 0L);
|
||||||
|
(long From, long To) proven = (0L, 0L);
|
||||||
|
|
||||||
|
while (segHi > stopTicks && result.Count < want)
|
||||||
|
{
|
||||||
|
if (carried.Item2 > carried.Item1 && segHi > carried.Item1 && segHi < carried.Item2)
|
||||||
|
{
|
||||||
|
segHi = carried.Item1; // (from, segHi] lies inside a stretch a previous page proved empty
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var probe = !probed && known == 0 && emptyRun >= 2 && segHi - stopTicks > width;
|
||||||
|
var w = probe ? segHi - stopTicks : width;
|
||||||
|
var lowered = false;
|
||||||
|
var bisected = false;
|
||||||
|
if (known > 0)
|
||||||
|
{
|
||||||
|
var gap = segHi - known;
|
||||||
|
lowered = lowerBy > 0 && lowerBy < gap;
|
||||||
|
bisected = !lowered && (bisecting || lowerBy > 0) && gap > 2 * Math.Max(granularity, knownWidth);
|
||||||
|
// Never narrower than the request granularity: requests are rounded outward to it, so a narrower segment
|
||||||
|
// re-sends the same request — and a width left at one tick (a tie cluster's) stepped segHi down a tick per
|
||||||
|
// read, ~10,000 identical reads (#691.4 re-review). Keep the Min: a truncated read crowded by rows before
|
||||||
|
// the segment must still shrink the segment.
|
||||||
|
w = lowered ? gap - lowerBy
|
||||||
|
: bisected ? gap / 2
|
||||||
|
: Math.Min(Math.Max(w, granularity), gap <= 2 * granularity ? gap : gap / 2);
|
||||||
|
}
|
||||||
|
var segLo = segHi - stopTicks <= w ? stopTicks : segHi - w;
|
||||||
|
var start = Utc(AlignDown(segLo, granularity));
|
||||||
|
var end = Utc(AlignUp(segHi + 1, granularity));
|
||||||
|
var segBudget = discovering && !probe ? discoveryBudget : pageBudget;
|
||||||
|
var cap = segBudget + 2;
|
||||||
|
IReadOnlyList<T> rows;
|
||||||
|
var narrowed = false;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var segment = await read(start, end, cap, ct).ConfigureAwait(false);
|
||||||
|
rows = segment.Rows;
|
||||||
|
if (!segment.Truncated || rows.Count == 0 || ticksOf(rows[^1]) > segHi) break; // complete for (segLo, segHi]
|
||||||
|
|
||||||
|
if (probe)
|
||||||
|
{
|
||||||
|
// Deep history below: the probe saw only its oldest rows. Carry on walking from the same segHi.
|
||||||
|
probed = true;
|
||||||
|
narrowed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var leading = 0;
|
||||||
|
var inner = 0;
|
||||||
|
var firstInner = 0L;
|
||||||
|
var lastInner = 0L;
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
var ts = ticksOf(r);
|
||||||
|
if (ts <= segLo) leading++;
|
||||||
|
else if (ts <= segHi)
|
||||||
|
{
|
||||||
|
if (inner++ == 0) firstInner = ts;
|
||||||
|
lastInner = ts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crowded out by rows before the segment, with the segment itself possibly within budget: re-read once
|
||||||
|
// with room for them.
|
||||||
|
var roomier = leading + segBudget + 2;
|
||||||
|
if (leading > 0 && inner <= segBudget && roomier > cap && roomier <= 2 * segBudget + 4)
|
||||||
|
{
|
||||||
|
cap = roomier;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segHi - segLo <= granularity)
|
||||||
|
{
|
||||||
|
// A segment no wider than the request granularity (1 ms — narrower requests are the same request) still
|
||||||
|
// over its budget: a tie cluster, or a burst inside one millisecond. Escalate to the tie budget M before
|
||||||
|
// refusing — up to M rows within one millisecond are served (#686); more are refused, per millisecond
|
||||||
|
// (#691.4 re-review — exact for the millisecond-resolution Gateway).
|
||||||
|
if (segBudget < budget)
|
||||||
|
{
|
||||||
|
segBudget = budget;
|
||||||
|
cap = segBudget + 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw tooManyAtOneTimestamp(Utc(segHi));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Narrow — the segment shrinks every time, so this terminates.
|
||||||
|
if (inner > 0 && lastInner == segHi)
|
||||||
|
{
|
||||||
|
width = resolution; // a tie cluster at the top of the segment: read that one timestamp
|
||||||
|
lowerBy = 0;
|
||||||
|
bisecting = false;
|
||||||
|
}
|
||||||
|
else if (inner > 0 && (lowered || bisected))
|
||||||
|
{
|
||||||
|
// Data reaches past this segment's bottom: move `known` up to what this read saw; a gallop goes further.
|
||||||
|
known = lastInner;
|
||||||
|
if (lowered) lowerBy = lowerBy > maxWidth / 4 ? maxWidth : lowerBy * 4;
|
||||||
|
}
|
||||||
|
else if (inner > 0)
|
||||||
|
{
|
||||||
|
// Rows exist at lastInner; what lies above it is unknown. Aim at about half the page budget at the
|
||||||
|
// density the truncated rows showed (their span, not the segment's — it may open on an empty stretch).
|
||||||
|
var aimed = inner > 1 && lastInner > firstInner
|
||||||
|
? (long)Math.Min((double)(lastInner - firstInner) * (pageBudget / 2.0) / (inner - 1), maxWidth)
|
||||||
|
: resolution;
|
||||||
|
known = lastInner;
|
||||||
|
knownWidth = Math.Max(resolution, aimed);
|
||||||
|
width = Math.Max(resolution, Math.Min(aimed, segHi - segLo - 1));
|
||||||
|
lowerBy = 0;
|
||||||
|
bisecting = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Only rows before the segment: shrink it — top-anchored from here on (a gallop would re-send the same
|
||||||
|
// crowded read).
|
||||||
|
width = Math.Max(resolution, Math.Min(width, segHi - segLo) / 4);
|
||||||
|
if (lowered || bisected) noGallop = true;
|
||||||
|
lowerBy = 0;
|
||||||
|
bisecting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
discovering = false;
|
||||||
|
narrowed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (narrowed) continue; // re-read the same segHi, narrower
|
||||||
|
|
||||||
|
var count = AppendNewestFirst(rows, ticksOf, tieOrder, segLo, segHi, result, (int)Math.Min(int.MaxValue, want),
|
||||||
|
out var oldest);
|
||||||
|
// This complete read proves (segLo, oldest) empty — or the whole segment when it held nothing.
|
||||||
|
proven = count > 0 ? (segLo, oldest) : (segLo, segHi + 1);
|
||||||
|
if (probe)
|
||||||
|
{
|
||||||
|
probed = true;
|
||||||
|
segHi = segLo; // the whole remainder is read
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyRun = count == 0 ? emptyRun + 1 : 0;
|
||||||
|
var grown = width > maxWidth / 4 ? maxWidth : width * 4;
|
||||||
|
if (count == 0)
|
||||||
|
{
|
||||||
|
discovering = true; // ×4 growth may overshoot into dense data
|
||||||
|
if (known > 0)
|
||||||
|
{
|
||||||
|
// An empty top-anchored segment: gallop up from `known`. An empty galloping read: the top is bracketed
|
||||||
|
// in (known, segLo] — bisect it (an empty bisecting read keeps bisecting).
|
||||||
|
if (lowered || bisected)
|
||||||
|
{
|
||||||
|
lowerBy = 0;
|
||||||
|
bisecting = true;
|
||||||
|
}
|
||||||
|
else if (noGallop)
|
||||||
|
{
|
||||||
|
width = maxWidth; // halve the gap, top-anchored
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lowerBy = Math.Max(knownWidth, granularity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
width = grown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Re-aim at about half the page budget from the density just seen.
|
||||||
|
width = (long)Math.Clamp((double)(segHi - segLo) * Math.Max(1.0, pageBudget / 2.0) / count, resolution, grown);
|
||||||
|
discovering = false;
|
||||||
|
known = 0;
|
||||||
|
lowerBy = 0;
|
||||||
|
bisecting = false;
|
||||||
|
noGallop = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
segHi = segLo;
|
||||||
|
if (known > 0 && segHi <= known)
|
||||||
|
{
|
||||||
|
// Everything above the newest row the truncated read saw is read: the next segment ends on it, at its density.
|
||||||
|
known = 0;
|
||||||
|
lowerBy = 0;
|
||||||
|
bisecting = false;
|
||||||
|
noGallop = false;
|
||||||
|
width = knownWidth;
|
||||||
|
discovering = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carry the stretch the last complete read proved empty, joined to the one carried in when they touch; when they
|
||||||
|
// do not, keep the one reaching the floor (it saves a whole walk), else the newer.
|
||||||
|
var next = carried;
|
||||||
|
if (proven.To > proven.From)
|
||||||
|
{
|
||||||
|
if (carried.Item2 > carried.Item1 && proven.From < carried.Item2 && carried.Item1 < proven.To)
|
||||||
|
next = (Math.Min(proven.From, carried.Item1), Math.Max(proven.To, carried.Item2));
|
||||||
|
else if (!(carried.Item2 > carried.Item1 && carried.Item1 <= stopTicks) || proven.From <= stopTicks)
|
||||||
|
next = proven;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (result, new ReverseWalkHint(width, next.Item1, next.Item2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Appends the rows in <c>(segLo, segHi]</c> newest-first (until <paramref name="limit"/>) and counts them.</summary>
|
||||||
|
private static int AppendNewestFirst<T>(
|
||||||
|
IReadOnlyList<T> rows, Func<T, long> ticksOf, IComparer<T>? tieOrder, long segLo, long segHi, List<T> result, int limit) =>
|
||||||
|
AppendNewestFirst(rows, ticksOf, tieOrder, segLo, segHi, result, limit, out _);
|
||||||
|
|
||||||
|
/// <summary>As above, also returning the oldest in-range timestamp (meaningless when none).</summary>
|
||||||
|
private static int AppendNewestFirst<T>(
|
||||||
|
IReadOnlyList<T> rows, Func<T, long> ticksOf, IComparer<T>? tieOrder, long segLo, long segHi, List<T> result, int limit,
|
||||||
|
out long oldest)
|
||||||
|
{
|
||||||
|
oldest = long.MaxValue;
|
||||||
|
IReadOnlyList<T> ordered = rows;
|
||||||
|
if (tieOrder is not null)
|
||||||
|
{
|
||||||
|
var copy = rows.ToList();
|
||||||
|
copy.Sort((a, b) =>
|
||||||
|
{
|
||||||
|
var c = ticksOf(a).CompareTo(ticksOf(b));
|
||||||
|
return c != 0 ? c : tieOrder.Compare(a, b);
|
||||||
|
});
|
||||||
|
ordered = copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
var count = 0;
|
||||||
|
for (var i = ordered.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var ts = ticksOf(ordered[i]);
|
||||||
|
if (ts <= segLo || ts > segHi) continue;
|
||||||
|
count++;
|
||||||
|
if (ts < oldest) oldest = ts;
|
||||||
|
if (result.Count < limit) result.Add(ordered[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime Utc(long ticks) => new(ticks, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private static long AlignDown(long ticks, long step) => ticks - ticks % step;
|
||||||
|
|
||||||
|
private static long AlignUp(long ticks, long step)
|
||||||
|
{
|
||||||
|
var down = AlignDown(ticks, step);
|
||||||
|
if (down == ticks) return ticks;
|
||||||
|
return down > DateTime.MaxValue.Ticks - step ? DateTime.MaxValue.Ticks : down + step;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orders events within one timestamp by <see cref="HistoricalEvent.EventId"/> (ordinal) — the tie order a walked-back
|
||||||
|
/// events read (#691.4) and its oversized-cluster over-fetch share, because the Gateway orders by event time only.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class EventIdTieOrder : IComparer<HistoricalEvent>
|
||||||
|
{
|
||||||
|
/// <summary>The shared instance.</summary>
|
||||||
|
public static readonly EventIdTieOrder Instance = new();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int Compare(HistoricalEvent? x, HistoricalEvent? y) => string.CompareOrdinal(x?.EventId, y?.EventId);
|
||||||
|
}
|
||||||
@@ -82,8 +82,8 @@ public sealed class ServerHistorianOptions
|
|||||||
/// <c>MaxTieClusterOverfetch + 1</c> ties at that single timestamp (a <c>start == end</c> read) and
|
/// <c>MaxTieClusterOverfetch + 1</c> ties at that single timestamp (a <c>start == end</c> read) and
|
||||||
/// pages through them; this bounds how large a single-timestamp burst the server will buffer in
|
/// pages through them; this bounds how large a single-timestamp burst the server will buffer in
|
||||||
/// memory before it gives up and surfaces <c>BadTooManyOperations</c> for that node (#686; was
|
/// memory before it gives up and surfaces <c>BadTooManyOperations</c> for that node (#686; was
|
||||||
/// <c>BadHistoryOperationUnsupported</c>). It is also the per-read row budget of a reverse raw read walked
|
/// <c>BadHistoryOperationUnsupported</c>). It is also the tie-cluster CEILING of a reverse read walked
|
||||||
/// back from forward reads on a backend that cannot read newest-first (the Gateway): one timestamp holding
|
/// back from forward reads on a backend that cannot read newest-first (the Gateway; segments are sized from the page, #689): one timestamp holding
|
||||||
/// more samples than this is refused <c>BadTooManyOperations</c>. The default (65536) comfortably covers
|
/// more samples than this is refused <c>BadTooManyOperations</c>. The default (65536) comfortably covers
|
||||||
/// any realistic same-millisecond burst; a value below 1 is treated as 1.
|
/// any realistic same-millisecond burst; a value below 1 is treated as 1.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+7
-5
@@ -68,17 +68,19 @@ public sealed class NullHistorianDataSourceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #675: the direction-aware overload's default — forward is the five-argument read, reverse is refused
|
/// #691.4 review: like raw, the null source reads events in either direction (empty) and says so — the server sends
|
||||||
/// (the server answers <c>BadHistoryOperationUnsupported</c>).
|
/// it the reverse read directly instead of walking back through empty forward reads (it refused reverse under #675).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Direction_aware_ReadEventsAsync_forwards_forward_and_refuses_reverse()
|
public async Task Direction_aware_ReadEventsAsync_is_empty_in_both_directions()
|
||||||
{
|
{
|
||||||
IHistorianDataSource source = Source;
|
IHistorianDataSource source = Source;
|
||||||
|
source.SupportsReverseEvents.ShouldBeTrue();
|
||||||
(await source.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 0, reverse: false, Ct))
|
(await source.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 0, reverse: false, Ct))
|
||||||
.Events.ShouldBeEmpty();
|
.Events.ShouldBeEmpty();
|
||||||
await Should.ThrowAsync<NotSupportedException>(() =>
|
var reverse = await source.ReadEventsAsync(null, DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), 0, reverse: true, Ct);
|
||||||
source.ReadEventsAsync(null, DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), 0, reverse: true, Ct));
|
reverse.Events.ShouldBeEmpty();
|
||||||
|
reverse.ContinuationPoint.ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>GetHealthSnapshot reports a fully-disabled (zeroed, disconnected, no-node) shape.</summary>
|
/// <summary>GetHealthSnapshot reports a fully-disabled (zeroed, disconnected, no-node) shape.</summary>
|
||||||
|
|||||||
+4
-1
@@ -54,6 +54,9 @@ public sealed class FakeHistorianGatewayClient : IHistorianGatewayClient
|
|||||||
public int LastReadEventsMaxEvents;
|
public int LastReadEventsMaxEvents;
|
||||||
public int ReadEventsCallCount;
|
public int ReadEventsCallCount;
|
||||||
|
|
||||||
|
/// <summary>When set, ReadEvents serves <c>EventsSource(start, end)</c> instead of <see cref="Events"/>.</summary>
|
||||||
|
public Func<DateTime, DateTime, IEnumerable<HistorianEvent>>? EventsSource;
|
||||||
|
|
||||||
/// <summary>Thrown (deferred to first enumeration) by every read method when set.</summary>
|
/// <summary>Thrown (deferred to first enumeration) by every read method when set.</summary>
|
||||||
public Exception? ThrowOnRead;
|
public Exception? ThrowOnRead;
|
||||||
|
|
||||||
@@ -187,7 +190,7 @@ public sealed class FakeHistorianGatewayClient : IHistorianGatewayClient
|
|||||||
LastReadEventsEndUtc = endUtc;
|
LastReadEventsEndUtc = endUtc;
|
||||||
LastReadEventsMaxEvents = maxEvents;
|
LastReadEventsMaxEvents = maxEvents;
|
||||||
ReadEventsCallCount++;
|
ReadEventsCallCount++;
|
||||||
return ToAsyncStream(Events, ThrowOnRead, ct);
|
return ToAsyncStream(EventsSource is null ? Events : EventsSource(startUtc, endUtc).ToList(), ThrowOnRead, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<WriteAck> WriteLiveValuesAsync(
|
public Task<WriteAck> WriteLiveValuesAsync(
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ public sealed class GatewayEventBoundsTests
|
|||||||
{
|
{
|
||||||
var fake = new FakeHistorianGatewayClient();
|
var fake = new FakeHistorianGatewayClient();
|
||||||
IHistorianDataSource ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
IHistorianDataSource ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||||||
ds.SupportsReverseEvents.ShouldBeFalse("the server must keep end-only reads forward on the default backend");
|
ds.SupportsReverseEvents.ShouldBeFalse("the server must walk reverse reads back from forward reads on the default backend (#691.4)");
|
||||||
|
|
||||||
await Should.ThrowAsync<NotSupportedException>(() =>
|
await Should.ThrowAsync<NotSupportedException>(() =>
|
||||||
ds.ReadEventsAsync("Pump1", DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), 10, reverse: true, Ct));
|
ds.ReadEventsAsync("Pump1", DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), 10, reverse: true, Ct));
|
||||||
|
|||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
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;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #691.4 review: events read uncapped (and walked back newest-first) through the REAL
|
||||||
|
/// <see cref="GatewayHistorianDataSource"/> over a gateway-shaped fake — inclusive <c>[start, end]</c>, ordered by event time.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GatewayReverseEventsTests
|
||||||
|
{
|
||||||
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
private static readonly DateTime T0 = new(2026, 10, 12, 18, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private static FakeHistorianGatewayClient Store(int count)
|
||||||
|
{
|
||||||
|
var stored = Enumerable.Range(0, count)
|
||||||
|
.Select(i => new HistorianEvent { Id = $"e{i:0000}", Type = "Alarm", EventTime = Timestamp.FromDateTime(T0.AddSeconds(i)) })
|
||||||
|
.ToList();
|
||||||
|
return new FakeHistorianGatewayClient
|
||||||
|
{
|
||||||
|
EventsSource = (start, end) => stored.Where(e => e.EventTime.ToDateTime() >= start && e.EventTime.ToDateTime() <= end),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>NumValuesPerNode = 0</c> reaches the source as <c>int.MaxValue</c>; the result list used to be pre-sized to it
|
||||||
|
/// ("Array dimensions exceeded") — every uncapped Gateway events read failed and counted as a backend failure.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task An_uncapped_forward_events_read_is_served()
|
||||||
|
{
|
||||||
|
var fake = Store(30);
|
||||||
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||||||
|
|
||||||
|
var r = await ds.ReadEventsAsync(null, T0, T0.AddMinutes(1), int.MaxValue, Ct);
|
||||||
|
|
||||||
|
r.Events.Count.ShouldBe(30);
|
||||||
|
r.ContinuationPoint.ShouldBeNull();
|
||||||
|
ds.GetHealthSnapshot().TotalFailures.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_uncapped_reverse_events_read_is_one_forward_read_newest_first()
|
||||||
|
{
|
||||||
|
var fake = Store(30);
|
||||||
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||||||
|
|
||||||
|
var page = await ReverseEventsReader.ReadAsync(ds, null, hi: T0.AddSeconds(25), lo: T0.AddSeconds(5), want: 0,
|
||||||
|
maxTieCluster: 65536, nowUtc: T0.AddHours(1), hint: default, Ct);
|
||||||
|
|
||||||
|
page.Events.Select(e => e.EventId).ShouldBe(Enumerable.Range(6, 20).Reverse().Select(i => $"e{i:0000}"));
|
||||||
|
fake.ReadEventsCallCount.ShouldBe(1);
|
||||||
|
ds.GetHealthSnapshot().TotalFailures.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_capped_reverse_events_read_walked_back_over_the_gateway_returns_the_latest_newest_first()
|
||||||
|
{
|
||||||
|
var fake = Store(300);
|
||||||
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||||||
|
|
||||||
|
var page = await ReverseEventsReader.ReadAsync(ds, null, hi: T0.AddSeconds(200), lo: T0.AddSeconds(50), want: 10,
|
||||||
|
maxTieCluster: 65536, nowUtc: T0.AddHours(1), hint: default, Ct);
|
||||||
|
|
||||||
|
page.Events.Select(e => e.EventId).ShouldBe(Enumerable.Range(191, 10).Reverse().Select(i => $"e{i:0000}"));
|
||||||
|
fake.LastReadEventsStartUtc.ShouldBeLessThanOrEqualTo(fake.LastReadEventsEndUtc);
|
||||||
|
ds.GetHealthSnapshot().TotalFailures.ShouldBe(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
-28
@@ -333,12 +333,16 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task A_backend_without_reverse_support_answers_BadHistoryOperationUnsupported_not_the_earliest_events()
|
public async Task A_backend_without_reverse_support_walks_a_reverse_read_back_never_answering_the_earliest_events()
|
||||||
{
|
{
|
||||||
|
// #691.4 (was refused BadHistoryOperationUnsupported since #675): served newest-first from forward reads.
|
||||||
var (host, nm, node, src) = await SetupAsync(Distinct(10));
|
var (host, nm, node, src) = await SetupAsync(Distinct(10));
|
||||||
nm.HistorianDataSource = new ForwardOnlyEventSource(src);
|
var fwd = new ForwardOnlyEventSource(src);
|
||||||
ReadEvents(nm, node, max: 3, inboundCp: null, end: Early, start: Late).Error.StatusCode.Code
|
nm.HistorianDataSource = fwd;
|
||||||
.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
var r = ReadEvents(nm, node, max: 3, inboundCp: null, end: Early, start: Late);
|
||||||
|
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||||
|
r.Ids.ShouldBe(["e0009", "e0008", "e0007"]);
|
||||||
|
r.Cp.ShouldNotBeNull();
|
||||||
// Forward reads on the same backend are unaffected.
|
// Forward reads on the same backend are unaffected.
|
||||||
DrainAsync(nm, node, max: 4).ShouldBe(src.All.Select(e => e.EventId));
|
DrainAsync(nm, node, max: 4).ShouldBe(src.All.Select(e => e.EventId));
|
||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
@@ -349,30 +353,133 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
|||||||
|
|
||||||
// --- #675 review follow-ups: the reverse-events capability ------------------------------------------------
|
// --- #675 review follow-ups: the reverse-events capability ------------------------------------------------
|
||||||
|
|
||||||
|
// --- #691.4: reverse events on a backend that reads forward only (the Gateway) are walked back ------------------
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task End_only_capped_read_on_a_backend_without_reverse_support_pages_forward_as_before()
|
public async Task End_only_capped_read_on_a_forward_only_backend_pages_backward_from_EndTime()
|
||||||
{
|
{
|
||||||
// Gateway (the default backend) cannot read newest-first: an end-only read keeps its pre-#675 forward
|
// Part 11 (as raw, #686): the latest events at or before EndTime, newest first — on Gateway this used to keep its
|
||||||
// [MinValue, EndTime) meaning there rather than turning into a refused reverse read.
|
// pre-#675 forward [MinValue, EndTime) meaning, i.e. the OLDEST events. The backend is never sent a reverse read.
|
||||||
var (host, nm, node, src) = await SetupAsync(Distinct(25));
|
var (host, nm, node, _) = await SetupAsync([]);
|
||||||
var fwd = new NonReverseSource(src);
|
var gw = new GatewayShapedEventSource(Distinct(25));
|
||||||
nm.HistorianDataSource = fwd;
|
nm.HistorianDataSource = gw;
|
||||||
var ids = DrainAsync(nm, node, max: 10, start: DateTime.MinValue, end: Epoch.AddSeconds(20));
|
var ids = DrainAsync(nm, node, max: 10, start: DateTime.MinValue, end: Epoch.AddSeconds(20));
|
||||||
ids.ShouldBe(Enumerable.Range(0, 20).Select(i => $"e{i:0000}"));
|
ids.ShouldBe(Enumerable.Range(0, 21).Reverse().Select(i => $"e{i:0000}"));
|
||||||
fwd.ReverseCalls.ShouldBe(0);
|
gw.ReverseCalls.ShouldBe(0);
|
||||||
|
gw.BadRequests.ShouldBe(0);
|
||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Explicit_reverse_read_on_a_backend_without_reverse_support_is_refused_without_calling_it()
|
public async Task Explicit_reverse_read_on_a_forward_only_backend_pages_the_reverse_window_newest_first()
|
||||||
{
|
{
|
||||||
var (host, nm, node, src) = await SetupAsync(Distinct(10));
|
// (EndTime, StartTime]: StartTime inclusive, EndTime exclusive.
|
||||||
var fwd = new NonReverseSource(src);
|
var (host, nm, node, _) = await SetupAsync([]);
|
||||||
nm.HistorianDataSource = fwd;
|
var gw = new GatewayShapedEventSource(Distinct(30));
|
||||||
ReadEvents(nm, node, max: 3, inboundCp: null, end: Early, start: Late).Error.StatusCode.Code
|
nm.HistorianDataSource = gw;
|
||||||
.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
var ids = DrainAsync(nm, node, max: 4, start: Epoch.AddSeconds(25), end: Epoch.AddSeconds(5));
|
||||||
fwd.ReverseCalls.ShouldBe(0, "the capability is checked BEFORE the source is called");
|
ids.ShouldBe(Enumerable.Range(6, 20).Reverse().Select(i => $"e{i:0000}"));
|
||||||
fwd.ForwardCalls.ShouldBe(0);
|
gw.ReverseCalls.ShouldBe(0);
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reverse_ties_on_a_forward_only_backend_page_in_a_stable_order_although_the_backend_shuffles_them()
|
||||||
|
{
|
||||||
|
// The gateway orders events by EventTimeUtc only, so ties come back in any order — this fake shuffles them on every
|
||||||
|
// call. The walk sorts ties by EventId (descending, newest-first) on every read, and the oversized-cluster over-fetch
|
||||||
|
// does the same, so a boundary-tie skip always lines up. Cap 3 cuts through a 7-way cluster.
|
||||||
|
var events = new List<HistoricalEvent> { Ev("a0", 0) };
|
||||||
|
for (var i = 0; i < 7; i++) events.Add(Ev($"t{i}", 5));
|
||||||
|
events.Add(Ev("z0", 9));
|
||||||
|
var (host, nm, node, _) = await SetupAsync([]);
|
||||||
|
nm.HistorianDataSource = new GatewayShapedEventSource(events, shuffleSeed: 7);
|
||||||
|
DrainAsync(nm, node, max: 3, start: DateTime.MinValue, end: Epoch.AddSeconds(30)).ShouldBe(
|
||||||
|
["z0", "t6", "t5", "t4", "t3", "t2", "t1", "t0", "a0"]);
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Uncapped_reverse_read_on_a_forward_only_backend_is_one_forward_read()
|
||||||
|
{
|
||||||
|
var (host, nm, node, _) = await SetupAsync([]);
|
||||||
|
var gw = new GatewayShapedEventSource(Distinct(30));
|
||||||
|
nm.HistorianDataSource = gw;
|
||||||
|
var r = ReadEvents(nm, node, max: 0, inboundCp: null, end: Epoch.AddSeconds(5), start: Epoch.AddSeconds(25));
|
||||||
|
r.Ids.ShouldBe(Enumerable.Range(6, 20).Reverse().Select(i => $"e{i:0000}"));
|
||||||
|
r.Cp.ShouldBeNull();
|
||||||
|
gw.ForwardCalls.ShouldBe(1);
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reverse_paging_on_a_forward_only_backend_is_exact_on_random_data()
|
||||||
|
{
|
||||||
|
var (host, nm, node, _) = await SetupAsync([]);
|
||||||
|
for (var seed = 0; seed < 80; seed++)
|
||||||
|
{
|
||||||
|
var rng = new Random(seed);
|
||||||
|
var events = new List<HistoricalEvent>();
|
||||||
|
var t = Epoch.Ticks;
|
||||||
|
var n = rng.Next(1, 120);
|
||||||
|
while (events.Count < n)
|
||||||
|
{
|
||||||
|
t += rng.Next(4) switch
|
||||||
|
{
|
||||||
|
0 => rng.Next(1, 10),
|
||||||
|
1 => rng.Next(1, 5) * TimeSpan.TicksPerMillisecond,
|
||||||
|
2 => rng.Next(1, 90) * TimeSpan.TicksPerMinute,
|
||||||
|
_ => rng.Next(1, 2000) * TimeSpan.TicksPerMillisecond,
|
||||||
|
};
|
||||||
|
var ties = rng.Next(5) == 0 ? rng.Next(2, 8) : 1;
|
||||||
|
for (var k = 0; k < ties; k++)
|
||||||
|
{
|
||||||
|
var at = new DateTime(t, DateTimeKind.Utc);
|
||||||
|
events.Add(new HistoricalEvent($"{Guid.NewGuid():N}", "src", at, at, "m", 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nm.HistorianDataSource = new GatewayShapedEventSource(events, shuffleSeed: seed);
|
||||||
|
nm.MaxTieClusterOverfetch = rng.Next(2) == 0 ? 8 : 65536;
|
||||||
|
var max = (uint)rng.Next(1, 9);
|
||||||
|
var end = new DateTime(t + rng.Next(0, 3) * TimeSpan.TicksPerSecond, DateTimeKind.Utc);
|
||||||
|
var expected = events.OrderByDescending(e => e.EventTimeUtc).ThenByDescending(e => e.EventId, StringComparer.Ordinal)
|
||||||
|
.Select(e => e.EventId).ToList();
|
||||||
|
|
||||||
|
// The walk refuses more than MaxTieClusterOverfetch events within ONE MILLISECOND (#691.4 re-review): with the
|
||||||
|
// small budget a dense sub-millisecond burst is a legitimate BadTooManyOperations — and nothing else is.
|
||||||
|
var times = events.Select(e => e.EventTimeUtc.Ticks).Order().ToList();
|
||||||
|
var msMax = 0;
|
||||||
|
for (int i = 0, j = 0; i < times.Count; i++)
|
||||||
|
{
|
||||||
|
while (times[i] - times[j] >= TimeSpan.TicksPerMillisecond) j++;
|
||||||
|
msMax = Math.Max(msMax, i - j + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
var all = new List<string>();
|
||||||
|
byte[]? cp = null;
|
||||||
|
var pages = 0;
|
||||||
|
var refused = false;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
var r = ReadEvents(nm, node, max, cp, end, DateTime.MinValue);
|
||||||
|
if (r.Error.StatusCode.Code == StatusCodes.BadTooManyOperations && msMax > nm.MaxTieClusterOverfetch)
|
||||||
|
{
|
||||||
|
refused = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good, $"seed {seed}, ms max {msMax}");
|
||||||
|
all.AddRange(r.Ids);
|
||||||
|
cp = r.Cp;
|
||||||
|
(++pages).ShouldBeLessThan(2000);
|
||||||
|
}
|
||||||
|
while (cp is not null);
|
||||||
|
|
||||||
|
if (refused) all.ShouldBe(expected.Take(all.Count), $"seed {seed}: what came before the refusal");
|
||||||
|
else all.ShouldBe(expected, $"seed {seed}, N = {max}");
|
||||||
|
}
|
||||||
|
|
||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,29 +498,39 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
|||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Declares the reverse overload (so a call would be visible) but reports no reverse capability.</summary>
|
/// <summary>
|
||||||
private sealed class NonReverseSource(IHistorianDataSource inner) : IHistorianDataSource
|
/// Shaped like the Gateway's SQL event read (#691.4): forward only (<see cref="SupportsReverseEvents"/> false — a
|
||||||
|
/// reverse call is counted and refused), <c>[start, end]</c> INCLUSIVE, ordered by event time only with ties in an
|
||||||
|
/// arbitrary order (shuffled on every call when a seed is given), capped with a truncation flag.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class GatewayShapedEventSource(List<HistoricalEvent> events, int? shuffleSeed = null) : IHistorianDataSource
|
||||||
{
|
{
|
||||||
|
private readonly Random? _rng = shuffleSeed is { } s ? new Random(s) : null;
|
||||||
public int ReverseCalls { get; private set; }
|
public int ReverseCalls { get; private set; }
|
||||||
public int ForwardCalls { get; private set; }
|
public int ForwardCalls { get; private set; }
|
||||||
|
public int BadRequests { get; private set; }
|
||||||
public bool SupportsReverseEvents => false;
|
public bool SupportsReverseEvents => false;
|
||||||
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct)
|
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct)
|
||||||
{
|
{
|
||||||
ForwardCalls++;
|
ForwardCalls++;
|
||||||
return inner.ReadEventsAsync(n, s, e, m, ct);
|
if (s > e || s < DateTime.UnixEpoch) BadRequests++;
|
||||||
|
var window = events.Where(x => x.EventTimeUtc >= s && x.EventTimeUtc <= e)
|
||||||
|
.OrderBy(x => x.EventTimeUtc).ThenBy(_ => _rng?.Next() ?? 0).ToList();
|
||||||
|
var cap = m <= 0 ? int.MaxValue : m;
|
||||||
|
return Task.FromResult(new HistoricalEventsResult(window.Take(cap).ToList(), window.Count > cap ? new byte[] { 1 } : null));
|
||||||
}
|
}
|
||||||
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, bool reverse, CancellationToken ct)
|
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, bool reverse, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (reverse) { ReverseCalls++; return inner.ReadEventsAsync(n, s, e, m, true, ct); }
|
if (reverse) { ReverseCalls++; throw new NotSupportedException("forward only"); }
|
||||||
return ReadEventsAsync(n, s, e, m, ct);
|
return ReadEventsAsync(n, s, e, m, ct);
|
||||||
}
|
}
|
||||||
public Task<HistorianRead> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
|
public Task<HistorianRead> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
|
||||||
inner.ReadRawAsync(r, s, e, m, ct);
|
NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct);
|
||||||
public Task<HistorianRead> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) =>
|
public Task<HistorianRead> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) =>
|
||||||
inner.ReadProcessedAsync(r, s, e, i, a, ct);
|
NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct);
|
||||||
public Task<HistorianRead> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) =>
|
public Task<HistorianRead> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) =>
|
||||||
inner.ReadAtTimeAsync(r, t, ct);
|
NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct);
|
||||||
public HistorianHealthSnapshot GetHealthSnapshot() => inner.GetHealthSnapshot();
|
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
|
||||||
public void Dispose() { }
|
public void Dispose() { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+163
-3
@@ -650,6 +650,156 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
|||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #689: a paged end-only read on a dense tag sizes each segment from the PAGE (N), not from
|
||||||
|
/// <c>MaxTieClusterOverfetch</c>. Live (AVEVA, 1 Hz, N = 3): from about page 5 every page read 32,770 rows and took
|
||||||
|
/// 2–5 s to return 2 rows, because the walk re-aimed its width at half the 65,536-row tie budget and carried it.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Raw_reverse_paging_on_a_dense_forward_only_tag_reads_about_the_page_size_per_page()
|
||||||
|
{
|
||||||
|
// 100,000 samples 100 ms apart (~2.8 h): far more than the default tie budget would ever let a page read.
|
||||||
|
var series = Enumerable.Range(0, 100_000)
|
||||||
|
.Select(i => { var t = Epoch.AddMilliseconds(100L * i); return new DataValueSnapshot((double)i, StatusCodes.Good, t, t); })
|
||||||
|
.ToArray();
|
||||||
|
var source = new ForwardOnlyHistorianDataSource(series);
|
||||||
|
var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdDense689");
|
||||||
|
nm.MaxTieClusterOverfetch.ShouldBe(65536, "the default budget — the one the live gate ran with");
|
||||||
|
var end = Epoch.AddMilliseconds(100L * 99_999);
|
||||||
|
|
||||||
|
byte[]? cp = null;
|
||||||
|
var expected = 99_999;
|
||||||
|
for (var page = 1; page <= 20; page++)
|
||||||
|
{
|
||||||
|
source.ResetCounts();
|
||||||
|
var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, end, max: 3, inboundCp: cp);
|
||||||
|
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||||
|
values.ShouldNotBeEmpty();
|
||||||
|
values[^1].ShouldBe((double)(expected - 2), $"page {page} continues exactly");
|
||||||
|
expected = (int)values[^1];
|
||||||
|
next.ShouldNotBeNull();
|
||||||
|
cp = next;
|
||||||
|
|
||||||
|
// The discriminating counts: rows read and the largest cap asked for, per page. Page 1 has no density yet and
|
||||||
|
// may read one discovery segment (a minute here, 600 rows); every page after it is sized from N.
|
||||||
|
var rowsBound = page == 1 ? ReverseWalk.DiscoveryRows + 2 : 64;
|
||||||
|
source.RowsReturned.ShouldBeLessThanOrEqualTo(rowsBound, $"page {page} read {source.RowsReturned} rows to return {values.Count}");
|
||||||
|
source.MaxCapRequested.ShouldBeLessThanOrEqualTo((uint)rowsBound, $"page {page} asked for {source.MaxCapRequested} rows");
|
||||||
|
source.ReadCount.ShouldBeLessThanOrEqualTo(3, $"page {page}");
|
||||||
|
}
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #689/#691: randomized end-to-end paging of end-only reads through the node manager on a forward-only backend —
|
||||||
|
/// bursts, gaps, sub-millisecond stamps and tie clusters (some larger than the page) — the union of the pages is
|
||||||
|
/// every sample exactly once, newest first, whatever N and the tie budget.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Raw_reverse_paging_on_a_forward_only_backend_is_exact_on_random_data()
|
||||||
|
{
|
||||||
|
var (host, nm, nodeId) = await BootForwardOnlyAsync(new ForwardOnlyHistorianDataSource([]), "FwdFuzz");
|
||||||
|
for (var seed = 0; seed < 150; seed++)
|
||||||
|
{
|
||||||
|
var rng = new Random(seed);
|
||||||
|
var series = new List<DataValueSnapshot>();
|
||||||
|
var t = Epoch.Ticks;
|
||||||
|
var v = 0;
|
||||||
|
var count = rng.Next(1, 300);
|
||||||
|
while (series.Count < count)
|
||||||
|
{
|
||||||
|
t += rng.Next(5) switch
|
||||||
|
{
|
||||||
|
0 => rng.Next(1, 10),
|
||||||
|
1 => rng.Next(1, 5) * TimeSpan.TicksPerMillisecond,
|
||||||
|
2 => rng.Next(1, 90) * TimeSpan.TicksPerMinute,
|
||||||
|
_ => rng.Next(1, 2000) * TimeSpan.TicksPerMillisecond,
|
||||||
|
};
|
||||||
|
var ties = rng.Next(6) == 0 ? rng.Next(2, 7) : 1;
|
||||||
|
for (var k = 0; k < ties; k++)
|
||||||
|
{
|
||||||
|
var ts = new DateTime(t, DateTimeKind.Utc);
|
||||||
|
series.Add(new DataValueSnapshot((double)v++, StatusCodes.Good, ts, ts));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No fabricated start row: the Gateway source drops it (#687) — the node manager's tie-cluster point read relies on
|
||||||
|
// that. The walk itself is fuzzed WITH the row in ReverseWalkTests.
|
||||||
|
nm.HistorianDataSource = new ForwardOnlyHistorianDataSource(series, emitBoundaryRow: false);
|
||||||
|
nm.MaxTieClusterOverfetch = rng.Next(2) == 0 ? 8 : 65536;
|
||||||
|
var max = (uint)rng.Next(1, 12);
|
||||||
|
var end = new DateTime(t + rng.Next(0, 3) * TimeSpan.TicksPerSecond, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
// The walk refuses more than MaxTieClusterOverfetch samples within ONE MILLISECOND (#691.4 re-review), so with
|
||||||
|
// the small budget a dense sub-millisecond burst is a legitimate BadTooManyOperations — and nothing else is.
|
||||||
|
var msMax = 0;
|
||||||
|
for (int i = 0, j = 0; i < series.Count; i++)
|
||||||
|
{
|
||||||
|
while (series[i].SourceTimestampUtc!.Value.Ticks - series[j].SourceTimestampUtc!.Value.Ticks >= TimeSpan.TicksPerMillisecond) j++;
|
||||||
|
msMax = Math.Max(msMax, i - j + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
var collected = new List<double>();
|
||||||
|
byte[]? cp = null;
|
||||||
|
var pages = 0;
|
||||||
|
var refused = false;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, end, max, inboundCp: cp);
|
||||||
|
if (error.StatusCode.Code == StatusCodes.BadTooManyOperations && msMax > nm.MaxTieClusterOverfetch)
|
||||||
|
{
|
||||||
|
refused = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
error.StatusCode.Code.ShouldBe(StatusCodes.Good, $"seed {seed}, N = {max}, ms max {msMax}");
|
||||||
|
collected.AddRange(values);
|
||||||
|
cp = next;
|
||||||
|
(++pages).ShouldBeLessThan(2000, "paging must terminate, not loop");
|
||||||
|
}
|
||||||
|
while (cp is not null);
|
||||||
|
|
||||||
|
var expected = Enumerable.Range(0, series.Count).Reverse().Select(i => (double)i).ToList();
|
||||||
|
if (refused) collected.ShouldBe(expected.Take(collected.Count), $"seed {seed}: what came before the refusal");
|
||||||
|
else collected.ShouldBe(expected, $"seed {seed}, N = {max}");
|
||||||
|
}
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #691: the last pages of a deep reverse page-through no longer each walk down to 1970 (live: ~14–16 reads, ~2.5 s
|
||||||
|
/// per page). The stretch a page proved empty below its oldest row rides the continuation point, and a page that runs
|
||||||
|
/// out of rows asks once for the whole remainder — so every page here is a couple of reads.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Raw_reverse_page_through_on_a_forward_only_backend_does_not_rewalk_to_the_epoch_on_each_last_page()
|
||||||
|
{
|
||||||
|
var source = new ForwardOnlyHistorianDataSource(MakeSeries(count: 25, stepSeconds: 1));
|
||||||
|
var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdDeep691");
|
||||||
|
nm.Clock = new FixedClock(Epoch.AddHours(1));
|
||||||
|
|
||||||
|
var collected = new List<double>();
|
||||||
|
byte[]? cp = null;
|
||||||
|
var page = 0;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
source.ResetCounts();
|
||||||
|
var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddHours(1), max: 10, inboundCp: cp);
|
||||||
|
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||||
|
collected.AddRange(values);
|
||||||
|
cp = next;
|
||||||
|
page++;
|
||||||
|
source.ReadCount.ShouldBeLessThanOrEqualTo(page == 1 ? 4 : 1, $"page {page}");
|
||||||
|
}
|
||||||
|
while (cp is not null && page < 10);
|
||||||
|
|
||||||
|
collected.ShouldBe(Enumerable.Range(0, 25).Reverse().Select(i => (double)i));
|
||||||
|
source.MinRequestedStart.ShouldBeGreaterThanOrEqualTo(DateTime.UnixEpoch);
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>#686 review: a reverse read from <c>StartTime = DateTime.MaxValue</c> walks back from now + 1 day, not from
|
/// <summary>#686 review: a reverse read from <c>StartTime = DateTime.MaxValue</c> walks back from now + 1 day, not from
|
||||||
/// year 9999.</summary>
|
/// year 9999.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -978,9 +1128,15 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
|||||||
/// <summary>Reads whose row count equalled the cap (a full, possibly truncated, read).</summary>
|
/// <summary>Reads whose row count equalled the cap (a full, possibly truncated, read).</summary>
|
||||||
public int FullReads { get; private set; }
|
public int FullReads { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Rows returned across every read since the last <see cref="ResetCounts"/> (#689).</summary>
|
||||||
|
public long RowsReturned { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>The largest <c>maxValuesPerNode</c> asked for since the last <see cref="ResetCounts"/> (#689).</summary>
|
||||||
|
public uint MaxCapRequested { get; private set; }
|
||||||
|
|
||||||
public void ResetCounts()
|
public void ResetCounts()
|
||||||
{
|
{
|
||||||
lock (_gate) FullReads = 0;
|
lock (_gate) { FullReads = 0; RowsReturned = 0; MaxCapRequested = 0; }
|
||||||
Interlocked.Exchange(ref _readCount, 0);
|
Interlocked.Exchange(ref _readCount, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1016,8 +1172,12 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
|||||||
: new DataValueSnapshot(prior.Value, prior.StatusCode, stamp, stamp));
|
: new DataValueSnapshot(prior.Value, prior.StatusCode, stamp, stamp));
|
||||||
rows.AddRange(series.Where(s => Ts(s) >= startUtc && Ts(s) <= endUtc));
|
rows.AddRange(series.Where(s => Ts(s) >= startUtc && Ts(s) <= endUtc));
|
||||||
var page = maxValuesPerNode == 0 ? rows : rows.Take((int)maxValuesPerNode).ToList();
|
var page = maxValuesPerNode == 0 ? rows : rows.Take((int)maxValuesPerNode).ToList();
|
||||||
if (maxValuesPerNode > 0 && page.Count == maxValuesPerNode)
|
lock (_gate)
|
||||||
lock (_gate) FullReads++;
|
{
|
||||||
|
if (maxValuesPerNode > 0 && page.Count == maxValuesPerNode) FullReads++;
|
||||||
|
RowsReturned += page.Count;
|
||||||
|
if (maxValuesPerNode > MaxCapRequested) MaxCapRequested = maxValuesPerNode;
|
||||||
|
}
|
||||||
int seen;
|
int seen;
|
||||||
while (page.Count > (seen = Volatile.Read(ref _maxRowsReturned))
|
while (page.Count > (seen = Volatile.Read(ref _maxRowsReturned))
|
||||||
&& Interlocked.CompareExchange(ref _maxRowsReturned, page.Count, seen) != seen) { }
|
&& Interlocked.CompareExchange(ref _maxRowsReturned, page.Count, seen) != seen) { }
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
using Opc.Ua;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #689 / #691: the reverse walk-back (<see cref="ReverseWalk"/> through <see cref="ReverseRawReader"/>) driven
|
||||||
|
/// directly, without a server: a randomized exactness check against a brute-force answer, and the sizing properties.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReverseWalkTests
|
||||||
|
{
|
||||||
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
private static readonly DateTime T0 = new(2026, 10, 12, 18, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private static DataValueSnapshot At(long ticks, double v) =>
|
||||||
|
new(v, StatusCodes.Good, new DateTime(ticks, DateTimeKind.Utc), new DateTime(ticks, DateTimeKind.Utc));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A randomized walk over AVEVA-shaped forward reads (inclusive end, a fabricated row at the query start — optionally
|
||||||
|
/// stamped at the start rounded UP to the millisecond — counted against the cap): every page is exactly the brute-force
|
||||||
|
/// newest-first answer over <c>(lo, hi]</c>, ties in the backend's forward order reversed, whatever the carried width
|
||||||
|
/// — and a timestamp over the tie budget is the only refusal.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_walked_page_is_exactly_the_newest_N_over_the_window_on_random_data()
|
||||||
|
{
|
||||||
|
var failures = new List<string>();
|
||||||
|
for (var seed = 0; seed < 400; seed++)
|
||||||
|
{
|
||||||
|
var rng = new Random(seed);
|
||||||
|
var series = RandomSeries(rng, out _);
|
||||||
|
// The refusal unit is one millisecond (#691.4 re-review): the most rows inside any 1 ms window.
|
||||||
|
var maxCluster = 0;
|
||||||
|
for (int i = 0, j = 0; i < series.Count; i++)
|
||||||
|
{
|
||||||
|
while (Ts(series[i]) - Ts(series[j]) >= TimeSpan.TicksPerMillisecond) j++;
|
||||||
|
maxCluster = Math.Max(maxCluster, i - j + 1);
|
||||||
|
}
|
||||||
|
var source = new AvevaShapedSource(series, roundUp: rng.Next(2) == 0);
|
||||||
|
var tieBudget = rng.Next(4) switch { 0 => 1, 1 => 3, 2 => 8, _ => 65536 };
|
||||||
|
var want = (uint)rng.Next(1, 40);
|
||||||
|
var span = Ts(series[^1]) - Ts(series[0]);
|
||||||
|
var hi = Ts(series[0]) + (long)(rng.NextDouble() * 1.2 * span) + rng.Next(0, 3);
|
||||||
|
var lo = Ts(series[0]) - TimeSpan.TicksPerSecond + (long)(rng.NextDouble() * (hi - Ts(series[0])) * 0.8);
|
||||||
|
if (rng.Next(5) == 0) lo = 0; // an end-only read
|
||||||
|
var hint = rng.Next(3) switch
|
||||||
|
{
|
||||||
|
0 => default,
|
||||||
|
1 => new ReverseWalkHint(rng.Next(1, 10)),
|
||||||
|
_ => new ReverseWalkHint((long)(rng.NextDouble() * TimeSpan.TicksPerHour)),
|
||||||
|
};
|
||||||
|
if (rng.Next(2) == 0)
|
||||||
|
{
|
||||||
|
// A TRUE empty stretch (#691): between two adjacent distinct timestamps, or from the floor to the first row.
|
||||||
|
var k = rng.Next(series.Count);
|
||||||
|
var from = k == 0 ? (rng.Next(2) == 0 ? 0 : DateTime.UnixEpoch.Ticks) : Ts(series[k - 1]);
|
||||||
|
var to = Ts(series[k]);
|
||||||
|
if (to > from) hint = hint with { EmptyFromTicks = from, EmptyToTicks = to };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page on: each next page resumes just below the previous page's oldest timestamp with the hint it returned —
|
||||||
|
// so the hints the walk itself produces (width, proven-empty stretch) are exercised too.
|
||||||
|
for (var pageNo = 0; pageNo < 6 && hi > Math.Max(lo, DateTime.UnixEpoch.Ticks); pageNo++)
|
||||||
|
{
|
||||||
|
var expected = series.Where(s => Ts(s) > Math.Max(lo, DateTime.UnixEpoch.Ticks) && Ts(s) <= hi)
|
||||||
|
.Select((s, i) => (s, i)).OrderByDescending(x => Ts(x.s)).ThenByDescending(x => x.i)
|
||||||
|
.Take((int)want).Select(x => (double)x.s.Value!).ToList();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var page = await ReverseRawReader.ReadAsync(source, "T", new DateTime(hi, DateTimeKind.Utc),
|
||||||
|
new DateTime(lo, DateTimeKind.Utc), want, tieBudget, T0.AddDays(1), hint, Ct);
|
||||||
|
var got = page.Samples.Select(s => (double)s.Value!).ToList();
|
||||||
|
if (!got.SequenceEqual(expected))
|
||||||
|
{
|
||||||
|
failures.Add($"seed {seed} page {pageNo}: got [{string.Join(",", got)}] expected [{string.Join(",", expected)}]");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page.Hint.WidthTicks <= 0) failures.Add($"seed {seed}: no width carried");
|
||||||
|
if (got.Count < want) break;
|
||||||
|
hint = page.Hint;
|
||||||
|
hi = Ts(page.Samples[^1]) - 1;
|
||||||
|
}
|
||||||
|
catch (HistoryReadLimitExceededException)
|
||||||
|
{
|
||||||
|
if (maxCluster <= tieBudget) failures.Add($"seed {seed}: refused with no millisecond over {tieBudget} rows (max {maxCluster})");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.MaxCap > 2L * Math.Max(Math.Max(tieBudget, ReverseWalk.DiscoveryRows), 2 * want + 16) + 4)
|
||||||
|
failures.Add($"seed {seed}: asked for {source.MaxCap} rows");
|
||||||
|
if (source.BadRequests > 0) failures.Add($"seed {seed}: {source.BadRequests} invalid requests");
|
||||||
|
}
|
||||||
|
|
||||||
|
failures.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #689: the carried width sizes a resumed page from N, not from the tie budget — the live symptom was 32,770 rows
|
||||||
|
/// per page for N = 3 on a 1 Hz tag once the width had grown over a few pages.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_resumed_page_on_a_dense_tag_reads_about_N_rows_with_the_default_tie_budget()
|
||||||
|
{
|
||||||
|
var series = Enumerable.Range(0, 200_000).Select(i => At(T0.Ticks + i * TimeSpan.TicksPerSecond, i)).ToList();
|
||||||
|
var source = new AvevaShapedSource(series);
|
||||||
|
var hi = new DateTime(Ts(series[^1]), DateTimeKind.Utc);
|
||||||
|
var hint = default(ReverseWalkHint);
|
||||||
|
|
||||||
|
for (var page = 1; page <= 30; page++)
|
||||||
|
{
|
||||||
|
source.Reset();
|
||||||
|
var result = await ReverseRawReader.ReadAsync(source, "T", hi, DateTime.MinValue, 3, 65536, hi.AddHours(1), hint, Ct);
|
||||||
|
result.Samples.Count.ShouldBe(3);
|
||||||
|
hint = result.Hint;
|
||||||
|
hi = result.Samples[^1].SourceTimestampUtc!.Value.AddTicks(-1);
|
||||||
|
if (page > 1) source.RowsReturned.ShouldBeLessThanOrEqualTo(30, $"page {page}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #689: a dense tag that stopped long ago. The walk's ×4 growth across the empty stretch overshoots into far more
|
||||||
|
/// rows than one read takes; the truncated read only shows the OLDEST of them, so the walk must not keep re-entering
|
||||||
|
/// that stretch — it searches the gap above the newest row it saw (gallop up, then bisect). Measured: 26 reads.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_dense_tag_below_a_long_empty_stretch_is_found_in_a_bounded_number_of_reads()
|
||||||
|
{
|
||||||
|
// 10 Hz for 3 hours, ending two days before "now".
|
||||||
|
var start = T0.AddDays(-2).AddHours(-3);
|
||||||
|
var series = Enumerable.Range(0, 108_000).Select(i => At(start.Ticks + i * TimeSpan.TicksPerMillisecond * 100, i)).ToList();
|
||||||
|
var source = new AvevaShapedSource(series);
|
||||||
|
|
||||||
|
var page = await ReverseRawReader.ReadAsync(source, "T", T0, DateTime.MinValue, 3, 65536, T0, default(ReverseWalkHint), Ct);
|
||||||
|
|
||||||
|
page.Samples.Select(s => (double)s.Value!).ShouldBe([107_999d, 107_998d, 107_997d]);
|
||||||
|
source.Reads.ShouldBeLessThanOrEqualTo(30);
|
||||||
|
source.RowsReturned.ShouldBeLessThanOrEqualTo(30 * (ReverseWalk.DiscoveryRows + 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #691: the end of a walk proves the rest of the window empty in about one read — after two empty segments the walk
|
||||||
|
/// asks once for the whole remainder down to the floor — instead of one read per ×4 growth step to 1970 (~14 reads).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Running_out_of_rows_proves_the_rest_of_the_window_empty_in_a_few_reads()
|
||||||
|
{
|
||||||
|
var series = Enumerable.Range(0, 5).Select(i => At(T0.Ticks + i * TimeSpan.TicksPerSecond, i)).ToList();
|
||||||
|
var source = new AvevaShapedSource(series);
|
||||||
|
|
||||||
|
var page = await ReverseRawReader.ReadAsync(source, "T", T0.AddSeconds(4), DateTime.MinValue, 10, 65536, T0,
|
||||||
|
default(ReverseWalkHint), Ct);
|
||||||
|
|
||||||
|
page.Samples.Select(s => (double)s.Value!).ShouldBe([4d, 3d, 2d, 1d, 0d]);
|
||||||
|
source.Reads.ShouldBeLessThanOrEqualTo(4, "one segment holding the rows, two empty ones, then the remainder at once");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #691: a page's last complete read proves the stretch below its oldest row empty, and the continuation carries it:
|
||||||
|
/// the next page steps straight over it to the floor instead of walking it again.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_resumed_page_steps_over_the_stretch_the_previous_page_proved_empty()
|
||||||
|
{
|
||||||
|
var series = Enumerable.Range(0, 25).Select(i => At(T0.Ticks + i * TimeSpan.TicksPerSecond, i)).ToList();
|
||||||
|
var source = new AvevaShapedSource(series);
|
||||||
|
var now = T0.AddHours(1);
|
||||||
|
|
||||||
|
// Page 1: an hour of nothing above the rows; the remainder probe reads all 25 and proves (1970, row 0) empty.
|
||||||
|
var p1 = await ReverseRawReader.ReadAsync(source, "T", now, DateTime.MinValue, 10, 65536, now, default(ReverseWalkHint), Ct);
|
||||||
|
p1.Samples.Select(s => (double)s.Value!).ShouldBe(Enumerable.Range(15, 10).Reverse().Select(i => (double)i));
|
||||||
|
p1.Hint.HasEmptyStretch.ShouldBeTrue();
|
||||||
|
p1.Hint.EmptyFromTicks.ShouldBeLessThanOrEqualTo(DateTime.UnixEpoch.Ticks);
|
||||||
|
p1.Hint.EmptyToTicks.ShouldBe(Ts(series[0]));
|
||||||
|
|
||||||
|
var hint = p1.Hint;
|
||||||
|
var hi = p1.Samples[^1].SourceTimestampUtc!.Value.AddTicks(-1);
|
||||||
|
for (var page = 2; page <= 3; page++)
|
||||||
|
{
|
||||||
|
source.Reset();
|
||||||
|
var p = await ReverseRawReader.ReadAsync(source, "T", hi, DateTime.MinValue, 10, 65536, now, hint, Ct);
|
||||||
|
p.Samples.Count.ShouldBe(page == 2 ? 10 : 5);
|
||||||
|
source.Reads.ShouldBe(1, $"page {page} reads its rows and steps over the proven-empty stretch to the floor");
|
||||||
|
hint = p.Hint;
|
||||||
|
hi = p.Samples[^1].SourceTimestampUtc!.Value.AddTicks(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #691.4 review: a burst just over the discovery read, sub-millisecond spaced, ending 100 days ago. The search for
|
||||||
|
/// the top of the burst above the truncated read never re-sends an identical request (halving below the 1 ms request
|
||||||
|
/// granularity did), and gallops up from the truncated read's newest row instead of bisecting the whole 100-day gap
|
||||||
|
/// down to the millisecond (was 46 reads).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Bisecting_above_a_truncated_read_never_resends_an_identical_request()
|
||||||
|
{
|
||||||
|
var end = T0.AddDays(-100);
|
||||||
|
var count = ReverseWalk.DiscoveryRows + 2;
|
||||||
|
var series = Enumerable.Range(0, count).Select(i => At(end.Ticks - (count - 1 - i) * 1_000, i)).ToList(); // 0.1 ms apart
|
||||||
|
var source = new AvevaShapedSource(series);
|
||||||
|
|
||||||
|
var page = await ReverseRawReader.ReadAsync(source, "T", T0, DateTime.MinValue, 3, 65536, T0, default(ReverseWalkHint), Ct);
|
||||||
|
|
||||||
|
page.Samples.Select(s => (double)s.Value!).ShouldBe([count - 1d, count - 2d, count - 3d]);
|
||||||
|
source.RepeatedRequests.ShouldBe(0, string.Join("\n", source.Log));
|
||||||
|
source.Reads.ShouldBeLessThanOrEqualTo(22, string.Join("\n", source.Log));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #691.4 re-review (S8): a tie cluster under a carried width. 30 rows at one whole-millisecond timestamp 0.3 s below
|
||||||
|
/// <c>hi</c>, nothing above, N = 1, a carried 1 s width. The truncated read of the cluster left the width at one tick;
|
||||||
|
/// once the gap above it was under two milliseconds nothing floored it, so segHi stepped down one tick per read —
|
||||||
|
/// 10,006 reads of the same millisecond (28 before the gallop).
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(30, 1, 0.3, 1.0)]
|
||||||
|
[InlineData(34, 1, 0.52, 0.48)]
|
||||||
|
[InlineData(25, 1, 1.2, 1.0)]
|
||||||
|
[InlineData(40, 3, 3.5, 2.0)]
|
||||||
|
public async Task A_tie_cluster_under_a_carried_width_is_read_in_a_bounded_number_of_reads(
|
||||||
|
int ties, int want, double secondsBelow, double hintSeconds)
|
||||||
|
{
|
||||||
|
var at = T0.Ticks - (long)(secondsBelow * TimeSpan.TicksPerSecond);
|
||||||
|
at -= at % TimeSpan.TicksPerMillisecond;
|
||||||
|
var series = Enumerable.Range(0, ties).Select(i => At(at, i)).ToList();
|
||||||
|
var source = new AvevaShapedSource(series);
|
||||||
|
|
||||||
|
var page = await ReverseRawReader.ReadAsync(source, "T", T0, DateTime.MinValue, (uint)want, 65536, T0,
|
||||||
|
new ReverseWalkHint((long)(hintSeconds * TimeSpan.TicksPerSecond)), Ct);
|
||||||
|
|
||||||
|
page.Samples.Select(s => (double)s.Value!).ShouldBe(Enumerable.Range(ties - want, want).Reverse().Select(i => (double)i));
|
||||||
|
source.Reads.ShouldBeLessThanOrEqualTo(30, string.Join("\n", source.Log.Take(40)));
|
||||||
|
source.RepeatedRequests.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long Ts(DataValueSnapshot s) => s.SourceTimestampUtc!.Value.Ticks;
|
||||||
|
|
||||||
|
/// <summary>Random ascending series: bursts, gaps, sub-millisecond stamps and tie clusters.</summary>
|
||||||
|
private static List<DataValueSnapshot> RandomSeries(Random rng, out int maxCluster)
|
||||||
|
{
|
||||||
|
var list = new List<DataValueSnapshot>();
|
||||||
|
var t = T0.Ticks - rng.Next(0, 3) * TimeSpan.TicksPerDay;
|
||||||
|
var count = rng.Next(1, 400);
|
||||||
|
maxCluster = 1;
|
||||||
|
var v = 0;
|
||||||
|
while (list.Count < count)
|
||||||
|
{
|
||||||
|
t += rng.Next(6) switch
|
||||||
|
{
|
||||||
|
0 => rng.Next(1, 10), // sub-millisecond
|
||||||
|
1 => rng.Next(1, 5) * TimeSpan.TicksPerMillisecond, // dense
|
||||||
|
2 => rng.Next(1, 3) * TimeSpan.TicksPerSecond,
|
||||||
|
3 => rng.Next(1, 120) * TimeSpan.TicksPerMinute, // a gap
|
||||||
|
_ => rng.Next(1, 1000) * TimeSpan.TicksPerMillisecond,
|
||||||
|
};
|
||||||
|
var ties = rng.Next(8) == 0 ? rng.Next(2, 12) : 1;
|
||||||
|
maxCluster = Math.Max(maxCluster, ties);
|
||||||
|
for (var k = 0; k < ties; k++) list.Add(At(t, v++));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>AVEVA-shaped forward-only raw source (see the fuzz test's summary).</summary>
|
||||||
|
private sealed class AvevaShapedSource(IReadOnlyList<DataValueSnapshot> series, bool roundUp = false) : IHistorianDataSource
|
||||||
|
{
|
||||||
|
public long MaxCap { get; private set; }
|
||||||
|
public long RowsReturned { get; private set; }
|
||||||
|
public int Reads { get; private set; }
|
||||||
|
public int BadRequests { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Requests that repeated an earlier request exactly (start, end, cap) — read-only, so pure waste.</summary>
|
||||||
|
public int RepeatedRequests { get; private set; }
|
||||||
|
|
||||||
|
private readonly HashSet<(DateTime, DateTime, uint)> _seen = [];
|
||||||
|
|
||||||
|
/// <summary>Every request, for failure messages.</summary>
|
||||||
|
public List<string> Log { get; } = [];
|
||||||
|
|
||||||
|
public void Reset() { MaxCap = 0; RowsReturned = 0; Reads = 0; RepeatedRequests = 0; _seen.Clear(); Log.Clear(); }
|
||||||
|
|
||||||
|
public bool SupportsReverseRaw => false;
|
||||||
|
|
||||||
|
public Task<HistorianRead> ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Reads++;
|
||||||
|
if (!_seen.Add((startUtc, endUtc, maxValuesPerNode))) RepeatedRequests++;
|
||||||
|
|
||||||
|
Log.Add($"[{startUtc:O} .. {endUtc:O}] cap {maxValuesPerNode}");
|
||||||
|
if (startUtc > endUtc || maxValuesPerNode == 0 || startUtc < DateTime.UnixEpoch) BadRequests++;
|
||||||
|
MaxCap = Math.Max(MaxCap, maxValuesPerNode);
|
||||||
|
var stamp = roundUp && startUtc.Ticks % TimeSpan.TicksPerMillisecond != 0
|
||||||
|
? new DateTime(startUtc.Ticks - startUtc.Ticks % TimeSpan.TicksPerMillisecond + TimeSpan.TicksPerMillisecond, DateTimeKind.Utc)
|
||||||
|
: startUtc;
|
||||||
|
var prior = series.LastOrDefault(s => s.SourceTimestampUtc <= startUtc);
|
||||||
|
var rows = new List<DataValueSnapshot>
|
||||||
|
{
|
||||||
|
prior is null
|
||||||
|
? new DataValueSnapshot(-1d, StatusCodes.BadNoData, stamp, stamp)
|
||||||
|
: new DataValueSnapshot(-2d, prior.StatusCode, stamp, stamp),
|
||||||
|
};
|
||||||
|
rows.AddRange(series.Where(s => s.SourceTimestampUtc >= startUtc && s.SourceTimestampUtc <= endUtc));
|
||||||
|
var page = rows.Take((int)Math.Min(int.MaxValue, maxValuesPerNode)).ToList();
|
||||||
|
RowsReturned += page.Count;
|
||||||
|
return Task.FromResult(new HistorianRead(page, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<HistorianRead> ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||||
|
HistoryAggregateType aggregate, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<HistorianRead> ReadAtTimeAsync(string fullReference, IReadOnlyList<DateTime> timestampsUtc,
|
||||||
|
CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<HistoricalEventsResult> ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
|
||||||
|
CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
|
||||||
|
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user