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
This commit is contained in:
Joseph Doherty
2026-10-11 03:35:35 -04:00
parent e177a50046
commit 8d82d049e9
11 changed files with 309 additions and 30 deletions
@@ -18,8 +18,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb;
/// validation and no I/O, so resolving the provider for a backend nobody consumes never fails on its options
/// (they are validated, consumer-gated, by the host's <c>ServerHistorianOptionsValidator</c>). The first
/// <c>Create*</c> call's service provider supplies the logger factory the shared pieces log through.
/// <para>The provider owns the shared connection pool and disposes it (IAsyncDisposable/IDisposable); the host
/// registers the provider through a DI factory so the container disposes it at shutdown.</para>
/// </remarks>
public sealed class QuestDbHistorianProvider : IHistorianBackendProvider
public sealed class QuestDbHistorianProvider : IHistorianBackendProvider, IAsyncDisposable, IDisposable
{
private static readonly FrozenSet<HistorianValueKind> Kinds =
Enum.GetValues<HistorianValueKind>().ToFrozenSet();
@@ -27,6 +29,7 @@ public sealed class QuestDbHistorianProvider : IHistorianBackendProvider
private readonly QuestDbHistorianOptions _options;
private readonly object _initLock = new();
private Shared? _shared;
private bool _disposed;
/// <summary>Creates the provider over the <c>ServerHistorian:QuestDb</c> options (validated elsewhere).</summary>
/// <param name="options">The <c>ServerHistorian:QuestDb</c> options.</param>
@@ -51,7 +54,8 @@ public sealed class QuestDbHistorianProvider : IHistorianBackendProvider
{
if (_shared is { } raced) return raced;
var loggers = Loggers(sp);
var conn = new QuestDbConnection(_options);
ObjectDisposedException.ThrowIf(_disposed, this);
var conn = new QuestDbConnection(_options, loggers.CreateLogger<QuestDbConnection>());
var bootstrap = new QuestDbSchemaBootstrapper(conn, _options, loggers.CreateLogger<QuestDbSchemaBootstrapper>());
var wal = new QuestDbWalVisibility(conn, _options.VisibilityTimeout, loggers.CreateLogger<QuestDbWalVisibility>());
var shared = new Shared(conn, bootstrap, new QuestDbTagCatalog(conn, bootstrap, wal), wal);
@@ -101,6 +105,28 @@ public sealed class QuestDbHistorianProvider : IHistorianBackendProvider
Loggers(sp).CreateLogger<QuestDbHistorianDataUpdater>());
}
/// <summary>Disposes the shared connection pool, when one was built. Idempotent.</summary>
/// <returns>A task completing when the pool is disposed.</returns>
public async ValueTask DisposeAsync()
{
if (TakeForDispose() is { } conn) await conn.DisposeAsync().ConfigureAwait(false);
}
/// <summary>Disposes the shared connection pool, when one was built. Idempotent.</summary>
public void Dispose() => TakeForDispose()?.Dispose();
private QuestDbConnection? TakeForDispose()
{
lock (_initLock)
{
if (_disposed) return null;
_disposed = true;
var conn = _shared?.Conn;
_shared = null;
return conn;
}
}
private sealed record Shared(
QuestDbConnection Conn, QuestDbSchemaBootstrapper Bootstrap, QuestDbTagCatalog Catalog, QuestDbWalVisibility Wal);
}