Merge qdb/T3: QuestDB historian skeleton

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-11 01:55:28 -04:00
18 changed files with 1457 additions and 0 deletions
@@ -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();
}
}
@@ -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";
}
@@ -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>
@@ -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");
}
}
@@ -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();
}
}
@@ -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);
}
@@ -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);
}
@@ -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>