Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryPagingTests.cs
T
Joseph Doherty 94c3ca60fc feat(historian): server-side continuation-point paging for HistoryRead-Raw
The Wonderware historian backend is single-shot — it returns up to
NumValuesPerNode samples with a null continuation point — so paging is
synthesised server-side, time-based, for the only count-capped arm (Raw):

- A full page (count == NumValuesPerNode, NumValuesPerNode > 0) emits an
  opaque 16-byte continuation point and stores a resume cursor; a short page
  (or NumValuesPerNode == 0 "all values") emits none.
- A resume read takes the stored cursor, reads the next page from the boundary
  forward, and emits a fresh CP only if that page is also full.
- The resume cursor is tie-safe (HistoryPaging.ComputeResumeCursor /
  TrimBoundaryDuplicates): the next page resumes from the boundary timestamp
  INCLUSIVE and drops the head ties already returned, so samples sharing the
  boundary SourceTimestamp are neither duplicated nor skipped.

Continuation points are bound to the OPC UA session via the SDK's
ISession.SaveHistoryContinuationPoint / RestoreHistoryContinuationPoint store
(SessionHistoryContinuationStore) — capped by ServerConfiguration.
MaxHistoryContinuationPoints (default 100, oldest-evicted) and disposed on
session close. releaseContinuationPoints is honoured via an override of
HistoryReleaseContinuationPoints (the base dispatcher routes release-only reads
there, never to the per-details arms). An unknown / evicted / released point
resumes to BadContinuationPointInvalid.

Processed and AtTime stay single-shot: neither details type carries a client
count cap, so the single-shot backend returns the complete result in one read
and there is no "full page" signal to page on (spec-conformant). Modified-value
history remains out of scope.

The pure paging decisions + CP store contract are unit-tested via HistoryPaging
+ InMemoryHistoryContinuationStore; the full multi-page round trip is driven
end-to-end through the node manager with an in-memory store + a series-backed
fake historian (the in-process harness is session-less).
2026-06-15 03:02:48 -04:00

215 lines
7.8 KiB
C#

