Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/QuestDbConformanceBackend.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

228 lines
11 KiB
C#

using System.Text;
using Npgsql;
using NpgsqlTypes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests;
/// <summary>
/// The QuestDB historian as a conformance backend: a fresh unique table prefix per instance (dropped on dispose),
/// seeded by plain multi-row INSERTs (never through the writer or updater) followed by the WAL visibility wait.
/// Storage resolution is 1 µs (10 ticks); <see cref="Round"/> is <see cref="QuestDbConnection.Round"/>, and
/// <see cref="SeedAsync"/> rounds the times it is given the same way before storing them.
/// </summary>
internal sealed class QuestDbConformanceBackend : IConformanceBackend
{
private const int SeedBatchRows = 1000;
private readonly QuestDbFixture _fx;
private readonly string _prefix;
private QuestDbConformanceBackend(QuestDbFixture fx, string prefix, QuestDbHistorianOptions options,
QuestDbConnection conn, QuestDbSchemaBootstrapper boot)
{
_fx = fx;
_prefix = prefix;
Options = options;
Conn = conn;
Boot = boot;
Wal = new QuestDbWalVisibility(conn, options.VisibilityTimeout);
}
/// <summary>The options of this instance's table set.</summary>
public QuestDbHistorianOptions Options { get; }
/// <summary>The connection helper.</summary>
public QuestDbConnection Conn { get; }
/// <summary>The schema bootstrapper (already run).</summary>
public QuestDbSchemaBootstrapper Boot { get; }
/// <summary>The WAL visibility wait.</summary>
public QuestDbWalVisibility Wal { get; }
/// <summary>Creates a backend over a fresh table prefix; skips the calling test when the gate is closed.</summary>
/// <param name="fx">The shared fixture.</param>
/// <returns>The backend.</returns>
public static async Task<IConformanceBackend> CreateAsync(QuestDbFixture fx) => await CreateConcreteAsync(fx);
/// <summary>As <see cref="CreateAsync"/>, typed (for QuestDB-specific tests that need the connection).</summary>
/// <param name="fx">The shared fixture.</param>
/// <returns>The backend.</returns>
public static async Task<QuestDbConformanceBackend> CreateConcreteAsync(QuestDbFixture fx)
{
fx.SkipIfUnavailable();
var prefix = fx.UniquePrefix();
var o = fx.Options(prefix);
var conn = new QuestDbConnection(o);
var boot = new QuestDbSchemaBootstrapper(conn, o);
await boot.EnsureAsync(CancellationToken.None);
return new QuestDbConformanceBackend(fx, prefix, o, conn, boot);
}
/// <inheritdoc/>
public long ResolutionTicks => 10;
/// <inheritdoc/>
public DateTime Round(DateTime t) => QuestDbConnection.Round(t);
/// <summary>A fresh tag catalog (fresh positive cache) over this table set.</summary>
public QuestDbTagCatalog NewCatalog() => new(Conn, Boot, Wal);
/// <inheritdoc/>
public IHistorianDataSource CreateDataSource(TimeProvider? clock = null) =>
new QuestDbHistorianDataSource(Conn, Boot, NewCatalog(), timeProvider: clock);
/// <inheritdoc/>
public IHistorianDataSource CreateDataSource(int maxTimeWeightedRawRows) =>
new QuestDbHistorianDataSource(Conn, Boot, NewCatalog(), maxTimeWeightedRawRows: maxTimeWeightedRawRows);
/// <inheritdoc/>
/// <remarks>The T4 live-value writer over a fresh catalog (fresh positive cache) and this instance's WAL wait.</remarks>
public IHistorianValueWriter CreateValueWriter(Func<DateTime>? utcNow = null) =>
new QuestDbHistorianValueWriter(Conn, NewCatalog(), Wal, utcNow: utcNow);
/// <inheritdoc/>
/// <remarks>The T6 soft-delete updater over a fresh catalog and this instance's WAL wait.</remarks>
public IHistorianDataUpdater CreateUpdater() => new QuestDbHistorianDataUpdater(Conn, NewCatalog(), Wal);
/// <inheritdoc/>
/// <remarks>
/// Times are rounded to the microsecond (<see cref="Round"/>) before they are stored; the tag row is registered
/// through the catalog (data type 0, as the SQL Server seed does); every row is live (<c>deleted = false</c>);
/// <c>server_ts</c> = rounded time + 1 s. Waits for WAL visibility before returning.
/// </remarks>
public async Task SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples)
{
await NewCatalog().EnsureAsync(tag, 0, CancellationToken.None);
await InsertRowsAsync(tag, samples.Select(s => (Round(s.T), s.V, s.Status, Deleted: false)).ToArray());
}
/// <summary>
/// Inserts raw rows (the tag must already be registered when reads should find it), including tombstones, then
/// waits for WAL visibility. A tombstone re-inserted at an existing <c>(ts, tag)</c> replaces that row (DEDUP).
/// </summary>
/// <param name="tag">The tag.</param>
/// <param name="rows">Rows: time (already rounded by the caller or not — it is bound through BindTimestamp), value, status, deleted.</param>
public async Task InsertRowsAsync(string tag, (DateTime T, HistorianValue V, uint Status, bool Deleted)[] rows)
{
if (rows.Length == 0) return;
await using (var c = await Conn.OpenAsync(CancellationToken.None))
{
for (var offset = 0; offset < rows.Length; offset += SeedBatchRows)
{
var batch = rows.Skip(offset).Take(SeedBatchRows).ToArray();
var sql = new StringBuilder(
$"INSERT INTO {Conn.SampleTable} (ts, tag, kind, num_value, int_value, str_value, status, deleted, server_ts) VALUES ");
await using var cmd = Conn.CreateCommand(c, "");
var n = 0;
for (var i = 0; i < batch.Length; i++)
{
var (t, v, status, deleted) = batch[i];
var col = SampleColumnMapper.ToColumns(v);
if (i > 0) sql.Append(", ");
sql.Append('(');
for (var j = 0; j < 9; j++) sql.Append(j == 0 ? "" : ", ").Append('$').Append(++n);
sql.Append(')');
QuestDbConnection.BindTimestamp(cmd, t);
QuestDbConnection.BindText(cmd, tag);
QuestDbConnection.BindByte(cmd, col.ValueKind);
cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Double, Value = (object?)col.NumValue ?? DBNull.Value });
cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Bigint, Value = (object?)col.IntValue ?? DBNull.Value });
QuestDbConnection.BindText(cmd, col.StrValue);
QuestDbConnection.BindLong(cmd, SampleColumnMapper.ToLongStatus(status));
cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Boolean, Value = deleted });
QuestDbConnection.BindTimestamp(cmd, Round(t).AddSeconds(1));
}
cmd.CommandText = sql.ToString();
await cmd.ExecuteNonQueryAsync();
}
}
await Wal.WaitAsync(Conn.SampleTable, CancellationToken.None);
}
/// <inheritdoc/>
public async Task SeedEventsAsync(params (DateTime T, string Id, string Source)[] events)
{
await using (var c = await Conn.OpenAsync(CancellationToken.None))
{
foreach (var (t, id, source) in events)
{
await using var cmd = Conn.CreateCommand(c,
$"INSERT INTO {Conn.EventTable} (ts, event_id, source, severity, message, received_utc) VALUES ($1, $2, $3, $4, $5, $6)");
QuestDbConnection.BindTimestamp(cmd, t);
QuestDbConnection.BindText(cmd, id);
QuestDbConnection.BindText(cmd, source);
cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Integer, Value = 500 });
QuestDbConnection.BindText(cmd, null);
QuestDbConnection.BindTimestamp(cmd, t);
await cmd.ExecuteNonQueryAsync();
}
}
await Wal.WaitAsync(Conn.EventTable, CancellationToken.None);
}
/// <summary>Runs a statement against this table set (test-only escape hatch for hand-made rows), then waits for WAL visibility of the sample table.</summary>
/// <param name="sql">The statement; parameters are bound positionally by <paramref name="bind"/>.</param>
/// <param name="bind">Binds the parameters.</param>
public async Task ExecuteAsync(string sql, Action<NpgsqlCommand> bind)
{
await using (var c = await Conn.OpenAsync(CancellationToken.None))
{
await using var cmd = Conn.CreateCommand(c, sql);
bind(cmd);
await cmd.ExecuteNonQueryAsync();
}
await Wal.WaitAsync(Conn.SampleTable, CancellationToken.None);
}
/// <inheritdoc/>
public async Task<IReadOnlyList<(DateTime T, HistorianValue V, uint Status)>> ReadBackAsync(string tag)
{
await using var c = await Conn.OpenAsync(CancellationToken.None);
await using var cmd = Conn.CreateCommand(c,
$"SELECT ts, kind, num_value, int_value, str_value, status FROM {Conn.SampleTable} " +
"WHERE tag = $1 AND NOT deleted ORDER BY ts");
QuestDbConnection.BindText(cmd, tag);
var list = new List<(DateTime, HistorianValue, uint)>();
await using var r = await cmd.ExecuteReaderAsync();
while (await r.ReadAsync())
{
var v = QuestDbHistorianDataSource.ToValue(QuestDbConnection.ReadKind(r, 1),
r.IsDBNull(2) ? null : r.GetDouble(2), r.IsDBNull(3) ? null : r.GetInt64(3), r.IsDBNull(4) ? null : r.GetString(4));
// Verbatim: no read-side clamp. A NULL status cannot be written by any code path; it reads as the raw 0 bits.
var status = r.IsDBNull(5) ? 0u : SampleColumnMapper.FromLongStatus(r.GetInt64(5));
list.Add((QuestDbConnection.ReadUtc(r, 0), v, status));
}
return list;
}
/// <inheritdoc/>
public async Task<bool> TagExistsAsync(string tag)
{
await using var c = await Conn.OpenAsync(CancellationToken.None);
await using var cmd = Conn.CreateCommand(c, $"SELECT count() FROM {Conn.TagTable} WHERE name = $1");
QuestDbConnection.BindText(cmd, tag);
return Convert.ToInt64(await cmd.ExecuteScalarAsync()) > 0;
}
/// <inheritdoc/>
public async Task<DriverDataType?> TagDataTypeAsync(string tag)
{
await using var c = await Conn.OpenAsync(CancellationToken.None);
await using var cmd = Conn.CreateCommand(c, $"SELECT data_type FROM {Conn.TagTable} WHERE name = $1 LIMIT 1");
QuestDbConnection.BindText(cmd, tag);
await using var r = await cmd.ExecuteReaderAsync();
return await r.ReadAsync() ? (DriverDataType)QuestDbConnection.ReadKind(r, 0) : null;
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
await Conn.DisposeAsync();
await _fx.DropPrefixAsync(_prefix); // early; the fixture also drops every prefix it handed out
}
}