diff --git a/.github/workflows/README.md b/.github/workflows/README.md index c3c1840..99f4331 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -2,7 +2,7 @@ This repo keeps `managed-db-validate.yml` as a reusable validation workflow. -`managed-db-validate.yml` installs `pgFirstAid.sql`, recreates `view_pgFirstAid_managed.sql`, and runs integration tests, including the pgTAP-backed checks in the integration harness. +`managed-db-validate.yml` installs `pgFirstAid.sql`, recreates `view_pgFirstAid_managed.sql`, runs integration tests including the pgTAP-backed checks, and then runs `testing/seed_and_validate.py --managed` against the same target. ## Supported connection modes diff --git a/.github/workflows/integration-pg-matrix.yml b/.github/workflows/integration-pg-matrix.yml index 23c7e2d..7c21d3a 100644 --- a/.github/workflows/integration-pg-matrix.yml +++ b/.github/workflows/integration-pg-matrix.yml @@ -30,6 +30,7 @@ jobs: defaults: run: + shell: bash -l {0} working-directory: testing/integration env: @@ -48,13 +49,12 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.14" - - - name: Install uv - uses: astral-sh/setup-uv@v4 + - name: Add Nix profile paths + run: | + echo "/run/current-system/sw/bin" >> "$GITHUB_PATH" + echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" + echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH" + echo "/etc/profiles/per-user/$USER/bin" >> "$GITHUB_PATH" - name: Validate required PG env vars run: | @@ -77,6 +77,14 @@ jobs: fi psql --version + - name: Verify uv is installed on runner + run: | + if ! command -v uv >/dev/null 2>&1; then + echo "::error::uv not found on runner. Install uv on the self-hosted NixOS runner." + exit 1 + fi + uv --version + - name: Sync dependencies run: uv sync @@ -91,3 +99,6 @@ jobs: - name: Run integration tests run: uv run python -m pytest tests/integration -m integration + + - name: Run seed and validate harness + run: uv run python ../seed_and_validate.py --managed diff --git a/.github/workflows/managed-db-validate.yml b/.github/workflows/managed-db-validate.yml index eb85e73..9ac523e 100644 --- a/.github/workflows/managed-db-validate.yml +++ b/.github/workflows/managed-db-validate.yml @@ -62,6 +62,7 @@ jobs: contents: read defaults: run: + shell: bash -l {0} working-directory: testing/integration env: PGHOST: ${{ inputs.pg_host }} @@ -79,6 +80,13 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Add Nix profile paths + run: | + echo "/run/current-system/sw/bin" >> "$GITHUB_PATH" + echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" + echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH" + echo "/etc/profiles/per-user/$USER/bin" >> "$GITHUB_PATH" + - name: Configure AWS credentials if: ${{ inputs.cloud_provider == 'aws' }} uses: aws-actions/configure-aws-credentials@v4 @@ -127,14 +135,6 @@ jobs: echo "PGHOST=$host" >> "$GITHUB_ENV" - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.14" - - - name: Install uv - uses: astral-sh/setup-uv@v4 - - name: Validate required PG env vars run: | missing=0 @@ -156,6 +156,14 @@ jobs: fi psql --version + - name: Verify uv is installed on runner + run: | + if ! command -v uv >/dev/null 2>&1; then + echo "::error::uv not found on runner. Install uv on the self-hosted NixOS runner." + exit 1 + fi + uv --version + - name: Sync dependencies run: uv sync @@ -169,3 +177,6 @@ jobs: - name: Run integration tests (includes pgTAP suite) run: uv run python -m pytest tests/integration -m integration + + - name: Run seed and validate harness + run: uv run python ../seed_and_validate.py --managed diff --git a/README.md b/README.md index e3105b6..5c7ed39 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,7 @@ pgFirstAid is designed to be lightweight and safe to run on production systems: - Query and health-check coverage is validated with pgTAP assertions grouped by severity. - Integration tests cover live runtime behavior, function/view parity, and checks that need concurrent sessions or timing control. - A coverage guard ensures every `check_name` in `pgFirstAid.sql` is referenced by at least one pgTAP assertion. +- The GitHub Actions validation workflows also run `testing/seed_and_validate.py --managed` against live database targets to confirm the seeded checks actually fire end-to-end. - Managed database validation is exercised through the reusable workflow in `.github/workflows/managed-db-validate.yml`. > **Important:** We currently validate managed-database testing against AWS, but we do not have the funding or credits needed to keep Azure and GCP test environments running. If you have access to Azure Database for PostgreSQL or GCP Cloud SQL and want to help validate pgFirstAid there, we would be happy to have the help. diff --git a/docs/superpowers/specs/2026-04-15-seed-and-validate-design.md b/docs/superpowers/specs/2026-04-15-seed-and-validate-design.md new file mode 100644 index 0000000..9d9ad57 --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-seed-and-validate-design.md @@ -0,0 +1,188 @@ +# pgFirstAid Seed & Validation Script — Design Spec + +**Date:** 2026-04-15 +**Branch:** feature/load_testing +**Status:** Approved + +--- + +## Goal + +A Python script that creates a throwaway PostgreSQL database, seeds it with data that triggers every health check in `pgFirstAid.sql`, runs the function, and reports which checks fired vs. which were missing. Exit code 0 = all expected checks fired; exit code 1 = gaps found. + +--- + +## Entry Point + +**`testing/seed_and_validate.py`** + +Single script. No third-party dependencies beyond `psycopg` (psycopg3). Invoked as: + +```bash +python testing/seed_and_validate.py [--host localhost] [--port 5432] [--user postgres] +``` + +Connection parameters default to standard env vars (`PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`) with CLI overrides. + +--- + +## Database Lifecycle + +1. Connect to `postgres` (maintenance database) as superuser +2. Drop `pgfirstaid_test` if it exists +3. Create `pgfirstaid_test` +4. Run all seeding against `pgfirstaid_test` +5. Drop `pgfirstaid_test` on exit (success or failure) via a `finally` block + +--- + +## Threshold Patching + +`pgFirstAid.sql` contains thresholds that are impractical in a test environment. The script reads the file, applies regex substitutions in memory, and installs the patched function into the test DB. The original file is never modified. + +| Check | Original threshold | Test threshold | +|---|---|---| +| Unused Large Index | `> 104857600` (100MB) | `> 8192` (8KB) | +| Tables larger than 100GB | `> 107374182400` | `> 1048576` (1MB) | +| Tables larger than 50GB | `between 53687091200 and 107374182400` | `between 524288 and 1048576` | + +--- + +## SQL Seed Files + +Located in `testing/healthcheck_seed/`. Each file is idempotent and targets the `pgfirstaid_seed` schema. + +### `01_seed_static_checks.sql` + +Seeds all structural checks that fire from schema state alone: + +| Check triggered | Seeded object | +|---|---| +| Missing Primary Key (CRITICAL) | Table with no PK | +| Unused Large Index (CRITICAL) | Index on a table that is never scanned, sized above 8KB threshold | +| Duplicate Index (HIGH) | Two identical indexes on the same table and columns | +| Table with more than 200 columns (HIGH) | Table with 201 columns | +| Missing Statistics (HIGH) | Table with >1000 inserts, never analyzed | +| Outdated Statistics (MEDIUM) | Table with dead tuples exceeding autovacuum threshold | +| Table with more than 50 columns (MEDIUM) | Table with 60 columns | +| Low Index Efficiency (MEDIUM) | Non-selective index scanned >100 times; each scan reads many tuples (seeded via a loop of 110 queries using a predicate that matches most rows) | +| Excessive Sequential Scans (MEDIUM) | Table with >1000 seq scans produced by a seeding loop of sequential scans against a large table | +| Missing FK Index (LOW) | Table with FK constraint and no supporting index | +| Table With Single Or No Columns (LOW) | Table with 1 column | +| Table With No Activity Since Stats Reset (LOW) | Table created but never read or written | +| Role Never Logged In (LOW) | Role with LOGIN created but never connected | +| Empty Table (LOW) | Table with 0 rows | +| Index With Very Low Usage (LOW) | Index with 1–99 scans and size > 1MB — seeded via a loop that scans the index a small number of times | + +> **Note:** Low Index Efficiency requires `idx_scan > 100` and `idx_tup_read / idx_scan > 1000`. The seed loop runs 110 queries using a predicate that hits the indexed column but matches a large fraction of rows, so each scan reads thousands of tuples. + +### `02_seed_pg_stat_statements.sql` + +Existing file. Seeds all pg_stat_statements checks. No changes needed. + +--- + +## Live Session Strategy + +Three background threads open `psycopg3` connections and hold them for the duration of the validation window. + +| Thread | What it does | Checks triggered | +|---|---|---| +| **Blocker** | Opens `BEGIN`, runs `UPDATE` on a row, calls `pg_sleep(600)`, then `ROLLBACK` | Current Blocked/Blocking Queries, Lock-Wait-Heavy Active Queries | +| **Blocked** | Waits for blocker to establish, then attempts `UPDATE` on the same row | Current Blocked/Blocking Queries, Lock-Wait-Heavy Active Queries | +| **Idle-in-transaction** | Opens `BEGIN`, runs `SELECT 1`, then sleeps in Python for 6 minutes (transaction stays open) | Idle In Transaction Over 5 Minutes | +| **Long query** | Runs `SELECT pg_sleep(360)` | Long Running Queries (>5 min), Top 10 Expensive Active Queries (>30 sec) | + +### Startup sequencing + +1. Start Blocker thread; wait until its lock is confirmed held (poll `pg_locks`) +2. Start Blocked thread; wait until it appears in `pg_stat_activity` with `wait_event_type = 'Lock'` +3. Start Idle-in-transaction thread; wait until it appears in `pg_stat_activity` with `state = 'idle in transaction'` +4. Start Long query thread; wait until it appears in `pg_stat_activity` with runtime > 30 seconds +5. Proceed to validation + +All threads are daemon threads. They are cancelled via `pg_terminate_backend()` during cleanup if they haven't exited naturally. + +--- + +## Replication Slot Guard + +```python +try: + # Requires wal_level = logical and superuser + conn.execute("SELECT pg_create_logical_replication_slot('pgfirstaid_test_slot', 'test_decoding')") + # Slot is inactive by definition (no consumer attached) + # Triggers: Inactive Replication Slots (HIGH) + replication_slot_created = True +except psycopg.errors.ObjectNotInPrerequisiteState: + print("SKIP: wal_level != logical — replication slot checks not seeded") + replication_slot_created = False +except psycopg.errors.InsufficientPrivilege: + print("SKIP: insufficient privilege to create replication slot") + replication_slot_created = False +``` + +The slot is dropped during cleanup if it was created. + +--- + +## Checks Not Seeded + +| Check | Reason | +|---|---| +| High Connection Count (>50 active) | Requires 50+ concurrent connections — out of scope for a seed script; pgbench covers this (existing `07_pgbench_active_query.sql`) | +| Inactive Replication Slots Near Max WAL | Requires sustained WAL generation to push retained WAL near `safe_wal_size` — not deterministic in a test environment | +| shared_buffers At Default / work_mem At Default | These fire based on server config, not seeded data — always present on a default-configured server | +| Server Role (standby) | Always fires as INFO, content depends on actual server role | +| INFO checks (version, uptime, extensions, log size, etc.) | Always fire — no seeding needed | + +--- + +## Validation + +After the 6-minute idle-in-transaction window is established, run: + +```sql +SELECT check_name, count(*) AS findings +FROM pg_firstAid() +GROUP BY check_name +ORDER BY check_name; +``` + +Compare results against an expected set defined in the script. Print a table: + +``` +PASS Missing Primary Key +PASS Duplicate Index +PASS Idle In Transaction Over 5 Minutes +FAIL Replication Slots Near Max Wal Size (skipped — wal_level) +... +``` + +Exit 0 if all non-skipped checks fired. Exit 1 if any non-skipped check produced 0 rows. + +--- + +## File Layout + +``` +testing/ + seed_and_validate.py ← new: orchestrator + healthcheck_seed/ + 01_seed_static_checks.sql ← rewrite: full structural seed + 02_seed_pg_stat_statements.sql ← existing, unchanged + 03_session_blocker.sql ← kept for manual use + 04_session_blocked.sql ← kept for manual use + 05_session_idle_in_transaction.sql ← kept for manual use + 06_session_long_running_query.sql ← kept for manual use + 07_pgbench_active_query.sql ← kept for manual use + 99_validate_seed_results.sql ← kept for manual use +``` + +--- + +## Dependencies + +- Python 3.11+ +- `psycopg` (psycopg3): `pip install psycopg[binary]` +- PostgreSQL superuser access to the target server diff --git a/pgFirstAid.sql b/pgFirstAid.sql index 6be916f..fffb023 100644 --- a/pgFirstAid.sql +++ b/pgFirstAid.sql @@ -22,6 +22,7 @@ begin return; end if; + begin return query with pss as ( select @@ -264,6 +265,9 @@ with pss as ( order by ((to_jsonb(pss)->>'wal_bytes')::numeric / NULLIF(pss.calls, 0)) desc limit 10; + exception when object_not_in_prerequisite_state then + return; + end; end; $$ language plpgsql; diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f7b02f0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "pgfirstaid-testing" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "psycopg2-binary>=2.9.12", + "pytest>=8.0", +] + +[tool.pytest.ini_options] +testpaths = ["testing"] diff --git a/testing/healthcheck_seed/01_seed_static_checks.sql b/testing/healthcheck_seed/01_seed_static_checks.sql new file mode 100644 index 0000000..d43165b --- /dev/null +++ b/testing/healthcheck_seed/01_seed_static_checks.sql @@ -0,0 +1,286 @@ +-- pgFirstAid seed: structural and static checks +-- Idempotent. Run against the pgfirstaid_test database. +-- Drops and recreates the pgfirstaid_seed schema on each run. + +DROP SCHEMA IF EXISTS pgfirstaid_seed CASCADE; +CREATE SCHEMA pgfirstaid_seed; + +-- ============================================================ +-- CRITICAL: Missing Primary Key +-- ============================================================ +CREATE TABLE pgfirstaid_seed.no_pk_table ( + data text +); +INSERT INTO pgfirstaid_seed.no_pk_table +SELECT md5(g::text) FROM generate_series(1, 100) g; + +-- ============================================================ +-- CRITICAL: Unused Large Index (threshold patched to >8KB) +-- Index is created but never scanned (idx_scan = 0). +-- ============================================================ +CREATE TABLE pgfirstaid_seed.unused_idx_table ( + id bigint PRIMARY KEY, + payload text NOT NULL +); +INSERT INTO pgfirstaid_seed.unused_idx_table +SELECT g, repeat(md5(g::text), 4) +FROM generate_series(1, 10000) g; +CREATE INDEX pgfirstaid_seed_unused_large_idx + ON pgfirstaid_seed.unused_idx_table (payload); +ANALYZE pgfirstaid_seed.unused_idx_table; +-- Deliberately not querying this index. + +-- ============================================================ +-- HIGH: Duplicate Indexes +-- Two indexes with identical column sets on the same table. +-- ============================================================ +CREATE TABLE pgfirstaid_seed.dup_idx_table ( + id bigint PRIMARY KEY, + val int NOT NULL +); +INSERT INTO pgfirstaid_seed.dup_idx_table +SELECT g, g % 1000 FROM generate_series(1, 10000) g; +CREATE INDEX pgfirstaid_seed_dup_idx_a ON pgfirstaid_seed.dup_idx_table (val); +CREATE INDEX pgfirstaid_seed_dup_idx_b ON pgfirstaid_seed.dup_idx_table (val); +ANALYZE pgfirstaid_seed.dup_idx_table; + +-- ============================================================ +-- HIGH: Table with 201 columns (threshold is >200) +-- ============================================================ +DO $$ +DECLARE + col_list text := 'id bigint PRIMARY KEY'; + i int; +BEGIN + FOR i IN 1..200 LOOP + col_list := col_list || ', col_' || i || ' text'; + END LOOP; + EXECUTE 'CREATE TABLE pgfirstaid_seed.wide_table_201 (' || col_list || ')'; +END $$; + +-- ============================================================ +-- HIGH: Missing Statistics +-- Table with >1000 modifications, never analyzed. +-- autovacuum disabled to prevent background analyze. +-- ============================================================ +CREATE TABLE pgfirstaid_seed.no_stats_table ( + id bigint, + payload text +) WITH (autovacuum_enabled = false); +INSERT INTO pgfirstaid_seed.no_stats_table +SELECT g, md5(g::text) FROM generate_series(1, 2000) g; +-- Deliberately NOT running ANALYZE. + +-- ============================================================ +-- HIGH: Tables larger than 100GB (threshold patched to >1MB) +-- ~300 bytes/row * 20000 rows ≈ 6MB, safely above 1MB. +-- ============================================================ +CREATE TABLE pgfirstaid_seed.large_table ( + id bigint PRIMARY KEY, + payload text NOT NULL +); +INSERT INTO pgfirstaid_seed.large_table +SELECT g, repeat(md5(g::text), 8) +FROM generate_series(1, 20000) g; +ANALYZE pgfirstaid_seed.large_table; + +-- ============================================================ +-- MEDIUM: Tables larger than 50GB (threshold patched to 512KB–1MB) +-- ~100 bytes/row * 7000 rows ≈ 700KB, inside the 512KB–1MB band. +-- ============================================================ +CREATE TABLE pgfirstaid_seed.medium_table ( + id bigint PRIMARY KEY, + payload text NOT NULL +); +INSERT INTO pgfirstaid_seed.medium_table +SELECT g, repeat(md5(g::text), 2) +FROM generate_series(1, 7000) g; +ANALYZE pgfirstaid_seed.medium_table; + +-- ============================================================ +-- MEDIUM: Outdated Statistics +-- Dead tuples exceed autovacuum threshold; no vacuum runs. +-- With 10000 rows: vacuum threshold = 0.2*10000+50 = 2050. +-- After UPDATE all rows: n_dead_tup ≈ 10000 > 2050. +-- ============================================================ +CREATE TABLE pgfirstaid_seed.dead_tuples_table ( + id bigint PRIMARY KEY, + payload text +) WITH (autovacuum_enabled = false); +INSERT INTO pgfirstaid_seed.dead_tuples_table +SELECT g, md5(g::text) FROM generate_series(1, 10000) g; +ANALYZE pgfirstaid_seed.dead_tuples_table; +UPDATE pgfirstaid_seed.dead_tuples_table SET payload = md5(payload); +-- No VACUUM - dead tuples stay. + +-- ============================================================ +-- MEDIUM: Table with more than 50 columns (50-199 range) +-- ============================================================ +DO $$ +DECLARE + col_list text := 'id bigint PRIMARY KEY'; + i int; +BEGIN + FOR i IN 1..59 LOOP + col_list := col_list || ', col_' || i || ' text'; + END LOOP; + EXECUTE 'CREATE TABLE pgfirstaid_seed.wide_table_60 (' || col_list || ')'; +END $$; + +-- ============================================================ +-- MEDIUM: Low Index Efficiency +-- idx_scan > 100 AND idx_tup_read/idx_scan > 1000. +-- grp has 5 distinct values over 50000 rows: +-- each scan with grp = X reads ~10000 tuples. +-- 110 scans * 10000 tuples/scan -> ratio = 10000 >> 1000. +-- ============================================================ +CREATE TABLE pgfirstaid_seed.low_eff_idx_table ( + id bigint PRIMARY KEY, + grp int NOT NULL, + payload text +); +INSERT INTO pgfirstaid_seed.low_eff_idx_table +SELECT g, g % 5, md5(g::text) +FROM generate_series(1, 50000) g; +CREATE INDEX pgfirstaid_seed_low_eff_idx + ON pgfirstaid_seed.low_eff_idx_table (grp); +ANALYZE pgfirstaid_seed.low_eff_idx_table; + +DO $$ +DECLARE + dummy bigint; +BEGIN + FOR i IN 1..110 LOOP + SELECT count(*) INTO dummy + FROM pgfirstaid_seed.low_eff_idx_table + WHERE grp = (i % 5); + END LOOP; +END $$; + +-- ============================================================ +-- MEDIUM: Excessive Sequential Scans +-- seq_scan > 1000 AND seq_tup_read > seq_scan * 10000. +-- 15000 rows * 1002 scans = 15,030,000 tuples read. +-- Threshold: 1002 * 10000 = 10,020,000. 15,030,000 > 10,020,000. +-- index scan disabled inside DO block to force seq scan. +-- ============================================================ +CREATE TABLE pgfirstaid_seed.seq_scan_table ( + id bigint PRIMARY KEY, + payload text +); +INSERT INTO pgfirstaid_seed.seq_scan_table +SELECT g, md5(g::text) +FROM generate_series(1, 15000) g; +ANALYZE pgfirstaid_seed.seq_scan_table; + +DO $$ +DECLARE + dummy bigint; +BEGIN + SET LOCAL enable_indexscan = off; + SET LOCAL enable_bitmapscan = off; + FOR i IN 1..1002 LOOP + SELECT count(*) INTO dummy + FROM pgfirstaid_seed.seq_scan_table + WHERE id > 0; + END LOOP; +END $$; + +-- ============================================================ +-- LOW: Missing FK Index +-- FK on parent_id with no supporting index. +-- ============================================================ +CREATE TABLE pgfirstaid_seed.fk_parent_table ( + id bigint PRIMARY KEY +); +INSERT INTO pgfirstaid_seed.fk_parent_table +SELECT g FROM generate_series(1, 100) g; + +CREATE TABLE pgfirstaid_seed.fk_child_table ( + id bigint PRIMARY KEY, + parent_id bigint REFERENCES pgfirstaid_seed.fk_parent_table (id) + -- Deliberately no index on parent_id. +); + +-- ============================================================ +-- LOW: Table With Single Or No Columns +-- ============================================================ +CREATE TABLE pgfirstaid_seed.single_col_table ( + only_col text +); + +-- ============================================================ +-- LOW: Table With No Activity Since Stats Reset +-- Created but never read or written; all stat counters stay at 0. +-- ============================================================ +CREATE TABLE pgfirstaid_seed.inactive_table ( + id bigint PRIMARY KEY, + data text +); +-- Deliberately not inserting, updating, or querying. + +-- ============================================================ +-- LOW: Role Never Logged In +-- Role has LOGIN privilege but has never connected. +-- Drop first so re-runs start clean (roles are cluster-level, +-- not dropped by DROP SCHEMA CASCADE). +-- ============================================================ +DROP ROLE IF EXISTS pgfirstaid_seed_role; +-- PASSWORD NULL: the check fires on schema structure, not authentication. +CREATE ROLE pgfirstaid_seed_role LOGIN PASSWORD NULL; + +-- ============================================================ +-- LOW: Empty Table +-- reltuples = 0 AND n_live_tup = 0. +-- ============================================================ +CREATE TABLE pgfirstaid_seed.empty_table ( + id bigint PRIMARY KEY, + data text +); +ANALYZE pgfirstaid_seed.empty_table; + +-- ============================================================ +-- LOW: Index With Very Low Usage +-- idx_scan > 0 AND idx_scan < 100 AND pg_relation_size > 1MB. +-- 25000 rows * ~70 bytes/row ≈ 1.75MB index, safely above 1MB threshold. +-- Run five top-level lookups so pg_stat_user_indexes records visible scans. +-- ============================================================ +CREATE TABLE pgfirstaid_seed.low_usage_idx_table ( + id bigint PRIMARY KEY, + search_key text NOT NULL +); +INSERT INTO pgfirstaid_seed.low_usage_idx_table +SELECT g, md5(g::text) +FROM generate_series(1, 25000) g; +CREATE INDEX pgfirstaid_seed_low_usage_idx + ON pgfirstaid_seed.low_usage_idx_table (search_key); +ANALYZE pgfirstaid_seed.low_usage_idx_table; + +SELECT id FROM pgfirstaid_seed.low_usage_idx_table +WHERE search_key = md5('1') +LIMIT 1; + +SELECT id FROM pgfirstaid_seed.low_usage_idx_table +WHERE search_key = md5('2') +LIMIT 1; + +SELECT id FROM pgfirstaid_seed.low_usage_idx_table +WHERE search_key = md5('3') +LIMIT 1; + +SELECT id FROM pgfirstaid_seed.low_usage_idx_table +WHERE search_key = md5('4') +LIMIT 1; + +SELECT id FROM pgfirstaid_seed.low_usage_idx_table +WHERE search_key = md5('5') +LIMIT 1; + +-- ============================================================ +-- Lock target for live session threads (blocker/blocked checks). +-- ============================================================ +CREATE TABLE pgfirstaid_seed.lock_target ( + id int PRIMARY KEY, + payload text +); +INSERT INTO pgfirstaid_seed.lock_target (id, payload) VALUES (1, 'seed'); diff --git a/testing/healthcheck_seed/02_seed_pg_stat_statements.sql b/testing/healthcheck_seed/02_seed_pg_stat_statements.sql new file mode 100644 index 0000000..9ed7e94 --- /dev/null +++ b/testing/healthcheck_seed/02_seed_pg_stat_statements.sql @@ -0,0 +1,112 @@ +-- pgFirstAid seed: pg_stat_statements workload checks +-- Requires pg_stat_statements to be preload-enabled and extension installed. +-- This script uses psql \gexec to issue many top-level statements. + +CREATE EXTENSION IF NOT EXISTS pg_stat_statements; +SELECT pg_stat_statements_reset(); + +CREATE SCHEMA IF NOT EXISTS pgfirstaid_seed; + +DROP TABLE IF EXISTS pgfirstaid_seed.pss_rows_table CASCADE; +DROP TABLE IF EXISTS pgfirstaid_seed.pss_wal_table CASCADE; +DROP TABLE IF EXISTS pgfirstaid_seed.pss_temp_table CASCADE; + +CREATE TABLE pgfirstaid_seed.pss_rows_table ( + id bigint PRIMARY KEY, + grp int NOT NULL, + payload text +); +INSERT INTO pgfirstaid_seed.pss_rows_table (id, grp, payload) +SELECT g, (g % 10), repeat(md5(g::text), 3) +FROM generate_series(1, 300000) AS g; +CREATE INDEX pss_rows_table_grp_idx ON pgfirstaid_seed.pss_rows_table(grp); +ANALYZE pgfirstaid_seed.pss_rows_table; + +CREATE TABLE pgfirstaid_seed.pss_wal_table ( + id bigint PRIMARY KEY, + payload text +); +INSERT INTO pgfirstaid_seed.pss_wal_table (id, payload) +SELECT g, repeat(md5(g::text), 15) +FROM generate_series(1, 15000) AS g; +ANALYZE pgfirstaid_seed.pss_wal_table; + +CREATE TABLE pgfirstaid_seed.pss_temp_table ( + id bigint PRIMARY KEY, + sort_key int NOT NULL, + payload text NOT NULL +); +INSERT INTO pgfirstaid_seed.pss_temp_table (id, sort_key, payload) +SELECT g, (random() * 1000000)::int, repeat(md5(g::text), 4) +FROM generate_series(1, 200000) AS g; +ANALYZE pgfirstaid_seed.pss_temp_table; + +-- High mean execution time (>=20 calls and >100ms) +SELECT 'SELECT pg_sleep(0.12);' +FROM generate_series(1, 25) +\gexec + +-- High runtime variance (stddev > mean) +SELECT CASE + WHEN g % 10 = 0 THEN 'SELECT pg_sleep(1.2);' + ELSE 'SELECT pg_sleep(0.005);' + END +FROM generate_series(1, 100) AS g +\gexec + +-- High calls, low value (>=5000 calls, <=2ms mean, <=2 rows/call) +SELECT 'SELECT 1;' +FROM generate_series(1, 6000) +\gexec + +-- High rows per call (>10000 rows/call, >=20 calls) +SELECT 'SELECT count(*) FROM pgfirstaid_seed.pss_rows_table WHERE grp IN (0,1,2,3,4,5,6,7,8,9);' +FROM generate_series(1, 25) +\gexec + +-- Shared block reads per call and low cache hit ratio candidates. +-- On smaller systems results are less deterministic because cache behavior is environment-specific. +SET enable_indexscan = off; +SET enable_bitmapscan = off; +SELECT 'SELECT sum(length(payload)) FROM pgfirstaid_seed.pss_rows_table WHERE id > 0;' +FROM generate_series(1, 30) +\gexec + +-- Temp block spills (sort large set with low work_mem) +SET work_mem = '64kB'; +SELECT 'SELECT sort_key, payload FROM pgfirstaid_seed.pss_temp_table ORDER BY sort_key DESC LIMIT 120000;' +FROM generate_series(1, 25) +\gexec + +-- High WAL bytes per call (>1MB/call) +SELECT format( + $$UPDATE pgfirstaid_seed.pss_wal_table + SET payload = md5(payload || '%s') || repeat(md5('%s'), 12) + WHERE id %% 3 = 0;$$, + g, + g +) +FROM generate_series(1, 25) AS g +\gexec + +SELECT + check_name, + count(*) AS findings +FROM pg_firstAid() +WHERE check_name IN ( + 'Top 10 Queries by Total Execution Time', + 'High Mean Execution Time Queries', + 'Top 10 Queries by Temp Block Spills', + 'Low Cache Hit Ratio Queries', + 'High Runtime Variance Queries', + 'High Calls Low Value Queries', + 'High Rows Per Call Queries', + 'High Shared Block Reads Per Call Queries', + 'Top Queries by WAL Bytes Per Call' +) +GROUP BY check_name +ORDER BY check_name; + +RESET work_mem; +RESET enable_indexscan; +RESET enable_bitmapscan; diff --git a/testing/healthcheck_seed/03_session_blocker.sql b/testing/healthcheck_seed/03_session_blocker.sql new file mode 100644 index 0000000..96d08ea --- /dev/null +++ b/testing/healthcheck_seed/03_session_blocker.sql @@ -0,0 +1,23 @@ +-- Session A (blocker) +-- Keep this transaction open while running Session B. + +BEGIN; + +CREATE SCHEMA IF NOT EXISTS pgfirstaid_seed; +CREATE TABLE IF NOT EXISTS pgfirstaid_seed.lock_target ( + id int PRIMARY KEY, + payload text +); + +INSERT INTO pgfirstaid_seed.lock_target (id, payload) +VALUES (1, 'seed') +ON CONFLICT (id) DO NOTHING; + +UPDATE pgfirstaid_seed.lock_target +SET payload = 'locked_by_session_a' +WHERE id = 1; + +-- Hold lock long enough for pg_firstAid checks. +SELECT pg_sleep(600); + +ROLLBACK; diff --git a/testing/healthcheck_seed/04_session_blocked.sql b/testing/healthcheck_seed/04_session_blocked.sql new file mode 100644 index 0000000..faa8ed6 --- /dev/null +++ b/testing/healthcheck_seed/04_session_blocked.sql @@ -0,0 +1,16 @@ +-- Session B (blocked) +-- Run in a separate psql session AFTER Session A (03_session_blocker.sql) +-- has taken its row lock on pgfirstaid_seed.lock_target(id=1). +-- +-- This UPDATE will block waiting on Session A's lock, producing the +-- blocking/blocked pair that pgFirstAid's lock checks detect. +-- Keep this session open until validation completes; it will unblock +-- automatically once Session A rolls back, then this session rolls back. + +BEGIN; + +UPDATE pgfirstaid_seed.lock_target +SET payload = 'blocked_by_session_b' +WHERE id = 1; + +ROLLBACK; diff --git a/testing/healthcheck_seed/05_session_idle_in_transaction.sql b/testing/healthcheck_seed/05_session_idle_in_transaction.sql new file mode 100644 index 0000000..48683b9 --- /dev/null +++ b/testing/healthcheck_seed/05_session_idle_in_transaction.sql @@ -0,0 +1,4 @@ +BEGIN; +SELECT 1; +\prompt 'Session is idle in transaction. Press enter to rollback and exit: ' dummy +ROLLBACK; diff --git a/testing/healthcheck_seed/06_session_long_running_query.sql b/testing/healthcheck_seed/06_session_long_running_query.sql new file mode 100644 index 0000000..a4b122d --- /dev/null +++ b/testing/healthcheck_seed/06_session_long_running_query.sql @@ -0,0 +1,6 @@ +-- Session D (single long-running active query) +-- Helps trigger: +-- - Long Running Queries (>5 minutes) +-- - Top 10 Expensive Active Queries (>30 seconds) + +SELECT pg_sleep(360); diff --git a/testing/healthcheck_seed/07_pgbench_active_query.sql b/testing/healthcheck_seed/07_pgbench_active_query.sql new file mode 100644 index 0000000..8392fe7 --- /dev/null +++ b/testing/healthcheck_seed/07_pgbench_active_query.sql @@ -0,0 +1,10 @@ +-- Use with pgbench to create many active connections. +-- Example: +-- pgbench "$PGDATABASE" -n -c 55 -j 10 -T 120 -f testing/healthcheck_seed/07_pgbench_active_query.sql +-- +-- Each pgbench client runs this script in a loop. pg_sleep(1) keeps each +-- session in state='active' long enough for pgFirstAid's connection and +-- active-session checks to observe the full -c concurrency. + +SELECT pg_sleep(1); + diff --git a/testing/healthcheck_seed/99_validate_seed_results.sql b/testing/healthcheck_seed/99_validate_seed_results.sql new file mode 100644 index 0000000..fde7f6f --- /dev/null +++ b/testing/healthcheck_seed/99_validate_seed_results.sql @@ -0,0 +1,17 @@ +-- Validation summary for seeded checks. + +SELECT + severity, + check_name, + count(*) AS findings +FROM pg_firstAid() +GROUP BY severity, check_name +ORDER BY + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'MEDIUM' THEN 3 + WHEN 'LOW' THEN 4 + ELSE 5 + END, + check_name; diff --git a/testing/healthcheck_seed/README.md b/testing/healthcheck_seed/README.md new file mode 100644 index 0000000..ab63044 --- /dev/null +++ b/testing/healthcheck_seed/README.md @@ -0,0 +1,42 @@ +# pgFirstAid Health-Check Seed Kit + +This folder contains SQL scripts to populate a database with data/workload patterns that trigger pgFirstAid checks. + +The checks are a mix of: + +- static metadata/data patterns (tables, indexes, stats) +- runtime/session behavior (locks, long-running queries, idle-in-transaction, active sessions) +- optional `pg_stat_statements` workload patterns + +## Run Order + +1. Load pgFirstAid (`pgFirstAid.sql` and optional view SQL). +2. Run `testing/healthcheck_seed/01_seed_static_checks.sql`. +3. Optional: run `testing/healthcheck_seed/02_seed_pg_stat_statements.sql` (run with `psql`, uses `\gexec`). +4. In separate terminals, run runtime scripts together: + - session A: `03_session_blocker.sql` + - session B: `04_session_blocked.sql` + - session C: `05_session_idle_in_transaction.sql` + - session D: `06_session_long_running_query.sql` + - optional high-connection burst (55 active sessions): + +```bash +pgbench "$PGDATABASE" -n -c 55 -j 10 -T 120 -f testing/healthcheck_seed/07_pgbench_active_query.sql +``` +5. While runtime scripts are still open/running, execute: + +```sql +SELECT severity, check_name, count(*) AS findings +FROM pg_firstAid() +GROUP BY severity, check_name +ORDER BY severity, check_name; +``` + +Or run `testing/healthcheck_seed/99_validate_seed_results.sql`. + +## Notes / Constraints + +- Some checks require elevated privileges (for example replication-slot checks and role creation). +- The 50GB/100GB table-size checks are intentionally not created by default in this kit. Creating truly large tables is usually too expensive for local/dev environments. +- `Unused Large Index` is generated and may take time/disk depending on your environment. +- `pg_stat_statements` checks only appear when the extension is installed and enabled. diff --git a/testing/integration/README.md b/testing/integration/README.md index 1e82ea8..cd4e076 100644 --- a/testing/integration/README.md +++ b/testing/integration/README.md @@ -7,11 +7,13 @@ It uses: - Integration tests using (`psycopg`) for live runtime behavior(for checks looking for active connections) - Execution of the pgTAP SQL suite sing Python +- The `testing/seed_and_validate.py` harness in CI for end-to-end seeded validation against live database targets ## What is covered - pgTAP assertions grouped by severity (`testing/pgTAP/01_*.sql` to `06_*.sql`) - Python integration scenarios that need concurrent sessions and timing control +- End-to-end seeded validation via `testing/seed_and_validate.py --managed` in the managed CI workflows - Function/view parity assertions - A coverage guard test that ensures every `check_name` in `pgFirstAid.sql` is referenced by at least one pgTAP assertion diff --git a/testing/integration/tests/conftest.py b/testing/integration/tests/conftest.py index 363681d..0b2c19e 100644 --- a/testing/integration/tests/conftest.py +++ b/testing/integration/tests/conftest.py @@ -23,6 +23,14 @@ def _teardown_sql_path() -> Path: return _repo_root() / "testing" / "pgTAP" / "99_teardown.sql" +def _pgfirstaid_sql_path() -> Path: + return _repo_root() / "pgFirstAid.sql" + + +def _managed_view_sql_path() -> Path: + return _repo_root() / "view_pgFirstAid_managed.sql" + + @pytest.fixture(scope="session") def config() -> TestConfig: missing = TestConfig.missing_env_vars() @@ -41,6 +49,8 @@ def config() -> TestConfig: def prepared_database(config: TestConfig) -> Iterator[None]: with connect(config, autocommit=True) as conn: execute_sql_file(conn, _setup_sql_path()) + execute_sql_file(conn, _pgfirstaid_sql_path()) + execute_sql_file(conn, _managed_view_sql_path()) yield with connect(config, autocommit=True) as conn: execute_sql_file(conn, _teardown_sql_path()) diff --git a/testing/seed_and_validate.py b/testing/seed_and_validate.py new file mode 100644 index 0000000..dbb32d5 --- /dev/null +++ b/testing/seed_and_validate.py @@ -0,0 +1,906 @@ +#!/usr/bin/env python3 +"""pgFirstAid seed and validation orchestrator. + +Creates a throwaway PostgreSQL database, seeds data that triggers every +pgFirstAid health check, runs live-session background threads, then +validates that all expected checks fire. + +Usage: + python testing/seed_and_validate.py [--host H] [--port P] [--user U] [--password W] + +Connection parameters default to PGHOST/PGPORT/PGUSER/PGPASSWORD env vars. +Requires superuser or CREATEDB + CREATE ROLE privileges. + +Dependency: + pip install psycopg2-binary +""" + +import argparse +import os +import re +import subprocess +import sys +import threading +import time +from typing import Any +from pathlib import Path + +import psycopg2 +from psycopg2 import Error, errors +from psycopg2.extensions import connection as PgConnection + +SEED_DIR = Path(__file__).parent / "healthcheck_seed" +PG_FIRSTAID_SQL = Path(__file__).parent.parent / "pgFirstAid.sql" +PG_FIRSTAID_MANAGED_SQL = Path(__file__).parent.parent / "view_pgFirstAid_managed.sql" +TEST_DB = "pgfirstaid_test" + +# Each tuple is (pattern, replacement). Applied in order. +_THRESHOLD_PATCHES: list[tuple[str, str]] = [ + # Unused Large Index: 100MB -> 8KB + (r"> 104857600", "> 8192"), + # Tables larger than 100GB -> 1MB + (r"> 107374182400", "> 1048576"), + # Tables larger than 50-100GB -> 512KB-1MB + (r"between 53687091200 and 107374182400", "between 524288 and 1048576"), +] + + +def patch_thresholds(sql: str) -> str: + """Return sql with size thresholds replaced by test-friendly values. + + The original pgFirstAid.sql file is never modified; callers receive + the patched text and install it directly into the test database. + """ + for pattern, replacement in _THRESHOLD_PATCHES: + sql = re.sub(pattern, replacement, sql) + return sql + + +def get_conn_params(args: argparse.Namespace) -> dict: + """Build psycopg2 connection kwargs from CLI args and env vars. + + CLI args take precedence over env vars; env vars over built-in defaults. + The returned dict always contains dbname='postgres' (maintenance db). + """ + return { + "host": args.host or os.environ.get("PGHOST", "localhost"), + "port": int(args.port or os.environ.get("PGPORT", 5432)), + "user": args.user or os.environ.get("PGUSER", "postgres"), + "password": args.password or os.environ.get("PGPASSWORD", ""), + "sslmode": os.environ.get("PGSSLMODE", "prefer"), + "dbname": "postgres", + } + + +def _connect(params: dict[str, Any], *, autocommit: bool) -> PgConnection: + conn = psycopg2.connect(**params) + conn.autocommit = autocommit + return conn + + +def _execute( + conn: Any, + query: str, + params: tuple[Any, ...] | None = None, +) -> None: + if not hasattr(conn, "cursor"): + if params is None: + conn.execute(query) + else: + conn.execute(query, params) + return + + with conn.cursor() as cur: + cur.execute(query, params) + + +def _fetchone( + conn: Any, + query: str, + params: tuple[Any, ...] | None = None, +) -> tuple[Any, ...] | None: + if not hasattr(conn, "cursor"): + if params is None: + result = conn.execute(query) + else: + result = conn.execute(query, params) + return result.fetchone() + + with conn.cursor() as cur: + cur.execute(query, params) + return cur.fetchone() + + +def _fetchall( + conn: Any, + query: str, + params: tuple[Any, ...] | None = None, +) -> list[tuple[Any, ...]]: + if not hasattr(conn, "cursor"): + if params is None: + result = conn.execute(query) + else: + result = conn.execute(query, params) + return result.fetchall() + + with conn.cursor() as cur: + cur.execute(query, params) + return cur.fetchall() + + +def connect_admin(params: dict[str, Any]) -> PgConnection: + """Connect to the maintenance database with autocommit (for CREATE/DROP DATABASE).""" + return _connect(params, autocommit=True) + + +def connect_test(params: dict[str, Any]) -> PgConnection: + """Connect to the test database with autocommit for DDL.""" + test_params = {**params, "dbname": TEST_DB} + return _connect(test_params, autocommit=True) + + +def create_test_db(admin_conn: PgConnection) -> None: + """Drop and recreate the test database from scratch.""" + # Terminate any existing connections to the test db before dropping. + _execute( + admin_conn, + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " + "WHERE datname = %s AND pid <> pg_backend_pid()", + (TEST_DB,), + ) + _execute(admin_conn, f"DROP DATABASE IF EXISTS {TEST_DB}") + _execute(admin_conn, f"CREATE DATABASE {TEST_DB}") + + +def drop_test_db(admin_conn: PgConnection) -> None: + """Terminate all connections to the test database and drop it.""" + _execute( + admin_conn, + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " + "WHERE datname = %s AND pid <> pg_backend_pid()", + (TEST_DB,), + ) + _execute(admin_conn, f"DROP DATABASE IF EXISTS {TEST_DB}") + + +def drop_seed_role(admin_conn: PgConnection) -> None: + """Drop the cluster-level seed role left behind after test DB is dropped.""" + try: + _execute(admin_conn, "DROP ROLE IF EXISTS pgfirstaid_seed_role") + except Exception as exc: + print(f" WARNING: failed to drop pgfirstaid_seed_role: {exc}") + + +def install_function(test_conn: PgConnection, managed: bool = False) -> None: + """Read and install pgFirstAid SQL into the test DB, patching thresholds. + + When managed=True, installs view_pgFirstAid_managed.sql (view-based, no + superuser-only queries) instead of the default function-based pgFirstAid.sql. + """ + sql_file = PG_FIRSTAID_MANAGED_SQL if managed else PG_FIRSTAID_SQL + sql = sql_file.read_text() + patched = patch_thresholds(sql) + _execute(test_conn, patched) + + +def run_sql_file(test_conn: PgConnection, path: Path) -> None: + """Execute a plain SQL file against the test connection.""" + _execute(test_conn, path.read_text()) + + +def run_psql_file(params: dict, path: Path) -> bool: + """Run a SQL file via psql subprocess (required for \\gexec support). + + Returns True on success, False if psql is unavailable or the file errors. + """ + env = {**os.environ, "PGPASSWORD": params.get("password", "")} + cmd = [ + "psql", + f"--host={params['host']}", + f"--port={params['port']}", + f"--username={params['user']}", + f"--dbname={TEST_DB}", + f"--file={path}", + "--no-psqlrc", + "-v", + "ON_ERROR_STOP=1", + ] + try: + result = subprocess.run(cmd, env=env, capture_output=True, text=True) + except FileNotFoundError: + print(" SKIP: psql not found — pg_stat_statements seed skipped") + return False + if result.returncode != 0: + print(f" WARNING: psql exited {result.returncode}:\n{result.stderr[:500]}") + return False + if result.stdout.strip(): + print(f" psql output:\n{result.stdout[:1000]}") + return True + + +def is_pss_queryable(test_conn: PgConnection) -> bool: + """Return True only if the pg_stat_statements view is actually accessible. + + The extension may be installed (in pg_extension) but still crash at query + time when pg_stat_statements is absent from shared_preload_libraries. + """ + try: + _execute(test_conn, "SELECT 1 FROM pg_stat_statements LIMIT 0") + return True + except Error: + return False + + +def is_extension_installed(test_conn: PgConnection, extension_name: str) -> bool: + """Return True if the named extension exists in pg_extension.""" + row = _fetchone( + test_conn, + "SELECT 1 FROM pg_extension WHERE extname = %s", + (extension_name,), + ) + return row is not None + + +def classify_pss_state( + test_conn: PgConnection, psql_seed_succeeded: bool +) -> tuple[bool, bool]: + """Return whether pg_stat_statements is installed and fully seedable.""" + installed = is_extension_installed(test_conn, "pg_stat_statements") + seeded = psql_seed_succeeded and installed and is_pss_queryable(test_conn) + return installed, seeded + + +def try_create_replication_slot(test_conn: PgConnection) -> bool: + """Create a logical replication slot to trigger the inactive-slot check. + + Returns True if the slot was created, False if skipped due to + wal_level != logical or insufficient privilege. + """ + try: + _execute( + test_conn, + "SELECT pg_create_logical_replication_slot(" + " 'pgfirstaid_test_slot', 'test_decoding')", + ) + return True + except errors.ObjectNotInPrerequisiteState: + print( + " SKIP: wal_level != logical — Inactive Replication Slots check not seeded" + ) + return False + except errors.InsufficientPrivilege: + print( + " SKIP: insufficient privilege — Inactive Replication Slots check not seeded" + ) + return False + except Error as error: + message = str(error).lower() + if "test_decoding" in message and ( + "does not exist" in message or "could not access file" in message + ): + print( + " SKIP: test_decoding unavailable — " + "Inactive Replication Slots check not seeded" + ) + return False + raise + + +def drop_replication_slot(test_conn: PgConnection) -> None: + """Drop the test replication slot if it exists.""" + try: + _execute(test_conn, "SELECT pg_drop_replication_slot('pgfirstaid_test_slot')") + except Exception: + pass + + +def verify_seed_sizes(test_conn: PgConnection) -> None: + """Warn if size-seeded tables are outside expected ranges after patching.""" + row = _fetchone( + test_conn, + """ + SELECT + pg_relation_size('pgfirstaid_seed.large_table') AS large_bytes, + pg_relation_size('pgfirstaid_seed.medium_table') AS medium_bytes + """, + ) + large_bytes, medium_bytes = row + if large_bytes <= 1_048_576: + print( + f" WARNING: large_table is {large_bytes} bytes — may not trigger >1MB check" + ) + if not (524_288 <= medium_bytes <= 1_048_576): + print( + f" WARNING: medium_table is {medium_bytes} bytes — " + f"expected 524288–1048576 for 50GB patched check" + ) + + +def seed_low_usage_index_scans(test_conn: PgConnection) -> None: + """Run low-cardinality index lookups as separate statements so stats record them.""" + for value in range(1, 6): + _execute( + test_conn, + "SELECT id FROM pgfirstaid_seed.low_usage_idx_table " + "WHERE search_key = md5(%s::text) LIMIT 1", + (str(value),), + ) + + +def wait_for_index_scan_count( + test_conn: PgConnection, + index_name: str, + min_scans: int = 1, + timeout: int = 5, +) -> bool: + """Wait for pg_stat_user_indexes to reflect recent scans for an index.""" + for _ in range(timeout): + row = _fetchone( + test_conn, + "SELECT idx_scan FROM pg_stat_user_indexes WHERE indexrelname = %s", + (index_name,), + ) + if row and row[0] >= min_scans: + return True + time.sleep(1.0) + return False + + +# --------------------------------------------------------------------------- +# Live session threads +# Each thread opens its own psycopg2 connection and holds it open to trigger +# session-based health checks. All threads are daemon threads so they are +# automatically killed when the main process exits. +# --------------------------------------------------------------------------- + + +def _blocker_thread( + params: dict, ready: threading.Event, stop: threading.Event +) -> None: + """Hold an UPDATE lock on lock_target row 1 for the duration of the test.""" + conn = psycopg2.connect(**{**params, "dbname": TEST_DB}) + conn.autocommit = False + _execute( + conn, + "UPDATE pgfirstaid_seed.lock_target SET payload = 'locked_by_blocker' WHERE id = 1", + ) + ready.set() + stop.wait(timeout=700) + try: + conn.rollback() + except Exception: + pass + conn.close() + + +def _blocked_thread(params: dict, blocker_ready: threading.Event) -> None: + """Attempt to UPDATE the same row as the blocker — will wait on the lock.""" + blocker_ready.wait() + time.sleep(0.5) # Ensure blocker's lock is fully held before we attempt. + conn = psycopg2.connect(**{**params, "dbname": TEST_DB}) + conn.autocommit = False + try: + _execute( + conn, + "UPDATE pgfirstaid_seed.lock_target SET payload = 'blocked' WHERE id = 1", + ) + except Exception: + pass + finally: + try: + conn.rollback() + except Exception: + pass + conn.close() + + +def _idle_in_txn_thread( + params: dict, ready: threading.Event, stop: threading.Event +) -> None: + """Open a transaction and remain idle — triggers Idle In Transaction checks.""" + conn = psycopg2.connect(**{**params, "dbname": TEST_DB}) + conn.autocommit = False + _execute(conn, "SELECT 1") # Starts the transaction; connection is now idle in txn. + ready.set() + stop.wait(timeout=700) + try: + conn.rollback() + except Exception: + pass + conn.close() + + +def _long_query_thread(params: dict) -> None: + """Run a long-sleeping query — triggers Long Running Queries checks. + + pg_sleep blocks psycopg2 query execution until the backend is terminated, so this + thread has no cooperative stop signal; drop_test_db() issues + pg_terminate_backend against the test DB which unblocks the sleep and + lets the thread exit. + """ + conn = psycopg2.connect(**{**params, "dbname": TEST_DB}) + try: + _execute(conn, "SELECT pg_sleep(700)") + except Exception: + pass + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Wait helpers — poll pg_stat_activity / pg_locks from the admin connection +# --------------------------------------------------------------------------- + + +def _wait_for_blocked(admin_conn: PgConnection, timeout: int = 30) -> bool: + """Wait until at least one session is waiting on a lock in TEST_DB.""" + for _ in range(timeout): + row = _fetchone( + admin_conn, + "SELECT 1 FROM pg_locks l " + "JOIN pg_stat_activity a ON l.pid = a.pid " + "WHERE NOT l.granted AND a.datname = %s", + (TEST_DB,), + ) + if row: + return True + time.sleep(1) + return False + + +def _wait_for_state(admin_conn: PgConnection, state: str, timeout: int = 30) -> bool: + """Wait until at least one session in TEST_DB has the given state.""" + for _ in range(timeout): + row = _fetchone( + admin_conn, + "SELECT 1 FROM pg_stat_activity " + "WHERE state = %s AND datname = %s AND pid <> pg_backend_pid()", + (state, TEST_DB), + ) + if row: + return True + time.sleep(1) + return False + + +def _wait_for_active_query( + admin_conn: PgConnection, min_seconds: int = 35, timeout: int = 60 +) -> bool: + """Wait until a session in TEST_DB has been active for at least min_seconds.""" + for _ in range(timeout): + row = _fetchone( + admin_conn, + "SELECT 1 FROM pg_stat_activity " + "WHERE state = 'active' AND datname = %s " + "AND now() - query_start > make_interval(secs => %s) " + "AND pid <> pg_backend_pid()", + (TEST_DB, min_seconds), + ) + if row: + return True + time.sleep(1) + return False + + +def start_session_threads( + params: dict[str, Any], admin_conn: PgConnection +) -> tuple[list[threading.Thread], threading.Event]: + """Start all live-session daemon threads and wait for each to establish. + + Returns the list of threads and a stop event. Set the stop event to + signal threads to clean up before the database is dropped. + """ + stop = threading.Event() + blocker_ready = threading.Event() + idle_ready = threading.Event() + + threads = [ + threading.Thread( + target=_blocker_thread, args=(params, blocker_ready, stop), daemon=True + ), + threading.Thread( + target=_blocked_thread, args=(params, blocker_ready), daemon=True + ), + threading.Thread( + target=_idle_in_txn_thread, args=(params, idle_ready, stop), daemon=True + ), + threading.Thread(target=_long_query_thread, args=(params,), daemon=True), + ] + + print(" Starting blocker thread...") + threads[0].start() + blocker_ready.wait(timeout=10) + + print(" Starting blocked thread...") + threads[1].start() + if not _wait_for_blocked(admin_conn, timeout=15): + print(" WARNING: blocked session did not appear in pg_locks within 15s") + + print(" Starting idle-in-transaction thread...") + threads[2].start() + idle_ready.wait(timeout=10) + if not _wait_for_state(admin_conn, "idle in transaction", timeout=15): + print(" WARNING: idle-in-transaction session did not appear within 15s") + + print(" Starting long query thread...") + threads[3].start() + + return threads, stop + + +# --------------------------------------------------------------------------- +# Expected checks: every check_name that should appear in pg_firstAid() +# output after a complete seed run. +# --------------------------------------------------------------------------- + +# Checks that always fire regardless of seed data. +_ALWAYS_FIRE: frozenset[str] = frozenset( + { + "Database Size", + "PostgreSQL Version", + "shared_buffers Setting", + "work_mem Setting", + "effective_cache_size Setting", + "maintenance_work_mem Setting", + "Transaction ID Wraparound Risk", + "Checkpoint Stats", + "Server Role", + "Connection Utilization", + "Installed Extension", + "Server Uptime", + "Is Logging Enabled", + "Size of ALL Logfiles combined", + } +) + +# Checks seeded by 01_seed_static_checks.sql. +_STATIC_CHECKS: frozenset[str] = frozenset( + { + "Missing Primary Key", + "Unused Large Index", + "Duplicate Index", + "Table with more than 200 columns", + "Missing Statistics", + "Tables larger than 100GB", + "Tables larger than 50GB", + "Outdated Statistics", + "Table with more than 50 columns", + "Low Index Efficiency", + "Excessive Sequential Scans", + "Missing FK Index", + "Table With Single Or No Columns", + "Table With No Activity Since Stats Reset", + "Role Never Logged In", + "Empty Table", + "Index With Very Low Usage", + } +) + +# Checks seeded by live session threads. +_SESSION_CHECKS: frozenset[str] = frozenset( + { + "Current Blocked/Blocking Queries", + "Long Running Queries", + "Top 10 Expensive Active Queries", + "Lock-Wait-Heavy Active Queries", + "Idle In Transaction Over 5 Minutes", + } +) + +# pg_stat_statements workload checks (seeded by 02_seed_pg_stat_statements.sql). +_PSS_WORKLOAD_CHECKS: frozenset[str] = frozenset( + { + "Top 10 Queries by Total Execution Time", + "High Mean Execution Time Queries", + "Top 10 Queries by Temp Block Spills", + "Low Cache Hit Ratio Queries", + "High Runtime Variance Queries", + "High Calls Low Value Queries", + "High Rows Per Call Queries", + "High Shared Block Reads Per Call Queries", + "Top Queries by WAL Bytes Per Call", + } +) + +# Checks that fire when pg_stat_statements is absent. +_PSS_MISSING_CHECK: str = "pg_stat_statements Extension Missing" + +# Checks that require wal_level=logical (conditional). +_REPLICATION_CHECKS: frozenset[str] = frozenset( + { + "Inactive Replication Slots", + } +) + +# Checks intentionally not seeded. +_NEVER_SEEDED: frozenset[str] = frozenset( + { + "High Connection Count", + "Replication Slots Near Max Wal Size", + "Table Bloat (Detailed)", + "Idle Connections Over 1 Hour", + } +) + +_MANAGED_UNSUPPORTED_CHECKS: frozenset[str] = frozenset( + { + "Empty Table", + "Index With Very Low Usage", + "Role Never Logged In", + "Table With No Activity Since Stats Reset", + } +) + +_DEFAULT_ONLY_CHECKS: dict[str, tuple[str, str]] = { + "shared_buffers At Default": ("shared_buffers", "128MB"), + "work_mem At Default": ("work_mem", "4MB"), +} + + +def classify_default_setting_checks( + test_conn: PgConnection, +) -> tuple[set[str], set[str]]: + """Return expected and skipped checks for settings that only fire at defaults.""" + expected: set[str] = set() + skipped: set[str] = set() + + for check_name, (setting_name, default_value) in _DEFAULT_ONLY_CHECKS.items(): + row = _fetchone( + test_conn, + "SELECT pg_size_bytes(current_setting(%s)) = pg_size_bytes(%s)", + (setting_name, default_value), + ) + if row and row[0]: + expected.add(check_name) + else: + skipped.add(check_name) + + return expected, skipped + + +def build_report( + fired: set[str], + expected: set[str], + skipped: set[str], +) -> tuple[list[str], list[str], list[str]]: + """Classify each expected check as passed, failed, or skipped. + + Returns (passed, failed, skipped_list) — each is a sorted list of check names. + A check in `skipped` is never placed in `failed`, even if it did not fire. + """ + passed: list[str] = [] + failed: list[str] = [] + skipped_list: list[str] = [] + + for check in sorted(expected): + if check in skipped: + skipped_list.append(check) + elif check in fired: + passed.append(check) + else: + failed.append(check) + + return passed, failed, skipped_list + + +def run_validation( + test_conn: PgConnection, + replication_slot_created: bool, + pss_seeded: bool, + pss_extension_installed: bool = False, + managed: bool = False, +) -> bool: + """Run pg_firstAid() or SELECT from v_pgfirstAid and compare to expected checks. + + Returns True if all non-skipped expected checks fired, False otherwise. + """ + query = ( + "SELECT check_name, count(*) FROM v_pgfirstAid GROUP BY check_name" + if managed + else "SELECT check_name, count(*) FROM pg_firstAid() GROUP BY check_name" + ) + rows = _fetchall(test_conn, query) + fired: set[str] = {row[0] for row in rows} + + expected = set(_ALWAYS_FIRE) | set(_STATIC_CHECKS) | set(_SESSION_CHECKS) + + skipped = set(_NEVER_SEEDED) + + default_expected, default_skipped = classify_default_setting_checks(test_conn) + expected |= default_expected + skipped |= default_skipped + + if managed: + skipped |= set(_MANAGED_UNSUPPORTED_CHECKS) + + if pss_seeded: + expected |= set(_PSS_WORKLOAD_CHECKS) + elif pss_extension_installed: + # Extension installed but not queryable (not in shared_preload_libraries). + # Neither workload checks nor "Extension Missing" check will fire. + skipped.add(_PSS_MISSING_CHECK) + else: + expected.add(_PSS_MISSING_CHECK) + + if replication_slot_created: + expected |= set(_REPLICATION_CHECKS) + else: + skipped |= set(_REPLICATION_CHECKS) + + passed, failed, skipped_list = build_report(fired, expected, skipped) + + print("\n=== pgFirstAid Validation Results ===\n") + for check in passed: + print(f" PASS {check}") + for check in failed: + print(f" FAIL {check}") + for check in skipped_list: + print(f" SKIP {check}") + + total = len(passed) + len(failed) + print( + f"\n {len(passed)}/{total} checks passed" + f", {len(skipped_list)} skipped" + f", {len(failed)} failed\n" + ) + + return len(failed) == 0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Seed and validate all pgFirstAid health checks" + ) + parser.add_argument( + "--host", default=None, help="PostgreSQL host (default: PGHOST or localhost)" + ) + parser.add_argument( + "--port", default=None, help="PostgreSQL port (default: PGPORT or 5432)" + ) + parser.add_argument( + "--user", default=None, help="PostgreSQL user (default: PGUSER or postgres)" + ) + parser.add_argument( + "--password", default=None, help="PostgreSQL password (default: PGPASSWORD)" + ) + parser.add_argument( + "--managed", + action="store_true", + default=False, + help="Install view_pgFirstAid_managed.sql and query v_pgfirstAid instead of pg_firstaid()", + ) + return parser.parse_args() + + +def main() -> int: + """Orchestrate the full seed-and-validate run. Returns exit code (0 = pass).""" + args = parse_args() + params = get_conn_params(args) + + managed = args.managed + mode_label = "managed (v_pgfirstAid)" if managed else "standard (pg_firstaid())" + print( + f"Connecting to {params['user']}@{params['host']}:{params['port']} [{mode_label}]" + ) + + admin_conn = connect_admin(params) + test_conn: PgConnection | None = None + replication_slot_created = False + pss_extension_installed = False + pss_seeded = False + stop_event: threading.Event | None = None + success = False + + try: + # --- Database setup ----------------------------------------------- + print(f"Creating test database '{TEST_DB}'...") + create_test_db(admin_conn) + + test_conn = connect_test(params) + + print("Installing pgFirstAid with patched thresholds...") + install_function(test_conn, managed=managed) + + # --- Static seed ------------------------------------------------------ + print("Seeding structural checks (01_seed_static_checks.sql)...") + run_sql_file(test_conn, SEED_DIR / "01_seed_static_checks.sql") + seed_low_usage_index_scans(test_conn) + verify_seed_sizes(test_conn) + if not wait_for_index_scan_count(test_conn, "pgfirstaid_seed_low_usage_idx"): + print( + " WARNING: low-usage index scan stats did not become visible within 5s" + ) + + # --- pg_stat_statements seed (via psql for \gexec support) ----------- + print("Seeding pg_stat_statements workload (02_seed_pg_stat_statements.sql)...") + psql_seed_succeeded = run_psql_file( + params, SEED_DIR / "02_seed_pg_stat_statements.sql" + ) + pss_extension_installed, pss_seeded = classify_pss_state( + test_conn, psql_seed_succeeded + ) + if pss_extension_installed and not pss_seeded: + print( + " SKIP: pg_stat_statements not in shared_preload_libraries — PSS checks not seeded" + ) + + # --- Replication slot ------------------------------------------------- + print("Attempting replication slot seeding...") + replication_slot_created = try_create_replication_slot(test_conn) + + # --- Live session threads --------------------------------------------- + print("Starting live session threads...") + _threads, stop_event = start_session_threads(params, admin_conn) + + # Wait for the long query to appear as an active query (>30s threshold). + print( + "Waiting 35s for active query threshold (Top 10 Expensive Active Queries)..." + ) + if not _wait_for_active_query(admin_conn, min_seconds=35, timeout=60): + print( + " WARNING: long query did not reach 35s threshold — check may not fire" + ) + + # Wait for idle-in-transaction and long-running query (>5 min threshold). + print( + "Waiting 6 minutes for 5-minute session thresholds " + "(Long Running Queries, Idle In Transaction)..." + ) + for minute in range(1, 7): + time.sleep(60) + print(f" {minute}/6 minutes elapsed") + + # --- PSS diagnostic (before validation) -------------------------------- + if pss_seeded: + if not is_pss_queryable(test_conn): + print( + " PSS diagnostic: pg_stat_statements not accessible — downgrading pss_seeded" + ) + pss_seeded = False + else: + try: + row = _fetchone( + test_conn, "SELECT count(*) FROM pg_stat_statements" + ) + print( + f" PSS diagnostic: {row[0]} total entries in pg_stat_statements" + ) + except Error as e: + print( + f" PSS diagnostic: query failed ({e}) — downgrading pss_seeded" + ) + pss_seeded = False + + # --- Validate --------------------------------------------------------- + target = "v_pgfirstAid" if managed else "pg_firstAid()" + print(f"Running {target} validation...") + success = run_validation( + test_conn, + replication_slot_created, + pss_seeded, + pss_extension_installed, + managed, + ) + + finally: + # Signal threads to stop and allow them to rollback cleanly. + if stop_event is not None: + stop_event.set() + time.sleep(2) + + if replication_slot_created and test_conn is not None: + drop_replication_slot(test_conn) + + if test_conn is not None: + test_conn.close() + + print(f"Dropping test database '{TEST_DB}'...") + drop_test_db(admin_conn) + drop_seed_role(admin_conn) + admin_conn.close() + + return 0 if success else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/testing/test_seed_and_validate.py b/testing/test_seed_and_validate.py new file mode 100644 index 0000000..96d9ba9 --- /dev/null +++ b/testing/test_seed_and_validate.py @@ -0,0 +1,363 @@ +import argparse +import os +import re +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +import psycopg2 + +from seed_and_validate import ( + _ALWAYS_FIRE, + _DEFAULT_ONLY_CHECKS, + _MANAGED_UNSUPPORTED_CHECKS, + _NEVER_SEEDED, + _PSS_MISSING_CHECK, + _PSS_WORKLOAD_CHECKS, + _REPLICATION_CHECKS, + _SESSION_CHECKS, + _STATIC_CHECKS, + PG_FIRSTAID_MANAGED_SQL, + PG_FIRSTAID_SQL, + TEST_DB, + build_report, + classify_default_setting_checks, + classify_pss_state, + get_conn_params, + seed_low_usage_index_scans, + wait_for_index_scan_count, + patch_thresholds, + run_psql_file, + try_create_replication_slot, +) + + +def _args(**kwargs): + parser = argparse.ArgumentParser() + parser.add_argument("--host", default=None) + parser.add_argument("--port", default=None) + parser.add_argument("--user", default=None) + parser.add_argument("--password", default=None) + return parser.parse_args([f"--{k}={v}" for k, v in kwargs.items()]) + + +def test_get_conn_params_defaults(monkeypatch): + monkeypatch.delenv("PGHOST", raising=False) + monkeypatch.delenv("PGPORT", raising=False) + monkeypatch.delenv("PGUSER", raising=False) + monkeypatch.delenv("PGPASSWORD", raising=False) + params = get_conn_params(_args()) + assert params["host"] == "localhost" + assert params["port"] == 5432 + assert params["user"] == "postgres" + assert params["password"] == "" + assert params["dbname"] == "postgres" + + +def test_get_conn_params_env(monkeypatch): + monkeypatch.setenv("PGHOST", "db.example.com") + monkeypatch.setenv("PGPORT", "5433") + monkeypatch.setenv("PGUSER", "admin") + monkeypatch.setenv("PGPASSWORD", "secret") + monkeypatch.setenv("PGSSLMODE", "require") + params = get_conn_params(_args()) + assert params["host"] == "db.example.com" + assert params["port"] == 5433 + assert params["user"] == "admin" + assert params["password"] == "secret" + assert params["sslmode"] == "require" + + +def test_get_conn_params_cli_overrides_env(monkeypatch): + monkeypatch.setenv("PGHOST", "env-host") + monkeypatch.setenv("PGPORT", "9999") + params = get_conn_params(_args(host="cli-host", port="5432")) + assert params["host"] == "cli-host" + assert params["port"] == 5432 + + +def test_get_conn_params_defaults_sslmode(monkeypatch): + monkeypatch.delenv("PGSSLMODE", raising=False) + params = get_conn_params(_args()) + assert params["sslmode"] == "prefer" + + +def test_patch_unused_large_index(): + sql = "pg_relation_size(psi.indexrelid) > 104857600;" + result = patch_thresholds(sql) + assert "> 8192" in result + assert "104857600" not in result + + +def test_patch_tables_over_100gb(): + sql = "pg_relation_size(...) > 107374182400" + result = patch_thresholds(sql) + assert "> 1048576" in result + assert "107374182400" not in result + + +def test_patch_tables_50gb_to_100gb(): + sql = "pg_relation_size(...) between 53687091200 and 107374182400" + result = patch_thresholds(sql) + assert "between 524288 and 1048576" in result + assert "53687091200" not in result + + +def test_patch_does_not_modify_file(tmp_path): + """patch_thresholds must not write to disk.""" + original = PG_FIRSTAID_SQL.read_text() + patch_thresholds(original) + assert PG_FIRSTAID_SQL.read_text() == original + + +def test_testing_suite_uses_psycopg2_not_psycopg() -> None: + root = Path(__file__).parent + + for python_file in root.rglob("*.py"): + if ".venv" in python_file.parts or "site-packages" in python_file.parts: + continue + + source = python_file.read_text() + assert re.search(r"(?m)^\s*import psycopg\s*$", source) is None + assert re.search(r"(?m)^\s*from psycopg\s+", source) is None + + pyproject = (Path(__file__).parent.parent / "pyproject.toml").read_text() + assert '"psycopg[binary]' not in pyproject + + +def test_integration_workflows_use_runner_python_on_self_hosted() -> None: + repo_root = Path(__file__).parent.parent + + for workflow_name in [ + "integration-pg-matrix.yml", + "managed-db-validate.yml", + ]: + workflow = (repo_root / ".github" / "workflows" / workflow_name).read_text() + assert "actions/setup-python" not in workflow + assert "astral-sh/setup-uv" not in workflow + assert "shell: bash -l {0}" in workflow + assert 'echo "/run/current-system/sw/bin" >> "$GITHUB_PATH"' in workflow + assert 'echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH"' in workflow + assert "command -v uv" in workflow + assert "command -v psql" in workflow + assert "uv sync" in workflow + assert "uv sync --python python3" not in workflow + + +def test_build_report_all_pass(): + fired = {"Missing Primary Key", "Duplicate Index", "Database Size"} + expected = {"Missing Primary Key", "Duplicate Index", "Database Size"} + skipped = set() + passed, failed, skip_list = build_report(fired, expected, skipped) + assert "Missing Primary Key" in passed + assert "Duplicate Index" in passed + assert failed == [] + assert skip_list == [] + + +def test_build_report_missing_check(): + fired = {"Database Size"} + expected = {"Database Size", "Missing Primary Key"} + skipped = set() + passed, failed, skip_list = build_report(fired, expected, skipped) + assert "Missing Primary Key" in failed + assert "Database Size" in passed + + +def test_build_report_skipped_not_in_failed(): + fired = set() + expected = {"Inactive Replication Slots", "Database Size"} + skipped = {"Inactive Replication Slots"} + passed, failed, skip_list = build_report(fired, expected, skipped) + assert "Inactive Replication Slots" in skip_list + assert "Inactive Replication Slots" not in failed + assert "Database Size" in failed + + +def test_run_psql_file_enables_on_error_stop(monkeypatch, tmp_path): + sql_file = tmp_path / "seed.sql" + sql_file.write_text("SELECT 1;\n") + calls: list[list[str]] = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(subprocess, "run", fake_run) + + success = run_psql_file( + {"host": "localhost", "port": 5432, "user": "postgres", "password": "secret"}, + sql_file, + ) + + assert success is True + assert calls == [ + [ + "psql", + "--host=localhost", + "--port=5432", + "--username=postgres", + f"--dbname={TEST_DB}", + f"--file={sql_file}", + "--no-psqlrc", + "-v", + "ON_ERROR_STOP=1", + ] + ] + + +def test_try_create_replication_slot_skips_missing_output_plugin(capsys): + class FakeConn: + def execute(self, _query): + raise psycopg2.errors.UndefinedObject( + 'logical decoding output plugin "test_decoding" does not exist' + ) + + created = try_create_replication_slot(FakeConn()) + + assert created is False + assert "test_decoding unavailable" in capsys.readouterr().out + + +def test_expected_check_groups_cover_all_defined_checks(): + standard_configured_checks = ( + set(_ALWAYS_FIRE) + | set(_DEFAULT_ONLY_CHECKS) + | set(_STATIC_CHECKS) + | set(_SESSION_CHECKS) + | set(_PSS_WORKLOAD_CHECKS) + | set(_REPLICATION_CHECKS) + | set(_NEVER_SEEDED) + | {_PSS_MISSING_CHECK} + ) + + def extract_checks(path: Path) -> set[str]: + return set(re.findall(r"'([^']+)'\s+as check_name", path.read_text())) + + standard_checks = extract_checks(PG_FIRSTAID_SQL) + managed_checks = extract_checks(PG_FIRSTAID_MANAGED_SQL) + managed_configured_checks = standard_configured_checks - set( + _MANAGED_UNSUPPORTED_CHECKS + ) + + assert standard_configured_checks == standard_checks + assert managed_configured_checks == managed_checks + + +def test_classify_default_setting_checks_marks_only_matching_defaults_expected(): + class FakeResult: + def __init__(self, value): + self._value = value + + def fetchone(self): + return (self._value,) + + class FakeConn: + def __init__(self): + self._responses = { + "shared_buffers": True, + "work_mem": False, + } + + def execute(self, _query, params): + return FakeResult(self._responses[params[0]]) + + expected, skipped = classify_default_setting_checks(FakeConn()) + + assert expected == {"shared_buffers At Default"} + assert skipped == {"work_mem At Default"} + + +def test_classify_pss_state_marks_installed_but_unqueryable_as_not_seeded(): + class FakeResult: + def __init__(self, value): + self._value = value + + def fetchone(self): + return (self._value,) + + class FakeConn: + def execute(self, query, params=None): + if "FROM pg_extension" in query: + return FakeResult(1) + if "FROM pg_stat_statements" in query: + raise psycopg2.errors.ObjectNotInPrerequisiteState( + 'pg_stat_statements must be loaded via "shared_preload_libraries"' + ) + raise AssertionError(query) + + installed, seeded = classify_pss_state(FakeConn(), psql_seed_succeeded=False) + + assert installed is True + assert seeded is False + + +def test_managed_sql_guards_pg_stat_statements_prerequisite_errors(): + managed_sql = PG_FIRSTAID_MANAGED_SQL.read_text() + + assert "exception when object_not_in_prerequisite_state then" in managed_sql + + +def test_wait_for_index_scan_count_polls_until_scans_visible(monkeypatch): + class FakeResult: + def __init__(self, value): + self._value = value + + def fetchone(self): + return (self._value,) + + class FakeConn: + def __init__(self): + self._values = iter([0, 0, 5]) + + def execute(self, _query, _params): + return FakeResult(next(self._values)) + + sleep_calls: list[float] = [] + monkeypatch.setattr("seed_and_validate.time.sleep", sleep_calls.append) + + visible = wait_for_index_scan_count( + FakeConn(), "pgfirstaid_seed_low_usage_idx", min_scans=1, timeout=3 + ) + + assert visible is True + assert sleep_calls == [1.0, 1.0] + + +def test_low_usage_seed_uses_top_level_selects_not_do_block(): + seed_sql = ( + Path(__file__).parent / "healthcheck_seed" / "01_seed_static_checks.sql" + ).read_text() + + low_usage_section = seed_sql.split("-- LOW: Index With Very Low Usage", 1)[1] + low_usage_section = low_usage_section.split( + "-- ============================================================\n-- Lock target", + 1, + )[0] + + assert "DO $$" not in low_usage_section + assert ( + low_usage_section.count("SELECT id FROM pgfirstaid_seed.low_usage_idx_table") + >= 5 + ) + + +def test_seed_low_usage_index_scans_runs_five_lookups(): + class FakeConn: + def __init__(self): + self.calls = [] + + def execute(self, query, params): + self.calls.append((query, params)) + + conn = FakeConn() + + seed_low_usage_index_scans(conn) + + assert len(conn.calls) == 5 + assert all( + "low_usage_idx_table" in query and "search_key = md5(%s::text)" in query + for query, _params in conn.calls + ) + assert [params for _query, params in conn.calls] == [(str(i),) for i in range(1, 6)] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..b5e8df8 --- /dev/null +++ b/uv.lock @@ -0,0 +1,130 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, +] + +[[package]] +name = "pgfirstaid-testing" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "psycopg2-binary" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "psycopg2-binary", specifier = ">=2.9.12" }, + { name = "pytest", specifier = ">=8.0" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.12" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/19/d4ce60954f3bb9d8e3bc5e5c4d1f2487de2d3851bf2391d54954c9df12a6/psycopg2_binary-2.9.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5c8ce6c61bd1b1f6b9c24ee32211599f6166af2c55abb19456090a21fd16554b", size = 3712338, upload-time = "2026-04-20T23:34:03.961Z" }, + { url = "https://files.pythonhosted.org/packages/53/71/c85409ee0d78890f0660eff262e815e7dd2bb741a17611d82e9e8cd9dc5e/psycopg2_binary-2.9.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4a9eaa6e7f4ff91bec10aa3fb296878e75187bced5cc4bafe17dc40915e1326", size = 3822407, upload-time = "2026-04-20T23:34:05.977Z" }, + { url = "https://files.pythonhosted.org/packages/3c/ed/60486c2c7f0d4d1ede2bfb1ed27e2498477ce646bc7f6b2759906303117e/psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c6528cefc8e50fcc6f4a107e27a672058b36cc5736d665476aeb413ba88dbb06", size = 4578425, upload-time = "2026-04-20T23:34:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b9/656cb03fad9f4f49f2145c334b1126ee75189929ca4e6187d485a2d59951/psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4e184b1fb6072bf05388aa41c697e1b2d01b3473f107e7ec44f186a32cfd0b8", size = 4273709, upload-time = "2026-04-20T23:34:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/99/66/08cf0da0e25cc6fb142c89be45fc8418792858f0c4cbff5e24530ff02cd6/psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4766ab678563054d3f1d064a4db19cc4b5f9e3a8d9018592a8285cf200c248f3", size = 5893779, upload-time = "2026-04-20T23:34:13.905Z" }, + { url = "https://files.pythonhosted.org/packages/17/d7/eecd9ce8e146d3721115d82d3836efdbb712187e4590325df549989d18f4/psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5a0253224780c978746cb9be55a946bcdaf40fe3519c0f622924cdabdafe2c39", size = 4109308, upload-time = "2026-04-20T23:34:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/b1dc289b362cc8d45697b57eefbd673186f49a4ea0906928988e3affcc98/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0dc9228d47c46bda253d2ecd6bb93b56a9f2d7ad33b684a1fa3622bf74ffe30c", size = 3654405, upload-time = "2026-04-20T23:34:19.303Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/4c4aea6473214dbdbd0fbba11aa4691e76dc01722c55724c5951719865ff/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f921f3cd87035ef7df233383011d7a53ea1d346224752c1385f1edfd790ceb6a", size = 3299187, upload-time = "2026-04-20T23:34:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5d/b03b99986446a4f57b170ed9a2579fb7ff9783ca0fa5226b19db99737fee/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d999bd982a723113c1a45b55a7a6a90d64d0ed2278020ed625c490ff7bef96c", size = 3047716, upload-time = "2026-04-20T23:34:23.077Z" }, + { url = "https://files.pythonhosted.org/packages/14/86/382ee4afbd1d97500c9d2862b20c2fdeddf4b7335e984df3fb4309f64108/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29d4d134bd0ab46ffb04e94aa3c5fa3ef582e9026609165e2f758ff76fc3a3be", size = 3349237, upload-time = "2026-04-20T23:34:25.211Z" }, + { url = "https://files.pythonhosted.org/packages/a8/16/9a57c75ba1eda7165c017342f526810d5f5a12647dde749c99ae9a7141d7/psycopg2_binary-2.9.12-cp311-cp311-win_amd64.whl", hash = "sha256:cb4a1dacdd48077150dc762a9e5ddbf32c256d66cb46f80839391aa458774936", size = 2757036, upload-time = "2026-04-20T23:34:27.77Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" }, + { url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" }, + { url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" }, + { url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" }, + { url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" }, + { url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" }, + { url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" }, + { url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" }, + { url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" }, + { url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" }, + { url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" }, + { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] diff --git a/view_pgFirstAid_managed.sql b/view_pgFirstAid_managed.sql index 426cdbc..e01fd93 100644 --- a/view_pgFirstAid_managed.sql +++ b/view_pgFirstAid_managed.sql @@ -22,6 +22,7 @@ begin return; end if; + begin return query with pss as ( select @@ -264,6 +265,9 @@ with pss as ( order by ((to_jsonb(pss)->>'wal_bytes')::numeric / NULLIF(pss.calls, 0)) desc limit 10; + exception when object_not_in_prerequisite_state then + return; + end; end; $$ language plpgsql;