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
185 lines
7.1 KiB
C#
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));
|
|
}
|
|
}
|
|
}
|