using System.Net.Sockets;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using NpgsqlTypes;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb;
///
/// Pooled Npgsql access to QuestDB plus every T0-confirmed typing rule, in one place: how timestamps are rounded and
/// bound (R6), how BYTE columns are bound and read, and how errors are classified.
/// Contract for callers: bind every inbound through (never
/// AddWithValue), call before writing a designated timestamp, and classify
/// failures with / . Every QuestDB server error carries
/// SQLSTATE 00000, so classification is by message text, never by SQLSTATE.
///
internal sealed class QuestDbConnection : IAsyncDisposable, IDisposable
{
///
/// Npgsql pool cap applied when the connection string does not set Maximum Pool Size itself. Npgsql's own
/// default (100) exceeds QuestDB's default PG-wire connection limit (64), and the server's default read
/// concurrency alone can reach 64; an explicit value in the connection string is always kept.
///
internal const int DefaultMaxPoolSize = 24;
private static readonly long MinSafeTicks = 10; // DateTime.MinValue itself binds as -infinity
private static readonly long MaxSafeTicks = DateTime.MaxValue.Ticks - DateTime.MaxValue.Ticks % 10; // MaxValue itself binds as +infinity
private readonly NpgsqlDataSource _dataSource;
/// Creates the connection helper; options must already be valid.
/// Validated options.
/// Optional logger; the effective pool size is logged once at Information.
public QuestDbConnection(QuestDbHistorianOptions options, ILogger? log = null)
{
ArgumentNullException.ThrowIfNull(options);
if (!QuestDbHistorianOptions.IsValidPrefix(options.TablePrefix))
throw new ArgumentException("Invalid table prefix.", nameof(options));
TablePrefix = options.TablePrefix;
CommandTimeoutSeconds = Math.Max(1, (int)Math.Ceiling(options.CommandTimeout.TotalSeconds));
var csb = new NpgsqlConnectionStringBuilder(options.ConnectionString);
var explicitPool = HasExplicitMaxPoolSize(options.ConnectionString);
if (!explicitPool) csb.MaxPoolSize = DefaultMaxPoolSize;
EffectiveMaxPoolSize = csb.MaxPoolSize;
(log ?? NullLogger.Instance).LogInformation(
"QuestDB historian connection pool: Maximum Pool Size = {MaxPoolSize} ({Source}).",
EffectiveMaxPoolSize, explicitPool ? "from the connection string" : "default cap");
_dataSource = new NpgsqlDataSourceBuilder(csb.ConnectionString).Build();
}
/// The Npgsql Maximum Pool Size in effect (the connection string's own value, else ).
internal int EffectiveMaxPoolSize { get; }
// Npgsql accepts "Maximum Pool Size" and "MaxPoolSize" (case-insensitive); a generic parse keeps the caller's own
// keywords so "was it set" is answered by the string itself, not by the builder's default.
private static bool HasExplicitMaxPoolSize(string connectionString)
{
var generic = new System.Data.Common.DbConnectionStringBuilder { ConnectionString = connectionString };
foreach (string key in generic.Keys)
{
var k = key.Replace(" ", "", StringComparison.Ordinal);
if (string.Equals(k, "MaxPoolSize", StringComparison.OrdinalIgnoreCase)
|| string.Equals(k, "MaximumPoolSize", StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// The validated table prefix.
public string TablePrefix { get; }
/// Command timeout applied by , in seconds.
public int CommandTimeoutSeconds { get; }
/// Prefixes a table base name (sample, event, tag) with the configured prefix.
/// Fixed base name from this project (never user input).
/// The full table name.
public string T(string table) => TablePrefix + table;
/// The prefixed sample table.
public string SampleTable => T(QuestDbTables.SampleBase);
/// The prefixed event table.
public string EventTable => T(QuestDbTables.EventBase);
/// The prefixed tag table.
public string TagTable => T(QuestDbTables.TagBase);
/// Opens a pooled connection (no bootstrap; callers run the bootstrapper first).
/// Cancellation.
/// An open connection the caller disposes.
public async Task OpenAsync(CancellationToken ct) =>
await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
/// Creates a text command with the configured timeout. Parameters are positional ($1, $2…).
/// Open connection.
/// Command text.
/// The command.
public NpgsqlCommand CreateCommand(NpgsqlConnection conn, string sql)
{
var cmd = conn.CreateCommand();
cmd.CommandText = sql;
cmd.CommandTimeout = CommandTimeoutSeconds;
return cmd;
}
///
public ValueTask DisposeAsync() => _dataSource.DisposeAsync();
///
public void Dispose() => _dataSource.Dispose();
// ---- timestamps (R6) -------------------------------------------------------------------------------------------
///
/// Normalises a timestamp for QuestDB (microsecond precision). Rule: Unspecified is treated as UTC
/// (SpecifyKind); Local is converted with ToUniversalTime; then ticks are truncated to a
/// microsecond (ticks - ticks % 10, because Npgsql truncates relative to 2000-01-01 and so rounds UP
/// before 2000); finally the result is nudged into [0001-01-01T00:00:00.000001, 9999-12-31T23:59:59.999999]
/// so a value equal to MinValue/MaxValue never reaches Npgsql, which would bind it as ±infinity
/// (a [Min, Max) range then silently matches nothing). The result always has Kind=Utc.
///
/// Any timestamp.
/// A UTC, microsecond-aligned, finite timestamp.
public static DateTime Round(DateTime value)
{
var utc = value.Kind switch
{
DateTimeKind.Local => value.ToUniversalTime(),
DateTimeKind.Utc => value,
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc),
};
var ticks = utc.Ticks - utc.Ticks % 10;
ticks = Math.Clamp(ticks, MinSafeTicks, MaxSafeTicks);
return new DateTime(ticks, DateTimeKind.Utc);
}
///
/// True when QuestDB can store the timestamp as a designated timestamp: after it is on or
/// after 1970-01-01 (RC1). Writers drop (live) or refuse (HistoryUpdate) samples for which this is false; reads
/// with earlier bounds are fine.
///
/// Candidate sample time.
/// Whether an INSERT with this designated timestamp can succeed.
public static bool IsStorable(DateTime value) => Round(value) >= DateTime.UnixEpoch;
///
/// Appends the next positional parameter ($n) as timestamp with time zone carrying
/// 's UTC value (T0-confirmed: Timestamp+Utc throws in Npgsql). It never emits
/// MinValue/MaxValue. By default a pre-1970 value is kept as is (a pre-1970 range bound is valid
/// and clamping it would move the bound across the epoch row); pass for a
/// designated-timestamp column so a pre-1970 value can only ever become the epoch itself, never a failed batch.
///
/// Target command.
/// Any timestamp.
/// Clamp anything before 1970-01-01 to the epoch (writes only, after ).
/// The added parameter.
public static NpgsqlParameter BindTimestamp(NpgsqlCommand cmd, DateTime value, bool clampToEpoch = false)
{
var rounded = Round(value);
if (clampToEpoch && rounded < DateTime.UnixEpoch) rounded = DateTime.UnixEpoch;
var p = new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.TimestampTz, Value = rounded };
cmd.Parameters.Add(p);
return p;
}
/// Appends a BYTE parameter (bound as smallint, T0-confirmed).
/// Target command.
/// The byte.
/// The added parameter.
public static NpgsqlParameter BindByte(NpgsqlCommand cmd, byte value)
{
var p = new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Smallint, Value = (short)value };
cmd.Parameters.Add(p);
return p;
}
/// Appends a LONG parameter (bigint; also the rule for integer arithmetic in tick math).
/// Target command.
/// The value.
/// The added parameter.
public static NpgsqlParameter BindLong(NpgsqlCommand cmd, long value)
{
var p = new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Bigint, Value = value };
cmd.Parameters.Add(p);
return p;
}
/// Appends a SYMBOL/VARCHAR parameter (varchar); null binds as NULL.
/// Target command.
/// The string.
/// The added parameter.
public static NpgsqlParameter BindText(NpgsqlCommand cmd, string? value)
{
var p = new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Varchar, Value = (object?)value ?? DBNull.Value };
cmd.Parameters.Add(p);
return p;
}
/// Reads a TIMESTAMP column as UTC (QuestDB returns Kind=Unspecified holding UTC wall clock).
/// Reader positioned on a row.
/// Column ordinal.
/// The timestamp with Kind=Utc.
public static DateTime ReadUtc(NpgsqlDataReader r, int i) =>
DateTime.SpecifyKind(r.GetDateTime(i), DateTimeKind.Utc);
/// Reads a BYTE column (QuestDB returns smallint); NULL (never stored) reads as 0 (Empty).
/// Reader positioned on a row.
/// Column ordinal.
/// The byte value.
public static byte ReadKind(NpgsqlDataReader r, int i) => r.IsDBNull(i) ? (byte)0 : (byte)r.GetInt16(i);
// ---- error classification --------------------------------------------------------------------------------------
///
/// True for a transport-level or timeout failure worth retrying. A is a
/// server-side rejection (QuestDB reports every one as SQLSTATE 00000), so it is never transient here; decide
/// those by message text.
///
/// The failure.
/// Whether the failure is transient.
public static bool IsTransient(Exception ex)
{
for (var e = ex; e is not null; e = e.InnerException)
{
if (e is PostgresException or OperationCanceledException) return false;
if (e is NpgsqlException { IsTransient: true } or SocketException or IOException or TimeoutException) return true;
}
return false;
}
/// True when a server error says the object already exists (message text; SQLSTATE is always 00000).
/// The failure.
/// Whether it is an "already exists" rejection.
public static bool IsAlreadyExists(Exception ex) =>
ex is PostgresException pg && pg.MessageText.Contains("already exists", StringComparison.OrdinalIgnoreCase);
/// Whether a table exists, per tables().
/// Open connection.
/// Full table name.
/// Cancellation.
/// True when listed.
public async Task TableExistsAsync(NpgsqlConnection conn, string table, CancellationToken ct)
{
await using var cmd = CreateCommand(conn, "SELECT count() FROM tables() WHERE table_name = $1");
BindText(cmd, table);
return Convert.ToInt64(await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false)) > 0;
}
}