d2be213e86
IHistorianDataSource.TimestampResolutionTicks (default 1; QuestDB 10). A drained tie cluster now steps off T by the resolution, so a forward one-value-per-page read on a microsecond backend no longer re-reads the same sample forever (C1). QuestDB AtTime results carry the client's original request time (M3); regression for the NEXT seek skipping a tombstone (M1). Conformance backend wires the real writer and updater. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
203 lines
9.9 KiB
C#
203 lines
9.9 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/>
|
|
/// <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);
|
|
}
|
|
|
|
/// <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
|
|
}
|
|
}
|