Files
lmxopcua/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/HistoryReadPaging.cs
T
Joseph Doherty 57ae69a05e fix(client): release a capped HistoryRead cursor, read (not release) follow-up pages, detach deleted subscriptions
R08 1.4: raw and modified history reads share HistoryReadPaging. A read stopped by
the caller's cap releases the server continuation point. The raw path also passed
releaseContinuationPoints = (cp != null), which released page two instead of reading
it, so every raw read was silently truncated to its first server page.

R08 1.5: DefaultSubscriptionAdapter removes its Subscription from the SDK Session
on delete/dispose (and a failed CreateAsync removes it too), so re-targeted alarm
subscriptions no longer accumulate in Session.Subscriptions.

Refs #747
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:21:22 -04:00

67 lines
3.3 KiB
C#

using Opc.Ua;
using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
/// <summary>
/// The client-side HistoryRead paging loop shared by the raw and modified reads. Continuation pages are
/// READ (<c>releaseContinuationPoints = false</c>) while the caller still wants values; a read stopped by the
/// caller's own cap while the server still holds a cursor releases that cursor so it does not occupy one of the
/// session's continuation-point slots until eviction.
/// </summary>
internal static class HistoryReadPaging
{
private static readonly ILogger Logger = Log.ForContext(typeof(HistoryReadPaging));
/// <summary>Reads (or, with <paramref name="releaseContinuationPoints"/>, releases) one page.</summary>
/// <param name="continuationPoint">The cursor to resume, or <see langword="null"/> for the first page.</param>
/// <param name="releaseContinuationPoints"><see langword="true"/> to release the cursor instead of reading.</param>
/// <param name="ct">The caller's cancellation token.</param>
/// <returns>The per-node result, or <see langword="null"/> when the response carried none.</returns>
public delegate Task<HistoryReadResult?> PageReader(
byte[]? continuationPoint, bool releaseContinuationPoints, CancellationToken ct);
/// <summary>Reads pages until the server finishes or the caller's cap is reached.</summary>
/// <typeparam name="T">The element type extracted from each page.</typeparam>
/// <param name="readPage">Issues one HistoryRead for the single node.</param>
/// <param name="extract">Extracts the page's elements from a result.</param>
/// <param name="maxValues">The caller's cap; <c>&lt;= 0</c> means no cap (follow every continuation point).</param>
/// <param name="nodeId">The node being read, for refusal messages.</param>
/// <param name="ct">The caller's cancellation token.</param>
/// <returns>Every element read.</returns>
/// <exception cref="ServiceResultException">A page was refused (see <see cref="HistoryReadStatus"/>).</exception>
public static async Task<List<T>> ReadAllAsync<T>(
PageReader readPage, Func<HistoryReadResult, IEnumerable<T>> extract, int maxValues, NodeId nodeId,
CancellationToken ct)
{
var all = new List<T>();
byte[]? continuationPoint = null;
while (true)
{
var result = await readPage(continuationPoint, false, ct).ConfigureAwait(false);
if (result == null) return all;
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
all.AddRange(extract(result));
continuationPoint = result.ContinuationPoint;
if (continuationPoint is not { Length: > 0 }) return all;
if (maxValues > 0 && all.Count >= maxValues) break;
}
// Stopped by the client's own cap: the server still holds a cursor for this session. Release it; failing to
// is not worth failing a read whose values are already in hand.
try
{
await readPage(continuationPoint, true, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Logger.Warning(ex, "Releasing the HistoryRead continuation point for {NodeId} failed", nodeId);
}
return all;
}
}