using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Pure unit tests for the server-side HistoryRead paging decisions
/// (<see cref="HistoryPaging"/>) and the continuation-point store
/// (<see cref="InMemoryHistoryContinuationStore"/> / round-trip + cap). No server, no session,
/// no SDK — these drive the decision surface directly so the boundary / tie / cap logic is pinned
/// independent of the (operator-driven) live wire-level round trip.
/// </summary>
public sealed class HistoryPagingTests
{
private static DataValueSnapshot Sample(double v, DateTime src) =>
new(v, StatusCodes.Good, src, src);
// --- IsFullPage -----------------------------------------------------------------------------
[Fact]
public void IsFullPage_full_count_with_finite_cap_emits()
{
// Backend returned exactly the cap ⇒ may be more ⇒ page.
HistoryPaging.IsFullPage(returnedCount: 100, numValuesPerNode: 100).ShouldBeTrue();
}
[Fact]
public void IsFullPage_short_page_does_not_emit()
{
// Fewer than the cap ⇒ last page ⇒ no continuation point.
HistoryPaging.IsFullPage(returnedCount: 37, numValuesPerNode: 100).ShouldBeFalse();
}
[Fact]
public void IsFullPage_unlimited_request_never_emits()
{
// NumValuesPerNode == 0 means "all values, no limit" (OPC UA Part 11) ⇒ never page.
HistoryPaging.IsFullPage(returnedCount: 5000, numValuesPerNode: 0).ShouldBeFalse();
}
[Fact]
public void IsFullPage_over_cap_still_emits()
{
// A backend that ignores the cap and over-returns is still treated as "maybe more".
HistoryPaging.IsFullPage(returnedCount: 101, numValuesPerNode: 100).ShouldBeTrue();
}
// --- ComputeResumeCursor --------------------------------------------------------------------
[Fact]
public void ComputeResumeCursor_distinct_timestamps_resumes_at_last_with_single_skip()
{
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var page = new[]
{
Sample(1, t0),
Sample(2, t0.AddSeconds(1)),
Sample(3, t0.AddSeconds(2)),
};
HistoryPaging.ComputeResumeCursor(page, out var nextStart, out var skip);
// Resume from the LAST sample's timestamp, inclusive; exactly one tie at the boundary (itself).
nextStart.ShouldBe(t0.AddSeconds(2));
skip.ShouldBe(1);
}
[Fact]
public void ComputeResumeCursor_tied_boundary_counts_all_ties()
{
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var boundary = t0.AddSeconds(5);
var page = new[]
{
Sample(1, t0),
Sample(2, boundary), // tie 1
Sample(3, boundary), // tie 2
Sample(4, boundary), // tie 3 (last)
};
HistoryPaging.ComputeResumeCursor(page, out var nextStart, out var skip);
nextStart.ShouldBe(boundary);
skip.ShouldBe(3); // all three boundary ties were emitted ⇒ next page must drop them.
}
[Fact]
public void ComputeResumeCursor_null_source_timestamp_falls_back_to_min_value()
{
var page = new[] { new DataValueSnapshot(1.0, StatusCodes.Bad, null, DateTime.UtcNow) };
HistoryPaging.ComputeResumeCursor(page, out var nextStart, out var skip);
nextStart.ShouldBe(DateTime.MinValue);
skip.ShouldBe(1);
}
// --- TrimBoundaryDuplicates -----------------------------------------------------------------
[Fact]
public void TrimBoundaryDuplicates_drops_emitted_ties_keeps_the_rest()
{
var b = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);
// Resumed read started AT the boundary; the first 2 ties were already emitted last page.
var resumed = new[]
{
Sample(10, b), // already-emitted tie (drop)
Sample(11, b), // already-emitted tie (drop)
Sample(12, b), // NEW un-emitted tie at the boundary (keep)
Sample(13, b.AddSeconds(1)), // past the boundary (keep)
};
var trimmed = HistoryPaging.TrimBoundaryDuplicates(resumed, b, boundarySkipCount: 2);
trimmed.Count.ShouldBe(2);
trimmed[0].Value.ShouldBe(12.0); // the previously-skipped tie is NOT lost.
trimmed[1].Value.ShouldBe(13.0);
}
[Fact]
public void TrimBoundaryDuplicates_zero_skip_is_identity()
{
var b = DateTime.UtcNow;
var resumed = new[] { Sample(1, b), Sample(2, b.AddSeconds(1)) };
HistoryPaging.TrimBoundaryDuplicates(resumed, b, boundarySkipCount: 0)
.ShouldBeSameAs(resumed);
}
[Fact]
public void TrimBoundaryDuplicates_only_trims_matching_boundary_timestamps()
{
var b = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);
// Skip count says 3, but only the first sample matches the boundary ⇒ trim stops at the mismatch.
var resumed = new[]
{
Sample(10, b),
Sample(11, b.AddSeconds(1)),
Sample(12, b.AddSeconds(2)),
};
var trimmed = HistoryPaging.TrimBoundaryDuplicates(resumed, b, boundarySkipCount: 3);
trimmed.Count.ShouldBe(2);
trimmed[0].Value.ShouldBe(11.0);
}
// --- InMemoryHistoryContinuationStore (mirrors the production session store contract) --------
[Fact]
public void Store_round_trips_state_through_opaque_bytes()
{
var store = new InMemoryHistoryContinuationStore();
var state = new HistoryContinuationState(
HistoryReadKind.Raw, "WW.Tag", new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc), BoundarySkipCount: 2,
NumValuesPerNode: 100, Aggregate: default, IntervalTicks: 0);
var cp = store.Save(session: null, state);
cp.ShouldNotBeNull();
cp!.Length.ShouldBe(16); // a 16-byte Guid, matching the production store's encoding.
var taken = store.TryTake(session: null, cp);
taken.ShouldBe(state);
}
[Fact]
public void Store_take_is_single_use()
{
var store = new InMemoryHistoryContinuationStore();
var cp = store.Save(null, RawState())!;
store.TryTake(null, cp).ShouldNotBeNull();
// Second take of the SAME point misses ⇒ caller surfaces BadContinuationPointInvalid.
store.TryTake(null, cp).ShouldBeNull();
}
[Fact]
public void Store_release_drops_the_point()
{
var store = new InMemoryHistoryContinuationStore();
var cp = store.Save(null, RawState())!;
store.Release(null, cp);
store.TryTake(null, cp).ShouldBeNull();
}
[Fact]
public void Store_malformed_point_is_a_miss()
{
var store = new InMemoryHistoryContinuationStore();
store.TryTake(null, new byte[] { 1, 2, 3 }).ShouldBeNull(); // not 16 bytes
store.TryTake(null, Array.Empty<byte>()).ShouldBeNull();
}
[Fact]
public void Store_evicts_oldest_over_capacity()
{
var store = new InMemoryHistoryContinuationStore(capacity: 2);
var cp1 = store.Save(null, RawState())!;
var cp2 = store.Save(null, RawState())!;
var cp3 = store.Save(null, RawState())!; // pushes cp1 out (oldest-eviction)
store.TryTake(null, cp1).ShouldBeNull(); // evicted
store.TryTake(null, cp2).ShouldNotBeNull(); // retained
store.TryTake(null, cp3).ShouldNotBeNull(); // retained
}
private static HistoryContinuationState RawState() => new(
HistoryReadKind.Raw, "WW.Tag", DateTime.UtcNow, DateTime.UtcNow.AddHours(1),
BoundarySkipCount: 1, NumValuesPerNode: 50, Aggregate: default, IntervalTicks: 0);
}