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,154 @@
|
||||
using Opc.Ua.Server;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>
|
||||
/// Stores the server-side resume state behind an opaque OPC UA HistoryRead continuation point.
|
||||
/// A continuation point is 16 opaque bytes (a fresh <see cref="Guid"/>); the store maps it to a
|
||||
/// <see cref="HistoryContinuationState"/>. The seam exists so the node manager can page against the
|
||||
/// SDK's per-session store in production (lifecycle + cap + cleanup owned by the SDK) while tests
|
||||
/// drive a session-less in-memory store through the same code path.
|
||||
/// </summary>
|
||||
internal interface IHistoryContinuationStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Persist <paramref name="state"/> and return the opaque continuation-point bytes a client
|
||||
/// hands back to resume. Returns <c>null</c> when the state cannot be stored (e.g. the
|
||||
/// session-backed store has no session on this request) — the caller then returns the page with
|
||||
/// NO continuation point, which is spec-safe (a server may always return what it has in one shot).
|
||||
/// </summary>
|
||||
/// <param name="session">The session the read runs under, or <c>null</c> for a session-less call.</param>
|
||||
/// <param name="state">The resume state to store.</param>
|
||||
/// <returns>The opaque continuation-point bytes, or <c>null</c> when storage is unavailable.</returns>
|
||||
byte[]? Save(ISession? session, HistoryContinuationState state);
|
||||
|
||||
/// <summary>
|
||||
/// Look up and REMOVE the resume state for an inbound continuation point (a continuation point is
|
||||
/// single-use: taking it frees the slot; a fresh point is emitted if the resumed page is also full).
|
||||
/// </summary>
|
||||
/// <param name="session">The session the read runs under, or <c>null</c> for a session-less call.</param>
|
||||
/// <param name="continuationPoint">The opaque bytes the client handed back.</param>
|
||||
/// <returns>The stored state, or <c>null</c> when the point is unknown / expired / malformed.</returns>
|
||||
HistoryContinuationState? TryTake(ISession? session, byte[] continuationPoint);
|
||||
|
||||
/// <summary>
|
||||
/// Drop the resume state for a continuation point the client asked to release
|
||||
/// (<c>releaseContinuationPoints</c>) WITHOUT reading any data. Idempotent — releasing an unknown
|
||||
/// point is a no-op.
|
||||
/// </summary>
|
||||
/// <param name="session">The session the read runs under, or <c>null</c> for a session-less call.</param>
|
||||
/// <param name="continuationPoint">The opaque bytes to release.</param>
|
||||
void Release(ISession? session, byte[] continuationPoint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="IHistoryContinuationStore"/> backed by the OPC UA SDK's per-session
|
||||
/// history-continuation store (<see cref="ISession.SaveHistoryContinuationPoint"/> /
|
||||
/// <see cref="ISession.RestoreHistoryContinuationPoint"/>). Using the SDK store gives us, for free:
|
||||
/// <list type="bullet">
|
||||
/// <item>per-session lifecycle — points are disposed when the session closes, so a client that
|
||||
/// disconnects mid-page can never leak resume state;</item>
|
||||
/// <item>a bounded capacity with oldest-eviction — the cap is
|
||||
/// <c>ServerConfiguration.MaxHistoryContinuationPoints</c> (SDK default 100); when a session
|
||||
/// exceeds it the SDK silently drops its OLDEST point, so a misbehaving client cannot grow the
|
||||
/// store unboundedly. A subsequent resume of an evicted point returns
|
||||
/// <c>BadContinuationPointInvalid</c> (a <see cref="TryTake"/> miss);</item>
|
||||
/// <item>thread-safety — the SDK session locks internally.</item>
|
||||
/// </list>
|
||||
/// The continuation-point bytes are a fresh 16-byte <see cref="Guid"/>; the SDK keys its slot by that
|
||||
/// Guid and round-trips the opaque bytes through the wire untouched (verified against the
|
||||
/// MasterNodeManager HistoryRead path — it does not register or cap history points itself).
|
||||
/// <para>
|
||||
/// When a HistoryRead arrives with no session (only the in-process test path does this), there is
|
||||
/// nowhere session-bound to durably store resume state across calls, so <see cref="Save"/> returns
|
||||
/// <c>null</c> and the read degrades to single-shot. Tests that exercise multi-page paging inject
|
||||
/// the in-memory <see cref="InMemoryHistoryContinuationStore"/> instead.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class SessionHistoryContinuationStore : IHistoryContinuationStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public byte[]? Save(ISession? session, HistoryContinuationState state)
|
||||
{
|
||||
if (session is null) return null;
|
||||
|
||||
// A fresh Guid is the opaque point: 16 bytes, collision-free, and the SDK keys its session slot by
|
||||
// it. The SDK enforces ServerConfiguration.MaxHistoryContinuationPoints with oldest-eviction.
|
||||
var id = Guid.NewGuid();
|
||||
session.SaveHistoryContinuationPoint(id, state);
|
||||
return id.ToByteArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public HistoryContinuationState? TryTake(ISession? session, byte[] continuationPoint)
|
||||
{
|
||||
if (session is null) return null;
|
||||
// RestoreHistoryContinuationPoint REMOVES the slot (single-use) and returns null for an unknown /
|
||||
// malformed (non-16-byte) point — exactly the "miss ⇒ BadContinuationPointInvalid" contract.
|
||||
return session.RestoreHistoryContinuationPoint(continuationPoint) as HistoryContinuationState;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Release(ISession? session, byte[] continuationPoint) =>
|
||||
// Restoring removes the slot; we discard the value. Null session / unknown point ⇒ no-op.
|
||||
session?.RestoreHistoryContinuationPoint(continuationPoint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="IHistoryContinuationStore"/> independent of any OPC UA session — for the
|
||||
/// session-less in-process test path, which boots a real server but invokes HistoryRead with a
|
||||
/// session-less <c>OperationContext</c>. Mirrors the production store's contract: 16-byte Guid points,
|
||||
/// single-use take, idempotent release, and a bounded capacity with oldest-eviction so the same cap
|
||||
/// semantics are exercised.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryHistoryContinuationStore(int capacity = 100) : IHistoryContinuationStore
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<Guid, HistoryContinuationState> _states = new();
|
||||
// Insertion order, so we can evict the OLDEST when over capacity (matches the SDK store).
|
||||
private readonly Queue<Guid> _order = new();
|
||||
private readonly int _capacity = capacity < 1 ? 1 : capacity;
|
||||
|
||||
/// <inheritdoc />
|
||||
public byte[]? Save(ISession? session, HistoryContinuationState state)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
lock (_gate)
|
||||
{
|
||||
while (_states.Count >= _capacity && _order.Count > 0)
|
||||
{
|
||||
var oldest = _order.Dequeue();
|
||||
_states.Remove(oldest);
|
||||
}
|
||||
|
||||
_states[id] = state;
|
||||
_order.Enqueue(id);
|
||||
}
|
||||
|
||||
return id.ToByteArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public HistoryContinuationState? TryTake(ISession? session, byte[] continuationPoint)
|
||||
{
|
||||
if (continuationPoint is null || continuationPoint.Length != 16) return null;
|
||||
var id = new Guid(continuationPoint);
|
||||
lock (_gate)
|
||||
{
|
||||
if (_states.Remove(id, out var state)) return state;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Release(ISession? session, byte[] continuationPoint)
|
||||
{
|
||||
if (continuationPoint is null || continuationPoint.Length != 16) return;
|
||||
var id = new Guid(continuationPoint);
|
||||
lock (_gate)
|
||||
{
|
||||
_states.Remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user