Merge feat/668-modified-values: Part 11 modified-value history (IsReadModified) on SqlServer + QuestDB (#668)
This commit is contained in:
@@ -31,11 +31,20 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
/// prior pages and must be dropped from the head of the next page (tie de-dup).
|
||||
/// </param>
|
||||
/// <param name="NumValuesPerNode">The client's per-page cap; re-applied to every resumed page.</param>
|
||||
/// <param name="IsEvents">
|
||||
/// <c>true</c> for an EVENT HistoryRead cursor (Gitea #669): <paramref name="Tagname"/> then carries the
|
||||
/// registered historian event-source name and the boundary fields are over <c>EventTimeUtc</c>. A cursor
|
||||
/// is only ever resumed by the read kind that issued it, so a raw point presented to an events read
|
||||
/// (or vice versa) is a <c>BadContinuationPointInvalid</c> miss.
|
||||
/// <param name="Kind">
|
||||
/// Which read issued the cursor (Gitea #669 / #668). A cursor is only ever resumed by the read kind that issued it, so a
|
||||
/// raw point presented to an events or modified read (or any other mismatch) is a <c>BadContinuationPointInvalid</c>
|
||||
/// miss. <see cref="HistoryCursorKind.Events"/>: <paramref name="Tagname"/> carries the registered historian
|
||||
/// event-source name and the boundary fields are over <c>EventTimeUtc</c>.
|
||||
/// </param>
|
||||
/// <param name="LastModifiedId">
|
||||
/// <see cref="HistoryCursorKind.Modified"/> only: the <c>ModifiedId</c> of the last returned modification. With
|
||||
/// <paramref name="NextStartUtc"/> (its source time) it is the exclusive resume key; <paramref name="BoundarySkipCount"/>
|
||||
/// is unused because the key is unique.
|
||||
/// </param>
|
||||
/// <param name="WindowStartUtc">
|
||||
/// <see cref="HistoryCursorKind.Modified"/> only: the normalised window start, so a resumed page keeps the original
|
||||
/// window and direction (the modified read resumes by key, not by moving the window start).
|
||||
/// </param>
|
||||
internal sealed record HistoryContinuationState(
|
||||
string Tagname,
|
||||
@@ -43,7 +52,22 @@ internal sealed record HistoryContinuationState(
|
||||
DateTime EndUtc,
|
||||
int BoundarySkipCount,
|
||||
uint NumValuesPerNode,
|
||||
bool IsEvents = false);
|
||||
HistoryCursorKind Kind = HistoryCursorKind.Raw,
|
||||
long LastModifiedId = 0,
|
||||
DateTime WindowStartUtc = default);
|
||||
|
||||
/// <summary>The HistoryRead kind that issued a <see cref="HistoryContinuationState"/>.</summary>
|
||||
internal enum HistoryCursorKind
|
||||
{
|
||||
/// <summary>HistoryReadRaw (values).</summary>
|
||||
Raw,
|
||||
|
||||
/// <summary>HistoryReadEvents (#669).</summary>
|
||||
Events,
|
||||
|
||||
/// <summary>HistoryReadRaw with <c>IsReadModified = true</c> — the Part 11 modification trail (#668).</summary>
|
||||
Modified,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure server-side continuation-point paging decisions for the count-capped variable-history arms
|
||||
|
||||
@@ -4977,8 +4977,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
{
|
||||
if (details.IsReadModified)
|
||||
{
|
||||
// We never serve modified-value history; mark this node unsupported and move on.
|
||||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||||
// Part 11 modified-value history (#668): the prior values every HistoryUpdate recorded. Gated by the
|
||||
// same HistoryRead authorization as a raw read (above); a backend with no trail ⇒ unsupported.
|
||||
work.Add(() => ServeModifiedPagedAsync(
|
||||
handle, session, nodesToRead, results, errors,
|
||||
details.StartTime, details.EndTime, details.NumValuesPerNode,
|
||||
ct));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -5156,7 +5160,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// for THIS node only.
|
||||
var state = _historyContinuationStore.TryTake(session, inboundCp!);
|
||||
// A point is bound to the node that issued it: another node's source name is a miss too.
|
||||
if (state is null || !state.IsEvents || state.Tagname != (sourceName ?? string.Empty))
|
||||
if (state is null || state.Kind != HistoryCursorKind.Events || state.Tagname != (sourceName ?? string.Empty))
|
||||
{
|
||||
errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
|
||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadContinuationPointInvalid };
|
||||
@@ -5237,7 +5241,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
if (nextStartUtc is { } resumeAt)
|
||||
{
|
||||
pendingCursor = new HistoryContinuationState(
|
||||
sourceName ?? string.Empty, resumeAt, endUtc, nextSkip, numValuesPerNode, IsEvents: true);
|
||||
sourceName ?? string.Empty, resumeAt, endUtc, nextSkip, numValuesPerNode, HistoryCursorKind.Events);
|
||||
}
|
||||
}
|
||||
else if (truncated && events.Count > 0)
|
||||
@@ -5247,7 +5251,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
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, IsEvents: true);
|
||||
sourceName ?? string.Empty, boundary, endUtc, skip, numValuesPerNode, HistoryCursorKind.Events);
|
||||
}
|
||||
|
||||
var historyEvent = ProjectEvents(events, selectClauses);
|
||||
@@ -5374,7 +5378,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
}
|
||||
|
||||
var items = (IList<DataValue>?)details.UpdateValues ?? Array.Empty<DataValue>();
|
||||
return (updater, tagname, ct) => UpdateDataAsync(updater, tagname, mode, items, ct);
|
||||
return (updater, tagname, who, ct) => UpdateDataAsync(updater, tagname, mode, items, who, ct);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5390,17 +5394,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
ServeHistoryUpdate(context, nodesToProcess, results, errors, handle =>
|
||||
{
|
||||
var details = nodesToUpdate[handle.Index];
|
||||
// We never keep modified-value history, so there is none to delete.
|
||||
// Deleting the modified-value trail itself (Part 11 IsDeleteModified) is not supported: the trail is the
|
||||
// audit record of every change and is aged out only by retention (#668).
|
||||
if (details.IsDeleteModified) return null;
|
||||
var start = details.StartTime;
|
||||
var end = details.EndTime;
|
||||
// Part 11 requires both bounds of a raw delete; DateTime.MinValue is the wire's "unspecified". Refuse
|
||||
// it here rather than let a backend read MinValue as "from the beginning of time".
|
||||
if (start == DateTime.MinValue || end == DateTime.MinValue)
|
||||
return (_, _, _) => Task.FromResult(new HistoryUpdateResult { StatusCode = StatusCodes.BadInvalidArgument });
|
||||
return async (updater, tagname, ct) =>
|
||||
return (_, _, _, _) => Task.FromResult(new HistoryUpdateResult { StatusCode = StatusCodes.BadInvalidArgument });
|
||||
return async (updater, tagname, who, ct) =>
|
||||
{
|
||||
var code = await updater.DeleteRawAsync(tagname, start, end, ct).ConfigureAwait(false);
|
||||
var code = await updater.DeleteRawAsync(tagname, start, end, who, ct).ConfigureAwait(false);
|
||||
return new HistoryUpdateResult { StatusCode = code };
|
||||
};
|
||||
});
|
||||
@@ -5418,11 +5423,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
ServeHistoryUpdate(context, nodesToProcess, results, errors, handle =>
|
||||
{
|
||||
var times = nodesToUpdate[handle.Index].ReqTimes?.ToList() ?? new List<DateTime>();
|
||||
return async (updater, tagname, ct) =>
|
||||
return async (updater, tagname, who, ct) =>
|
||||
{
|
||||
var codes = times.Count == 0
|
||||
? Array.Empty<uint>()
|
||||
: await updater.DeleteAtTimeAsync(tagname, times, ct).ConfigureAwait(false);
|
||||
: await updater.DeleteAtTimeAsync(tagname, times, who, ct).ConfigureAwait(false);
|
||||
if (codes.Count != times.Count)
|
||||
throw new InvalidOperationException(
|
||||
$"historian updater returned {codes.Count} result(s) for {times.Count} time(s)");
|
||||
@@ -5445,11 +5450,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// <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="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,
|
||||
CancellationToken ct)
|
||||
HistoryUpdateContext who, CancellationToken ct)
|
||||
{
|
||||
var codes = new uint[items.Count];
|
||||
var toSend = new List<HistorizationValue>(items.Count);
|
||||
@@ -5478,7 +5484,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
|
||||
if (toSend.Count > 0)
|
||||
{
|
||||
var backend = await updater.UpdateDataAsync(tagname, mode, toSend, ct).ConfigureAwait(false);
|
||||
var backend = await updater.UpdateDataAsync(tagname, mode, toSend, who, ct).ConfigureAwait(false);
|
||||
if (backend.Count != toSend.Count)
|
||||
throw new InvalidOperationException(
|
||||
$"historian updater returned {backend.Count} result(s) for {toSend.Count} value(s)");
|
||||
@@ -5510,11 +5516,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
List<NodeHandle> nodesToProcess,
|
||||
IList<HistoryUpdateResult> results,
|
||||
IList<ServiceResult> errors,
|
||||
Func<NodeHandle, Func<IHistorianDataUpdater, string, CancellationToken, Task<HistoryUpdateResult>>?> plan)
|
||||
Func<NodeHandle, Func<IHistorianDataUpdater, string, HistoryUpdateContext, CancellationToken, Task<HistoryUpdateResult>>?> plan)
|
||||
{
|
||||
nodesToProcess = AuthorizeHistoryUpdate(context, nodesToProcess, results, errors);
|
||||
if (nodesToProcess.Count == 0) return;
|
||||
|
||||
// Who is rewriting history — recorded on every modification-trail row (Part 11 ModificationInfo.UserName, #668).
|
||||
var who = HistoryUpdateCallerOf(context);
|
||||
|
||||
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
|
||||
{
|
||||
using var deadline = CreateDeadline();
|
||||
@@ -5533,13 +5542,30 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
|
||||
var h = handle;
|
||||
var tag = tagname;
|
||||
work.Add(() => ServeUpdateNodeAsync(h, results, errors, token => operation(updater!, tag, token), ct));
|
||||
work.Add(() => ServeUpdateNodeAsync(h, results, errors, token => operation(updater!, tag, who, token), ct));
|
||||
}
|
||||
|
||||
RunBounded(work, HistoryReadBatchParallelism);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="HistoryUpdateContext"/> of the session behind <paramref name="context"/>: its API-key principal
|
||||
/// (or the reserved <c>anonymous</c> principal) and key id. Only an identity that already passed
|
||||
/// <see cref="AuthorizeHistoryUpdate"/> reaches a backend, so the anonymous fallback for a missing identity is
|
||||
/// defence in depth, not a path.
|
||||
/// </summary>
|
||||
/// <param name="context">The SDK context carrying the session identity.</param>
|
||||
/// <returns>The caller recorded in the modification trail.</returns>
|
||||
internal static HistoryUpdateContext HistoryUpdateCallerOf(ServerSystemContext context)
|
||||
{
|
||||
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity
|
||||
?? context.OperationContext?.UserIdentity as RoleCarryingUserIdentity;
|
||||
return identity is null
|
||||
? new HistoryUpdateContext(OpcUaPrincipals.Anonymous, null)
|
||||
: new HistoryUpdateContext(identity.Principal, identity.ApiKeyId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one node's HistoryUpdate operation and fills its slots. Never throws (the
|
||||
/// <see cref="RunBounded"/> contract): a deadline cancellation is <c>BadTimeout</c>, any backend failure
|
||||
@@ -5950,7 +5976,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// Resume read: take the stored cursor. A miss (unknown / evicted / malformed point) ⇒
|
||||
// BadContinuationPointInvalid for THIS node.
|
||||
var state = _historyContinuationStore.TryTake(session, inboundCp);
|
||||
if (state is null || state.IsEvents
|
||||
if (state is null || state.Kind != HistoryCursorKind.Raw
|
||||
|| !TryGetHistorizedTagname(handle.NodeId, out var boundTag) || boundTag != state.Tagname)
|
||||
{
|
||||
errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
|
||||
@@ -6116,6 +6142,138 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serve one historized variable for a HistoryReadRaw with <c>IsReadModified = true</c> (Part 11 modified-value
|
||||
/// history, Gitea #668), with continuation-point paging. The backend orders the trail by
|
||||
/// <c>(SourceTimeUtc, ModifiedId)</c> and reports truncation itself (<see cref="HistoryModifiedReadResult.HasMore"/>),
|
||||
/// so the cursor is an exact, exclusive key — there are no ties to trim. Window normalisation matches
|
||||
/// <see cref="ServeRawPagedAsync"/>: with a cap, an unset EndTime pages forward to an open bound and an unset
|
||||
/// StartTime pages backward. A cursor is bound to the node's tagname and to the modified read kind; any other
|
||||
/// point is <c>BadContinuationPointInvalid</c>. A backend that keeps no trail (Gateway, Null) throws
|
||||
/// <see cref="NotSupportedException"/> ⇒ <c>BadHistoryOperationUnsupported</c> for this node.
|
||||
/// </summary>
|
||||
/// <param name="handle">The node handle; <c>handle.Index</c> indexes results/errors.</param>
|
||||
/// <param name="session">The session (null on the session-less in-process path ⇒ single-shot).</param>
|
||||
/// <param name="nodesToRead">The per-node read list (inbound continuation points).</param>
|
||||
/// <param name="results">The service-level results list.</param>
|
||||
/// <param name="errors">The service-level errors list.</param>
|
||||
/// <param name="startTimeUtc">Request StartTime.</param>
|
||||
/// <param name="endUtc">Request EndTime.</param>
|
||||
/// <param name="numValuesPerNode">Per-page cap; <c>0</c> = all.</param>
|
||||
/// <param name="ct">Request deadline.</param>
|
||||
private async Task ServeModifiedPagedAsync(
|
||||
NodeHandle handle,
|
||||
ISession? session,
|
||||
IList<HistoryReadValueId> nodesToRead,
|
||||
IList<SdkHistoryReadResult> results,
|
||||
IList<ServiceResult> errors,
|
||||
DateTime startTimeUtc,
|
||||
DateTime endUtc,
|
||||
uint numValuesPerNode,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var inboundCp = nodesToRead[handle.Index].ContinuationPoint;
|
||||
try
|
||||
{
|
||||
if (!TryGetHistorizedTagname(handle.NodeId, out var tagname) || tagname is null)
|
||||
{
|
||||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
||||
return;
|
||||
}
|
||||
|
||||
var startUtc = startTimeUtc;
|
||||
HistoryModifiedCursor? resumeAfter = null;
|
||||
if (inboundCp is { Length: > 0 })
|
||||
{
|
||||
var state = _historyContinuationStore.TryTake(session, inboundCp);
|
||||
if (state is null || state.Kind != HistoryCursorKind.Modified || state.Tagname != tagname)
|
||||
{
|
||||
errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
|
||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadContinuationPointInvalid };
|
||||
return;
|
||||
}
|
||||
|
||||
// The cursor carries the NORMALISED window, so a resumed page never re-derives its direction.
|
||||
startUtc = state.WindowStartUtc;
|
||||
endUtc = state.EndUtc;
|
||||
numValuesPerNode = state.NumValuesPerNode;
|
||||
resumeAfter = new HistoryModifiedCursor(state.NextStartUtc, state.LastModifiedId);
|
||||
}
|
||||
else if (numValuesPerNode > 0)
|
||||
{
|
||||
if (endUtc == DateTime.MinValue && startUtc != DateTime.MinValue)
|
||||
endUtc = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc);
|
||||
else if (startUtc == DateTime.MinValue && endUtc != DateTime.MinValue)
|
||||
(startUtc, endUtc) = (endUtc, new DateTime(0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
var page = await HistorianDataSource
|
||||
.ReadModifiedAsync(tagname, startUtc, endUtc, numValuesPerNode, resumeAfter, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var data = ToHistoryModifiedData(page.Values);
|
||||
byte[]? outboundCp = null;
|
||||
if (page.HasMore && numValuesPerNode > 0 && page.Values.Count > 0)
|
||||
{
|
||||
var last = page.Values[^1];
|
||||
outboundCp = _historyContinuationStore.Save(session, new HistoryContinuationState(
|
||||
tagname, last.Value.SourceTimestampUtc ?? DateTime.MinValue, endUtc, 0, numValuesPerNode,
|
||||
HistoryCursorKind.Modified, last.ModifiedId, startUtc));
|
||||
}
|
||||
|
||||
results[handle.Index] = new SdkHistoryReadResult
|
||||
{
|
||||
StatusCode = page.Values.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
|
||||
HistoryData = new ExtensionObject(data),
|
||||
ContinuationPoint = outboundCp,
|
||||
};
|
||||
errors[handle.Index] = ServiceResult.Good;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
errors[handle.Index] = StatusCodes.BadTimeout;
|
||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
|
||||
}
|
||||
catch (NotSupportedException ex)
|
||||
{
|
||||
// The backend keeps no modification trail (Gateway / Null) — the spec status, not a server fault.
|
||||
_logger.LogDebug(ex, "OtOpcUaNodeManager: backend serves no modified-value history for node {NodeId}", handle.NodeId);
|
||||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryRead (modified) failed for node {NodeId}", handle.NodeId);
|
||||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Project a modification trail into an SDK <see cref="HistoryModifiedData"/>: one prior <see cref="DataValue"/>
|
||||
/// per modification plus a parallel <see cref="ModificationInfo"/> (time, Part 11 update type, user name).
|
||||
/// </summary>
|
||||
/// <param name="values">The modifications, in the order to emit.</param>
|
||||
/// <returns>The populated <see cref="HistoryModifiedData"/>.</returns>
|
||||
internal static HistoryModifiedData ToHistoryModifiedData(IReadOnlyList<HistoryModifiedValue> values)
|
||||
{
|
||||
var dataValues = new DataValueCollection(values.Count);
|
||||
var infos = new ModificationInfoCollection(values.Count);
|
||||
foreach (var m in values)
|
||||
{
|
||||
dataValues.Add(ToSdkDataValue(m.Value));
|
||||
infos.Add(new ModificationInfo
|
||||
{
|
||||
ModificationTime = m.ModificationTimeUtc,
|
||||
UpdateType = (HistoryUpdateType)(int)m.UpdateType,
|
||||
UserName = m.UserName,
|
||||
});
|
||||
}
|
||||
|
||||
return new HistoryModifiedData { DataValues = dataValues, ModificationInfos = infos };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void HistoryReleaseContinuationPoints(
|
||||
ServerSystemContext context,
|
||||
|
||||
Reference in New Issue
Block a user