Files
lmxopcua/tests/Drivers/Historian.Conformance/IConformanceBackend.cs
T
Joseph Doherty 8d154c6a81 feat(historian): #675 reverse HistoryRead-Events paging + end-only backward reads
- IHistorianDataSource.ReadEventsAsync(..., bool reverse, ...) overload: reverse = Part 11 window (lo, hi]
  newest-first by (time, id) DESC. Default forwards forward reads and throws NotSupportedException for
  reverse (Null); Gateway refuses explicitly (ascending-only stream). Server answers
  BadHistoryOperationUnsupported instead of silently returning the earliest N ascending.
- SqlServer / QuestDB serve reverse natively (ORDER BY time DESC, id DESC + TOP/LIMIT).
- Node manager: reverse event reads page with continuation points; direction stored in the cursor
  (HistoryContinuationState.Reverse) so a resume continues in the cursor's direction; reverse tie clusters
  over-fetched forward and sliced from the newest end (SliceTieCluster(reverse:)); end-only reads with a
  cap page backward from EndTime (mirrors raw).
- Resumed event pages over-fetch by the boundary skip, so every non-final page is a full cap page.
- Tests: node-manager reverse paging (ties, clusters, coarse resolution, overfetch bound, resume
  direction both ways, end-only, unsupported backend); shared EventReadConformance on SqlServer +
  QuestDB; over-the-wire reverse paging; offline Gateway pin of the UTC DateTime.MaxValue bound through
  the published client's proto conversion (in-process gRPC capture) and FILETIME.
- docs/Historian.md event paging section.
2026-10-11 11:19:54 -04:00

101 lines
5.1 KiB
C#

using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
/// <summary>
/// One historian backend under test. The conformance suite talks to a backend only through this contract
/// (plus Core.Abstractions), so the same hand-computed expectations run against SQL Server and QuestDB.
/// Each instance owns an isolated store (a fresh schema / table set) so tests never see each other's data.
/// </summary>
public interface IConformanceBackend : IAsyncDisposable
{
/// <summary>Storage timestamp resolution in .NET ticks: 1 for SQL Server (100 ns), 10 for QuestDB (µs).</summary>
long ResolutionTicks { get; }
/// <summary>Truncates a timestamp to the backend's storage resolution (UTC kind). Every timestamp assertion goes through it.</summary>
/// <param name="t">The timestamp.</param>
/// <returns>The value the backend stores/serves for <paramref name="t"/>.</returns>
DateTime Round(DateTime t) => new(t.Ticks - t.Ticks % ResolutionTicks, DateTimeKind.Utc);
/// <summary>A fresh data source (fresh catalog cache) over this backend's store.</summary>
/// <param name="clock">Its "now"; null means the system clock.</param>
IHistorianDataSource CreateDataSource(TimeProvider? clock = null);
/// <summary>A fresh data source whose time-weighted raw-row cap (<c>ServerHistorian:MaxTimeWeightedRawRows</c>) is <paramref name="maxTimeWeightedRawRows"/>.</summary>
/// <param name="maxTimeWeightedRawRows">The cap (&lt;= 0 = unbounded).</param>
IHistorianDataSource CreateDataSource(int maxTimeWeightedRawRows);
/// <summary>A fresh live-value writer over this backend's store.</summary>
/// <param name="utcNow">The clock used to stamp values that carry no timestamp; null means the system clock.</param>
IHistorianValueWriter CreateValueWriter(Func<DateTime>? utcNow = null);
/// <summary>A fresh history updater over this backend's store.</summary>
IHistorianDataUpdater CreateUpdater();
/// <summary>
/// Writes rows directly in the backend's schema (never through the writer or updater), status verbatim, creating the
/// tag if needed. The stored server timestamp of each row is its source time + 1 s, so tests can tell them apart.
/// </summary>
/// <param name="tag">The tag name.</param>
/// <param name="samples">The rows to store.</param>
Task SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples);
/// <summary>
/// Writes historical events directly in the backend's event table (never through the alarm writer): event id,
/// source and time as given (rounded to the storage resolution), severity 500, received time = event time.
/// </summary>
/// <param name="events">The events to store.</param>
Task SeedEventsAsync(params (DateTime T, string Id, string Source)[] events);
/// <summary>
/// Live (non-deleted) rows for a tag, ascending by time, read directly from storage. The status is returned
/// VERBATIM as stored — there is no read-side clamp (an Empty row stored non-Bad reads back non-Bad here).
/// </summary>
/// <param name="tag">The tag name.</param>
Task<IReadOnlyList<(DateTime T, HistorianValue V, uint Status)>> ReadBackAsync(string tag);
/// <summary>Whether a tag with exactly this name exists in storage (no truncation of long names).</summary>
/// <param name="tag">The tag name.</param>
Task<bool> TagExistsAsync(string tag);
/// <summary>The data type the backend recorded for a tag, or null when the tag does not exist.</summary>
/// <param name="tag">The tag name.</param>
Task<DriverDataType?> TagDataTypeAsync(string tag);
}
/// <summary>
/// Base of every conformance class. A backend's test project subclasses each abstract suite class once,
/// supplying <see cref="CreateBackendAsync"/>, which must call its fixture's skip gate so the tests report Skipped
/// (not Failed) when the backend is unavailable.
/// </summary>
public abstract class ConformanceBase : IAsyncLifetime
{
/// <summary>Good.</summary>
protected const uint Good = ConformanceData.Good;
/// <summary>Bad (generic).</summary>
protected const uint Bad = ConformanceData.Bad;
/// <summary>BadNoCommunication.</summary>
protected const uint BadNoCommunication = ConformanceData.BadNoCommunication;
/// <summary>Uncertain (generic).</summary>
protected const uint Uncertain = ConformanceData.Uncertain;
/// <summary>The backend under test for the current test.</summary>
protected IConformanceBackend B { get; private set; } = null!;
/// <summary>Creates this test's isolated backend; calls the fixture's skip gate first.</summary>
protected abstract Task<IConformanceBackend> CreateBackendAsync();
/// <summary>T0 + <paramref name="s"/> seconds.</summary>
protected static DateTime At(double s) => ConformanceData.At(s);
/// <inheritdoc/>
public async ValueTask InitializeAsync() => B = await CreateBackendAsync();
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
if (B is not null) await B.DisposeAsync();
}
}