Merge feat/668-modified-values: Part 11 modified-value history (IsReadModified) on SqlServer + QuestDB (#668)

This commit is contained in:
Joseph Doherty
2026-10-11 09:32:13 -04:00
51 changed files with 2173 additions and 195 deletions
@@ -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,