8d154c6a81
- 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.
101 lines
5.1 KiB
C#
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 (<= 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();
|
|
}
|
|
}
|