using Opc.Ua.Server;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
///
/// Stores the server-side resume state behind an opaque OPC UA HistoryRead continuation point.
/// A continuation point is 16 opaque bytes (a fresh ); the store maps it to a
/// . 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.
///
internal interface IHistoryContinuationStore
{
///
/// Persist and return the opaque continuation-point bytes a client
/// hands back to resume. Returns null 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).
///
/// The session the read runs under, or null for a session-less call.
/// The resume state to store.
/// The opaque continuation-point bytes, or null when storage is unavailable.
byte[]? Save(ISession? session, HistoryContinuationState state);
///
/// 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).
///
/// The session the read runs under, or null for a session-less call.
/// The opaque bytes the client handed back.
/// The stored state, or null when the point is unknown / expired / malformed.
HistoryContinuationState? TryTake(ISession? session, byte[] continuationPoint);
///
/// Drop the resume state for a continuation point the client asked to release
/// (releaseContinuationPoints) WITHOUT reading any data. Idempotent — releasing an unknown
/// point is a no-op.
///
/// The session the read runs under, or null for a session-less call.
/// The opaque bytes to release.
void Release(ISession? session, byte[] continuationPoint);
}
///
/// Production backed by the OPC UA SDK's per-session
/// history-continuation store ( /
/// ). Using the SDK store gives us, for free:
///
/// - per-session lifecycle — points are disposed when the session closes, so a client that
/// disconnects mid-page can never leak resume state;
/// - a bounded capacity with oldest-eviction — the cap is
/// ServerConfiguration.MaxHistoryContinuationPoints (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
/// BadContinuationPointInvalid (a miss);
/// - thread-safety — the SDK session locks internally.
///
/// The continuation-point bytes are a fresh 16-byte ; 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).
///
/// 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 returns
/// null and the read degrades to single-shot. Tests that exercise multi-page paging inject
/// the in-memory instead.
///
///
internal sealed class SessionHistoryContinuationStore : IHistoryContinuationStore
{
///
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();
}
///
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;
}
///
public void Release(ISession? session, byte[] continuationPoint) =>
// Restoring removes the slot; we discard the value. Null session / unknown point ⇒ no-op.
session?.RestoreHistoryContinuationPoint(continuationPoint);
}
///
/// In-memory 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 OperationContext. 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.
///
internal sealed class InMemoryHistoryContinuationStore(int capacity = 100) : IHistoryContinuationStore
{
private readonly object _gate = new();
private readonly Dictionary _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 _order = new();
private readonly int _capacity = capacity < 1 ? 1 : capacity;
///
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();
}
///
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;
}
///
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);
}
}
}