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; } }