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).
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
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);
|
||||
}
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
||||
using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Phase-D server-side HistoryRead continuation-point paging for the Raw arm. Boots a real
|
||||
/// <see cref="OtOpcUaSdkServer"/> (as <see cref="NodeManagerHistoryReadTests"/> does), injects an
|
||||
/// <see cref="InMemoryHistoryContinuationStore"/> (the in-process harness uses a SESSION-LESS
|
||||
/// <c>OperationContext</c>, so the production session-backed store has no session to bind to — the
|
||||
/// in-memory store exercises the SAME dispatch path), and a series-backed fake historian, then drives
|
||||
/// the full multi-page round trip through the node manager's public HistoryRead.
|
||||
/// </summary>
|
||||
public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private readonly string _pkiRoot = Path.Combine(
|
||||
Path.GetTempPath(), $"otopcua-historypaging-{Guid.NewGuid():N}");
|
||||
|
||||
private static readonly DateTime Epoch = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>A full first page emits a continuation point; resuming returns subsequent pages; the
|
||||
/// final short page clears the continuation point — and the union of pages is the complete series with
|
||||
/// no duplicate or skipped sample. (Resumed pages net slightly fewer than the cap because each resume
|
||||
/// re-reads + trims the boundary sample to stay tie-safe — see the design; the union is what matters.)</summary>
|
||||
[Fact]
|
||||
public async Task Raw_pages_full_series_across_continuation_points()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
|
||||
|
||||
// 250 distinct-timestamp samples; client caps each page at 100.
|
||||
var series = MakeSeries(count: 250, stepSeconds: 1);
|
||||
nm.HistorianDataSource = new SeriesHistorianDataSource(series);
|
||||
|
||||
nm.EnsureVariable("eq-1/pv", parentFolderNodeId: null, displayName: "PV", dataType: "Double",
|
||||
writable: false, historianTagname: "WW.PV");
|
||||
var nodeId = nm.TryGetVariable("eq-1/pv")!.NodeId;
|
||||
|
||||
var collected = new List<double>();
|
||||
byte[]? cp = null;
|
||||
var pageCount = 0;
|
||||
var sawCp = false;
|
||||
do
|
||||
{
|
||||
var (values, error, nextCp) = ReadRaw(nm, nodeId, start: Epoch, end: Epoch.AddHours(1),
|
||||
max: 100, inboundCp: cp);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
collected.AddRange(values);
|
||||
cp = nextCp;
|
||||
if (cp is not null) sawCp = true;
|
||||
pageCount++;
|
||||
pageCount.ShouldBeLessThan(20, "paging must terminate, not loop");
|
||||
}
|
||||
while (cp is not null);
|
||||
|
||||
sawCp.ShouldBeTrue("a 250-sample series capped at 100 must page");
|
||||
pageCount.ShouldBeGreaterThan(1);
|
||||
|
||||
// The union is the exact series — no duplicates, no skips, in order.
|
||||
collected.Count.ShouldBe(250);
|
||||
collected.ShouldBe(Enumerable.Range(0, 250).Select(i => (double)i));
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>An exactly-full series whose total is a multiple of the cap pages to a trailing
|
||||
/// EMPTY page (GoodNoData, no CP) — the server can't know the prior page was the last until it reads
|
||||
/// past the end.</summary>
|
||||
[Fact]
|
||||
public async Task Raw_exact_multiple_terminates_with_empty_final_page()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
|
||||
nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 100, stepSeconds: 1));
|
||||
|
||||
nm.EnsureVariable("eq-1/exact", parentFolderNodeId: null, displayName: "Exact", dataType: "Double",
|
||||
writable: false, historianTagname: "WW.Exact");
|
||||
var nodeId = nm.TryGetVariable("eq-1/exact")!.NodeId;
|
||||
|
||||
var (r1, _, cp1) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: null);
|
||||
r1.Count.ShouldBe(100);
|
||||
cp1.ShouldNotBeNull("a full page emits a CP even when it happens to be the last");
|
||||
|
||||
var (r2, e2, cp2) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: cp1);
|
||||
r2.Count.ShouldBe(0);
|
||||
e2.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
cp2.ShouldBeNull("the empty trailing page terminates paging");
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Boundary timestamps that TIE across the page split are neither duplicated nor skipped:
|
||||
/// the resume cursor drops the already-emitted ties and re-reads the un-emitted ones at the same
|
||||
/// timestamp.</summary>
|
||||
[Fact]
|
||||
public async Task Raw_paging_dedups_tied_boundary_timestamps()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
|
||||
|
||||
// 5 samples: indices 2,3,4 all share the SAME timestamp (Epoch+2s). With cap 4, page 1 returns
|
||||
// [0,1,2,3] (cutting through the tie cluster); page 2 must return [4] (the un-emitted tie) only.
|
||||
var ts = new[]
|
||||
{
|
||||
Epoch, // 0
|
||||
Epoch.AddSeconds(1), // 1
|
||||
Epoch.AddSeconds(2), // 2 ┐
|
||||
Epoch.AddSeconds(2), // 3 │ tied at Epoch+2s
|
||||
Epoch.AddSeconds(2), // 4 ┘
|
||||
};
|
||||
var series = ts.Select((t, i) => new DataValueSnapshot((double)i, StatusCodes.Good, t, t)).ToArray();
|
||||
nm.HistorianDataSource = new SeriesHistorianDataSource(series);
|
||||
|
||||
nm.EnsureVariable("eq-1/tie", parentFolderNodeId: null, displayName: "Tie", dataType: "Double",
|
||||
writable: false, historianTagname: "WW.Tie");
|
||||
var nodeId = nm.TryGetVariable("eq-1/tie")!.NodeId;
|
||||
|
||||
var collected = new List<double>();
|
||||
|
||||
var (r1, _, cp1) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 4, inboundCp: null);
|
||||
r1.Count.ShouldBe(4);
|
||||
r1.ShouldBe(new[] { 0.0, 1.0, 2.0, 3.0 });
|
||||
cp1.ShouldNotBeNull();
|
||||
collected.AddRange(r1);
|
||||
|
||||
var (r2, _, cp2) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 4, inboundCp: cp1);
|
||||
// Only the un-emitted tie (index 4) — NOT the already-emitted 2 and 3.
|
||||
r2.ShouldBe(new[] { 4.0 });
|
||||
cp2.ShouldBeNull();
|
||||
collected.AddRange(r2);
|
||||
|
||||
// Exactly the 5 distinct samples, once each.
|
||||
collected.ShouldBe(new[] { 0.0, 1.0, 2.0, 3.0, 4.0 });
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>NumValuesPerNode == 0 ("all values") never pages — the whole series returns in one shot
|
||||
/// with a null continuation point.</summary>
|
||||
[Fact]
|
||||
public async Task Raw_unlimited_request_returns_everything_without_a_cp()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
|
||||
nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1));
|
||||
|
||||
nm.EnsureVariable("eq-1/all", parentFolderNodeId: null, displayName: "All", dataType: "Double",
|
||||
writable: false, historianTagname: "WW.All");
|
||||
var nodeId = nm.TryGetVariable("eq-1/all")!.NodeId;
|
||||
|
||||
var (r, e, cp) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 0, inboundCp: null);
|
||||
|
||||
e.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
r.Count.ShouldBe(250);
|
||||
cp.ShouldBeNull("an unlimited (max==0) request must not page");
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>An inbound continuation point the store doesn't know (released / never issued / from
|
||||
/// another node) yields BadContinuationPointInvalid for that node, and the source is NOT read.</summary>
|
||||
[Fact]
|
||||
public async Task Raw_unknown_continuation_point_yields_BadContinuationPointInvalid()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
|
||||
var fake = new SeriesHistorianDataSource(MakeSeries(count: 10, stepSeconds: 1));
|
||||
nm.HistorianDataSource = fake;
|
||||
|
||||
nm.EnsureVariable("eq-1/bad-cp", parentFolderNodeId: null, displayName: "BadCp", dataType: "Double",
|
||||
writable: false, historianTagname: "WW.BadCp");
|
||||
var nodeId = nm.TryGetVariable("eq-1/bad-cp")!.NodeId;
|
||||
|
||||
fake.ResetReadCount();
|
||||
var bogus = Guid.NewGuid().ToByteArray();
|
||||
var (_, e, cp) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: bogus);
|
||||
|
||||
e.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
||||
cp.ShouldBeNull();
|
||||
fake.ReadCount.ShouldBe(0); // never reached the backend.
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>releaseContinuationPoints drops the stored cursor WITHOUT reading data: a subsequent
|
||||
/// resume of the released point then misses (BadContinuationPointInvalid).</summary>
|
||||
[Fact]
|
||||
public async Task Release_drops_the_cursor_without_reading()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
|
||||
var fake = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1));
|
||||
nm.HistorianDataSource = fake;
|
||||
|
||||
nm.EnsureVariable("eq-1/rel", parentFolderNodeId: null, displayName: "Rel", dataType: "Double",
|
||||
writable: false, historianTagname: "WW.Rel");
|
||||
var nodeId = nm.TryGetVariable("eq-1/rel")!.NodeId;
|
||||
|
||||
// Page 1 — get a CP.
|
||||
var (_, _, cp1) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: null);
|
||||
cp1.ShouldNotBeNull();
|
||||
|
||||
// Release it — the dispatcher routes releaseContinuationPoints=true to HistoryReleaseContinuationPoints,
|
||||
// which never reaches the Raw arm. No data is read.
|
||||
fake.ResetReadCount();
|
||||
InvokeRelease(nm, nodeId, cp1!);
|
||||
fake.ReadCount.ShouldBe(0);
|
||||
|
||||
// Resuming the released point now misses.
|
||||
var (_, e, _) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: cp1);
|
||||
e.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
// --- helpers --------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Issue a single-node Raw HistoryRead and return that node's decoded sample values, error,
|
||||
/// and outbound continuation point.</summary>
|
||||
private static (IReadOnlyList<double> Values, ServiceResult Error, byte[]? Cp) ReadRaw(
|
||||
OtOpcUaNodeManager nm, NodeId nodeId, DateTime start, DateTime end, uint max, byte[]? inboundCp)
|
||||
{
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = start,
|
||||
EndTime = end,
|
||||
NumValuesPerNode = max,
|
||||
IsReadModified = false,
|
||||
};
|
||||
|
||||
var nodesToRead = new List<HistoryReadValueId>
|
||||
{
|
||||
new() { NodeId = nodeId, ContinuationPoint = inboundCp },
|
||||
};
|
||||
var results = new List<SdkHistoryReadResult> { null! };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
|
||||
nm.HistoryRead(context, details, TimestampsToReturn.Both,
|
||||
releaseContinuationPoints: false, nodesToRead, results, errors);
|
||||
|
||||
var values = new List<double>();
|
||||
if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho))
|
||||
{
|
||||
var data = (HistoryData)ExtensionObject.ToEncodeable(ho);
|
||||
foreach (var dv in data.DataValues) values.Add((double)dv.Value);
|
||||
}
|
||||
|
||||
return (values, errors[0], results[0]?.ContinuationPoint);
|
||||
}
|
||||
|
||||
/// <summary>Issue a release-only HistoryRead (releaseContinuationPoints=true) for the node + point.</summary>
|
||||
private static void InvokeRelease(OtOpcUaNodeManager nm, NodeId nodeId, byte[] cp)
|
||||
{
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = Epoch, EndTime = Epoch.AddHours(1), NumValuesPerNode = 100, IsReadModified = false,
|
||||
};
|
||||
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = nodeId, ContinuationPoint = cp } };
|
||||
var results = new List<SdkHistoryReadResult> { null! };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
|
||||
nm.HistoryRead(context, details, TimestampsToReturn.Both,
|
||||
releaseContinuationPoints: true, nodesToRead, results, errors);
|
||||
}
|
||||
|
||||
private static DataValueSnapshot[] MakeSeries(int count, int stepSeconds) =>
|
||||
Enumerable.Range(0, count)
|
||||
.Select(i =>
|
||||
{
|
||||
var t = Epoch.AddSeconds((long)i * stepSeconds);
|
||||
return new DataValueSnapshot((double)i, StatusCodes.Good, t, t);
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
/// <summary>A series-backed fake historian: holds a full sorted series and serves a Raw read by
|
||||
/// returning the samples in [start, end] capped at maxValuesPerNode (start inclusive — exactly the
|
||||
/// resume-cursor contract). Processed / AtTime / Events are not exercised here.</summary>
|
||||
private sealed class SeriesHistorianDataSource(IReadOnlyList<DataValueSnapshot> series) : IHistorianDataSource
|
||||
{
|
||||
private int _readCount;
|
||||
public int ReadCount => _readCount;
|
||||
public void ResetReadCount() => _readCount = 0;
|
||||
|
||||
public Task<HistorianRead> ReadRawAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref _readCount);
|
||||
var window = series
|
||||
.Where(s => (s.SourceTimestampUtc ?? DateTime.MinValue) >= startUtc
|
||||
&& (s.SourceTimestampUtc ?? DateTime.MinValue) <= endUtc)
|
||||
.ToList();
|
||||
// maxValuesPerNode == 0 means "no limit".
|
||||
var page = maxValuesPerNode == 0 ? window : window.Take((int)maxValuesPerNode).ToList();
|
||||
return Task.FromResult(new HistorianRead(page, null));
|
||||
}
|
||||
|
||||
public Task<HistorianRead> ReadProcessedAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||
HistoryAggregateType aggregate, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new HistorianRead(Array.Empty<DataValueSnapshot>(), null));
|
||||
|
||||
public Task<HistorianRead> ReadAtTimeAsync(
|
||||
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new HistorianRead(Array.Empty<DataValueSnapshot>(), null));
|
||||
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new HistoricalEventsResult(Array.Empty<HistoricalEvent>(), null));
|
||||
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||
{
|
||||
var host = new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.HistoryPagingTest",
|
||||
ApplicationUri = $"urn:OtOpcUa.HistoryPagingTest:{Guid.NewGuid():N}",
|
||||
OpcUaPort = AllocateFreePort(),
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance);
|
||||
|
||||
var server = new OtOpcUaSdkServer();
|
||||
await host.StartAsync(server, Ct);
|
||||
return (host, server);
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pkiRoot))
|
||||
{
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||
catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user