Merge qdb/T3: QuestDB historian skeleton
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+241
@@ -0,0 +1,241 @@
|
||||
using Npgsql;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests;
|
||||
|
||||
[Collection(QuestDbCollection.Name)]
|
||||
public sealed class BootstrapTests(QuestDbFixture fx)
|
||||
{
|
||||
private (QuestDbConnection Conn, QuestDbSchemaBootstrapper Boot, QuestDbHistorianOptions Opts) Make(
|
||||
string prefix, int? retention = null, bool autoCreate = true)
|
||||
{
|
||||
var o = fx.Options(prefix, retention, autoCreate);
|
||||
var c = new QuestDbConnection(o);
|
||||
return (c, new QuestDbSchemaBootstrapper(c, o), o);
|
||||
}
|
||||
|
||||
private static async Task<List<string>> TablesAsync(QuestDbConnection c, string prefix)
|
||||
{
|
||||
await using var conn = await c.OpenAsync(default);
|
||||
await using var cmd = c.CreateCommand(conn, "SELECT table_name FROM tables() WHERE starts_with(table_name, $1) ORDER BY table_name");
|
||||
QuestDbConnection.BindText(cmd, prefix);
|
||||
var names = new List<string>();
|
||||
await using var r = await cmd.ExecuteReaderAsync();
|
||||
while (await r.ReadAsync()) names.Add(r.GetString(0));
|
||||
return names;
|
||||
}
|
||||
|
||||
private static async Task<(long Value, string Unit)> TtlAsync(QuestDbConnection c, string table)
|
||||
{
|
||||
await using var conn = await c.OpenAsync(default);
|
||||
await using var cmd = c.CreateCommand(conn, "SELECT ttlValue, ttlUnit FROM tables() WHERE table_name = $1");
|
||||
QuestDbConnection.BindText(cmd, table);
|
||||
await using var r = await cmd.ExecuteReaderAsync();
|
||||
(await r.ReadAsync()).ShouldBeTrue();
|
||||
return (Convert.ToInt64(r.GetValue(0)), r.GetString(1));
|
||||
}
|
||||
|
||||
private static async Task<long> SequencerTxnAsync(QuestDbConnection c, string table)
|
||||
{
|
||||
await using var conn = await c.OpenAsync(default);
|
||||
await using var cmd = c.CreateCommand(conn, "SELECT sequencerTxn FROM wal_tables() WHERE name = $1");
|
||||
QuestDbConnection.BindText(cmd, table);
|
||||
return Convert.ToInt64(await cmd.ExecuteScalarAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tables_are_created()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var p = fx.UniquePrefix();
|
||||
var (c, boot, _) = Make(p);
|
||||
await boot.EnsureAsync(default);
|
||||
(await TablesAsync(c, p)).ShouldBe([p + "event", p + "sample", p + "tag"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_second_run_is_a_noop_and_issues_no_alter()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var p = fx.UniquePrefix();
|
||||
var (c, boot, _) = Make(p, retention: 7);
|
||||
await boot.EnsureAsync(default);
|
||||
var before = await SequencerTxnAsync(c, p + "sample");
|
||||
|
||||
var (c2, boot2, _) = Make(p, retention: 7); // a fresh instance on a table whose TTL already matches (stored as 1 WEEK)
|
||||
await boot2.EnsureAsync(default);
|
||||
await Task.Delay(300); // an ALTER would have been applied by now
|
||||
(await SequencerTxnAsync(c2, p + "sample")).ShouldBe(before);
|
||||
(await SequencerTxnAsync(c2, p + "event")).ShouldBe(await SequencerTxnAsync(c, p + "event"));
|
||||
(await TablesAsync(c2, p)).Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Two_concurrent_bootstrappers_both_succeed()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
for (var round = 0; round < 5; round++)
|
||||
{
|
||||
var p = fx.UniquePrefix();
|
||||
var a = Make(p, retention: 7);
|
||||
var b = Make(p, retention: 7);
|
||||
await Task.WhenAll(
|
||||
Task.Run(() => a.Boot.EnsureAsync(default)),
|
||||
Task.Run(() => b.Boot.EnsureAsync(default)));
|
||||
(await TablesAsync(a.Conn, p)).Count.ShouldBe(3);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ttl_is_in_metadata_when_retention_is_7_days_and_absent_when_unset()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var withTtl = fx.UniquePrefix();
|
||||
var (c1, b1, _) = Make(withTtl, retention: 7);
|
||||
await b1.EnsureAsync(default);
|
||||
QuestDbTables.TtlHours((await TtlAsync(c1, withTtl + "sample")).Value, (await TtlAsync(c1, withTtl + "sample")).Unit).ShouldBe(168);
|
||||
QuestDbTables.TtlHours((await TtlAsync(c1, withTtl + "event")).Value, (await TtlAsync(c1, withTtl + "event")).Unit).ShouldBe(168);
|
||||
(await TtlAsync(c1, withTtl + "tag")).Value.ShouldBe(0); // the tag catalog never expires
|
||||
|
||||
var noTtl = fx.UniquePrefix();
|
||||
var (c2, b2, _) = Make(noTtl);
|
||||
await b2.EnsureAsync(default);
|
||||
(await TtlAsync(c2, noTtl + "sample")).Value.ShouldBe(0);
|
||||
(await TtlAsync(c2, noTtl + "event")).Value.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_changed_retention_is_reapplied_and_removal_clears_it()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var p = fx.UniquePrefix();
|
||||
var (c, b7, _) = Make(p, retention: 7);
|
||||
await b7.EnsureAsync(default);
|
||||
|
||||
await Make(p, retention: 30).Boot.EnsureAsync(default);
|
||||
await AwaitHoursAsync(c, p + "sample", 720);
|
||||
await AwaitHoursAsync(c, p + "event", 720);
|
||||
|
||||
await Make(p).Boot.EnsureAsync(default); // unset => SET TTL 0
|
||||
await AwaitHoursAsync(c, p + "sample", 0);
|
||||
}
|
||||
|
||||
private static async Task AwaitHoursAsync(QuestDbConnection c, string table, long hours)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
long got;
|
||||
do
|
||||
{
|
||||
var (v, u) = await TtlAsync(c, table);
|
||||
got = QuestDbTables.TtlHours(v, u);
|
||||
if (got == hours) return;
|
||||
await Task.Delay(50); // the ALTER is a WAL transaction: visible only once applied
|
||||
} while (DateTime.UtcNow < deadline);
|
||||
got.ShouldBe(hours);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoCreateSchema_false_against_missing_tables_throws_HistorianSchemaMissingException()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (_, boot, _) = Make(fx.UniquePrefix(), autoCreate: false);
|
||||
var ex = await Should.ThrowAsync<HistorianSchemaMissingException>(() => boot.EnsureAsync(default));
|
||||
ex.Message.ShouldContain("AutoCreateSchema");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoCreateSchema_false_passes_once_the_tables_exist()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var p = fx.UniquePrefix();
|
||||
await Make(p).Boot.EnsureAsync(default);
|
||||
await Make(p, autoCreate: false).Boot.EnsureAsync(default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_is_false_for_an_unknown_tag_and_true_after_EnsureAsync()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var p = fx.UniquePrefix();
|
||||
var (c, boot, o) = Make(p);
|
||||
var catalog = new QuestDbTagCatalog(c, boot, new QuestDbWalVisibility(c, o.VisibilityTimeout));
|
||||
|
||||
(await catalog.ExistsAsync("Plant.Pump1.Speed", default)).ShouldBeFalse();
|
||||
await catalog.EnsureAsync("Plant.Pump1.Speed", 3, default);
|
||||
(await catalog.ExistsAsync("Plant.Pump1.Speed", default)).ShouldBeTrue();
|
||||
// A fresh catalog (no cache) sees it through the table, and a different name is still unknown.
|
||||
var fresh = new QuestDbTagCatalog(c, boot, new QuestDbWalVisibility(c, o.VisibilityTimeout));
|
||||
(await fresh.ExistsAsync("Plant.Pump1.Speed", default)).ShouldBeTrue();
|
||||
(await fresh.ExistsAsync("Plant.Pump1.Other", default)).ShouldBeFalse();
|
||||
await catalog.EnsureAsync("Plant.Pump1.Speed", 3, default); // idempotent
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_over_long_tag_name_is_unknown_and_cannot_be_ensured()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var p = fx.UniquePrefix();
|
||||
var (c, boot, o) = Make(p);
|
||||
var catalog = new QuestDbTagCatalog(c, boot, new QuestDbWalVisibility(c, o.VisibilityTimeout));
|
||||
var name = new string('x', HistorianTagNames.MaxLength + 1);
|
||||
(await catalog.ExistsAsync(name, default)).ShouldBeFalse();
|
||||
await Should.ThrowAsync<ArgumentException>(() => catalog.EnsureAsync(name, 3, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wait_returns_once_writerTxn_reaches_the_captured_sequencerTxn()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var p = fx.UniquePrefix();
|
||||
var (c, boot, o) = Make(p);
|
||||
await boot.EnsureAsync(default);
|
||||
var wal = new QuestDbWalVisibility(c, TimeSpan.FromSeconds(5));
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
var ts = new DateTime(2026, 3, 1, 0, 0, i, DateTimeKind.Utc);
|
||||
await using (var conn = await c.OpenAsync(default))
|
||||
{
|
||||
await using var cmd = c.CreateCommand(conn,
|
||||
$"INSERT INTO {c.SampleTable} (ts, tag, kind, num_value, status, deleted) VALUES ($1, $2, $3, $4, $5, false)");
|
||||
QuestDbConnection.BindTimestamp(cmd, ts);
|
||||
QuestDbConnection.BindText(cmd, "wal-tag");
|
||||
QuestDbConnection.BindByte(cmd, 3);
|
||||
cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Double, Value = (double)i });
|
||||
QuestDbConnection.BindLong(cmd, 0);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
(await wal.WaitAsync(c.SampleTable, default)).ShouldBeTrue();
|
||||
// After the wait the row is visible on the very next query (the T0 gap was 7/20 without it).
|
||||
await using var q = await c.OpenAsync(default);
|
||||
await using var count = c.CreateCommand(q, $"SELECT count() FROM {c.SampleTable} WHERE tag = $1");
|
||||
QuestDbConnection.BindText(count, "wal-tag");
|
||||
Convert.ToInt64(await count.ExecuteScalarAsync()).ShouldBe(i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeout_zero_skips_the_wait()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var p = fx.UniquePrefix();
|
||||
var (c, boot, _) = Make(p);
|
||||
await boot.EnsureAsync(default);
|
||||
var wal = new QuestDbWalVisibility(c, TimeSpan.Zero);
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
(await wal.WaitAsync(c.SampleTable, default)).ShouldBeTrue();
|
||||
sw.ElapsedMilliseconds.ShouldBeLessThan(50); // no round-trip at all
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Waiting_on_a_table_that_is_not_a_WAL_table_returns_immediately()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var p = fx.UniquePrefix();
|
||||
var (c, boot, _) = Make(p);
|
||||
await boot.EnsureAsync(default);
|
||||
var wal = new QuestDbWalVisibility(c, TimeSpan.FromSeconds(5));
|
||||
(await wal.WaitAsync(p + "no_such_table", default)).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Env-gated QuestDB for the historian integration tests. <c>HISTORIAN_QUESTDB_TEST_CONNECTION</c> absent ⇒
|
||||
/// <see cref="SkipReason"/> is set, nothing connects and every test calls <c>Assert.Skip</c>. Present ⇒ tests isolate
|
||||
/// from each other with <see cref="UniquePrefix"/>; every prefix handed out is dropped on dispose (and a test may
|
||||
/// drop its own early with <see cref="DropPrefixAsync"/>). Never touches a table outside those prefixes.
|
||||
/// </summary>
|
||||
public sealed class QuestDbFixture : IAsyncLifetime
|
||||
{
|
||||
/// <summary>Env var holding an Npgsql connection string for QuestDB's PG-wire port.</summary>
|
||||
public const string ConnectionEnvVar = "HISTORIAN_QUESTDB_TEST_CONNECTION";
|
||||
|
||||
private readonly ConcurrentBag<string> _prefixes = new();
|
||||
|
||||
/// <summary>Why tests must skip, or null when QuestDB is available.</summary>
|
||||
public string? SkipReason { get; private set; }
|
||||
|
||||
/// <summary>The Npgsql connection string. Valid only when <see cref="SkipReason"/> is null.</summary>
|
||||
public string ConnectionString { get; private set; } = "";
|
||||
|
||||
/// <summary>Skips the calling test when the gate is closed.</summary>
|
||||
public void SkipIfUnavailable()
|
||||
{
|
||||
if (SkipReason is not null) Assert.Skip(SkipReason);
|
||||
}
|
||||
|
||||
/// <summary>A fresh, valid table prefix (<c>"t" + 12 hex + "_"</c>) that nothing uses yet; dropped on dispose.</summary>
|
||||
/// <returns>The prefix.</returns>
|
||||
public string UniquePrefix()
|
||||
{
|
||||
var p = "t" + Guid.NewGuid().ToString("N")[..12] + "_";
|
||||
_prefixes.Add(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
/// <summary>Options targeting this fixture's QuestDB under <paramref name="prefix"/>.</summary>
|
||||
/// <param name="prefix">Table prefix (normally from <see cref="UniquePrefix"/>).</param>
|
||||
/// <param name="retentionDays">Optional TTL.</param>
|
||||
/// <param name="autoCreate">AutoCreateSchema.</param>
|
||||
/// <returns>Valid options.</returns>
|
||||
public QuestDbHistorianOptions Options(string prefix, int? retentionDays = null, bool autoCreate = true) => new()
|
||||
{
|
||||
ConnectionString = ConnectionString,
|
||||
TablePrefix = prefix,
|
||||
RetentionDays = retentionDays,
|
||||
AutoCreateSchema = autoCreate,
|
||||
};
|
||||
|
||||
/// <summary>Drops every table whose name starts with <paramref name="prefix"/> (found through <c>tables()</c>).</summary>
|
||||
/// <param name="prefix">Table prefix.</param>
|
||||
/// <returns>A task completing when they are gone.</returns>
|
||||
public async Task DropPrefixAsync(string prefix)
|
||||
{
|
||||
if (SkipReason is not null) return;
|
||||
if (!QuestDbHistorianOptions.IsValidPrefix(prefix)) throw new ArgumentException("Invalid prefix.", nameof(prefix));
|
||||
await using var ds = NpgsqlDataSource.Create(ConnectionString);
|
||||
await using var conn = await ds.OpenConnectionAsync().ConfigureAwait(false);
|
||||
var names = new List<string>();
|
||||
await using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT table_name FROM tables() WHERE starts_with(table_name, $1)"; // never LIKE: '_' is a wildcard
|
||||
cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Varchar, Value = prefix });
|
||||
await using var r = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
|
||||
while (await r.ReadAsync().ConfigureAwait(false)) names.Add(r.GetString(0));
|
||||
}
|
||||
foreach (var n in names)
|
||||
{
|
||||
await using var drop = conn.CreateCommand();
|
||||
drop.CommandText = $"DROP TABLE IF EXISTS {n}";
|
||||
await drop.ExecuteNonQueryAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
var cs = Environment.GetEnvironmentVariable(ConnectionEnvVar);
|
||||
if (string.IsNullOrWhiteSpace(cs))
|
||||
SkipReason = $"{ConnectionEnvVar} is not set; QuestDB historian integration tests skipped.";
|
||||
else
|
||||
ConnectionString = cs;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (SkipReason is not null) return;
|
||||
foreach (var p in _prefixes.Distinct())
|
||||
await DropPrefixAsync(p).ConfigureAwait(false);
|
||||
NpgsqlConnection.ClearAllPools();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shares one fixture across the QuestDB integration tests.</summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class QuestDbCollection : ICollectionFixture<QuestDbFixture>
|
||||
{
|
||||
/// <summary>Collection name.</summary>
|
||||
public const string Name = "QuestDb";
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests</RootNamespace>
|
||||
<NoWarn>$(NoWarn);xUnit1051</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb\ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Shared conformance suite (owned by the Conformance task); an empty glob until it lands. -->
|
||||
<Compile Include="..\Historian.Conformance\**\*.cs" LinkBase="Conformance"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
using System.Net.Sockets;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Tests;
|
||||
|
||||
public sealed class QuestDbConnectionTests
|
||||
{
|
||||
private static DateTime Bound(DateTime v, bool clamp = false)
|
||||
{
|
||||
var cmd = new NpgsqlCommand();
|
||||
var p = QuestDbConnection.BindTimestamp(cmd, v, clamp);
|
||||
p.NpgsqlDbType.ShouldBe(NpgsqlDbType.TimestampTz);
|
||||
var bound = (DateTime)p.Value!;
|
||||
bound.Kind.ShouldBe(DateTimeKind.Utc);
|
||||
return bound;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_truncates_sub_microsecond_ticks()
|
||||
{
|
||||
var t = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc).AddTicks(1234567);
|
||||
QuestDbConnection.Round(t).ShouldBe(new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc).AddTicks(1234560));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_truncates_rather_than_rounds_before_2000()
|
||||
{
|
||||
var t = new DateTime(1990, 5, 5, 0, 0, 0, DateTimeKind.Utc).AddTicks(1234565);
|
||||
QuestDbConnection.Round(t).Ticks.ShouldBe(t.Ticks - 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_treats_Unspecified_as_UTC()
|
||||
{
|
||||
var u = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Unspecified);
|
||||
var r = QuestDbConnection.Round(u);
|
||||
r.Kind.ShouldBe(DateTimeKind.Utc);
|
||||
r.Ticks.ShouldBe(u.Ticks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_converts_Local_to_UTC()
|
||||
{
|
||||
var local = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Local);
|
||||
var r = QuestDbConnection.Round(local);
|
||||
r.Kind.ShouldBe(DateTimeKind.Utc);
|
||||
r.ShouldBe(local.ToUniversalTime());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_keeps_a_UTC_value_that_is_already_aligned()
|
||||
{
|
||||
var t = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
QuestDbConnection.Round(t).ShouldBe(t);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)] // MinValue itself
|
||||
[InlineData(5)] // truncates to MinValue
|
||||
public void BindTimestamp_never_emits_MinValue(long ticks)
|
||||
{
|
||||
var b = Bound(new DateTime(ticks, DateTimeKind.Utc));
|
||||
b.ShouldNotBe(DateTime.MinValue);
|
||||
b.Ticks.ShouldBe(10);
|
||||
b.ShouldBe(new DateTime(10, DateTimeKind.Utc)); // 0001-01-01T00:00:00.000001 (T0-confirmed)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindTimestamp_never_emits_MaxValue()
|
||||
{
|
||||
var b = Bound(DateTime.MaxValue);
|
||||
b.ShouldNotBe(DateTime.MaxValue);
|
||||
b.ShouldBe(new DateTime(9999, 12, 31, 23, 59, 59, DateTimeKind.Utc).AddTicks(9999990)); // T0-confirmed bound
|
||||
Bound(DateTime.MinValue).ShouldNotBe(DateTime.MinValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindTimestamp_never_emits_either_extreme_for_any_kind()
|
||||
{
|
||||
foreach (var kind in new[] { DateTimeKind.Utc, DateTimeKind.Unspecified, DateTimeKind.Local })
|
||||
foreach (var t in new[] { new DateTime(DateTime.MinValue.Ticks, kind), new DateTime(DateTime.MaxValue.Ticks, kind) })
|
||||
{
|
||||
var b = Bound(t);
|
||||
b.ShouldNotBe(DateTime.MinValue);
|
||||
b.ShouldNotBe(DateTime.MaxValue);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindTimestamp_keeps_a_pre_1970_range_bound_unless_asked_to_clamp()
|
||||
{
|
||||
var pre = new DateTime(1960, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
Bound(pre).ShouldBe(pre);
|
||||
Bound(pre, clamp: true).ShouldBe(DateTime.UnixEpoch);
|
||||
Bound(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), clamp: true).Year.ShouldBe(2026);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsStorable_is_false_only_before_1970()
|
||||
{
|
||||
QuestDbConnection.IsStorable(DateTime.UnixEpoch).ShouldBeTrue();
|
||||
QuestDbConnection.IsStorable(DateTime.UnixEpoch.AddTicks(-1)).ShouldBeFalse();
|
||||
QuestDbConnection.IsStorable(new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc)).ShouldBeFalse();
|
||||
QuestDbConnection.IsStorable(DateTime.MinValue).ShouldBeFalse();
|
||||
QuestDbConnection.IsStorable(DateTime.MaxValue).ShouldBeTrue();
|
||||
QuestDbConnection.IsStorable(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindByte_binds_smallint()
|
||||
{
|
||||
var p = QuestDbConnection.BindByte(new NpgsqlCommand(), 200);
|
||||
p.NpgsqlDbType.ShouldBe(NpgsqlDbType.Smallint);
|
||||
p.Value.ShouldBe((short)200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsTransient_classifies_transport_failures_not_server_rejections()
|
||||
{
|
||||
QuestDbConnection.IsTransient(new SocketException()).ShouldBeTrue();
|
||||
QuestDbConnection.IsTransient(new IOException("x")).ShouldBeTrue();
|
||||
QuestDbConnection.IsTransient(new TimeoutException()).ShouldBeTrue();
|
||||
QuestDbConnection.IsTransient(new NpgsqlException("x", new IOException())).ShouldBeTrue();
|
||||
QuestDbConnection.IsTransient(new InvalidOperationException("x")).ShouldBeFalse();
|
||||
QuestDbConnection.IsTransient(new OperationCanceledException()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void T_prefixes_the_table_base_name()
|
||||
{
|
||||
var c = new QuestDbConnection(new QuestDbHistorianOptions { ConnectionString = "Host=127.0.0.1;Port=1", TablePrefix = "p_" });
|
||||
c.T("sample").ShouldBe("p_sample");
|
||||
c.SampleTable.ShouldBe("p_sample");
|
||||
c.EventTable.ShouldBe("p_event");
|
||||
c.TagTable.ShouldBe("p_tag");
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Tests;
|
||||
|
||||
public sealed class QuestDbHistorianOptionsTests
|
||||
{
|
||||
private static QuestDbHistorianOptions Valid() => new() { ConnectionString = "Host=h;Port=8812" };
|
||||
|
||||
[Fact]
|
||||
public void Defaults_are_the_documented_ones()
|
||||
{
|
||||
var o = new QuestDbHistorianOptions();
|
||||
o.ConnectionString.ShouldBe("");
|
||||
o.TablePrefix.ShouldBe("hist_");
|
||||
o.RetentionDays.ShouldBeNull();
|
||||
o.CommandTimeout.ShouldBe(TimeSpan.FromSeconds(30));
|
||||
o.AutoCreateSchema.ShouldBeTrue();
|
||||
o.VisibilityTimeout.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_valid_configuration_has_no_errors() => Valid().Validate().ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Missing_connection_string_names_the_key()
|
||||
{
|
||||
var o = Valid(); o.ConnectionString = " ";
|
||||
o.Validate().ShouldHaveSingleItem().ShouldContain("ServerHistorian:QuestDb:ConnectionString");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("hist-")]
|
||||
[InlineData("hist.")]
|
||||
[InlineData("hist_; DROP")]
|
||||
public void Bad_prefix_names_the_key(string prefix)
|
||||
{
|
||||
var o = Valid(); o.TablePrefix = prefix;
|
||||
o.Validate().ShouldHaveSingleItem().ShouldContain("ServerHistorian:QuestDb:TablePrefix");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("t0123456789ab_")]
|
||||
[InlineData("A_9")]
|
||||
public void Good_prefixes_pass(string prefix)
|
||||
{
|
||||
var o = Valid(); o.TablePrefix = prefix;
|
||||
o.Validate().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-3)]
|
||||
public void Retention_below_one_names_the_key(int days)
|
||||
{
|
||||
var o = Valid(); o.RetentionDays = days;
|
||||
o.Validate().ShouldHaveSingleItem().ShouldContain("ServerHistorian:QuestDb:RetentionDays");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retention_of_one_is_valid() { var o = Valid(); o.RetentionDays = 1; o.Validate().ShouldBeEmpty(); }
|
||||
|
||||
[Fact]
|
||||
public void Non_positive_command_timeout_names_the_key()
|
||||
{
|
||||
var o = Valid(); o.CommandTimeout = TimeSpan.Zero;
|
||||
o.Validate().ShouldHaveSingleItem().ShouldContain("ServerHistorian:QuestDb:CommandTimeout");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Negative_visibility_timeout_names_the_key_and_zero_is_valid()
|
||||
{
|
||||
var o = Valid(); o.VisibilityTimeout = TimeSpan.FromMilliseconds(-1);
|
||||
o.Validate().ShouldHaveSingleItem().ShouldContain("ServerHistorian:QuestDb:VisibilityTimeout");
|
||||
o.VisibilityTimeout = TimeSpan.Zero;
|
||||
o.Validate().ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Tests;
|
||||
|
||||
public sealed class QuestDbSchemaBootstrapperFailureTests
|
||||
{
|
||||
private sealed class ManualClock : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = new(2026, 10, 11, 0, 0, 0, TimeSpan.Zero);
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
public void Advance(TimeSpan by) => _now += by;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failure_is_cached_for_five_seconds_then_retried()
|
||||
{
|
||||
var opts = new QuestDbHistorianOptions { ConnectionString = "Host=127.0.0.1;Port=1;Timeout=1" };
|
||||
var clock = new ManualClock();
|
||||
var boot = new QuestDbSchemaBootstrapper(new QuestDbConnection(opts), opts, timeProvider: clock);
|
||||
|
||||
var first = await Should.ThrowAsync<Exception>(() => boot.EnsureAsync(CancellationToken.None));
|
||||
// Within the window the SAME failure is re-thrown without another connection attempt.
|
||||
var second = await Should.ThrowAsync<Exception>(() => boot.EnsureAsync(CancellationToken.None));
|
||||
second.ShouldBeSameAs(first);
|
||||
|
||||
clock.Advance(BootstrapFailureCache.DefaultDuration + TimeSpan.FromSeconds(1));
|
||||
var third = await Should.ThrowAsync<Exception>(() => boot.EnsureAsync(CancellationToken.None));
|
||||
third.ShouldNotBeSameAs(first); // the window closed: QuestDB was tried again
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Tests;
|
||||
|
||||
public sealed class QuestDbTablesTests
|
||||
{
|
||||
[Fact]
|
||||
public void Sample_ddl_without_ttl_is_pinned() =>
|
||||
QuestDbTables.CreateSample("hist_", null).ShouldBe(
|
||||
"CREATE TABLE IF NOT EXISTS hist_sample (ts TIMESTAMP, tag SYMBOL, kind BYTE, num_value DOUBLE, " +
|
||||
"int_value LONG, str_value VARCHAR, status LONG, deleted BOOLEAN, server_ts TIMESTAMP) " +
|
||||
"TIMESTAMP(ts) PARTITION BY DAY WAL DEDUP UPSERT KEYS(ts, tag)");
|
||||
|
||||
[Fact]
|
||||
public void Sample_ddl_with_ttl_puts_ttl_between_partition_and_wal() =>
|
||||
QuestDbTables.CreateSample("hist_", 7).ShouldBe(
|
||||
"CREATE TABLE IF NOT EXISTS hist_sample (ts TIMESTAMP, tag SYMBOL, kind BYTE, num_value DOUBLE, " +
|
||||
"int_value LONG, str_value VARCHAR, status LONG, deleted BOOLEAN, server_ts TIMESTAMP) " +
|
||||
"TIMESTAMP(ts) PARTITION BY DAY TTL 7 DAYS WAL DEDUP UPSERT KEYS(ts, tag)");
|
||||
|
||||
[Fact]
|
||||
public void Event_ddl_is_pinned()
|
||||
{
|
||||
QuestDbTables.CreateEvent("hist_", null).ShouldBe(
|
||||
"CREATE TABLE IF NOT EXISTS hist_event (ts TIMESTAMP, event_id VARCHAR, source SYMBOL, alarm_id VARCHAR, " +
|
||||
"alarm_name VARCHAR, alarm_type_name VARCHAR, severity INT, event_kind VARCHAR, message VARCHAR, " +
|
||||
"user_name VARCHAR, comment VARCHAR, received_utc TIMESTAMP) " +
|
||||
"TIMESTAMP(ts) PARTITION BY DAY WAL DEDUP UPSERT KEYS(ts, event_id)");
|
||||
QuestDbTables.CreateEvent("hist_", 30).ShouldContain("PARTITION BY DAY TTL 30 DAYS WAL DEDUP");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tag_ddl_is_pinned_and_never_carries_a_ttl() =>
|
||||
QuestDbTables.CreateTag("hist_").ShouldBe(
|
||||
"CREATE TABLE IF NOT EXISTS hist_tag (ts TIMESTAMP, name SYMBOL, data_type BYTE, eng_unit VARCHAR, " +
|
||||
"description VARCHAR, updated_utc TIMESTAMP) TIMESTAMP(ts) PARTITION BY YEAR WAL DEDUP UPSERT KEYS(ts, name)");
|
||||
|
||||
[Fact]
|
||||
public void The_prefix_is_applied_to_every_table_name()
|
||||
{
|
||||
QuestDbTables.All("t1_").ShouldBe(["t1_sample", "t1_event", "t1_tag"]);
|
||||
QuestDbTables.CreateAll("t1_", 3).ShouldAllBe(s => s.Contains("t1_"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_invalid_prefix_is_refused() =>
|
||||
Should.Throw<ArgumentException>(() => QuestDbTables.CreateSample("a b", null));
|
||||
|
||||
[Fact]
|
||||
public void Alter_ttl_is_pinned_and_null_removes_it()
|
||||
{
|
||||
QuestDbTables.AlterTtl("hist_sample", 30).ShouldBe("ALTER TABLE hist_sample SET TTL 30 DAYS");
|
||||
QuestDbTables.AlterTtl("hist_sample", null).ShouldBe("ALTER TABLE hist_sample SET TTL 0 DAYS");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "WEEK", 168)] // 7 days reads back as 1 WEEK
|
||||
[InlineData(2, "WEEK", 336)]
|
||||
[InlineData(30, "DAY", 720)]
|
||||
[InlineData(0, "HOUR", 0)] // no TTL
|
||||
[InlineData(5, "hour", 5)]
|
||||
[InlineData(1, "MONTH", -1)] // unknown unit never equals a wanted value
|
||||
public void Ttl_is_compared_in_hours(long value, string unit, long hours) =>
|
||||
QuestDbTables.TtlHours(value, unit).ShouldBe(hours);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Tests;
|
||||
|
||||
public sealed class QuestDbWalVisibilityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Timeout_zero_skips_the_wait_without_touching_the_database()
|
||||
{
|
||||
// Port 1 refuses connections: any attempt to query would throw, so returning true proves nothing was opened.
|
||||
var conn = new QuestDbConnection(new QuestDbHistorianOptions { ConnectionString = "Host=127.0.0.1;Port=1;Timeout=1" });
|
||||
var wal = new QuestDbWalVisibility(conn, TimeSpan.Zero);
|
||||
wal.Disabled.ShouldBeTrue();
|
||||
(await wal.WaitAsync("hist_sample", CancellationToken.None)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_positive_timeout_does_touch_the_database_control()
|
||||
{
|
||||
var conn = new QuestDbConnection(new QuestDbHistorianOptions { ConnectionString = "Host=127.0.0.1;Port=1;Timeout=1" });
|
||||
var wal = new QuestDbWalVisibility(conn, TimeSpan.FromSeconds(1));
|
||||
wal.Disabled.ShouldBeFalse();
|
||||
await Should.ThrowAsync<Exception>(() => wal.WaitAsync("hist_sample", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(5, 5, true)]
|
||||
[InlineData(6, 5, true)]
|
||||
[InlineData(4, 5, false)]
|
||||
public void Reached_is_writerTxn_at_or_beyond_the_target(long writer, long target, bool expected) =>
|
||||
QuestDbWalVisibility.Reached(writer, target).ShouldBe(expected);
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb\ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user