fix(history): advertise HistoryServerCapabilities from the wired backend (#881)
v2-ci / unit-tests (push) Has been cancelled

Every Boolean under Server/ServerCapabilities/HistoryServerCapabilities read
false (the SDK default) even though data and event history were served, so a
spec-following client skipped HistoryRead entirely.

Each backend now declares what it serves through its own interface:
- IHistorianDataSource.ReadCapabilities (Data / Events / ServerTimestamps):
  Gateway Data|Events; SqlServer + QuestDb add ServerTimestamps (they store
  ServerTimeUtc, the Gateway stamps read time); Null declares None. A
  reflection guard requires every production source to declare it itself.
- IHistorianDataUpdater.Capabilities (required member): SqlServer and QuestDb
  declare Insert/Replace/Update/DeleteRaw/DeleteAtTime; the Gateway provider
  has no updater.

The node manager writes the flags on every source/updater assignment
(AdvertisedHistoryCapabilities), and the same declarations drive dispatch:
an update the updater does not declare is BadHistoryOperationUnsupported
without reaching it, the HistoryWrite AccessLevel bit follows a non-empty
declaration, and event-history notifier promotion follows the Events flag.
Event and annotation update flags stay false (nothing dispatches them).

Tests: booted-server wire test per backend + Null + a SqlServer->Gateway
rewire pairing on one session; dispatch gating unit test; the #801 live
gate now asserts the Gateway flags (not run here).

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-12 10:31:29 -05:00
parent ac4c4d40dc
commit b27b5b9cc1
17 changed files with 526 additions and 15 deletions
@@ -721,6 +721,11 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
public IReadOnlySet<HistoryAggregateType> SupportedAggregates =>
_enableNativeTimeWeighted ? ServedAggregates : RawEvaluatedAggregates;
/// <inheritdoc />
// Data + events, but NOT ServerTimestamps: AVEVA keeps no per-sample server time, so SampleMapper stamps the read
// time — advertising ServerTimestampSupported would promise a stored value we do not have (lmxopcua#881).
public HistorianReadCapabilities ReadCapabilities => HistorianReadCapabilities.Data | HistorianReadCapabilities.Events;
/// <inheritdoc />
// A reverse (newest-first) read is refused with NotSupportedException — the gateway's
// ReadEvents streams ascending only and rejects end < start. The server never calls this with
@@ -438,6 +438,11 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
/// <inheritdoc />
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => AggregateFamilies.Served;
/// <inheritdoc />
// Data + events; every sample row stores its server time and a raw read returns it (lmxopcua#881).
public HistorianReadCapabilities ReadCapabilities =>
HistorianReadCapabilities.Data | HistorianReadCapabilities.Events | HistorianReadCapabilities.ServerTimestamps;
// [start, end) by event time (a reversed window is swapped), ordered by (ts, event_id) ascending.
// maxEvents <= 0 or int.MaxValue => unbounded. Otherwise maxEvents + 1 rows are fetched; when the
@@ -87,6 +87,10 @@ internal sealed class QuestDbHistorianDataUpdater : IHistorianDataUpdater
public void Dispose() => sem.Release();
}
/// <inheritdoc/>
// All five data updates; the deletes are soft (tombstone rows), which a reader cannot tell from a hard delete.
public HistoryUpdateCapabilities Capabilities => HistoryUpdateCapabilities.AllData;
/// <inheritdoc/>
public async Task<IReadOnlyList<uint>> UpdateDataAsync(string tag, HistoryUpdateMode mode,
IReadOnlyList<HistorizationValue> values, HistoryUpdateContext context, CancellationToken ct)
@@ -363,6 +363,12 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
/// <inheritdoc />
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => AggregateFamilies.Served;
/// <inheritdoc />
// Data + events; every sample row stores ServerTimeUtc (written by the recorder / HistoryUpdate) and a raw read
// returns it (lmxopcua#881).
public HistorianReadCapabilities ReadCapabilities =>
HistorianReadCapabilities.Data | HistorianReadCapabilities.Events | HistorianReadCapabilities.ServerTimestamps;
// <remarks>
// <c>[start, end)</c> by <c>EventTimeUtc</c> (a reversed window is swapped), ordered by
@@ -67,6 +67,11 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
"HistoryUpdate needs the Part 11 modification trail (apply 002_sample_modified.sql and restart).");
}
/// <inheritdoc/>
// All five data updates (MERGE / OUTPUT-trail deletes). On a trail-less v1 schema (AutoCreateSchema=false, logged at
// boot) every call still throws NotSupportedException at run time → BadHistoryOperationUnsupported.
public HistoryUpdateCapabilities Capabilities => HistoryUpdateCapabilities.AllData;
/// <inheritdoc/>
public async Task<IReadOnlyList<uint>> UpdateDataAsync(string tag, HistoryUpdateMode mode,
IReadOnlyList<HistorizationValue> values, HistoryUpdateContext context, CancellationToken ct)