|
|
|
@@ -102,7 +102,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|
|
|
|
/// <see cref="_alarmConditions"/> / <see cref="_notifierFolders"/>.</summary>
|
|
|
|
|
private readonly Dictionary<RealmKey, AlarmNotifierWiring> _alarmNotifierWiring = new();
|
|
|
|
|
/// <summary>Phase C: event-notifier folder NodeId-identifier → the event-history source
|
|
|
|
|
/// name passed to <see cref="IHistorianDataSource.ReadEventsAsync"/>. The equipment-folder NodeId
|
|
|
|
|
/// name passed to <see cref="IHistorianDataSource.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/>. The equipment-folder NodeId
|
|
|
|
|
/// identifier IS the equipment id, which IS the sourceName, so key and value are the same string;
|
|
|
|
|
/// the map's presence (not its value) is what makes a folder an event-history source. Populated by
|
|
|
|
|
/// <see cref="EnsureFolderIsEventNotifier"/> only when a real historian is wired at promotion time,
|
|
|
|
@@ -247,6 +247,48 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|
|
|
|
/// </summary>
|
|
|
|
|
public Func<bool>? NativeAlarmAckPrimaryGate { get; set; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gitea #674 — whether this node may serve an OPC UA HistoryUpdate right now: the redundancy data-plane
|
|
|
|
|
/// verdict (<c>PrimaryGatePolicy.ShouldServiceAsPrimary</c>, as published to <c>IDriverPrimaryRoleSource</c>) —
|
|
|
|
|
/// the same source as <see cref="NativeAlarmAckPrimaryGate"/>.
|
|
|
|
|
/// <para>
|
|
|
|
|
/// <c>false</c> ⇒ every node of the request that passed authorization is refused <c>BadInvalidState</c>
|
|
|
|
|
/// and nothing reaches the updater. Rewriting history is a single-writer operation: the SQL backends'
|
|
|
|
|
/// prior-value capture (and QuestDB's per-process per-tag lock) assume one node serialises a tag's updates,
|
|
|
|
|
/// so both pair nodes accepting updates concurrently could record a wrong prior value in the modification
|
|
|
|
|
/// trail. HistoryRead is NOT gated — reads are served from any node.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// Evaluated AFTER authorization (a caller who may not update learns nothing about the node's role). It must
|
|
|
|
|
/// be a cheap, non-blocking read (the host passes a volatile-backed property). Null (the default) allows — the
|
|
|
|
|
/// single-node posture, the same default <c>UnmanagedDriverPrimaryRoleSource</c> answers with.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </summary>
|
|
|
|
|
public Func<bool>? HistoryUpdatePrimaryGate { get; set; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gitea #674 — how far beyond <see cref="Clock"/>'s now a HistoryUpdate (Insert / Replace / Update) item's
|
|
|
|
|
/// <c>SourceTimestamp</c> may lie. An item past <c>now + skew</c> is refused <c>BadOutOfRange</c> before it
|
|
|
|
|
/// reaches any backend: a far-future row (a 2062 typo) is never a legitimate history edit, and on a backend whose
|
|
|
|
|
/// retention is judged against the newest timestamp it could age out every older partition. Mirrors
|
|
|
|
|
/// <c>ServerHistorianOptions.HistoryUpdateMaxFutureSkew</c>; the Host sets it at <c>StartAsync</c>. Non-positive
|
|
|
|
|
/// disables the bound. Default 1 day. Live recorder writes are not bounded by this.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public TimeSpan HistoryUpdateMaxFutureSkew { get; set; } = TimeSpan.FromDays(1);
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The clock the HistoryUpdate future-skew bound (<see cref="HistoryUpdateMaxFutureSkew"/>) reads. The Host
|
|
|
|
|
/// assigns its <see cref="TimeProvider"/> at <c>StartAsync</c>; tests inject a fixed clock. Defaults to
|
|
|
|
|
/// <see cref="TimeProvider.System"/>; assigning null restores it.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public TimeProvider Clock
|
|
|
|
|
{
|
|
|
|
|
get => _clock;
|
|
|
|
|
set => _clock = value ?? TimeProvider.System;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private volatile TimeProvider _clock = TimeProvider.System;
|
|
|
|
|
|
|
|
|
|
private volatile IOpcUaNodeWriteGateway _nodeWriteGateway = NullOpcUaNodeWriteGateway.Instance;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
@@ -5161,6 +5203,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|
|
|
|
var endUtc = details.EndTime;
|
|
|
|
|
var numValuesPerNode = details.NumValuesPerNode;
|
|
|
|
|
var boundarySkip = 0;
|
|
|
|
|
bool reverse;
|
|
|
|
|
|
|
|
|
|
if (resumed)
|
|
|
|
|
{
|
|
|
|
@@ -5180,36 +5223,59 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|
|
|
|
endUtc = state.EndUtc;
|
|
|
|
|
boundarySkip = state.BoundarySkipCount;
|
|
|
|
|
numValuesPerNode = state.NumValuesPerNode;
|
|
|
|
|
// The direction is the CURSOR's (Gitea #675), never re-derived from the resume request's details: a
|
|
|
|
|
// reverse read resumes reverse, a forward one forward.
|
|
|
|
|
reverse = state.Reverse;
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
// Part 11: with one bound unset, an unset EndTime (MinValue) with a StartTime means "page FORWARD from
|
|
|
|
|
// StartTime" — normalise it to an open upper bound, or the cursor would store MinValue and the read would
|
|
|
|
|
// look reversed. An unset StartTime with an EndTime and a count cap means "page BACKWARD from EndTime"
|
|
|
|
|
// (#675, mirroring raw): the reverse window (MinValue, EndTime]. Without a count cap an end-only read
|
|
|
|
|
// keeps its historical forward [MinValue, EndTime) meaning.
|
|
|
|
|
if (endUtc == DateTime.MinValue && startUtc != DateTime.MinValue)
|
|
|
|
|
endUtc = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc); // Utc kind: a proto Timestamp conversion rejects Unspecified
|
|
|
|
|
else if (numValuesPerNode > 0 && startUtc == DateTime.MinValue && endUtc != DateTime.MinValue)
|
|
|
|
|
(startUtc, endUtc) = (endUtc, new DateTime(0, DateTimeKind.Utc));
|
|
|
|
|
|
|
|
|
|
// Part 11: an unset EndTime (MinValue) with a StartTime means "page forward from StartTime". Normalise it
|
|
|
|
|
// to an open upper bound, or the cursor would store MinValue and a backend would swap the reversed
|
|
|
|
|
// resume window. A resumed read is always forward (the cursor is only ever stored for a forward read),
|
|
|
|
|
// so its direction comes from the cursor, not from the resume request's details.
|
|
|
|
|
if (!resumed && endUtc == DateTime.MinValue && startUtc != DateTime.MinValue)
|
|
|
|
|
endUtc = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc); // Utc kind: a proto Timestamp conversion rejects Unspecified
|
|
|
|
|
// Reverse event reads (start > end) are not paged: the cursor below is forward-only.
|
|
|
|
|
var forward = resumed || startUtc <= endUtc;
|
|
|
|
|
// Part 11: StartTime later than EndTime is a reverse read — events newest-first over (end, start].
|
|
|
|
|
reverse = startUtc > endUtc;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NOT under the node-manager Lock — awaiting the async source is safe here.
|
|
|
|
|
// NumValuesPerNode==0 means "no limit — return ALL values" per OPC UA Part 4/11, so it is translated
|
|
|
|
|
// to UNBOUNDED (EventMaxEvents) rather than the backend's <=0 default-cap sentinel.
|
|
|
|
|
// to UNBOUNDED (EventMaxEvents) rather than the backend's <=0 default-cap sentinel. A resumed page re-reads its
|
|
|
|
|
// boundary timestamp inclusively and then drops the `boundarySkip` ties already emitted, so it asks for that
|
|
|
|
|
// many more rows than the cap — otherwise every resumed page would come back short by the skip.
|
|
|
|
|
var fetch = EventMaxEvents(numValuesPerNode);
|
|
|
|
|
if (numValuesPerNode > 0 && boundarySkip > 0)
|
|
|
|
|
fetch = (int)Math.Min(int.MaxValue - 1L, (long)fetch + boundarySkip);
|
|
|
|
|
var sourceResult = await _historianDataSource.ReadEventsAsync(
|
|
|
|
|
sourceName, startUtc, endUtc, EventMaxEvents(numValuesPerNode), ct).ConfigureAwait(false);
|
|
|
|
|
sourceName, startUtc, endUtc, fetch, reverse, ct).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
// Truncation is the SOURCE's word (non-null continuation point) — never inferred from the count, so a
|
|
|
|
|
// page that happens to be exactly cap-sized but complete never emits a point.
|
|
|
|
|
var truncated = sourceResult.ContinuationPoint is not null && numValuesPerNode > 0 && forward;
|
|
|
|
|
var truncated = sourceResult.ContinuationPoint is not null && numValuesPerNode > 0;
|
|
|
|
|
|
|
|
|
|
IReadOnlyList<HistoricalEvent> events = sourceResult.Events;
|
|
|
|
|
if (resumed && boundarySkip > 0)
|
|
|
|
|
{
|
|
|
|
|
// Drop the events at the boundary timestamp already emitted on the prior page.
|
|
|
|
|
// Drop the events at the boundary timestamp already emitted on the prior page. The resumed read is in
|
|
|
|
|
// the cursor's direction, so in both directions those ties lead the page.
|
|
|
|
|
var i = 0;
|
|
|
|
|
while (i < events.Count && i < boundarySkip && events[i].EventTimeUtc == startUtc) i++;
|
|
|
|
|
if (i > 0) events = events.Skip(i).ToList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The skip over-fetch can leave more than the cap (fewer boundary ties came back than were skipped — pruned
|
|
|
|
|
// between pages): keep the cap, and the dropped tail means the read continues.
|
|
|
|
|
if (numValuesPerNode > 0 && events.Count > numValuesPerNode)
|
|
|
|
|
{
|
|
|
|
|
events = events.Take((int)Math.Min(int.MaxValue, numValuesPerNode)).ToList();
|
|
|
|
|
truncated = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The cursor to store, if any. Saved only AFTER the page is projected, so a projection throw can
|
|
|
|
|
// never orphan a stored cursor.
|
|
|
|
|
HistoryContinuationState? pendingCursor = null;
|
|
|
|
@@ -5222,10 +5288,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|
|
|
|
// with an explicit bounded cap and page WITHIN it (same scheme as raw).
|
|
|
|
|
var resolution = Math.Max(1L, _historianDataSource.TimestampResolutionTicks);
|
|
|
|
|
var overfetchCap = (int)Math.Min(int.MaxValue - 1L, (long)Math.Max(1, MaxTieClusterOverfetch) + 1);
|
|
|
|
|
var cluster = (await _historianDataSource.ReadEventsAsync(
|
|
|
|
|
sourceName, startUtc, startUtc.AddTicks(resolution), overfetchCap, ct)
|
|
|
|
|
IReadOnlyList<HistoricalEvent> cluster = (await _historianDataSource.ReadEventsAsync(
|
|
|
|
|
sourceName, startUtc, startUtc.AddTicks(resolution), overfetchCap, reverse: false, ct)
|
|
|
|
|
.ConfigureAwait(false)).Events;
|
|
|
|
|
|
|
|
|
|
// The over-fetch reads the cluster FORWARD (ascending EventId), but a reverse read emitted its ties — and
|
|
|
|
|
// counted boundarySkip — in descending order. Slice in the read's own direction, as raw does.
|
|
|
|
|
if (reverse) cluster = cluster.Reverse().ToList();
|
|
|
|
|
|
|
|
|
|
if (cluster.Count > MaxTieClusterOverfetch)
|
|
|
|
|
{
|
|
|
|
|
_logger.LogError(
|
|
|
|
@@ -5240,7 +5310,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|
|
|
|
HistoryPaging.SliceTieCluster(
|
|
|
|
|
cluster.Count, boundarySkip, numValuesPerNode, startUtc, endUtc,
|
|
|
|
|
out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip,
|
|
|
|
|
reverse: false, resolutionTicks: resolution);
|
|
|
|
|
reverse: reverse, resolutionTicks: resolution);
|
|
|
|
|
|
|
|
|
|
var slice = new List<HistoricalEvent>(sliceCount);
|
|
|
|
|
for (var i = sliceStart; i < sliceStart + sliceCount; i++) slice.Add(cluster[i]);
|
|
|
|
@@ -5249,17 +5319,20 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|
|
|
|
if (nextStartUtc is { } resumeAt)
|
|
|
|
|
{
|
|
|
|
|
pendingCursor = new HistoryContinuationState(
|
|
|
|
|
sourceName ?? string.Empty, resumeAt, endUtc, nextSkip, numValuesPerNode, HistoryCursorKind.Events);
|
|
|
|
|
sourceName ?? string.Empty, resumeAt, endUtc, nextSkip, numValuesPerNode, HistoryCursorKind.Events,
|
|
|
|
|
Reverse: reverse);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else if (truncated && events.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
// Cursor from the last event: resume at its time INCLUSIVE, skipping the trailing ties emitted.
|
|
|
|
|
// Cursor from the last event: resume at its time INCLUSIVE, skipping the trailing ties emitted. In a
|
|
|
|
|
// reverse read the last event is the OLDEST on the page, and the resumed (end, T] read starts there.
|
|
|
|
|
var boundary = events[^1].EventTimeUtc;
|
|
|
|
|
var skip = 0;
|
|
|
|
|
for (var i = events.Count - 1; i >= 0 && events[i].EventTimeUtc == boundary; i--) skip++;
|
|
|
|
|
pendingCursor = new HistoryContinuationState(
|
|
|
|
|
sourceName ?? string.Empty, boundary, endUtc, skip, numValuesPerNode, HistoryCursorKind.Events);
|
|
|
|
|
sourceName ?? string.Empty, boundary, endUtc, skip, numValuesPerNode, HistoryCursorKind.Events,
|
|
|
|
|
Reverse: reverse);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var historyEvent = ProjectEvents(events, selectClauses);
|
|
|
|
@@ -5280,6 +5353,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|
|
|
|
errors[handle.Index] = StatusCodes.BadTimeout;
|
|
|
|
|
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
|
|
|
|
|
}
|
|
|
|
|
catch (NotSupportedException ex)
|
|
|
|
|
{
|
|
|
|
|
// The backend cannot serve this read's shape — today a reverse (newest-first) read on Gateway / Null
|
|
|
|
|
// (#675). A client request the backend refuses, not a backend fault: Warning, not Error.
|
|
|
|
|
_logger.LogWarning("OtOpcUaNodeManager: HistoryReadEvents for node {NodeId} is not supported by the historian backend: {Reason}",
|
|
|
|
|
handle.NodeId, ex.Message);
|
|
|
|
|
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
|
|
|
|
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
// One node's backend failure must not throw out of the batch — surface Bad for THIS node
|
|
|
|
@@ -5373,6 +5455,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|
|
|
|
List<NodeHandle> nodesToProcess,
|
|
|
|
|
IDictionary<NodeId, NodeState> cache)
|
|
|
|
|
{
|
|
|
|
|
// #674: one "now" per request, so every item of the batch is judged against the same bound.
|
|
|
|
|
var latest = LatestAcceptedSourceTime();
|
|
|
|
|
ServeHistoryUpdate(context, nodesToProcess, results, errors, handle =>
|
|
|
|
|
{
|
|
|
|
|
var details = nodesToUpdate[handle.Index];
|
|
|
|
@@ -5386,7 +5470,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var items = (IList<DataValue>?)details.UpdateValues ?? Array.Empty<DataValue>();
|
|
|
|
|
return (updater, tagname, who, ct) => UpdateDataAsync(updater, tagname, mode, items, who, ct);
|
|
|
|
|
return (updater, tagname, who, ct) => UpdateDataAsync(updater, tagname, mode, items, latest, who, ct);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -5448,22 +5532,40 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// #674: the latest <c>SourceTimestamp</c> a HistoryUpdate item may carry — <see cref="Clock"/>'s now plus
|
|
|
|
|
/// <see cref="HistoryUpdateMaxFutureSkew"/> — or <c>null</c> when the bound is disabled (non-positive skew).
|
|
|
|
|
/// Saturates at <see cref="DateTime.MaxValue"/> rather than overflowing for an absurd configured skew.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <returns>The inclusive upper bound, or null for unbounded.</returns>
|
|
|
|
|
private DateTime? LatestAcceptedSourceTime()
|
|
|
|
|
{
|
|
|
|
|
var skew = HistoryUpdateMaxFutureSkew;
|
|
|
|
|
if (skew <= TimeSpan.Zero) return null;
|
|
|
|
|
var now = _clock.GetUtcNow().UtcDateTime;
|
|
|
|
|
return skew.Ticks > DateTime.MaxValue.Ticks - now.Ticks
|
|
|
|
|
? new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc)
|
|
|
|
|
: now + skew;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Applies one node's UpdateDataDetails: items the server can refuse on its own
|
|
|
|
|
/// (<c>SourceTimestamp == DateTime.MinValue</c> ⇒ <c>BadInvalidTimestamp</c>; a value
|
|
|
|
|
/// <see cref="HistorianValue.TryFromObject"/> cannot type ⇒ <c>BadTypeMismatch</c>) are answered locally,
|
|
|
|
|
/// the rest go to the updater in ONE call, and the per-item codes are stitched back in request order.
|
|
|
|
|
/// (<c>SourceTimestamp == DateTime.MinValue</c> ⇒ <c>BadInvalidTimestamp</c>; a <c>SourceTimestamp</c> later than
|
|
|
|
|
/// <paramref name="latest"/> ⇒ <c>BadOutOfRange</c>, #674; a value <see cref="HistorianValue.TryFromObject"/>
|
|
|
|
|
/// cannot type ⇒ <c>BadTypeMismatch</c>) are answered locally, the rest go to the updater in ONE call, and the
|
|
|
|
|
/// per-item codes are stitched back in request order.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="updater">The wired updater.</param>
|
|
|
|
|
/// <param name="tagname">The node's historian tagname.</param>
|
|
|
|
|
/// <param name="mode">The update mode.</param>
|
|
|
|
|
/// <param name="items">The request's values, in order.</param>
|
|
|
|
|
/// <param name="latest">The latest accepted source time (<see cref="LatestAcceptedSourceTime"/>); null = unbounded.</param>
|
|
|
|
|
/// <param name="who">The caller, recorded in the modification trail.</param>
|
|
|
|
|
/// <param name="ct">The request deadline.</param>
|
|
|
|
|
/// <returns>The node's result, carrying one operation result per item.</returns>
|
|
|
|
|
private static async Task<HistoryUpdateResult> UpdateDataAsync(
|
|
|
|
|
IHistorianDataUpdater updater, string tagname, HistoryUpdateMode mode, IList<DataValue> items,
|
|
|
|
|
HistoryUpdateContext who, CancellationToken ct)
|
|
|
|
|
DateTime? latest, HistoryUpdateContext who, CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
var codes = new uint[items.Count];
|
|
|
|
|
var toSend = new List<HistorizationValue>(items.Count);
|
|
|
|
@@ -5479,6 +5581,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|
|
|
|
{
|
|
|
|
|
codes[i] = StatusCodes.BadInvalidTimestamp;
|
|
|
|
|
}
|
|
|
|
|
else if (latest is { } bound && item.SourceTimestamp > bound)
|
|
|
|
|
{
|
|
|
|
|
// #674: a far-future history edit is refused before any backend sees it (wire timestamps are UTC).
|
|
|
|
|
codes[i] = StatusCodes.BadOutOfRange;
|
|
|
|
|
}
|
|
|
|
|
else if (!HistorianValue.TryFromObject(item.Value, out var value))
|
|
|
|
|
{
|
|
|
|
|
codes[i] = StatusCodes.BadTypeMismatch;
|
|
|
|
@@ -5529,6 +5636,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|
|
|
|
nodesToProcess = AuthorizeHistoryUpdate(context, nodesToProcess, results, errors);
|
|
|
|
|
if (nodesToProcess.Count == 0) return;
|
|
|
|
|
|
|
|
|
|
// #674 — PRIMARY GATE, after authorization (a caller who may not update learns nothing about the node's role).
|
|
|
|
|
// Off-Primary every authorized node is refused BadInvalidState and nothing reaches the updater.
|
|
|
|
|
if (HistoryUpdatePrimaryGate is { } primary && !primary())
|
|
|
|
|
{
|
|
|
|
|
var refusal = new ServiceResult(StatusCodes.BadInvalidState,
|
|
|
|
|
new LocalizedText("This server is not the redundancy Primary; update history on the Primary (the server with the higher ServiceLevel)."));
|
|
|
|
|
foreach (var handle in nodesToProcess)
|
|
|
|
|
{
|
|
|
|
|
errors[handle.Index] = refusal;
|
|
|
|
|
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadInvalidState };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Who is rewriting history — recorded on every modification-trail row (Part 11 ModificationInfo.UserName, #668).
|
|
|
|
|
var who = HistoryUpdateCallerOf(context);
|
|
|
|
|
|
|
|
|
@@ -5619,7 +5741,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|
|
|
|
private static int ClampToInt(uint value) => value > int.MaxValue ? int.MaxValue : (int)value;
|
|
|
|
|
|
|
|
|
|
/// <summary>Map a HistoryRead-Events <c>NumValuesPerNode</c> request cap onto the
|
|
|
|
|
/// <see cref="IHistorianDataSource.ReadEventsAsync"/> <c>maxEvents</c> argument, honouring the OPC UA
|
|
|
|
|
/// <see cref="IHistorianDataSource.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/> <c>maxEvents</c> argument, honouring the OPC UA
|
|
|
|
|
/// Part 4/11 semantics that <c>NumValuesPerNode == 0</c> means "no limit — return ALL values".
|
|
|
|
|
/// We translate 0 to UNBOUNDED (<see cref="int.MaxValue"/>) — a very large positive cap — rather than
|
|
|
|
|
/// the backend's <c>maxEvents <= 0</c> "use the default cap" sentinel, so a client asking for the
|
|
|
|
|