0e2bffc74a
AtTime conformance stamps are the requested time (M3; identical on SQL Server). The QuestDB test fixture prunes idle pooled connections after 1 s: undisposed per-test data sources exhausted QuestDB's 64-connection PG-wire limit in a full run. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
117 lines
5.2 KiB
C#
117 lines
5.2 KiB
C#
using System.Collections.Concurrent;
|
|
using Npgsql;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Env-gated QuestDB for the historian integration tests. <c>HISTORIAN_QUESTDB_TEST_CONNECTION</c> absent ⇒
|
|
/// <see cref="SkipReason"/> is set, nothing connects and every test calls <c>Assert.Skip</c>. Present ⇒ tests isolate
|
|
/// from each other with <see cref="UniquePrefix"/>; every prefix handed out is dropped on dispose (and a test may
|
|
/// drop its own early with <see cref="DropPrefixAsync"/>). Never touches a table outside those prefixes.
|
|
/// </summary>
|
|
public sealed class QuestDbFixture : IAsyncLifetime
|
|
{
|
|
/// <summary>Env var holding an Npgsql connection string for QuestDB's PG-wire port.</summary>
|
|
public const string ConnectionEnvVar = "HISTORIAN_QUESTDB_TEST_CONNECTION";
|
|
|
|
private readonly ConcurrentBag<string> _prefixes = new();
|
|
|
|
/// <summary>Why tests must skip, or null when QuestDB is available.</summary>
|
|
public string? SkipReason { get; private set; }
|
|
|
|
/// <summary>The Npgsql connection string. Valid only when <see cref="SkipReason"/> is null.</summary>
|
|
public string ConnectionString { get; private set; } = "";
|
|
|
|
/// <summary>Skips the calling test when the gate is closed.</summary>
|
|
public void SkipIfUnavailable()
|
|
{
|
|
if (SkipReason is not null) Assert.Skip(SkipReason);
|
|
}
|
|
|
|
/// <summary>A fresh, valid table prefix (<c>"t" + 12 hex + "_"</c>) that nothing uses yet; dropped on dispose.</summary>
|
|
/// <returns>The prefix.</returns>
|
|
public string UniquePrefix()
|
|
{
|
|
var p = "t" + Guid.NewGuid().ToString("N")[..12] + "_";
|
|
_prefixes.Add(p);
|
|
return p;
|
|
}
|
|
|
|
/// <summary>Options targeting this fixture's QuestDB under <paramref name="prefix"/>.</summary>
|
|
/// <param name="prefix">Table prefix (normally from <see cref="UniquePrefix"/>).</param>
|
|
/// <param name="retentionDays">Optional TTL.</param>
|
|
/// <param name="autoCreate">AutoCreateSchema.</param>
|
|
/// <returns>Valid options.</returns>
|
|
/// <remarks>
|
|
/// Pooled connections idle for more than a second are closed (<c>Connection Idle Lifetime</c> /
|
|
/// <c>Connection Pruning Interval</c>). Many tests build their own <see cref="QuestDbConnection"/> (one Npgsql
|
|
/// data source each) and never dispose it; with Npgsql's default 300 s idle lifetime their pooled connections
|
|
/// piled up until QuestDB's PG-wire limit (64) was reached and later tests' opens timed out after 15 s.
|
|
/// </remarks>
|
|
public QuestDbHistorianOptions Options(string prefix, int? retentionDays = null, bool autoCreate = true) => new()
|
|
{
|
|
ConnectionString = new NpgsqlConnectionStringBuilder(ConnectionString)
|
|
{
|
|
ConnectionIdleLifetime = 1,
|
|
ConnectionPruningInterval = 1,
|
|
}.ConnectionString,
|
|
TablePrefix = prefix,
|
|
RetentionDays = retentionDays,
|
|
AutoCreateSchema = autoCreate,
|
|
};
|
|
|
|
/// <summary>Drops every table whose name starts with <paramref name="prefix"/> (found through <c>tables()</c>).</summary>
|
|
/// <param name="prefix">Table prefix.</param>
|
|
/// <returns>A task completing when they are gone.</returns>
|
|
public async Task DropPrefixAsync(string prefix)
|
|
{
|
|
if (SkipReason is not null) return;
|
|
if (!QuestDbHistorianOptions.IsValidPrefix(prefix)) throw new ArgumentException("Invalid prefix.", nameof(prefix));
|
|
await using var ds = NpgsqlDataSource.Create(ConnectionString);
|
|
await using var conn = await ds.OpenConnectionAsync().ConfigureAwait(false);
|
|
var names = new List<string>();
|
|
await using (var cmd = conn.CreateCommand())
|
|
{
|
|
cmd.CommandText = "SELECT table_name FROM tables() WHERE starts_with(table_name, $1)"; // never LIKE: '_' is a wildcard
|
|
cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Varchar, Value = prefix });
|
|
await using var r = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
|
|
while (await r.ReadAsync().ConfigureAwait(false)) names.Add(r.GetString(0));
|
|
}
|
|
foreach (var n in names)
|
|
{
|
|
await using var drop = conn.CreateCommand();
|
|
drop.CommandText = $"DROP TABLE IF EXISTS {n}";
|
|
await drop.ExecuteNonQueryAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public ValueTask InitializeAsync()
|
|
{
|
|
var cs = Environment.GetEnvironmentVariable(ConnectionEnvVar);
|
|
if (string.IsNullOrWhiteSpace(cs))
|
|
SkipReason = $"{ConnectionEnvVar} is not set; QuestDB historian integration tests skipped.";
|
|
else
|
|
ConnectionString = cs;
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (SkipReason is not null) return;
|
|
foreach (var p in _prefixes.Distinct())
|
|
await DropPrefixAsync(p).ConfigureAwait(false);
|
|
NpgsqlConnection.ClearAllPools();
|
|
}
|
|
}
|
|
|
|
/// <summary>Shares one fixture across the QuestDB integration tests.</summary>
|
|
[CollectionDefinition(Name)]
|
|
public sealed class QuestDbCollection : ICollectionFixture<QuestDbFixture>
|
|
{
|
|
/// <summary>Collection name.</summary>
|
|
public const string Name = "QuestDb";
|
|
}
|