Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryContinuationStore.cs
T
Joseph Doherty bea0b482d4 fix(historian): address code review on Raw HistoryRead paging
C1 (critical): a boundary tie cluster larger than NumValuesPerNode could
silently truncate a resumed read to GoodNoData, permanently dropping the
un-emitted ties — the (timestamp, skip) cursor cannot advance past a single
timestamp the fixed-(start,end,cap) backend keeps re-returning. Now detected
and failed LOUDLY per node with BadHistoryOperationUnsupported + a log naming
the tag/timestamp/cap; documented in Historian.md with the larger-cap remedy.
Regression test Raw_tie_cluster_larger_than_page_fails_loudly_not_silently.

I3: build HistoryData before Save() so a projection failure can never orphan a
stored continuation cursor.

N1 (YAGNI): drop the never-produced HistoryReadKind enum + Processed-only
Aggregate/IntervalTicks fields from HistoryContinuationState — only Raw pages.

N3: ComputeResumeCursor guards its documented non-empty precondition.

I1: document InMemoryHistoryContinuationStore's eventual-consistency (test double).

Build clean, 182/182 OpcUaServer tests pass.
2026-06-15 05:15:07 -04:00

159 lines
8.0 KiB
C#

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). Eventually
// consistent: a Guid taken/released stays in _order until the eviction loop reaches it and finds it
// already gone from _states (a harmless no-op dequeue). _states is the source of truth for liveness;
// _order only ever over-approximates it, so eviction never drops a LIVE entry early. Fine for a
// bounded test double — production uses the SDK's own per-session store, not this class.
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);
}
}
}