feat(historian): QuestDB reads and aggregates (pushed down, shared evaluation)
QuestDbHistorianDataSource serves Raw (LIMIT $n, no continuation, R8), AtTime (chunked LIMIT 1 neighbour seeks, <=50 times per statement), Events and Processed (one statement per node, bucket = (cast(ts AS LONG)*10 - start)/interval, shared BucketPlan/AggregateEvaluator/AtTimeEvaluator). Every sample read filters NOT deleted; every row goes through StoredSample.FromStored; RC2 long.MinValue read repair. QuestDbConformanceBackend (seed/read-back) for the conformance suite; writer/updater factories are T7 placeholders. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+203
@@ -0,0 +1,203 @@
|
||||
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>TODO(T7): build the T4 <c>QuestDbHistorianValueWriter</c> here once T4 is merged.</remarks>
|
||||
public IHistorianValueWriter CreateValueWriter(Func<DateTime>? utcNow = null) =>
|
||||
throw new NotSupportedException("TODO(T7): the QuestDB value writer (T4) is not merged into this branch yet.");
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>TODO(T7): build the T6 <c>QuestDbHistorianDataUpdater</c> here once T6 is merged.</remarks>
|
||||
public IHistorianDataUpdater CreateUpdater() =>
|
||||
throw new NotSupportedException("TODO(T7): the QuestDB history updater (T6) is not merged into this branch yet.");
|
||||
|
||||
/// <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
|
||||
}
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
using System.Text;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
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>
|
||||
/// QuestDB-specific read behaviour (the shared Raw/AtTime/Processed literals run through the conformance suite):
|
||||
/// tombstones, the pre-1970 / whole-range bucket math, reverse paging at one value per page, QuestDB's LONG NULL
|
||||
/// sentinel, the kind-decides-the-value rule, AtTime chunking, Events and failure accounting.
|
||||
/// </summary>
|
||||
[Collection(QuestDbCollection.Name)]
|
||||
public sealed class ReadTests(QuestDbFixture fx) : IAsyncLifetime
|
||||
{
|
||||
private static readonly CancellationToken Ct = CancellationToken.None;
|
||||
|
||||
private const uint Good = ConformanceData.Good;
|
||||
private const uint Bad = ConformanceData.Bad;
|
||||
private const uint GoodCalc = 0x00000401u;
|
||||
private const uint UncertainCalc = 0x40A40401u;
|
||||
private const uint UncertainInterpolated = 0x40A40402u;
|
||||
private const uint GoodInterpolated = 0x00000402u;
|
||||
private const uint NoDataCalc = 0x809B0401u;
|
||||
|
||||
private QuestDbConformanceBackend _b = null!;
|
||||
|
||||
private static DateTime At(double s) => ConformanceData.At(s);
|
||||
|
||||
public async ValueTask InitializeAsync() => _b = await QuestDbConformanceBackend.CreateConcreteAsync(fx);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_b is not null) await _b.DisposeAsync();
|
||||
}
|
||||
|
||||
private static double?[] Values(HistoryReadResult r) => r.Samples.Select(s => (double?)s.Value).ToArray();
|
||||
|
||||
/// <summary>What T6's DeleteAtTime writes: the same (ts, tag), deleted = true, value columns NULL, status BadNoData.</summary>
|
||||
private Task TombstoneAsync(string tag, DateTime t) =>
|
||||
_b.InsertRowsAsync(tag, [(t, HistorianValue.Empty, HistorianStatusCodes.BadNoData, true)]);
|
||||
|
||||
[Fact]
|
||||
public async Task Tombstoned_rows_are_invisible_to_raw_attime_and_processed()
|
||||
{
|
||||
await ConformanceData.SeedAAsync(_b); // +0=10, +10=20, +20=Bad 30, +30=40, +40=50
|
||||
await TombstoneAsync("A", At(10)); // DEDUP replaces the +10 row with a tombstone
|
||||
(await _b.ReadBackAsync("A")).Count.ShouldBe(4);
|
||||
var src = _b.CreateDataSource();
|
||||
|
||||
var raw = await src.ReadRawAsync("A", At(0), At(100), 0, Ct);
|
||||
Values(raw).ShouldBe([10d, 30d, 40d, 50d]);
|
||||
raw.Samples.Select(s => s.StatusCode).ShouldBe([Good, Bad, Good, Good]);
|
||||
|
||||
// +10 is no longer an exact hit: previous is +0 = 10 (Good), next is the Bad +20 ⇒ held, Uncertain.
|
||||
var at = (await src.ReadAtTimeAsync("A", [At(10), At(5)], Ct)).Samples;
|
||||
at.Select(s => s.Value).ShouldBe([(object?)10d, 10d]);
|
||||
at.ShouldAllBe(s => s.StatusCode == UncertainInterpolated);
|
||||
|
||||
// B0 [0,20) holds only {10}: Good, count 1. A visible tombstone would be a Bad sample ⇒ Uncertain, PercentBad 50.
|
||||
async Task<DataValueSnapshot> B0(HistoryAggregateType a) =>
|
||||
(await src.ReadProcessedAsync("A", At(0), At(80), TimeSpan.FromSeconds(20), a, Ct)).Samples[0];
|
||||
var avg = await B0(HistoryAggregateType.Average);
|
||||
avg.Value.ShouldBe(10d);
|
||||
avg.StatusCode.ShouldBe(GoodCalc);
|
||||
(await B0(HistoryAggregateType.Count)).Value.ShouldBe(1);
|
||||
(await B0(HistoryAggregateType.PercentBad)).Value.ShouldBe(0d);
|
||||
(await B0(HistoryAggregateType.End)).Value.ShouldBe(10d);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_whole_range_processed_read_from_1601_to_9999_with_interval_zero_does_not_overflow()
|
||||
{
|
||||
await ConformanceData.SeedAAsync(_b);
|
||||
var src = _b.CreateDataSource();
|
||||
var start = new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var end = new DateTime(9999, 12, 31, 0, 0, 0, DateTimeKind.Utc);
|
||||
foreach (var agg in new[] { HistoryAggregateType.Average, HistoryAggregateType.Start, HistoryAggregateType.MaximumActualTime })
|
||||
{
|
||||
var s = (await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, agg, Ct)).Samples.Single();
|
||||
s.StatusCode.ShouldBe(UncertainCalc, agg.ToString());
|
||||
}
|
||||
(await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.Average, Ct)).Samples.Single().Value.ShouldBe(30d);
|
||||
(await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.Start, Ct)).Samples.Single().Value.ShouldBe(10d);
|
||||
var max = (await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.MaximumActualTime, Ct)).Samples.Single();
|
||||
max.Value.ShouldBe(50d);
|
||||
max.SourceTimestampUtc.ShouldBe(_b.Round(At(40)));
|
||||
// The extreme DateTime range: MinValue/MaxValue are nudged (never bound as ±infinity, which would match nothing).
|
||||
(await src.ReadProcessedAsync("A", DateTime.MinValue, DateTime.MaxValue, TimeSpan.Zero, HistoryAggregateType.Total, Ct))
|
||||
.Samples.Single().Value.ShouldBe(120d);
|
||||
// A pre-1970 window holds no data and is not an error.
|
||||
(await src.ReadProcessedAsync("A", start, new DateTime(1969, 1, 1, 0, 0, 0, DateTimeKind.Utc), TimeSpan.FromDays(3650),
|
||||
HistoryAggregateType.Count, Ct)).Samples.ShouldAllBe(x => x.StatusCode == NoDataCalc);
|
||||
src.GetHealthSnapshot().TotalFailures.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The node manager's reverse paging at <c>NumValuesPerNode = 1</c> (OtOpcUaNodeManager + HistoryPaging): resume at
|
||||
/// the last timestamp inclusive and trim it; when the trimmed page stalls on that timestamp, point-read it and step
|
||||
/// one tick down. One tick below a µs-aligned time rounds to the previous µs, so the walk advances.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_reverse_paging_walk_with_one_value_per_page_returns_every_row()
|
||||
{
|
||||
await ConformanceData.SeedAAsync(_b);
|
||||
var src = _b.CreateDataSource();
|
||||
var end = At(-1);
|
||||
DateTime start = At(100);
|
||||
var resumed = false;
|
||||
var emitted = new List<object?>();
|
||||
for (var guard = 0; guard < 50; guard++)
|
||||
{
|
||||
var page = (await src.ReadRawAsync("A", start, end, 1, Ct)).Samples;
|
||||
var trimmed = resumed ? page.Where(s => s.SourceTimestampUtc != start).ToList() : page.ToList();
|
||||
if (page.Count == 0) break;
|
||||
if (resumed && (trimmed.Count == 0 || trimmed[^1].SourceTimestampUtc == start))
|
||||
{
|
||||
// Stalled on the boundary: the tie cluster at `start` is one sample (already emitted) ⇒ step down a tick.
|
||||
(await src.ReadRawAsync("A", start, start, 2, Ct)).Samples.Count.ShouldBe(1);
|
||||
if (start.AddTicks(-1) <= end) break;
|
||||
start = start.AddTicks(-1);
|
||||
resumed = false;
|
||||
continue;
|
||||
}
|
||||
emitted.AddRange(trimmed.Select(s => s.Value));
|
||||
start = page[^1].SourceTimestampUtc!.Value;
|
||||
resumed = true;
|
||||
}
|
||||
emitted.ShouldBe([50d, 40d, 30d, 20d, 10d]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_int64_of_long_MinValue_is_served_as_long_MinValue_not_zero()
|
||||
{
|
||||
// QuestDB stores long.MinValue in a LONG as NULL (its NULL sentinel); num_value keeps -2^63 exactly (RC2).
|
||||
await _b.SeedAsync("L",
|
||||
(At(0), HistorianValue.FromInt64(long.MinValue), Good),
|
||||
(At(1), HistorianValue.FromInt64(long.MinValue + 1), Good));
|
||||
var src = _b.CreateDataSource();
|
||||
(await src.ReadRawAsync("L", At(0), At(10), 0, Ct)).Samples.Select(s => s.Value)
|
||||
.ShouldBe([(object?)long.MinValue, long.MinValue + 1]);
|
||||
(await src.ReadAtTimeAsync("L", [At(0)], Ct)).Samples.Single().Value.ShouldBe(long.MinValue);
|
||||
(await src.ReadProcessedAsync("L", At(0), At(10), TimeSpan.Zero, HistoryAggregateType.Start, Ct))
|
||||
.Samples.Single().Value.ShouldBe(long.MinValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_kind_not_the_populated_column_decides_the_value()
|
||||
{
|
||||
// An Int64-kind row whose num_value is NULL (a NaN-free double view lost) still serves its int_value, and a
|
||||
// Double-kind row carrying a stray int_value still serves num_value.
|
||||
await _b.SeedAsync("K");
|
||||
await _b.ExecuteAsync(
|
||||
$"INSERT INTO {_b.Conn.SampleTable} (ts, tag, kind, num_value, int_value, status, deleted, server_ts) " +
|
||||
"VALUES ($1, $2, $3, NULL, $4, $5, false, $6), ($7, $2, $8, $9, $10, $5, false, $11)",
|
||||
cmd =>
|
||||
{
|
||||
QuestDbConnection.BindTimestamp(cmd, At(0)); // $1
|
||||
QuestDbConnection.BindText(cmd, "K"); // $2
|
||||
QuestDbConnection.BindByte(cmd, (byte)HistorianValueKind.Int64); // $3
|
||||
QuestDbConnection.BindLong(cmd, 7); // $4
|
||||
QuestDbConnection.BindLong(cmd, 0); // $5 status Good
|
||||
QuestDbConnection.BindTimestamp(cmd, At(1)); // $6
|
||||
QuestDbConnection.BindTimestamp(cmd, At(2)); // $7
|
||||
QuestDbConnection.BindByte(cmd, (byte)HistorianValueKind.Double); // $8
|
||||
cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Double, Value = 2.5 }); // $9
|
||||
QuestDbConnection.BindLong(cmd, 99); // $10
|
||||
QuestDbConnection.BindTimestamp(cmd, At(3)); // $11
|
||||
});
|
||||
var s = (await _b.CreateDataSource().ReadRawAsync("K", At(0), At(10), 0, Ct)).Samples;
|
||||
s.Select(x => x.Value).ShouldBe([(object?)7L, 2.5d]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AtTime_over_several_statement_chunks_keeps_request_length_and_order()
|
||||
{
|
||||
await ConformanceData.SeedAAsync(_b);
|
||||
// 140 distinct times (3 statements of ≤ 50), requested in descending order, plus a duplicate of the first.
|
||||
var times = Enumerable.Range(0, 140).Select(i => At((390 - i * 3) / 10.0)).ToList();
|
||||
times.Add(times[0]);
|
||||
var s = (await _b.CreateDataSource().ReadAtTimeAsync("A", times, Ct)).Samples;
|
||||
s.Count.ShouldBe(times.Count);
|
||||
s.Select(x => x.SourceTimestampUtc).ShouldBe(times.Select(t => (DateTime?)_b.Round(t)));
|
||||
s[0].Value.ShouldBe(49d); // +39: 40 + 10 × 9/10
|
||||
s[0].StatusCode.ShouldBe(GoodInterpolated);
|
||||
s[^1].Value.ShouldBe(49d); // the duplicate
|
||||
s[100].Value.ShouldBe(19d); // +9: 10 + 10 × 9/10, from the third chunk
|
||||
s[139].Value.ShouldBeNull(); // -2.7 s: no previous sample
|
||||
s[139].StatusCode.ShouldBe(0x809B0000u); // BadNoData
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Events_are_filtered_by_source_and_window_ascending_and_limited_with_a_continuation()
|
||||
{
|
||||
await _b.Boot.EnsureAsync(Ct);
|
||||
async Task Event(DateTime t, string id, string source, int severity, string? message)
|
||||
{
|
||||
await _b.ExecuteAsync(
|
||||
$"INSERT INTO {_b.Conn.EventTable} (ts, event_id, source, severity, message, received_utc) VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
cmd =>
|
||||
{
|
||||
QuestDbConnection.BindTimestamp(cmd, t);
|
||||
QuestDbConnection.BindText(cmd, id);
|
||||
QuestDbConnection.BindText(cmd, source);
|
||||
cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Integer, Value = severity });
|
||||
QuestDbConnection.BindText(cmd, message);
|
||||
QuestDbConnection.BindTimestamp(cmd, t.AddSeconds(2));
|
||||
});
|
||||
await _b.Wal.WaitAsync(_b.Conn.EventTable, Ct);
|
||||
}
|
||||
await Event(At(3), "e3", "src1", 700, "three");
|
||||
await Event(At(1), "e1", "src1", 70000, "one");
|
||||
await Event(At(2), "e2", "src2", 500, null);
|
||||
await Event(At(9), "e9", "src1", 500, "out of window");
|
||||
|
||||
var src = _b.CreateDataSource();
|
||||
var all = await src.ReadEventsAsync(null, At(0), At(5), 0, Ct);
|
||||
all.Events.Select(e => e.EventId).ShouldBe(["e1", "e2", "e3"]);
|
||||
all.ContinuationPoint.ShouldBeNull();
|
||||
all.Events[0].Severity.ShouldBe((ushort)65535);
|
||||
all.Events[0].EventTimeUtc.ShouldBe(_b.Round(At(1)));
|
||||
all.Events[0].ReceivedTimeUtc.ShouldBe(_b.Round(At(1)).AddSeconds(2));
|
||||
all.Events[1].Message.ShouldBeNull();
|
||||
|
||||
var one = await src.ReadEventsAsync("src1", At(5), At(0), 1, Ct); // reversed window is swapped
|
||||
one.Events.Select(e => e.EventId).ShouldBe(["e1"]);
|
||||
Encoding.UTF8.GetString(one.ContinuationPoint!).ShouldBe(EventContinuation.Encode(_b.Round(At(3)), "e3"));
|
||||
(await src.ReadEventsAsync("src1", At(0), At(5), 2, Ct)).ContinuationPoint.ShouldBeNull();
|
||||
(await src.ReadEventsAsync(new string('x', 401), At(0), At(5), 0, Ct)).Events.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_read_is_rethrown_and_recorded_in_health_without_counting_client_errors()
|
||||
{
|
||||
// A table set that does not exist, with AutoCreateSchema off: every read that reaches the database fails.
|
||||
var prefix = fx.UniquePrefix();
|
||||
var o = fx.Options(prefix, autoCreate: false);
|
||||
await using var conn = new QuestDbConnection(o);
|
||||
var boot = new QuestDbSchemaBootstrapper(conn, o);
|
||||
using var src = new QuestDbHistorianDataSource(conn, boot, new QuestDbTagCatalog(conn, boot, new QuestDbWalVisibility(conn, o.VisibilityTimeout)));
|
||||
|
||||
await Should.ThrowAsync<HistorianSchemaMissingException>(() => src.ReadRawAsync("A", At(0), At(100), 0, Ct));
|
||||
await Should.ThrowAsync<HistorianSchemaMissingException>(() => src.ReadEventsAsync(null, At(0), At(5), 0, Ct));
|
||||
// Client-request errors are thrown before any I/O and are not backend failures.
|
||||
await Should.ThrowAsync<HistoryAggregateNotSupportedException>(() =>
|
||||
src.ReadProcessedAsync("A", At(0), At(5), TimeSpan.Zero, HistoryAggregateType.Interpolative, Ct));
|
||||
await Should.ThrowAsync<ArgumentOutOfRangeException>(() =>
|
||||
src.ReadProcessedAsync("A", At(0), At(100), TimeSpan.FromTicks(10), HistoryAggregateType.Count, Ct));
|
||||
|
||||
var h = src.GetHealthSnapshot();
|
||||
h.TotalQueries.ShouldBe(2);
|
||||
h.TotalFailures.ShouldBe(2);
|
||||
h.ConsecutiveFailures.ShouldBe(2);
|
||||
h.LastError.ShouldNotBeNull();
|
||||
h.LastError.ShouldStartWith(nameof(HistorianSchemaMissingException));
|
||||
h.ProcessConnectionOpen.ShouldBeFalse();
|
||||
|
||||
// The healthy backend's own data source is unaffected (a failure fails only its call).
|
||||
await ConformanceData.SeedAAsync(_b);
|
||||
(await _b.CreateDataSource().ReadRawAsync("A", At(0), At(100), 0, Ct)).Samples.Count.ShouldBe(5);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user