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); } } }