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
@@ -92,6 +92,39 @@ public sealed class ProvisioningTests(QuestDbFixture fx)
(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()
{