From b9d01ef1474e65234e709fc235d8f9a3b04cbfc4 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 18:29:05 -0500 Subject: [PATCH 01/41] docs: record M1 design decisions --- AGENTS.md | 6 +++--- ROADMAP.md | 16 +++++++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b8ef622..e2b2905 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ The product is only as good as these rules. Never break them, not even in debug - **Read-only, always.** A check is one `SELECT` against catalogs and statistics views. No DDL or DML, and no functions with side effects: `pg_terminate_backend`, `pg_cancel_backend`, `pg_reload_conf`, `pg_stat_reset*`, `pg_switch_wal`, `pg_create_*`, `pg_drop_*`, `pg_advisory_*`, `nextval`, `setval`, `set_config`, `txid_current`. Every statement pgcheckup sends runs inside `BEGIN READ ONLY` with `SET LOCAL statement_timeout`, `lock_timeout` and `search_path = pg_catalog, pg_temp`, then rolls back. Never set anything for the whole session, because behind a transaction pooler it reaches the app's connections. Never weaken or bypass these guards. - **Fixes are text.** pgcheckup prints fix SQL and never executes it. -- **Least privilege.** No check needs more than `pg_monitor`. Never require superuser or `rds_superuser`. If the role lacks a privilege, the check is skipped with the reason. It is never an error. +- **Least privilege.** No check needs more than `pg_monitor`, except `integer-exhaustion`, which needs SELECT on sequences (counters only, never rows). Never require superuser or `rds_superuser`. If the role lacks a privilege, the check is skipped with the reason. It is never an error. - **No network beyond the Postgres connection.** No telemetry, update checks, crash reporting or remote lookups. Data such as end-of-life dates ships inside the release. - **No secrets or data in output.** Never print or log a password or a full connection string, query text (`pg_stat_activity.query`, `pg_stat_statements.query`), row data or client addresses. Findings name objects, settings, process IDs and durations only. - **Placeholders everywhere.** Docs, fixtures, tests and issues use `db.example.com`, `app` and `checkup`, never real hosts or credentials. @@ -36,10 +36,10 @@ The product is only as good as these rules. Never break them, not even in debug - `check.sql` is one read-only query that returns values, never prose. The wording lives in the `message` and `fix` templates in `check.md`. Thresholds come in as `@name` parameters and are never hard-coded. The search path is `pg_catalog` only, so qualify anything in another schema. - Compute ages and durations in SQL from the server's `now()`, not the client's clock. - `check.md` has **What breaks**, **Fix** and **Seen in** sections. Every check has at least one **Seen in** link to a public incident or the Postgres docs. Never cite anything a reader can't open. -- Both fixtures are required. `fires.sql` is the positive control, so a check without one isn't done. A fixture may lower a threshold (`-- threshold name = value`) when the real condition can't be reproduced at scale. +- Both fixtures are required. `fires.sql` is the positive control, so a check without one isn't done. A fixture may lower a threshold (`-- threshold name = value`) when the real condition can't be reproduced at scale, start Postgres with a setting that needs a restart (`-- server name = value`), and let its next statement fail (`-- expect error`). - Check ids are kebab-case and stable, because baselines and ignore lists depend on them. Renaming one is a breaking change that needs a decision in `ROADMAP.md`. - Severity: `critical` can take the database down or lose data soon. `warning` is heading there, or removes a safety net. `info` is housekeeping. Don't inflate severity. -- A check declares its minimum Postgres version and the providers where it is skipped. Every check is tested on every supported version. +- A check declares its minimum Postgres version, the privileges it needs and the providers where it is skipped. Every check is tested on every supported version, and its `fires` fixture is tested as a role with exactly its declared privileges. ## .NET and NativeAOT diff --git a/ROADMAP.md b/ROADMAP.md index 4f70603..370ecec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,7 +7,7 @@ pgcheckup is a read-only CLI (.NET 10, NativeAOT) that checks a PostgreSQL datab - **Name.** The idea started as "Postgres Doctor". `pgdoctor` is already an existing Go project, so this one is pgcheckup. PostgresAI's `postgres-checkup` is unrelated, and the README says so. - **Position.** Production readiness for teams without a DBA. It has fewer checks than pg-healthcheck or pgdoctor, and each one maps to a failure that causes outages and is explained in plain words with its fix. It is not a DBA toolkit. - **Read-only by construction.** Every statement pgcheckup sends runs in its own transaction: `BEGIN READ ONLY`, `SET LOCAL statement_timeout = '5s'`, `SET LOCAL lock_timeout = '1s'`, `SET LOCAL search_path = pg_catalog, pg_temp`, the query, then `ROLLBACK`. The pinned search path stops a function planted in another schema from shadowing a built-in and running as the scanning role. Only `application_name = pgcheckup` is set for the whole session. Behind a transaction-mode pooler such as PgBouncer, a session-level `SET` would reach the app's next transaction, and the `options` connection parameter is rejected or dropped. The lock timeout stops a scan from queueing behind a migration's lock and then blocking the traffic behind it. pgcheckup prints fixes and never runs them. -- **Least privilege.** No check needs more than `pg_monitor`. Each check declares what it needs, and if the role doesn't have it, the check is skipped with the reason. `pgcheckup grant` prints the SQL for a checkup role, including `ALTER ROLE … SET default_transaction_read_only = on`, so the role is read-only outside pgcheckup too. +- **Least privilege.** No check needs more than `pg_monitor`, with one exception decided on 2026-09-26: `integer-exhaustion` (below). Each check declares what it needs, and if the role doesn't have it, the check is skipped with the reason. `pgcheckup grant` prints the SQL for a checkup role, including `ALTER ROLE … SET default_transaction_read_only = on`, so the role is read-only outside pgcheckup too. - **SQL only.** pgcheckup talks only to Postgres. Provider settings that SQL can see (such as `rds.force_ssl`) are in scope. Checks that need a cloud API (RDS backups, deletion protection, encryption at rest) are not. - **Managed providers.** pgcheckup detects RDS/Aurora, Cloud SQL, Azure Database for PostgreSQL, Supabase and Neon from SQL. A check can list providers where it doesn't apply or can't run, and it shows as skipped there, with the reason. - **Checks are SQL plus Markdown**, one folder per check under `checks//`: @@ -21,7 +21,7 @@ pgcheckup is a read-only CLI (.NET 10, NativeAOT) that checks a PostgreSQL datab - **Postgres versions:** every community-supported major (14 to 18 today), each tested in CI with Testcontainers. When a major reaches end of life, it moves to best effort, and scanning it reports the end of life as a finding. - **Fixture tests** give each fixture a fresh Testcontainers Postgres, because slots, prepared transactions and roles belong to the whole server. The fixture's connection stays open until the check has run, so a fixture can hold a transaction open. The check runs through the same code as `scan`, as a role with only `pg_monitor`. - **Output:** terminal (default), `--format json` and `--format markdown` in v0.1. The JSON has a `schema` version. The HTML report comes in v0.2. -- **Exit codes:** 0 when no finding reaches `--fail-on` (default `critical`), 1 when one does, and 2 when the scan couldn't run. Skipped checks never fail a scan. +- **Exit codes:** 1 when a finding reaches `--fail-on` (default `critical`). Otherwise 2 when the scan couldn't run or any check errored (errors added 2026-09-26), and 0 when neither happened. The report always prints in full. A finding outranks an error because it is the more useful signal, and an error still fails CI. Skipped checks never fail a scan. - **Severity:** `critical` can take the database down or lose data soon. `warning` is heading there, or removes a safety net. `info` is housekeeping. - **Connection:** a `postgres://` URL, a key-value connection string, or the standard `PG*` environment variables and `.pgpass`. Docs keep passwords off the command line. - **What output may contain.** Findings name database objects (tables, slots, roles), settings, process IDs and durations. They never include query text, row data, passwords or client addresses. @@ -32,6 +32,16 @@ pgcheckup is a read-only CLI (.NET 10, NativeAOT) that checks a PostgreSQL datab - **Brand** is option 1A, "Scan": stacked layers read by a single probe line. The wordmark is Bricolage Grotesque SemiBold (optical size 34), converted to vector paths, with "pg" in Postgres blue (`#336791`, or `#5B9BD5` on dark). The assets are in `docs/brand/`, with `-dark` files for dark backgrounds. - **License:** Apache-2.0. +## Decisions (2026-09-26) + +- **`integer-exhaustion` reads sequence counters,** which `pg_monitor` can't see: `pg_sequences.last_value` is NULL and `pg_sequence_last_value()` is denied. The check needs SELECT on sequences, which shows counters but no table rows. It is the one exception to the `pg_monitor` ceiling. Without the grant it is skipped with the reason, and `pgcheckup grant` prints the extra lines. +- **Provider detection** reads roles and settings: `rds_superuser` for RDS, the `aurora_version()` function for Aurora, `cloudsqlsuperuser` for Cloud SQL, `azure_pg_admin` for Azure, `supabase_admin` for Supabase, and `neon.*` settings for Neon. Fixtures create them to test each one. +- **Declared privileges are tested.** Each check's `fires` fixture must still fire when run as a role with exactly the privileges the check declares. Without `pg_read_all_stats`, for example, `pg_stat_activity` hides other users' sessions, so a check could pass while seeing nothing. +- **Fixture directives.** `-- server name = value` starts the fixture's container with that setting, for settings that need a restart (`max_prepared_transactions`, `archive_mode`). `-- expect error` lets the next statement fail, such as a `CREATE INDEX CONCURRENTLY` that leaves an invalid index. +- **`postgres-eol` is a SQL check.** The end-of-life dates ship in a `VALUES` list in its `check.sql` and are compared with the server's clock, so no client clock is read. +- **Quiet on stock Postgres.** Stock Postgres ships with `max_slot_wal_keep_size = -1` and no `idle_in_transaction_session_timeout`, and flagging every database for them would teach people to ignore pgcheckup. `replication-slot-unbounded` fires only when a slot exists. An unset timeout is `info`, and sessions idle in a transaction are `warning`. +- **JSON `schema: 1`** holds the server, a summary, and each check's status (passed, critical, warning, info, skipped or errored) with its reason and findings. A finding has a subject, severity, message, fix and values. The values are the columns its message uses, in raw form: bytes as integers, durations in seconds, and timestamps in ISO 8601 UTC. `docs/json.md` documents the shape. + ## M0: Placeholder (as soon as possible) - [x] Add `LICENSE` (Apache-2.0). @@ -71,7 +81,7 @@ pgcheckup is a read-only CLI (.NET 10, NativeAOT) that checks a PostgreSQL datab | `postgres-eol` | Major versions past their end-of-life date | - [ ] `pgcheckup explain ` prints the check's note. `pgcheckup list` shows every check with its category and minimum version. -- [ ] `pgcheckup grant` prints SQL for a least-privilege checkup role: `pg_monitor`, `CONNECT`, and `default_transaction_read_only = on` for the role. +- [ ] `pgcheckup grant` prints SQL for a least-privilege checkup role: `pg_monitor`, `CONNECT`, `default_transaction_read_only = on` for the role, and SELECT on sequences for `integer-exhaustion`. - [ ] `--format json` and `--format markdown`. The JSON shape is documented, with `"schema": 1`. **Done when:** every check's fixtures pass on Postgres 14 to 18, and a scan as a role with only `pg_monitor` either runs or skips (with a reason) every check, with no errors. From 087644bcd6d26b5004609f61c61c97ac7ebf07e2 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 18:38:00 -0500 Subject: [PATCH 02/41] feat(engine): read the server's version, provider and privileges --- src/Pgcheckup/Engine/ServerContext.cs | 85 +++++++++++++++++ .../Engine/ServerContextTests.cs | 95 +++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 src/Pgcheckup/Engine/ServerContext.cs create mode 100644 tests/Pgcheckup.Tests/Engine/ServerContextTests.cs diff --git a/src/Pgcheckup/Engine/ServerContext.cs b/src/Pgcheckup/Engine/ServerContext.cs new file mode 100644 index 0000000..a1ac717 --- /dev/null +++ b/src/Pgcheckup/Engine/ServerContext.cs @@ -0,0 +1,85 @@ +using System.Globalization; + +namespace Pgcheckup.Engine; + +/// A managed Postgres service, detected from SQL. +/// The id that check.md lists under skip_on, such as rds. +/// The name the report prints, such as "Amazon RDS". +public sealed record Provider(string Id, string Name); + +/// What pgcheckup knows about the server before any check runs. +/// The database scanned. +/// The host as given, never the full connection string. +/// The server's server_version_num, such as 170006. +/// The managed service, or when none was detected. +/// +/// The privileges a check can declare that this role has: the predefined roles it is a member of, +/// and select_on_sequences when it can read every sequence's counter. +/// +public sealed record ServerContext( + string Database, + string Host, + int VersionNumber, + Provider? Provider, + IReadOnlySet Privileges) +{ + // Aurora first: it also has rds_superuser. + private static readonly (string Column, Provider Provider)[] Providers = + [ + ("aurora", new("aurora", "Amazon Aurora")), + ("rds", new("rds", "Amazon RDS")), + ("cloudsql", new("cloudsql", "Google Cloud SQL")), + ("azure", new("azure", "Azure Database for PostgreSQL")), + ("supabase", new("supabase", "Supabase")), + ("neon", new("neon", "Neon")), + ]; + + private static readonly string[] PrivilegeColumns = + ["pg_monitor", "pg_read_all_settings", "pg_read_all_stats", "pg_stat_scan_tables", "select_on_sequences"]; + + // One statement, so it runs in one guarded transaction. Provider signals are roles, a function + // or settings that each service creates; a self-managed server has none of them. + private const string Sql = """ + SELECT current_database() AS database, + current_setting('server_version_num')::int AS version, + pg_has_role(current_user, 'pg_monitor', 'USAGE') AS pg_monitor, + pg_has_role(current_user, 'pg_read_all_settings', 'USAGE') AS pg_read_all_settings, + pg_has_role(current_user, 'pg_read_all_stats', 'USAGE') AS pg_read_all_stats, + pg_has_role(current_user, 'pg_stat_scan_tables', 'USAGE') AS pg_stat_scan_tables, + -- Starting from pg_sequence, because Postgres may test the privilege before a + -- relkind filter and fail on a relation that isn't a sequence. + NOT EXISTS ( + SELECT FROM pg_sequence AS s JOIN pg_class AS c ON c.oid = s.seqrelid + WHERE c.relpersistence <> 't' AND NOT has_sequence_privilege(s.seqrelid, 'SELECT,USAGE') + ) AS select_on_sequences, + EXISTS (SELECT FROM pg_proc WHERE proname = 'aurora_version') AS aurora, + EXISTS (SELECT FROM pg_roles WHERE rolname = 'rds_superuser') AS rds, + EXISTS (SELECT FROM pg_roles WHERE rolname = 'cloudsqlsuperuser') AS cloudsql, + EXISTS (SELECT FROM pg_roles WHERE rolname = 'azure_pg_admin') AS azure, + EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_admin') AS supabase, + current_setting('neon.tenant_id', true) IS NOT NULL AS neon + """; + + /// The major version, such as 17. + public int Major => VersionNumber / 10000; + + /// The version as people write it, such as "17.6". + public string Version => string.Create(CultureInfo.InvariantCulture, $"{Major}.{VersionNumber % 10000}"); + + /// Reads the server's version, provider and the role's privileges in one query. + /// The guarded session to query through. + /// The host to name in the report. + /// Cancels the query. + /// The context every check's applicability is decided from. + /// The query failed. + public static async Task ReadAsync(ReadOnlySession session, string host, CancellationToken cancellationToken) + { + var row = (await session.QueryAsync(Sql, [], cancellationToken)).Single(); + return new ServerContext( + (string)row["database"]!, + host, + (int)row["version"]!, + Providers.FirstOrDefault(p => (bool)row[p.Column]!).Provider, + PrivilegeColumns.Where(p => (bool)row[p]!).ToHashSet(StringComparer.Ordinal)); + } +} diff --git a/tests/Pgcheckup.Tests/Engine/ServerContextTests.cs b/tests/Pgcheckup.Tests/Engine/ServerContextTests.cs new file mode 100644 index 0000000..72cb371 --- /dev/null +++ b/tests/Pgcheckup.Tests/Engine/ServerContextTests.cs @@ -0,0 +1,95 @@ +using Pgcheckup.Engine; +using Pgcheckup.Tests.Postgres; + +namespace Pgcheckup.Tests.Engine; + +public class ServerContextTests(PostgresServerFixture postgres) : IClassFixture +{ + private static CancellationToken Cancel => TestContext.Current.CancellationToken; + + private async Task ReadAsync(Npgsql.NpgsqlConnectionStringBuilder? role = null) + { + await using var session = await ReadOnlySession.OpenAsync(role ?? postgres.Server.Checkup, Cancel); + return await ServerContext.ReadAsync(session, "db.example.com", Cancel); + } + + // Roles are cluster-wide, so each case removes what it created. + public static TheoryData Providers() => new() + { + { [], [], null }, + { ["CREATE ROLE rds_superuser"], ["DROP ROLE rds_superuser"], "rds" }, + { + ["CREATE ROLE rds_superuser", "CREATE FUNCTION public.aurora_version() RETURNS text LANGUAGE sql AS $$ SELECT '16.4.0' $$"], + ["DROP FUNCTION public.aurora_version()", "DROP ROLE rds_superuser"], + "aurora" + }, + { ["CREATE ROLE cloudsqlsuperuser"], ["DROP ROLE cloudsqlsuperuser"], "cloudsql" }, + { ["CREATE ROLE azure_pg_admin"], ["DROP ROLE azure_pg_admin"], "azure" }, + { ["CREATE ROLE supabase_admin"], ["DROP ROLE supabase_admin"], "supabase" }, + { ["ALTER DATABASE app SET neon.tenant_id = 'placeholder'"], ["ALTER DATABASE app RESET neon.tenant_id"], "neon" }, + }; + + [Theory] + [MemberData(nameof(Providers))] + public async Task Detects_the_managed_provider_from_its_roles_and_settings(string[] setup, string[] teardown, string? expected) + { + await postgres.Server.ExecuteAsSuperuserAsync(Cancel, setup); + try + { + Assert.Equal(expected, (await ReadAsync()).Provider?.Id); + } + finally + { + await postgres.Server.ExecuteAsSuperuserAsync(Cancel, teardown); + } + } + + [Fact] + public async Task Reads_the_database_and_version() + { + var context = await ReadAsync(); + + Assert.Equal("app", context.Database); + Assert.Equal("db.example.com", context.Host); + Assert.Equal(int.Parse(PostgresServer.Version), context.Major); + Assert.StartsWith($"{PostgresServer.Version}.", context.Version); + } + + [Fact] + public async Task Knows_the_privileges_that_pg_monitor_brings() + { + var context = await ReadAsync(); + + Assert.Superset( + new HashSet { "pg_monitor", "pg_read_all_settings", "pg_read_all_stats", "pg_stat_scan_tables" }, + new HashSet(context.Privileges)); + } + + [Fact] + public async Task Knows_a_plain_role_has_none_of_them() + { + await postgres.Server.ExecuteAsSuperuserAsync(Cancel, "CREATE ROLE plain LOGIN PASSWORD 'plain'"); + var plain = postgres.Server.Checkup; + plain.Username = "plain"; + plain.Password = "plain"; + + Assert.DoesNotContain((await ReadAsync(plain)).Privileges, p => p.StartsWith("pg_", StringComparison.Ordinal)); + } + + [Fact] + public async Task Can_read_sequence_counters_only_once_granted() + { + await postgres.Server.ExecuteAsSuperuserAsync(Cancel, "CREATE SEQUENCE public.counter"); + try + { + Assert.DoesNotContain("select_on_sequences", (await ReadAsync()).Privileges); + + await postgres.Server.ExecuteAsSuperuserAsync(Cancel, "GRANT SELECT ON SEQUENCE public.counter TO checkup"); + Assert.Contains("select_on_sequences", (await ReadAsync()).Privileges); + } + finally + { + await postgres.Server.ExecuteAsSuperuserAsync(Cancel, "DROP SEQUENCE public.counter"); + } + } +} From 0e01b8a32d7208fa01cf9ec6f6664a16dfceb038 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 18:38:10 -0500 Subject: [PATCH 03/41] feat(engine): skip checks that can't run and report errored ones Errored checks show in the report, and a scan exits 2 when one errored and no finding reached --fail-on. --- .../CheckCompiler.cs | 7 +- src/Pgcheckup/Cli/PgcheckupCli.cs | 12 ++- src/Pgcheckup/Cli/TerminalReport.cs | 61 ++++++++---- src/Pgcheckup/Engine/Applicability.cs | 38 ++++++++ src/Pgcheckup/Engine/Scanner.cs | 97 ++++++++++--------- tests/Pgcheckup.Tests/Cli/CommandLineTests.cs | 52 ++++++---- .../Cli/TerminalReportTests.cs | 96 +++++++++++++----- .../Engine/ApplicabilityTests.cs | 58 +++++++++++ tests/Pgcheckup.Tests/Engine/ScannerTests.cs | 80 +++++++++++++++ 9 files changed, 390 insertions(+), 111 deletions(-) create mode 100644 src/Pgcheckup/Engine/Applicability.cs create mode 100644 tests/Pgcheckup.Tests/Engine/ApplicabilityTests.cs create mode 100644 tests/Pgcheckup.Tests/Engine/ScannerTests.cs diff --git a/src/Pgcheckup.Checks.Generator/CheckCompiler.cs b/src/Pgcheckup.Checks.Generator/CheckCompiler.cs index 0d96d88..7dfcb4d 100644 --- a/src/Pgcheckup.Checks.Generator/CheckCompiler.cs +++ b/src/Pgcheckup.Checks.Generator/CheckCompiler.cs @@ -129,8 +129,11 @@ public static class CheckCompiler /// The allowed values of severity, from most to least severe. public static readonly string[] Severities = ["critical", "warning", "info"]; - /// The predefined roles a check may list under privileges. All are part of pg_monitor. - public static readonly string[] Privileges = ["pg_monitor", "pg_read_all_settings", "pg_read_all_stats", "pg_stat_scan_tables"]; + /// + /// What a check may list under privileges: the predefined roles in pg_monitor, and + /// select_on_sequences for reading sequence counters. + /// + public static readonly string[] Privileges = ["pg_monitor", "pg_read_all_settings", "pg_read_all_stats", "pg_stat_scan_tables", "select_on_sequences"]; /// The managed providers a check may list under skip_on. public static readonly string[] Providers = ["rds", "aurora", "cloudsql", "azure", "supabase", "neon"]; diff --git a/src/Pgcheckup/Cli/PgcheckupCli.cs b/src/Pgcheckup/Cli/PgcheckupCli.cs index 07ac8c3..ac7989e 100644 --- a/src/Pgcheckup/Cli/PgcheckupCli.cs +++ b/src/Pgcheckup/Cli/PgcheckupCli.cs @@ -159,9 +159,9 @@ private static async Task ScanAsync( { report = await Scanner.ScanAsync(session, host, checks, cancellationToken); } - catch (Exception problem) when (problem is CheckFailedException or NpgsqlException) + catch (NpgsqlException problem) { - error.WriteLine($"pgcheckup: {problem.Message}"); + error.WriteLine($"pgcheckup: couldn't read the server's version and privileges: {problem.Message}"); return CouldNotRun; } @@ -173,7 +173,13 @@ private static async Task ScanAsync( "warning" => Severity.Warning, _ => Severity.Critical, }; - return report.Results.Any(r => r.Worst >= threshold) ? FindingsReachedFailOn : Passed; + // A finding outranks an error: it is the more useful signal, and an error still fails CI. + if (report.Results.Any(r => r.Worst >= threshold)) + { + return FindingsReachedFailOn; + } + + return report.Results.Any(r => r.Status == CheckStatus.Errored) ? CouldNotRun : Passed; } } } diff --git a/src/Pgcheckup/Cli/TerminalReport.cs b/src/Pgcheckup/Cli/TerminalReport.cs index fb6ad01..edf191e 100644 --- a/src/Pgcheckup/Cli/TerminalReport.cs +++ b/src/Pgcheckup/Cli/TerminalReport.cs @@ -22,17 +22,18 @@ public static bool UseColor(bool outputRedirected, IReadOnlyDictionary - /// Writes the header, then each finding with its fix, then a summary that counts each check - /// once at its worst severity. + /// Writes the header, then each finding with its fix, then each errored check, then a + /// summary that counts each check once at its worst severity and names skipped checks. /// /// Where to write. /// What the scan found. - /// Whether to color the severity words. The words are always there. + /// Whether to color the status words. The words are always there. /// Findings are ordered by severity, most severe first, then by check id. public static void Write(TextWriter output, ScanReport report, bool color) { var server = report.Server; - output.WriteLine($"pgcheckup · {server.Database} on {server.Host} · PostgreSQL {server.Version}"); + var provider = server.Provider is { } managed ? $" · {managed.Name}" : ""; + output.WriteLine($"pgcheckup · {server.Database} on {server.Host} · PostgreSQL {server.Version}{provider}"); output.WriteLine(); var findings = report.Results @@ -45,16 +46,31 @@ public static void Write(TextWriter output, ScanReport report, bool color) foreach (var finding in findings) { - var label = finding.Severity.ToString().ToUpperInvariant(); - output.WriteLine($"{Paint(label, finding.Severity, color)}{new string(' ', Indent - label.Length)}{finding.CheckId}"); + WriteLabel(output, finding.Severity.ToString().ToUpperInvariant(), SeverityColor(finding.Severity), finding.CheckId, color); WriteIndented(output, finding.Message, new string(' ', Indent), new string(' ', Indent)); WriteIndented(output, finding.Fix, new string(' ', Indent) + FixLabel, new string(' ', Indent + FixLabel.Length)); output.WriteLine(); } + foreach (var errored in report.Results.Where(r => r.Status == CheckStatus.Errored).OrderBy(r => r.Check.Id, StringComparer.Ordinal)) + { + WriteLabel(output, "ERRORED", ErroredColor, errored.Check.Id, color); + WriteIndented(output, Sentence(errored.Reason ?? "it failed"), new string(' ', Indent), new string(' ', Indent)); + output.WriteLine(); + } + output.WriteLine(Summary(report)); } + private static void WriteLabel(TextWriter output, string label, string colorCode, string checkId, bool color) + { + var painted = color ? $"\u001b[{colorCode}m{label}\u001b[0m" : label; + output.WriteLine($"{painted}{new string(' ', Indent - label.Length)}{checkId}"); + } + + private static string Sentence(string reason) => + char.ToUpperInvariant(reason[0]) + reason[1..] + (reason.EndsWith('.') ? "" : "."); + private static void WriteIndented(TextWriter output, string text, string first, string rest) { var lines = text.Split('\n'); @@ -66,7 +82,7 @@ private static void WriteIndented(TextWriter output, string text, string first, private static string Summary(ScanReport report) { - var parts = new List { $"{report.Results.Count(r => r.Worst == null)} passed" }; + var parts = new List { $"{report.Results.Count(r => r.Status == CheckStatus.Passed)} passed" }; var critical = report.Results.Count(r => r.Worst == Severity.Critical); var warning = report.Results.Count(r => r.Worst == Severity.Warning); var info = report.Results.Count(r => r.Worst == Severity.Info); @@ -85,22 +101,27 @@ private static string Summary(ScanReport report) parts.Add($"{info} info"); } - return string.Join(" · ", parts); - } - - private static string Paint(string label, Severity severity, bool color) - { - if (!color) + var errored = report.Results.Count(r => r.Status == CheckStatus.Errored); + if (errored > 0) { - return label; + parts.Add($"{errored} errored"); } - var code = severity switch + var skipped = report.Results.Where(r => r.Status == CheckStatus.Skipped).ToList(); + if (skipped.Count > 0) { - Severity.Critical => "1;31", - Severity.Warning => "1;33", - _ => "1;34", - }; - return $"\u001b[{code}m{label}\u001b[0m"; + parts.Add($"{skipped.Count} skipped ({string.Join(", ", skipped.Select(r => $"{r.Check.Id}: {r.Reason}"))})"); + } + + return string.Join(" · ", parts); } + + private const string ErroredColor = "1;35"; + + private static string SeverityColor(Severity severity) => severity switch + { + Severity.Critical => "1;31", + Severity.Warning => "1;33", + _ => "1;34", + }; } diff --git a/src/Pgcheckup/Engine/Applicability.cs b/src/Pgcheckup/Engine/Applicability.cs new file mode 100644 index 0000000..e4bb057 --- /dev/null +++ b/src/Pgcheckup/Engine/Applicability.cs @@ -0,0 +1,38 @@ +using Pgcheckup.Checks; + +namespace Pgcheckup.Engine; + +/// Decides whether a check can run on a server, before it runs. +public static class Applicability +{ + /// The privilege a check declares when it reads sequence counters. + public const string SelectOnSequences = "select_on_sequences"; + + /// Says why a check can't run here, if it can't. + /// The check. + /// The server and role. + /// + /// when the check can run. Otherwise one reason, checked in this order: + /// the Postgres version, the managed provider, then the role's privileges. + /// + public static string? SkipReason(CheckDefinition check, ServerContext context) + { + if (context.Major < check.MinVersion) + { + return $"needs Postgres {check.MinVersion} or later"; + } + + if (context.Provider is { } provider && check.SkipOn.Contains(provider.Id)) + { + return $"managed by {provider.Name}"; + } + + var missing = check.Privileges.Where(p => !context.Privileges.Contains(p)).ToList(); + if (missing.Contains(SelectOnSequences)) + { + return "can't read sequence counters; see pgcheckup grant"; + } + + return missing.Count > 0 ? $"needs {string.Join(" and ", missing)}" : null; + } +} diff --git a/src/Pgcheckup/Engine/Scanner.cs b/src/Pgcheckup/Engine/Scanner.cs index 9df65b0..75caf7a 100644 --- a/src/Pgcheckup/Engine/Scanner.cs +++ b/src/Pgcheckup/Engine/Scanner.cs @@ -1,77 +1,86 @@ -using System.Globalization; +using Npgsql; using Pgcheckup.Checks; namespace Pgcheckup.Engine; -/// What the report's header says about the server. -/// The database scanned. -/// The host as given, never the full connection string. -/// The Postgres version, such as "17.6". -public sealed record ServerInfo(string Database, string Host, string Version); +/// How a check's run ended. +public enum CheckStatus +{ + /// It ran and found nothing. + Passed, + + /// It ran and found at least one problem. + Found, + + /// It didn't run here, for the reason given. A skip never fails a scan. + Skipped, + + /// It ran and failed, for the reason given. The rest of the scan still ran. + Errored, +} -/// One check's findings. -/// The check that ran. -/// What it found. Empty when it passed. -public sealed record CheckResult(CheckDefinition Check, IReadOnlyList Findings) +/// One check's outcome. +/// The check. +/// How its run ended. +/// What it found. Empty unless is . +/// Why it was skipped or errored, or when it ran. +public sealed record CheckResult(CheckDefinition Check, CheckStatus Status, IReadOnlyList Findings, string? Reason = null) { - /// The most severe finding's severity, or when the check passed. + /// The most severe finding's severity, or when there are no findings. public Severity? Worst => Findings.Count == 0 ? null : Findings.Max(f => f.Severity); } /// Everything a scan found. -/// The server scanned. -/// Each check's findings, in the order the checks ran. -public sealed record ScanReport(ServerInfo Server, IReadOnlyList Results); - -/// A check that couldn't run. In M0 this ends the scan; M1 reports it as errored and goes on. -/// The check that failed. -/// What went wrong. -public sealed class CheckFailedException(string checkId, Exception inner) - : Exception($"{checkId} couldn't run: {inner.Message}", inner) -{ - /// The check that failed. - public string CheckId { get; } = checkId; -} +/// The server scanned, and the role's privileges. +/// Each check's outcome, in the order the checks were given. +public sealed record ScanReport(ServerContext Server, IReadOnlyList Results); /// Runs checks against one database. public static class Scanner { - /// Reads the server's version and database, then runs each check in turn. + /// + /// Reads the server context, then runs every check that applies. A check that fails is + /// reported as errored, and the others still run. + /// /// The guarded session to query through. /// The host to name in the report. - /// The checks to run, in order. + /// The checks, in the order to run and report them. /// Cancels the scan. - /// The server and each check's findings. - /// A check failed for any reason. The scan stops there. - /// The server's version or database couldn't be read. + /// The server context and every check's outcome. + /// The server context couldn't be read, so no check ran. + /// The scan was cancelled. public static async Task ScanAsync( ReadOnlySession session, string host, IReadOnlyList checks, CancellationToken cancellationToken) { - var row = (await session.QueryAsync( - "SELECT current_database() AS database, current_setting('server_version_num')::int AS version", - [], - cancellationToken)).Single(); - - // server_version_num is major * 10000 + minor from Postgres 10 on. - var version = (int)row["version"]!; - var server = new ServerInfo( - (string)row["database"]!, - host, - string.Create(CultureInfo.InvariantCulture, $"{version / 10000}.{version % 10000}")); - + var context = await ServerContext.ReadAsync(session, host, cancellationToken); var results = new List(); foreach (var check in checks) { + if (Applicability.SkipReason(check, context) is { } reason) + { + results.Add(new CheckResult(check, CheckStatus.Skipped, [], reason)); + continue; + } + try { - results.Add(new CheckResult(check, await CheckRunner.RunAsync(session, check, cancellationToken))); + var findings = await CheckRunner.RunAsync(session, check, cancellationToken); + results.Add(new CheckResult(check, findings.Count == 0 ? CheckStatus.Passed : CheckStatus.Found, findings)); } catch (Exception error) when (error is not OperationCanceledException) { - throw new CheckFailedException(check.Id, error); + results.Add(new CheckResult(check, CheckStatus.Errored, [], Describe(error))); } } - return new ScanReport(server, results); + return new ScanReport(context, results); } + + private static string Describe(Exception error) => error switch + { + PostgresException { SqlState: PostgresErrorCodes.QueryCanceled } => "timed out after 5 s", + PostgresException { SqlState: PostgresErrorCodes.LockNotAvailable } => "waited over 1 s for a lock", + PostgresException postgres => $"{postgres.SqlState}: {postgres.MessageText}", + _ => error.Message, + }; } diff --git a/tests/Pgcheckup.Tests/Cli/CommandLineTests.cs b/tests/Pgcheckup.Tests/Cli/CommandLineTests.cs index 293b285..8d66072 100644 --- a/tests/Pgcheckup.Tests/Cli/CommandLineTests.cs +++ b/tests/Pgcheckup.Tests/Cli/CommandLineTests.cs @@ -60,11 +60,32 @@ public async Task Exits_2_when_it_cannot_connect() public class ScanCommandTests(InactiveSlotFixture postgres) : IClassFixture { + private static readonly CheckDefinition SlotCheck = CheckCatalog.All.Single(c => c.Id == "replication-slot-inactive"); + + // Npgsql can't read NaN into a decimal, so this check errors on every run. + private static readonly CheckDefinition BrokenCheck = new( + "nan-check", "NaN check", "wal", Severity.Critical, 14, [], [], [], + "SELECT 'a' AS subject, 'NaN'::numeric AS size", + new Template([new ValuePart("size", ValueFormat.Bytes)]), + new Template([new TextPart("nothing")]), + ""); + + // Pinning the checks keeps the summary stable as the catalog grows. + private async Task<(int ExitCode, string Output, string Error)> ScanAsync(CheckDefinition[] checks, params string[] options) + { + var output = new StringWriter { NewLine = "\n" }; + var error = new StringWriter { NewLine = "\n" }; + var exitCode = await PgcheckupCli.RunAsync( + ["scan", await postgres.CheckupUrlAsync(), .. options], checks, output, error, + new Dictionary(), outputRedirected: true, TestContext.Current.CancellationToken); + return (exitCode, output.ToString(), error.ToString()); + } + [Fact] public async Task Reports_a_warning_and_exits_0_below_the_default_fail_on() { var server = await postgres.ServerAsync(); - var (exitCode, output, error) = await CommandLineTests.RunAsync("scan", await postgres.CheckupUrlAsync()); + var (exitCode, output, error) = await ScanAsync([SlotCheck]); Assert.Equal("", error); Assert.Equal(0, exitCode); @@ -76,30 +97,25 @@ public async Task Reports_a_warning_and_exits_0_below_the_default_fail_on() } [Fact] - public async Task Exits_2_when_a_check_fails_in_a_way_nobody_planned_for() + public async Task Exits_1_when_a_finding_reaches_fail_on() { - // Npgsql can't read NaN into a decimal, so the runner throws something no catch expects. - var check = new CheckDefinition( - "nan-check", "NaN check", "wal", Severity.Critical, 14, [], [], [], - "SELECT 'a' AS subject, 'NaN'::numeric AS size", - new Template([new ValuePart("size", ValueFormat.Bytes)]), - new Template([new TextPart("nothing")]), - ""); - var output = new StringWriter(); - var error = new StringWriter(); + Assert.Equal(1, (await ScanAsync([SlotCheck], "--fail-on", "warning")).ExitCode); + } - var exitCode = await PgcheckupCli.RunAsync( - ["scan", await postgres.CheckupUrlAsync()], [check], output, error, new Dictionary(), outputRedirected: true, TestContext.Current.CancellationToken); + [Fact] + public async Task Exits_2_when_a_check_errored_and_no_finding_reached_fail_on() + { + var (exitCode, output, error) = await ScanAsync([BrokenCheck, SlotCheck]); Assert.Equal(2, exitCode); - Assert.StartsWith("pgcheckup: ", error.ToString()); + Assert.Equal("", error); + Assert.Contains("ERRORED nan-check", output); + Assert.Contains("WARNING replication-slot-inactive", output); } [Fact] - public async Task Exits_1_when_a_finding_reaches_fail_on() + public async Task Exits_1_when_a_finding_reaches_fail_on_even_if_a_check_errored() { - var (exitCode, _, _) = await CommandLineTests.RunAsync("scan", await postgres.CheckupUrlAsync(), "--fail-on", "warning"); - - Assert.Equal(1, exitCode); + Assert.Equal(1, (await ScanAsync([BrokenCheck, SlotCheck], "--fail-on", "warning")).ExitCode); } } diff --git a/tests/Pgcheckup.Tests/Cli/TerminalReportTests.cs b/tests/Pgcheckup.Tests/Cli/TerminalReportTests.cs index 590ce30..4ca4a8c 100644 --- a/tests/Pgcheckup.Tests/Cli/TerminalReportTests.cs +++ b/tests/Pgcheckup.Tests/Cli/TerminalReportTests.cs @@ -6,7 +6,7 @@ namespace Pgcheckup.Tests.Cli; public class TerminalReportTests { - private static readonly ServerInfo Server = new("app", "db.example.com", "17.6"); + private static readonly ServerContext Server = new("app", "db.example.com", 170006, null, new HashSet()); private static CheckDefinition Check(string id, Severity severity = Severity.Warning) => new( id, id, "wal", severity, 14, [], [], [], "SELECT 1", new Template([]), new Template([]), ""); @@ -14,6 +14,10 @@ public class TerminalReportTests private static Finding Finding(string checkId, Severity severity, string message, string fix) => new(checkId, "subject", severity, message, fix, new Dictionary()); + private static CheckResult Passed(string id) => new(Check(id), CheckStatus.Passed, []); + + private static CheckResult Found(string id, params Finding[] findings) => new(Check(id), CheckStatus.Found, findings); + private static string Render(ScanReport report, bool color = false) { var output = new StringWriter { NewLine = "\n" }; @@ -24,16 +28,12 @@ private static string Render(ScanReport report, bool color = false) [Fact] public void Writes_each_finding_with_its_fix_under_a_header_and_above_a_summary() { - var slot = Check("replication-slot-inactive"); var report = new ScanReport(Server, [ - new CheckResult(Check("connection-saturation"), []), - new CheckResult(slot, - [ - Finding(slot.Id, Severity.Warning, - "Slot debezium has been inactive for 3 days and is holding 48 GB of WAL.", - "restart its consumer, or drop the slot:\nSELECT pg_drop_replication_slot('debezium');"), - ]), + Passed("connection-saturation"), + Found("replication-slot-inactive", Finding("replication-slot-inactive", Severity.Warning, + "Slot debezium has been inactive for 3 days and is holding 48 GB of WAL.", + "restart its consumer, or drop the slot:\nSELECT pg_drop_replication_slot('debezium');")), ]); Assert.Equal( @@ -51,34 +51,77 @@ 1 passed · 1 warning Render(report)); } + [Fact] + public void Names_a_managed_provider_in_the_header() + { + var report = new ScanReport(Server with { Provider = new Provider("rds", "Amazon RDS") }, []); + + Assert.StartsWith("pgcheckup · app on db.example.com · PostgreSQL 17.6 · Amazon RDS\n", Render(report)); + } + [Fact] public void Lists_critical_findings_first_and_counts_each_check_once_at_its_worst() { - var slot = Check("replication-slot-inactive"); - var xid = Check("xid-wraparound", Severity.Critical); var report = new ScanReport(Server, [ - new CheckResult(slot, - [ - Finding(slot.Id, Severity.Warning, "Slot a is inactive.", "drop a"), - Finding(slot.Id, Severity.Critical, "Slot b is inactive.", "drop b"), - ]), - new CheckResult(xid, [Finding(xid.Id, Severity.Critical, "Table orders is old.", "vacuum orders")]), - new CheckResult(Check("other-slot"), [Finding("other-slot", Severity.Warning, "Other.", "fix")]), + Found("replication-slot-inactive", + Finding("replication-slot-inactive", Severity.Warning, "Slot a is inactive.", "drop a"), + Finding("replication-slot-inactive", Severity.Critical, "Slot b is inactive.", "drop b")), + Found("xid-wraparound", Finding("xid-wraparound", Severity.Critical, "Table orders is old.", "vacuum orders")), + Found("other-slot", Finding("other-slot", Severity.Warning, "Other.", "fix")), ]); var lines = Render(report).Split('\n'); Assert.Equal( ["CRITICAL replication-slot-inactive", "CRITICAL xid-wraparound", "WARNING other-slot", "WARNING replication-slot-inactive"], - lines.Where(l => l.Length > 0 && !l.StartsWith(' ') && (l.StartsWith("CRITICAL") || l.StartsWith("WARNING")))); + lines.Where(l => l.StartsWith("CRITICAL", StringComparison.Ordinal) || l.StartsWith("WARNING", StringComparison.Ordinal))); Assert.Equal("0 passed · 2 critical · 1 warning", lines[^2]); } + [Fact] + public void Shows_an_errored_check_after_the_findings_with_its_reason() + { + var report = new ScanReport(Server, + [ + new CheckResult(Check("xid-wraparound"), CheckStatus.Errored, [], "timed out after 5 s"), + Found("replication-slot-inactive", Finding("replication-slot-inactive", Severity.Warning, "Slot a is inactive.", "drop a")), + ]); + + Assert.EndsWith( + """ + WARNING replication-slot-inactive + Slot a is inactive. + Fix: drop a + + ERRORED xid-wraparound + Timed out after 5 s. + + 0 passed · 1 warning · 1 errored + + """.ReplaceLineEndings("\n"), + Render(report)); + } + + [Fact] + public void Lists_skipped_checks_with_their_reasons_in_the_summary() + { + var report = new ScanReport(Server, + [ + Passed("dangerous-settings"), + new CheckResult(Check("wal-archiving-failing"), CheckStatus.Skipped, [], "managed by Amazon RDS"), + new CheckResult(Check("integer-exhaustion"), CheckStatus.Skipped, [], "can't read sequence counters; see pgcheckup grant"), + ]); + + Assert.EndsWith( + "\n1 passed · 2 skipped (wal-archiving-failing: managed by Amazon RDS, integer-exhaustion: can't read sequence counters; see pgcheckup grant)\n", + Render(report)); + } + [Fact] public void Says_how_many_passed_when_nothing_is_found() { - var report = new ScanReport(Server, [new CheckResult(Check("a"), []), new CheckResult(Check("b"), [])]); + var report = new ScanReport(Server, [Passed("a"), Passed("b")]); Assert.Equal("pgcheckup · app on db.example.com · PostgreSQL 17.6\n\n2 passed\n", Render(report)); } @@ -88,21 +131,26 @@ public void Pluralises_warnings() { var report = new ScanReport(Server, [ - new CheckResult(Check("a"), [Finding("a", Severity.Warning, "A.", "fix")]), - new CheckResult(Check("b"), [Finding("b", Severity.Warning, "B.", "fix")]), + Found("a", Finding("a", Severity.Warning, "A.", "fix")), + Found("b", Finding("b", Severity.Warning, "B.", "fix")), ]); Assert.EndsWith("0 passed · 2 warnings\n", Render(report)); } [Fact] - public void Colours_the_severity_word_only_when_asked() + public void Colours_the_status_word_only_when_asked() { - var report = new ScanReport(Server, [new CheckResult(Check("a"), [Finding("a", Severity.Warning, "A.", "fix")])]); + var report = new ScanReport(Server, + [ + Found("a", Finding("a", Severity.Warning, "A.", "fix")), + new CheckResult(Check("b"), CheckStatus.Errored, [], "timed out after 5 s"), + ]); Assert.DoesNotContain("\u001b", Render(report, color: false)); var coloured = Render(report, color: true); Assert.Contains("\u001b[1;33mWARNING\u001b[0m", coloured); + Assert.Contains("\u001b[1;35mERRORED\u001b[0m", coloured); } [Theory] diff --git a/tests/Pgcheckup.Tests/Engine/ApplicabilityTests.cs b/tests/Pgcheckup.Tests/Engine/ApplicabilityTests.cs new file mode 100644 index 0000000..7953c88 --- /dev/null +++ b/tests/Pgcheckup.Tests/Engine/ApplicabilityTests.cs @@ -0,0 +1,58 @@ +using Pgcheckup.Checks; +using Pgcheckup.Engine; + +namespace Pgcheckup.Tests.Engine; + +public class ApplicabilityTests +{ + private static readonly Provider Rds = new("rds", "Amazon RDS"); + + private static CheckDefinition Check(int minVersion = 14, string[]? privileges = null, string[]? skipOn = null) => new( + "sample-check", "Sample", "wal", Severity.Warning, minVersion, privileges ?? [], skipOn ?? [], [], "SELECT 1", + new Template([]), new Template([]), ""); + + private static ServerContext Server(int versionNumber = 170006, Provider? provider = null, params string[] privileges) => + new("app", "db.example.com", versionNumber, provider, privileges.ToHashSet()); + + [Fact] + public void Runs_a_check_the_server_and_role_can_run() + { + Assert.Null(Applicability.SkipReason(Check(privileges: ["pg_monitor"], skipOn: ["neon"]), Server(provider: Rds, privileges: "pg_monitor"))); + } + + [Fact] + public void Skips_a_check_that_needs_a_newer_postgres() + { + Assert.Equal("needs Postgres 17 or later", Applicability.SkipReason(Check(minVersion: 17), Server(versionNumber: 160004))); + } + + [Fact] + public void Skips_a_check_on_a_provider_that_manages_it() + { + Assert.Equal("managed by Amazon RDS", Applicability.SkipReason(Check(skipOn: ["rds"]), Server(provider: Rds))); + } + + [Fact] + public void Skips_a_check_the_role_lacks_privileges_for() + { + Assert.Equal( + "needs pg_read_all_stats and pg_stat_scan_tables", + Applicability.SkipReason(Check(privileges: ["pg_read_all_stats", "pg_stat_scan_tables"]), Server())); + } + + [Fact] + public void Points_to_grant_when_sequence_counters_are_unreadable() + { + Assert.Equal( + "can't read sequence counters; see pgcheckup grant", + Applicability.SkipReason(Check(privileges: ["select_on_sequences"]), Server())); + } + + [Fact] + public void Gives_the_version_reason_before_the_others() + { + Assert.Equal( + "needs Postgres 17 or later", + Applicability.SkipReason(Check(minVersion: 17, privileges: ["pg_monitor"], skipOn: ["rds"]), Server(versionNumber: 140012, provider: Rds))); + } +} diff --git a/tests/Pgcheckup.Tests/Engine/ScannerTests.cs b/tests/Pgcheckup.Tests/Engine/ScannerTests.cs new file mode 100644 index 0000000..d444f8d --- /dev/null +++ b/tests/Pgcheckup.Tests/Engine/ScannerTests.cs @@ -0,0 +1,80 @@ +using Pgcheckup.Checks; +using Pgcheckup.Engine; +using Pgcheckup.Tests.Postgres; + +namespace Pgcheckup.Tests.Engine; + +public class ScannerTests(PostgresServerFixture postgres) : IClassFixture +{ + private static CancellationToken Cancel => TestContext.Current.CancellationToken; + + private static CheckDefinition Check(string id, string sql, int minVersion = 14) => new( + id, id, "wal", Severity.Warning, minVersion, [], [], [], sql, + new Template([new ValuePart("subject", ValueFormat.Default)]), + new Template([new TextPart("fix it")]), + ""); + + private async Task ScanAsync(params CheckDefinition[] checks) + { + await using var session = await ReadOnlySession.OpenAsync(postgres.Server.Checkup, Cancel); + return await Scanner.ScanAsync(session, "db.example.com", checks, Cancel); + } + + [Fact] + public async Task Reports_a_failing_check_as_errored_and_runs_the_rest() + { + var report = await ScanAsync( + Check("broken-check", "SELECT (1 / 0)::text AS subject"), + Check("working-check", "SELECT 'orders' AS subject")); + + Assert.Collection( + report.Results, + broken => + { + Assert.Equal(CheckStatus.Errored, broken.Status); + Assert.Contains("division by zero", broken.Reason); + }, + working => + { + Assert.Equal(CheckStatus.Found, working.Status); + Assert.Equal("orders", Assert.Single(working.Findings).Subject); + }); + } + + [Fact] + public async Task Reports_a_check_that_runs_too_long_as_timed_out() + { + var result = Assert.Single((await ScanAsync(Check("slow-check", "SELECT 'a' AS subject FROM pg_sleep(6)"))).Results); + + Assert.Equal(CheckStatus.Errored, result.Status); + Assert.Equal("timed out after 5 s", result.Reason); + } + + [Fact] + public async Task Skips_a_check_that_does_not_apply_without_running_it() + { + // The query would fail if it ran. + var result = Assert.Single((await ScanAsync(Check("future-check", "SELECT (1 / 0)::text AS subject", minVersion: 99))).Results); + + Assert.Equal(CheckStatus.Skipped, result.Status); + Assert.Equal("needs Postgres 99 or later", result.Reason); + } + + [Fact] + public async Task Passes_a_check_that_finds_nothing() + { + var result = Assert.Single((await ScanAsync(Check("quiet-check", "SELECT 'a' AS subject WHERE false"))).Results); + + Assert.Equal(CheckStatus.Passed, result.Status); + Assert.Null(result.Reason); + } + + [Fact] + public async Task Carries_the_server_context() + { + var report = await ScanAsync(); + + Assert.Equal("app", report.Server.Database); + Assert.Equal(int.Parse(PostgresServer.Version), report.Server.Major); + } +} From 10268ddc405e59b1090348a1017590e9d5f2059a Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 18:41:22 -0500 Subject: [PATCH 04/41] test(checks): run fixtures on stock Postgres with directives and declared privileges Fixtures can now start Postgres with a setting (-- server) and expect a statement to fail (-- expect error). Each fires fixture also runs as a role with only the check's declared privileges. --- tests/Pgcheckup.Tests/Checks/FixtureTests.cs | 77 +++++++++++++++---- .../Pgcheckup.Tests/Postgres/FixtureScript.cs | 37 +++++++-- .../Postgres/FixtureScriptTests.cs | 48 ++++++++++++ .../Postgres/PostgresServer.cs | 14 +++- .../Postgres/PostgresServerTests.cs | 35 +++++++++ 5 files changed, 188 insertions(+), 23 deletions(-) create mode 100644 tests/Pgcheckup.Tests/Postgres/FixtureScriptTests.cs create mode 100644 tests/Pgcheckup.Tests/Postgres/PostgresServerTests.cs diff --git a/tests/Pgcheckup.Tests/Checks/FixtureTests.cs b/tests/Pgcheckup.Tests/Checks/FixtureTests.cs index afe4217..fd3bd40 100644 --- a/tests/Pgcheckup.Tests/Checks/FixtureTests.cs +++ b/tests/Pgcheckup.Tests/Checks/FixtureTests.cs @@ -5,8 +5,8 @@ namespace Pgcheckup.Tests.Checks; -// Every check runs against its own fixtures on a fresh Postgres, as the checkup role, through -// the same session and runner as a scan. `fires` must produce a finding and `healthy` must not. +// Every check runs against its own fixtures on a fresh Postgres, through the same session and +// runner as a scan. `fires` must produce a finding and `healthy` must not. public class FixtureTests { private static CancellationToken Cancel => TestContext.Current.CancellationToken; @@ -23,9 +23,46 @@ public static TheoryData Fixtures() return data; } + public static TheoryData Checks() => new(CheckCatalog.All.Select(c => c.Id)); + [Theory] [MemberData(nameof(Fixtures))] public async Task Fires_on_its_fires_fixture_and_stays_quiet_on_healthy(string checkId, string fixture) + { + await using var prepared = await PrepareAsync(checkId, fixture); + + var findings = await RunAsync(prepared, prepared.Server.Checkup); + + if (fixture == "fires") + { + Assert.NotEmpty(findings); + } + else + { + Assert.Empty(findings); + } + } + + // A check can pass while seeing nothing: without pg_read_all_stats, for example, + // pg_stat_activity hides other users' sessions. So the declared privileges must be enough. + [Theory] + [MemberData(nameof(Checks))] + public async Task Fires_as_a_role_with_only_its_declared_privileges(string checkId) + { + await using var prepared = await PrepareAsync(checkId, "fires"); + var grants = prepared.Check.Privileges.Select(p => p == Applicability.SelectOnSequences + ? "GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO declared" + : $"GRANT {p} TO declared"); + await prepared.Server.ExecuteAsSuperuserAsync(Cancel, ["CREATE ROLE declared LOGIN PASSWORD 'declared'", .. grants]); + + var declared = prepared.Server.Checkup; + declared.Username = "declared"; + declared.Password = "declared"; + + Assert.NotEmpty(await RunAsync(prepared, declared)); + } + + private static async Task PrepareAsync(string checkId, string fixture) { var check = CheckCatalog.All.Single(c => c.Id == checkId); if (int.Parse(PostgresServer.Version) < check.MinVersion) @@ -34,26 +71,38 @@ public async Task Fires_on_its_fires_fixture_and_stays_quiet_on_healthy(string c } var script = FixtureScript.Load(checkId, fixture); - await using var server = await PostgresServer.StartAsync(Cancel); + var server = await PostgresServer.StartAsync(script.ServerSettings, Cancel); // Stays open until the check has run, so a fixture can hold a transaction or lock open. - await using var setup = await server.OpenSuperuserAsync(Cancel); + var setup = await server.OpenSuperuserAsync(Cancel); foreach (var statement in script.Statements) { - await using var command = new NpgsqlCommand(statement, setup); - await command.ExecuteNonQueryAsync(Cancel); + await using var command = new NpgsqlCommand(statement.Sql, setup); + if (!statement.MayFail) + { + await command.ExecuteNonQueryAsync(Cancel); + continue; + } + + // The error is the fixture's point, so a statement that succeeds means the fixture is broken. + await Assert.ThrowsAsync(() => command.ExecuteNonQueryAsync(Cancel)); } - await using var session = await ReadOnlySession.OpenAsync(server.Checkup, Cancel); - var findings = await CheckRunner.RunAsync(session, script.Apply(check), Cancel); + return new PreparedFixture(server, setup, script.Apply(check)); + } - if (fixture == "fires") - { - Assert.NotEmpty(findings); - } - else + private static async Task> RunAsync(PreparedFixture prepared, NpgsqlConnectionStringBuilder role) + { + await using var session = await ReadOnlySession.OpenAsync(role, Cancel); + return await CheckRunner.RunAsync(session, prepared.Check, Cancel); + } + + private sealed record PreparedFixture(PostgresServer Server, NpgsqlConnection Setup, CheckDefinition Check) : IAsyncDisposable + { + public async ValueTask DisposeAsync() { - Assert.Empty(findings); + await Setup.DisposeAsync(); + await Server.DisposeAsync(); } } } diff --git a/tests/Pgcheckup.Tests/Postgres/FixtureScript.cs b/tests/Pgcheckup.Tests/Postgres/FixtureScript.cs index 46491f8..f829516 100644 --- a/tests/Pgcheckup.Tests/Postgres/FixtureScript.cs +++ b/tests/Pgcheckup.Tests/Postgres/FixtureScript.cs @@ -5,26 +5,42 @@ namespace Pgcheckup.Tests.Postgres; -// A check's fixture: setup statements, plus `-- threshold name = value` lines that lower a -// threshold when the real condition can't be reproduced at full scale. +/// One statement of a fixture. +/// The statement, with any comments before it. +/// Whether an -- expect error line comes before it, so it must fail. +internal sealed record FixtureStatement(string Sql, bool MayFail); + +/// +/// A check's fixture: setup statements, plus directives. -- threshold name = value lowers a +/// threshold when the real condition can't be reproduced at full scale. -- server name = value +/// starts Postgres with a setting that needs a restart. -- expect error means the next +/// statement must fail, as a failed CREATE INDEX CONCURRENTLY does. +/// internal sealed partial class FixtureScript { - private FixtureScript(IReadOnlyList statements, IReadOnlyDictionary thresholds) + private FixtureScript( + IReadOnlyList statements, + IReadOnlyDictionary thresholds, + IReadOnlyDictionary serverSettings) { Statements = statements; Thresholds = thresholds; + ServerSettings = serverSettings; } - public IReadOnlyList Statements { get; } + public IReadOnlyList Statements { get; } public IReadOnlyDictionary Thresholds { get; } + public IReadOnlyDictionary ServerSettings { get; } + public static FixtureScript Load(string checkId, string fixture) => Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "checks", checkId, "fixtures", fixture + ".sql"))); public static FixtureScript Parse(string text) { var thresholds = ThresholdLine().Matches(text).ToDictionary(m => m.Groups[1].Value, m => m.Groups[2].Value.Trim()); + var serverSettings = ServerLine().Matches(text).ToDictionary(m => m.Groups[1].Value, m => m.Groups[2].Value.Trim()); var errors = new List(); var tokens = SqlTokenizer.Tokenize(text, errors); @@ -33,19 +49,20 @@ public static FixtureScript Parse(string text) throw new InvalidOperationException($"The fixture doesn't parse: {string.Join("; ", errors)}"); } - var statements = new List(); + var statements = new List(); var start = 0; foreach (var end in tokens.Where(t => t.Kind == TokenKind.Semicolon).Select(t => t.Start).Append(text.Length)) { if (tokens.Any(t => t.Start >= start && t.Start < end && t.Kind != TokenKind.Semicolon)) { - statements.Add(text[start..end].Trim()); + var sql = text[start..end].Trim(); + statements.Add(new FixtureStatement(sql, ExpectErrorLine().IsMatch(sql))); } start = end + 1; } - return new FixtureScript(statements, thresholds); + return new FixtureScript(statements, thresholds, serverSettings); } public CheckDefinition Apply(CheckDefinition check) @@ -77,4 +94,10 @@ public CheckDefinition Apply(CheckDefinition check) [GeneratedRegex(@"^--\s*threshold\s+([a-z][a-z0-9_]*)\s*=\s*(.+)$", RegexOptions.Multiline)] private static partial Regex ThresholdLine(); + + [GeneratedRegex(@"^--\s*server\s+([a-z][a-z0-9_.]*)\s*=\s*(.+)$", RegexOptions.Multiline)] + private static partial Regex ServerLine(); + + [GeneratedRegex(@"^--\s*expect error\s*$", RegexOptions.Multiline)] + private static partial Regex ExpectErrorLine(); } diff --git a/tests/Pgcheckup.Tests/Postgres/FixtureScriptTests.cs b/tests/Pgcheckup.Tests/Postgres/FixtureScriptTests.cs new file mode 100644 index 0000000..4868c13 --- /dev/null +++ b/tests/Pgcheckup.Tests/Postgres/FixtureScriptTests.cs @@ -0,0 +1,48 @@ +namespace Pgcheckup.Tests.Postgres; + +public class FixtureScriptTests +{ + [Fact] + public void Splits_statements_on_semicolons_outside_literals() + { + var script = FixtureScript.Parse(""" + CREATE TABLE t (n int); + DO $$ BEGIN PERFORM 1; END $$; + -- a comment; with a semicolon + SELECT 'a;b'; + """); + + Assert.Equal( + ["CREATE TABLE t (n int)", "DO $$ BEGIN PERFORM 1; END $$", "-- a comment; with a semicolon\nSELECT 'a;b'"], + script.Statements.Select(s => s.Sql)); + } + + [Fact] + public void Reads_threshold_and_server_directives() + { + var script = FixtureScript.Parse(""" + -- threshold min_age = 0s + -- server max_prepared_transactions = 5 + -- server archive_mode = on + SELECT 1; + """); + + Assert.Equal(new Dictionary { ["min_age"] = "0s" }, script.Thresholds); + Assert.Equal( + new Dictionary { ["max_prepared_transactions"] = "5", ["archive_mode"] = "on" }, + script.ServerSettings); + } + + [Fact] + public void Lets_only_the_statement_after_expect_error_fail() + { + var script = FixtureScript.Parse(""" + CREATE TABLE t (n int); + -- expect error + CREATE UNIQUE INDEX CONCURRENTLY t_n ON t (n); + SELECT 1; + """); + + Assert.Equal([false, true, false], script.Statements.Select(s => s.MayFail)); + } +} diff --git a/tests/Pgcheckup.Tests/Postgres/PostgresServer.cs b/tests/Pgcheckup.Tests/Postgres/PostgresServer.cs index 8cc6521..cb22aba 100644 --- a/tests/Pgcheckup.Tests/Postgres/PostgresServer.cs +++ b/tests/Pgcheckup.Tests/Postgres/PostgresServer.cs @@ -1,3 +1,4 @@ +using DotNet.Testcontainers.Configurations; using Npgsql; using Testcontainers.PostgreSql; @@ -23,9 +24,18 @@ public sealed class PostgresServer : IAsyncDisposable Password = "checkup", }; - public static async Task StartAsync(CancellationToken cancellationToken) + public static Task StartAsync(CancellationToken cancellationToken) => + StartAsync(new Dictionary(), cancellationToken); + + // Replaces Testcontainers' default command, which turns fsync, full_page_writes and + // synchronous_commit off, so fixtures run on stock Postgres plus what they ask for. + public static async Task StartAsync(IReadOnlyDictionary settings, CancellationToken cancellationToken) { - var container = new PostgreSqlBuilder($"postgres:{Version}-alpine").WithDatabase("app").Build(); + var command = settings.SelectMany(s => new[] { "-c", $"{s.Key}={s.Value}" }).ToArray(); + var container = new PostgreSqlBuilder($"postgres:{Version}-alpine") + .WithDatabase("app") + .WithCommand(new OverwriteEnumerable(command)) + .Build(); await container.StartAsync(cancellationToken); var server = new PostgresServer(container); await server.ExecuteAsSuperuserAsync(cancellationToken, "CREATE ROLE checkup LOGIN PASSWORD 'checkup' IN ROLE pg_monitor"); diff --git a/tests/Pgcheckup.Tests/Postgres/PostgresServerTests.cs b/tests/Pgcheckup.Tests/Postgres/PostgresServerTests.cs new file mode 100644 index 0000000..e262547 --- /dev/null +++ b/tests/Pgcheckup.Tests/Postgres/PostgresServerTests.cs @@ -0,0 +1,35 @@ +using Npgsql; + +namespace Pgcheckup.Tests.Postgres; + +// Fixtures must run on stock Postgres, or a check that looks for risky settings would fire on +// every fixture. Testcontainers turns fsync, full_page_writes and synchronous_commit off by default. +public class PostgresServerTests +{ + private static CancellationToken Cancel => TestContext.Current.CancellationToken; + + private static async Task ShowAsync(PostgresServer server, string setting) + { + await using var connection = await server.OpenSuperuserAsync(Cancel); + await using var command = new NpgsqlCommand($"SHOW {setting}", connection); + return (string)(await command.ExecuteScalarAsync(Cancel))!; + } + + [Fact] + public async Task Starts_with_stock_settings() + { + await using var server = await PostgresServer.StartAsync(Cancel); + + Assert.Equal("on", await ShowAsync(server, "fsync")); + Assert.Equal("on", await ShowAsync(server, "full_page_writes")); + Assert.Equal("on", await ShowAsync(server, "synchronous_commit")); + } + + [Fact] + public async Task Starts_with_the_settings_a_fixture_asks_for() + { + await using var server = await PostgresServer.StartAsync(new Dictionary { ["max_prepared_transactions"] = "5" }, Cancel); + + Assert.Equal("5", await ShowAsync(server, "max_prepared_transactions")); + } +} From 21ded503b103f177c38c28afd92a20f2dabd6322 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 18:41:23 -0500 Subject: [PATCH 05/41] test(checks): scan every check as a pg_monitor role without errors --- tests/Pgcheckup.Tests/Checks/CatalogTests.cs | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/Pgcheckup.Tests/Checks/CatalogTests.cs diff --git a/tests/Pgcheckup.Tests/Checks/CatalogTests.cs b/tests/Pgcheckup.Tests/Checks/CatalogTests.cs new file mode 100644 index 0000000..c7f2630 --- /dev/null +++ b/tests/Pgcheckup.Tests/Checks/CatalogTests.cs @@ -0,0 +1,23 @@ +using Pgcheckup.Checks; +using Pgcheckup.Engine; +using Pgcheckup.Tests.Postgres; + +namespace Pgcheckup.Tests.Checks; + +public class CatalogTests(PostgresServerFixture postgres) : IClassFixture +{ + // M1's "Done when": the least-privilege role gets a clean scan, where every check runs or + // says why it was skipped, and none errors. + [Fact] + public async Task A_pg_monitor_role_runs_or_skips_every_check_without_errors() + { + var cancel = TestContext.Current.CancellationToken; + await using var session = await ReadOnlySession.OpenAsync(postgres.Server.Checkup, cancel); + + var report = await Scanner.ScanAsync(session, "db.example.com", CheckCatalog.All, cancel); + + Assert.Equal(CheckCatalog.All.Count, report.Results.Count); + Assert.All(report.Results, r => Assert.True(r.Status != CheckStatus.Errored, $"{r.Check.Id} errored: {r.Reason}")); + Assert.All(report.Results.Where(r => r.Status == CheckStatus.Skipped), r => Assert.False(string.IsNullOrEmpty(r.Reason))); + } +} From 083fa2b4c6fbc84c7dde778c2c8a26d54b6d6ce4 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 18:43:52 -0500 Subject: [PATCH 06/41] feat(cli): add explain and grant, and show more in list grant prints SQL for a pg_monitor role that is read-only by default, plus the sequence grants integer-exhaustion needs. A test runs the printed SQL and scans as the new role. --- src/Pgcheckup/Cli/CatalogText.cs | 59 +++++++++++ src/Pgcheckup/Cli/GrantScript.cs | 59 +++++++++++ src/Pgcheckup/Cli/PgcheckupCli.cs | 43 ++++++-- src/Pgcheckup/Engine/ServerContext.cs | 28 +++--- .../Cli/CatalogCommandTests.cs | 99 +++++++++++++++++++ tests/Pgcheckup.Tests/Cli/GrantTests.cs | 73 ++++++++++++++ 6 files changed, 342 insertions(+), 19 deletions(-) create mode 100644 src/Pgcheckup/Cli/CatalogText.cs create mode 100644 src/Pgcheckup/Cli/GrantScript.cs create mode 100644 tests/Pgcheckup.Tests/Cli/CatalogCommandTests.cs create mode 100644 tests/Pgcheckup.Tests/Cli/GrantTests.cs diff --git a/src/Pgcheckup/Cli/CatalogText.cs b/src/Pgcheckup/Cli/CatalogText.cs new file mode 100644 index 0000000..5f73aff --- /dev/null +++ b/src/Pgcheckup/Cli/CatalogText.cs @@ -0,0 +1,59 @@ +using Pgcheckup.Checks; +using Pgcheckup.Engine; + +namespace Pgcheckup.Cli; + +/// What pgcheckup list and pgcheckup explain print. +public static class CatalogText +{ + private const int LabelWidth = 13; + + /// Writes one line per check, under a header: id, severity, category, minimum Postgres version and title. + /// Where to write. + /// The checks, in the order to list them. + public static void WriteList(TextWriter output, IReadOnlyList checks) + { + var idWidth = Math.Max("check".Length, checks.Count == 0 ? 0 : checks.Max(c => c.Id.Length)) + 2; + output.WriteLine($"{"check".PadRight(idWidth)}{"severity",-10}{"category",-10}{"postgres",-10}title"); + foreach (var check in checks) + { + output.WriteLine($"{check.Id.PadRight(idWidth)}{Lower(check.Severity),-10}{check.Category,-10}{check.MinVersion + "+",-10}{check.Title}"); + } + } + + /// Writes a check's details, then its note: what breaks, how to fix it and where it was seen. + /// Where to write. + /// The check to explain. + /// Lines for providers and thresholds appear only when the check has some. + public static void WriteExplanation(TextWriter output, CheckDefinition check) + { + output.WriteLine($"{check.Id} · {check.Title}"); + output.WriteLine(); + WriteDetail(output, "Severity:", Lower(check.Severity)); + WriteDetail(output, "Category:", check.Category); + WriteDetail(output, "Postgres:", $"{check.MinVersion} or later"); + WriteDetail(output, "Needs:", check.Privileges.Count == 0 ? "no extra privileges" : string.Join(" and ", check.Privileges.Select(PrivilegeName))); + if (check.SkipOn.Count > 0) + { + WriteDetail(output, "Skipped on:", string.Join(", ", check.SkipOn.Select(ProviderName))); + } + + if (check.Thresholds.Count > 0) + { + WriteDetail(output, "Thresholds:", string.Join(", ", check.Thresholds.Select(t => $"{t.Name} = {t.Text}"))); + } + + output.WriteLine(); + output.WriteLine(check.Note); + } + + private static void WriteDetail(TextWriter output, string label, string value) => + output.WriteLine($"{label.PadRight(LabelWidth)}{value}"); + + private static string Lower(Severity severity) => severity.ToString().ToLowerInvariant(); + + private static string PrivilegeName(string privilege) => + privilege == Applicability.SelectOnSequences ? "SELECT on sequences" : privilege; + + private static string ProviderName(string id) => Provider.Known.FirstOrDefault(p => p.Id == id)?.Name ?? id; +} diff --git a/src/Pgcheckup/Cli/GrantScript.cs b/src/Pgcheckup/Cli/GrantScript.cs new file mode 100644 index 0000000..593f71c --- /dev/null +++ b/src/Pgcheckup/Cli/GrantScript.cs @@ -0,0 +1,59 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace Pgcheckup.Cli; + +/// The SQL that pgcheckup grant prints for a least-privilege checkup role. +public static partial class GrantScript +{ + // Reserved words can't be role or database names without quotes (PostgreSQL docs, appendix C). + private static readonly HashSet Reserved = new(StringComparer.Ordinal) + { + "all", "analyse", "analyze", "and", "any", "array", "as", "asc", "asymmetric", "authorization", + "binary", "both", "case", "cast", "check", "collate", "collation", "column", "concurrently", + "constraint", "create", "cross", "current_catalog", "current_date", "current_role", + "current_schema", "current_time", "current_timestamp", "current_user", "default", "deferrable", + "desc", "distinct", "do", "else", "end", "except", "false", "fetch", "for", "foreign", "freeze", + "from", "full", "grant", "group", "having", "ilike", "in", "initially", "inner", "intersect", + "into", "is", "isnull", "join", "lateral", "leading", "left", "like", "limit", "localtime", + "localtimestamp", "natural", "not", "notnull", "null", "offset", "on", "only", "or", "order", + "outer", "overlaps", "placing", "primary", "references", "returning", "right", "select", + "session_user", "similar", "some", "symmetric", "system_user", "table", "tablesample", "then", + "to", "trailing", "true", "union", "unique", "user", "using", "variadic", "verbose", "when", + "where", "window", "with", + }; + + /// Builds the SQL. It is printed for a person to review and run, never run by pgcheckup. + /// The role to create, quoted when the name needs it. + /// The database the role may connect to, quoted when the name needs it. + /// + /// SQL that creates the role with pg_monitor, CONNECT and a read-only default, then + /// the sequence grants that only integer-exhaustion needs. + /// + public static string Build(string role, string database) + { + var r = Identifier(role); + var d = Identifier(database); + var sql = new StringBuilder(); + sql.Append("-- A least-privilege role for pgcheckup. Review it, then run it as a superuser\n"); + sql.Append("-- (rds_superuser on Amazon RDS, cloudsqlsuperuser on Cloud SQL).\n"); + sql.Append($"CREATE ROLE {r} LOGIN;\n"); + sql.Append($"-- Set its password with \\password {r}, or use your provider's IAM login.\n"); + sql.Append($"GRANT pg_monitor TO {r};\n"); + sql.Append($"GRANT CONNECT ON DATABASE {d} TO {r};\n"); + sql.Append("-- Every session of this role is read-only, even outside pgcheckup.\n"); + sql.Append($"ALTER ROLE {r} SET default_transaction_read_only = on;\n"); + sql.Append('\n'); + sql.Append("-- Only integer-exhaustion needs these. They show sequence counters, never table rows.\n"); + sql.Append("-- Repeat them for each schema with sequences, as the role that creates them.\n"); + sql.Append($"GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO {r};\n"); + sql.Append($"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON SEQUENCES TO {r};\n"); + return sql.ToString(); + } + + private static string Identifier(string name) => + PlainIdentifier().IsMatch(name) && !Reserved.Contains(name) ? name : $"\"{name.Replace("\"", "\"\"")}\""; + + [GeneratedRegex("^[a-z_][a-z0-9_$]*$")] + private static partial Regex PlainIdentifier(); +} diff --git a/src/Pgcheckup/Cli/PgcheckupCli.cs b/src/Pgcheckup/Cli/PgcheckupCli.cs index ac7989e..856a5e9 100644 --- a/src/Pgcheckup/Cli/PgcheckupCli.cs +++ b/src/Pgcheckup/Cli/PgcheckupCli.cs @@ -57,10 +57,40 @@ public static async Task RunAsync( { var root = new RootCommand("Checks a PostgreSQL database for the problems that cause outages. Read-only, and safe to run on production."); - var list = new Command("list", "List every check."); - list.SetAction(_ => List(checks, output)); + var list = new Command("list", "List every check with its severity, category and minimum Postgres version."); + list.SetAction(_ => + { + CatalogText.WriteList(output, checks); + return Passed; + }); root.Subcommands.Add(list); + var checkId = new Argument("check") { Description = "The id of a check, as pgcheckup list shows it." }; + var explain = new Command("explain", "Explain a check: what breaks, how to fix it, and where it has happened."); + explain.Arguments.Add(checkId); + explain.SetAction(result => Explain(checks, result.GetValue(checkId)!, output, error)); + root.Subcommands.Add(explain); + + var role = new Option("--role") + { + Description = "The role to create.", + DefaultValueFactory = _ => "checkup", + }; + var database = new Option("--database") + { + Description = "The database the role may connect to.", + DefaultValueFactory = _ => "app", + }; + var grant = new Command("grant", "Print SQL for a least-privilege checkup role. pgcheckup never runs it."); + grant.Options.Add(role); + grant.Options.Add(database); + grant.SetAction(result => + { + output.Write(GrantScript.Build(result.GetValue(role)!, result.GetValue(database)!)); + return Passed; + }); + root.Subcommands.Add(grant); + var connection = new Argument("connection") { Description = "A postgres:// URL or a libpq key-value string. Without one, the PG* environment variables are used. Keep the password in PGPASSWORD or ~/.pgpass, not here.", @@ -108,14 +138,15 @@ public static async Task RunAsync( } } - private static int List(IReadOnlyList checks, TextWriter output) + private static int Explain(IReadOnlyList checks, string id, TextWriter output, TextWriter error) { - var width = checks.Max(c => c.Id.Length) + 2; - foreach (var check in checks) + if (checks.FirstOrDefault(c => c.Id == id) is not { } check) { - output.WriteLine($"{check.Id.PadRight(width)}{check.Severity.ToString().ToLowerInvariant(),-10}{check.Title}"); + error.WriteLine($"pgcheckup: there is no check named {id}. Run pgcheckup list to see them."); + return CouldNotRun; } + CatalogText.WriteExplanation(output, check); return Passed; } diff --git a/src/Pgcheckup/Engine/ServerContext.cs b/src/Pgcheckup/Engine/ServerContext.cs index a1ac717..0dc6f42 100644 --- a/src/Pgcheckup/Engine/ServerContext.cs +++ b/src/Pgcheckup/Engine/ServerContext.cs @@ -5,7 +5,19 @@ namespace Pgcheckup.Engine; /// A managed Postgres service, detected from SQL. /// The id that check.md lists under skip_on, such as rds. /// The name the report prints, such as "Amazon RDS". -public sealed record Provider(string Id, string Name); +public sealed record Provider(string Id, string Name) +{ + /// Every provider pgcheckup detects, in detection order. + public static IReadOnlyList Known { get; } = + [ + new("aurora", "Amazon Aurora"), + new("rds", "Amazon RDS"), + new("cloudsql", "Google Cloud SQL"), + new("azure", "Azure Database for PostgreSQL"), + new("supabase", "Supabase"), + new("neon", "Neon"), + ]; +} /// What pgcheckup knows about the server before any check runs. /// The database scanned. @@ -23,17 +35,6 @@ public sealed record ServerContext( Provider? Provider, IReadOnlySet Privileges) { - // Aurora first: it also has rds_superuser. - private static readonly (string Column, Provider Provider)[] Providers = - [ - ("aurora", new("aurora", "Amazon Aurora")), - ("rds", new("rds", "Amazon RDS")), - ("cloudsql", new("cloudsql", "Google Cloud SQL")), - ("azure", new("azure", "Azure Database for PostgreSQL")), - ("supabase", new("supabase", "Supabase")), - ("neon", new("neon", "Neon")), - ]; - private static readonly string[] PrivilegeColumns = ["pg_monitor", "pg_read_all_settings", "pg_read_all_stats", "pg_stat_scan_tables", "select_on_sequences"]; @@ -79,7 +80,8 @@ public static async Task ReadAsync(ReadOnlySession session, strin (string)row["database"]!, host, (int)row["version"]!, - Providers.FirstOrDefault(p => (bool)row[p.Column]!).Provider, + // Aurora comes first in Provider.Known because it also has rds_superuser. + Provider.Known.FirstOrDefault(p => (bool)row[p.Id]!), PrivilegeColumns.Where(p => (bool)row[p]!).ToHashSet(StringComparer.Ordinal)); } } diff --git a/tests/Pgcheckup.Tests/Cli/CatalogCommandTests.cs b/tests/Pgcheckup.Tests/Cli/CatalogCommandTests.cs new file mode 100644 index 0000000..3bdc051 --- /dev/null +++ b/tests/Pgcheckup.Tests/Cli/CatalogCommandTests.cs @@ -0,0 +1,99 @@ +using Pgcheckup.Checks; +using Pgcheckup.Cli; + +namespace Pgcheckup.Tests.Cli; + +public class CatalogCommandTests +{ + private static readonly CheckDefinition Sample = new( + "sample-check", "A sample check", "wal", Severity.Warning, 15, ["pg_monitor"], ["rds"], + [new Threshold("min_retained_wal", ThresholdKind.Bytes, 1_073_741_824m, "1GB")], + "SELECT 1", + new Template([]), + new Template([]), + "## What breaks\n\nDisks fill.\n\n## Fix\n\nDrop it.\n\n## Seen in\n\n- [Docs](https://www.postgresql.org/docs/current/)"); + + private static readonly CheckDefinition Other = Sample with + { + Id = "other-check", Title = "Another check", Category = "ids", Severity = Severity.Critical, MinVersion = 14, + Privileges = [], SkipOn = [], Thresholds = [], + }; + + private static async Task<(int ExitCode, string Output, string Error)> RunAsync(params string[] args) + { + var output = new StringWriter { NewLine = "\n" }; + var error = new StringWriter { NewLine = "\n" }; + var exitCode = await PgcheckupCli.RunAsync( + args, [Sample, Other], output, error, new Dictionary(), outputRedirected: true, TestContext.Current.CancellationToken); + return (exitCode, output.ToString(), error.ToString()); + } + + [Fact] + public async Task Lists_each_check_with_its_severity_category_and_minimum_version() + { + var (exitCode, output, _) = await RunAsync("list"); + + Assert.Equal(0, exitCode); + Assert.Equal( + """ + check severity category postgres title + sample-check warning wal 15+ A sample check + other-check critical ids 14+ Another check + + """.ReplaceLineEndings("\n"), + output); + } + + [Fact] + public async Task Explains_a_check_with_its_details_and_note() + { + var (exitCode, output, _) = await RunAsync("explain", "sample-check"); + + Assert.Equal(0, exitCode); + Assert.Equal( + """ + sample-check · A sample check + + Severity: warning + Category: wal + Postgres: 15 or later + Needs: pg_monitor + Skipped on: Amazon RDS + Thresholds: min_retained_wal = 1GB + + ## What breaks + + Disks fill. + + ## Fix + + Drop it. + + ## Seen in + + - [Docs](https://www.postgresql.org/docs/current/) + + """.ReplaceLineEndings("\n"), + output); + } + + [Fact] + public async Task Says_when_a_check_needs_nothing() + { + var (_, output, _) = await RunAsync("explain", "other-check"); + + Assert.Contains("Needs: no extra privileges\n", output); + Assert.DoesNotContain("Skipped on:", output); + Assert.DoesNotContain("Thresholds:", output); + } + + [Fact] + public async Task Exits_2_for_a_check_that_does_not_exist() + { + var (exitCode, _, error) = await RunAsync("explain", "no-such-check"); + + Assert.Equal(2, exitCode); + Assert.Contains("no-such-check", error); + Assert.Contains("pgcheckup list", error); + } +} diff --git a/tests/Pgcheckup.Tests/Cli/GrantTests.cs b/tests/Pgcheckup.Tests/Cli/GrantTests.cs new file mode 100644 index 0000000..6b3bf6d --- /dev/null +++ b/tests/Pgcheckup.Tests/Cli/GrantTests.cs @@ -0,0 +1,73 @@ +using Npgsql; +using Pgcheckup.Checks; +using Pgcheckup.Cli; +using Pgcheckup.Engine; +using Pgcheckup.Tests.Postgres; + +namespace Pgcheckup.Tests.Cli; + +public class GrantTests(PostgresServerFixture postgres) : IClassFixture +{ + private static CancellationToken Cancel => TestContext.Current.CancellationToken; + + [Fact] + public void Prints_a_least_privilege_role() + { + Assert.Equal( + """ + -- A least-privilege role for pgcheckup. Review it, then run it as a superuser + -- (rds_superuser on Amazon RDS, cloudsqlsuperuser on Cloud SQL). + CREATE ROLE checkup LOGIN; + -- Set its password with \password checkup, or use your provider's IAM login. + GRANT pg_monitor TO checkup; + GRANT CONNECT ON DATABASE app TO checkup; + -- Every session of this role is read-only, even outside pgcheckup. + ALTER ROLE checkup SET default_transaction_read_only = on; + + -- Only integer-exhaustion needs these. They show sequence counters, never table rows. + -- Repeat them for each schema with sequences, as the role that creates them. + GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO checkup; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON SEQUENCES TO checkup; + + """.ReplaceLineEndings("\n"), + GrantScript.Build("checkup", "app")); + } + + [Theory] + [InlineData("user", "\"user\"")] + [InlineData("Checkup", "\"Checkup\"")] + [InlineData("check-up", "\"check-up\"")] + [InlineData("we\"ird", "\"we\"\"ird\"")] + [InlineData("checkup_2", "checkup_2")] + public void Quotes_names_that_need_it(string name, string quoted) + { + Assert.Contains($"CREATE ROLE {quoted} LOGIN;", GrantScript.Build(name, "app")); + Assert.Contains($"GRANT CONNECT ON DATABASE {quoted} TO", GrantScript.Build("checkup", name)); + } + + // The printed SQL must actually produce a role that scans cleanly and can't write. + [Fact] + public async Task Creates_a_role_that_scans_every_check_and_cannot_write() + { + var statements = FixtureScript.Parse(GrantScript.Build("scanner", "app")).Statements.Select(s => s.Sql); + await postgres.Server.ExecuteAsSuperuserAsync(Cancel, [.. statements, "ALTER ROLE scanner PASSWORD 'scanner'"]); + var scanner = postgres.Server.Checkup; + scanner.Username = "scanner"; + scanner.Password = "scanner"; + + await using (var session = await ReadOnlySession.OpenAsync(scanner, Cancel)) + { + var report = await Scanner.ScanAsync(session, "db.example.com", CheckCatalog.All, Cancel); + // Only a newer Postgres version may skip a check; the grant must cover every privilege. + Assert.DoesNotContain(report.Results, r => r.Status == CheckStatus.Errored + || (r.Status == CheckStatus.Skipped && !r.Reason!.StartsWith("needs Postgres", StringComparison.Ordinal))); + } + + // Outside pgcheckup's guards, the role's own default still refuses writes. + await using var plain = new NpgsqlConnection(scanner.ConnectionString); + await plain.OpenAsync(Cancel); + await using var write = new NpgsqlCommand("CREATE TABLE public.scanner_wrote (n int)", plain); + var error = await Assert.ThrowsAsync(() => write.ExecuteNonQueryAsync(Cancel)); + Assert.Equal(PostgresErrorCodes.ReadOnlySqlTransaction, error.SqlState); + } +} From 3596d4742a373714a9d2d1ea2c9ce790b05d9b27 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 18:48:08 -0500 Subject: [PATCH 07/41] refactor(cli): share report wording between output formats --- src/Pgcheckup/Cli/ReportText.cs | 77 +++++++++++++++++++++++++++++ src/Pgcheckup/Cli/TerminalReport.cs | 55 ++------------------- 2 files changed, 81 insertions(+), 51 deletions(-) create mode 100644 src/Pgcheckup/Cli/ReportText.cs diff --git a/src/Pgcheckup/Cli/ReportText.cs b/src/Pgcheckup/Cli/ReportText.cs new file mode 100644 index 0000000..a7191e4 --- /dev/null +++ b/src/Pgcheckup/Cli/ReportText.cs @@ -0,0 +1,77 @@ +using Pgcheckup.Checks; +using Pgcheckup.Engine; + +namespace Pgcheckup.Cli; + +/// Wording that the terminal and Markdown reports share, so they never disagree. +public static class ReportText +{ + /// + /// Counts each check once at its worst severity, then errored checks, then names each skipped + /// check with its reason. + /// + /// What the scan found. + /// Such as "11 passed · 1 critical · 1 warning · 1 skipped (wal-archiving-failing: managed by Amazon RDS)". + public static string Summary(ScanReport report) + { + var parts = new List { $"{report.Results.Count(r => r.Status == CheckStatus.Passed)} passed" }; + var critical = report.Results.Count(r => r.Worst == Severity.Critical); + var warning = report.Results.Count(r => r.Worst == Severity.Warning); + var info = report.Results.Count(r => r.Worst == Severity.Info); + var errored = report.Results.Count(r => r.Status == CheckStatus.Errored); + var skipped = report.Results.Where(r => r.Status == CheckStatus.Skipped).ToList(); + + if (critical > 0) + { + parts.Add($"{critical} critical"); + } + + if (warning > 0) + { + parts.Add(warning == 1 ? "1 warning" : $"{warning} warnings"); + } + + if (info > 0) + { + parts.Add($"{info} info"); + } + + if (errored > 0) + { + parts.Add($"{errored} errored"); + } + + if (skipped.Count > 0) + { + parts.Add($"{skipped.Count} skipped ({string.Join(", ", skipped.Select(r => $"{r.Check.Id}: {r.Reason}"))})"); + } + + return string.Join(" · ", parts); + } + + /// Every finding across all checks, most severe first, then by check id, then in the check's order. + /// What the scan found. + /// The findings in report order. + public static IEnumerable OrderedFindings(ScanReport report) => report.Results + .SelectMany(r => r.Findings) + .Select((finding, order) => (finding, order)) + .OrderByDescending(f => f.finding.Severity) + .ThenBy(f => f.finding.CheckId, StringComparer.Ordinal) + .ThenBy(f => f.order) + .Select(f => f.finding); + + /// The errored checks, ordered by id. + /// What the scan found. + /// The errored results. + public static IEnumerable Errored(ScanReport report) => + report.Results.Where(r => r.Status == CheckStatus.Errored).OrderBy(r => r.Check.Id, StringComparer.Ordinal); + + /// Turns a reason such as "timed out after 5 s" into a sentence: "Timed out after 5 s." + /// A lowercase reason, or . + /// The reason capitalized and ending in a full stop. + public static string Sentence(string? reason) + { + var text = string.IsNullOrEmpty(reason) ? "it failed" : reason; + return char.ToUpperInvariant(text[0]) + text[1..] + (text.EndsWith('.') ? "" : "."); + } +} diff --git a/src/Pgcheckup/Cli/TerminalReport.cs b/src/Pgcheckup/Cli/TerminalReport.cs index edf191e..10f55a6 100644 --- a/src/Pgcheckup/Cli/TerminalReport.cs +++ b/src/Pgcheckup/Cli/TerminalReport.cs @@ -36,15 +36,7 @@ public static void Write(TextWriter output, ScanReport report, bool color) output.WriteLine($"pgcheckup · {server.Database} on {server.Host} · PostgreSQL {server.Version}{provider}"); output.WriteLine(); - var findings = report.Results - .SelectMany(r => r.Findings) - .Select((finding, order) => (finding, order)) - .OrderByDescending(f => f.finding.Severity) - .ThenBy(f => f.finding.CheckId, StringComparer.Ordinal) - .ThenBy(f => f.order) - .Select(f => f.finding); - - foreach (var finding in findings) + foreach (var finding in ReportText.OrderedFindings(report)) { WriteLabel(output, finding.Severity.ToString().ToUpperInvariant(), SeverityColor(finding.Severity), finding.CheckId, color); WriteIndented(output, finding.Message, new string(' ', Indent), new string(' ', Indent)); @@ -52,14 +44,14 @@ public static void Write(TextWriter output, ScanReport report, bool color) output.WriteLine(); } - foreach (var errored in report.Results.Where(r => r.Status == CheckStatus.Errored).OrderBy(r => r.Check.Id, StringComparer.Ordinal)) + foreach (var errored in ReportText.Errored(report)) { WriteLabel(output, "ERRORED", ErroredColor, errored.Check.Id, color); - WriteIndented(output, Sentence(errored.Reason ?? "it failed"), new string(' ', Indent), new string(' ', Indent)); + WriteIndented(output, ReportText.Sentence(errored.Reason), new string(' ', Indent), new string(' ', Indent)); output.WriteLine(); } - output.WriteLine(Summary(report)); + output.WriteLine(ReportText.Summary(report)); } private static void WriteLabel(TextWriter output, string label, string colorCode, string checkId, bool color) @@ -68,9 +60,6 @@ private static void WriteLabel(TextWriter output, string label, string colorCode output.WriteLine($"{painted}{new string(' ', Indent - label.Length)}{checkId}"); } - private static string Sentence(string reason) => - char.ToUpperInvariant(reason[0]) + reason[1..] + (reason.EndsWith('.') ? "" : "."); - private static void WriteIndented(TextWriter output, string text, string first, string rest) { var lines = text.Split('\n'); @@ -80,42 +69,6 @@ private static void WriteIndented(TextWriter output, string text, string first, } } - private static string Summary(ScanReport report) - { - var parts = new List { $"{report.Results.Count(r => r.Status == CheckStatus.Passed)} passed" }; - var critical = report.Results.Count(r => r.Worst == Severity.Critical); - var warning = report.Results.Count(r => r.Worst == Severity.Warning); - var info = report.Results.Count(r => r.Worst == Severity.Info); - if (critical > 0) - { - parts.Add($"{critical} critical"); - } - - if (warning > 0) - { - parts.Add(warning == 1 ? "1 warning" : $"{warning} warnings"); - } - - if (info > 0) - { - parts.Add($"{info} info"); - } - - var errored = report.Results.Count(r => r.Status == CheckStatus.Errored); - if (errored > 0) - { - parts.Add($"{errored} errored"); - } - - var skipped = report.Results.Where(r => r.Status == CheckStatus.Skipped).ToList(); - if (skipped.Count > 0) - { - parts.Add($"{skipped.Count} skipped ({string.Join(", ", skipped.Select(r => $"{r.Check.Id}: {r.Reason}"))})"); - } - - return string.Join(" · ", parts); - } - private const string ErroredColor = "1;35"; private static string SeverityColor(Severity severity) => severity switch From 41b959de2b3967170bd07b792bf86a6a30bde713 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 18:48:09 -0500 Subject: [PATCH 08/41] feat(cli): add --format json and markdown The JSON shape is schema 1, documented in docs/json.md. Its values are the columns a finding's message uses, in raw form. --- docs/json.md | 60 +++++++ src/Pgcheckup/Checks/Template.cs | 8 + src/Pgcheckup/Cli/JsonReport.cs | 105 +++++++++++++ src/Pgcheckup/Cli/MarkdownReport.cs | 68 ++++++++ src/Pgcheckup/Cli/PgcheckupCli.cs | 27 +++- tests/Pgcheckup.Tests/Cli/CommandLineTests.cs | 22 +++ .../Pgcheckup.Tests/Cli/MachineReportTests.cs | 146 ++++++++++++++++++ 7 files changed, 434 insertions(+), 2 deletions(-) create mode 100644 docs/json.md create mode 100644 src/Pgcheckup/Cli/JsonReport.cs create mode 100644 src/Pgcheckup/Cli/MarkdownReport.cs create mode 100644 tests/Pgcheckup.Tests/Cli/MachineReportTests.cs diff --git a/docs/json.md b/docs/json.md new file mode 100644 index 0000000..d7ccd3d --- /dev/null +++ b/docs/json.md @@ -0,0 +1,60 @@ +# JSON report + +`pgcheckup scan --format json` writes one JSON object. Its shape is versioned by `schema`. Adding a field doesn't change the version. Removing or renaming one, or changing what a value means, raises it. The exit codes are the same as for the terminal report. + +```json +{ + "schema": 1, + "pgcheckup": "0.1.0", + "server": { + "database": "app", + "host": "db.example.com", + "version": "17.6", + "provider": { "id": "rds", "name": "Amazon RDS" } + }, + "summary": { "passed": 11, "critical": 0, "warning": 1, "info": 0, "errored": 0, "skipped": 1 }, + "checks": [ + { + "id": "replication-slot-inactive", + "title": "Inactive replication slot", + "category": "wal", + "status": "warning", + "findings": [ + { + "subject": "debezium", + "severity": "warning", + "message": "Slot debezium has been inactive for 3 days and is holding 48 GB of WAL.", + "fix": "restart its consumer, or drop the slot:\nSELECT pg_drop_replication_slot('debezium');", + "values": { "subject": "debezium", "inactive_for": 259200, "retained_wal": 51539607552 } + } + ] + }, + { + "id": "wal-archiving-failing", + "title": "Failing WAL archiving", + "category": "wal", + "status": "skipped", + "reason": "managed by Amazon RDS", + "findings": [] + } + ] +} +``` + +## Fields + +| Field | Meaning | +| --- | --- | +| `schema` | The version of this shape. Currently `1`. | +| `pgcheckup` | The version of pgcheckup that wrote the report. | +| `server.database`, `server.host` | The database scanned and the host as given. The connection string and password are never included. | +| `server.version` | The Postgres version, such as `17.6`. | +| `server.provider` | The managed service detected (`id` and `name`), or `null` for a self-managed server. | +| `summary` | How many checks passed, and how many ended at each severity. Each check counts once, at its worst finding. | +| `checks[].id` | The check's stable id. `pgcheckup explain ` describes it. | +| `checks[].status` | `passed`, `critical`, `warning`, `info` (its worst finding), `skipped` or `errored`. | +| `checks[].reason` | Why the check was skipped or errored. Only present for those two statuses. | +| `checks[].findings[].subject` | The object the finding is about, such as a slot or table name. | +| `checks[].findings[].severity` | `critical`, `warning` or `info`. | +| `checks[].findings[].message`, `.fix` | The text the terminal report prints. pgcheckup never runs the fix. | +| `checks[].findings[].values` | The facts behind the message, one per column it uses: sizes and counts as numbers, durations in seconds, timestamps as ISO 8601 UTC strings. The names differ per check. | diff --git a/src/Pgcheckup/Checks/Template.cs b/src/Pgcheckup/Checks/Template.cs index 163133a..b4b817a 100644 --- a/src/Pgcheckup/Checks/Template.cs +++ b/src/Pgcheckup/Checks/Template.cs @@ -42,6 +42,14 @@ public sealed class Template(IReadOnlyList parts) /// The template's parts in order. public IReadOnlyList Parts { get; } = parts; + /// The columns the template uses, in order of first use, including those inside sections. + public IReadOnlyList ValueNames { get; } = parts + .SelectMany(p => p is SectionPart section ? section.Parts : [p]) + .OfType() + .Select(v => v.Name) + .Distinct(StringComparer.Ordinal) + .ToList(); + /// Renders the template with one row of the check's query. /// The row, by column name. SQL NULL is . /// The text, with each section left out when a value in it is NULL. diff --git a/src/Pgcheckup/Cli/JsonReport.cs b/src/Pgcheckup/Cli/JsonReport.cs new file mode 100644 index 0000000..ef58b5d --- /dev/null +++ b/src/Pgcheckup/Cli/JsonReport.cs @@ -0,0 +1,105 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Pgcheckup.Checks; +using Pgcheckup.Engine; + +namespace Pgcheckup.Cli; + +/// +/// The --format json report. Its shape is a contract, versioned by schema and +/// described in docs/json.md. +/// +public static class JsonReport +{ + /// The version of the JSON shape. Change it only through a decision in ROADMAP.md. + public const int Schema = 1; + + /// Writes the report as indented JSON. + /// What the scan found. + /// pgcheckup's own version, for the pgcheckup field. + /// The JSON text. + public static string Write(ScanReport report, string version) + { + var server = report.Server; + var document = new JsonDocumentModel( + Schema, + version, + new JsonServer(server.Database, server.Host, server.Version, server.Provider is { } p ? new JsonProvider(p.Id, p.Name) : null), + new JsonSummary( + report.Results.Count(r => r.Status == CheckStatus.Passed), + report.Results.Count(r => r.Worst == Severity.Critical), + report.Results.Count(r => r.Worst == Severity.Warning), + report.Results.Count(r => r.Worst == Severity.Info), + report.Results.Count(r => r.Status == CheckStatus.Errored), + report.Results.Count(r => r.Status == CheckStatus.Skipped)), + report.Results.Select(ToJson).ToList()); + return JsonSerializer.Serialize(document, JsonReportContext.Default.JsonDocumentModel); + } + + private static JsonCheck ToJson(CheckResult result) => new( + result.Check.Id, + result.Check.Title, + result.Check.Category, + result.Status switch + { + CheckStatus.Found => Lower(result.Worst!.Value), + _ => result.Status.ToString().ToLowerInvariant(), + }, + result.Reason, + result.Findings.Select(f => new JsonFinding(f.Subject, Lower(f.Severity), f.Message, f.Fix, Values(result.Check, f))).ToList()); + + // Only the columns the message uses: the facts behind the finding, not helpers such as a + // quoted name that exists for the fix. + private static JsonObject Values(CheckDefinition check, Finding finding) + { + var values = new JsonObject(); + foreach (var name in check.Message.ValueNames.Where(finding.Values.ContainsKey)) + { + values[name] = ToNode(finding.Values[name]); + } + + return values; + } + + private static JsonNode? ToNode(object? value) => value switch + { + null => null, + string text => JsonValue.Create(text), + bool flag => JsonValue.Create(flag), + short number => JsonValue.Create(number), + int number => JsonValue.Create(number), + long number => JsonValue.Create(number), + decimal number => JsonValue.Create(number), + double number => JsonValue.Create(number), + TimeSpan span => JsonValue.Create(Math.Round((decimal)span.TotalSeconds, 3)), + DateTime time => JsonValue.Create(time.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture)), + _ => JsonValue.Create(Convert.ToString(value, CultureInfo.InvariantCulture)), + }; + + private static string Lower(Severity severity) => severity.ToString().ToLowerInvariant(); +} + +// The JSON shape; docs/json.md describes each field. +internal sealed record JsonDocumentModel(int Schema, string Pgcheckup, JsonServer Server, JsonSummary Summary, IReadOnlyList Checks); + +internal sealed record JsonServer(string Database, string Host, string Version, JsonProvider? Provider); + +internal sealed record JsonProvider(string Id, string Name); + +internal sealed record JsonSummary(int Passed, int Critical, int Warning, int Info, int Errored, int Skipped); + +internal sealed record JsonCheck( + string Id, + string Title, + string Category, + string Status, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Reason, + IReadOnlyList Findings); + +internal sealed record JsonFinding(string Subject, string Severity, string Message, string Fix, JsonObject Values); + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true)] +[JsonSerializable(typeof(JsonDocumentModel))] +internal sealed partial class JsonReportContext : JsonSerializerContext; diff --git a/src/Pgcheckup/Cli/MarkdownReport.cs b/src/Pgcheckup/Cli/MarkdownReport.cs new file mode 100644 index 0000000..71408a3 --- /dev/null +++ b/src/Pgcheckup/Cli/MarkdownReport.cs @@ -0,0 +1,68 @@ +using System.Text; +using Pgcheckup.Checks; +using Pgcheckup.Engine; + +namespace Pgcheckup.Cli; + +/// The --format markdown report, shaped for a pull request comment. +public static class MarkdownReport +{ + /// + /// Writes a heading, a summary line, a table of findings and errored checks, then each fix in + /// a code block. With nothing found or errored, only the heading and summary. + /// + /// What the scan found. + /// The Markdown text. + public static string Write(ScanReport report) + { + var server = report.Server; + var markdown = new StringBuilder(); + Line(markdown, $"## pgcheckup · {server.Database} on {server.Host}"); + Line(markdown); + var provider = server.Provider is { } managed ? $" · {managed.Name}" : ""; + Line(markdown, $"PostgreSQL {server.Version}{provider} · {ReportText.Summary(report)}"); + + var findings = ReportText.OrderedFindings(report).ToList(); + var errored = ReportText.Errored(report).ToList(); + if (findings.Count == 0 && errored.Count == 0) + { + return markdown.ToString(); + } + + Line(markdown); + Line(markdown, "| Severity | Check | Finding |"); + Line(markdown, "| --- | --- | --- |"); + foreach (var finding in findings) + { + var severity = finding.Severity.ToString(); + Line(markdown, $"| {(finding.Severity == Severity.Critical ? $"**{severity}**" : severity)} | `{finding.CheckId}` | {Cell(finding.Message)} |"); + } + + foreach (var result in errored) + { + Line(markdown, $"| Errored | `{result.Check.Id}` | {Cell(ReportText.Sentence(result.Reason))} |"); + } + + if (findings.Count > 0) + { + Line(markdown); + Line(markdown, "### Fixes"); + foreach (var finding in findings) + { + Line(markdown); + Line(markdown, $"**`{finding.CheckId}`** · {Cell(finding.Subject)}"); + Line(markdown); + Line(markdown, "```"); + Line(markdown, finding.Fix); + Line(markdown, "```"); + } + } + + return markdown.ToString(); + } + + // Written with \n on every platform, so the text is the same wherever it is produced. + private static void Line(StringBuilder markdown, string text = "") => markdown.Append(text).Append('\n'); + + private static string Cell(string text) => text.Replace("|", "\\|").Replace("\n", "
"); +} diff --git a/src/Pgcheckup/Cli/PgcheckupCli.cs b/src/Pgcheckup/Cli/PgcheckupCli.cs index 856a5e9..76df317 100644 --- a/src/Pgcheckup/Cli/PgcheckupCli.cs +++ b/src/Pgcheckup/Cli/PgcheckupCli.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using System.Reflection; using Npgsql; using Pgcheckup.Checks; using Pgcheckup.Engine; @@ -17,6 +18,9 @@ public static class PgcheckupCli /// Exit code 2: the scan couldn't run, whatever the reason. public const int CouldNotRun = 2; + private static string Version => + typeof(PgcheckupCli).Assembly.GetCustomAttribute()?.InformationalVersion ?? "unknown"; + /// Runs pgcheckup with every check compiled into the binary. /// The command-line arguments. /// Where the report and help go. @@ -102,12 +106,19 @@ public static async Task RunAsync( DefaultValueFactory = _ => "critical", }; failOn.AcceptOnlyFromAmong("critical", "warning", "info"); + var format = new Option("--format") + { + Description = "How to write the report: terminal, json (see docs/json.md) or markdown.", + DefaultValueFactory = _ => "terminal", + }; + format.AcceptOnlyFromAmong("terminal", "json", "markdown"); var scan = new Command("scan", "Scan a database and report what could take it down."); scan.Arguments.Add(connection); scan.Options.Add(failOn); + scan.Options.Add(format); scan.SetAction((result, token) => ScanAsync( - result.GetValue(connection), result.GetValue(failOn)!, checks, output, error, environment, outputRedirected, token)); + result.GetValue(connection), result.GetValue(failOn)!, result.GetValue(format)!, checks, output, error, environment, outputRedirected, token)); root.Subcommands.Add(scan); var parsed = root.Parse(args); @@ -153,6 +164,7 @@ private static int Explain(IReadOnlyList checks, string id, Tex private static async Task ScanAsync( string? input, string failOn, + string format, IReadOnlyList checks, TextWriter output, TextWriter error, @@ -196,7 +208,18 @@ private static async Task ScanAsync( return CouldNotRun; } - TerminalReport.Write(output, report, TerminalReport.UseColor(outputRedirected, environment)); + switch (format) + { + case "json": + output.WriteLine(JsonReport.Write(report, Version)); + break; + case "markdown": + output.Write(MarkdownReport.Write(report)); + break; + default: + TerminalReport.Write(output, report, TerminalReport.UseColor(outputRedirected, environment)); + break; + } var threshold = failOn switch { diff --git a/tests/Pgcheckup.Tests/Cli/CommandLineTests.cs b/tests/Pgcheckup.Tests/Cli/CommandLineTests.cs index 8d66072..4265707 100644 --- a/tests/Pgcheckup.Tests/Cli/CommandLineTests.cs +++ b/tests/Pgcheckup.Tests/Cli/CommandLineTests.cs @@ -29,6 +29,7 @@ public async Task Lists_every_check() [Theory] [InlineData("scan", "--nope")] [InlineData("scan", "--fail-on", "sometimes")] + [InlineData("scan", "--format", "yaml")] [InlineData("frobnicate")] public async Task Exits_2_when_the_arguments_are_wrong(params string[] args) { @@ -102,6 +103,27 @@ public async Task Exits_1_when_a_finding_reaches_fail_on() Assert.Equal(1, (await ScanAsync([SlotCheck], "--fail-on", "warning")).ExitCode); } + [Fact] + public async Task Writes_json_with_the_same_exit_codes() + { + var (exitCode, output, _) = await ScanAsync([SlotCheck], "--format", "json", "--fail-on", "warning"); + + Assert.Equal(1, exitCode); + using var json = System.Text.Json.JsonDocument.Parse(output); + Assert.Equal(1, json.RootElement.GetProperty("schema").GetInt32()); + Assert.Equal("warning", json.RootElement.GetProperty("checks")[0].GetProperty("status").GetString()); + } + + [Fact] + public async Task Writes_markdown() + { + var (exitCode, output, _) = await ScanAsync([SlotCheck], "--format", "markdown"); + + Assert.Equal(0, exitCode); + Assert.StartsWith("## pgcheckup · app on ", output); + Assert.Contains("| Warning | `replication-slot-inactive` |", output); + } + [Fact] public async Task Exits_2_when_a_check_errored_and_no_finding_reached_fail_on() { diff --git a/tests/Pgcheckup.Tests/Cli/MachineReportTests.cs b/tests/Pgcheckup.Tests/Cli/MachineReportTests.cs new file mode 100644 index 0000000..1bf0692 --- /dev/null +++ b/tests/Pgcheckup.Tests/Cli/MachineReportTests.cs @@ -0,0 +1,146 @@ +using System.Text.Json; +using Pgcheckup.Checks; +using Pgcheckup.Cli; +using Pgcheckup.Engine; + +namespace Pgcheckup.Tests.Cli; + +public class MachineReportTests +{ + private static readonly ServerContext Server = new("app", "db.example.com", 170006, new Provider("rds", "Amazon RDS"), new HashSet()); + + private static readonly Template SlotMessage = new( + [ + new TextPart("Slot "), + new ValuePart("subject", ValueFormat.Default), + new SectionPart([new TextPart(" for "), new ValuePart("inactive_for", ValueFormat.Default)]), + new TextPart(" holds "), + new ValuePart("retained_wal", ValueFormat.Bytes), + ]); + + private static CheckDefinition Check(string id, Template? message = null) => new( + id, $"Title of {id}", "wal", Severity.Warning, 14, [], [], [], "SELECT 1", + message ?? new Template([]), new Template([new ValuePart("slot_literal", ValueFormat.Default)]), ""); + + private static readonly ScanReport Report = new(Server, + [ + new CheckResult(Check("replication-slot-inactive", SlotMessage), CheckStatus.Found, + [ + new Finding("replication-slot-inactive", "debezium", Severity.Warning, + "Slot debezium for 3 days holds 48 GB", + "restart its consumer, or drop the slot:\nSELECT pg_drop_replication_slot('debezium');", + new Dictionary + { + ["subject"] = "debezium", + ["slot_literal"] = "'debezium'", + ["inactive_for"] = new TimeSpan(3, 0, 0, 0, 500), + ["retained_wal"] = 51_539_607_552L, + ["seen_at"] = new DateTime(2026, 9, 25, 14, 3, 59, DateTimeKind.Utc), + }), + ]), + new CheckResult(Check("connection-saturation"), CheckStatus.Passed, []), + new CheckResult(Check("wal-archiving-failing"), CheckStatus.Skipped, [], "managed by Amazon RDS"), + new CheckResult(Check("xid-wraparound"), CheckStatus.Errored, [], "timed out after 5 s"), + ]); + + [Fact] + public void Lists_the_columns_a_template_uses_in_order() + { + Assert.Equal(["subject", "inactive_for", "retained_wal"], SlotMessage.ValueNames); + } + + [Fact] + public void Writes_json_with_schema_1_the_server_and_a_summary() + { + using var json = JsonDocument.Parse(JsonReport.Write(Report, "0.1.0")); + var root = json.RootElement; + + Assert.Equal(1, root.GetProperty("schema").GetInt32()); + Assert.Equal("0.1.0", root.GetProperty("pgcheckup").GetString()); + var server = root.GetProperty("server"); + Assert.Equal("app", server.GetProperty("database").GetString()); + Assert.Equal("db.example.com", server.GetProperty("host").GetString()); + Assert.Equal("17.6", server.GetProperty("version").GetString()); + Assert.Equal("rds", server.GetProperty("provider").GetProperty("id").GetString()); + var summary = root.GetProperty("summary"); + Assert.Equal( + [("passed", 1), ("critical", 0), ("warning", 1), ("info", 0), ("errored", 1), ("skipped", 1)], + summary.EnumerateObject().Select(p => (p.Name, p.Value.GetInt32()))); + } + + [Fact] + public void Writes_each_checks_status_and_reason() + { + using var json = JsonDocument.Parse(JsonReport.Write(Report, "0.1.0")); + + var checks = json.RootElement.GetProperty("checks").EnumerateArray().ToList(); + Assert.Equal( + ["warning", "passed", "skipped", "errored"], + checks.Select(c => c.GetProperty("status").GetString())); + Assert.Equal("managed by Amazon RDS", checks[2].GetProperty("reason").GetString()); + Assert.Equal("timed out after 5 s", checks[3].GetProperty("reason").GetString()); + Assert.False(checks[1].TryGetProperty("reason", out _)); + } + + [Fact] + public void Writes_the_message_columns_of_a_finding_as_raw_values() + { + using var json = JsonDocument.Parse(JsonReport.Write(Report, "0.1.0")); + + var finding = json.RootElement.GetProperty("checks")[0].GetProperty("findings")[0]; + Assert.Equal("debezium", finding.GetProperty("subject").GetString()); + Assert.Equal("warning", finding.GetProperty("severity").GetString()); + Assert.StartsWith("restart its consumer", finding.GetProperty("fix").GetString()); + var values = finding.GetProperty("values"); + Assert.Equal(["subject", "inactive_for", "retained_wal"], values.EnumerateObject().Select(p => p.Name)); + Assert.Equal(259_200.5m, values.GetProperty("inactive_for").GetDecimal()); + Assert.Equal(51_539_607_552L, values.GetProperty("retained_wal").GetInt64()); + } + + [Fact] + public void Writes_markdown_for_a_pull_request_comment() + { + Assert.Equal( + """ + ## pgcheckup · app on db.example.com + + PostgreSQL 17.6 · Amazon RDS · 1 passed · 1 warning · 1 errored · 1 skipped (wal-archiving-failing: managed by Amazon RDS) + + | Severity | Check | Finding | + | --- | --- | --- | + | Warning | `replication-slot-inactive` | Slot debezium for 3 days holds 48 GB | + | Errored | `xid-wraparound` | Timed out after 5 s. | + + ### Fixes + + **`replication-slot-inactive`** · debezium + + ``` + restart its consumer, or drop the slot: + SELECT pg_drop_replication_slot('debezium'); + ``` + + """.ReplaceLineEndings("\n"), + MarkdownReport.Write(Report)); + } + + [Fact] + public void Escapes_pipes_and_line_breaks_in_markdown_cells() + { + var report = new ScanReport(Server, + [ + new CheckResult(Check("a"), CheckStatus.Found, + [new Finding("a", "x", Severity.Critical, "one | two\nthree", "fix", new Dictionary())]), + ]); + + Assert.Contains("| **Critical** | `a` | one \\| two
three |", MarkdownReport.Write(report)); + } + + [Fact] + public void Writes_only_the_summary_when_nothing_was_found() + { + var report = new ScanReport(Server with { Provider = null }, [new CheckResult(Check("a"), CheckStatus.Passed, [])]); + + Assert.Equal("## pgcheckup · app on db.example.com\n\nPostgreSQL 17.6 · 1 passed\n", MarkdownReport.Write(report)); + } +} From 29620614e94c6b1aa5172eb7ec9df14bbff2af94 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 18:51:48 -0500 Subject: [PATCH 09/41] docs(roadmap): revise the v0.1 checks after desk research --- ROADMAP.md | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 370ecec..3c38002 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -40,6 +40,7 @@ pgcheckup is a read-only CLI (.NET 10, NativeAOT) that checks a PostgreSQL datab - **Fixture directives.** `-- server name = value` starts the fixture's container with that setting, for settings that need a restart (`max_prepared_transactions`, `archive_mode`). `-- expect error` lets the next statement fail, such as a `CREATE INDEX CONCURRENTLY` that leaves an invalid index. - **`postgres-eol` is a SQL check.** The end-of-life dates ship in a `VALUES` list in its `check.sql` and are compared with the server's clock, so no client clock is read. - **Quiet on stock Postgres.** Stock Postgres ships with `max_slot_wal_keep_size = -1` and no `idle_in_transaction_session_timeout`, and flagging every database for them would teach people to ignore pgcheckup. `replication-slot-unbounded` fires only when a slot exists. An unset timeout is `info`, and sessions idle in a transaction are `warning`. +- **The check table follows the desk research.** Public postmortems cluster around vacuum: transaction ID wraparound (6) and old snapshots holding vacuum back (5), then WAL filling the disk and connection exhaustion (3 each). So `collation-version-mismatch` joins, sessions idle in a transaction are reported by `long-transaction`, `idle-in-transaction` becomes `idle-transaction-timeout`, and the autovacuum settings move from `dangerous-settings` into `autovacuum-disabled` (was `autovacuum-disabled-table`). Lock queues, missing statistics, MultiXact member space and pooler exhaustion can't be predicted from catalog state. `sync-standby-missing` has too little evidence for v0.1 and goes under Later. - **JSON `schema: 1`** holds the server, a summary, and each check's status (passed, critical, warning, info, skipped or errored) with its reason and findings. A finding has a subject, severity, message, fix and values. The values are the columns its message uses, in raw form: bytes as integers, durations in seconds, and timestamps in ISO 8601 UTC. `docs/json.md` documents the shape. ## M0: Placeholder (as soon as possible) @@ -58,31 +59,32 @@ pgcheckup is a read-only CLI (.NET 10, NativeAOT) that checks a PostgreSQL datab ## M1: Engine and check catalog -- [ ] Engine: server version and provider detection, and a privilege probe, followed by every applicable check. A check that errors or times out is reported as errored, and the others still run. -- [ ] Provider detection, tested by simulating each provider's roles and settings in fixtures. -- [ ] Desk research for the catalog: go through public Postgres postmortems (danluu/post-mortems, engineering blogs) and DBA Stack Exchange, list the failures that recur, and adjust the table below to match. Every check gets at least one **Seen in** link. +- [x] Engine: server version and provider detection, and a privilege probe, followed by every applicable check. A check that errors or times out is reported as errored, and the others still run. +- [x] Provider detection, tested by simulating each provider's roles and settings in fixtures. +- [x] Desk research for the catalog: go through public Postgres postmortems (danluu/post-mortems, engineering blogs) and DBA Stack Exchange, list the failures that recur, and adjust the table below to match. Every check gets at least one **Seen in** link. - [ ] The v0.1 checks: | Check | Catches | |---|---| | `xid-wraparound` | Databases and tables whose oldest unfrozen transaction ID is nearing the 2.1 billion limit | | `multixact-wraparound` | The same for multixact IDs | -| `long-transaction` | Transactions open longer than the threshold, which hold back vacuum | -| `idle-in-transaction` | Sessions idle inside an open transaction, and `idle_in_transaction_session_timeout` left unset | +| `long-transaction` | Transactions open, or idle, longer than the threshold, which hold back vacuum | +| `idle-transaction-timeout` | `idle_in_transaction_session_timeout` left unset, so an abandoned transaction stays open until someone ends it | | `prepared-transaction-orphaned` | Prepared transactions left behind, which hold locks and block vacuum | -| `replication-slot-inactive` | Slots with no consumer, which keep WAL until the disk fills | -| `replication-slot-unbounded` | `max_slot_wal_keep_size = -1`, so one stuck slot can keep unlimited WAL | -| `wal-archiving-failing` | `archive_command` failing since the last success, which breaks point-in-time recovery | +| `replication-slot-inactive` | Slots with no consumer, which keep WAL until the disk fills, or pin `xmin` and block vacuum | +| `replication-slot-unbounded` | Slots with no cap on the WAL they keep, so one stuck slot can fill the disk | +| `wal-archiving-failing` | WAL archiving that fails, hangs or has no command, which breaks point-in-time recovery and keeps WAL | | `connection-saturation` | Connections close to `max_connections` minus the reserved slots | -| `dangerous-settings` | `fsync`, `full_page_writes` or `autovacuum` turned off | -| `autovacuum-disabled-table` | Tables with `autovacuum_enabled = false` | -| `integer-exhaustion` | `int4` sequences and identity columns past a share of their range | +| `dangerous-settings` | `fsync` or `full_page_writes` turned off, or `zero_damaged_pages` turned on | +| `autovacuum-disabled` | `autovacuum` or `track_counts` turned off, and tables with `autovacuum_enabled = false` | +| `integer-exhaustion` | `int4` sequences, identity columns and foreign keys past a share of their range | | `invalid-index` | Indexes left invalid by a failed `CREATE INDEX CONCURRENTLY`, which slow writes and are never used | -| `postgres-eol` | Major versions past their end-of-life date | +| `collation-version-mismatch` | Databases and collations whose version changed under them after an OS upgrade, which breaks text indexes | +| `postgres-eol` | Major versions past, or close to, their end-of-life date | -- [ ] `pgcheckup explain ` prints the check's note. `pgcheckup list` shows every check with its category and minimum version. -- [ ] `pgcheckup grant` prints SQL for a least-privilege checkup role: `pg_monitor`, `CONNECT`, `default_transaction_read_only = on` for the role, and SELECT on sequences for `integer-exhaustion`. -- [ ] `--format json` and `--format markdown`. The JSON shape is documented, with `"schema": 1`. +- [x] `pgcheckup explain ` prints the check's note. `pgcheckup list` shows every check with its category and minimum version. +- [x] `pgcheckup grant` prints SQL for a least-privilege checkup role: `pg_monitor`, `CONNECT`, `default_transaction_read_only = on` for the role, and SELECT on sequences for `integer-exhaustion`. +- [x] `--format json` and `--format markdown`. The JSON shape is documented, with `"schema": 1`. **Done when:** every check's fixtures pass on Postgres 14 to 18, and a scan as a role with only `pg_monitor` either runs or skips (with a reason) every check, with no errors. @@ -113,6 +115,7 @@ pgcheckup is a read-only CLI (.NET 10, NativeAOT) that checks a PostgreSQL datab - Hosted monitoring (scheduled scans with alerts), then a team dashboard - Scanning every database in a cluster in one run - Checks based on `pg_stat_statements` +- `sync-standby-missing`: `synchronous_standby_names` is set, but no synchronous standby is connected, so every commit hangs - Scoop, Homebrew and winget packages - Signed releases with build provenance From 9c5ed4942a16eba7f3789ef133487ec3fc313850 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 18:52:03 -0500 Subject: [PATCH 10/41] test(checks): run one check's fixtures with PGCHECKUP_TEST_CHECK --- AGENTS.md | 2 +- tests/Pgcheckup.Tests/Checks/FixtureTests.cs | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e2b2905..174b3cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ These are the working rules for agents in this repo. pgcheckup is a read-only CL Keep commands cross-platform (`dotnet`, `docker`), because the owner develops on Windows. Avoid bash-only scripts. - Build: `dotnet build pgcheckup.slnx`. A broken check folder fails the build with its file and line. -- Test: `dotnet test --project tests/Pgcheckup.Tests`. It needs Docker, and uses Postgres 18 unless `PGCHECKUP_TEST_POSTGRES` names another major (14 to 17). CI runs all five. +- Test: `dotnet test --project tests/Pgcheckup.Tests`. It needs Docker, and uses Postgres 18 unless `PGCHECKUP_TEST_POSTGRES` names another major (14 to 17). CI runs all five. `PGCHECKUP_TEST_CHECK=` runs only that check's fixtures, which is quicker while writing a check. - Publish: `dotnet publish src/Pgcheckup -c Release -r win-x64 -o out` (`linux-x64` on Linux). Trim and AOT warnings fail it. - Test the published binary: set `PGCHECKUP_BINARY` to it, then run `dotnet test --project tests/Pgcheckup.Tests -- --filter-class Pgcheckup.Tests.Cli.NativeBinaryTests`. - NativeAOT publish on this Windows machine fails with `'vswhere.exe' is not recognized` unless the VS Installer folder is on PATH. Run it as `$env:PATH = "C:\Program Files (x86)\Microsoft Visual Studio\Installer;$env:PATH"; dotnet publish …`. That is an environment problem, not an AOT warning. diff --git a/tests/Pgcheckup.Tests/Checks/FixtureTests.cs b/tests/Pgcheckup.Tests/Checks/FixtureTests.cs index fd3bd40..d94dd9d 100644 --- a/tests/Pgcheckup.Tests/Checks/FixtureTests.cs +++ b/tests/Pgcheckup.Tests/Checks/FixtureTests.cs @@ -11,10 +11,16 @@ public class FixtureTests { private static CancellationToken Cancel => TestContext.Current.CancellationToken; + // PGCHECKUP_TEST_CHECK= runs one check's fixtures, which is quicker while writing a check. + private static IEnumerable Selected => + Environment.GetEnvironmentVariable("PGCHECKUP_TEST_CHECK") is { Length: > 0 } id + ? CheckCatalog.All.Where(c => c.Id == id) + : CheckCatalog.All; + public static TheoryData Fixtures() { var data = new TheoryData(); - foreach (var check in CheckCatalog.All) + foreach (var check in Selected) { data.Add(check.Id, "fires"); data.Add(check.Id, "healthy"); @@ -23,7 +29,7 @@ public static TheoryData Fixtures() return data; } - public static TheoryData Checks() => new(CheckCatalog.All.Select(c => c.Id)); + public static TheoryData Checks() => new(Selected.Select(c => c.Id)); [Theory] [MemberData(nameof(Fixtures))] From 679b49a4584d17a711001956a9dd8dfd5275cc33 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 18:55:13 -0500 Subject: [PATCH 11/41] test(checks): collect rendered findings with PGCHECKUP_TEST_FINDINGS --- tests/Pgcheckup.Tests/Checks/FixtureTests.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Pgcheckup.Tests/Checks/FixtureTests.cs b/tests/Pgcheckup.Tests/Checks/FixtureTests.cs index d94dd9d..9e20ba9 100644 --- a/tests/Pgcheckup.Tests/Checks/FixtureTests.cs +++ b/tests/Pgcheckup.Tests/Checks/FixtureTests.cs @@ -38,6 +38,12 @@ public async Task Fires_on_its_fires_fixture_and_stays_quiet_on_healthy(string c await using var prepared = await PrepareAsync(checkId, fixture); var findings = await RunAsync(prepared, prepared.Server.Checkup); + // PGCHECKUP_TEST_FINDINGS= collects what each fixture renders, for reviewing a check's wording. + if (Environment.GetEnvironmentVariable("PGCHECKUP_TEST_FINDINGS") is { Length: > 0 } path) + { + await File.AppendAllLinesAsync( + path, findings.Select(f => $"[{PostgresServer.Version} {fixture}] {f.Severity} {f.CheckId}: {f.Message} | Fix: {f.Fix.Replace('\n', ' ')}"), Cancel); + } if (fixture == "fires") { From b60af6843be11b3502fadb0cbd592b6986fe6fc1 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 18:55:14 -0500 Subject: [PATCH 12/41] feat(checks): add xid-wraparound --- checks/xid-wraparound/check.md | 40 ++++++++++++++++++++++ checks/xid-wraparound/check.sql | 29 ++++++++++++++++ checks/xid-wraparound/fixtures/fires.sql | 7 ++++ checks/xid-wraparound/fixtures/healthy.sql | 4 +++ 4 files changed, 80 insertions(+) create mode 100644 checks/xid-wraparound/check.md create mode 100644 checks/xid-wraparound/check.sql create mode 100644 checks/xid-wraparound/fixtures/fires.sql create mode 100644 checks/xid-wraparound/fixtures/healthy.sql diff --git a/checks/xid-wraparound/check.md b/checks/xid-wraparound/check.md new file mode 100644 index 0000000..6dc7b72 --- /dev/null +++ b/checks/xid-wraparound/check.md @@ -0,0 +1,40 @@ +--- +id: xid-wraparound +title: Transaction ID wraparound +category: ids +severity: warning +min_version: 14 +privileges: [] +thresholds: + warning_age: 500000000 + critical_age: 1500000000 +message: "[Database {database}][Table {table_name}] has used {xid_age:count} of its 2.1 billion transaction IDs." +fix: "[connect to {database} and scan it to find its oldest tables, then freeze them.][VACUUM (FREEZE, VERBOSE) {table_name};]" +--- + +## What breaks + +Postgres numbers transactions with 32-bit IDs and reuses them in a circle. Vacuum marks old rows as frozen so that reuse is safe. If a table goes about 2.1 billion transactions without being frozen, Postgres stops accepting writes to protect the data, and the fix is a long single-user vacuum while the application is down. + +Long before that, at 200 million by default (`autovacuum_freeze_max_age`), autovacuum starts an anti-wraparound vacuum that can't be cancelled, and schema changes queue behind its lock. From 1.6 billion, Postgres switches to its failsafe mode. + +The usual cause is something that holds vacuum back: a long transaction, a forgotten prepared transaction or an inactive replication slot. The checks `long-transaction`, `prepared-transaction-orphaned` and `replication-slot-inactive` look for those. + +This check measures tables in the database it connects to, and every other database in the cluster as a whole. It lists at most the 20 oldest tables. + +## Fix + +Freeze the oldest tables first: + +```sql +VACUUM (FREEZE, VERBOSE) public.orders; +``` + +If the age keeps climbing, find and end what holds vacuum back before vacuuming again. On a large table, the vacuum takes a while; let it finish rather than cancelling it. + +## Seen in + +- [Transaction ID wraparound in Postgres](https://blog.sentry.io/transaction-id-wraparound-in-postgres/), Sentry +- [What we learned from the recent Mandrill outage](https://mailchimp.com/what-we-learned-from-the-recent-mandrill-outage/), Mailchimp +- [Understanding an outage: concurrency control and vacuuming in PostgreSQL](https://duffel.com/blog/understanding-outage-concurrency-vacuum-postgresql), Duffel +- [Preventing transaction ID wraparound failures](https://www.postgresql.org/docs/current/routine-vacuuming.html#VACUUM-FOR-WRAPAROUND), PostgreSQL documentation diff --git a/checks/xid-wraparound/check.sql b/checks/xid-wraparound/check.sql new file mode 100644 index 0000000..f416fd5 --- /dev/null +++ b/checks/xid-wraparound/check.sql @@ -0,0 +1,29 @@ +-- Other databases in the cluster, as a whole. Those that don't allow connections (template0) +-- are frozen by autovacuum itself. +SELECT d.datname AS subject, + CASE WHEN age(d.datfrozenxid) >= @critical_age THEN 'critical' END AS severity, + d.datname AS database, + NULL::text AS table_name, + age(d.datfrozenxid)::bigint AS xid_age +FROM pg_database AS d +WHERE d.datallowconn + AND d.datname <> current_database() + AND age(d.datfrozenxid) >= @warning_age +UNION ALL +-- Tables in this database. A TOAST table is vacuumed with its table, so it counts toward it. +SELECT * FROM ( + SELECT format('%I.%I', n.nspname, c.relname), + CASE WHEN greatest(age(c.relfrozenxid), age(t.relfrozenxid)) >= @critical_age THEN 'critical' END, + NULL::text, + format('%I.%I', n.nspname, c.relname), + greatest(age(c.relfrozenxid), age(t.relfrozenxid))::bigint + FROM pg_class AS c + JOIN pg_namespace AS n ON n.oid = c.relnamespace + LEFT JOIN pg_class AS t ON t.oid = c.reltoastrelid + WHERE c.relkind IN ('r', 'm') + -- Only the session that owns a temporary table can vacuum it. + AND c.relpersistence <> 't' + AND greatest(age(c.relfrozenxid), age(t.relfrozenxid)) >= @warning_age + ORDER BY 5 DESC + LIMIT 20 +) AS oldest diff --git a/checks/xid-wraparound/fixtures/fires.sql b/checks/xid-wraparound/fixtures/fires.sql new file mode 100644 index 0000000..444cecd --- /dev/null +++ b/checks/xid-wraparound/fixtures/fires.sql @@ -0,0 +1,7 @@ +-- threshold warning_age = 3 +-- Consuming transaction IDs ages every table that hasn't been frozen since. +CREATE TABLE fixture_orders (id int); +SELECT txid_current(); +SELECT txid_current(); +SELECT txid_current(); +SELECT txid_current(); diff --git a/checks/xid-wraparound/fixtures/healthy.sql b/checks/xid-wraparound/fixtures/healthy.sql new file mode 100644 index 0000000..9c4dd49 --- /dev/null +++ b/checks/xid-wraparound/fixtures/healthy.sql @@ -0,0 +1,4 @@ +-- threshold warning_age = 10000 +-- Freshly frozen, every table here is young. The other databases of a new cluster are too. +CREATE TABLE fixture_orders (id int); +VACUUM (FREEZE); From 167aa3caac81695af44077e930dc9058da9ae193 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 18:56:52 -0500 Subject: [PATCH 13/41] feat(checks): add multixact-wraparound --- checks/multixact-wraparound/check.md | 36 +++++++++++++++++++ checks/multixact-wraparound/check.sql | 29 +++++++++++++++ .../multixact-wraparound/fixtures/fires.sql | 10 ++++++ .../multixact-wraparound/fixtures/healthy.sql | 5 +++ 4 files changed, 80 insertions(+) create mode 100644 checks/multixact-wraparound/check.md create mode 100644 checks/multixact-wraparound/check.sql create mode 100644 checks/multixact-wraparound/fixtures/fires.sql create mode 100644 checks/multixact-wraparound/fixtures/healthy.sql diff --git a/checks/multixact-wraparound/check.md b/checks/multixact-wraparound/check.md new file mode 100644 index 0000000..8e22fc4 --- /dev/null +++ b/checks/multixact-wraparound/check.md @@ -0,0 +1,36 @@ +--- +id: multixact-wraparound +title: Multixact ID wraparound +category: ids +severity: warning +min_version: 14 +privileges: [] +thresholds: + warning_age: 500000000 + critical_age: 1500000000 +message: "[Database {database}][Table {table_name}] has used {mxid_age:count} of its 2.1 billion multixact IDs." +fix: "[connect to {database} and scan it to find its oldest tables, then freeze them.][VACUUM (FREEZE, VERBOSE) {table_name};]" +--- + +## What breaks + +When more than one transaction locks the same row, as foreign keys and `SELECT … FOR SHARE` do, Postgres records the group as a multixact, with its own 32-bit ID. Like transaction IDs, multixact IDs wrap around. Vacuum has to freeze old ones, and if a table goes about 2.1 billion multixacts without that, Postgres stops accepting writes. + +Autovacuum starts an anti-wraparound vacuum at 400 million by default (`autovacuum_multixact_freeze_max_age`). Workloads with many foreign keys, or many concurrent row locks, use multixacts fastest. + +This check measures tables in the database it connects to, and every other database in the cluster as a whole. It lists at most the 20 oldest tables. + +## Fix + +Freeze the oldest tables first: + +```sql +VACUUM (FREEZE, VERBOSE) public.accounts; +``` + +As with transaction IDs, a long transaction, a forgotten prepared transaction or an inactive replication slot can hold vacuum back. pgcheckup's other checks look for them. + +## Seen in + +- [Root cause analysis: PostgreSQL MultiXact member exhaustion incidents](https://metronome.com/blog/root-cause-analysis-postgresql-multixact-member-exhaustion-incidents-may-2025), Metronome +- [Multixacts and wraparound](https://www.postgresql.org/docs/current/routine-vacuuming.html#VACUUM-FOR-MULTIXACT-WRAPAROUND), PostgreSQL documentation diff --git a/checks/multixact-wraparound/check.sql b/checks/multixact-wraparound/check.sql new file mode 100644 index 0000000..4faed97 --- /dev/null +++ b/checks/multixact-wraparound/check.sql @@ -0,0 +1,29 @@ +-- Other databases in the cluster, as a whole. Those that don't allow connections (template0) +-- are frozen by autovacuum itself. +SELECT d.datname AS subject, + CASE WHEN mxid_age(d.datminmxid) >= @critical_age THEN 'critical' END AS severity, + d.datname AS database, + NULL::text AS table_name, + mxid_age(d.datminmxid)::bigint AS mxid_age +FROM pg_database AS d +WHERE d.datallowconn + AND d.datname <> current_database() + AND mxid_age(d.datminmxid) >= @warning_age +UNION ALL +-- Tables in this database. A TOAST table is vacuumed with its table, so it counts toward it. +SELECT * FROM ( + SELECT format('%I.%I', n.nspname, c.relname), + CASE WHEN greatest(mxid_age(c.relminmxid), mxid_age(t.relminmxid)) >= @critical_age THEN 'critical' END, + NULL::text, + format('%I.%I', n.nspname, c.relname), + greatest(mxid_age(c.relminmxid), mxid_age(t.relminmxid))::bigint + FROM pg_class AS c + JOIN pg_namespace AS n ON n.oid = c.relnamespace + LEFT JOIN pg_class AS t ON t.oid = c.reltoastrelid + WHERE c.relkind IN ('r', 'm') + -- Only the session that owns a temporary table can vacuum it. + AND c.relpersistence <> 't' + AND greatest(mxid_age(c.relminmxid), mxid_age(t.relminmxid)) >= @warning_age + ORDER BY 5 DESC + LIMIT 20 +) AS oldest diff --git a/checks/multixact-wraparound/fixtures/fires.sql b/checks/multixact-wraparound/fixtures/fires.sql new file mode 100644 index 0000000..04396ae --- /dev/null +++ b/checks/multixact-wraparound/fixtures/fires.sql @@ -0,0 +1,10 @@ +-- threshold warning_age = 1 +-- Locking a row, then updating it in a savepoint, makes one multixact, which ages every table +-- that hasn't been frozen since. +CREATE TABLE fixture_accounts (id int PRIMARY KEY, balance int); +INSERT INTO fixture_accounts VALUES (1, 0); +BEGIN; +SELECT * FROM fixture_accounts FOR SHARE; +SAVEPOINT fixture; +UPDATE fixture_accounts SET balance = 1; +COMMIT; diff --git a/checks/multixact-wraparound/fixtures/healthy.sql b/checks/multixact-wraparound/fixtures/healthy.sql new file mode 100644 index 0000000..fd4b522 --- /dev/null +++ b/checks/multixact-wraparound/fixtures/healthy.sql @@ -0,0 +1,5 @@ +-- threshold warning_age = 1 +-- With no multixact made, nothing has aged, even at the lowest threshold. +CREATE TABLE fixture_accounts (id int PRIMARY KEY, balance int); +INSERT INTO fixture_accounts VALUES (1, 0); +UPDATE fixture_accounts SET balance = 1; From 2f8f778387942c12cfc65222cddbde3f290924cb Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 18:57:49 -0500 Subject: [PATCH 14/41] feat(checks): add long-transaction --- checks/long-transaction/check.md | 39 ++++++++++++++++++++ checks/long-transaction/check.sql | 11 ++++++ checks/long-transaction/fixtures/fires.sql | 5 +++ checks/long-transaction/fixtures/healthy.sql | 6 +++ 4 files changed, 61 insertions(+) create mode 100644 checks/long-transaction/check.md create mode 100644 checks/long-transaction/check.sql create mode 100644 checks/long-transaction/fixtures/fires.sql create mode 100644 checks/long-transaction/fixtures/healthy.sql diff --git a/checks/long-transaction/check.md b/checks/long-transaction/check.md new file mode 100644 index 0000000..04e2e0b --- /dev/null +++ b/checks/long-transaction/check.md @@ -0,0 +1,39 @@ +--- +id: long-transaction +title: Long or idle transaction +category: cleanup +severity: warning +min_version: 14 +privileges: [pg_read_all_stats] +thresholds: + min_duration: 1h + min_idle: 10min +message: "Session {subject} ({role_name} on {database}) has had a transaction open for {open_for}[ and has been idle in it for {idle_for}]." +fix: | + if the session is stuck or abandoned, end it: + SELECT pg_terminate_backend({subject}); +--- + +## What breaks + +While a transaction is open, vacuum can't remove any row that the transaction might still see, anywhere in the database. Tables and indexes bloat, queries slow down, and transaction IDs can't be frozen, which leads toward wraparound. The transaction's locks also stay held, and a schema change that queues behind them blocks the traffic behind it. + +A session that is idle inside a transaction is the usual culprit: an application that began a transaction and never committed, or a console left open. It does nothing, but holds everything back. + +Backups with `pg_dump` also keep a transaction open for their whole run. That is expected, but it has the same effect on a busy database. + +## Fix + +Find out what the session is and whether it is still needed. If it is stuck or abandoned, end it: + +```sql +SELECT pg_terminate_backend(4127); +``` + +To stop sessions from idling in a transaction for good, set `idle_in_transaction_session_timeout` (see `idle-transaction-timeout`). + +## Seen in + +- [Post-mortem: service disruption on January 21–22, 2020](https://www.figma.com/blog/post-mortem-service-disruption-on-january-21-22-2020/), Figma +- [How we upgraded our 4 TB main application Postgres database](https://retool.com/blog/how-we-upgraded-postgresql-database), Retool +- [Zero-downtime Postgres migrations: the hard parts](https://gocardless.com/blog/zero-downtime-postgres-migrations-the-hard-parts/), GoCardless diff --git a/checks/long-transaction/check.sql b/checks/long-transaction/check.sql new file mode 100644 index 0000000..3d7f562 --- /dev/null +++ b/checks/long-transaction/check.sql @@ -0,0 +1,11 @@ +SELECT a.pid::text AS subject, + a.usename AS role_name, + a.datname AS database, + now() - a.xact_start AS open_for, + CASE WHEN a.state LIKE 'idle in transaction%' THEN now() - a.state_change END AS idle_for +FROM pg_stat_activity AS a +WHERE a.backend_type = 'client backend' + AND a.pid <> pg_backend_pid() + AND (a.xact_start < now() - @min_duration + OR (a.state LIKE 'idle in transaction%' AND a.state_change < now() - @min_idle)) +ORDER BY a.xact_start diff --git a/checks/long-transaction/fixtures/fires.sql b/checks/long-transaction/fixtures/fires.sql new file mode 100644 index 0000000..bd5ae3d --- /dev/null +++ b/checks/long-transaction/fixtures/fires.sql @@ -0,0 +1,5 @@ +-- threshold min_duration = 0s +-- threshold min_idle = 0s +-- The fixture's own session keeps this transaction open while the check runs. +BEGIN; +SELECT txid_current(); diff --git a/checks/long-transaction/fixtures/healthy.sql b/checks/long-transaction/fixtures/healthy.sql new file mode 100644 index 0000000..960b7e0 --- /dev/null +++ b/checks/long-transaction/fixtures/healthy.sql @@ -0,0 +1,6 @@ +-- threshold min_duration = 0s +-- threshold min_idle = 0s +-- A transaction that has already committed holds nothing back. +BEGIN; +SELECT txid_current(); +COMMIT; From 417f0d567a2d5e146c0fc533ea1e35e887ebe27a Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 18:58:38 -0500 Subject: [PATCH 15/41] feat(checks): add idle-transaction-timeout --- checks/idle-transaction-timeout/check.md | 39 +++++++++++++++++++ checks/idle-transaction-timeout/check.sql | 11 ++++++ .../fixtures/fires.sql | 2 + .../fixtures/healthy.sql | 2 + 4 files changed, 54 insertions(+) create mode 100644 checks/idle-transaction-timeout/check.md create mode 100644 checks/idle-transaction-timeout/check.sql create mode 100644 checks/idle-transaction-timeout/fixtures/fires.sql create mode 100644 checks/idle-transaction-timeout/fixtures/healthy.sql diff --git a/checks/idle-transaction-timeout/check.md b/checks/idle-transaction-timeout/check.md new file mode 100644 index 0000000..67b4306 --- /dev/null +++ b/checks/idle-transaction-timeout/check.md @@ -0,0 +1,39 @@ +--- +id: idle-transaction-timeout +title: No timeout for idle transactions +category: cleanup +severity: info +min_version: 14 +privileges: [] +message: "{subject} is not set, so a session left idle in a transaction holds back vacuum until someone ends it." +fix: | + set it for the whole server, or only for your application's role: + ALTER SYSTEM SET idle_in_transaction_session_timeout = '10min'; + SELECT pg_reload_conf(); +--- + +## What breaks + +Nothing yet. But without `idle_in_transaction_session_timeout`, a session that opens a transaction and then waits, because of an application bug, a crashed worker or a console left open, keeps it open indefinitely. While it does, vacuum can't clean up after any transaction since, and its locks stay held. `long-transaction` reports such sessions when they happen; this timeout ends them on its own. + +Postgres ships with the timeout off, so most databases get this finding. That is why it is `info`. + +The check passes when the timeout, or `transaction_timeout` on Postgres 17 and later, is set for the server, a database or a role. + +## Fix + +Pick a limit longer than any transaction your application means to leave idle, and set it for the server or for the application's role: + +```sql +ALTER SYSTEM SET idle_in_transaction_session_timeout = '10min'; +SELECT pg_reload_conf(); + +ALTER ROLE app SET idle_in_transaction_session_timeout = '10min'; +``` + +On a managed provider, set it in the parameter group or its equivalent. + +## Seen in + +- [A production story: downtime caused by Postgres transaction ID wraparound](https://www.sqlservercentral.com/articles/i-too-have-a-production-story-a-downtime-caused-by-postgres-transaction-id-wraparound-problem), SQLServerCentral +- [idle_in_transaction_session_timeout](https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-IDLE-IN-TRANSACTION-SESSION-TIMEOUT), PostgreSQL documentation diff --git a/checks/idle-transaction-timeout/check.sql b/checks/idle-transaction-timeout/check.sql new file mode 100644 index 0000000..5a2c1a6 --- /dev/null +++ b/checks/idle-transaction-timeout/check.sql @@ -0,0 +1,11 @@ +-- The session's own value covers the server and anything set for pgcheckup's role and database; +-- pg_db_role_setting covers what is set for the application's roles and databases. +SELECT 'idle_in_transaction_session_timeout' AS subject +WHERE current_setting('idle_in_transaction_session_timeout') = '0' + -- transaction_timeout arrived in Postgres 17, and ends idle transactions too. + AND coalesce(current_setting('transaction_timeout', true), '0') = '0' + AND NOT EXISTS ( + SELECT FROM pg_db_role_setting AS s, unnest(s.setconfig) AS c(setting) + WHERE c.setting ~ '^(idle_in_transaction_session_timeout|transaction_timeout)=' + AND c.setting !~ '=0$' + ) diff --git a/checks/idle-transaction-timeout/fixtures/fires.sql b/checks/idle-transaction-timeout/fixtures/fires.sql new file mode 100644 index 0000000..c20e9a3 --- /dev/null +++ b/checks/idle-transaction-timeout/fixtures/fires.sql @@ -0,0 +1,2 @@ +-- Stock Postgres leaves the timeout off. +SELECT 1; diff --git a/checks/idle-transaction-timeout/fixtures/healthy.sql b/checks/idle-transaction-timeout/fixtures/healthy.sql new file mode 100644 index 0000000..d5c2059 --- /dev/null +++ b/checks/idle-transaction-timeout/fixtures/healthy.sql @@ -0,0 +1,2 @@ +-- Set for the database, it applies to every new session there. +ALTER DATABASE app SET idle_in_transaction_session_timeout = '10min'; From 3d8127f7f0dc22192fd14456724c1d2885c3c78f Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 18:59:38 -0500 Subject: [PATCH 16/41] feat(checks): add prepared-transaction-orphaned --- checks/prepared-transaction-orphaned/check.md | 35 +++++++++++++++++++ .../prepared-transaction-orphaned/check.sql | 7 ++++ .../fixtures/fires.sql | 6 ++++ .../fixtures/healthy.sql | 8 +++++ 4 files changed, 56 insertions(+) create mode 100644 checks/prepared-transaction-orphaned/check.md create mode 100644 checks/prepared-transaction-orphaned/check.sql create mode 100644 checks/prepared-transaction-orphaned/fixtures/fires.sql create mode 100644 checks/prepared-transaction-orphaned/fixtures/healthy.sql diff --git a/checks/prepared-transaction-orphaned/check.md b/checks/prepared-transaction-orphaned/check.md new file mode 100644 index 0000000..542fae5 --- /dev/null +++ b/checks/prepared-transaction-orphaned/check.md @@ -0,0 +1,35 @@ +--- +id: prepared-transaction-orphaned +title: Orphaned prepared transaction +category: cleanup +severity: warning +min_version: 14 +privileges: [] +thresholds: + min_age: 5min +message: "Prepared transaction {subject} in {database} has waited {waiting_for} for a COMMIT PREPARED or ROLLBACK PREPARED, holding its locks and holding back vacuum." +fix: | + if the coordinator that prepared it is gone, finish it by hand in {database}: + COMMIT PREPARED {gid_literal}; -- or ROLLBACK PREPARED {gid_literal}; +--- + +## What breaks + +A prepared transaction is the first half of a two-phase commit. It survives disconnects and restarts until someone commits or rolls it back. If the coordinator that prepared it crashes or forgets it, the transaction stays forever: its row locks keep blocking writes, and vacuum can't clean up after it, which leads toward transaction ID wraparound. + +It doesn't show up in `pg_stat_activity`, because no session holds it, so it is easy to miss. + +Two-phase commit is off unless `max_prepared_transactions` is above zero, so on most servers this check finds nothing. + +## Fix + +Find out from the coordinator (a transaction manager, a message queue, an application server) whether the transaction should commit or roll back, then finish it in its database: + +```sql +COMMIT PREPARED 'fixture'; +ROLLBACK PREPARED 'fixture'; +``` + +## Seen in + +- [PREPARE TRANSACTION](https://www.postgresql.org/docs/current/sql-prepare-transaction.html), PostgreSQL documentation diff --git a/checks/prepared-transaction-orphaned/check.sql b/checks/prepared-transaction-orphaned/check.sql new file mode 100644 index 0000000..bb2b1bd --- /dev/null +++ b/checks/prepared-transaction-orphaned/check.sql @@ -0,0 +1,7 @@ +SELECT p.gid AS subject, + p.database, + quote_literal(p.gid) AS gid_literal, + now() - p.prepared AS waiting_for +FROM pg_prepared_xacts AS p +WHERE p.prepared < now() - @min_age +ORDER BY p.prepared diff --git a/checks/prepared-transaction-orphaned/fixtures/fires.sql b/checks/prepared-transaction-orphaned/fixtures/fires.sql new file mode 100644 index 0000000..cde2926 --- /dev/null +++ b/checks/prepared-transaction-orphaned/fixtures/fires.sql @@ -0,0 +1,6 @@ +-- server max_prepared_transactions = 5 +-- threshold min_age = 0s +CREATE TABLE fixture_payments (id int); +BEGIN; +INSERT INTO fixture_payments VALUES (1); +PREPARE TRANSACTION 'fixture'; diff --git a/checks/prepared-transaction-orphaned/fixtures/healthy.sql b/checks/prepared-transaction-orphaned/fixtures/healthy.sql new file mode 100644 index 0000000..f0623c5 --- /dev/null +++ b/checks/prepared-transaction-orphaned/fixtures/healthy.sql @@ -0,0 +1,8 @@ +-- server max_prepared_transactions = 5 +-- threshold min_age = 0s +-- A prepared transaction that was committed is gone. +CREATE TABLE fixture_payments (id int); +BEGIN; +INSERT INTO fixture_payments VALUES (1); +PREPARE TRANSACTION 'fixture'; +COMMIT PREPARED 'fixture'; From 1b889d62295b4bd560f710fc7a6e35bf23aec356 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:00:51 -0500 Subject: [PATCH 17/41] feat(checks): report replication slots that hold back vacuum --- checks/replication-slot-inactive/check.md | 6 ++++-- checks/replication-slot-inactive/check.sql | 10 ++++++---- checks/replication-slot-inactive/fixtures/fires.sql | 13 +++++++++---- .../replication-slot-inactive/fixtures/healthy.sql | 4 +++- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/checks/replication-slot-inactive/check.md b/checks/replication-slot-inactive/check.md index 2c013b6..d44dfa2 100644 --- a/checks/replication-slot-inactive/check.md +++ b/checks/replication-slot-inactive/check.md @@ -7,7 +7,8 @@ min_version: 14 privileges: [] thresholds: min_retained_wal: 1GB -message: Slot {subject} has been inactive[ for {inactive_for}] and is holding {retained_wal:bytes} of WAL. + min_xmin_age: 100000000 +message: "Slot {subject} has been inactive[ for {inactive_for}][ and is holding {retained_wal:bytes} of WAL].[ Vacuum can't clean up after the last {xmin_age:count} transactions while it exists.]" fix: | restart its consumer, or drop the slot: SELECT pg_drop_replication_slot({slot_literal}); @@ -17,7 +18,7 @@ fix: | A replication slot makes Postgres keep every WAL segment that its consumer hasn't confirmed. When the consumer stops, the slot keeps WAL for as long as it exists. Typical consumers are a replica that was removed, a paused CDC connector such as Debezium, or a subscription dropped without its slot. `pg_wal` grows until the disk is full, and then Postgres stops accepting writes. -A logical slot also holds back `catalog_xmin`, so vacuum can't clean up the system catalogs while the slot waits. +A slot can also hold back vacuum while keeping little WAL. A logical slot pins `catalog_xmin`, and a physical slot used with `hot_standby_feedback` pins `xmin`. Vacuum can't clean up after any transaction since, which bloats tables and leads toward transaction ID wraparound. The check reports that from 100 million transactions (`min_xmin_age`). ## Fix @@ -31,5 +32,6 @@ Then cap the WAL any slot can keep with `max_slot_wal_keep_size`. A slot that pa ## Seen in +- [Postgres almost-outage postmortem: the hidden dangers of replication slots and autovacuum](https://dev.to/sasikumart/postgres-almost-outage-postmortem-the-hidden-dangers-of-replication-slots-and-autovacuum-2nem), DEV Community - [The Insatiable Postgres Replication Slot](https://www.morling.dev/blog/insatiable-postgres-replication-slot/), Gunnar Morling: an inactive slot on an idle Amazon RDS database kept growing its WAL. - [Replication slots](https://www.postgresql.org/docs/current/warm-standby.html#STREAMING-REPLICATION-SLOTS), PostgreSQL documentation: "replication slots can cause the server to retain so many WAL segments that they fill up the space allocated for pg_wal." diff --git a/checks/replication-slot-inactive/check.sql b/checks/replication-slot-inactive/check.sql index 070362f..ce4223c 100644 --- a/checks/replication-slot-inactive/check.sql +++ b/checks/replication-slot-inactive/check.sql @@ -3,13 +3,15 @@ SELECT s.slot_name AS subject, -- inactive_since arrived in Postgres 17. Reading it through to_jsonb keeps one query -- for every supported version, and gives NULL before 17. now() - (to_jsonb(s) ->> 'inactive_since')::timestamptz AS inactive_for, - w.retained_wal + w.retained_wal, + CASE WHEN w.xmin_age >= @min_xmin_age THEN w.xmin_age END AS xmin_age FROM pg_replication_slots AS s CROSS JOIN LATERAL ( -- On a standby, pg_current_wal_lsn() raises an error; the replay position is its equivalent. SELECT pg_wal_lsn_diff( CASE WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn() ELSE pg_current_wal_lsn() END, - s.restart_lsn)::bigint AS retained_wal + s.restart_lsn)::bigint AS retained_wal, + greatest(age(s.xmin), age(s.catalog_xmin))::bigint AS xmin_age ) AS w WHERE NOT s.active -- A lost slot has already been invalidated and holds no WAL. @@ -17,5 +19,5 @@ WHERE NOT s.active -- On a standby, a slot synced from the primary (Postgres 17 and later) always looks inactive, -- and can't be dropped there. Its consumer is on the primary, where this check covers it. AND NOT coalesce((to_jsonb(s) ->> 'synced')::boolean, false) - AND w.retained_wal >= @min_retained_wal -ORDER BY w.retained_wal DESC + AND (w.retained_wal >= @min_retained_wal OR w.xmin_age >= @min_xmin_age) +ORDER BY w.retained_wal DESC NULLS LAST diff --git a/checks/replication-slot-inactive/fixtures/fires.sql b/checks/replication-slot-inactive/fixtures/fires.sql index 0bb7edc..723ee32 100644 --- a/checks/replication-slot-inactive/fixtures/fires.sql +++ b/checks/replication-slot-inactive/fixtures/fires.sql @@ -1,4 +1,9 @@ --- threshold min_retained_wal = 0B --- A physical slot that reserves WAL from the start and never gets a consumer. -SELECT pg_create_physical_replication_slot('fixture_slot', true); -CREATE TABLE fixture_wal AS SELECT g FROM generate_series(1, 1000) AS g; +-- server wal_level = logical +-- threshold min_retained_wal = 1TB +-- threshold min_xmin_age = 1 +-- A logical slot pins catalog_xmin from the moment it is created, and ages as transactions +-- go by. It keeps little WAL, so only the xmin half of the check fires. The WAL half is covered +-- by the scan tests, which fill a slot with more than 1 GB. +SELECT pg_create_logical_replication_slot('fixture_slot', 'pgoutput'); +SELECT txid_current(); +SELECT txid_current(); diff --git a/checks/replication-slot-inactive/fixtures/healthy.sql b/checks/replication-slot-inactive/fixtures/healthy.sql index 74b0433..6de479e 100644 --- a/checks/replication-slot-inactive/fixtures/healthy.sql +++ b/checks/replication-slot-inactive/fixtures/healthy.sql @@ -1,4 +1,6 @@ -- threshold min_retained_wal = 0B --- A slot that has never reserved WAL holds none back, so even a zero threshold stays quiet. +-- threshold min_xmin_age = 1 +-- A slot that has never reserved WAL holds none back, and pins no xmin, so even the lowest +-- thresholds stay quiet. SELECT pg_create_physical_replication_slot('fixture_slot'); CREATE TABLE fixture_wal AS SELECT g FROM generate_series(1, 1000) AS g; From c0ff1295c37c7ecfdfc1cd6af216e2e42fb92a8a Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:02:19 -0500 Subject: [PATCH 18/41] feat(checks): add replication-slot-unbounded --- checks/replication-slot-unbounded/check.md | 37 +++++++++++++++++++ checks/replication-slot-unbounded/check.sql | 5 +++ .../fixtures/fires.sql | 2 + .../fixtures/healthy.sql | 2 + 4 files changed, 46 insertions(+) create mode 100644 checks/replication-slot-unbounded/check.md create mode 100644 checks/replication-slot-unbounded/check.sql create mode 100644 checks/replication-slot-unbounded/fixtures/fires.sql create mode 100644 checks/replication-slot-unbounded/fixtures/healthy.sql diff --git a/checks/replication-slot-unbounded/check.md b/checks/replication-slot-unbounded/check.md new file mode 100644 index 0000000..8144578 --- /dev/null +++ b/checks/replication-slot-unbounded/check.md @@ -0,0 +1,37 @@ +--- +id: replication-slot-unbounded +title: Replication slots without a WAL limit +category: wal +severity: warning +min_version: 14 +privileges: [] +message: "{subject} is -1 and this server has replication slots, so one stuck slot can keep WAL until the disk fills." +fix: | + cap it below the free space on the WAL disk: + ALTER SYSTEM SET max_slot_wal_keep_size = '50GB'; + SELECT pg_reload_conf(); +--- + +## What breaks + +A replication slot keeps every WAL segment its consumer hasn't confirmed. With `max_slot_wal_keep_size = -1`, the default, there is no limit: one consumer that stops, a CDC connector or a replica, makes `pg_wal` grow until the disk is full and Postgres stops accepting writes. `replication-slot-inactive` reports such a slot once it has stopped; this limit keeps it from taking the server down. + +With a limit, a slot that falls too far behind is invalidated instead. Its consumer then has to resynchronize, which is much cheaper than an outage. + +The check only fires when the server has at least one slot, because without slots the setting doesn't matter. On Postgres 18, `idle_replication_slot_timeout` also counts as a limit. + +## Fix + +Choose a size below the free space on the disk that holds `pg_wal`, with room to spare: + +```sql +ALTER SYSTEM SET max_slot_wal_keep_size = '50GB'; +SELECT pg_reload_conf(); +``` + +On a managed provider, set it in the parameter group or its equivalent. + +## Seen in + +- [Postgres almost-outage postmortem: the hidden dangers of replication slots and autovacuum](https://dev.to/sasikumart/postgres-almost-outage-postmortem-the-hidden-dangers-of-replication-slots-and-autovacuum-2nem), DEV Community +- [max_slot_wal_keep_size](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-SLOT-WAL-KEEP-SIZE), PostgreSQL documentation diff --git a/checks/replication-slot-unbounded/check.sql b/checks/replication-slot-unbounded/check.sql new file mode 100644 index 0000000..b764892 --- /dev/null +++ b/checks/replication-slot-unbounded/check.sql @@ -0,0 +1,5 @@ +SELECT 'max_slot_wal_keep_size' AS subject +WHERE current_setting('max_slot_wal_keep_size') = '-1' + -- idle_replication_slot_timeout arrived in Postgres 18, and caps idle slots too. + AND coalesce(current_setting('idle_replication_slot_timeout', true), '0') = '0' + AND EXISTS (SELECT FROM pg_replication_slots) diff --git a/checks/replication-slot-unbounded/fixtures/fires.sql b/checks/replication-slot-unbounded/fixtures/fires.sql new file mode 100644 index 0000000..840350f --- /dev/null +++ b/checks/replication-slot-unbounded/fixtures/fires.sql @@ -0,0 +1,2 @@ +-- Stock Postgres has no limit, and this slot makes it matter. +SELECT pg_create_physical_replication_slot('fixture_slot'); diff --git a/checks/replication-slot-unbounded/fixtures/healthy.sql b/checks/replication-slot-unbounded/fixtures/healthy.sql new file mode 100644 index 0000000..e53cf98 --- /dev/null +++ b/checks/replication-slot-unbounded/fixtures/healthy.sql @@ -0,0 +1,2 @@ +-- server max_slot_wal_keep_size = 10GB +SELECT pg_create_physical_replication_slot('fixture_slot'); From e0b6c2e6fe7fccaa88ae6cac3b8417bad7ca1d2e Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:04:09 -0500 Subject: [PATCH 19/41] feat(checks): add wal-archiving-failing --- checks/wal-archiving-failing/check.md | 34 +++++++++++++++++++ checks/wal-archiving-failing/check.sql | 34 +++++++++++++++++++ .../wal-archiving-failing/fixtures/fires.sql | 15 ++++++++ .../fixtures/healthy.sql | 14 ++++++++ 4 files changed, 97 insertions(+) create mode 100644 checks/wal-archiving-failing/check.md create mode 100644 checks/wal-archiving-failing/check.sql create mode 100644 checks/wal-archiving-failing/fixtures/fires.sql create mode 100644 checks/wal-archiving-failing/fixtures/healthy.sql diff --git a/checks/wal-archiving-failing/check.md b/checks/wal-archiving-failing/check.md new file mode 100644 index 0000000..7401974 --- /dev/null +++ b/checks/wal-archiving-failing/check.md @@ -0,0 +1,34 @@ +--- +id: wal-archiving-failing +title: WAL archiving failing +category: wal +severity: warning +min_version: 14 +privileges: [pg_monitor] +skip_on: [rds, aurora, cloudsql, azure, supabase, neon] +thresholds: + max_ready_age: 1h +message: "[archive_mode is {unset_mode}, but no archive_command is set, so WAL piles up in pg_wal and none of it is archived.][WAL archiving has been failing for {failing_for}, so point-in-time recovery has a gap and WAL piles up in pg_wal.][The oldest of {ready_count:count} WAL segments has waited {waiting_for} to be archived, so the archiver seems stuck.]" +fix: "read the server log for the archiver's errors. Set archive_command if it is empty, fix it or its destination if it fails or hangs (a full or unreachable destination is typical), or turn archive_mode off if you don't archive." +--- + +## What breaks + +With `archive_mode` on, Postgres keeps each WAL segment until `archive_command` has copied it somewhere safe. If archiving fails or stops, two things go wrong: + +- Point-in-time recovery has a gap from the last segment archived. A restore can't get past it. +- The segments that wait pile up in `pg_wal` until the disk is full, and Postgres stops accepting writes. + +The check reports three cases: `archive_mode` on without a command, a command that keeps failing, and an archiver that makes no progress for an hour (`max_ready_age`) without logging a failure. It never prints `archive_command`, which can hold credentials. + +Managed providers archive WAL themselves, so the check is skipped there. + +## Fix + +Read the server log for the command's error, and fix the command or its destination. When the command succeeds again, Postgres archives the backlog on its own. If you don't need archiving, turn `archive_mode` off, which needs a restart. + +## Seen in + +- [PostgreSQL archiver failure](https://vsevolod.net/postgresql-archiver-failure/), Vsevolod +- [Postgres is out of disk and how to recover: the dos and don'ts](https://www.crunchydata.com/blog/postgres-is-out-of-disk-and-how-to-recover-the-dos-and-donts), Crunchy Data +- [Setting up WAL archiving](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-ARCHIVING-WAL), PostgreSQL documentation diff --git a/checks/wal-archiving-failing/check.sql b/checks/wal-archiving-failing/check.sql new file mode 100644 index 0000000..5042718 --- /dev/null +++ b/checks/wal-archiving-failing/check.sql @@ -0,0 +1,34 @@ +WITH settings AS ( + SELECT current_setting('archive_mode') AS archive_mode, + -- archive_library arrived in Postgres 15. Neither value is ever returned: either can + -- hold credentials. + current_setting('archive_command') = '' + AND coalesce(current_setting('archive_library', true), '') = '' AS unset +), +archiver AS ( + SELECT coalesce(a.last_failed_time > coalesce(a.last_archived_time, '-infinity'), false) AS failing, + coalesce(a.last_archived_time, a.stats_reset, pg_postmaster_start_time()) AS good_since + FROM pg_stat_archiver AS a +), +ready AS ( + SELECT count(*) AS segments, min(modification) AS oldest + FROM pg_ls_archive_statusdir() + WHERE name LIKE '%.ready' +) +SELECT 'archive_command' AS subject, + s.archive_mode AS unset_mode, + NULL::interval AS failing_for, + NULL::bigint AS ready_count, + NULL::interval AS waiting_for +FROM settings AS s +WHERE s.archive_mode <> 'off' AND s.unset +UNION ALL +SELECT 'archiver', NULL, now() - a.good_since, NULL, NULL +FROM settings AS s, archiver AS a +WHERE s.archive_mode <> 'off' AND NOT s.unset AND a.failing +UNION ALL +-- A hung archiver logs no failure, so only the age of the waiting segments shows it. +SELECT 'archive_status', NULL, NULL, r.segments, now() - r.oldest +FROM settings AS s, archiver AS a, ready AS r +WHERE s.archive_mode <> 'off' AND NOT s.unset AND NOT a.failing + AND r.oldest < now() - @max_ready_age diff --git a/checks/wal-archiving-failing/fixtures/fires.sql b/checks/wal-archiving-failing/fixtures/fires.sql new file mode 100644 index 0000000..5885979 --- /dev/null +++ b/checks/wal-archiving-failing/fixtures/fires.sql @@ -0,0 +1,15 @@ +-- server archive_mode = on +-- server archive_command = false +-- `false` always fails. Switching WAL gives the archiver a segment to try, then this waits +-- until the archiver has reported its first failure. +CREATE TABLE fixture_wal AS SELECT g FROM generate_series(1, 1000) AS g; +SELECT pg_switch_wal(); +DO $$ +BEGIN + FOR i IN 1..100 LOOP + PERFORM pg_stat_clear_snapshot(); + EXIT WHEN (SELECT failed_count FROM pg_stat_archiver) > 0; + PERFORM pg_sleep(0.1); + END LOOP; +END +$$; diff --git a/checks/wal-archiving-failing/fixtures/healthy.sql b/checks/wal-archiving-failing/fixtures/healthy.sql new file mode 100644 index 0000000..393c525 --- /dev/null +++ b/checks/wal-archiving-failing/fixtures/healthy.sql @@ -0,0 +1,14 @@ +-- server archive_mode = on +-- server archive_command = true +-- `true` always succeeds. This waits until the archiver has archived the switched segment. +CREATE TABLE fixture_wal AS SELECT g FROM generate_series(1, 1000) AS g; +SELECT pg_switch_wal(); +DO $$ +BEGIN + FOR i IN 1..100 LOOP + PERFORM pg_stat_clear_snapshot(); + EXIT WHEN (SELECT archived_count FROM pg_stat_archiver) > 0; + PERFORM pg_sleep(0.1); + END LOOP; +END +$$; From 4108a3d4fee81243b609131d10f8faa2a956c7ea Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:05:22 -0500 Subject: [PATCH 20/41] feat(checks): add connection-saturation --- checks/connection-saturation/check.md | 38 +++++++++++++++++++ checks/connection-saturation/check.sql | 15 ++++++++ .../connection-saturation/fixtures/fires.sql | 5 +++ .../fixtures/healthy.sql | 2 + 4 files changed, 60 insertions(+) create mode 100644 checks/connection-saturation/check.md create mode 100644 checks/connection-saturation/check.sql create mode 100644 checks/connection-saturation/fixtures/fires.sql create mode 100644 checks/connection-saturation/fixtures/healthy.sql diff --git a/checks/connection-saturation/check.md b/checks/connection-saturation/check.md new file mode 100644 index 0000000..8c05088 --- /dev/null +++ b/checks/connection-saturation/check.md @@ -0,0 +1,38 @@ +--- +id: connection-saturation +title: Connections running out +category: capacity +severity: warning +min_version: 14 +privileges: [pg_read_all_stats] +thresholds: + warning_ratio: 0.8 + critical_ratio: 0.95 +message: "{used:count} of the {available:count} connections available to applications are in use. When they run out, Postgres refuses new connections." +fix: | + see who holds them: + SELECT usename, state, count(*) FROM pg_stat_activity GROUP BY 1, 2 ORDER BY 3 DESC; + -- then close idle sessions, shrink the pools, or put a pooler such as PgBouncer in front. +--- + +## What breaks + +Postgres accepts at most `max_connections` sessions, and keeps a few of them back for superusers (`superuser_reserved_connections`, and `reserved_connections` from Postgres 16). When applications have used the rest, every new connection fails with "too many clients already". Deploys, cron jobs and autoscaling are the usual moments: each new process opens its own pool. + +Raising `max_connections` helps less than it seems, because each connection costs memory and the server slows down with too many active at once. A pooler such as PgBouncer lets many clients share a few connections. + +## Fix + +Find out which roles and states hold the connections: + +```sql +SELECT usename, state, count(*) FROM pg_stat_activity GROUP BY 1, 2 ORDER BY 3 DESC; +``` + +Many `idle` sessions point to pools that are too large for the number of processes. Put a pooler in front, or shrink each pool. + +## Seen in + +- [Database outage: too many connections](https://gitlab.com/gitlab-com/gl-infra/production/-/issues/122), GitLab +- [PostgreSQL connections exhausted](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/8292), GitLab +- [How to increase the max connections in Postgres](https://dba.stackexchange.com/questions/69438), DBA Stack Exchange diff --git a/checks/connection-saturation/check.sql b/checks/connection-saturation/check.sql new file mode 100644 index 0000000..10ab699 --- /dev/null +++ b/checks/connection-saturation/check.sql @@ -0,0 +1,15 @@ +WITH available AS ( + -- reserved_connections arrived in Postgres 16. + SELECT current_setting('max_connections')::int + - current_setting('superuser_reserved_connections')::int + - coalesce(current_setting('reserved_connections', true)::int, 0) AS slots +), +used AS ( + SELECT count(*) AS sessions FROM pg_stat_activity WHERE backend_type = 'client backend' +) +SELECT 'max_connections' AS subject, + CASE WHEN u.sessions >= a.slots * @critical_ratio THEN 'critical' END AS severity, + u.sessions AS used, + a.slots AS available +FROM available AS a, used AS u +WHERE u.sessions >= a.slots * @warning_ratio diff --git a/checks/connection-saturation/fixtures/fires.sql b/checks/connection-saturation/fixtures/fires.sql new file mode 100644 index 0000000..a0522c1 --- /dev/null +++ b/checks/connection-saturation/fixtures/fires.sql @@ -0,0 +1,5 @@ +-- server max_connections = 6 +-- threshold warning_ratio = 0.6 +-- Three slots are left for applications. This session and the check's make two, which only +-- reaches the threshold if the check can see this session. +SELECT 1; diff --git a/checks/connection-saturation/fixtures/healthy.sql b/checks/connection-saturation/fixtures/healthy.sql new file mode 100644 index 0000000..1117d7c --- /dev/null +++ b/checks/connection-saturation/fixtures/healthy.sql @@ -0,0 +1,2 @@ +-- A handful of sessions on stock Postgres, which allows 100. +SELECT 1; From 42d1612fae4c779cbb0924ba7763addbc9c18f04 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:06:20 -0500 Subject: [PATCH 21/41] feat(checks): add dangerous-settings --- checks/dangerous-settings/check.md | 40 +++++++++++++++++++ checks/dangerous-settings/check.sql | 7 ++++ checks/dangerous-settings/fixtures/fires.sql | 2 + .../dangerous-settings/fixtures/healthy.sql | 2 + 4 files changed, 51 insertions(+) create mode 100644 checks/dangerous-settings/check.md create mode 100644 checks/dangerous-settings/check.sql create mode 100644 checks/dangerous-settings/fixtures/fires.sql create mode 100644 checks/dangerous-settings/fixtures/healthy.sql diff --git a/checks/dangerous-settings/check.md b/checks/dangerous-settings/check.md new file mode 100644 index 0000000..a1d8579 --- /dev/null +++ b/checks/dangerous-settings/check.md @@ -0,0 +1,40 @@ +--- +id: dangerous-settings +title: Settings that risk data +category: capacity +severity: critical +min_version: 14 +privileges: [] +message: "{subject} is {setting}. Postgres then can't protect your data from a crash or a damaged page." +fix: | + turn it back: + ALTER SYSTEM SET {subject} = {safe_value}; + SELECT pg_reload_conf(); +--- + +## What breaks + +Three settings trade Postgres's protection of your data for speed or convenience: + +- `fsync = off`: Postgres doesn't wait for writes to reach the disk. After a crash or power loss, the database can be corrupt, not just missing recent commits. +- `full_page_writes = off`: after a crash, a page that was half written can't be repaired from WAL, which corrupts it. +- `zero_damaged_pages = on`: Postgres silently replaces damaged pages with empty ones, which destroys the rows in them. It is meant for a one-off rescue, never for normal running. + +They are sometimes turned off to speed up a bulk load or a test server, and left that way. + +## Fix + +Turn the setting back and reload: + +```sql +ALTER SYSTEM SET fsync = on; +SELECT pg_reload_conf(); +``` + +If the server ran with `fsync` or `full_page_writes` off through a crash, check it for corruption, for example with `pg_amcheck`. + +## Seen in + +- [fsync](https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-FSYNC), PostgreSQL documentation +- [full_page_writes](https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-FULL-PAGE-WRITES), PostgreSQL documentation +- [zero_damaged_pages](https://www.postgresql.org/docs/current/runtime-config-developer.html#GUC-ZERO-DAMAGED-PAGES), PostgreSQL documentation diff --git a/checks/dangerous-settings/check.sql b/checks/dangerous-settings/check.sql new file mode 100644 index 0000000..dc0857e --- /dev/null +++ b/checks/dangerous-settings/check.sql @@ -0,0 +1,7 @@ +SELECT s.name AS subject, + s.setting, + CASE s.name WHEN 'zero_damaged_pages' THEN 'off' ELSE 'on' END AS safe_value +FROM pg_settings AS s +WHERE (s.name IN ('fsync', 'full_page_writes') AND s.setting = 'off') + OR (s.name = 'zero_damaged_pages' AND s.setting = 'on') +ORDER BY s.name diff --git a/checks/dangerous-settings/fixtures/fires.sql b/checks/dangerous-settings/fixtures/fires.sql new file mode 100644 index 0000000..bad9ac8 --- /dev/null +++ b/checks/dangerous-settings/fixtures/fires.sql @@ -0,0 +1,2 @@ +-- server fsync = off +SELECT 1; diff --git a/checks/dangerous-settings/fixtures/healthy.sql b/checks/dangerous-settings/fixtures/healthy.sql new file mode 100644 index 0000000..9bbbcfe --- /dev/null +++ b/checks/dangerous-settings/fixtures/healthy.sql @@ -0,0 +1,2 @@ +-- Stock Postgres keeps all three safe. +SELECT 1; From 704dd9e61843bd11e36f27d7d914c12888198bd9 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:08:01 -0500 Subject: [PATCH 22/41] fix(checks): read \n and \t in double-quoted frontmatter as YAML does --- src/Pgcheckup.Checks.Generator/Frontmatter.cs | 29 +++++++++++++++++-- .../Checks/CheckCompilerTests.cs | 12 ++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/Pgcheckup.Checks.Generator/Frontmatter.cs b/src/Pgcheckup.Checks.Generator/Frontmatter.cs index 1f14165..b6a4572 100644 --- a/src/Pgcheckup.Checks.Generator/Frontmatter.cs +++ b/src/Pgcheckup.Checks.Generator/Frontmatter.cs @@ -228,13 +228,36 @@ private static string Unquote(string value) if (end > 0 && (after.Length == 0 || after[0] == '#')) { var inner = value.Substring(1, end - 1); - return quote == '"' - ? inner.Replace("\\\"", "\"").Replace("\\\\", "\\") - : inner.Replace("''", "'"); + return quote == '"' ? Unescape(inner) : inner.Replace("''", "'"); } } var comment = value.IndexOf(" #", System.StringComparison.Ordinal); return comment >= 0 ? value.Substring(0, comment).TrimEnd() : value; } + + // The escapes YAML gives double-quoted scalars that a template can use. + private static string Unescape(string text) + { + var result = new StringBuilder(text.Length); + for (var i = 0; i < text.Length; i++) + { + if (text[i] == '\\' && i + 1 < text.Length) + { + i++; + result.Append(text[i] switch + { + 'n' => '\n', + 't' => '\t', + _ => text[i], + }); + } + else + { + result.Append(text[i]); + } + } + + return result.ToString(); + } } diff --git a/tests/Pgcheckup.Tests/Checks/CheckCompilerTests.cs b/tests/Pgcheckup.Tests/Checks/CheckCompilerTests.cs index c61d1f8..ee75297 100644 --- a/tests/Pgcheckup.Tests/Checks/CheckCompilerTests.cs +++ b/tests/Pgcheckup.Tests/Checks/CheckCompilerTests.cs @@ -107,6 +107,18 @@ public void Reads_quoted_scalars_and_ignores_comments() Assert.Equal("wal", check.Category); } + [Fact] + public void Reads_escaped_line_breaks_in_double_quoted_scalars_as_yaml_does() + { + var frontmatter = ValidFrontmatter.Replace(""" + fix: | + Do this: + SELECT 1; + """, "fix: \"Do this:\\nSELECT 1;\""); + + Assert.Equal("'Do this:\nSELECT 1;'", TemplateText.Describe(Compile(Markdown(frontmatter)).Check!.Fix)); + } + [Fact] public void Requires_frontmatter() { From e8d83ab7c8282a17b40f45657f3b4b910ea46f21 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:08:02 -0500 Subject: [PATCH 23/41] feat(checks): add autovacuum-disabled --- checks/autovacuum-disabled/check.md | 40 +++++++++++++++++++ checks/autovacuum-disabled/check.sql | 19 +++++++++ checks/autovacuum-disabled/fixtures/fires.sql | 3 ++ .../autovacuum-disabled/fixtures/healthy.sql | 2 + 4 files changed, 64 insertions(+) create mode 100644 checks/autovacuum-disabled/check.md create mode 100644 checks/autovacuum-disabled/check.sql create mode 100644 checks/autovacuum-disabled/fixtures/fires.sql create mode 100644 checks/autovacuum-disabled/fixtures/healthy.sql diff --git a/checks/autovacuum-disabled/check.md b/checks/autovacuum-disabled/check.md new file mode 100644 index 0000000..0324717 --- /dev/null +++ b/checks/autovacuum-disabled/check.md @@ -0,0 +1,40 @@ +--- +id: autovacuum-disabled +title: Autovacuum turned off +category: cleanup +severity: warning +min_version: 14 +privileges: [] +message: "[{setting} is off, so dead rows pile up in every table, and tables are only frozen when wraparound forces it.][Table {table_name} has autovacuum turned off, so its dead rows pile up, and it is only frozen when wraparound forces it.]" +fix: "[ALTER SYSTEM SET {setting} = on;\nSELECT pg_reload_conf();][ALTER TABLE {table_name} RESET (autovacuum_enabled, toast.autovacuum_enabled);]" +--- + +## What breaks + +Autovacuum removes the dead rows that updates and deletes leave behind, keeps planner statistics current, and freezes old rows before transaction IDs wrap around. With it off, tables and indexes bloat, queries slow down as statistics go stale, and freezing only happens in the emergency anti-wraparound vacuum, which can't be cancelled and blocks schema changes. + +Autovacuum needs `track_counts` to know which tables changed, so turning that off stops it too. A single table can also have it turned off with `autovacuum_enabled = false`, often left over from a bulk load. + +The check looks at the server's settings, and at the tables of the database it connects to. + +## Fix + +Turn it back on for the server: + +```sql +ALTER SYSTEM SET autovacuum = on; +SELECT pg_reload_conf(); +``` + +Or for a table: + +```sql +ALTER TABLE public.events RESET (autovacuum_enabled, toast.autovacuum_enabled); +``` + +If a table was off for long, run `VACUUM (ANALYZE, VERBOSE)` on it once. + +## Seen in + +- [A production story: downtime caused by Postgres transaction ID wraparound](https://www.sqlservercentral.com/articles/i-too-have-a-production-story-a-downtime-caused-by-postgres-transaction-id-wraparound-problem), SQLServerCentral +- [autovacuum_enabled storage parameter](https://www.postgresql.org/docs/current/sql-createtable.html#RELOPTION-AUTOVACUUM-ENABLED), PostgreSQL documentation diff --git a/checks/autovacuum-disabled/check.sql b/checks/autovacuum-disabled/check.sql new file mode 100644 index 0000000..e33c9d4 --- /dev/null +++ b/checks/autovacuum-disabled/check.sql @@ -0,0 +1,19 @@ +SELECT s.name AS subject, + s.name AS setting, + NULL::text AS table_name +FROM pg_settings AS s +WHERE s.name IN ('autovacuum', 'track_counts') AND s.setting = 'off' +UNION ALL +-- Tables in this database. The option is stored as written, in any spelling Postgres accepts +-- for false, and a TOAST table carries its own. +SELECT format('%I.%I', n.nspname, c.relname), + NULL, + format('%I.%I', n.nspname, c.relname) +FROM pg_class AS c +JOIN pg_namespace AS n ON n.oid = c.relnamespace +LEFT JOIN pg_class AS t ON t.oid = c.reltoastrelid +WHERE c.relkind IN ('r', 'm') + AND EXISTS ( + SELECT FROM unnest(c.reloptions || coalesce(t.reloptions, '{}')) AS o(option) + WHERE o.option ~* '^autovacuum_enabled=(f(a(l(se?)?)?)?|off?|no?|0)$' + ) diff --git a/checks/autovacuum-disabled/fixtures/fires.sql b/checks/autovacuum-disabled/fixtures/fires.sql new file mode 100644 index 0000000..4400a82 --- /dev/null +++ b/checks/autovacuum-disabled/fixtures/fires.sql @@ -0,0 +1,3 @@ +-- server autovacuum = off +-- Off for the server, and for a table left over from a bulk load. +CREATE TABLE fixture_events (id int) WITH (autovacuum_enabled = off); diff --git a/checks/autovacuum-disabled/fixtures/healthy.sql b/checks/autovacuum-disabled/fixtures/healthy.sql new file mode 100644 index 0000000..54b659c --- /dev/null +++ b/checks/autovacuum-disabled/fixtures/healthy.sql @@ -0,0 +1,2 @@ +-- Set explicitly on, which the check must not mistake for off. +CREATE TABLE fixture_events (id int) WITH (autovacuum_enabled = on); From 562b4de2ff4c9a41c83afdfb69fe24b183e377a6 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:10:42 -0500 Subject: [PATCH 24/41] feat(checks): add integer-exhaustion --- checks/integer-exhaustion/check.md | 41 ++++++++++ checks/integer-exhaustion/check.sql | 82 +++++++++++++++++++ checks/integer-exhaustion/fixtures/fires.sql | 8 ++ .../integer-exhaustion/fixtures/healthy.sql | 8 ++ 4 files changed, 139 insertions(+) create mode 100644 checks/integer-exhaustion/check.md create mode 100644 checks/integer-exhaustion/check.sql create mode 100644 checks/integer-exhaustion/fixtures/fires.sql create mode 100644 checks/integer-exhaustion/fixtures/healthy.sql diff --git a/checks/integer-exhaustion/check.md b/checks/integer-exhaustion/check.md new file mode 100644 index 0000000..4952ebb --- /dev/null +++ b/checks/integer-exhaustion/check.md @@ -0,0 +1,41 @@ +--- +id: integer-exhaustion +title: Integer IDs running out +category: ids +severity: warning +min_version: 14 +privileges: [select_on_sequences] +thresholds: + warning_ratio: 0.5 + critical_ratio: 0.8 +message: "[Column {column_name} has used {used:count} of the {capacity:count} values it can hold.][Sequence {sequence_name} has used {used:count} of the {capacity:count} values it can hold.][Column {fk_column} holds values up to {capacity:count}, but it is a foreign key to {referenced}, which has reached {used:count}.]" +fix: "[move it to bigint. This rewrites the table under an exclusive lock, so plan it:\nALTER TABLE {table_name} ALTER COLUMN {attribute} TYPE bigint;][\nALTER SEQUENCE {owned_sequence} AS bigint;][ALTER SEQUENCE {sequence_name} AS bigint;]" +--- + +## What breaks + +An `integer` column holds values up to about 2.1 billion (`smallint`, 32,767). When the sequence that feeds it reaches that, every insert fails with "integer out of range" or "nextval: reached maximum value", and the application stops taking new rows. + +Three shapes lead there: + +- A `serial` or identity column declared `integer`, often from before anyone expected the table to grow. +- A sequence declared `AS integer` on its own. +- An `integer` foreign key that points at a `bigint` key. The key is fine, but once its values pass 2.1 billion, no row can reference them. + +Reading sequence counters needs SELECT on the sequences, which shows counters but no table rows. `pgcheckup grant` prints that grant. Without it, the check is skipped. + +## Fix + +Change the column to `bigint`, and its sequence with it: + +```sql +ALTER TABLE public.orders ALTER COLUMN id TYPE bigint; +ALTER SEQUENCE public.orders_id_seq AS bigint; +``` + +Changing a column's type rewrites the table and blocks it for the whole time, so plan it: on a large table, add a new `bigint` column, backfill it in batches, and swap it in. + +## Seen in + +- [Incident 2558: Heroku API unavailable](https://status.heroku.com/incidents/2558), Heroku +- [Sequence manipulation functions](https://www.postgresql.org/docs/current/functions-sequence.html), PostgreSQL documentation diff --git a/checks/integer-exhaustion/check.sql b/checks/integer-exhaustion/check.sql new file mode 100644 index 0000000..093bbbe --- /dev/null +++ b/checks/integer-exhaustion/check.sql @@ -0,0 +1,82 @@ +WITH sequences AS ( + SELECT s.seqrelid, + format('%I.%I', n.nspname, c.relname) AS sequence_name, + s.seqmax, + pg_sequence_last_value(s.seqrelid) AS last_value + FROM pg_sequence AS s + JOIN pg_class AS c ON c.oid = s.seqrelid + JOIN pg_namespace AS n ON n.oid = c.relnamespace + WHERE s.seqincrement > 0 + -- Another session's temporary sequence can't be read. + AND c.relpersistence <> 't' +), +feeds AS ( + -- The column a sequence feeds: 'a' for serial, 'i' for identity. + SELECT d.objid AS seqrelid, d.refobjid AS table_oid, d.refobjsubid AS attnum + FROM pg_depend AS d + WHERE d.classid = 'pg_class'::regclass + AND d.refclassid = 'pg_class'::regclass + AND d.deptype IN ('a', 'i') + AND d.refobjsubid > 0 +), +columns AS ( + SELECT a.attrelid, + a.attnum, + format('%I.%I', n.nspname, c.relname) AS table_name, + quote_ident(a.attname) AS attribute, + format('%I.%I.%I', n.nspname, c.relname, a.attname) AS column_name, + CASE a.atttypid + WHEN 'int2'::regtype THEN 32767 + WHEN 'int4'::regtype THEN 2147483647 + ELSE 9223372036854775807 + END AS type_max + FROM pg_attribute AS a + JOIN pg_class AS c ON c.oid = a.attrelid + JOIN pg_namespace AS n ON n.oid = c.relnamespace + WHERE a.attnum > 0 AND NOT a.attisdropped +), +fed AS ( + SELECT s.*, col.table_name, col.attribute, col.column_name, + least(s.seqmax, coalesce(col.type_max, s.seqmax)) AS capacity + FROM sequences AS s + LEFT JOIN feeds AS f ON f.seqrelid = s.seqrelid + LEFT JOIN columns AS col ON col.attrelid = f.table_oid AND col.attnum = f.attnum +) +-- Sequences, measured against the column they feed, or their own maximum. +SELECT coalesce(fed.column_name, fed.sequence_name) AS subject, + CASE WHEN fed.last_value >= fed.capacity * @critical_ratio THEN 'critical' END AS severity, + fed.column_name, + CASE WHEN fed.column_name IS NULL THEN fed.sequence_name END AS sequence_name, + fed.table_name, + fed.attribute, + -- A serial column's sequence is often integer too, and needs changing with it. + CASE WHEN fed.column_name IS NOT NULL AND fed.seqmax <= 2147483647 THEN fed.sequence_name END AS owned_sequence, + NULL::text AS fk_column, + NULL::text AS referenced, + fed.last_value AS used, + fed.capacity +FROM fed +WHERE fed.last_value >= fed.capacity * @warning_ratio +UNION ALL +-- Narrower foreign keys that point at a column a sequence feeds. +SELECT fk.column_name, + CASE WHEN fed.last_value >= fk.type_max * @critical_ratio THEN 'critical' END, + NULL, + NULL, + fk.table_name, + fk.attribute, + NULL, + fk.column_name, + fed.column_name, + fed.last_value, + fk.type_max +FROM pg_constraint AS k +JOIN columns AS fk ON fk.attrelid = k.conrelid AND fk.attnum = k.conkey[1] +JOIN columns AS target ON target.attrelid = k.confrelid AND target.attnum = k.confkey[1] +JOIN feeds AS f ON f.table_oid = k.confrelid AND f.attnum = k.confkey[1] +JOIN fed ON fed.seqrelid = f.seqrelid +WHERE k.contype = 'f' + AND cardinality(k.conkey) = 1 + AND fk.type_max < target.type_max + AND fed.last_value >= fk.type_max * @warning_ratio +ORDER BY 1 diff --git a/checks/integer-exhaustion/fixtures/fires.sql b/checks/integer-exhaustion/fixtures/fires.sql new file mode 100644 index 0000000..bcdb51e --- /dev/null +++ b/checks/integer-exhaustion/fixtures/fires.sql @@ -0,0 +1,8 @@ +-- A serial column past 90% of integer, and an integer foreign key to a bigint key past 70%. +CREATE TABLE fixture_orders (id serial PRIMARY KEY); +SELECT setval('fixture_orders_id_seq', 2000000000); +CREATE TABLE fixture_invoices (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY); +SELECT setval(pg_get_serial_sequence('fixture_invoices', 'id'), 1500000000); +CREATE TABLE fixture_payments (invoice_id int REFERENCES fixture_invoices (id)); +-- As pgcheckup grant prints it, so the check can read the counters. +GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO checkup; diff --git a/checks/integer-exhaustion/fixtures/healthy.sql b/checks/integer-exhaustion/fixtures/healthy.sql new file mode 100644 index 0000000..f12ce55 --- /dev/null +++ b/checks/integer-exhaustion/fixtures/healthy.sql @@ -0,0 +1,8 @@ +-- The same high counters, on bigint columns all the way through. +CREATE TABLE fixture_orders (id bigserial PRIMARY KEY); +SELECT setval('fixture_orders_id_seq', 2000000000); +CREATE TABLE fixture_invoices (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY); +SELECT setval(pg_get_serial_sequence('fixture_invoices', 'id'), 1500000000); +CREATE TABLE fixture_payments (invoice_id bigint REFERENCES fixture_invoices (id)); +-- As pgcheckup grant prints it, so the check can read the counters. +GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO checkup; From 94e6fb9aa008592e5fba5a3998653251585b1372 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:11:49 -0500 Subject: [PATCH 25/41] feat(checks): add invalid-index --- checks/invalid-index/check.md | 35 +++++++++++++++++++++++ checks/invalid-index/check.sql | 12 ++++++++ checks/invalid-index/fixtures/fires.sql | 5 ++++ checks/invalid-index/fixtures/healthy.sql | 3 ++ 4 files changed, 55 insertions(+) create mode 100644 checks/invalid-index/check.md create mode 100644 checks/invalid-index/check.sql create mode 100644 checks/invalid-index/fixtures/fires.sql create mode 100644 checks/invalid-index/fixtures/healthy.sql diff --git a/checks/invalid-index/check.md b/checks/invalid-index/check.md new file mode 100644 index 0000000..21e3410 --- /dev/null +++ b/checks/invalid-index/check.md @@ -0,0 +1,35 @@ +--- +id: invalid-index +title: Invalid index +category: capacity +severity: warning +min_version: 14 +privileges: [pg_read_all_stats] +message: "Index {subject} on {table_name} is invalid, usually left by a failed CREATE INDEX CONCURRENTLY. Queries never use it, but writes may still pay to maintain it." +fix: | + rebuild it, or drop it if it isn't needed: + REINDEX INDEX CONCURRENTLY {subject}; -- or DROP INDEX CONCURRENTLY {subject}; +--- + +## What breaks + +`CREATE INDEX CONCURRENTLY` builds an index without blocking writes, but if it fails (a duplicate value for a unique index, a deadlock, a cancelled migration), it leaves the index behind, marked invalid. The planner never uses it, so the queries it was built for keep scanning the table, which can overload the database once traffic grows. Meanwhile, writes may still keep it up to date, and an invalid unique index still rejects duplicates. + +Migrations that retry often leave one invalid index per failed attempt. + +The check ignores indexes still being built, and the parent index of a partitioned table, which stays invalid until every partition has its own. + +## Fix + +Rebuild it, or drop it if the migration that created it was abandoned: + +```sql +REINDEX INDEX CONCURRENTLY public.accounts_email; +DROP INDEX CONCURRENTLY public.accounts_email; +``` + +If it was a unique index, remove the duplicate rows first, or the rebuild fails the same way. + +## Seen in + +- [Building indexes concurrently](https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-CONCURRENTLY), PostgreSQL documentation diff --git a/checks/invalid-index/check.sql b/checks/invalid-index/check.sql new file mode 100644 index 0000000..bb8d65c --- /dev/null +++ b/checks/invalid-index/check.sql @@ -0,0 +1,12 @@ +SELECT format('%I.%I', n.nspname, i.relname) AS subject, + format('%I.%I', n.nspname, t.relname) AS table_name +FROM pg_index AS x +JOIN pg_class AS i ON i.oid = x.indexrelid +JOIN pg_class AS t ON t.oid = x.indrelid +JOIN pg_namespace AS n ON n.oid = i.relnamespace +WHERE NOT x.indisvalid + -- A partitioned table's index stays invalid until every partition has its own. + AND i.relkind <> 'I' + -- An index still being built is invalid until the build finishes. + AND NOT EXISTS (SELECT FROM pg_stat_progress_create_index AS p WHERE p.index_relid = x.indexrelid) +ORDER BY 1 diff --git a/checks/invalid-index/fixtures/fires.sql b/checks/invalid-index/fixtures/fires.sql new file mode 100644 index 0000000..e9fc03d --- /dev/null +++ b/checks/invalid-index/fixtures/fires.sql @@ -0,0 +1,5 @@ +-- A unique index over duplicate values fails half way and stays behind, invalid. +CREATE TABLE fixture_accounts (email text); +INSERT INTO fixture_accounts VALUES ('ada@example.com'), ('ada@example.com'); +-- expect error +CREATE UNIQUE INDEX CONCURRENTLY fixture_accounts_email ON fixture_accounts (email); diff --git a/checks/invalid-index/fixtures/healthy.sql b/checks/invalid-index/fixtures/healthy.sql new file mode 100644 index 0000000..1a8cd82 --- /dev/null +++ b/checks/invalid-index/fixtures/healthy.sql @@ -0,0 +1,3 @@ +CREATE TABLE fixture_accounts (email text); +INSERT INTO fixture_accounts VALUES ('ada@example.com'), ('grace@example.com'); +CREATE UNIQUE INDEX CONCURRENTLY fixture_accounts_email ON fixture_accounts (email); From 32319c02b18f875439535e582d16af224bb7b348 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:12:26 -0500 Subject: [PATCH 26/41] feat(checks): add collation-version-mismatch --- checks/collation-version-mismatch/check.md | 35 +++++++++++++++++++ checks/collation-version-mismatch/check.sql | 30 ++++++++++++++++ .../fixtures/fires.sql | 6 ++++ .../fixtures/healthy.sql | 3 ++ 4 files changed, 74 insertions(+) create mode 100644 checks/collation-version-mismatch/check.md create mode 100644 checks/collation-version-mismatch/check.sql create mode 100644 checks/collation-version-mismatch/fixtures/fires.sql create mode 100644 checks/collation-version-mismatch/fixtures/healthy.sql diff --git a/checks/collation-version-mismatch/check.md b/checks/collation-version-mismatch/check.md new file mode 100644 index 0000000..f5b489b --- /dev/null +++ b/checks/collation-version-mismatch/check.md @@ -0,0 +1,35 @@ +--- +id: collation-version-mismatch +title: Collation changed under the database +category: capacity +severity: critical +min_version: 15 +privileges: [] +message: "[Database {database}][Collation {collation_name}] was recorded with version {recorded}, but the system now provides {actual}. Text indexes that use it can return wrong results or let duplicate keys in." +fix: "[rebuild its indexes while connected to it, then record the new version:\nREINDEX DATABASE {database_ident};\nALTER DATABASE {database_ident} REFRESH COLLATION VERSION;][rebuild the indexes that use it, then record the new version:\nALTER COLLATION {collation_name} REFRESH VERSION;]" +--- + +## What breaks + +Text indexes are sorted by a collation, which comes from the operating system's C library (glibc) or from ICU. When an OS upgrade changes how that library sorts, as glibc 2.28 did for many languages, the order stored in existing indexes no longer matches. Lookups miss rows that are there, and unique indexes let duplicates in. Nothing reports an error; the data just goes quietly wrong. + +Postgres records each collation's version when it is created, and from Postgres 15 the version of each database's default collation too. This check compares those with what the system provides now. It looks at every database's default collation, and at collations that indexes use in the database it connects to. + +When the system can't report a version (the C collation, or a C library that doesn't version its collations), there is nothing to compare, and the check stays quiet. + +## Fix + +Rebuild the affected indexes, then record the new version so Postgres stops warning: + +```sql +REINDEX DATABASE app; +ALTER DATABASE app REFRESH COLLATION VERSION; +``` + +`REINDEX DATABASE` rebuilds every index and takes locks as it goes. On a large database, rebuild only the text indexes, with `REINDEX INDEX CONCURRENTLY`. A unique index that now holds duplicates fails to rebuild until you remove them. + +## Seen in + +- [Unique constraint violation in Postgres due to an OS upgrade](https://support.atlassian.com/bitbucket-data-center/kb/unique-constraint-violation-in-postgres-due-to-os-upgrade/), Atlassian +- [Upgrading the operating system for PostgreSQL](https://docs.gitlab.com/administration/postgresql/upgrading_os/), GitLab +- [glibc collations and data corruption](https://www.crunchydata.com/blog/glibc-collations-and-data-corruption), Crunchy Data diff --git a/checks/collation-version-mismatch/check.sql b/checks/collation-version-mismatch/check.sql new file mode 100644 index 0000000..8943267 --- /dev/null +++ b/checks/collation-version-mismatch/check.sql @@ -0,0 +1,30 @@ +-- Each database's default collation, recorded from Postgres 15 on. +SELECT d.datname AS subject, + d.datname AS database, + quote_ident(d.datname) AS database_ident, + NULL::text AS collation_name, + d.datcollversion AS recorded, + v.actual +FROM pg_database AS d +CROSS JOIN LATERAL (SELECT pg_database_collation_actual_version(d.oid) AS actual) AS v +WHERE d.datallowconn + AND d.datcollversion IS NOT NULL + AND v.actual IS NOT NULL + AND v.actual <> d.datcollversion +UNION ALL +-- Collations that indexes use in this database. Checking only those avoids asking ICU for the +-- version of each of the hundreds of collations Postgres imports. +SELECT format('%I.%I', n.nspname, c.collname), + NULL, + NULL, + format('%I.%I', n.nspname, c.collname), + c.collversion, + v.actual +FROM pg_collation AS c +JOIN pg_namespace AS n ON n.oid = c.collnamespace +CROSS JOIN LATERAL (SELECT pg_collation_actual_version(c.oid) AS actual) AS v +WHERE c.oid IN (SELECT unnest(x.indcollation::oid[]) FROM pg_index AS x) + AND c.collversion IS NOT NULL + AND v.actual IS NOT NULL + AND v.actual <> c.collversion +ORDER BY 1 diff --git a/checks/collation-version-mismatch/fixtures/fires.sql b/checks/collation-version-mismatch/fixtures/fires.sql new file mode 100644 index 0000000..dffed02 --- /dev/null +++ b/checks/collation-version-mismatch/fixtures/fires.sql @@ -0,0 +1,6 @@ +-- An indexed column with an ICU collation, then the version Postgres recorded is set back, as +-- if the library had been upgraded since. +CREATE COLLATION fixture_collation (provider = icu, locale = 'en-US'); +CREATE TABLE fixture_names (name text COLLATE fixture_collation); +CREATE INDEX fixture_names_name ON fixture_names (name); +UPDATE pg_collation SET collversion = '1.0' WHERE collname = 'fixture_collation'; diff --git a/checks/collation-version-mismatch/fixtures/healthy.sql b/checks/collation-version-mismatch/fixtures/healthy.sql new file mode 100644 index 0000000..329ec89 --- /dev/null +++ b/checks/collation-version-mismatch/fixtures/healthy.sql @@ -0,0 +1,3 @@ +CREATE COLLATION fixture_collation (provider = icu, locale = 'en-US'); +CREATE TABLE fixture_names (name text COLLATE fixture_collation); +CREATE INDEX fixture_names_name ON fixture_names (name); From d3b98bb956e390f143d9b57c0b07b8df7262d9b2 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:13:39 -0500 Subject: [PATCH 27/41] feat(checks): add postgres-eol --- checks/postgres-eol/check.md | 27 ++++++++++++++++++++++++ checks/postgres-eol/check.sql | 15 +++++++++++++ checks/postgres-eol/fixtures/fires.sql | 3 +++ checks/postgres-eol/fixtures/healthy.sql | 4 ++++ 4 files changed, 49 insertions(+) create mode 100644 checks/postgres-eol/check.md create mode 100644 checks/postgres-eol/check.sql create mode 100644 checks/postgres-eol/fixtures/fires.sql create mode 100644 checks/postgres-eol/fixtures/healthy.sql diff --git a/checks/postgres-eol/check.md b/checks/postgres-eol/check.md new file mode 100644 index 0000000..c8cd0e9 --- /dev/null +++ b/checks/postgres-eol/check.md @@ -0,0 +1,27 @@ +--- +id: postgres-eol +title: Postgres version near or past end of life +category: capacity +severity: warning +min_version: 10 +privileges: [] +thresholds: + warn_before: 90d +message: "PostgreSQL {subject}[ reached end of life on {ended_on} and gets no more bug or security fixes][ reaches end of life on {ends_on}, in {time_left}]." +fix: plan an upgrade to PostgreSQL {latest} with pg_upgrade or logical replication, and rehearse it on a copy first. +--- + +## What breaks + +Each major version of Postgres gets fixes for five years. After its end-of-life date, security holes and data-corruption bugs found in it stay open, and extensions and managed providers drop it. Managed providers also force an upgrade on their own schedule, or charge for extended support. + +The check warns once a version is past its end of life, and gives notice (`info`) from 90 days before (`warn_before`). The dates ship inside each pgcheckup release, and the check compares them with the server's clock. + +## Fix + +Upgrade to a supported major version. `pg_upgrade` is fastest on a self-managed server; logical replication keeps downtime shortest. Either way, rehearse on a copy first: extensions and query plans can change between versions. + +## Seen in + +- [How we upgraded our 4 TB main application Postgres database](https://retool.com/blog/how-we-upgraded-postgresql-database), Retool +- [Versioning policy](https://www.postgresql.org/support/versioning/), PostgreSQL diff --git a/checks/postgres-eol/check.sql b/checks/postgres-eol/check.sql new file mode 100644 index 0000000..619d29c --- /dev/null +++ b/checks/postgres-eol/check.sql @@ -0,0 +1,15 @@ +-- End-of-life dates from https://www.postgresql.org/support/versioning/. Add each new major here. +WITH eol(major, ends) AS ( + VALUES (10, date '2022-11-10'), (11, date '2023-11-09'), (12, date '2024-11-21'), + (13, date '2025-11-13'), (14, date '2026-11-12'), (15, date '2027-11-11'), + (16, date '2028-11-09'), (17, date '2029-11-08'), (18, date '2030-11-14') +) +SELECT e.major::text AS subject, + CASE WHEN e.ends > now() THEN 'info' END AS severity, + CASE WHEN e.ends <= now() THEN to_char(e.ends, 'YYYY-MM-DD') END AS ended_on, + CASE WHEN e.ends > now() THEN to_char(e.ends, 'YYYY-MM-DD') END AS ends_on, + CASE WHEN e.ends > now() THEN (e.ends - current_date) * interval '1 day' END AS time_left, + (SELECT max(major) FROM eol) AS latest +FROM eol AS e +WHERE e.major = current_setting('server_version_num')::int / 10000 + AND e.ends - @warn_before <= now() diff --git a/checks/postgres-eol/fixtures/fires.sql b/checks/postgres-eol/fixtures/fires.sql new file mode 100644 index 0000000..895911e --- /dev/null +++ b/checks/postgres-eol/fixtures/fires.sql @@ -0,0 +1,3 @@ +-- threshold warn_before = 36500d +-- A century of notice puts every supported version within reach of its end of life. +SELECT 1; diff --git a/checks/postgres-eol/fixtures/healthy.sql b/checks/postgres-eol/fixtures/healthy.sql new file mode 100644 index 0000000..f28900c --- /dev/null +++ b/checks/postgres-eol/fixtures/healthy.sql @@ -0,0 +1,4 @@ +-- threshold warn_before = 0s +-- A supported version, with no notice, has nothing to report. Once Postgres 14 passes its end +-- of life (2026-11-12), this fails on 14, which is the signal to drop it from the CI matrix. +SELECT 1; From 23788f810970992118432b22c0eac4b0d8bf8452 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:13:40 -0500 Subject: [PATCH 28/41] docs(roadmap): plan for Postgres 14 reaching end of life --- ROADMAP.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ROADMAP.md b/ROADMAP.md index 3c38002..f33a4b1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -93,6 +93,7 @@ pgcheckup is a read-only CLI (.NET 10, NativeAOT) that checks a PostgreSQL datab - [ ] Release workflow: NativeAOT binaries for linux-x64, linux-arm64, osx-arm64 and win-x64 on GitHub Releases, with SHA-256 checksums. - [ ] Container image on GHCR (amd64, arm64), and a GitHub Actions example in the README. - [ ] Error messages: connection, TLS, authentication and missing-privilege failures each say what to do next. +- [ ] Postgres 14 reaches end of life on 2026-11-12. Move it to best effort: drop it from the CI matrix, where `postgres-eol`'s healthy fixture starts failing on it that day. - [ ] Dogfooding log: scan real databases, including at least one managed provider, and record every false alarm and every missed problem. - [ ] Launch gates: README quick start tested on a clean machine, supported platforms and Postgres versions documented, `CONTRIBUTING.md` with build and test steps, `SECURITY.md`, no known critical bugs, and green CI. From 4bc5b219f0efd37c066159355cf90563ff338c76 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:28:16 -0500 Subject: [PATCH 29/41] docs(readme): describe the v0.1 checks and exit codes --- README.md | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9c5969c..4212466 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ In February 2019, one of the Postgres shards behind Mailchimp's Mandrill [ran ou Most of these failures show up in the system catalogs weeks ahead: a table's transaction ID age, a replication slot nobody reads, WAL archiving that failed last night. Teams without a DBA rarely look. pgcheckup looks for them and tells you what to do. -> **Status:** early development. The first check, `replication-slot-inactive`, runs end to end. There is no release to install yet. See [ROADMAP.md](ROADMAP.md). +> **Status:** early development. The 15 checks of v0.1 run end to end, but there is no release to install yet. See [ROADMAP.md](ROADMAP.md). ![pgcheckup scanning a database whose inactive replication slot is holding 1.07 GB of WAL](docs/scan.gif) @@ -26,24 +26,30 @@ pgcheckup scan "postgres://checkup@db.example.com:5432/app?sslmode=verify-full" pgcheckup · app on db.example.com · PostgreSQL 17.6 · Amazon RDS CRITICAL xid-wraparound - Table orders has used 1.61 billion of its 2.1 billion transaction IDs. - Vacuum can't freeze it while pid 4127 holds a transaction open (6 days). - Fix: end pid 4127, then run VACUUM (FREEZE) orders; + Table public.orders has used 1.61 billion of its 2.1 billion transaction IDs. + Fix: VACUUM (FREEZE, VERBOSE) public.orders; + +WARNING long-transaction + Session 4127 (worker on app) has had a transaction open for 6 days and has been idle in it for 6 days. + Fix: if the session is stuck or abandoned, end it: + SELECT pg_terminate_backend(4127); WARNING replication-slot-inactive Slot debezium has been inactive for 3 days and is holding 48 GB of WAL. Fix: restart its consumer, or drop the slot: SELECT pg_drop_replication_slot('debezium'); -11 passed · 1 critical · 1 warning · 1 skipped (wal-archiving-failing: managed by Amazon RDS) +11 passed · 1 critical · 2 warnings · 1 skipped (wal-archiving-failing: managed by Amazon RDS) ``` ## What it checks -- **Running out of IDs:** transaction ID and multixact wraparound, and `int4` sequences and identity columns near their limit. -- **Cleanup that can't run:** long transactions, sessions idle inside a transaction, forgotten prepared transactions, and tables with autovacuum turned off. -- **Disks filling with WAL:** inactive replication slots, slots with no WAL limit, and failing WAL archiving. -- **Capacity and settings:** connection saturation, `fsync`, `full_page_writes` or `autovacuum` turned off, invalid indexes, and Postgres versions past end of life. +- **Running out of IDs:** transaction ID and multixact wraparound, and `int4` sequences, identity columns and foreign keys near their limit. +- **Cleanup that can't run:** long transactions and sessions idle inside one, forgotten prepared transactions, no timeout for idle transactions, and autovacuum turned off. +- **Disks filling with WAL:** inactive replication slots, slots with no WAL limit, and WAL archiving that fails or hangs. +- **Capacity and settings:** connection saturation, `fsync` or `full_page_writes` turned off, invalid indexes, collations changed by an OS upgrade, and Postgres versions near or past end of life. + +`pgcheckup list` shows every check. Every finding says what breaks and how to fix it. `pgcheckup explain ` prints the full note, with links to incidents where it happened. @@ -57,7 +63,7 @@ Every finding says what breaks and how to fix it. `pgcheckup explain ` pr ## In CI -`pgcheckup scan` exits 0 when no finding reaches `--fail-on` (default `critical`), 1 when one does, and 2 when the scan couldn't run. `--format json` and `--format markdown` are there for pipelines and pull requests. A baseline, so CI fails only on new findings, comes with v0.2. +`pgcheckup scan` exits 1 when a finding reaches `--fail-on` (default `critical`), otherwise 2 when the scan couldn't run or a check errored, and 0 when neither happened. `--format json` ([its shape](docs/json.md)) and `--format markdown` are there for pipelines and pull requests. A baseline, so CI fails only on new findings, comes with v0.2. ## Prior art From cebbb35754e7202cef59c3174a4d2bd1390b498f Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:39:54 -0500 Subject: [PATCH 30/41] fix(cli): escape control characters from the database in reports Object names are text anyone who can create a table controls. Terminal escapes in one could rewrite the report or set the clipboard, and a line break or backticks could break out of Markdown. --- src/Pgcheckup/Checks/ValueText.cs | 32 ++++++++++++++++++- src/Pgcheckup/Cli/MarkdownReport.cs | 23 ++++++++++--- src/Pgcheckup/Cli/ReportText.cs | 8 ++--- src/Pgcheckup/Cli/TerminalReport.cs | 2 +- .../Checks/TemplateRenderTests.cs | 8 +++++ .../Pgcheckup.Tests/Cli/MachineReportTests.cs | 15 +++++++++ .../Cli/TerminalReportTests.cs | 13 ++++++++ 7 files changed, 91 insertions(+), 10 deletions(-) diff --git a/src/Pgcheckup/Checks/ValueText.cs b/src/Pgcheckup/Checks/ValueText.cs index 5d9ae33..78f829f 100644 --- a/src/Pgcheckup/Checks/ValueText.cs +++ b/src/Pgcheckup/Checks/ValueText.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text; namespace Pgcheckup.Checks; @@ -23,10 +24,39 @@ public static class ValueText DateTime time => time.ToUniversalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) + " UTC", bool flag => flag ? "on" : "off", IFormattable number => number.ToString(null, CultureInfo.InvariantCulture), - _ => value.ToString() ?? "", + _ => Printable(value.ToString() ?? ""), }, }; + /// Makes text from the database safe to print: control characters become \uXXXX. + /// Text such as an object name, which anyone who can create a table controls. + /// + /// The text with every control character escaped, including line breaks, so it can't move the + /// cursor, rewrite a terminal line, set the clipboard or break out of Markdown. + /// + public static string Printable(string text) + { + if (!text.Any(char.IsControl)) + { + return text; + } + + var printable = new StringBuilder(text.Length + 8); + foreach (var c in text) + { + if (char.IsControl(c)) + { + printable.Append(CultureInfo.InvariantCulture, $"\\u{(int)c:x4}"); + } + else + { + printable.Append(c); + } + } + + return printable.ToString(); + } + /// Prints a size with Postgres's units (1024-based, as in pg_size_pretty) and three significant digits. /// The size in bytes. /// Such as "512 bytes", "1.5 GB" or "48 GB". diff --git a/src/Pgcheckup/Cli/MarkdownReport.cs b/src/Pgcheckup/Cli/MarkdownReport.cs index 71408a3..35379fd 100644 --- a/src/Pgcheckup/Cli/MarkdownReport.cs +++ b/src/Pgcheckup/Cli/MarkdownReport.cs @@ -17,7 +17,7 @@ public static string Write(ScanReport report) { var server = report.Server; var markdown = new StringBuilder(); - Line(markdown, $"## pgcheckup · {server.Database} on {server.Host}"); + Line(markdown, $"## pgcheckup · {Cell(ValueText.Printable(server.Database))} on {Cell(ValueText.Printable(server.Host))}"); Line(markdown); var provider = server.Provider is { } managed ? $" · {managed.Name}" : ""; Line(markdown, $"PostgreSQL {server.Version}{provider} · {ReportText.Summary(report)}"); @@ -50,11 +50,14 @@ public static string Write(ScanReport report) foreach (var finding in findings) { Line(markdown); - Line(markdown, $"**`{finding.CheckId}`** · {Cell(finding.Subject)}"); + Line(markdown, $"**`{finding.CheckId}`** · {Cell(ValueText.Printable(finding.Subject))}"); Line(markdown); - Line(markdown, "```"); + + // A fence longer than any run of backticks in the fix, so an object name can't close it. + var fence = new string('`', Math.Max(3, LongestBacktickRun(finding.Fix) + 1)); + Line(markdown, fence); Line(markdown, finding.Fix); - Line(markdown, "```"); + Line(markdown, fence); } } @@ -65,4 +68,16 @@ public static string Write(ScanReport report) private static void Line(StringBuilder markdown, string text = "") => markdown.Append(text).Append('\n'); private static string Cell(string text) => text.Replace("|", "\\|").Replace("\n", "
"); + + private static int LongestBacktickRun(string text) + { + int longest = 0, run = 0; + foreach (var c in text) + { + run = c == '`' ? run + 1 : 0; + longest = Math.Max(longest, run); + } + + return longest; + } } diff --git a/src/Pgcheckup/Cli/ReportText.cs b/src/Pgcheckup/Cli/ReportText.cs index a7191e4..2251d1d 100644 --- a/src/Pgcheckup/Cli/ReportText.cs +++ b/src/Pgcheckup/Cli/ReportText.cs @@ -43,7 +43,7 @@ public static string Summary(ScanReport report) if (skipped.Count > 0) { - parts.Add($"{skipped.Count} skipped ({string.Join(", ", skipped.Select(r => $"{r.Check.Id}: {r.Reason}"))})"); + parts.Add($"{skipped.Count} skipped ({string.Join(", ", skipped.Select(r => $"{r.Check.Id}: {ValueText.Printable(r.Reason ?? "")}"))})"); } return string.Join(" · ", parts); @@ -67,11 +67,11 @@ public static IEnumerable Errored(ScanReport report) => report.Results.Where(r => r.Status == CheckStatus.Errored).OrderBy(r => r.Check.Id, StringComparer.Ordinal); /// Turns a reason such as "timed out after 5 s" into a sentence: "Timed out after 5 s." - /// A lowercase reason, or . - /// The reason capitalized and ending in a full stop. + /// A lowercase reason, or . A server error can quote object names in it. + /// The reason capitalized, ending in a full stop, and safe to print. public static string Sentence(string? reason) { - var text = string.IsNullOrEmpty(reason) ? "it failed" : reason; + var text = ValueText.Printable(string.IsNullOrEmpty(reason) ? "it failed" : reason); return char.ToUpperInvariant(text[0]) + text[1..] + (text.EndsWith('.') ? "" : "."); } } diff --git a/src/Pgcheckup/Cli/TerminalReport.cs b/src/Pgcheckup/Cli/TerminalReport.cs index 10f55a6..60b17f3 100644 --- a/src/Pgcheckup/Cli/TerminalReport.cs +++ b/src/Pgcheckup/Cli/TerminalReport.cs @@ -33,7 +33,7 @@ public static void Write(TextWriter output, ScanReport report, bool color) { var server = report.Server; var provider = server.Provider is { } managed ? $" · {managed.Name}" : ""; - output.WriteLine($"pgcheckup · {server.Database} on {server.Host} · PostgreSQL {server.Version}{provider}"); + output.WriteLine($"pgcheckup · {ValueText.Printable(server.Database)} on {ValueText.Printable(server.Host)} · PostgreSQL {server.Version}{provider}"); output.WriteLine(); foreach (var finding in ReportText.OrderedFindings(report)) diff --git a/tests/Pgcheckup.Tests/Checks/TemplateRenderTests.cs b/tests/Pgcheckup.Tests/Checks/TemplateRenderTests.cs index 64369b7..dc19d5c 100644 --- a/tests/Pgcheckup.Tests/Checks/TemplateRenderTests.cs +++ b/tests/Pgcheckup.Tests/Checks/TemplateRenderTests.cs @@ -79,6 +79,14 @@ public void Prints_other_values_plainly(object value, string expected) Assert.Equal(expected, RenderOne(value)); } + // Object names come from the database, and anyone who can create a table can put terminal + // escape sequences or line breaks in one. + [Fact] + public void Escapes_control_characters_in_text_values() + { + Assert.Equal("orders\\u001b[2K\\u000aDROP", RenderOne("orders\u001b[2K\nDROP")); + } + [Fact] public void Prints_decimals_and_timestamps_without_culture() { diff --git a/tests/Pgcheckup.Tests/Cli/MachineReportTests.cs b/tests/Pgcheckup.Tests/Cli/MachineReportTests.cs index 1bf0692..6f89d6d 100644 --- a/tests/Pgcheckup.Tests/Cli/MachineReportTests.cs +++ b/tests/Pgcheckup.Tests/Cli/MachineReportTests.cs @@ -136,6 +136,21 @@ public void Escapes_pipes_and_line_breaks_in_markdown_cells() Assert.Contains("| **Critical** | `a` | one \\| two
three |", MarkdownReport.Write(report)); } + [Fact] + public void Fences_a_fix_with_more_backticks_than_it_contains() + { + var report = new ScanReport(Server, + [ + new CheckResult(Check("a"), CheckStatus.Found, + [new Finding("a", "x\u001b", Severity.Warning, "A.", "DROP TABLE \"a```b\";", new Dictionary())]), + ]); + + var markdown = MarkdownReport.Write(report); + + Assert.Contains("````\nDROP TABLE \"a```b\";\n````\n", markdown); + Assert.Contains("**`a`** · x\\u001b\n", markdown); + } + [Fact] public void Writes_only_the_summary_when_nothing_was_found() { diff --git a/tests/Pgcheckup.Tests/Cli/TerminalReportTests.cs b/tests/Pgcheckup.Tests/Cli/TerminalReportTests.cs index 4ca4a8c..b8b960f 100644 --- a/tests/Pgcheckup.Tests/Cli/TerminalReportTests.cs +++ b/tests/Pgcheckup.Tests/Cli/TerminalReportTests.cs @@ -103,6 +103,19 @@ 0 passed · 1 warning · 1 errored Render(report)); } + [Fact] + public void Escapes_control_characters_from_the_server() + { + var report = new ScanReport(Server with { Database = "app\u001b]52;c;x\u0007" }, + [new CheckResult(Check("a"), CheckStatus.Errored, [], "42P01: relation \"t\u001b[2K\" does not exist")]); + + var output = Render(report); + + Assert.DoesNotContain("\u001b", output); + Assert.DoesNotContain("\u0007", output); + Assert.Contains("app\\u001b]52;c;x\\u0007 on", output); + } + [Fact] public void Lists_skipped_checks_with_their_reasons_in_the_summary() { From e0f765ad1e659a89ea13ff6bd73656bf760ec5fe Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:39:56 -0500 Subject: [PATCH 31/41] fix(cli): grant default privileges for the tables' owner ALTER DEFAULT PRIVILEGES only covers sequences its role creates, so grant takes --owner. It also refuses names with control characters, which could end a comment in the printed SQL. --- src/Pgcheckup/Cli/GrantScript.cs | 18 ++++++++++--- src/Pgcheckup/Cli/PgcheckupCli.cs | 18 +++++++++++-- .../Cli/CatalogCommandTests.cs | 21 +++++++++++++++ tests/Pgcheckup.Tests/Cli/GrantTests.cs | 26 ++++++++++++++----- 4 files changed, 71 insertions(+), 12 deletions(-) diff --git a/src/Pgcheckup/Cli/GrantScript.cs b/src/Pgcheckup/Cli/GrantScript.cs index 593f71c..60c4cc7 100644 --- a/src/Pgcheckup/Cli/GrantScript.cs +++ b/src/Pgcheckup/Cli/GrantScript.cs @@ -26,14 +26,20 @@ public static partial class GrantScript /// Builds the SQL. It is printed for a person to review and run, never run by pgcheckup. /// The role to create, quoted when the name needs it. /// The database the role may connect to, quoted when the name needs it. + /// + /// The role that owns the application's tables. Default privileges only cover sequences that + /// this role creates later. + /// /// /// SQL that creates the role with pg_monitor, CONNECT and a read-only default, then /// the sequence grants that only integer-exhaustion needs. /// - public static string Build(string role, string database) + /// A name contains a control character. + public static string Build(string role, string database, string owner) { var r = Identifier(role); var d = Identifier(database); + var o = Identifier(owner); var sql = new StringBuilder(); sql.Append("-- A least-privilege role for pgcheckup. Review it, then run it as a superuser\n"); sql.Append("-- (rds_superuser on Amazon RDS, cloudsqlsuperuser on Cloud SQL).\n"); @@ -45,14 +51,18 @@ public static string Build(string role, string database) sql.Append($"ALTER ROLE {r} SET default_transaction_read_only = on;\n"); sql.Append('\n'); sql.Append("-- Only integer-exhaustion needs these. They show sequence counters, never table rows.\n"); - sql.Append("-- Repeat them for each schema with sequences, as the role that creates them.\n"); + sql.Append($"-- Repeat them for each schema with sequences. The second covers sequences that {o},\n"); + sql.Append("-- the role that owns your tables, creates later; name another with --owner.\n"); sql.Append($"GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO {r};\n"); - sql.Append($"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON SEQUENCES TO {r};\n"); + sql.Append($"ALTER DEFAULT PRIVILEGES FOR ROLE {o} IN SCHEMA public GRANT SELECT ON SEQUENCES TO {r};\n"); return sql.ToString(); } + // A line break in a name would end the -- comment it appears in, and run the rest as SQL. private static string Identifier(string name) => - PlainIdentifier().IsMatch(name) && !Reserved.Contains(name) ? name : $"\"{name.Replace("\"", "\"\"")}\""; + name.Any(char.IsControl) ? throw new ArgumentException("A role or database name can't contain control characters.") + : PlainIdentifier().IsMatch(name) && !Reserved.Contains(name) ? name + : $"\"{name.Replace("\"", "\"\"")}\""; [GeneratedRegex("^[a-z_][a-z0-9_$]*$")] private static partial Regex PlainIdentifier(); diff --git a/src/Pgcheckup/Cli/PgcheckupCli.cs b/src/Pgcheckup/Cli/PgcheckupCli.cs index 76df317..8bae5fc 100644 --- a/src/Pgcheckup/Cli/PgcheckupCli.cs +++ b/src/Pgcheckup/Cli/PgcheckupCli.cs @@ -85,13 +85,27 @@ public static async Task RunAsync( Description = "The database the role may connect to.", DefaultValueFactory = _ => "app", }; + var owner = new Option("--owner") + { + Description = "The role that owns your tables, so the grant covers sequences it creates later.", + DefaultValueFactory = _ => "app", + }; var grant = new Command("grant", "Print SQL for a least-privilege checkup role. pgcheckup never runs it."); grant.Options.Add(role); grant.Options.Add(database); + grant.Options.Add(owner); grant.SetAction(result => { - output.Write(GrantScript.Build(result.GetValue(role)!, result.GetValue(database)!)); - return Passed; + try + { + output.Write(GrantScript.Build(result.GetValue(role)!, result.GetValue(database)!, result.GetValue(owner)!)); + return Passed; + } + catch (ArgumentException problem) + { + error.WriteLine($"pgcheckup: {problem.Message}"); + return CouldNotRun; + } }); root.Subcommands.Add(grant); diff --git a/tests/Pgcheckup.Tests/Cli/CatalogCommandTests.cs b/tests/Pgcheckup.Tests/Cli/CatalogCommandTests.cs index 3bdc051..95d72ab 100644 --- a/tests/Pgcheckup.Tests/Cli/CatalogCommandTests.cs +++ b/tests/Pgcheckup.Tests/Cli/CatalogCommandTests.cs @@ -87,6 +87,27 @@ public async Task Says_when_a_check_needs_nothing() Assert.DoesNotContain("Thresholds:", output); } + [Fact] + public async Task Grants_to_the_role_and_owner_given() + { + var (exitCode, output, _) = await RunAsync("grant", "--role", "scanner", "--database", "shop", "--owner", "shop_owner"); + + Assert.Equal(0, exitCode); + Assert.Contains("CREATE ROLE scanner LOGIN;", output); + Assert.Contains("GRANT CONNECT ON DATABASE shop TO scanner;", output); + Assert.Contains("ALTER DEFAULT PRIVILEGES FOR ROLE shop_owner IN SCHEMA public", output); + } + + [Fact] + public async Task Exits_2_when_grant_is_given_a_name_with_a_line_break() + { + var (exitCode, output, error) = await RunAsync("grant", "--role", "scanner\nDROP TABLE orders;"); + + Assert.Equal(2, exitCode); + Assert.Equal("", output); + Assert.Contains("control characters", error); + } + [Fact] public async Task Exits_2_for_a_check_that_does_not_exist() { diff --git a/tests/Pgcheckup.Tests/Cli/GrantTests.cs b/tests/Pgcheckup.Tests/Cli/GrantTests.cs index 6b3bf6d..afb2d44 100644 --- a/tests/Pgcheckup.Tests/Cli/GrantTests.cs +++ b/tests/Pgcheckup.Tests/Cli/GrantTests.cs @@ -25,12 +25,24 @@ public void Prints_a_least_privilege_role() ALTER ROLE checkup SET default_transaction_read_only = on; -- Only integer-exhaustion needs these. They show sequence counters, never table rows. - -- Repeat them for each schema with sequences, as the role that creates them. + -- Repeat them for each schema with sequences. The second covers sequences that app, + -- the role that owns your tables, creates later; name another with --owner. GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO checkup; - ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON SEQUENCES TO checkup; + ALTER DEFAULT PRIVILEGES FOR ROLE app IN SCHEMA public GRANT SELECT ON SEQUENCES TO checkup; """.ReplaceLineEndings("\n"), - GrantScript.Build("checkup", "app")); + GrantScript.Build("checkup", "app", "app")); + } + + [Theory] + [InlineData("check\nup")] + [InlineData("check\u001bup")] + public void Refuses_a_name_with_control_characters(string name) + { + // A line break would end the -- comment the name appears in, and run the rest as SQL. + Assert.Throws(() => GrantScript.Build(name, "app", "app")); + Assert.Throws(() => GrantScript.Build("checkup", name, "app")); + Assert.Throws(() => GrantScript.Build("checkup", "app", name)); } [Theory] @@ -41,15 +53,17 @@ public void Prints_a_least_privilege_role() [InlineData("checkup_2", "checkup_2")] public void Quotes_names_that_need_it(string name, string quoted) { - Assert.Contains($"CREATE ROLE {quoted} LOGIN;", GrantScript.Build(name, "app")); - Assert.Contains($"GRANT CONNECT ON DATABASE {quoted} TO", GrantScript.Build("checkup", name)); + Assert.Contains($"CREATE ROLE {quoted} LOGIN;", GrantScript.Build(name, "app", "app")); + Assert.Contains($"GRANT CONNECT ON DATABASE {quoted} TO", GrantScript.Build("checkup", name, "app")); + Assert.Contains($"FOR ROLE {quoted} IN SCHEMA", GrantScript.Build("checkup", "app", name)); } // The printed SQL must actually produce a role that scans cleanly and can't write. [Fact] public async Task Creates_a_role_that_scans_every_check_and_cannot_write() { - var statements = FixtureScript.Parse(GrantScript.Build("scanner", "app")).Statements.Select(s => s.Sql); + // The test server's tables belong to its superuser, postgres. + var statements = FixtureScript.Parse(GrantScript.Build("scanner", "app", "postgres")).Statements.Select(s => s.Sql); await postgres.Server.ExecuteAsSuperuserAsync(Cancel, [.. statements, "ALTER ROLE scanner PASSWORD 'scanner'"]); var scanner = postgres.Server.Checkup; scanner.Username = "scanner"; From 9bc13977e693f2f7ad27373869a491b0a9addfa7 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:39:57 -0500 Subject: [PATCH 32/41] test(checks): run fixtures on Debian images to cover glibc collations --- checks/collation-version-mismatch/fixtures/fires.sql | 2 ++ tests/Pgcheckup.Tests/Postgres/PostgresServer.cs | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/checks/collation-version-mismatch/fixtures/fires.sql b/checks/collation-version-mismatch/fixtures/fires.sql index dffed02..f907043 100644 --- a/checks/collation-version-mismatch/fixtures/fires.sql +++ b/checks/collation-version-mismatch/fixtures/fires.sql @@ -4,3 +4,5 @@ CREATE COLLATION fixture_collation (provider = icu, locale = 'en-US'); CREATE TABLE fixture_names (name text COLLATE fixture_collation); CREATE INDEX fixture_names_name ON fixture_names (name); UPDATE pg_collation SET collversion = '1.0' WHERE collname = 'fixture_collation'; +-- The same for the database's default collation, from glibc. +UPDATE pg_database SET datcollversion = '1.0' WHERE datname = 'app'; diff --git a/tests/Pgcheckup.Tests/Postgres/PostgresServer.cs b/tests/Pgcheckup.Tests/Postgres/PostgresServer.cs index cb22aba..9628e1e 100644 --- a/tests/Pgcheckup.Tests/Postgres/PostgresServer.cs +++ b/tests/Pgcheckup.Tests/Postgres/PostgresServer.cs @@ -32,7 +32,8 @@ public static Task StartAsync(CancellationToken cancellationToke public static async Task StartAsync(IReadOnlyDictionary settings, CancellationToken cancellationToken) { var command = settings.SelectMany(s => new[] { "-c", $"{s.Key}={s.Value}" }).ToArray(); - var container = new PostgreSqlBuilder($"postgres:{Version}-alpine") + // Debian, not Alpine: most servers run glibc, whose collation versions a check compares. + var container = new PostgreSqlBuilder($"postgres:{Version}") .WithDatabase("app") .WithCommand(new OverwriteEnumerable(command)) .Build(); From d1ee7f54de4bc9b10294ca0086bf7eb33fc88b22 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:39:59 -0500 Subject: [PATCH 33/41] fix(checks): skip dangerous-settings on Neon, which runs with fsync off --- checks/dangerous-settings/check.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/checks/dangerous-settings/check.md b/checks/dangerous-settings/check.md index a1d8579..0f43bb6 100644 --- a/checks/dangerous-settings/check.md +++ b/checks/dangerous-settings/check.md @@ -5,6 +5,7 @@ category: capacity severity: critical min_version: 14 privileges: [] +skip_on: [neon] message: "{subject} is {setting}. Postgres then can't protect your data from a crash or a damaged page." fix: | turn it back: @@ -22,6 +23,8 @@ Three settings trade Postgres's protection of your data for speed or convenience They are sometimes turned off to speed up a bulk load or a test server, and left that way. +Neon runs with `fsync = off` by design, because its storage layer makes writes durable, so the check is skipped there. + ## Fix Turn the setting back and reload: From cac2286f1ee422caca19e827538e987096652a4a Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:40:01 -0500 Subject: [PATCH 34/41] fix(checks): ignore cycling sequences and report narrow sequences behind bigint columns --- checks/integer-exhaustion/check.md | 4 +++- checks/integer-exhaustion/check.sql | 8 ++++++-- checks/integer-exhaustion/fixtures/fires.sql | 4 ++++ checks/integer-exhaustion/fixtures/healthy.sql | 3 +++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/checks/integer-exhaustion/check.md b/checks/integer-exhaustion/check.md index 4952ebb..4b5da86 100644 --- a/checks/integer-exhaustion/check.md +++ b/checks/integer-exhaustion/check.md @@ -19,7 +19,9 @@ An `integer` column holds values up to about 2.1 billion (`smallint`, 32,767). W Three shapes lead there: - A `serial` or identity column declared `integer`, often from before anyone expected the table to grow. -- A sequence declared `AS integer` on its own. +- A sequence declared `AS integer` on its own, or left `integer` when its column was changed to `bigint`. + +Sequences with `CYCLE` start over by design, so the check ignores them. - An `integer` foreign key that points at a `bigint` key. The key is fine, but once its values pass 2.1 billion, no row can reference them. Reading sequence counters needs SELECT on the sequences, which shows counters but no table rows. `pgcheckup grant` prints that grant. Without it, the check is skipped. diff --git a/checks/integer-exhaustion/check.sql b/checks/integer-exhaustion/check.sql index 093bbbe..e493a5c 100644 --- a/checks/integer-exhaustion/check.sql +++ b/checks/integer-exhaustion/check.sql @@ -7,6 +7,8 @@ WITH sequences AS ( JOIN pg_class AS c ON c.oid = s.seqrelid JOIN pg_namespace AS n ON n.oid = c.relnamespace WHERE s.seqincrement > 0 + -- A cycling sequence starts over at its minimum by design. + AND NOT s.seqcycle -- Another session's temporary sequence can't be read. AND c.relpersistence <> 't' ), @@ -40,7 +42,9 @@ fed AS ( least(s.seqmax, coalesce(col.type_max, s.seqmax)) AS capacity FROM sequences AS s LEFT JOIN feeds AS f ON f.seqrelid = s.seqrelid - LEFT JOIN columns AS col ON col.attrelid = f.table_oid AND col.attnum = f.attnum + -- The column is the limit only if it is no wider than its sequence. A bigint column fed by an + -- integer sequence (left behind by ALTER COLUMN TYPE bigint) needs the sequence changed, not the table. + LEFT JOIN columns AS col ON col.attrelid = f.table_oid AND col.attnum = f.attnum AND col.type_max <= s.seqmax ) -- Sequences, measured against the column they feed, or their own maximum. SELECT coalesce(fed.column_name, fed.sequence_name) AS subject, @@ -67,7 +71,7 @@ SELECT fk.column_name, fk.attribute, NULL, fk.column_name, - fed.column_name, + target.column_name, fed.last_value, fk.type_max FROM pg_constraint AS k diff --git a/checks/integer-exhaustion/fixtures/fires.sql b/checks/integer-exhaustion/fixtures/fires.sql index bcdb51e..84abf55 100644 --- a/checks/integer-exhaustion/fixtures/fires.sql +++ b/checks/integer-exhaustion/fixtures/fires.sql @@ -4,5 +4,9 @@ SELECT setval('fixture_orders_id_seq', 2000000000); CREATE TABLE fixture_invoices (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY); SELECT setval(pg_get_serial_sequence('fixture_invoices', 'id'), 1500000000); CREATE TABLE fixture_payments (invoice_id int REFERENCES fixture_invoices (id)); +-- A column moved to bigint whose sequence stayed integer. +CREATE TABLE fixture_migrated (id serial); +ALTER TABLE fixture_migrated ALTER COLUMN id TYPE bigint; +SELECT setval('fixture_migrated_id_seq', 2000000000); -- As pgcheckup grant prints it, so the check can read the counters. GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO checkup; diff --git a/checks/integer-exhaustion/fixtures/healthy.sql b/checks/integer-exhaustion/fixtures/healthy.sql index f12ce55..ec78817 100644 --- a/checks/integer-exhaustion/fixtures/healthy.sql +++ b/checks/integer-exhaustion/fixtures/healthy.sql @@ -4,5 +4,8 @@ SELECT setval('fixture_orders_id_seq', 2000000000); CREATE TABLE fixture_invoices (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY); SELECT setval(pg_get_serial_sequence('fixture_invoices', 'id'), 1500000000); CREATE TABLE fixture_payments (invoice_id bigint REFERENCES fixture_invoices (id)); +-- A cycling sequence near its maximum starts over by design. +CREATE SEQUENCE fixture_tickets AS integer MAXVALUE 9999 CYCLE; +SELECT setval('fixture_tickets', 9990); -- As pgcheckup grant prints it, so the check can read the counters. GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO checkup; From 7b96d4c85870cbdf352eb1f7abc93d2dd396ce40 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:40:02 -0500 Subject: [PATCH 35/41] fix(checks): report temporary tables in the wraparound checks --- checks/multixact-wraparound/check.md | 6 +++--- checks/multixact-wraparound/check.sql | 8 ++++---- checks/multixact-wraparound/fixtures/fires.sql | 2 ++ checks/xid-wraparound/check.md | 6 +++--- checks/xid-wraparound/check.sql | 8 ++++---- checks/xid-wraparound/fixtures/fires.sql | 2 ++ 6 files changed, 18 insertions(+), 14 deletions(-) diff --git a/checks/multixact-wraparound/check.md b/checks/multixact-wraparound/check.md index 8e22fc4..35c623c 100644 --- a/checks/multixact-wraparound/check.md +++ b/checks/multixact-wraparound/check.md @@ -8,8 +8,8 @@ privileges: [] thresholds: warning_age: 500000000 critical_age: 1500000000 -message: "[Database {database}][Table {table_name}] has used {mxid_age:count} of its 2.1 billion multixact IDs." -fix: "[connect to {database} and scan it to find its oldest tables, then freeze them.][VACUUM (FREEZE, VERBOSE) {table_name};]" +message: "[Database {database}][Table {table_name}][Temporary table {temp_table}] has used {mxid_age:count} of its 2.1 billion multixact IDs." +fix: "[connect to {database} and scan it to find its oldest tables, then freeze them.][VACUUM (FREEZE, VERBOSE) {table_name};][only the session that created {temp_table} can vacuum it, so have it drop the table, or end that session.]" --- ## What breaks @@ -18,7 +18,7 @@ When more than one transaction locks the same row, as foreign keys and `SELECT Autovacuum starts an anti-wraparound vacuum at 400 million by default (`autovacuum_multixact_freeze_max_age`). Workloads with many foreign keys, or many concurrent row locks, use multixacts fastest. -This check measures tables in the database it connects to, and every other database in the cluster as a whole. It lists at most the 20 oldest tables. +This check measures tables in the database it connects to, and every other database in the cluster as a whole. It lists at most the 20 oldest tables, including temporary ones: only the session that created a temporary table can vacuum it, so a pooled connection that keeps one for months ages the whole database. ## Fix diff --git a/checks/multixact-wraparound/check.sql b/checks/multixact-wraparound/check.sql index 4faed97..c676c3e 100644 --- a/checks/multixact-wraparound/check.sql +++ b/checks/multixact-wraparound/check.sql @@ -4,6 +4,7 @@ SELECT d.datname AS subject, CASE WHEN mxid_age(d.datminmxid) >= @critical_age THEN 'critical' END AS severity, d.datname AS database, NULL::text AS table_name, + NULL::text AS temp_table, mxid_age(d.datminmxid)::bigint AS mxid_age FROM pg_database AS d WHERE d.datallowconn @@ -15,15 +16,14 @@ SELECT * FROM ( SELECT format('%I.%I', n.nspname, c.relname), CASE WHEN greatest(mxid_age(c.relminmxid), mxid_age(t.relminmxid)) >= @critical_age THEN 'critical' END, NULL::text, - format('%I.%I', n.nspname, c.relname), + CASE WHEN c.relpersistence <> 't' THEN format('%I.%I', n.nspname, c.relname) END, + CASE WHEN c.relpersistence = 't' THEN format('%I.%I', n.nspname, c.relname) END, greatest(mxid_age(c.relminmxid), mxid_age(t.relminmxid))::bigint FROM pg_class AS c JOIN pg_namespace AS n ON n.oid = c.relnamespace LEFT JOIN pg_class AS t ON t.oid = c.reltoastrelid WHERE c.relkind IN ('r', 'm') - -- Only the session that owns a temporary table can vacuum it. - AND c.relpersistence <> 't' AND greatest(mxid_age(c.relminmxid), mxid_age(t.relminmxid)) >= @warning_age - ORDER BY 5 DESC + ORDER BY 6 DESC LIMIT 20 ) AS oldest diff --git a/checks/multixact-wraparound/fixtures/fires.sql b/checks/multixact-wraparound/fixtures/fires.sql index 04396ae..6f95926 100644 --- a/checks/multixact-wraparound/fixtures/fires.sql +++ b/checks/multixact-wraparound/fixtures/fires.sql @@ -2,6 +2,8 @@ -- Locking a row, then updating it in a savepoint, makes one multixact, which ages every table -- that hasn't been frozen since. CREATE TABLE fixture_accounts (id int PRIMARY KEY, balance int); +-- The fixture's session keeps this until the check has run. +CREATE TEMP TABLE fixture_scratch (id int); INSERT INTO fixture_accounts VALUES (1, 0); BEGIN; SELECT * FROM fixture_accounts FOR SHARE; diff --git a/checks/xid-wraparound/check.md b/checks/xid-wraparound/check.md index 6dc7b72..8970218 100644 --- a/checks/xid-wraparound/check.md +++ b/checks/xid-wraparound/check.md @@ -8,8 +8,8 @@ privileges: [] thresholds: warning_age: 500000000 critical_age: 1500000000 -message: "[Database {database}][Table {table_name}] has used {xid_age:count} of its 2.1 billion transaction IDs." -fix: "[connect to {database} and scan it to find its oldest tables, then freeze them.][VACUUM (FREEZE, VERBOSE) {table_name};]" +message: "[Database {database}][Table {table_name}][Temporary table {temp_table}] has used {xid_age:count} of its 2.1 billion transaction IDs." +fix: "[connect to {database} and scan it to find its oldest tables, then freeze them.][VACUUM (FREEZE, VERBOSE) {table_name};][only the session that created {temp_table} can vacuum it, so have it drop the table, or end that session.]" --- ## What breaks @@ -20,7 +20,7 @@ Long before that, at 200 million by default (`autovacuum_freeze_max_age`), autov The usual cause is something that holds vacuum back: a long transaction, a forgotten prepared transaction or an inactive replication slot. The checks `long-transaction`, `prepared-transaction-orphaned` and `replication-slot-inactive` look for those. -This check measures tables in the database it connects to, and every other database in the cluster as a whole. It lists at most the 20 oldest tables. +This check measures tables in the database it connects to, and every other database in the cluster as a whole. It lists at most the 20 oldest tables, including temporary ones: only the session that created a temporary table can vacuum it, so a pooled connection that keeps one for months ages the whole database. ## Fix diff --git a/checks/xid-wraparound/check.sql b/checks/xid-wraparound/check.sql index f416fd5..4a29240 100644 --- a/checks/xid-wraparound/check.sql +++ b/checks/xid-wraparound/check.sql @@ -4,6 +4,7 @@ SELECT d.datname AS subject, CASE WHEN age(d.datfrozenxid) >= @critical_age THEN 'critical' END AS severity, d.datname AS database, NULL::text AS table_name, + NULL::text AS temp_table, age(d.datfrozenxid)::bigint AS xid_age FROM pg_database AS d WHERE d.datallowconn @@ -15,15 +16,14 @@ SELECT * FROM ( SELECT format('%I.%I', n.nspname, c.relname), CASE WHEN greatest(age(c.relfrozenxid), age(t.relfrozenxid)) >= @critical_age THEN 'critical' END, NULL::text, - format('%I.%I', n.nspname, c.relname), + CASE WHEN c.relpersistence <> 't' THEN format('%I.%I', n.nspname, c.relname) END, + CASE WHEN c.relpersistence = 't' THEN format('%I.%I', n.nspname, c.relname) END, greatest(age(c.relfrozenxid), age(t.relfrozenxid))::bigint FROM pg_class AS c JOIN pg_namespace AS n ON n.oid = c.relnamespace LEFT JOIN pg_class AS t ON t.oid = c.reltoastrelid WHERE c.relkind IN ('r', 'm') - -- Only the session that owns a temporary table can vacuum it. - AND c.relpersistence <> 't' AND greatest(age(c.relfrozenxid), age(t.relfrozenxid)) >= @warning_age - ORDER BY 5 DESC + ORDER BY 6 DESC LIMIT 20 ) AS oldest diff --git a/checks/xid-wraparound/fixtures/fires.sql b/checks/xid-wraparound/fixtures/fires.sql index 444cecd..9aa5f47 100644 --- a/checks/xid-wraparound/fixtures/fires.sql +++ b/checks/xid-wraparound/fixtures/fires.sql @@ -1,6 +1,8 @@ -- threshold warning_age = 3 -- Consuming transaction IDs ages every table that hasn't been frozen since. CREATE TABLE fixture_orders (id int); +-- The fixture's session keeps this until the check has run. +CREATE TEMP TABLE fixture_scratch (id int); SELECT txid_current(); SELECT txid_current(); SELECT txid_current(); From 2261448e4d0d657f8662e3cd4125731b5794a206 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:40:03 -0500 Subject: [PATCH 36/41] fix(checks): judge archiving failures by how long a segment has waited --- checks/wal-archiving-failing/check.md | 6 +++--- checks/wal-archiving-failing/check.sql | 12 +++++++----- checks/wal-archiving-failing/fixtures/fires.sql | 1 + 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/checks/wal-archiving-failing/check.md b/checks/wal-archiving-failing/check.md index 7401974..9894635 100644 --- a/checks/wal-archiving-failing/check.md +++ b/checks/wal-archiving-failing/check.md @@ -7,8 +7,8 @@ min_version: 14 privileges: [pg_monitor] skip_on: [rds, aurora, cloudsql, azure, supabase, neon] thresholds: - max_ready_age: 1h -message: "[archive_mode is {unset_mode}, but no archive_command is set, so WAL piles up in pg_wal and none of it is archived.][WAL archiving has been failing for {failing_for}, so point-in-time recovery has a gap and WAL piles up in pg_wal.][The oldest of {ready_count:count} WAL segments has waited {waiting_for} to be archived, so the archiver seems stuck.]" + min_duration: 1h +message: "[archive_mode is {unset_mode}, but no archive_command is set, so WAL piles up in pg_wal and none of it is archived.][WAL archiving is failing, and the oldest segment has waited {failing_for}, so point-in-time recovery has a gap and WAL piles up in pg_wal.][The oldest of {ready_count:count} WAL segments has waited {waiting_for} to be archived, so the archiver seems stuck.]" fix: "read the server log for the archiver's errors. Set archive_command if it is empty, fix it or its destination if it fails or hangs (a full or unreachable destination is typical), or turn archive_mode off if you don't archive." --- @@ -19,7 +19,7 @@ With `archive_mode` on, Postgres keeps each WAL segment until `archive_command` - Point-in-time recovery has a gap from the last segment archived. A restore can't get past it. - The segments that wait pile up in `pg_wal` until the disk is full, and Postgres stops accepting writes. -The check reports three cases: `archive_mode` on without a command, a command that keeps failing, and an archiver that makes no progress for an hour (`max_ready_age`) without logging a failure. It never prints `archive_command`, which can hold credentials. +The check reports three cases: `archive_mode` on without a command, a command that keeps failing, and an archiver that makes no progress without logging a failure. For the last two, a segment must have waited an hour (`min_duration`), so a brief failure that the archiver retries past isn't reported. It never prints `archive_command`, which can hold credentials. Managed providers archive WAL themselves, so the check is skipped there. diff --git a/checks/wal-archiving-failing/check.sql b/checks/wal-archiving-failing/check.sql index 5042718..4a18025 100644 --- a/checks/wal-archiving-failing/check.sql +++ b/checks/wal-archiving-failing/check.sql @@ -6,8 +6,7 @@ WITH settings AS ( AND coalesce(current_setting('archive_library', true), '') = '' AS unset ), archiver AS ( - SELECT coalesce(a.last_failed_time > coalesce(a.last_archived_time, '-infinity'), false) AS failing, - coalesce(a.last_archived_time, a.stats_reset, pg_postmaster_start_time()) AS good_since + SELECT coalesce(a.last_failed_time > coalesce(a.last_archived_time, '-infinity'), false) AS failing FROM pg_stat_archiver AS a ), ready AS ( @@ -23,12 +22,15 @@ SELECT 'archive_command' AS subject, FROM settings AS s WHERE s.archive_mode <> 'off' AND s.unset UNION ALL -SELECT 'archiver', NULL, now() - a.good_since, NULL, NULL -FROM settings AS s, archiver AS a +-- Both of these go by how long the oldest segment has waited: the archiver retries a brief +-- failure, and on a quiet server the last success can be long ago without anything wrong. +SELECT 'archiver', NULL, now() - r.oldest, NULL, NULL +FROM settings AS s, archiver AS a, ready AS r WHERE s.archive_mode <> 'off' AND NOT s.unset AND a.failing + AND r.oldest < now() - @min_duration UNION ALL -- A hung archiver logs no failure, so only the age of the waiting segments shows it. SELECT 'archive_status', NULL, NULL, r.segments, now() - r.oldest FROM settings AS s, archiver AS a, ready AS r WHERE s.archive_mode <> 'off' AND NOT s.unset AND NOT a.failing - AND r.oldest < now() - @max_ready_age + AND r.oldest < now() - @min_duration diff --git a/checks/wal-archiving-failing/fixtures/fires.sql b/checks/wal-archiving-failing/fixtures/fires.sql index 5885979..3b97a40 100644 --- a/checks/wal-archiving-failing/fixtures/fires.sql +++ b/checks/wal-archiving-failing/fixtures/fires.sql @@ -1,5 +1,6 @@ -- server archive_mode = on -- server archive_command = false +-- threshold min_duration = 0s -- `false` always fails. Switching WAL gives the archiver a segment to try, then this waits -- until the archiver has reported its first failure. CREATE TABLE fixture_wal AS SELECT g FROM generate_series(1, 1000) AS g; From 280d042eed9d036b21d990fd231a0ef89930e348 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:40:04 -0500 Subject: [PATCH 37/41] fix(checks): report sessions of a dropped role in long-transaction --- checks/long-transaction/check.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checks/long-transaction/check.md b/checks/long-transaction/check.md index 04e2e0b..67108b1 100644 --- a/checks/long-transaction/check.md +++ b/checks/long-transaction/check.md @@ -8,7 +8,7 @@ privileges: [pg_read_all_stats] thresholds: min_duration: 1h min_idle: 10min -message: "Session {subject} ({role_name} on {database}) has had a transaction open for {open_for}[ and has been idle in it for {idle_for}]." +message: "Session {subject}[ ({role_name} on {database})] has had a transaction open for {open_for}[ and has been idle in it for {idle_for}]." fix: | if the session is stuck or abandoned, end it: SELECT pg_terminate_backend({subject}); From c71144a95ff922f5ebe8936000aea9ceffe0976d Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:40:05 -0500 Subject: [PATCH 38/41] docs(json): document null values and fix a check title --- docs/json.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/json.md b/docs/json.md index d7ccd3d..c5a6e02 100644 --- a/docs/json.md +++ b/docs/json.md @@ -25,13 +25,13 @@ "severity": "warning", "message": "Slot debezium has been inactive for 3 days and is holding 48 GB of WAL.", "fix": "restart its consumer, or drop the slot:\nSELECT pg_drop_replication_slot('debezium');", - "values": { "subject": "debezium", "inactive_for": 259200, "retained_wal": 51539607552 } + "values": { "subject": "debezium", "inactive_for": 259200, "retained_wal": 51539607552, "xmin_age": null } } ] }, { "id": "wal-archiving-failing", - "title": "Failing WAL archiving", + "title": "WAL archiving failing", "category": "wal", "status": "skipped", "reason": "managed by Amazon RDS", @@ -57,4 +57,4 @@ | `checks[].findings[].subject` | The object the finding is about, such as a slot or table name. | | `checks[].findings[].severity` | `critical`, `warning` or `info`. | | `checks[].findings[].message`, `.fix` | The text the terminal report prints. pgcheckup never runs the fix. | -| `checks[].findings[].values` | The facts behind the message, one per column it uses: sizes and counts as numbers, durations in seconds, timestamps as ISO 8601 UTC strings. The names differ per check. | +| `checks[].findings[].values` | The facts behind the message, one per column it uses: sizes and counts as numbers, durations in seconds, timestamps as ISO 8601 UTC strings. A value the message left out, because it was NULL, is `null`. The names differ per check. | From d829e5cf04a2019b02e5ef49c0988dd55ab2108f Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:55:10 -0500 Subject: [PATCH 39/41] feat(cli): wrap messages to the terminal width --- src/Pgcheckup/Cli/PgcheckupCli.cs | 15 +++++- src/Pgcheckup/Cli/TerminalReport.cs | 49 ++++++++++++++++--- .../Cli/TerminalReportTests.cs | 26 +++++++++- 3 files changed, 80 insertions(+), 10 deletions(-) diff --git a/src/Pgcheckup/Cli/PgcheckupCli.cs b/src/Pgcheckup/Cli/PgcheckupCli.cs index 8bae5fc..10c4ea4 100644 --- a/src/Pgcheckup/Cli/PgcheckupCli.cs +++ b/src/Pgcheckup/Cli/PgcheckupCli.cs @@ -163,6 +163,19 @@ public static async Task RunAsync( } } + // Files and pipes get unwrapped lines; so does a console whose width can't be read. + private static int? TerminalWidth() + { + try + { + return Console.WindowWidth > 0 ? Console.WindowWidth : null; + } + catch (IOException) + { + return null; + } + } + private static int Explain(IReadOnlyList checks, string id, TextWriter output, TextWriter error) { if (checks.FirstOrDefault(c => c.Id == id) is not { } check) @@ -231,7 +244,7 @@ private static async Task ScanAsync( output.Write(MarkdownReport.Write(report)); break; default: - TerminalReport.Write(output, report, TerminalReport.UseColor(outputRedirected, environment)); + TerminalReport.Write(output, report, TerminalReport.UseColor(outputRedirected, environment), outputRedirected ? null : TerminalWidth()); break; } diff --git a/src/Pgcheckup/Cli/TerminalReport.cs b/src/Pgcheckup/Cli/TerminalReport.cs index 60b17f3..1724107 100644 --- a/src/Pgcheckup/Cli/TerminalReport.cs +++ b/src/Pgcheckup/Cli/TerminalReport.cs @@ -1,3 +1,4 @@ +using System.Text; using Pgcheckup.Checks; using Pgcheckup.Engine; @@ -28,8 +29,12 @@ public static bool UseColor(bool outputRedirected, IReadOnlyDictionaryWhere to write. /// What the scan found. /// Whether to color the status words. The words are always there. + /// + /// The terminal's width, to wrap messages at, or not to wrap. Fixes are + /// never wrapped: they are SQL to copy, and a line break inside a quoted name would change it. + /// /// Findings are ordered by severity, most severe first, then by check id. - public static void Write(TextWriter output, ScanReport report, bool color) + public static void Write(TextWriter output, ScanReport report, bool color, int? width = null) { var server = report.Server; var provider = server.Provider is { } managed ? $" · {managed.Name}" : ""; @@ -39,15 +44,15 @@ public static void Write(TextWriter output, ScanReport report, bool color) foreach (var finding in ReportText.OrderedFindings(report)) { WriteLabel(output, finding.Severity.ToString().ToUpperInvariant(), SeverityColor(finding.Severity), finding.CheckId, color); - WriteIndented(output, finding.Message, new string(' ', Indent), new string(' ', Indent)); - WriteIndented(output, finding.Fix, new string(' ', Indent) + FixLabel, new string(' ', Indent + FixLabel.Length)); + WriteIndented(output, finding.Message, new string(' ', Indent), new string(' ', Indent), width); + WriteIndented(output, finding.Fix, new string(' ', Indent) + FixLabel, new string(' ', Indent + FixLabel.Length), null); output.WriteLine(); } foreach (var errored in ReportText.Errored(report)) { WriteLabel(output, "ERRORED", ErroredColor, errored.Check.Id, color); - WriteIndented(output, ReportText.Sentence(errored.Reason), new string(' ', Indent), new string(' ', Indent)); + WriteIndented(output, ReportText.Sentence(errored.Reason), new string(' ', Indent), new string(' ', Indent), width); output.WriteLine(); } @@ -60,15 +65,45 @@ private static void WriteLabel(TextWriter output, string label, string colorCode output.WriteLine($"{painted}{new string(' ', Indent - label.Length)}{checkId}"); } - private static void WriteIndented(TextWriter output, string text, string first, string rest) + private static void WriteIndented(TextWriter output, string text, string first, string rest, int? width) { - var lines = text.Split('\n'); - for (var i = 0; i < lines.Length; i++) + var lines = text.Split('\n').SelectMany(line => width is { } w ? Wrap(line, w - rest.Length) : [line]).ToList(); + for (var i = 0; i < lines.Count; i++) { output.WriteLine((i == 0 ? first : rest) + lines[i]); } } + // Breaks at spaces. A word longer than the line stays whole, and a very narrow terminal gets + // no wrapping rather than a column of single words. + private static IEnumerable Wrap(string line, int available) + { + if (available < 20 || line.Length <= available) + { + yield return line; + yield break; + } + + var current = new StringBuilder(); + foreach (var word in line.Split(' ')) + { + if (current.Length > 0 && current.Length + 1 + word.Length > available) + { + yield return current.ToString(); + current.Clear(); + } + + if (current.Length > 0) + { + current.Append(' '); + } + + current.Append(word); + } + + yield return current.ToString(); + } + private const string ErroredColor = "1;35"; private static string SeverityColor(Severity severity) => severity switch diff --git a/tests/Pgcheckup.Tests/Cli/TerminalReportTests.cs b/tests/Pgcheckup.Tests/Cli/TerminalReportTests.cs index b8b960f..46e23f3 100644 --- a/tests/Pgcheckup.Tests/Cli/TerminalReportTests.cs +++ b/tests/Pgcheckup.Tests/Cli/TerminalReportTests.cs @@ -18,13 +18,35 @@ private static Finding Finding(string checkId, Severity severity, string message private static CheckResult Found(string id, params Finding[] findings) => new(Check(id), CheckStatus.Found, findings); - private static string Render(ScanReport report, bool color = false) + private static string Render(ScanReport report, bool color = false, int? width = null) { var output = new StringWriter { NewLine = "\n" }; - TerminalReport.Write(output, report, color); + TerminalReport.Write(output, report, color, width); return output.ToString(); } + [Fact] + public void Wraps_messages_to_the_terminal_width_but_never_the_fix() + { + var report = new ScanReport(Server, + [ + Found("replication-slot-unbounded", Finding("replication-slot-unbounded", Severity.Warning, + "max_slot_wal_keep_size is -1 and this server has replication slots, so one stuck slot can keep WAL until the disk fills.", + "cap it below the free space on the WAL disk:\nALTER SYSTEM SET max_slot_wal_keep_size = '50GB'; SELECT pg_reload_conf();")), + ]); + + Assert.Contains( + """ + WARNING replication-slot-unbounded + max_slot_wal_keep_size is -1 and this server has replication + slots, so one stuck slot can keep WAL until the disk fills. + Fix: cap it below the free space on the WAL disk: + ALTER SYSTEM SET max_slot_wal_keep_size = '50GB'; SELECT pg_reload_conf(); + + """.ReplaceLineEndings("\n"), + Render(report, width: 72)); + } + [Fact] public void Writes_each_finding_with_its_fix_under_a_header_and_above_a_summary() { From ad1f0b07101999c4174ea04acf743663addbf7cb Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:55:12 -0500 Subject: [PATCH 40/41] docs(readme): re-record the scan with the full catalog --- README.md | 2 +- docs/scan.gif | Bin 45604 -> 96333 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4212466..626fec6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Most of these failures show up in the system catalogs weeks ahead: a table's tra > **Status:** early development. The 15 checks of v0.1 run end to end, but there is no release to install yet. See [ROADMAP.md](ROADMAP.md). -![pgcheckup scanning a database whose inactive replication slot is holding 1.07 GB of WAL](docs/scan.gif) +![pgcheckup scanning a database: an inactive replication slot holding 1.07 GB of WAL, no WAL limit for slots, and no timeout for idle transactions](docs/scan.gif) ## How it works diff --git a/docs/scan.gif b/docs/scan.gif index 78a4d0295201de617134e6d32cb318e80dd30009..fb70d3c2ff12733965f235341fb37dd4338f7244 100644 GIT binary patch literal 96333 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 $@pr2q2wcZNmaU2IJ?Kns$_T? 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 UmJpxbJ<0lSgS7wG`?Bso0JI`uV*mgE From ef12d5b389e8f1c70101871583ad104968adc601 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Sat, 26 Sep 2026 19:55:14 -0500 Subject: [PATCH 41/41] docs(roadmap): tick the v0.1 checks --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index f33a4b1..618bc7d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -62,7 +62,7 @@ pgcheckup is a read-only CLI (.NET 10, NativeAOT) that checks a PostgreSQL datab - [x] Engine: server version and provider detection, and a privilege probe, followed by every applicable check. A check that errors or times out is reported as errored, and the others still run. - [x] Provider detection, tested by simulating each provider's roles and settings in fixtures. - [x] Desk research for the catalog: go through public Postgres postmortems (danluu/post-mortems, engineering blogs) and DBA Stack Exchange, list the failures that recur, and adjust the table below to match. Every check gets at least one **Seen in** link. -- [ ] The v0.1 checks: +- [x] The v0.1 checks: | Check | Catches | |---|---|