Merge feat/674-historyupdate-posture follow-ups: HistoryUpdate closed until first redundancy snapshot, node-level v1 refusal, host wiring tests, plan-before-gate, UTC skew (#674 review)

This commit is contained in:
Joseph Doherty
2026-10-11 11:41:36 -04:00
16 changed files with 618 additions and 78 deletions
@@ -5530,6 +5530,20 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
});
}
/// <summary>
/// #674 review: a HistoryUpdate timestamp as UTC. Wire timestamps are UTC; an in-process caller can hand a
/// <see cref="DateTimeKind.Local"/> value, which is converted (comparing its raw ticks against a UTC bound would be
/// off by the zone offset). <see cref="DateTimeKind.Unspecified"/> is treated as UTC, as the backends treat it.
/// </summary>
/// <param name="value">The item's source timestamp.</param>
/// <returns>The same instant as a UTC <see cref="DateTime"/>.</returns>
private static DateTime ToUtc(DateTime value) => value.Kind switch
{
DateTimeKind.Local => value.ToUniversalTime(),
DateTimeKind.Unspecified => DateTime.SpecifyKind(value, DateTimeKind.Utc),
_ => value,
};
/// <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).
@@ -5579,9 +5593,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
codes[i] = StatusCodes.BadInvalidTimestamp;
}
else if (latest is { } bound && item.SourceTimestamp > bound)
else if (latest is { } bound && ToUtc(item.SourceTimestamp) > bound)
{
// #674: a far-future history edit is refused before any backend sees it (wire timestamps are UTC).
// #674: a far-future history edit is refused before any backend sees it.
codes[i] = StatusCodes.BadOutOfRange;
}
else if (!HistorianValue.TryFromObject(item.Value, out var value))
@@ -5590,7 +5604,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
else
{
toSend.Add(new HistorizationValue(item.SourceTimestamp, value, item.StatusCode.Code));
toSend.Add(new HistorizationValue(ToUtc(item.SourceTimestamp), value, item.StatusCode.Code));
sentIndex.Add(i);
}
}
@@ -5634,13 +5648,35 @@ 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.
// Plan BEFORE the Primary gate (#674 review): a request this server can never serve (no updater, a node that is
// not historized, Remove, IsDeleteModified) is answered with the honest BadHistoryOperationUnsupported on every
// node, so a Secondary does not send the client to the Primary for something the Primary would also refuse.
// Planning is pure — it builds closures and reads the in-memory tagname map; nothing reaches a backend.
var updater = _historianDataUpdater;
var planned = new List<(NodeHandle Handle, string Tagname,
Func<IHistorianDataUpdater, string, HistoryUpdateContext, CancellationToken, Task<HistoryUpdateResult>> Operation)>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
var operation = updater is null ? null : plan(handle);
if (operation is null || !TryGetHistorizedTagname(handle.NodeId, out var tagname) || tagname is null)
{
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
continue;
}
planned.Add((handle, tagname, operation));
}
if (planned.Count == 0) return;
// #674 — PRIMARY GATE, after authorization (a caller who may not update learns nothing about the node's role)
// and after planning. Off-Primary every servable 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)
foreach (var (handle, _, _) in planned)
{
errors[handle.Index] = refusal;
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadInvalidState };
@@ -5652,25 +5688,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Who is rewriting history — recorded on every modification-trail row (Part 11 ModificationInfo.UserName, #668).
var who = HistoryUpdateCallerOf(context);
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
WithHistoryReadBatchLimiter(planned.Select(p => p.Handle).ToList(), results, errors, () =>
{
using var deadline = CreateDeadline();
var ct = deadline?.Token ?? CancellationToken.None;
var updater = _historianDataUpdater;
var work = new List<Func<Task>>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
var work = new List<Func<Task>>(planned.Count);
foreach (var (handle, tagname, operation) in planned)
{
var operation = updater is null ? null : plan(handle);
if (operation is null || !TryGetHistorizedTagname(handle.NodeId, out var tagname) || tagname is null)
{
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
continue;
}
var h = handle;
var tag = tagname;
work.Add(() => ServeUpdateNodeAsync(h, results, errors, token => operation(updater!, tag, who, token), ct));
var op = operation;
work.Add(() => ServeUpdateNodeAsync(h, results, errors, token => op(updater!, tag, who, token), ct));
}
RunBounded(work, HistoryReadBatchParallelism);
@@ -5723,6 +5751,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
errors[handle.Index] = StatusCodes.BadTimeout;
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadTimeout };
}
catch (NotSupportedException)
{
// The backend cannot perform this update at all (e.g. a SqlServer historian still at schema v1, with no
// modification trail to record it in, #674) — the spec status for the whole node, not a server fault.
// Mirrors the HistoryRead side's handling of the same exception.
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
}
catch (Exception ex)
{
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryUpdate failed for node {NodeId}", handle.NodeId);