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