11b703708c
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
242 lines
10 KiB
C#
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();
|
|
}
|
|
}
|