fix(historian): #674 review follow-ups — HistoryUpdate boot-window gate, node-level v1 refusal, plan-before-gate, UTC skew compare
- HistoryUpdate Primary gate now reads IHistoryUpdateRoleView (PrimaryGatePolicy.ShouldServeHistoryUpdate): the data-plane verdict but CLOSED until this node's first redundancy snapshot, so both pair nodes cannot accept in the pre-join boot window. Single nodes open on their own singleton's first snapshot; admin-only nodes register no view => no gate. DriverHostActor publishes it at every Primary-verdict publish point. - SqlServer v1: the updater throws NotSupportedException on every arm; the node manager maps it to a node-level BadHistoryOperationUnsupported (was per-item under a Good node for UpdateData/DeleteAtTime). HistoryWrite bit still advertised on v1 (not knowable at materialise time) — documented. - The update plan is evaluated before the Primary gate: an unservable request (Remove, IsDeleteModified, not historized, no updater) is BadHistoryOperationUnsupported off-Primary, not BadInvalidState. - Local-kind SourceTimestamp converted to UTC before the future-skew compare and sent as UTC. - Hosted-service wiring extracted to BuildHistoryUpdatePosture and tested (gate source, skew, clock). - Docs: Historian.md (boot window, ordering, node-level v1, HistoryWrite bit), CLAUDE.md. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -5484,6 +5484,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).
|
||||
@@ -5533,9 +5547,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))
|
||||
@@ -5544,7 +5558,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);
|
||||
}
|
||||
}
|
||||
@@ -5588,13 +5602,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 };
|
||||
@@ -5606,25 +5642,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);
|
||||
@@ -5677,6 +5705,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);
|
||||
|
||||
Reference in New Issue
Block a user