2ceed561ad
QuestDB Start/End/Delta reads of a tag being written intermittently failed BadHistoryOperationUnsupported (InvalidCastException: Column 'ts_3' is null): the StartEnd query referenced its CTE four times and QuestDB opens a table reader per reference, so a row committed between two of them was counted but not picked. MinimumActualTime/MaximumActualTime had the same race and returned a value-less Good. (a) SQL - QuestDB StartEnd: one scan, one GROUP BY — first()/last() for the any-quality picks, arg_min/arg_max keyed on the usable rows' time for the usable picks (no str_value: arg_min has no VARCHAR form; only Delta reads them). Byte-identical output to the old query on a 1M-row tag, ~15% faster. - SqlServer StartEnd: one windowed pass + conditional MAX (was five CTE references); identical output, 1.4-3x faster. - ActualTime kept on both backends: every single-scan form measured 1.2-2.4x slower; its inconsistency is absorbed by (b). (b) Defence in depth on both backends - ReadBucket takes DbDataReader and reads absent picks as null (ReadStoredOrNull) instead of throwing. - AggregateEvaluator: Delta with a missing first/last usable pick and ActualTime with a missing extreme are BadNoData (were BadAggregateInvalidInputs / a value-less Good); Start/End with no pick already were. - Unit tests (Sql.Common evaluator, QuestDb + SqlServer mappers over a DataTableReader) fail with the guards removed. LiveWriteReadRaceStressTests (QuestDB integration, opt-in via HISTORIAN_QUESTDB_STRESS_SECONDS, Category=SlowUnit): single-row writer vs 4 tight-loop readers. Old code: 6 faults in ~105k reads; new: 0 in 106k. Docs: Historian.md QuestDB processed-read note; F2 (Min/Max on the interval start: SDK raw, ours Calculated) and F3 (integer tags: SDK casts interpolated values to the integer type, ours Double) added to the SDK-parity deviations.
240 lines
12 KiB
C#
240 lines
12 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 InsertRowsNoWaitAsync(tag, rows);
|
|
await Wal.WaitAsync(Conn.SampleTable, CancellationToken.None);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <see cref="InsertRowsAsync"/> without the WAL visibility wait: the rows become visible whenever QuestDB applies
|
|
/// them (the live write/read race stress test commits one row per call and never waits).
|
|
/// </summary>
|
|
/// <param name="tag">The tag.</param>
|
|
/// <param name="rows">Rows: time, value, status, deleted.</param>
|
|
public async Task InsertRowsNoWaitAsync(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();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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
|
|
}
|
|
}
|