Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/BootstrapTests.cs
T

242 lines
10 KiB
C#

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();
}
}