bea0b482d4
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.
159 lines
8.0 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|