feat(historian): #674 HistoryUpdate posture — future-skew bound, Primary gate, SqlServer v1 accepted, QuestDB TTL pin
- HistoryUpdate items with SourceTimestamp > now + ServerHistorian:HistoryUpdateMaxFutureSkew (default 1 day, <=0 disables) => BadOutOfRange in the node manager (every backend, never sent); clock injected via OtOpcUaNodeManager.Clock (host TimeProvider). - HistoryUpdate is Primary-only: off-Primary => BadInvalidState per node, after authorization, same IDriverPrimaryRoleSource verdict as the native-ack gate; HistoryRead ungated. - SqlServer: base schema requirement back to v1; SupportsModifiedTrail (v>=2). On v1 with AutoCreateSchema=false: writes/reads/events/retention work, modified reads + HistoryUpdate => BadHistoryOperationUnsupported with nothing sent, retention skips SampleModified, one Warning per boot. - QuestDB integration test pinning wall-clock TTL (far-future row evicts only partitions older than the TTL by the wall clock), with a positive control. - Docs: Historian.md, Configuration.md, security.md, CLAUDE.md (incl. QuestDB retry duplicates, #674.4 anonymous-readable trail with ACL off, #674.5 not implemented). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -252,13 +252,15 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
if (_primaryRoleSource is { } primaryRoleSource)
|
||||
{
|
||||
_server.SetNativeAlarmAckPrimaryGate(() => primaryRoleSource.ShouldActAsPrimary);
|
||||
// Gitea #674: HistoryUpdate is single-writer — only the Primary rewrites history (BadInvalidState elsewhere).
|
||||
_server.SetHistoryUpdatePrimaryGate(() => primaryRoleSource.ShouldActAsPrimary);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Every driver node registers one (AddOtOpcUaRuntime), so this is a wiring regression, not a mode:
|
||||
// say so rather than let a Secondary silently go back to accepting acks it will revert.
|
||||
_logger.LogWarning(
|
||||
"OtOpcUaServerHostedService: no IDriverPrimaryRoleSource registered — native-alarm acknowledges are accepted on every node regardless of redundancy role");
|
||||
"OtOpcUaServerHostedService: no IDriverPrimaryRoleSource registered — native-alarm acknowledges and HistoryUpdate are accepted on every node regardless of redundancy role");
|
||||
}
|
||||
|
||||
// Wire the reverse-path inbound operator-write gateway: a client write to a writable equipment-tag
|
||||
@@ -317,6 +319,11 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
_server.NodeManager.MaxConcurrentHistoryReadBatches = _serverHistorianOptions.MaxConcurrentHistoryReadBatches;
|
||||
_server.NodeManager.HistoryReadDeadline = _serverHistorianOptions.HistoryReadDeadline;
|
||||
|
||||
// Gitea #674: HistoryUpdate refuses a SourceTimestamp beyond now + HistoryUpdateMaxFutureSkew (BadOutOfRange),
|
||||
// judged against the host clock.
|
||||
_server.NodeManager.HistoryUpdateMaxFutureSkew = _serverHistorianOptions.HistoryUpdateMaxFutureSkew;
|
||||
_server.NodeManager.Clock = _time;
|
||||
|
||||
// ServiceLevel publisher needs IServerInternal — only available after Start.
|
||||
if (_server.CurrentInstance is { } serverInternal)
|
||||
{
|
||||
@@ -382,6 +389,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
// Clear the native-alarm-ack router so a late ack doesn't Tell a stopping DriverHostActor.
|
||||
_server?.SetNativeAlarmAckRouter(null);
|
||||
_server?.SetNativeAlarmAckPrimaryGate(null);
|
||||
_server?.SetHistoryUpdatePrimaryGate(null);
|
||||
// Restore the Null historian so a late HistoryRead doesn't hit a disposed read client.
|
||||
_server?.SetHistorianDataSource(null);
|
||||
_server?.SetHistorianDataUpdater(null);
|
||||
|
||||
@@ -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>
|
||||
@@ -5365,6 +5407,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];
|
||||
@@ -5378,7 +5422,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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5440,22 +5484,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);
|
||||
@@ -5471,6 +5533,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;
|
||||
@@ -5521,6 +5588,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);
|
||||
|
||||
|
||||
@@ -83,6 +83,21 @@ public sealed class OtOpcUaSdkServer : StandardServer
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #674 — wire the redundancy verdict that decides whether this node serves an OPC UA HistoryUpdate
|
||||
/// (<see cref="OtOpcUaNodeManager.HistoryUpdatePrimaryGate"/>); on any other node every authorized node of a
|
||||
/// HistoryUpdate is refused <c>BadInvalidState</c>. Passing <c>null</c> clears it (allow). No-op (returns
|
||||
/// <c>false</c>) when the node manager has not been created yet (mirrors <see cref="SetNativeAlarmAckPrimaryGate"/>).
|
||||
/// </summary>
|
||||
/// <param name="isPrimary">A cheap, non-blocking read of the data-plane Primary verdict; null to allow.</param>
|
||||
/// <returns><c>true</c> when set on a live node manager; <c>false</c> when no node manager exists yet.</returns>
|
||||
public bool SetHistoryUpdatePrimaryGate(Func<bool>? isPrimary)
|
||||
{
|
||||
if (_otOpcUaNodeManager is null) return false;
|
||||
_otOpcUaNodeManager.HistoryUpdatePrimaryGate = isPrimary;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire the reverse-path gateway for inbound operator writes to writable equipment-tag nodes onto the
|
||||
/// created <see cref="OtOpcUaNodeManager"/>. The host calls this after start with an
|
||||
|
||||
@@ -122,6 +122,16 @@ public sealed class ServerHistorianOptions
|
||||
/// </summary>
|
||||
public int MaxTimeWeightedRawRows { get; init; } = 1_000_000;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #674: how far into the future an OPC UA HistoryUpdate (Insert / Replace / Update) item's
|
||||
/// <c>SourceTimestamp</c> may lie, measured from the server clock. An item beyond <c>now + skew</c> is refused
|
||||
/// <c>BadOutOfRange</c> by the node manager before any backend sees it — a far-future row is never a legitimate
|
||||
/// history edit, and on a backend that judges retention against the newest timestamp it could age out every
|
||||
/// older partition. Applies to every backend. The live recorder is not bounded. Non-positive disables the bound.
|
||||
/// Default 1 day.
|
||||
/// </summary>
|
||||
public TimeSpan HistoryUpdateMaxFutureSkew { get; init; } = TimeSpan.FromDays(1);
|
||||
|
||||
/// <summary>Returns operator-facing misconfiguration warnings for an <c>Enabled</c> historian
|
||||
/// (empty when disabled or correctly configured). Pure — the registration logs each entry.
|
||||
/// <para>
|
||||
|
||||
Reference in New Issue
Block a user