Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Tests/QuestDbConnectionTests.cs
T
Joseph Doherty 8d82d049e9 fix(historian): QuestDB final-review fix wave (I-1, M-1, M-3, M-5, M-8, M-9)
- 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
2026-10-11 03:35:35 -04:00

185 lines
7.1 KiB
C#

using System.Net.Sockets;
using Npgsql;
using NpgsqlTypes;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Tests;
public sealed class QuestDbConnectionTests
{
private static DateTime Bound(DateTime v, bool clamp = false)
{
var cmd = new NpgsqlCommand();
var p = QuestDbConnection.BindTimestamp(cmd, v, clamp);
p.NpgsqlDbType.ShouldBe(NpgsqlDbType.TimestampTz);
var bound = (DateTime)p.Value!;
bound.Kind.ShouldBe(DateTimeKind.Utc);
return bound;
}
[Fact]
public void Round_truncates_sub_microsecond_ticks()
{
var t = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc).AddTicks(1234567);
QuestDbConnection.Round(t).ShouldBe(new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc).AddTicks(1234560));
}
[Fact]
public void Round_truncates_rather_than_rounds_before_2000()
{
var t = new DateTime(1990, 5, 5, 0, 0, 0, DateTimeKind.Utc).AddTicks(1234565);
QuestDbConnection.Round(t).Ticks.ShouldBe(t.Ticks - 5);
}
[Fact]
public void Round_treats_Unspecified_as_UTC()
{
var u = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Unspecified);
var r = QuestDbConnection.Round(u);
r.Kind.ShouldBe(DateTimeKind.Utc);
r.Ticks.ShouldBe(u.Ticks);
}
[Fact]
public void Round_converts_Local_to_UTC()
{
var local = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Local);
var r = QuestDbConnection.Round(local);
r.Kind.ShouldBe(DateTimeKind.Utc);
r.ShouldBe(local.ToUniversalTime());
}
[Fact]
public void Round_keeps_a_UTC_value_that_is_already_aligned()
{
var t = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);
QuestDbConnection.Round(t).ShouldBe(t);
}
[Theory]
[InlineData(0)] // MinValue itself
[InlineData(5)] // truncates to MinValue
public void BindTimestamp_never_emits_MinValue(long ticks)
{
var b = Bound(new DateTime(ticks, DateTimeKind.Utc));
b.ShouldNotBe(DateTime.MinValue);
b.Ticks.ShouldBe(10);
b.ShouldBe(new DateTime(10, DateTimeKind.Utc)); // 0001-01-01T00:00:00.000001 (T0-confirmed)
}
[Fact]
public void BindTimestamp_never_emits_MaxValue()
{
var b = Bound(DateTime.MaxValue);
b.ShouldNotBe(DateTime.MaxValue);
b.ShouldBe(new DateTime(9999, 12, 31, 23, 59, 59, DateTimeKind.Utc).AddTicks(9999990)); // T0-confirmed bound
Bound(DateTime.MinValue).ShouldNotBe(DateTime.MinValue);
}
[Fact]
public void BindTimestamp_never_emits_either_extreme_for_any_kind()
{
foreach (var kind in new[] { DateTimeKind.Utc, DateTimeKind.Unspecified, DateTimeKind.Local })
foreach (var t in new[] { new DateTime(DateTime.MinValue.Ticks, kind), new DateTime(DateTime.MaxValue.Ticks, kind) })
{
var b = Bound(t);
b.ShouldNotBe(DateTime.MinValue);
b.ShouldNotBe(DateTime.MaxValue);
}
}
[Fact]
public void BindTimestamp_keeps_a_pre_1970_range_bound_unless_asked_to_clamp()
{
var pre = new DateTime(1960, 1, 1, 0, 0, 0, DateTimeKind.Utc);
Bound(pre).ShouldBe(pre);
Bound(pre, clamp: true).ShouldBe(DateTime.UnixEpoch);
Bound(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), clamp: true).Year.ShouldBe(2026);
}
[Fact]
public void IsStorable_is_false_only_before_1970()
{
QuestDbConnection.IsStorable(DateTime.UnixEpoch).ShouldBeTrue();
QuestDbConnection.IsStorable(DateTime.UnixEpoch.AddTicks(-1)).ShouldBeFalse();
QuestDbConnection.IsStorable(new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc)).ShouldBeFalse();
QuestDbConnection.IsStorable(DateTime.MinValue).ShouldBeFalse();
QuestDbConnection.IsStorable(DateTime.MaxValue).ShouldBeTrue();
QuestDbConnection.IsStorable(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)).ShouldBeTrue();
}
[Fact]
public void BindByte_binds_smallint()
{
var p = QuestDbConnection.BindByte(new NpgsqlCommand(), 200);
p.NpgsqlDbType.ShouldBe(NpgsqlDbType.Smallint);
p.Value.ShouldBe((short)200);
}
[Fact]
public void IsTransient_classifies_transport_failures_not_server_rejections()
{
QuestDbConnection.IsTransient(new SocketException()).ShouldBeTrue();
QuestDbConnection.IsTransient(new IOException("x")).ShouldBeTrue();
QuestDbConnection.IsTransient(new TimeoutException()).ShouldBeTrue();
QuestDbConnection.IsTransient(new NpgsqlException("x", new IOException())).ShouldBeTrue();
QuestDbConnection.IsTransient(new InvalidOperationException("x")).ShouldBeFalse();
QuestDbConnection.IsTransient(new OperationCanceledException()).ShouldBeFalse();
}
[Fact]
public void T_prefixes_the_table_base_name()
{
var c = new QuestDbConnection(new QuestDbHistorianOptions { ConnectionString = "Host=127.0.0.1;Port=1", TablePrefix = "p_" });
c.T("sample").ShouldBe("p_sample");
c.SampleTable.ShouldBe("p_sample");
c.EventTable.ShouldBe("p_event");
c.TagTable.ShouldBe("p_tag");
}
// ---- pool size (final review I-1) -------------------------------------------------------------------------------
private static int PoolOf(string cs)
{
using var c = new QuestDbConnection(new QuestDbHistorianOptions { ConnectionString = cs });
return c.EffectiveMaxPoolSize;
}
[Fact]
public void An_unset_Maximum_Pool_Size_is_capped_at_24() =>
PoolOf("Host=127.0.0.1;Port=1").ShouldBe(24);
[Fact]
public void The_default_cap_constant_is_24() => QuestDbConnection.DefaultMaxPoolSize.ShouldBe(24);
[Theory]
[InlineData("Host=127.0.0.1;Port=1;Maximum Pool Size=10", 10)]
[InlineData("Host=127.0.0.1;Port=1;Maximum Pool Size=80", 80)]
[InlineData("Host=127.0.0.1;Port=1;maximum pool size=30", 30)]
[InlineData("Host=127.0.0.1;Port=1;MaxPoolSize=80", 80)]
public void An_explicit_Maximum_Pool_Size_is_preserved(string cs, int expected) =>
PoolOf(cs).ShouldBe(expected);
[Fact]
public void The_effective_pool_size_is_logged_once_at_Information()
{
var log = new InfoLogger();
using var c = new QuestDbConnection(new QuestDbHistorianOptions { ConnectionString = "Host=127.0.0.1;Port=1" }, log);
log.Messages.Count.ShouldBe(1);
log.Messages[0].ShouldContain("24");
}
private sealed class InfoLogger : Microsoft.Extensions.Logging.ILogger
{
public List<string> Messages { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId, TState state,
Exception? exception, Func<TState, Exception?, string> formatter)
{
if (logLevel == Microsoft.Extensions.Logging.LogLevel.Information) Messages.Add(formatter(state, exception));
}
}
}