feat(historian): QuestDb provider, registry + consumer-gated validator, compose env

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-11 02:23:21 -04:00
parent 18e85265f9
commit 13d628a07f
8 changed files with 351 additions and 0 deletions
@@ -0,0 +1,90 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Tests;
public sealed class QuestDbHistorianProviderTests
{
private static QuestDbHistorianProvider Make() => new(new QuestDbHistorianOptions
{
ConnectionString = "Host=localhost;Port=18812;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading",
});
[Fact]
public void Name_and_all_six_kinds()
{
var p = Make();
p.Name.ShouldBe("QuestDb");
p.SupportedKinds.ShouldBe(Enum.GetValues<HistorianValueKind>(), ignoreOrder: true);
p.SupportedKinds.Count.ShouldBe(6);
}
[Fact]
public void Creates_all_five_seams_without_touching_questdb()
{
var sp = new NullSp();
var p = Make();
p.CreateDataSource(sp).ShouldBeOfType<QuestDbHistorianDataSource>();
p.CreateValueWriter(sp).ShouldBeOfType<QuestDbHistorianValueWriter>();
p.CreateAlarmWriter(sp).ShouldBeOfType<QuestDbAlarmHistorianWriter>();
p.CreateProvisioning(sp).ShouldBeOfType<QuestDbTagProvisioner>();
p.CreateUpdater(sp).ShouldBeOfType<QuestDbHistorianDataUpdater>();
}
[Fact]
public void Supported_kinds_are_a_frozen_set() =>
Make().SupportedKinds.ShouldBeAssignableTo<System.Collections.Frozen.FrozenSet<HistorianValueKind>>();
/// <summary>
/// An invalid table prefix must not fail provider construction (the host resolves the provider even when no
/// consumer is enabled); it surfaces only when a seam is actually built.
/// </summary>
[Fact]
public void An_invalid_prefix_fails_only_when_a_seam_is_built()
{
var p = new QuestDbHistorianProvider(new QuestDbHistorianOptions { ConnectionString = "Host=x", TablePrefix = "bad;prefix" });
p.Name.ShouldBe("QuestDb");
Should.Throw<ArgumentException>(() => p.CreateDataSource(new NullSp()));
}
[Fact]
public void Every_seam_shares_one_connection_bootstrapper_catalog_and_wal_wait()
{
var sp = new NullSp();
var p = Make();
var a = (QuestDbHistorianDataSource)p.CreateDataSource(sp);
var b = (QuestDbHistorianDataSource)p.CreateDataSource(sp);
var w = p.CreateValueWriter(sp);
var u = p.CreateUpdater(sp)!;
var prov = p.CreateProvisioning(sp);
var alarm = p.CreateAlarmWriter(sp);
Field(a, "_conn").ShouldBeSameAs(Field(b, "_conn"));
Field(a, "_conn").ShouldBeSameAs(Field(w, "_conn"));
Field(a, "_conn").ShouldBeSameAs(Field(u, "_conn"));
Field(a, "_conn").ShouldBeSameAs(Field(alarm, "_conn"));
Field(a, "_bootstrap").ShouldBeSameAs(Field(b, "_bootstrap"));
Field(a, "_bootstrap").ShouldBeSameAs(Field(prov, "_bootstrap"));
Field(a, "_bootstrap").ShouldBeSameAs(Field(alarm, "_bootstrap"));
Field(a, "_catalog").ShouldBeSameAs(Field(b, "_catalog"));
Field(a, "_catalog").ShouldBeSameAs(Field(w, "_catalog"));
Field(a, "_catalog").ShouldBeSameAs(Field(u, "_tags"));
Field(a, "_catalog").ShouldBeSameAs(Field(prov, "_catalog"));
Field(w, "_wal").ShouldBeSameAs(Field(u, "_wal"));
Field(w, "_wal").ShouldBeSameAs(Field(prov, "_wal"));
Field(w, "_wal").ShouldBeSameAs(Field(alarm, "_wal"));
}
[Fact]
public void The_constructor_rejects_null_options() =>
Should.Throw<ArgumentNullException>(() => new QuestDbHistorianProvider(null!));
private static object? Field(object o, string name) =>
o.GetType().GetField(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(o);
private sealed class NullSp : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
}
@@ -375,4 +375,89 @@ public sealed class ServerHistorianOptionsValidatorTests
result.Failed.ShouldBeTrue();
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:SqlServer:Schema"));
}
private static ServerHistorianOptionsValidator QuestDbSut(string? connectionString, string? prefix = null, bool alarmHistorianEnabled = false)
{
var values = new Dictionary<string, string?> { ["AlarmHistorian:Enabled"] = alarmHistorianEnabled ? "true" : "false" };
if (connectionString is not null) values["ServerHistorian:QuestDb:ConnectionString"] = connectionString;
if (prefix is not null) values["ServerHistorian:QuestDb:TablePrefix"] = prefix;
return new ServerHistorianOptionsValidator(new ConfigurationBuilder().AddInMemoryCollection(values).Build());
}
/// <summary>QuestDb with a connection string passes; the gateway Endpoint/ApiKey/UseTls checks do not fire.</summary>
[Fact]
public void QuestDb_backend_with_connection_string_passes_without_gateway_settings()
{
var result = QuestDbSut("Host=questdb;Port=8812").Validate(null,
new ServerHistorianOptions { Backend = "QuestDb", Enabled = true });
result.Failed.ShouldBeFalse(string.Join("; ", result.Failures ?? []));
}
/// <summary>QuestDb with no connection string fails, naming the key, and never asks for gateway settings.</summary>
[Fact]
public void QuestDb_backend_without_connection_string_fails_naming_the_key()
{
var result = QuestDbSut(null).Validate(null, new ServerHistorianOptions { Backend = "questdb", Enabled = true });
result.Failed.ShouldBeTrue();
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:QuestDb:ConnectionString"));
result.Failures.ShouldNotContain(f => f.Contains("ServerHistorian:Endpoint"));
result.Failures.ShouldNotContain(f => f.Contains("ServerHistorian:ApiKey"));
}
/// <summary>Every <c>QuestDbHistorianOptions.Validate()</c> error is reported, each naming its key.</summary>
[Fact]
public void QuestDb_backend_reports_every_options_error_naming_the_key()
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["AlarmHistorian:Enabled"] = "false",
["ServerHistorian:QuestDb:TablePrefix"] = "bad;prefix",
["ServerHistorian:QuestDb:RetentionDays"] = "0",
["ServerHistorian:QuestDb:CommandTimeout"] = "00:00:00",
["ServerHistorian:QuestDb:VisibilityTimeout"] = "-00:00:01",
}).Build();
var result = new ServerHistorianOptionsValidator(configuration)
.Validate(null, new ServerHistorianOptions { Backend = "QuestDb", Enabled = true });
result.Failed.ShouldBeTrue();
foreach (var key in new[] { "ConnectionString", "TablePrefix", "RetentionDays", "CommandTimeout", "VisibilityTimeout" })
result.Failures.ShouldContain(f => f.Contains($"ServerHistorian:QuestDb:{key}"), key);
}
/// <summary>Unconsumed QuestDb section (nothing enabled) is not validated.</summary>
[Fact]
public void QuestDb_backend_not_consumed_passes_without_connection_string() =>
QuestDbSut(null).Validate(null, new ServerHistorianOptions { Backend = "QuestDb" }).Failed.ShouldBeFalse();
/// <summary>
/// An invalid <c>ServerHistorian:QuestDb:TablePrefix</c> must not fail the host when nothing consumes the
/// section: <see cref="HistorianBackendRegistry.Resolve"/> does not throw and the validator does not check it.
/// </summary>
[Fact]
public void QuestDb_invalid_prefix_not_consumed_neither_resolve_nor_validation_fails()
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["AlarmHistorian:Enabled"] = "false",
["ServerHistorian:QuestDb:ConnectionString"] = "Host=questdb;Port=8812",
["ServerHistorian:QuestDb:TablePrefix"] = "bad;prefix",
}).Build();
var options = new ServerHistorianOptions { Backend = "QuestDb" };
Should.NotThrow(() => HistorianBackendRegistry.Resolve(options, configuration));
new ServerHistorianOptionsValidator(configuration).Validate(null, options).Failed.ShouldBeFalse();
}
/// <summary>Consumed (here by AlarmHistorian alone), the same invalid prefix is a named validator error.</summary>
[Fact]
public void QuestDb_invalid_prefix_consumed_is_a_validator_error_naming_the_key()
{
var options = new ServerHistorianOptions { Backend = "QuestDb" };
var result = QuestDbSut("Host=questdb;Port=8812", "bad;prefix", alarmHistorianEnabled: true).Validate(null, options);
result.Failed.ShouldBeTrue();
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:QuestDb:TablePrefix"));
}
}
@@ -6,6 +6,7 @@ using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
@@ -86,4 +87,46 @@ public sealed class HistorianBackendRegistryTests
holder.Updater.ShouldNotBeNull();
holder.Updater!.GetType().Name.ShouldBe("SqlServerHistorianDataUpdater");
}
private static IConfiguration QuestDbCfg() => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ServerHistorian:QuestDb:ConnectionString"] = "Host=localhost;Port=18812;Username=admin;Password=quest;Database=qdb",
["ServerHistorian:QuestDb:TablePrefix"] = "rig_",
}).Build();
[Theory]
[InlineData("questdb")]
[InlineData("QUESTDB")]
[InlineData("QuestDb")]
public void QuestDb_backend_resolves_case_insensitively(string name) =>
HistorianBackendRegistry.Resolve(new ServerHistorianOptions { Backend = name, Enabled = true }, QuestDbCfg())
.ShouldBeOfType<QuestDbHistorianProvider>().Name.ShouldBe("QuestDb");
[Fact]
public void QuestDb_backend_resolves_and_builds_the_questdb_seams()
{
var sp = new ServiceCollection().BuildServiceProvider();
var p = HistorianBackendRegistry.Resolve(new ServerHistorianOptions { Backend = "questdb", Enabled = true }, QuestDbCfg());
p.ShouldBeOfType<QuestDbHistorianProvider>();
HistorianBackendRegistry.KnownBackends.ShouldContain("QuestDb");
p.SupportedKinds.Count.ShouldBe(6);
p.SupportedKinds.ShouldContain(HistorianValueKind.String);
p.SupportedKinds.ShouldContain(HistorianValueKind.Empty);
p.CreateDataSource(sp).ShouldBeOfType<QuestDbHistorianDataSource>();
p.CreateValueWriter(sp).GetType().Name.ShouldBe("QuestDbHistorianValueWriter");
p.CreateAlarmWriter(sp).GetType().Name.ShouldBe("QuestDbAlarmHistorianWriter");
p.CreateProvisioning(sp).GetType().Name.ShouldBe("QuestDbTagProvisioner");
var holder = new HistorianUpdaterHolder(p.CreateUpdater(sp));
holder.Updater.ShouldNotBeNull();
holder.Updater!.GetType().Name.ShouldBe("QuestDbHistorianDataUpdater");
}
[Fact]
public void BindQuestDb_reads_the_QuestDb_section()
{
var o = HistorianBackendRegistry.BindQuestDb(QuestDbCfg());
o.ConnectionString.ShouldContain("Port=18812");
o.TablePrefix.ShouldBe("rig_");
}
}