using System.Text; using Microsoft.Extensions.Logging; using Npgsql; using NpgsqlRest; namespace NpgsqlRestClient; /// /// Database change detection for watch mode. There is no filesystem to watch for routine-source /// endpoints — instead, the poller runs the SAME discovery query the routine source uses (same /// configured filters — see ), hashed server-side /// into one scalar, on a dedicated non-pooled connection. If the hash changes, the discovered /// endpoints changed — by definition: create/replace/drop/alter of functions and procedures, grants, /// COMMENT ON (annotations), schema renames, and changes to the composite/table types their signatures /// use. Anything the query does not read (an unrelated table, temp objects, data) can never trigger. /// public sealed class WatchDbPoller( string connectionString, TimeSpan interval, Action onChange, ILogger? logger, IReadOnlyList sources) { private readonly string _connString = new NpgsqlConnectionStringBuilder(connectionString) { Pooling = false }.ConnectionString; private NpgsqlConnection? _conn; private string? _baseline; private volatile bool _rebaseline; /// Fingerprint of one source's discovery result on an open connection (also used by tests). public static string? GetFingerprint(NpgsqlConnection connection, RoutineSource source) { using var cmd = source.CreateFingerprintCommand(connection); return cmd.ExecuteScalar() as string; } /// /// Forget the baseline so the next tick captures a fresh one WITHOUT firing. Called after work /// that legitimately changes the database (a test rerun with committed fixtures, a restart) so /// self-inflicted changes never trigger. /// public void Rebaseline() => _rebaseline = true; /// Poll loop; runs until cancelled. Connection failures skip the tick and retry. public async Task RunAsync(CancellationToken ct) { if (sources.Count == 0) { return; } while (ct.IsCancellationRequested is false) { try { await Task.Delay(interval, ct); } catch (OperationCanceledException) { break; } try { if (_conn is null || _conn.State != System.Data.ConnectionState.Open) { _conn?.Dispose(); _conn = new NpgsqlConnection(_connString); await _conn.OpenAsync(ct); } var sb = new StringBuilder(); foreach (var source in sources) { await using var cmd = source.CreateFingerprintCommand(_conn); sb.Append(await cmd.ExecuteScalarAsync(ct) as string).Append('|'); } var current = sb.ToString(); if (_rebaseline) { _rebaseline = false; _baseline = current; continue; } if (_baseline is null) { _baseline = current; continue; } if (string.Equals(_baseline, current, StringComparison.Ordinal) is false) { _baseline = current; onChange(); } } catch (OperationCanceledException) { break; } catch (Exception ex) { // DB down / restarting: skip this tick, reconnect on the next one. logger?.LogDebug("watch: database poll failed ({Message}) — retrying", ex.Message); try { _conn?.Dispose(); } catch { } _conn = null; } } try { _conn?.Dispose(); } catch { } } }