Merge master into feat/677-aggregates

# Conflicts:
#	docs/Historian.md
This commit is contained in:
Joseph Doherty
2026-10-11 11:30:46 -04:00
66 changed files with 2054 additions and 154 deletions
@@ -47,7 +47,7 @@ public class HistoryReadCommand : CommandBase
/// <summary>
/// Gets the optional aggregate name used when the operator wants processed history instead of raw samples.
/// </summary>
[CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, StandardDeviation")]
[CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, StandardDeviation, TimeAverage, TimeAverage2, Total, Total2, Interpolative")]
public string? Aggregate { get; init; }
/// <summary>
@@ -183,6 +183,12 @@ public class HistoryReadCommand : CommandBase
await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync($"{values.Count} values returned.");
}
catch (ServiceResultException ex) when (ex.InnerException is null)
{
// e.g. HistoryReadStatus.ThrowIfRefused: a refusal is an operator-facing outcome, not a crash.
// A wrapped failure (certificate validation, identity rejection on connect) keeps its full detail.
throw new CommandException(ex.Message);
}
finally
{
if (service != null)
@@ -204,8 +210,13 @@ public class HistoryReadCommand : CommandBase
"start" or "first" => AggregateType.Start,
"end" or "last" => AggregateType.End,
"standarddeviation" or "stddev" or "stdev" => AggregateType.StandardDeviation,
"timeaverage" or "timeavg" => AggregateType.TimeAverage,
"timeaverage2" or "timeavg2" => AggregateType.TimeAverage2,
"total" => AggregateType.Total,
"total2" => AggregateType.Total2,
"interpolative" or "interp" => AggregateType.Interpolative,
_ => throw new ArgumentException(
$"Unknown aggregate: '{name}'. Supported: Average, Minimum, Maximum, Count, Start, End, StandardDeviation")
$"Unknown aggregate: '{name}'. Supported: Average, Minimum, Maximum, Count, Start, End, StandardDeviation, TimeAverage, TimeAverage2, Total, Total2, Interpolative")
};
}
}
@@ -85,17 +85,22 @@ internal static class EndpointSelector
$"No endpoint found with security mode '{requestedMode}'. Available endpoints: {available}");
}
// Rewrite endpoint URL hostname to match user-supplied hostname. Necessary
// when the OPC UA server returns a discovery URL using a different hostname
// (e.g. internal DNS name) than the one the operator routed to.
// Rewrite the endpoint URL host AND port to the user-supplied ones (as the SDK's
// CoreClientUtils.SelectEndpoint does). A server behind a port mapping or internal DNS
// advertises its own host:port (docker-dev containers all say :4840); following that would
// open the session on a different server and fail the secure-channel certificate check.
// Only when the scheme matches: a different scheme (https vs opc.tcp) is a different transport.
var serverUri = new Uri(best.EndpointUrl);
var requestedUri = new Uri(endpointUrl);
if (serverUri.Host != requestedUri.Host)
// A URL typed without a port (opc.tcp has no registered default, so Port is -1) keeps the advertised port.
var port = requestedUri.Port > 0 ? requestedUri.Port : serverUri.Port;
if (string.Equals(serverUri.Scheme, requestedUri.Scheme, StringComparison.OrdinalIgnoreCase)
&& (serverUri.Host != requestedUri.Host || serverUri.Port != port))
{
var builder = new UriBuilder(best.EndpointUrl) { Host = requestedUri.Host };
var builder = new UriBuilder(best.EndpointUrl) { Host = requestedUri.Host, Port = port };
best.EndpointUrl = builder.ToString();
Logger.Debug("Rewrote endpoint host from {ServerHost} to {RequestedHost}", serverUri.Host,
requestedUri.Host);
Logger.Debug("Rewrote endpoint from {ServerHost}:{ServerPort} to {RequestedHost}:{RequestedPort}",
serverUri.Host, serverUri.Port, requestedUri.Host, port);
}
return best;
@@ -24,6 +24,11 @@ public static class AggregateTypeMapper
AggregateType.Start => ObjectIds.AggregateFunction_Start,
AggregateType.End => ObjectIds.AggregateFunction_End,
AggregateType.StandardDeviation => ObjectIds.AggregateFunction_StandardDeviationPopulation,
AggregateType.TimeAverage => ObjectIds.AggregateFunction_TimeAverage,
AggregateType.TimeAverage2 => ObjectIds.AggregateFunction_TimeAverage2,
AggregateType.Total => ObjectIds.AggregateFunction_Total,
AggregateType.Total2 => ObjectIds.AggregateFunction_Total2,
AggregateType.Interpolative => ObjectIds.AggregateFunction_Interpolative,
_ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, "Unknown AggregateType value.")
};
}
@@ -24,5 +24,20 @@ public enum AggregateType
End,
/// <summary>Population standard deviation of values in the interval.</summary>
StandardDeviation
StandardDeviation,
/// <summary>Time-weighted average, interpolated bounds (Part 13 TimeAverage).</summary>
TimeAverage,
/// <summary>Time-weighted average, stepped bounds (Part 13 TimeAverage2).</summary>
TimeAverage2,
/// <summary>Time-weighted integral over the interval (Part 13 Total).</summary>
Total,
/// <summary>Time-weighted integral, stepped bounds (Part 13 Total2).</summary>
Total2,
/// <summary>Interpolated value at the interval start (Part 13 Interpolative).</summary>
Interpolative
}
@@ -53,7 +53,12 @@ public partial class HistoryViewModel : ObservableObject
AggregateType.Count,
AggregateType.Start,
AggregateType.End,
AggregateType.StandardDeviation
AggregateType.StandardDeviation,
AggregateType.TimeAverage,
AggregateType.TimeAverage2,
AggregateType.Total,
AggregateType.Total2,
AggregateType.Interpolative
];
/// <summary>Gets a value indicating whether an aggregate read is selected.</summary>
@@ -88,7 +88,7 @@ public sealed class HistoryReadLimitExceededException : InvalidOperationExceptio
}
/// <summary>
/// One row returned by <see cref="IHistorianDataSource.ReadEventsAsync"/> — a historical
/// One row returned by <see cref="IHistorianDataSource.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/> — a historical
/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the
/// Server needs to populate a <c>HistoryEventFieldList</c> for HistoryReadEvents responses.
/// </summary>
@@ -106,8 +106,8 @@ public sealed record HistoricalEvent(
string? Message,
ushort Severity);
/// <summary>Result of a <see cref="IHistorianDataSource.ReadEventsAsync"/> call.</summary>
/// <param name="Events">Events in chronological order by <c>EventTimeUtc</c>.</param>
/// <summary>Result of a <see cref="IHistorianDataSource.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/> call.</summary>
/// <param name="Events">Events ordered by <c>EventTimeUtc</c> in the read's direction — ascending, or descending for a reverse read (#675).</param>
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
public sealed record HistoricalEventsResult(
IReadOnlyList<HistoricalEvent> Events,
@@ -79,7 +79,7 @@ public interface IHistorianDataSource : IDisposable
/// <c>uint maxValuesPerNode</c> because their OPC UA HistoryRead surface has no
/// equivalent "use default" sentinel.
///
/// This surface declares <see cref="ReadAtTimeAsync"/> and <see cref="ReadEventsAsync"/>
/// This surface declares <see cref="ReadAtTimeAsync"/> and <see cref="ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/>
/// as required members — a server-side historian owns the full read surface.
/// </remarks>
/// <param name="sourceName">The source name to filter events, or null to return events from all sources.</param>
@@ -101,6 +101,46 @@ public interface IHistorianDataSource : IDisposable
int maxEvents,
CancellationToken cancellationToken);
/// <summary>
/// Read historical alarm / event records in a chosen direction — the overload the server's HistoryReadEvents
/// paging calls (Gitea #675).
/// </summary>
/// <remarks>
/// <para>
/// The bounds may be given in either order; let <c>lo</c>/<c>hi</c> be the earlier/later of them. Forward
/// (<paramref name="reverse"/> <c>false</c>) is exactly <see cref="ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/>:
/// <c>[lo, hi)</c> ascending by <c>(EventTimeUtc, EventId)</c>. Reverse is the Part 11 reverse window
/// <c>(lo, hi]</c> in DESCENDING <c>(EventTimeUtc, EventId)</c> order, so a cap keeps the LATEST
/// <paramref name="maxEvents"/> events — the mirror of forward, with the same tie-breaker reversed, so a
/// resumed reverse page that re-reads its boundary timestamp sees the boundary ties in the same order.
/// <paramref name="maxEvents"/> and the continuation-point contract are as for the forward read.
/// </para>
/// <para>
/// The default forwards a forward read to the five-argument overload and throws
/// <see cref="NotSupportedException"/> for a reverse one — a backend that cannot read newest-first (Gateway,
/// Null) — which the server reports as <c>BadHistoryOperationUnsupported</c> rather than returning the
/// earliest events in ascending order as if they were the answer.
/// </para>
/// </remarks>
/// <param name="sourceName">The source name to filter events, or null to return events from all sources.</param>
/// <param name="startUtc">One bound of the window (UTC).</param>
/// <param name="endUtc">The other bound of the window (UTC).</param>
/// <param name="maxEvents">The cap; non-positive uses the backend default (see the five-argument overload).</param>
/// <param name="reverse"><c>true</c> to read newest-first over <c>(lo, hi]</c>.</param>
/// <param name="cancellationToken">Cancellation.</param>
/// <returns>The matching events in the read's direction, with a continuation point if truncated.</returns>
/// <exception cref="NotSupportedException">A reverse read on a backend that cannot read newest-first.</exception>
Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName,
DateTime startUtc,
DateTime endUtc,
int maxEvents,
bool reverse,
CancellationToken cancellationToken) =>
reverse
? throw new NotSupportedException("This historian backend cannot read events newest-first (reverse).")
: ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, cancellationToken);
/// <summary>
/// Point-in-time health snapshot for diagnostics and dashboards. Pure
/// observation; never blocks on backend I/O.
@@ -125,6 +125,21 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
}
}
/// <inheritdoc />
/// <remarks>
/// A reverse (newest-first) read is refused with <see cref="NotSupportedException"/> — the server reports
/// <c>BadHistoryOperationUnsupported</c> (Gitea #675). The gateway's <c>ReadEvents</c> streams ascending only and
/// rejects <c>end &lt; start</c>, so the only way to serve "the latest N" would be to drain the whole window and
/// keep its tail, an unbounded read the cap exists to prevent. Nothing is sent.
/// </remarks>
public Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse,
CancellationToken cancellationToken) =>
reverse
? throw new NotSupportedException(
"The HistorianGateway backend reads events oldest-first only; a reverse (newest-first) events read is not supported.")
: ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, cancellationToken);
/// <inheritdoc />
public async Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
@@ -361,8 +361,18 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
/// <c>maxEvents + 1</c> rows are fetched; when the extra row arrives it is dropped and the continuation point is
/// the UTF-8 of <see cref="EventContinuation.Encode"/> for that first not-returned event.
/// </remarks>
public Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) =>
ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, reverse: false, cancellationToken);
/// <inheritdoc />
/// <remarks>
/// Forward is the five-argument read. Reverse (Gitea #675) is the (rounded) <c>(lo, hi]</c> ordered by
/// <c>(ts, event_id)</c> DESCENDING, so <c>LIMIT</c> keeps the latest events; truncation and the continuation point
/// work as forward.
/// </remarks>
public async Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken)
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse, CancellationToken cancellationToken)
{
// No stored event can carry a source longer than the limit, so an over-long filter matches nothing.
if (sourceName is not null && sourceName.Length > HistorianTagNames.MaxLength)
@@ -376,9 +386,9 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
var bounded = maxEvents > 0 && maxEvents < int.MaxValue;
var sql =
$"SELECT event_id, source, ts, received_utc, message, severity FROM {_conn.EventTable} " +
"WHERE ts >= $1 AND ts < $2" +
(reverse ? "WHERE ts > $1 AND ts <= $2" : "WHERE ts >= $1 AND ts < $2") +
(sourceName is null ? "" : " AND source = $3") +
" ORDER BY ts ASC, event_id ASC" +
(reverse ? " ORDER BY ts DESC, event_id DESC" : " ORDER BY ts ASC, event_id ASC") +
(bounded ? (sourceName is null ? " LIMIT $3" : " LIMIT $4") : "");
await using var conn = await _conn.OpenAsync(cancellationToken).ConfigureAwait(false);
@@ -137,7 +137,10 @@ internal sealed class QuestDbSchemaBootstrapper
// The ALTER is a WAL transaction: tables() keeps the old value until it applies, so do NOT re-read to verify.
await using (var alter = _conn.CreateCommand(conn, QuestDbTables.AlterTtl(table, _options.RetentionDays)))
await alter.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
_log.LogInformation("Applied TTL {Days} day(s) to QuestDB table {Table}.", _options.RetentionDays, table);
if (_options.RetentionDays is { } days)
_log.LogInformation("Applied TTL {Days} day(s) to QuestDB table {Table}.", days, table);
else
_log.LogInformation("Cleared TTL on QuestDB table {Table}.", table);
}
}
}
@@ -27,4 +27,10 @@ public static class HistorianStatusCodes
/// status, or a tag name longer than <see cref="HistorianTagNames.MaxLength"/>.
/// </summary>
public const uint BadInvalidArgument = 0x80AB0000u;
/// <summary>
/// A modified-value read or HistoryUpdate item against a SQL Server schema still at version 1 (no Part 11
/// modification trail, Gitea #674): refused without touching the database.
/// </summary>
public const uint BadHistoryOperationUnsupported = 0x80720000u;
}
@@ -154,6 +154,12 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
var bounded = maxValues > 0 && maxValues < int.MaxValue;
try
{
// #674: a v1 schema has no SampleModified. NotSupportedException is the interface's "no trail" signal (the
// node manager reports BadHistoryOperationUnsupported); it is not a backend failure and sends no query.
await _bootstrap.EnsureAsync(cancellationToken).ConfigureAwait(false);
if (!_bootstrap.SupportsModifiedTrail)
throw new NotSupportedException(
$"Historian schema [{_conn.Schema}] is below version {SqlServerSchemaBootstrapper.ModifiedTrailVersion}: no modified-value history.");
var values = new List<HistoryModifiedValue>();
var hasMore = false;
var tagId = await ResolveAsync(fullReference, cancellationToken).ConfigureAwait(false);
@@ -219,7 +225,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
RecordSuccess();
return new HistoryModifiedReadResult(values, hasMore);
}
catch (Exception ex)
catch (Exception ex) when (ex is not NotSupportedException)
{
RecordFailure(ex);
throw;
@@ -314,8 +320,18 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
/// of <c>"{EventTimeUtc.Ticks}|{EventId}"</c> of that first not-returned event (opaque to callers; only
/// its presence is contractual).
/// </remarks>
public Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) =>
ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, reverse: false, cancellationToken);
/// <inheritdoc />
/// <remarks>
/// Forward is the five-argument read. Reverse (Gitea #675) is <c>(lo, hi]</c> ordered by
/// <c>(EventTimeUtc, EventId)</c> DESCENDING — the clustered index scanned backwards — so <c>TOP</c> keeps the latest
/// events; truncation and the continuation point work as forward.
/// </remarks>
public async Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken)
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse, CancellationToken cancellationToken)
{
// No stored event can carry a source longer than the column (the alarm writer dead-letters it rather than
// truncating), so an over-long filter matches nothing — never a truncated prefix of someone else's source.
@@ -328,9 +344,10 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
var bounded = maxEvents > 0 && maxEvents < int.MaxValue;
var sql =
$"SELECT {(bounded ? "TOP (@take) " : "")}EventId, SourceName, EventTimeUtc, ReceivedTimeUtc, Message, Severity " +
$"FROM {_conn.Q("Event")} WHERE EventTimeUtc >= @lo AND EventTimeUtc < @hi" +
$"FROM {_conn.Q("Event")} WHERE " +
(reverse ? "EventTimeUtc > @lo AND EventTimeUtc <= @hi" : "EventTimeUtc >= @lo AND EventTimeUtc < @hi") +
(sourceName is null ? "" : " AND SourceName = @source") +
" ORDER BY EventTimeUtc ASC, EventId ASC";
(reverse ? " ORDER BY EventTimeUtc DESC, EventId DESC" : " ORDER BY EventTimeUtc ASC, EventId ASC");
await using var conn = await _conn.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = _conn.CreateCommand(conn, sql);
@@ -27,21 +27,44 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
/// value from <c>deleted.*</c> (NULL for an insert), the caller's <see cref="HistoryUpdateContext"/>, the
/// <see cref="HistoryModificationType"/> and <c>SYSUTCDATETIME()</c>. One statement means the sample change and its
/// audit row commit or roll back together: a failed update leaves no audit row.</para>
/// <para><b>Schema v1 (Gitea #674).</b> Without the trail (<see cref="SqlServerSchemaBootstrapper.SupportsModifiedTrail"/>
/// false) a change could not be audited, so every item (and DeleteRaw's single result) reports
/// <c>BadHistoryOperationUnsupported</c> and nothing is sent to the database beyond the one-time schema check.</para>
/// </summary>
internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
{
private readonly SqlHistorianConnection _conn;
private readonly SqlServerTagCatalog _tags;
private readonly SqlServerSchemaBootstrapper _bootstrap;
/// <summary>Creates the updater.</summary>
/// <param name="conn">Connection helper.</param>
/// <param name="bootstrap">Schema bootstrapper — decides whether the modification trail exists (#674).</param>
/// <param name="tags">Tag catalog (runs the schema bootstrap itself).</param>
public SqlServerHistorianDataUpdater(SqlHistorianConnection conn, SqlServerTagCatalog tags)
public SqlServerHistorianDataUpdater(SqlHistorianConnection conn, SqlServerSchemaBootstrapper bootstrap,
SqlServerTagCatalog tags)
{
_conn = conn;
_bootstrap = bootstrap;
_tags = tags;
}
/// <summary>#674: ensures the schema and reports whether the Part 11 trail exists (no SQL after the first success).</summary>
/// <param name="ct">Cancellation.</param>
/// <returns>True when the modification trail is available.</returns>
private async Task<bool> TrailAvailableAsync(CancellationToken ct)
{
await _bootstrap.EnsureAsync(ct).ConfigureAwait(false);
return _bootstrap.SupportsModifiedTrail;
}
private static uint[] Unsupported(int count)
{
var codes = new uint[count];
Array.Fill(codes, HistorianStatusCodes.BadHistoryOperationUnsupported);
return codes;
}
/// <inheritdoc/>
public async Task<IReadOnlyList<uint>> UpdateDataAsync(string tag, HistoryUpdateMode mode,
IReadOnlyList<HistorizationValue> values, HistoryUpdateContext context, CancellationToken ct)
@@ -49,6 +72,7 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
ArgumentNullException.ThrowIfNull(tag);
ArgumentNullException.ThrowIfNull(values);
ArgumentNullException.ThrowIfNull(context);
if (!await TrailAvailableAsync(ct).ConfigureAwait(false)) return Unsupported(values.Count);
var plan = HistoryUpdatePlanner.Plan(tag, mode, values);
if (plan.Winners.Count == 0) return HistoryUpdatePlanner.Complete(plan, mode, new Dictionary<long, UpsertOutcome>());
@@ -68,6 +92,7 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
{
ArgumentNullException.ThrowIfNull(tag);
ArgumentNullException.ThrowIfNull(context);
if (!await TrailAvailableAsync(ct).ConfigureAwait(false)) return HistorianStatusCodes.BadHistoryOperationUnsupported;
if (!HistorianTagNames.IsValid(tag)) return HistorianStatusCodes.BadInvalidArgument;
var tagId = await _tags.TryGetTagIdAsync(tag, ct).ConfigureAwait(false);
if (tagId is null) return HistoryUpdatePlanner.DeleteRawResult(tagKnown: false, 0);
@@ -95,6 +120,7 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
ArgumentNullException.ThrowIfNull(tag);
ArgumentNullException.ThrowIfNull(timesUtc);
ArgumentNullException.ThrowIfNull(context);
if (!await TrailAvailableAsync(ct).ConfigureAwait(false)) return Unsupported(timesUtc.Count);
if (!HistorianTagNames.IsValid(tag))
{
var refused = new uint[timesUtc.Count];
@@ -18,8 +18,10 @@ public sealed partial class SqlServerHistorianOptions
/// <summary>
/// <c>true</c> creates the schema and applies missing scripts on first use; <c>false</c> only verifies
/// that the required schema version (<c>SqlServerSchemaBootstrapper.RequiredVersion</c>, currently 2) exists and
/// fails every historian call with a clear error otherwise.
/// that the base schema version (<c>SqlServerSchemaBootstrapper.RequiredVersion</c>, 1) exists and fails every
/// historian call with a clear error otherwise. A verified v1 schema (no Part 11 modification trail, script 002
/// not applied) is served with modified reads and HistoryUpdate refused <c>BadHistoryOperationUnsupported</c>
/// and one Warning per boot (Gitea #674).
/// </summary>
public bool AutoCreateSchema { get; set; } = true;
@@ -99,7 +99,7 @@ public sealed class SqlServerHistorianProvider : IHistorianBackendProvider
public IHistorianDataUpdater? CreateUpdater(IServiceProvider sp)
{
var s = Get(sp);
return new SqlServerHistorianDataUpdater(s.Conn, s.Catalog);
return new SqlServerHistorianDataUpdater(s.Conn, s.Bootstrap, s.Catalog);
}
/// <inheritdoc />
@@ -11,7 +11,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
/// <c>DELETE TOP(n)</c> batches (under the lock-escalation threshold). Samples are purged per tag so each
/// delete is a seek on the clustered key <c>(TagId, SourceTimeUtc)</c>; events seek on <c>CX_Event_Time</c>.
/// A session <c>sp_getapplock</c> (timeout 0) serialises purgers that share one database; a loser skips.
/// To purge another table later, add it to <see cref="PurgeAsync"/>.
/// To purge another table later, add it to <see cref="PurgeAsync"/>. On a v1 schema (no Part 11 trail, Gitea #674)
/// the <c>SampleModified</c> purge is skipped and absent from the results.
/// </summary>
internal sealed class SqlServerHistorianRetention : IHistorianRetention
{
@@ -42,6 +43,9 @@ internal sealed class SqlServerHistorianRetention : IHistorianRetention
/// <summary>Test seam for <see cref="SqlConnection.ClearPool"/>.</summary>
internal Action<SqlConnection> ClearPool { get; set; } = SqlConnection.ClearPool;
/// <summary>Test seam: the release statement. Tests point it at a failing statement to exercise the pool-clear fallback.</summary>
internal string ReleaseSql { get; set; } = "EXEC sp_releaseapplock @Resource = @res, @LockOwner = 'Session';";
public async Task<IReadOnlyList<RetentionPurgeResult>> PurgeAsync(CancellationToken ct)
{
await _bootstrap.EnsureAsync(ct).ConfigureAwait(false);
@@ -69,6 +73,8 @@ internal sealed class SqlServerHistorianRetention : IHistorianRetention
return [];
}
// #674: a v1 schema has no SampleModified table — skip its purge rather than fail the whole pass.
var trail = _bootstrap.SupportsModifiedTrail;
long samples = 0, modified = 0;
var tagIds = new List<int>();
await using (var cmd = _conn.CreateCommand(conn, $"SELECT TagId FROM {_conn.Q("Tag")}"))
@@ -82,21 +88,30 @@ internal sealed class SqlServerHistorianRetention : IHistorianRetention
cutoff, id, ct).ConfigureAwait(false);
// The Part 11 modification trail (#668) ages out on the SAME source-time cutoff as the samples it
// describes; a purge never writes to it (no trigger, no OUTPUT INTO).
modified += await DeleteLoopAsync(conn,
$"DELETE TOP (@n) FROM {_conn.Q("SampleModified")} WHERE TagId = @id AND SourceTimeUtc < @cutoff",
cutoff, id, ct).ConfigureAwait(false);
if (trail)
{
modified += await DeleteLoopAsync(conn,
$"DELETE TOP (@n) FROM {_conn.Q("SampleModified")} WHERE TagId = @id AND SourceTimeUtc < @cutoff",
cutoff, id, ct).ConfigureAwait(false);
}
}
var events = await DeleteLoopAsync(conn,
$"DELETE TOP (@n) FROM {_conn.Q("Event")} WHERE EventTimeUtc < @cutoff",
cutoff, null, ct).ConfigureAwait(false);
return
[
new RetentionPurgeResult("Sample", samples),
new RetentionPurgeResult("SampleModified", modified),
new RetentionPurgeResult("Event", events),
];
return trail
?
[
new RetentionPurgeResult("Sample", samples),
new RetentionPurgeResult("SampleModified", modified),
new RetentionPurgeResult("Event", events),
]
:
[
new RetentionPurgeResult("Sample", samples),
new RetentionPurgeResult("Event", events),
];
}
finally
{
@@ -137,8 +152,7 @@ internal sealed class SqlServerHistorianRetention : IHistorianRetention
{
try
{
await using var cmd = _conn.CreateCommand(conn,
"EXEC sp_releaseapplock @Resource = @res, @LockOwner = 'Session';");
await using var cmd = _conn.CreateCommand(conn, ReleaseSql);
cmd.Parameters.Add(new SqlParameter("@res", SqlDbType.NVarChar, 255) { Value = LockResource });
await cmd.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
}
@@ -15,15 +15,27 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
/// SQL, so a missing schema (or a SQL outage) does not serialise every write, read and alarm event behind one
/// semaphore and one round-trip each.
/// With <c>AutoCreateSchema=false</c> it only verifies <c>MAX(Version) &gt;= </c><see cref="RequiredVersion"/>.
/// <para><b>Schema v1 is accepted (Gitea #674).</b> A database at version 1 (no <c>SampleModified</c>) still serves
/// live writes, raw / processed / at-time reads, alarm events and retention; only the Part 11 modification trail is
/// unavailable — <see cref="SupportsModifiedTrail"/> is false, modified reads and HistoryUpdate report
/// <c>BadHistoryOperationUnsupported</c> without touching SQL, and one Warning is logged per instance (= per boot: the
/// provider shares one bootstrapper). Before #674 a v1 database refused EVERY historian call, continuous-historization
/// drains included, until script 002 was applied by hand.</para>
/// </summary>
internal sealed partial class SqlServerSchemaBootstrapper
{
/// <summary>
/// The schema version this build requires: 2 = <c>002_sample_modified.sql</c> (Part 11 modified-value
/// history, Gitea #668). An existing v1 schema is upgraded in place on the next bootstrap when
/// <c>AutoCreateSchema</c> is on; otherwise the script must be applied by hand.
/// The schema version this build requires to serve at all: 1 = <c>001_initial.sql</c>. Gitea #674 lowered it from
/// 2 — a missing modification trail disables only the features that need it (<see cref="ModifiedTrailVersion"/>).
/// </summary>
public const int RequiredVersion = 2;
public const int RequiredVersion = 1;
/// <summary>
/// The schema version that carries the Part 11 modification trail: 2 = <c>002_sample_modified.sql</c> (Gitea #668).
/// An auto-creating bootstrap always upgrades to it; with <c>AutoCreateSchema=false</c> the script must be applied by
/// hand (and the node restarted — the verdict is taken once per instance).
/// </summary>
public const int ModifiedTrailVersion = 2;
private const string LockResource = "OtOpcUaHistorianSchema";
private const int LockTimeoutMs = 30_000;
@@ -34,6 +46,13 @@ internal sealed partial class SqlServerSchemaBootstrapper
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly BootstrapFailureCache _failure; // guarded by _gate
private volatile bool _ready;
private volatile bool _supportsModifiedTrail;
/// <summary>
/// Whether the schema carries the Part 11 modification trail (version &gt;= <see cref="ModifiedTrailVersion"/>).
/// Meaningful only after <see cref="EnsureAsync"/> has succeeded (false before). Taken once per instance.
/// </summary>
public bool SupportsModifiedTrail => _supportsModifiedTrail;
/// <summary>Creates the bootstrapper.</summary>
/// <param name="conn">Connection helper.</param>
@@ -99,6 +118,13 @@ internal sealed partial class SqlServerSchemaBootstrapper
throw new HistorianSchemaMissingException(
$"Historian schema [{_conn.Schema}] is missing version {RequiredVersion} (found {v}) and AutoCreateSchema is false. " +
"Apply the docs/historian/sqlserver/NNN_*.sql scripts above version " + v + " in order, or enable AutoCreateSchema.");
_supportsModifiedTrail = v >= ModifiedTrailVersion;
if (!_supportsModifiedTrail)
_log.LogWarning("Historian schema [{Schema}] is at version {Version}, below {TrailVersion}, and AutoCreateSchema is false: " +
"the Part 11 modification trail is unavailable, so modified-value history reads and HistoryUpdate report " +
"BadHistoryOperationUnsupported and retention skips SampleModified. Live historization, reads, events and retention " +
"are unaffected. Apply docs/historian/sqlserver/002_sample_modified.sql and restart the node to enable them.",
_conn.Schema, v, ModifiedTrailVersion);
}
private async Task ApplyAsync(SqlConnection conn, CancellationToken ct)
@@ -0,0 +1,28 @@
using ZB.MOM.WW.OtOpcUa.Cluster;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// <summary>
/// Names the per-role appsettings overlay (<c>appsettings.{suffix}.json</c>) for a node.
/// </summary>
/// <remarks>
/// The suffix is built from the node's PLAIN roles only. <c>cluster-{ClusterId}</c> roles are mesh-membership
/// tags (and carry a verbatim, operator-chosen ClusterId), not configuration profiles — folding one into the
/// filename produced names like <c>appsettings.admin-cluster-MAIN-driver.json</c> that never exist, so on every
/// node with a cluster role the shipped overlays silently never loaded.
/// </remarks>
internal static class RoleOverlay
{
/// <summary>
/// The overlay filename suffix: the plain (non-cluster) roles, ordinal-sorted and joined with <c>-</c>, or
/// <see langword="null"/> when the node has none.
/// </summary>
public static string? Suffix(IReadOnlyList<string> gossipedRoles)
{
var plain = gossipedRoles
.Where(r => !string.IsNullOrWhiteSpace(r) && !RoleParser.IsClusterRole(r))
.OrderBy(r => r, StringComparer.Ordinal)
.ToList();
return plain.Count == 0 ? null : string.Join('-', plain);
}
}
@@ -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);
+3 -2
View File
@@ -90,8 +90,9 @@ builder.WebHost.UseStaticWebAssets();
builder.Host.UseWindowsService(options => options.ServiceName = "OtOpcUaHost");
// Per-role appsettings overlay: appsettings.{role}.json (single role) or appsettings.admin-driver.json
// (both). Optional — base appsettings.json carries enough to boot if these don't exist.
var roleSuffix = roles.Count == 0 ? null : string.Join('-', roles.OrderBy(r => r, StringComparer.Ordinal));
// (both). Optional — base appsettings.json carries enough to boot if these don't exist. Built from the
// PLAIN roles only (#678.6): a cluster-{ClusterId} role in the name pointed at a file that never exists.
var roleSuffix = RoleOverlay.Suffix(roles);
if (roleSuffix is not null)
{
builder.Configuration.AddJsonFile($"appsettings.{roleSuffix}.json", optional: true, reloadOnChange: true);
@@ -3,7 +3,7 @@
"MinimumLevel": {
"Default": "Information",
"Override": {
"Opc.Ua": "Debug",
"Opc.Ua": "Information",
"Akka": "Information"
}
}
@@ -46,6 +46,11 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <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>
/// <param name="Reverse">
/// <see cref="HistoryCursorKind.Events"/> only (Gitea #675): the read is newest-first over
/// <c>(EndUtc, NextStartUtc]</c>. A resumed events read takes its direction from here, never from the resume
/// request's details. Raw keeps deriving it from the window (<c>NextStartUtc &gt; EndUtc</c>).
/// </param>
internal sealed record HistoryContinuationState(
string Tagname,
DateTime NextStartUtc,
@@ -54,7 +59,8 @@ internal sealed record HistoryContinuationState(
uint NumValuesPerNode,
HistoryCursorKind Kind = HistoryCursorKind.Raw,
long LastModifiedId = 0,
DateTime WindowStartUtc = default);
DateTime WindowStartUtc = default,
bool Reverse = false);
/// <summary>The HistoryRead kind that issued a <see cref="HistoryContinuationState"/>.</summary>
internal enum HistoryCursorKind
@@ -102,7 +102,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <see cref="_alarmConditions"/> / <see cref="_notifierFolders"/>.</summary>
private readonly Dictionary<RealmKey, AlarmNotifierWiring> _alarmNotifierWiring = new();
/// <summary>Phase C: event-notifier folder NodeId-identifier → the event-history source
/// name passed to <see cref="IHistorianDataSource.ReadEventsAsync"/>. The equipment-folder NodeId
/// name passed to <see cref="IHistorianDataSource.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/>. The equipment-folder NodeId
/// identifier IS the equipment id, which IS the sourceName, so key and value are the same string;
/// the map's presence (not its value) is what makes a folder an event-history source. Populated by
/// <see cref="EnsureFolderIsEventNotifier"/> only when a real historian is wired at promotion time,
@@ -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>
@@ -5161,6 +5203,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var endUtc = details.EndTime;
var numValuesPerNode = details.NumValuesPerNode;
var boundarySkip = 0;
bool reverse;
if (resumed)
{
@@ -5180,36 +5223,59 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
endUtc = state.EndUtc;
boundarySkip = state.BoundarySkipCount;
numValuesPerNode = state.NumValuesPerNode;
// The direction is the CURSOR's (Gitea #675), never re-derived from the resume request's details: a
// reverse read resumes reverse, a forward one forward.
reverse = state.Reverse;
}
else
{
// Part 11: with one bound unset, an unset EndTime (MinValue) with a StartTime means "page FORWARD from
// StartTime" — normalise it to an open upper bound, or the cursor would store MinValue and the read would
// look reversed. An unset StartTime with an EndTime and a count cap means "page BACKWARD from EndTime"
// (#675, mirroring raw): the reverse window (MinValue, EndTime]. Without a count cap an end-only read
// keeps its historical forward [MinValue, EndTime) meaning.
if (endUtc == DateTime.MinValue && startUtc != DateTime.MinValue)
endUtc = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc); // Utc kind: a proto Timestamp conversion rejects Unspecified
else if (numValuesPerNode > 0 && startUtc == DateTime.MinValue && endUtc != DateTime.MinValue)
(startUtc, endUtc) = (endUtc, new DateTime(0, DateTimeKind.Utc));
// Part 11: an unset EndTime (MinValue) with a StartTime means "page forward from StartTime". Normalise it
// to an open upper bound, or the cursor would store MinValue and a backend would swap the reversed
// resume window. A resumed read is always forward (the cursor is only ever stored for a forward read),
// so its direction comes from the cursor, not from the resume request's details.
if (!resumed && endUtc == DateTime.MinValue && startUtc != DateTime.MinValue)
endUtc = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc); // Utc kind: a proto Timestamp conversion rejects Unspecified
// Reverse event reads (start > end) are not paged: the cursor below is forward-only.
var forward = resumed || startUtc <= endUtc;
// Part 11: StartTime later than EndTime is a reverse read — events newest-first over (end, start].
reverse = startUtc > endUtc;
}
// NOT under the node-manager Lock — awaiting the async source is safe here.
// NumValuesPerNode==0 means "no limit — return ALL values" per OPC UA Part 4/11, so it is translated
// to UNBOUNDED (EventMaxEvents) rather than the backend's <=0 default-cap sentinel.
// to UNBOUNDED (EventMaxEvents) rather than the backend's <=0 default-cap sentinel. A resumed page re-reads its
// boundary timestamp inclusively and then drops the `boundarySkip` ties already emitted, so it asks for that
// many more rows than the cap — otherwise every resumed page would come back short by the skip.
var fetch = EventMaxEvents(numValuesPerNode);
if (numValuesPerNode > 0 && boundarySkip > 0)
fetch = (int)Math.Min(int.MaxValue - 1L, (long)fetch + boundarySkip);
var sourceResult = await _historianDataSource.ReadEventsAsync(
sourceName, startUtc, endUtc, EventMaxEvents(numValuesPerNode), ct).ConfigureAwait(false);
sourceName, startUtc, endUtc, fetch, reverse, ct).ConfigureAwait(false);
// Truncation is the SOURCE's word (non-null continuation point) — never inferred from the count, so a
// page that happens to be exactly cap-sized but complete never emits a point.
var truncated = sourceResult.ContinuationPoint is not null && numValuesPerNode > 0 && forward;
var truncated = sourceResult.ContinuationPoint is not null && numValuesPerNode > 0;
IReadOnlyList<HistoricalEvent> events = sourceResult.Events;
if (resumed && boundarySkip > 0)
{
// Drop the events at the boundary timestamp already emitted on the prior page.
// Drop the events at the boundary timestamp already emitted on the prior page. The resumed read is in
// the cursor's direction, so in both directions those ties lead the page.
var i = 0;
while (i < events.Count && i < boundarySkip && events[i].EventTimeUtc == startUtc) i++;
if (i > 0) events = events.Skip(i).ToList();
}
// The skip over-fetch can leave more than the cap (fewer boundary ties came back than were skipped — pruned
// between pages): keep the cap, and the dropped tail means the read continues.
if (numValuesPerNode > 0 && events.Count > numValuesPerNode)
{
events = events.Take((int)Math.Min(int.MaxValue, numValuesPerNode)).ToList();
truncated = true;
}
// The cursor to store, if any. Saved only AFTER the page is projected, so a projection throw can
// never orphan a stored cursor.
HistoryContinuationState? pendingCursor = null;
@@ -5222,10 +5288,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// with an explicit bounded cap and page WITHIN it (same scheme as raw).
var resolution = Math.Max(1L, _historianDataSource.TimestampResolutionTicks);
var overfetchCap = (int)Math.Min(int.MaxValue - 1L, (long)Math.Max(1, MaxTieClusterOverfetch) + 1);
var cluster = (await _historianDataSource.ReadEventsAsync(
sourceName, startUtc, startUtc.AddTicks(resolution), overfetchCap, ct)
IReadOnlyList<HistoricalEvent> cluster = (await _historianDataSource.ReadEventsAsync(
sourceName, startUtc, startUtc.AddTicks(resolution), overfetchCap, reverse: false, ct)
.ConfigureAwait(false)).Events;
// The over-fetch reads the cluster FORWARD (ascending EventId), but a reverse read emitted its ties — and
// counted boundarySkip — in descending order. Slice in the read's own direction, as raw does.
if (reverse) cluster = cluster.Reverse().ToList();
if (cluster.Count > MaxTieClusterOverfetch)
{
_logger.LogError(
@@ -5240,7 +5310,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
HistoryPaging.SliceTieCluster(
cluster.Count, boundarySkip, numValuesPerNode, startUtc, endUtc,
out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip,
reverse: false, resolutionTicks: resolution);
reverse: reverse, resolutionTicks: resolution);
var slice = new List<HistoricalEvent>(sliceCount);
for (var i = sliceStart; i < sliceStart + sliceCount; i++) slice.Add(cluster[i]);
@@ -5249,17 +5319,20 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (nextStartUtc is { } resumeAt)
{
pendingCursor = new HistoryContinuationState(
sourceName ?? string.Empty, resumeAt, endUtc, nextSkip, numValuesPerNode, HistoryCursorKind.Events);
sourceName ?? string.Empty, resumeAt, endUtc, nextSkip, numValuesPerNode, HistoryCursorKind.Events,
Reverse: reverse);
}
}
else if (truncated && events.Count > 0)
{
// Cursor from the last event: resume at its time INCLUSIVE, skipping the trailing ties emitted.
// Cursor from the last event: resume at its time INCLUSIVE, skipping the trailing ties emitted. In a
// reverse read the last event is the OLDEST on the page, and the resumed (end, T] read starts there.
var boundary = events[^1].EventTimeUtc;
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, HistoryCursorKind.Events);
sourceName ?? string.Empty, boundary, endUtc, skip, numValuesPerNode, HistoryCursorKind.Events,
Reverse: reverse);
}
var historyEvent = ProjectEvents(events, selectClauses);
@@ -5280,6 +5353,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
errors[handle.Index] = StatusCodes.BadTimeout;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
}
catch (NotSupportedException ex)
{
// The backend cannot serve this read's shape — today a reverse (newest-first) read on Gateway / Null
// (#675). A client request the backend refuses, not a backend fault: Warning, not Error.
_logger.LogWarning("OtOpcUaNodeManager: HistoryReadEvents for node {NodeId} is not supported by the historian backend: {Reason}",
handle.NodeId, ex.Message);
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
}
catch (Exception ex)
{
// One node's backend failure must not throw out of the batch — surface Bad for THIS node
@@ -5373,6 +5455,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];
@@ -5386,7 +5470,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);
});
}
@@ -5448,22 +5532,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);
@@ -5479,6 +5581,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;
@@ -5529,6 +5636,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);
@@ -5619,7 +5741,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
private static int ClampToInt(uint value) => value > int.MaxValue ? int.MaxValue : (int)value;
/// <summary>Map a HistoryRead-Events <c>NumValuesPerNode</c> request cap onto the
/// <see cref="IHistorianDataSource.ReadEventsAsync"/> <c>maxEvents</c> argument, honouring the OPC UA
/// <see cref="IHistorianDataSource.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/> <c>maxEvents</c> argument, honouring the OPC UA
/// Part 4/11 semantics that <c>NumValuesPerNode == 0</c> means "no limit — return ALL values".
/// We translate 0 to UNBOUNDED (<see cref="int.MaxValue"/>) — a very large positive cap — rather than
/// the backend's <c>maxEvents &lt;= 0</c> "use the default cap" sentinel, so a client asking for the
@@ -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>