8d82d049e9
- I-1: QuestDbConnection caps Npgsql Maximum Pool Size at 24 (DefaultMaxPoolSize) when the connection string does not set it; an explicit value is kept. The effective value is logged once at Information. - M-1: QuestDbTagCatalog.EnsureAsync waits for the tag table's sequenced commits and re-checks before inserting, so it no longer overwrites a provisioner row (unit/description) that is committed but not yet applied. Integration test reproduces the clobber against real QuestDB (falsified). - M-3: the pre-1970 drop Warning is rate-limited per tag (once per 10 min); DroppedPre1970 still counts every sample. - M-5: QuestDbHistorianProvider is IAsyncDisposable/IDisposable and disposes the shared Npgsql pool; Program.cs registers the provider through a factory and every seam resolves it from the container, so DI disposes it at shutdown. - M-8, M-9: docs fixes (HistoryUpdate transaction wording; folded duplicate HistoryUpdate paragraph). Pool-size docs updated for the new default cap. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
136 lines
6.8 KiB
C#
136 lines
6.8 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests;
|
|
|
|
[Collection(QuestDbCollection.Name)]
|
|
public sealed class ProvisioningTests(QuestDbFixture fx)
|
|
{
|
|
private (QuestDbTagProvisioner P, QuestDbConnection C, QuestDbTagCatalog Cat) Make(string? prefix = null, string? connectionString = null,
|
|
bool autoCreate = true)
|
|
{
|
|
var o = fx.Options(prefix ?? fx.UniquePrefix(), autoCreate: autoCreate);
|
|
if (connectionString is not null) o.ConnectionString = connectionString;
|
|
var c = new QuestDbConnection(o);
|
|
var b = new QuestDbSchemaBootstrapper(c, o);
|
|
var wal = new QuestDbWalVisibility(c, o.VisibilityTimeout);
|
|
var cat = new QuestDbTagCatalog(c, b, wal);
|
|
return (new QuestDbTagProvisioner(c, b, cat, wal), c, cat);
|
|
}
|
|
|
|
private static async Task<(byte Type, string? Eu, string? Desc)> TagAsync(QuestDbConnection c, string name)
|
|
{
|
|
await using var conn = await c.OpenAsync(default);
|
|
await using var cmd = c.CreateCommand(conn, $"SELECT data_type, eng_unit, description FROM {c.TagTable} WHERE name = $1");
|
|
QuestDbConnection.BindText(cmd, name);
|
|
await using var r = await cmd.ExecuteReaderAsync();
|
|
(await r.ReadAsync()).ShouldBeTrue();
|
|
return (QuestDbConnection.ReadKind(r, 0), r.IsDBNull(1) ? null : r.GetString(1), r.IsDBNull(2) ? null : r.GetString(2));
|
|
}
|
|
|
|
private static async Task<long> CountAsync(QuestDbConnection c)
|
|
{
|
|
await using var conn = await c.OpenAsync(default);
|
|
await using var cmd = c.CreateCommand(conn, $"SELECT count() FROM {c.TagTable}");
|
|
return Convert.ToInt64(await cmd.ExecuteScalarAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Upsert_creates_then_changes_DataType_on_rerun_and_every_type_is_accepted()
|
|
{
|
|
fx.SkipIfUnavailable();
|
|
var (p, c, _) = Make();
|
|
var r1 = await p.EnsureTagsAsync([new("a", DriverDataType.Boolean, "degC", "desc"), new("s", DriverDataType.String, null, null)], CancellationToken.None);
|
|
r1.ShouldBe(new HistorianProvisionResult(2, 2, 0, 0));
|
|
(await TagAsync(c, "a")).ShouldBe(((byte)DriverDataType.Boolean, "degC", "desc"));
|
|
var r2 = await p.EnsureTagsAsync([new("a", DriverDataType.Float64, null, null)], CancellationToken.None);
|
|
r2.ShouldBe(new HistorianProvisionResult(1, 1, 0, 0));
|
|
(await TagAsync(c, "a")).ShouldBe(((byte)DriverDataType.Float64, "degC", "desc")); // null keeps stored metadata
|
|
(await CountAsync(c)).ShouldBe(2); // upsert by name, not a second row
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_request_with_values_replaces_only_the_fields_it_carries()
|
|
{
|
|
fx.SkipIfUnavailable();
|
|
var (p, c, _) = Make();
|
|
await p.EnsureTagsAsync([new("m", DriverDataType.Int32, "A", "first")], CancellationToken.None);
|
|
await p.EnsureTagsAsync([new("m", DriverDataType.Int32, "B", null)], CancellationToken.None);
|
|
(await TagAsync(c, "m")).ShouldBe(((byte)DriverDataType.Int32, "B", "first"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Duplicate_names_and_bad_names_are_tallied_not_thrown()
|
|
{
|
|
fx.SkipIfUnavailable();
|
|
var (p, _, _) = Make();
|
|
var r = await p.EnsureTagsAsync([
|
|
new("d", DriverDataType.Int32, null, null), new("d", DriverDataType.Int64, null, null),
|
|
new("", DriverDataType.Int32, null, null), new(new string('x', 401), DriverDataType.Int32, null, null)], CancellationToken.None);
|
|
r.ShouldBe(new HistorianProvisionResult(4, 2, 0, 2));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task More_tags_than_one_chunk_all_land()
|
|
{
|
|
fx.SkipIfUnavailable();
|
|
var (p, c, _) = Make();
|
|
var reqs = Enumerable.Range(0, 1000).Select(i => new HistorianTagProvisionRequest($"bulk{i}", DriverDataType.Float32, null, null)).ToList();
|
|
(await p.EnsureTagsAsync(reqs, CancellationToken.None)).ShouldBe(new HistorianProvisionResult(1000, 1000, 0, 0));
|
|
(await CountAsync(c)).ShouldBe(1000);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_later_catalog_Ensure_of_a_provisioned_tag_does_not_wipe_its_metadata()
|
|
{
|
|
fx.SkipIfUnavailable();
|
|
var (p, c, cat) = Make();
|
|
await p.EnsureTagsAsync([new("k", DriverDataType.Float64, "kg", "mass")], CancellationToken.None);
|
|
(await cat.ExistsAsync("k", CancellationToken.None)).ShouldBeTrue();
|
|
await cat.EnsureAsync("k", 1, CancellationToken.None);
|
|
(await TagAsync(c, "k")).ShouldBe(((byte)DriverDataType.Float64, "kg", "mass"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Final review M-1: the catalog's first sighting of a tag can run while the provisioner's row is committed but
|
|
/// not yet applied (its WAL-visibility window). An unconditional catalog INSERT then lands after it and the
|
|
/// dedup key replaces the provisioner's row with one carrying no unit/description. The provisioner here does
|
|
/// not wait (VisibilityTimeout 0) and has its own catalog, so every catalog Ensure runs inside that window.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_catalog_Ensure_inside_the_provisioners_visibility_window_keeps_its_metadata()
|
|
{
|
|
fx.SkipIfUnavailable();
|
|
var o = fx.Options(fx.UniquePrefix());
|
|
await using var c = new QuestDbConnection(o);
|
|
var b = new QuestDbSchemaBootstrapper(c, o);
|
|
await b.EnsureAsync(CancellationToken.None);
|
|
var noWait = new QuestDbWalVisibility(c, TimeSpan.Zero);
|
|
var provisioner = new QuestDbTagProvisioner(c, b, new QuestDbTagCatalog(c, b, noWait), noWait);
|
|
var catalog = new QuestDbTagCatalog(c, b, new QuestDbWalVisibility(c, o.VisibilityTimeout));
|
|
|
|
const int Tags = 25;
|
|
for (var i = 0; i < Tags; i++)
|
|
{
|
|
var name = $"race{i}";
|
|
(await provisioner.EnsureTagsAsync([new(name, DriverDataType.Float64, "kg", $"mass {i}")], CancellationToken.None))
|
|
.Ensured.ShouldBe(1);
|
|
await catalog.EnsureAsync(name, 1, CancellationToken.None); // the recorder's first value for the tag
|
|
}
|
|
|
|
await new QuestDbWalVisibility(c, o.VisibilityTimeout).WaitAsync(c.TagTable, CancellationToken.None);
|
|
for (var i = 0; i < Tags; i++)
|
|
(await TagAsync(c, $"race{i}")).ShouldBe(((byte)DriverDataType.Float64, "kg", $"mass {i}"), $"race{i}");
|
|
(await CountAsync(c)).ShouldBe(Tags);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_connection_failure_is_a_Failed_tally_not_an_exception()
|
|
{
|
|
fx.SkipIfUnavailable();
|
|
var (p, _, _) = Make(connectionString: "Host=localhost;Port=1;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading;Timeout=2");
|
|
(await p.EnsureTagsAsync([new("x", DriverDataType.Int32, null, null)], CancellationToken.None)).ShouldBe(new HistorianProvisionResult(1, 0, 0, 1));
|
|
}
|
|
}
|