HistoryRead-Raw with NumValuesPerNode=1 returns an empty page between every value #692

Closed
opened 2026-10-13 00:11:06 -04:00 by dohertj2 · 1 comment
Owner

Found by the #689–#691 live AVEVA gate (2026-10-13, master 942ca7bc). Affects every backend.

Symptom: paging a raw read with NumValuesPerNode = 1 alternates a value page with an EMPTY page (GoodNoData + continuation point). 8 values cost 17 pages and 25 backend reads; on Gateway each empty page asks for ~65,570 rows. Forward and reverse, with or without ReturnBounds.

Repro: raw read on HistGW.LiveTest.Gate685 over [19:00:00.1, 19:00:10.5) with N=1, paged to completion.

Likely area: the #669 resume/tie-cluster logic in OtOpcUaNodeManager.ServeRawPagedAsync (TrimBoundaryDuplicates + the stall/over-fetch path) — with N=1 a resumed page re-reads the boundary sample, trims it, and returns nothing. Expected: one value per page. Pin with a paging test at N=1 over all three backend shapes and a read-count assertion.

Also from the #691.1 re-review (low): the N=1 ReturnBounds path (BoundedReadHasAnythingAsync) repeats the data/end-bound reads that page 2 does again, and drops the walk hint it just computed; and HistoryBounds.IsPlaceholder matches on status BadBoundNotFound + null value, so a row a client stored with that status via HistoryUpdate would be taken for a placeholder (theoretical; a marker flag would close it).

Evidence: scratchpad livegate-aveva-689.md (F1).

Found by the #689–#691 live AVEVA gate (2026-10-13, master `942ca7bc`). Affects every backend. **Symptom:** paging a raw read with `NumValuesPerNode = 1` alternates a value page with an EMPTY page (`GoodNoData` + continuation point). 8 values cost 17 pages and 25 backend reads; on Gateway each empty page asks for ~65,570 rows. Forward and reverse, with or without `ReturnBounds`. **Repro:** raw read on `HistGW.LiveTest.Gate685` over `[19:00:00.1, 19:00:10.5)` with N=1, paged to completion. **Likely area:** the #669 resume/tie-cluster logic in `OtOpcUaNodeManager.ServeRawPagedAsync` (`TrimBoundaryDuplicates` + the stall/over-fetch path) — with N=1 a resumed page re-reads the boundary sample, trims it, and returns nothing. Expected: one value per page. Pin with a paging test at N=1 over all three backend shapes and a read-count assertion. Also from the #691.1 re-review (low): the N=1 ReturnBounds path (`BoundedReadHasAnythingAsync`) repeats the data/end-bound reads that page 2 does again, and drops the walk hint it just computed; and `HistoryBounds.IsPlaceholder` matches on status `BadBoundNotFound` + null value, so a row a client stored with that status via HistoryUpdate would be taken for a placeholder (theoretical; a marker flag would close it). Evidence: scratchpad `livegate-aveva-689.md` (F1).
Author
Owner

Fixed on master 66a2b0c3 (merge aa923cea; commits 8dfa1651, 3d7eea62, e04e1bf1). Live AVEVA gate PASS (2026-10-13).

Root cause: a resumed raw page restarts at the boundary timestamp inclusive but asked the backend for only N rows; TrimBoundaryDuplicates then dropped the ties already served, so every resumed page was short. At N=1 it came back empty and was mistaken for a stuck tie cluster, triggering the MaxTieClusterOverfetch + 1 over-fetch. Every backend, both directions, with and without ReturnBounds.

Fix: each page asks for N + skip + 1 rows (the ties it will trim, the page, one lookahead row). No lookahead row ⇒ last page, no continuation point. The over-fetch fires only for a genuine cluster at the boundary; a cursor inside one is marked WithinTieCluster.

Live (Gate685 [19:00:00.1, 19:00:10.5), N=1): 8 pages / 8 reads / 151 rows requested (was 17 / 25 / 524,713). With bounds 10 / 10, forward and reverse. No empty page with a continuation point; largest request 1,042 rows.

Also fixed:

  • The low items: the N=1 ReturnBounds probe keeps its walk hint and resolved end bound in the cursor; bound placeholders carry an explicit marker, so a stored Bad_BoundNotFound/null row is data.
  • Review fuzz found a pre-existing bug: SliceTieCluster could resume AT the exclusive EndTime and return samples stamped there. Fixed.
  • The trailing empty page at the end of a reverse read is gone.

Client-visible: a full last page no longer carries a continuation point. Exception: an oversized tie cluster (more ties at one timestamp than N plus those already sent) can still give a short page with a continuation point, or one empty final page (documented in docs/Historian.md).

Tests: 85-case NodeManagerHistoryReadPageFillTests. Paged-vs-unpaged fuzz: 0 mismatches over 60k cases (master: ~1 in 160).

Follow-up: #697 (backend outage answered BadHistoryOperationUnsupported, seen during this gate).

Fixed on master `66a2b0c3` (merge `aa923cea`; commits `8dfa1651`, `3d7eea62`, `e04e1bf1`). Live AVEVA gate PASS (2026-10-13). **Root cause:** a resumed raw page restarts at the boundary timestamp inclusive but asked the backend for only N rows; `TrimBoundaryDuplicates` then dropped the ties already served, so every resumed page was short. At N=1 it came back empty and was mistaken for a stuck tie cluster, triggering the `MaxTieClusterOverfetch + 1` over-fetch. Every backend, both directions, with and without ReturnBounds. **Fix:** each page asks for `N + skip + 1` rows (the ties it will trim, the page, one lookahead row). No lookahead row ⇒ last page, no continuation point. The over-fetch fires only for a genuine cluster at the boundary; a cursor inside one is marked `WithinTieCluster`. **Live (Gate685 `[19:00:00.1, 19:00:10.5)`, N=1):** 8 pages / 8 reads / 151 rows requested (was 17 / 25 / 524,713). With bounds 10 / 10, forward and reverse. No empty page with a continuation point; largest request 1,042 rows. **Also fixed:** - The low items: the N=1 ReturnBounds probe keeps its walk hint and resolved end bound in the cursor; bound placeholders carry an explicit marker, so a stored `Bad_BoundNotFound`/null row is data. - Review fuzz found a pre-existing bug: `SliceTieCluster` could resume AT the exclusive EndTime and return samples stamped there. Fixed. - The trailing empty page at the end of a reverse read is gone. **Client-visible:** a full last page no longer carries a continuation point. Exception: an oversized tie cluster (more ties at one timestamp than N plus those already sent) can still give a short page with a continuation point, or one empty final page (documented in `docs/Historian.md`). **Tests:** 85-case `NodeManagerHistoryReadPageFillTests`. Paged-vs-unpaged fuzz: 0 mismatches over 60k cases (master: ~1 in 160). Follow-up: #697 (backend outage answered BadHistoryOperationUnsupported, seen during this gate).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dohertj2/lmxopcua#692